From 7391b55f96888be2f1f71206fdb662a9809ac281 Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 11:34:49 +0200 Subject: [PATCH 01/10] Add comprehensive test suite for etui Add a large set of unit and integration tests --- test/app_loop_test.gleam | 173 ++ test/cheese_widgets_test.gleam | 216 ++ test/etui_test.gleam | 3213 ++++++++++++++++++++++ test/geometry_new_constraints_test.gleam | 172 ++ test/geometry_property_test.gleam | 192 ++ test/snapshot_test.gleam | 767 ++++++ test/text_unicode_test.gleam | 301 ++ test/theme_test.gleam | 173 ++ test/viewport_helpers_test.gleam | 199 ++ test/widget_extensibility_test.gleam | 364 +++ 10 files changed, 5770 insertions(+) create mode 100644 test/app_loop_test.gleam create mode 100644 test/cheese_widgets_test.gleam create mode 100644 test/etui_test.gleam create mode 100644 test/geometry_new_constraints_test.gleam create mode 100644 test/geometry_property_test.gleam create mode 100644 test/snapshot_test.gleam create mode 100644 test/text_unicode_test.gleam create mode 100644 test/theme_test.gleam create mode 100644 test/viewport_helpers_test.gleam create mode 100644 test/widget_extensibility_test.gleam diff --git a/test/app_loop_test.gleam b/test/app_loop_test.gleam new file mode 100644 index 0000000..d607fe7 --- /dev/null +++ b/test/app_loop_test.gleam @@ -0,0 +1,173 @@ +@target(erlang) +/// Drives every erlang app loop (`run`, `run_buffered`, `run_animated`, +/// `run_buffered_cursor`) end to end against a scripted mock backend. +/// This is the safety net for the shared `step` core in `etui/app`. +import etui/app +import etui/backend +import etui/buffer +import etui/geometry.{Fill, Horizontal, Length, Percentage, rect_new, split} +import etui/style +import etui/widgets/block +import etui/widgets/gauge +import etui/widgets/list as glist +import etui/widgets/table +import gleam/list +import gleeunit/should + +// ───────────────────────────────────────────────────────────────── +// Mock backend: poll replays a fixed event script, one event per frame. + +@target(erlang) +type MockState { + MockState(events: List(backend.InputEvent)) +} + +@target(erlang) +fn mock_backend( + events: List(backend.InputEvent), +) -> backend.Backend(MockState) { + backend.Backend( + init: fn() { Ok(MockState(events: events)) }, + render: fn(s, _ops) { Ok(s) }, + poll: fn(s, _timeout) { + case s.events { + [ev, ..rest] -> Ok(#(ev, MockState(events: rest))) + // Script exhausted: a poll failure ends the loop (StepQuit path). + [] -> Error(backend.Interrupted) + } + }, + next_size: fn(s) { Ok(#(backend.TerminalSize(80, 24), s)) }, + cleanup: fn(_s) { Nil }, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Test model: counts non-quit key presses, quits on "q". + +@target(erlang) +type Counter { + Counter(count: Int, quit: Bool) +} + +@target(erlang) +fn count_update(ev: backend.InputEvent, m: Counter) -> Counter { + case ev { + backend.KeyPress("q") -> Counter(..m, quit: True) + backend.KeyPress(_) -> Counter(..m, count: m.count + 1) + _ -> m + } +} + +@target(erlang) +fn count_quit(m: Counter) -> Bool { + m.quit +} + +// ───────────────────────────────────────────────────────────────── +// Tests: each entry point drives the script to the quit condition. + +@target(erlang) +pub fn run_drives_to_quit_test() { + let result = + app.run( + mock_backend([backend.KeyPress("x"), backend.KeyPress("q")]), + Counter(count: 0, quit: False), + fn(_m) { [] }, + count_update, + count_quit, + 16, + ) + result |> should.equal(app.Success(Counter(count: 1, quit: True))) +} + +@target(erlang) +pub fn run_buffered_drives_to_quit_test() { + let result = + app.run_buffered( + mock_backend([ + backend.KeyPress("a"), + backend.KeyPress("b"), + backend.KeyPress("q"), + ]), + Counter(count: 0, quit: False), + fn(_m, screen) { buffer.buffer_new(screen) }, + count_update, + count_quit, + 16, + ) + result |> should.equal(app.Success(Counter(count: 2, quit: True))) +} + +@target(erlang) +pub fn run_animated_drives_to_quit_test() { + let result = + app.run_animated( + mock_backend([backend.KeyPress("q")]), + Counter(count: 0, quit: False), + fn(_m, screen, _anim) { buffer.buffer_new(screen) }, + count_update, + count_quit, + 16, + ) + result |> should.equal(app.Success(Counter(count: 0, quit: True))) +} + +@target(erlang) +pub fn run_buffered_cursor_drives_to_quit_test() { + let result = + app.run_buffered_cursor( + mock_backend([backend.KeyPress("z"), backend.KeyPress("q")]), + Counter(count: 0, quit: False), + fn(_m, screen) { #(buffer.buffer_new(screen), Error(Nil)) }, + count_update, + count_quit, + 16, + ) + result |> should.equal(app.Success(Counter(count: 1, quit: True))) +} + +// A script with no quit event: poll runs dry, the loop ends with the +// state reached so far. Exercises `step`'s poll-failure StepQuit branch. +@target(erlang) +pub fn run_buffered_poll_failure_ends_loop_test() { + let result = + app.run_buffered( + mock_backend([backend.KeyPress("a")]), + Counter(count: 0, quit: False), + fn(_m, screen) { buffer.buffer_new(screen) }, + count_update, + count_quit, + 16, + ) + result |> should.equal(app.Success(Counter(count: 1, quit: False))) +} + +// Doc snippets in `docs/` must keep compiling (layout, widgets, style). +@target(erlang) +pub fn doc_snippets_compile_test() { + let area = rect_new(0, 0, 80, 24) + let buf = buffer.buffer_new(area) + let cols = split(Horizontal, area, [Length(20), Percentage(50), Fill]) + cols |> list.length |> should.equal(3) + + style.Style( + fg: style.Rgb(255, 128, 0), + bg: style.Indexed(0), + modifier: style.bold(), + ) + |> fn(s) { s.fg } + |> should.equal(style.Rgb(255, 128, 0)) + + let blk = block.block_new() |> block.with_border(block.Rounded) + let _ = block.render(buf, area, blk) + let _ = + glist.render_stateful(buf, area, glist.list_new(["a"]), glist.state_new()) + let _ = + table.render_stateful( + buf, + area, + table.table_new([["x"]]), + table.state_new(), + ) + let _ = gauge.render(buf, area, gauge.gauge_new(50)) +} diff --git a/test/cheese_widgets_test.gleam b/test/cheese_widgets_test.gleam new file mode 100644 index 0000000..38103fd --- /dev/null +++ b/test/cheese_widgets_test.gleam @@ -0,0 +1,216 @@ +/// Tests for the bubbletea-inspired widgets ported from ratatui-cheese: +/// paginator, help, fieldset, multi_select. Focuses on the state and +/// helper logic; render correctness is covered indirectly by the snapshot +/// tests and visually by the demo apps. +import etui/widgets/fieldset +import etui/widgets/help +import etui/widgets/multi_select +import etui/widgets/paginator +import etui/widgets/spinner +import etui/widgets/tree +import gleeunit/should + +// ───────────────────────────────────────────────────────────────── +// Paginator + +pub fn paginator_next_clamps_to_last_page_test() { + let p = + paginator.paginator_new(3) + |> paginator.next_page + |> paginator.next_page + |> paginator.next_page + |> paginator.next_page + p.current |> should.equal(2) +} + +pub fn paginator_prev_clamps_to_zero_test() { + let p = + paginator.paginator_new(5) + |> paginator.prev_page + |> paginator.prev_page + p.current |> should.equal(0) +} + +pub fn paginator_go_to_clamps_test() { + let p = + paginator.paginator_new(5) + |> paginator.go_to(99) + p.current |> should.equal(4) +} + +pub fn paginator_slice_returns_current_page_items_test() { + let p = + paginator.paginator_new(3) + |> paginator.with_page_size(2) + |> paginator.go_to(1) + paginator.slice([10, 20, 30, 40, 50], p) + |> should.equal([30, 40]) +} + +pub fn paginator_set_item_count_recomputes_total_test() { + let p = + paginator.paginator_new(1) + |> paginator.with_page_size(3) + |> paginator.set_item_count(10) + // ceil(10 / 3) = 4 pages + p.total |> should.equal(4) +} + +pub fn paginator_total_clamped_to_one_test() { + paginator.paginator_new(0).total |> should.equal(1) +} + +// ───────────────────────────────────────────────────────────────── +// Help + +pub fn help_toggle_mode_test() { + let h = help.help_new([help.binding(["q"], "quit")]) + h.mode |> should.equal(help.Short) + let h = help.toggle_mode(h) + h.mode |> should.equal(help.Full) + let h = help.toggle_mode(h) + h.mode |> should.equal(help.Short) +} + +pub fn help_binding_keys_preserved_test() { + let b = help.binding(["ctrl+c", "q"], "quit") + b.keys |> should.equal(["ctrl+c", "q"]) + b.description |> should.equal("quit") +} + +// ───────────────────────────────────────────────────────────────── +// Fieldset + +pub fn fieldset_default_align_test() { + let fs = fieldset.fieldset_new("Section") + fs.align |> should.equal(fieldset.AlignLeft) + fs.title |> should.equal("Section") +} + +pub fn fieldset_with_align_center_test() { + let fs = + fieldset.fieldset_new("Section") + |> fieldset.with_align(fieldset.AlignCenter) + fs.align |> should.equal(fieldset.AlignCenter) +} + +// ───────────────────────────────────────────────────────────────── +// MultiSelect + +pub fn multi_select_toggle_adds_and_removes_test() { + let s = + multi_select.state_new() + |> multi_select.toggle(0) + multi_select.selected_indices(s) |> should.equal([0]) + let s = multi_select.toggle(s, 0) + multi_select.selected_indices(s) |> should.equal([]) +} + +pub fn multi_select_toggle_respects_max_test() { + let s = multi_select.state_new() + // cursor 0 then 1, max 1 -> second toggle blocked + let s = multi_select.toggle(s, 1) + let s = multi_select.select_next(s, 3) + let s = multi_select.toggle(s, 1) + // still only the first selected + multi_select.selected_indices(s) |> should.equal([0]) +} + +pub fn multi_select_select_next_clamps_test() { + let s = multi_select.state_new() + let s = multi_select.select_next(s, 2) + let s = multi_select.select_next(s, 2) + let s = multi_select.select_next(s, 2) + s.cursor |> should.equal(1) +} + +pub fn multi_select_select_prev_clamps_test() { + let s = multi_select.state_new() + let s = multi_select.select_prev(s) + s.cursor |> should.equal(0) +} + +pub fn multi_select_selected_values_test() { + let s = multi_select.state_new() + let s = multi_select.toggle(s, 0) + let s = multi_select.select_next(s, 4) + let s = multi_select.select_next(s, 4) + let s = multi_select.toggle(s, 0) + multi_select.selected_values(["a", "b", "c", "d"], s) + |> should.equal(["a", "c"]) +} + +pub fn multi_select_clear_test() { + let s = multi_select.state_new() + let s = multi_select.toggle(s, 0) + let s = multi_select.clear_selection(s) + multi_select.selected_indices(s) |> should.equal([]) +} + +pub fn multi_select_effective_offset_test() { + let s = multi_select.state_new() + let s = multi_select.select_next(s, 10) + let s = multi_select.select_next(s, 10) + let s = multi_select.select_next(s, 10) + // cursor 3, height 2 → offset 2 + multi_select.effective_offset(s, 2) |> should.equal(2) +} + +// ───────────────────────────────────────────────────────────────── +// Spinner presets sanity (every preset must produce a non-empty frame) + +pub fn spinner_all_presets_have_frames_test() { + let presets = [ + spinner.Dots, + spinner.Line, + spinner.Circle, + spinner.Bounce, + spinner.MiniDot, + spinner.Jump, + spinner.Pulse, + spinner.Points, + spinner.Globe, + spinner.Moon, + spinner.Monkey, + spinner.Meter, + spinner.Hamburger, + spinner.Ellipsis, + ] + // Each preset must be a valid SpinnerStyle. Building the widget never panics. + let _ = + presets + |> gleeunit_dummy_iter + Nil +} + +fn gleeunit_dummy_iter(presets: List(spinner.SpinnerStyle)) -> Nil { + case presets { + [] -> Nil + [p, ..rest] -> { + let _ = spinner.spinner_new() |> spinner.with_style(p) + gleeunit_dummy_iter(rest) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Tree counts (constructors + accessors) + +pub fn tree_node_with_count_test() { + let n = tree.node_with_count("inbox", "Inbox", 12, []) + n.count |> should.equal(Ok(12)) +} + +pub fn tree_leaf_with_count_test() { + let n = tree.leaf_with_count("a", "A", 3) + n.count |> should.equal(Ok(3)) +} + +pub fn tree_with_count_attaches_test() { + let n = tree.leaf("x", "X") |> tree.with_count(7) + n.count |> should.equal(Ok(7)) +} + +pub fn tree_node_default_no_count_test() { + tree.leaf("a", "A").count |> should.equal(Error(Nil)) +} diff --git a/test/etui_test.gleam b/test/etui_test.gleam new file mode 100644 index 0000000..957462f --- /dev/null +++ b/test/etui_test.gleam @@ -0,0 +1,3213 @@ +import etui/anim +import etui/buffer +import etui/color +import etui/cursor +import etui/focus +import etui/geometry.{ + Position, Rect, Size, area, bottom, contains, intersect, rect_new, + resolve_sizes, right, split, union, +} +import etui/keymap +import etui/keys +import etui/span +import etui/style +import etui/text +import etui/undo +import etui/widgets/block +import etui/widgets/canvas as gcanvas_widget +import etui/widgets/chart as gchart_widget +import etui/widgets/dialog +import etui/widgets/form as gform_widget +import etui/widgets/gauge as ggauge_widget +import etui/widgets/gradient_bar as ggradient_widget +import etui/widgets/hbar as ghbar_widget +import etui/widgets/input as ginput_widget +import etui/widgets/line +import etui/widgets/list as glist_widget +import etui/widgets/marquee as gmarquee_widget +import etui/widgets/notification as gnotif_widget +import etui/widgets/paragraph +import etui/widgets/progress as gprogress_widget +import etui/widgets/scene as gscene_widget +import etui/widgets/scroll_view +import etui/widgets/sparkline as gspark_widget +import etui/widgets/spinner as gspinner_widget +import etui/widgets/table as gtable_widget +import etui/widgets/tabs as gtabs_widget +import etui/widgets/textarea +import etui/widgets/tree +import gleam/int +import gleam/list +import gleam/string +import gleeunit +import gleeunit/should + +pub fn main() -> Nil { + gleeunit.main() +} + +// ───────────────────────────────────────────────────────────────── +// Sacred 12 tests (must pass) + +pub fn resolve_sizes_percentage_no_fill_test() { + resolve_sizes(100, [ + geometry.Percentage(33), + geometry.Percentage(33), + geometry.Percentage(33), + ]) + |> should.equal([33, 33, 33]) +} + +pub fn resolve_sizes_asymmetric_percentage_test() { + resolve_sizes(100, [ + geometry.Percentage(33), + geometry.Percentage(33), + geometry.Percentage(34), + ]) + |> should.equal([33, 33, 34]) +} + +pub fn resolve_sizes_percentage_with_fill_test() { + resolve_sizes(100, [ + geometry.Percentage(33), + geometry.Percentage(33), + geometry.Percentage(33), + geometry.Fill, + ]) + |> should.equal([33, 33, 33, 1]) +} + +pub fn resolve_sizes_percentage_overflow_test() { + resolve_sizes(100, [geometry.Percentage(60), geometry.Percentage(60)]) + |> should.equal([50, 50]) +} + +pub fn resolve_sizes_length_percentage_fill_test() { + resolve_sizes(100, [ + geometry.Length(30), + geometry.Percentage(50), + geometry.Fill, + ]) + |> should.equal([30, 50, 20]) +} + +pub fn resolve_sizes_length_overflow_percentage_test() { + resolve_sizes(100, [geometry.Length(80), geometry.Percentage(50)]) + |> should.equal([80, 20]) +} + +pub fn resolve_sizes_consecutive_length_test() { + resolve_sizes(100, [geometry.Length(60), geometry.Length(60)]) + |> should.equal([60, 40]) +} + +pub fn resolve_sizes_single_length_overflow_test() { + resolve_sizes(100, [geometry.Length(120)]) + |> should.equal([100]) +} + +pub fn resolve_sizes_multiple_fill_test() { + resolve_sizes(10, [geometry.Fill, geometry.Fill, geometry.Fill]) + |> should.equal([4, 3, 3]) +} + +pub fn resolve_sizes_fill_minimum_area_test() { + resolve_sizes(1, [geometry.Fill, geometry.Fill]) + |> should.equal([1, 0]) +} + +pub fn resolve_sizes_zero_area_test() { + resolve_sizes(0, [geometry.Fill, geometry.Length(10)]) + |> should.equal([0, 0]) +} + +pub fn resolve_sizes_empty_test() { + resolve_sizes(100, []) + |> should.equal([]) +} + +// ───────────────────────────────────────────────────────────────── +// Temporal stability tests + +fn cumulative_sum(sizes: List(Int)) -> List(Int) { + let #(_, acc) = + list.fold(sizes, #(0, []), fn(state, size) { + let #(cursor, sums) = state + #(cursor + size, [cursor + size, ..sums]) + }) + list.reverse(acc) +} + +pub fn stability_percentage_fill_on_range_test() { + let r99 = resolve_sizes(99, [geometry.Percentage(50), geometry.Fill]) + let r100 = resolve_sizes(100, [geometry.Percentage(50), geometry.Fill]) + let r101 = resolve_sizes(101, [geometry.Percentage(50), geometry.Fill]) + let r102 = resolve_sizes(102, [geometry.Percentage(50), geometry.Fill]) + let r103 = resolve_sizes(103, [geometry.Percentage(50), geometry.Fill]) + + let c99 = cumulative_sum(r99) + let c100 = cumulative_sum(r100) + let c101 = cumulative_sum(r101) + let c102 = cumulative_sum(r102) + let c103 = cumulative_sum(r103) + + case c99 { + [b99, ..] -> + case c100 { + [b100, ..] -> + case c101 { + [b101, ..] -> + case c102 { + [b102, ..] -> + case c103 { + [b103, ..] -> { + b99 |> should.equal(49) + b100 |> should.equal(50) + b101 |> should.equal(50) + b102 |> should.equal(51) + b103 |> should.equal(51) + } + _ -> should.fail() + } + _ -> should.fail() + } + _ -> should.fail() + } + _ -> should.fail() + } + _ -> should.fail() + } +} + +pub fn stability_fill_multiple_on_range_test() { + resolve_sizes(2, [geometry.Fill, geometry.Fill]) + |> should.equal([1, 1]) + + resolve_sizes(3, [geometry.Fill, geometry.Fill]) + |> should.equal([2, 1]) + + resolve_sizes(4, [geometry.Fill, geometry.Fill]) + |> should.equal([2, 2]) +} + +pub fn stability_percentage_asymmetric_on_range_test() { + let r100 = + resolve_sizes(100, [ + geometry.Percentage(33), + geometry.Percentage(33), + geometry.Percentage(34), + ]) + let r101 = + resolve_sizes(101, [ + geometry.Percentage(33), + geometry.Percentage(33), + geometry.Percentage(34), + ]) + + r100 |> should.equal([33, 33, 34]) + r101 |> should.equal([33, 33, 35]) +} + +pub fn stability_length_fill_test() { + resolve_sizes(10, [geometry.Length(10), geometry.Fill]) + |> should.equal([10, 0]) + + resolve_sizes(11, [geometry.Length(10), geometry.Fill]) + |> should.equal([10, 1]) + + resolve_sizes(15, [geometry.Length(10), geometry.Fill]) + |> should.equal([10, 5]) +} + +// ───────────────────────────────────────────────────────────────── +// Split tests + +pub fn split_vertical_test() { + let area = rect_new(0, 0, 100, 100) + let chunks = + split(geometry.Vertical, area, [ + geometry.Length(10), + geometry.Percentage(20), + geometry.Fill, + ]) + + chunks |> list.length |> should.equal(3) + + case chunks { + [c0, c1, c2] -> { + c0 + |> should.equal(Rect(Position(x: 0, y: 0), Size(width: 100, height: 10))) + c1 + |> should.equal(Rect(Position(x: 0, y: 10), Size(width: 100, height: 20))) + c2 + |> should.equal(Rect(Position(x: 0, y: 30), Size(width: 100, height: 70))) + } + other -> { + other |> should.equal([]) + } + } +} + +pub fn split_horizontal_test() { + let area = rect_new(0, 0, 100, 50) + let chunks = + split(geometry.Horizontal, area, [ + geometry.Percentage(50), + geometry.Fill, + ]) + + chunks |> list.length |> should.equal(2) + + case chunks { + [c0, c1] -> { + c0 + |> should.equal(Rect(Position(x: 0, y: 0), Size(width: 50, height: 50))) + c1 + |> should.equal(Rect(Position(x: 50, y: 0), Size(width: 50, height: 50))) + } + other -> { + other |> should.equal([]) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Rect helpers + +pub fn rect_new_clamps_negative_test() { + rect_new(5, 10, -20, -30) + |> should.equal(Rect(Position(x: 5, y: 10), Size(width: 0, height: 0))) +} + +pub fn rect_right_test() { + let r = rect_new(10, 20, 30, 40) + right(r) |> should.equal(40) +} + +pub fn rect_bottom_test() { + let r = rect_new(10, 20, 30, 40) + bottom(r) |> should.equal(60) +} + +pub fn rect_area_test() { + let r = rect_new(0, 0, 25, 10) + area(r) |> should.equal(250) +} + +pub fn rect_contains_test() { + let r = rect_new(10, 10, 20, 20) + + contains(r, Position(x: 10, y: 10)) |> should.be_true() + contains(r, Position(x: 15, y: 15)) |> should.be_true() + contains(r, Position(x: 29, y: 29)) |> should.be_true() + contains(r, Position(x: 30, y: 30)) |> should.be_false() + contains(r, Position(x: 9, y: 15)) |> should.be_false() +} + +pub fn rect_intersect_valid_test() { + let a = rect_new(10, 10, 20, 20) + let b = rect_new(15, 15, 20, 20) + + let result = intersect(a, b) + result + |> should.equal(Ok(Rect(Position(x: 15, y: 15), Size(width: 15, height: 15)))) +} + +pub fn rect_intersect_disjoint_test() { + let a = rect_new(0, 0, 10, 10) + let b = rect_new(20, 20, 10, 10) + + intersect(a, b) |> should.equal(Error(Nil)) +} + +pub fn rect_union_test() { + let a = rect_new(0, 0, 10, 10) + let b = rect_new(5, 5, 20, 20) + + let result = union(a, b) + result + |> should.equal(Rect(Position(x: 0, y: 0), Size(width: 25, height: 25))) +} + +// ───────────────────────────────────────────────────────────────── +// Invariant checks + +pub fn invariant_sizes_non_negative_test() { + let result = + resolve_sizes(100, [ + geometry.Length(30), + geometry.Percentage(50), + geometry.Fill, + geometry.Length(20), + ]) + + list.all(result, fn(s) { s >= 0 }) + |> should.be_true() +} + +pub fn invariant_sum_leq_total_test() { + let total = 100 + let result = + resolve_sizes(total, [ + geometry.Length(20), + geometry.Percentage(30), + ]) + + let sum = list.fold(result, 0, fn(acc, s) { acc + s }) + + sum + |> int.min(total) + |> should.equal(sum) +} + +pub fn invariant_sum_eq_total_with_fill_test() { + let total = 100 + let result = + resolve_sizes(total, [ + geometry.Length(20), + geometry.Percentage(30), + geometry.Fill, + ]) + + list.fold(result, 0, fn(acc, s) { acc + s }) + |> should.equal(total) +} + +// ───────────────────────────────────────────────────────────────── +// Text module tests (M2) + +pub fn text_cell_width_ascii_test() { + text.cell_width("hello") |> should.equal(5) +} + +pub fn text_cell_width_empty_test() { + text.cell_width("") |> should.equal(0) +} + +pub fn text_cell_width_single_char_test() { + text.cell_width("a") |> should.equal(1) +} + +pub fn text_cell_width_space_test() { + text.cell_width(" ") |> should.equal(1) +} + +pub fn text_cell_width_mixed_test() { + text.cell_width("Hello World!") |> should.equal(12) +} + +pub fn text_truncate_basic_test() { + // Greedy truncate: fits as much as possible + ellipsis + text.truncate("hello world", 8, "…") + |> should.equal("hello w…") +} + +pub fn text_truncate_no_need_test() { + text.truncate("hi", 10, "…") + |> should.equal("hi") +} + +pub fn text_truncate_zero_width_test() { + text.truncate("hello", 0, "…") + |> should.equal("") +} + +pub fn text_wrap_single_line_test() { + text.wrap("hello world", 20) + |> should.equal(["hello world"]) +} + +pub fn text_wrap_two_lines_test() { + text.wrap("hello world test", 8) + |> should.equal(["hello", "world", "test"]) +} + +pub fn text_wrap_empty_test() { + text.wrap("hello", 0) + |> should.equal([]) +} + +pub fn text_pad_right_test() { + text.pad_right("hi", 5) + |> should.equal("hi ") +} + +pub fn text_pad_left_test() { + text.pad_left("hi", 5) + |> should.equal(" hi") +} + +pub fn text_align_left_test() { + text.align("hi", 5, text.Left) + |> should.equal("hi ") +} + +pub fn text_align_right_test() { + text.align("hi", 5, text.Right) + |> should.equal(" hi") +} + +pub fn text_align_center_test() { + text.align("hi", 5, text.Center) + |> should.equal(" hi ") +} + +pub fn text_strip_ansi_basic_test() { + let styled = "\u{001B}[1mhello\u{001B}[0m" + text.strip_ansi(styled) + |> should.equal("hello") +} + +pub fn text_strip_ansi_no_codes_test() { + text.strip_ansi("plain") + |> should.equal("plain") +} + +pub fn text_strip_ansi_color_test() { + let colored = "\u{001B}[32mgreen\u{001B}[0m" + text.strip_ansi(colored) + |> should.equal("green") +} + +// ───────────────────────────────────────────────────────────────── +// Block widget tests + +pub fn block_new_test() { + let b = block.block_new() + b.border |> should.equal(block.None) + b.title |> should.equal("") + b.padding_top |> should.equal(0) +} + +pub fn block_with_border_test() { + let b = block.block_new() |> block.with_border(block.Single) + b.border |> should.equal(block.Single) +} + +pub fn block_with_title_test() { + let b = block.block_new() |> block.with_title("Test", block.Top) + b.title |> should.equal("Test") + b.title_position |> should.equal(block.Top) +} + +pub fn block_with_padding_test() { + let b = block.block_new() |> block.with_padding(1, 2, 3, 4) + b.padding_top |> should.equal(1) + b.padding_bottom |> should.equal(2) + b.padding_left |> should.equal(3) + b.padding_right |> should.equal(4) +} + +pub fn block_render_no_border_test() { + let area = rect_new(0, 0, 10, 10) + let buf = buffer.buffer_new(area) + let b = block.block_new() + + let result = block.render(buf, area, b) + result |> buffer.area |> should.equal(area) +} + +pub fn block_render_bordered_test() { + let area = rect_new(0, 0, 10, 10) + let buf = buffer.buffer_new(area) + let b = block.block_new() |> block.with_border(block.Single) + + let result = block.render(buf, area, b) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Paragraph widget tests + +pub fn paragraph_new_test() { + let p = paragraph.paragraph_new("hello") + p.text |> should.equal("hello") +} + +pub fn paragraph_with_alignment_test() { + let p = + paragraph.paragraph_new("hello") + |> paragraph.with_alignment(text.Left) + p.alignment |> should.equal(text.Left) +} + +pub fn paragraph_render_single_line_test() { + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let p = paragraph.paragraph_new("hello world") + + let result = paragraph.render(buf, area, p) + result |> buffer.area |> should.equal(area) +} + +pub fn paragraph_render_wrapped_test() { + let area = rect_new(0, 0, 5, 5) + let buf = buffer.buffer_new(area) + let p = paragraph.paragraph_new("hello world test") + + let result = paragraph.render(buf, area, p) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Line widget tests + +pub fn line_new_test() { + let l = line.line_new() + l.style |> should.equal(line.Solid) + l.fg |> should.equal(style.Default) +} + +pub fn line_with_color_test() { + let l = + line.line_new() + |> line.with_color(style.Indexed(1)) + l.fg |> should.equal(style.Indexed(1)) +} + +pub fn line_render_horizontal_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let l = line.line_new() + + let result = line.render_horizontal(buf, area, l) + result |> buffer.area |> should.equal(area) +} + +pub fn line_render_vertical_test() { + let area = rect_new(0, 0, 1, 10) + let buf = buffer.buffer_new(area) + let l = line.line_new() + + let result = line.render_vertical(buf, area, l) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// List widget tests + +pub fn list_new_test() { + let items = ["Item 1", "Item 2", "Item 3"] + let l = glist_widget.list_new(items) + l.fg |> should.equal(style.Default) +} + +pub fn list_state_new_test() { + let state = glist_widget.state_new() + state.selected |> should.equal(0) + state.offset |> should.equal(0) +} + +pub fn list_select_test() { + let state = glist_widget.state_new() |> glist_widget.select(2) + state.selected |> should.equal(2) +} + +pub fn list_select_next_test() { + let state = glist_widget.state_new() |> glist_widget.select_next(5) + state.selected |> should.equal(1) +} + +pub fn list_select_prev_clamps_test() { + let state = glist_widget.state_new() |> glist_widget.select_prev() + state.selected |> should.equal(0) +} + +pub fn list_with_colors_test() { + let items = ["Item 1", "Item 2"] + let l = + glist_widget.list_new(items) + |> glist_widget.with_colors(style.Indexed(2), style.Indexed(3)) + l.fg |> should.equal(style.Indexed(2)) + l.bg |> should.equal(style.Indexed(3)) +} + +pub fn list_render_test() { + let items = ["Item 1", "Item 2", "Item 3"] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let l = glist_widget.list_new(items) + + let result = glist_widget.render(buf, area, l) + result |> buffer.area |> should.equal(area) +} + +pub fn list_render_stateful_test() { + let items = ["Item 1", "Item 2", "Item 3"] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let l = glist_widget.list_new(items) + let state = glist_widget.state_new() |> glist_widget.select(1) + + let result = glist_widget.render_stateful(buf, area, l, state) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Table widget tests + +pub fn table_new_test() { + let rows = [["Name", "Age"], ["Alice", "30"], ["Bob", "25"]] + let t = gtable_widget.table_new(rows) + t.fg |> should.equal(style.Default) +} + +pub fn table_state_new_test() { + let state = gtable_widget.state_new() + state.selected_row |> should.equal(0) + state.offset |> should.equal(0) +} + +pub fn table_select_row_test() { + let state = gtable_widget.state_new() |> gtable_widget.select_row(2) + state.selected_row |> should.equal(2) +} + +pub fn table_with_col_widths_test() { + let rows = [["X", "Y"]] + let t = + gtable_widget.table_new(rows) + |> gtable_widget.with_col_widths([15, 20]) + t.col_widths |> should.equal([15, 20]) +} + +pub fn table_with_header_test() { + let rows = [["Col1", "Col2"]] + let t = + gtable_widget.table_new(rows) + |> gtable_widget.with_header(True) + t.show_header |> should.equal(True) +} + +pub fn table_render_test() { + let rows = [["A", "B"], ["C", "D"], ["E", "F"]] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let t = gtable_widget.table_new(rows) + + let result = gtable_widget.render(buf, area, t) + result |> buffer.area |> should.equal(area) +} + +pub fn table_render_stateful_test() { + let rows = [["A", "B"], ["C", "D"], ["E", "F"]] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let t = gtable_widget.table_new(rows) + let state = gtable_widget.state_new() |> gtable_widget.select_row(1) + + let result = gtable_widget.render_stateful(buf, area, t, state) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Input widget tests + +pub fn input_new_test() { + let w = ginput_widget.input_new("Enter text") + w.placeholder |> should.equal("Enter text") + w.max_length |> should.equal(256) +} + +pub fn input_state_new_test() { + let s = ginput_widget.state_new() + s.value |> should.equal("") + s.cursor |> should.equal(0) +} + +pub fn input_state_from_string_test() { + let s = ginput_widget.state_from_string("hello") + s.value |> should.equal("hello") + s.cursor |> should.equal(5) +} + +pub fn input_insert_char_test() { + let w = ginput_widget.input_new("") + let s0 = ginput_widget.state_new() + let s1 = ginput_widget.insert_char(w, s0, "a") + let s2 = ginput_widget.insert_char(w, s1, "b") + s2.value |> should.equal("ab") + s2.cursor |> should.equal(2) +} + +pub fn input_backspace_test() { + let s = + ginput_widget.state_from_string("hello") + |> ginput_widget.backspace() + s.value |> should.equal("hell") + s.cursor |> should.equal(4) +} + +pub fn input_move_cursor_test() { + let s = + ginput_widget.state_from_string("test") + |> ginput_widget.move_cursor_left() + |> ginput_widget.move_cursor_left() + s.cursor |> should.equal(2) + + let s2 = s |> ginput_widget.move_cursor_right() + s2.cursor |> should.equal(3) +} + +pub fn input_clear_test() { + let s = + ginput_widget.state_from_string("content") + |> ginput_widget.clear_state() + s.value |> should.equal("") + s.cursor |> should.equal(0) +} + +pub fn input_render_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let w = ginput_widget.input_new("default") + let s = ginput_widget.state_new() + + let result = ginput_widget.render(buf, area, w, s) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Gauge widget tests + +pub fn gauge_new_test() { + let g = ggauge_widget.gauge_new(75) + g.percent |> should.equal(75) + g.label |> should.equal("") +} + +pub fn gauge_clamps_percent_test() { + ggauge_widget.gauge_new(150).percent |> should.equal(100) + ggauge_widget.gauge_new(-10).percent |> should.equal(0) +} + +pub fn gauge_with_label_test() { + let g = ggauge_widget.gauge_new(50) |> ggauge_widget.with_label("50%") + g.label |> should.equal("50%") +} + +pub fn gauge_render_empty_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let g = ggauge_widget.gauge_new(0) + let result = ggauge_widget.render(buf, area, g) + result |> buffer.area |> should.equal(area) +} + +pub fn gauge_render_full_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let g = ggauge_widget.gauge_new(100) + let result = ggauge_widget.render(buf, area, g) + result |> buffer.area |> should.equal(area) +} + +pub fn gauge_render_partial_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let g = ggauge_widget.gauge_new(50) + let result = ggauge_widget.render(buf, area, g) + result |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Scroll tests + +pub fn list_scroll_into_view_down_test() { + // selected=5, offset=0, height=3 → offset should become 3 + let state = glist_widget.ListState(selected: 5, offset: 0) + let items = ["a", "b", "c", "d", "e", "f", "g"] + let area = rect_new(0, 0, 20, 3) + let buf = buffer.buffer_new(area) + let l = glist_widget.list_new(items) + // render_stateful auto-adjusts offset; result must not crash + let result = glist_widget.render_stateful(buf, area, l, state) + result |> buffer.area |> should.equal(area) +} + +pub fn list_scroll_into_view_up_test() { + // selected=0 but offset=3 → should scroll back up + let state = glist_widget.ListState(selected: 0, offset: 3) + let items = ["a", "b", "c", "d", "e"] + let area = rect_new(0, 0, 20, 3) + let buf = buffer.buffer_new(area) + let l = glist_widget.list_new(items) + let result = glist_widget.render_stateful(buf, area, l, state) + result |> buffer.area |> should.equal(area) +} + +pub fn list_highlight_style_test() { + let items = ["Item 1", "Item 2"] + let s = + style.Style(fg: style.Default, bg: style.Default, modifier: style.bold()) + let l = + glist_widget.list_new(items) + |> glist_widget.with_highlight_style(s) + l.highlight_style.modifier |> should.equal(style.bold()) +} + +pub fn table_highlight_style_test() { + let rows = [["A", "B"], ["C", "D"]] + let s = + style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.add(style.bold(), style.reverse()), + ) + let t = + gtable_widget.table_new(rows) + |> gtable_widget.with_highlight_style(s) + t.highlight_style.modifier + |> should.equal(style.add(style.bold(), style.reverse())) +} + +// ───────────────────────────────────────────────────────────────── +// Style type tests + +pub fn style_default_test() { + let s = style.default_style() + s.fg |> should.equal(style.Default) + s.bg |> should.equal(style.Default) + s.modifier |> should.equal(style.none()) +} + +pub fn style_with_fg_test() { + let s = style.default_style() |> style.with_fg(style.Indexed(1)) + s.fg |> should.equal(style.Indexed(1)) +} + +pub fn style_with_modifier_test() { + let s = style.default_style() |> style.with_modifier(style.bold()) + s.modifier |> should.equal(style.bold()) +} + +pub fn style_bold_test() { + style.bold_style().modifier |> should.equal(style.bold()) +} + +pub fn style_reversed_test() { + style.reversed().modifier |> should.equal(style.reverse()) +} + +pub fn style_patch_test() { + let base = + style.Style( + fg: style.Indexed(1), + bg: style.Indexed(2), + modifier: style.bold(), + ) + let over = + style.Style(fg: style.Default, bg: style.Indexed(3), modifier: style.none()) + let result = style.patch(base, over) + result.fg |> should.equal(style.Indexed(1)) + result.bg |> should.equal(style.Indexed(3)) + result.modifier |> should.equal(style.bold()) +} + +// ───────────────────────────────────────────────────────────────── +// Anim easing + sequence tests + +pub fn anim_interpolate_linear_test() { + anim.interpolate(0, 100, 5, 10, anim.Linear) |> should.equal(50) +} + +pub fn anim_interpolate_ease_out_test() { + anim.interpolate(0, 100, 10, 10, anim.EaseOut) |> should.equal(100) + anim.interpolate(0, 100, 0, 10, anim.EaseOut) |> should.equal(0) +} + +pub fn anim_interpolate_ease_in_out_test() { + anim.interpolate(0, 100, 0, 10, anim.EaseInOut) |> should.equal(0) + anim.interpolate(0, 100, 10, 10, anim.EaseInOut) |> should.equal(100) +} + +pub fn anim_ease_in_out_midpoint_test() { + let mid = anim.ease_in_out(0, 100, 5, 10) + mid |> should.equal(50) +} + +pub fn anim_sequence_linear_test() { + let kfs = [anim.Keyframe(0, 0), anim.Keyframe(10, 100), anim.Keyframe(20, 50)] + anim.sequence(kfs, 0, anim.Linear) |> should.equal(0) + anim.sequence(kfs, 5, anim.Linear) |> should.equal(50) + anim.sequence(kfs, 10, anim.Linear) |> should.equal(100) + anim.sequence(kfs, 20, anim.Linear) |> should.equal(50) + anim.sequence(kfs, 99, anim.Linear) |> should.equal(50) +} + +pub fn anim_sequence_empty_test() { + anim.sequence([], 5, anim.Linear) |> should.equal(0) +} + +pub fn anim_sequence_single_test() { + anim.sequence([anim.Keyframe(0, 42)], 99, anim.Linear) |> should.equal(42) +} + +// ───────────────────────────────────────────────────────────────── +// Spinner custom style test + +pub fn spinner_custom_frames_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let s = + gspinner_widget.spinner_new() + |> gspinner_widget.with_style(gspinner_widget.Custom(["A", "B", "C"])) + let result = gspinner_widget.render(buf, area, s, 0) + result |> buffer.area |> should.equal(area) +} + +pub fn spinner_custom_cycles_test() { + let area = rect_new(0, 0, 1, 1) + let buf = buffer.buffer_new(area) + let s = + gspinner_widget.spinner_new() + |> gspinner_widget.with_style(gspinner_widget.Custom(["X", "Y"])) + [0, 1, 2, 3] + |> list.each(fn(f) { + let _ = gspinner_widget.render(buf, area, s, f) + Nil + }) +} + +// ───────────────────────────────────────────────────────────────── +// Anim tests + +pub fn anim_new_test() { + let a = anim.anim_new() + a.frame |> should.equal(0) +} + +pub fn anim_tick_test() { + let a = anim.anim_new() |> anim.tick() |> anim.tick() |> anim.tick() + a.frame |> should.equal(3) +} + +pub fn anim_reset_test() { + let a = anim.anim_new() |> anim.tick() |> anim.tick() |> anim.reset() + a.frame |> should.equal(0) +} + +pub fn anim_is_done_test() { + anim.is_done(anim.AnimState(frame: 10), 10) |> should.be_true() + anim.is_done(anim.AnimState(frame: 5), 10) |> should.be_false() +} + +pub fn anim_cycle_test() { + anim.cycle(0, 4) |> should.equal(0) + anim.cycle(3, 4) |> should.equal(3) + anim.cycle(4, 4) |> should.equal(0) + anim.cycle(7, 4) |> should.equal(3) +} + +pub fn anim_lerp_test() { + anim.lerp(0, 100, 0, 10) |> should.equal(0) + anim.lerp(0, 100, 5, 10) |> should.equal(50) + anim.lerp(0, 100, 10, 10) |> should.equal(100) + anim.lerp(0, 100, 99, 10) |> should.equal(100) +} + +pub fn anim_lerp_zero_duration_test() { + anim.lerp(0, 100, 5, 0) |> should.equal(100) +} + +pub fn anim_ease_out_ends_at_target_test() { + anim.ease_out(0, 100, 10, 10) |> should.equal(100) + anim.ease_out(0, 100, 0, 10) |> should.equal(0) +} + +pub fn anim_ease_in_ends_at_target_test() { + anim.ease_in(0, 100, 10, 10) |> should.equal(100) + anim.ease_in(0, 100, 0, 10) |> should.equal(0) +} + +pub fn anim_oscillate_test() { + anim.oscillate(0, 10, 0, 20) |> should.equal(0) + anim.oscillate(0, 10, 10, 20) |> should.equal(10) +} + +// ───────────────────────────────────────────────────────────────── +// Spinner widget tests + +pub fn spinner_new_test() { + let s = gspinner_widget.spinner_new() + s.label |> should.equal("") + s.fg |> should.equal(style.Default) +} + +pub fn spinner_with_label_test() { + let s = gspinner_widget.spinner_new() |> gspinner_widget.with_label("Loading") + s.label |> should.equal("Loading") +} + +pub fn spinner_render_dots_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let s = gspinner_widget.spinner_new() + let result = gspinner_widget.render(buf, area, s, 0) + result |> buffer.area |> should.equal(area) +} + +pub fn spinner_render_all_styles_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + + [ + gspinner_widget.Dots, + gspinner_widget.Line, + gspinner_widget.Circle, + gspinner_widget.Bounce, + ] + |> list.each(fn(sty) { + let s = gspinner_widget.spinner_new() |> gspinner_widget.with_style(sty) + let result = gspinner_widget.render(buf, area, s, 42) + result |> buffer.area |> should.equal(area) + }) +} + +pub fn spinner_cycles_frames_test() { + let area = rect_new(0, 0, 1, 1) + let buf = buffer.buffer_new(area) + let s = + gspinner_widget.spinner_new() + |> gspinner_widget.with_style(gspinner_widget.Line) + [0, 1, 2, 3, 4, 5, 6, 7] + |> list.each(fn(f) { + let _ = gspinner_widget.render(buf, area, s, f) + Nil + }) +} + +// ───────────────────────────────────────────────────────────────── +// Anim blink tests + +pub fn anim_blink_on_test() { + anim.blink(0, 10) |> should.be_true() + anim.blink(4, 10) |> should.be_true() +} + +pub fn anim_blink_off_test() { + anim.blink(5, 10) |> should.be_false() + anim.blink(9, 10) |> should.be_false() +} + +pub fn anim_blink_zero_period_always_on_test() { + anim.blink(0, 0) |> should.be_true() + anim.blink(999, 0) |> should.be_true() +} + +// ───────────────────────────────────────────────────────────────── +// Cursor tests + +pub fn cursor_set_shape_block_test() { + cursor.set_shape(cursor.Block) |> should.equal("\u{001B}[2 q") +} + +pub fn cursor_set_shape_bar_blink_test() { + cursor.set_shape(cursor.BarBlink) |> should.equal("\u{001B}[5 q") +} + +pub fn cursor_show_hide_test() { + cursor.show() |> should.equal("\u{001B}[?25h") + cursor.hide() |> should.equal("\u{001B}[?25l") +} + +pub fn cursor_move_to_test() { + cursor.move_to(1, 1) |> should.equal("\u{001B}[1;1H") + cursor.move_to(10, 42) |> should.equal("\u{001B}[10;42H") +} + +// ───────────────────────────────────────────────────────────────── +// Progress widget tests + +pub fn progress_new_test() { + let p = gprogress_widget.progress_new(75) + p.label |> should.equal("") +} + +pub fn progress_clamps_percent_test() { + let p = gprogress_widget.progress_new(150) + case p.mode { + gprogress_widget.Determinate(pct) -> pct |> should.equal(100) + _ -> should.fail() + } +} + +pub fn progress_indeterminate_mode_test() { + let p = gprogress_widget.progress_indeterminate() + case p.mode { + gprogress_widget.Indeterminate -> Nil + _ -> should.fail() + } +} + +pub fn progress_render_determinate_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let p = gprogress_widget.progress_new(50) + let result = gprogress_widget.render(buf, area, p, 0) + result |> buffer.area |> should.equal(area) +} + +pub fn progress_render_indeterminate_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let p = gprogress_widget.progress_indeterminate() + [0, 10, 20, 30] + |> list.each(fn(f) { + let result = gprogress_widget.render(buf, area, p, f) + result |> buffer.area |> should.equal(area) + }) +} + +pub fn progress_render_zero_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + gprogress_widget.render(buf, area, gprogress_widget.progress_new(0), 0) + |> buffer.area + |> should.equal(area) +} + +pub fn progress_render_full_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + gprogress_widget.render(buf, area, gprogress_widget.progress_new(100), 0) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Blinking list/table tests + +pub fn list_render_animated_steady_test() { + let items = ["a", "b", "c"] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let l = glist_widget.list_new(items) + let state = glist_widget.state_new() + // blink_period=0 → always visible + let result = glist_widget.render_animated(buf, area, l, state, 42) + result |> buffer.area |> should.equal(area) +} + +pub fn list_render_animated_blink_test() { + let items = ["a", "b", "c"] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let l = glist_widget.list_new(items) |> glist_widget.with_blink(10) + let state = glist_widget.state_new() + // frame=0 → on, frame=5 → off — both must render without crash + let r1 = glist_widget.render_animated(buf, area, l, state, 0) + let r2 = glist_widget.render_animated(buf, area, l, state, 5) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn table_render_animated_blink_test() { + let rows = [["A", "B"], ["C", "D"]] + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let t = gtable_widget.table_new(rows) |> gtable_widget.with_blink(10) + let state = gtable_widget.state_new() + let r1 = gtable_widget.render_animated(buf, area, t, state, 0) + let r2 = gtable_widget.render_animated(buf, area, t, state, 5) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// color.gleam tests + +pub fn lerp_rgb_full_test() { + color.lerp_rgb(style.Rgb(0, 0, 0), style.Rgb(100, 200, 50), 1, 1) + |> should.equal(style.Rgb(100, 200, 50)) +} + +pub fn lerp_rgb_zero_test() { + color.lerp_rgb(style.Rgb(0, 0, 0), style.Rgb(100, 200, 50), 0, 1) + |> should.equal(style.Rgb(0, 0, 0)) +} + +pub fn lerp_rgb_midpoint_test() { + color.lerp_rgb(style.Rgb(0, 0, 0), style.Rgb(200, 100, 50), 1, 2) + |> should.equal(style.Rgb(100, 50, 25)) +} + +pub fn lerp_rgb_non_rgb_low_test() { + color.lerp_rgb(style.Default, style.Indexed(1), 0, 10) + |> should.equal(style.Default) +} + +pub fn lerp_rgb_non_rgb_high_test() { + color.lerp_rgb(style.Default, style.Indexed(1), 5, 10) + |> should.equal(style.Indexed(1)) +} + +pub fn hue_to_rgb_red_test() { + color.hue_to_rgb(0) + |> should.equal(style.Rgb(255, 0, 0)) +} + +pub fn hue_to_rgb_green_test() { + color.hue_to_rgb(120) + |> should.equal(style.Rgb(0, 255, 0)) +} + +pub fn hue_to_rgb_blue_test() { + color.hue_to_rgb(240) + |> should.equal(style.Rgb(0, 0, 255)) +} + +pub fn hue_to_rgb_wrap_test() { + color.hue_to_rgb(360) + |> should.equal(color.hue_to_rgb(0)) +} + +pub fn hue_to_rgb_negative_test() { + let c = color.hue_to_rgb(-1) + c |> should.not_equal(style.Default) +} + +pub fn rainbow_returns_rgb_test() { + let c = color.rainbow(0, 60) + case c { + style.Rgb(_, _, _) -> True + _ -> False + } + |> should.equal(True) +} + +pub fn rainbow_period_zero_test() { + let c = color.rainbow(5, 0) + c |> should.not_equal(style.Default) +} + +pub fn gradient_empty_test() { + color.gradient([], 0, 100) + |> should.equal(style.Default) +} + +pub fn gradient_single_test() { + color.gradient([style.Rgb(255, 0, 0)], 50, 100) + |> should.equal(style.Rgb(255, 0, 0)) +} + +pub fn gradient_two_stops_start_test() { + color.gradient([style.Rgb(0, 0, 0), style.Rgb(100, 0, 0)], 0, 100) + |> should.equal(style.Rgb(0, 0, 0)) +} + +pub fn gradient_two_stops_end_test() { + color.gradient([style.Rgb(0, 0, 0), style.Rgb(100, 0, 0)], 100, 100) + |> should.equal(style.Rgb(100, 0, 0)) +} + +pub fn pulse_non_rgb_passthrough_test() { + color.pulse(style.Default, 0, 30) + |> should.equal(style.Default) +} + +pub fn pulse_rgb_returns_rgb_test() { + let c = color.pulse(style.Rgb(255, 255, 255), 0, 30) + case c { + style.Rgb(_, _, _) -> True + _ -> False + } + |> should.equal(True) +} + +pub fn scale_full_test() { + color.scale(style.Rgb(200, 100, 50), 255) + |> should.equal(style.Rgb(200, 100, 50)) +} + +pub fn scale_half_test() { + color.scale(style.Rgb(200, 100, 50), 128) + |> should.equal(style.Rgb(100, 50, 25)) +} + +pub fn scale_zero_test() { + color.scale(style.Rgb(200, 100, 50), 0) + |> should.equal(style.Rgb(0, 0, 0)) +} + +pub fn scale_non_rgb_passthrough_test() { + color.scale(style.Indexed(3), 128) + |> should.equal(style.Indexed(3)) +} + +// ───────────────────────────────────────────────────────────────── +// gradient_bar.gleam tests + +pub fn gradient_bar_render_area_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let stops = [style.Rgb(0, 0, 255), style.Rgb(255, 0, 0)] + let g = ggradient_widget.gradient_bar_new(stops) + ggradient_widget.render(buf, area, g, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn gradient_bar_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let g = ggradient_widget.rainbow_bar() + ggradient_widget.render(buf, area, g, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn rainbow_bar_render_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let g = ggradient_widget.rainbow_bar() + ggradient_widget.render(buf, area, g, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn animated_rainbow_bar_render_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let g = ggradient_widget.animated_rainbow_bar() + let r1 = ggradient_widget.render(buf, area, g, 0) + let r2 = ggradient_widget.render(buf, area, g, 30) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn gradient_progress_partial_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let stops = [style.Rgb(0, 255, 0), style.Rgb(255, 0, 0)] + let g = ggradient_widget.gradient_progress_new(stops, 50) + ggradient_widget.render(buf, area, g, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn gradient_progress_zero_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let stops = [style.Rgb(0, 255, 0), style.Rgb(255, 0, 0)] + let g = ggradient_widget.gradient_progress_new(stops, 0) + ggradient_widget.render(buf, area, g, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn gradient_bar_with_percent_clamp_test() { + let g = ggradient_widget.rainbow_bar() |> ggradient_widget.with_percent(150) + g.percent |> should.equal(100) +} + +pub fn pulse_bar_render_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let g = ggradient_widget.pulse_bar(style.Rgb(0, 180, 255)) + let r1 = ggradient_widget.render(buf, area, g, 0) + let r2 = ggradient_widget.render(buf, area, g, 15) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn animated_gradient_render_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let stops = [style.Rgb(0, 0, 255), style.Rgb(0, 255, 0), style.Rgb(255, 0, 0)] + let g = ggradient_widget.animated_gradient_bar_new(stops) + let r1 = ggradient_widget.render(buf, area, g, 0) + let r2 = ggradient_widget.render(buf, area, g, 60) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// sparkline tests + +pub fn sparkline_render_area_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let s = gspark_widget.sparkline_new([10, 20, 50, 80, 100, 40, 60, 30, 70, 90]) + gspark_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn sparkline_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let s = gspark_widget.sparkline_new([10, 50, 100]) + gspark_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn sparkline_empty_data_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let s = gspark_widget.sparkline_new([]) + gspark_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn sparkline_rainbow_fill_test() { + let area = rect_new(0, 0, 8, 1) + let buf = buffer.buffer_new(area) + let s = + gspark_widget.sparkline_new([10, 30, 60, 100, 80, 50, 20, 40]) + |> gspark_widget.with_fill(gspark_widget.SparkRainbow) + gspark_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn sparkline_animated_rainbow_test() { + let area = rect_new(0, 0, 8, 1) + let buf = buffer.buffer_new(area) + let s = + gspark_widget.sparkline_new([10, 30, 60, 100, 80, 50, 20, 40]) + |> gspark_widget.with_fill(gspark_widget.SparkAnimatedRainbow) + let r1 = gspark_widget.render(buf, area, s, 0) + let r2 = gspark_widget.render(buf, area, s, 30) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn sparkline_solid_fill_test() { + let area = rect_new(0, 0, 5, 1) + let buf = buffer.buffer_new(area) + let s = + gspark_widget.sparkline_new([0, 25, 50, 75, 100]) + |> gspark_widget.with_fill(gspark_widget.SparkSolid(style.Rgb(0, 255, 0))) + gspark_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn sparkline_animated_gradient_test() { + let area = rect_new(0, 0, 8, 1) + let buf = buffer.buffer_new(area) + let s = + gspark_widget.sparkline_new([10, 30, 60, 100, 80, 50, 20, 40]) + |> gspark_widget.with_fill( + gspark_widget.SparkAnimated([style.Rgb(0, 0, 255), style.Rgb(255, 0, 0)]), + ) + let r1 = gspark_widget.render(buf, area, s, 0) + let r2 = gspark_widget.render(buf, area, s, 60) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn sparkline_with_max_test() { + let area = rect_new(0, 0, 5, 1) + let buf = buffer.buffer_new(area) + let s = + gspark_widget.sparkline_new([200, 150, 100, 50, 0]) + |> gspark_widget.with_max(200) + gspark_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// marquee tests + +pub fn marquee_render_area_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let m = gmarquee_widget.marquee_new("Hello world") + gmarquee_widget.render(buf, area, m, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn marquee_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let m = gmarquee_widget.marquee_new("Hello world") + gmarquee_widget.render(buf, area, m, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn marquee_scrolls_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let m = + gmarquee_widget.marquee_new("ABCDEFGHIJKLMNOP") + |> gmarquee_widget.with_speed(1) + let r1 = gmarquee_widget.render(buf, area, m, 0) + let r2 = gmarquee_widget.render(buf, area, m, 5) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn marquee_empty_text_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let m = gmarquee_widget.marquee_new("") + gmarquee_widget.render(buf, area, m, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn marquee_speed_clamp_test() { + let m = gmarquee_widget.marquee_new("test") |> gmarquee_widget.with_speed(0) + m.speed |> should.equal(1) +} + +pub fn marquee_separator_test() { + let area = rect_new(0, 0, 30, 1) + let buf = buffer.buffer_new(area) + let m = + gmarquee_widget.marquee_new("Hi") + |> gmarquee_widget.with_separator(" -- ") + gmarquee_widget.render(buf, area, m, 0) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// tabs tests + +pub fn tabs_render_area_test() { + let area = rect_new(0, 0, 40, 1) + let buf = buffer.buffer_new(area) + let t = gtabs_widget.tabs_new(["Home", "Settings", "About"]) + gtabs_widget.render(buf, area, t) + |> buffer.area + |> should.equal(area) +} + +pub fn tabs_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let t = gtabs_widget.tabs_new(["A", "B"]) + gtabs_widget.render(buf, area, t) + |> buffer.area + |> should.equal(area) +} + +pub fn tabs_active_index_test() { + let t = gtabs_widget.tabs_new(["A", "B", "C"]) |> gtabs_widget.with_active(2) + t.active |> should.equal(2) +} + +pub fn tabs_next_prev_test() { + let t = gtabs_widget.tabs_new(["A", "B", "C"]) |> gtabs_widget.with_active(0) + gtabs_widget.next_tab(t).active |> should.equal(1) +} + +pub fn tabs_next_wraps_test() { + let t = gtabs_widget.tabs_new(["A", "B", "C"]) |> gtabs_widget.with_active(2) + gtabs_widget.next_tab(t).active |> should.equal(0) +} + +pub fn tabs_prev_wraps_test() { + let t = gtabs_widget.tabs_new(["A", "B", "C"]) |> gtabs_widget.with_active(0) + gtabs_widget.prev_tab(t).active |> should.equal(2) +} + +pub fn tabs_empty_labels_test() { + let area = rect_new(0, 0, 20, 1) + let buf = buffer.buffer_new(area) + let t = gtabs_widget.tabs_new([]) + gtabs_widget.render(buf, area, t) + |> buffer.area + |> should.equal(area) +} + +pub fn tabs_custom_divider_test() { + let area = rect_new(0, 0, 30, 1) + let buf = buffer.buffer_new(area) + let t = + gtabs_widget.tabs_new(["X", "Y"]) + |> gtabs_widget.with_divider(" | ") + |> gtabs_widget.with_padding(2) + gtabs_widget.render(buf, area, t) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// chart tests + +pub fn chart_render_area_test() { + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let c = gchart_widget.chart_new([10, 50, 80, 30, 100]) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let c = gchart_widget.chart_new([10, 50, 80]) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_empty_data_test() { + let area = rect_new(0, 0, 10, 5) + let buf = buffer.buffer_new(area) + let c = gchart_widget.chart_new([]) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_rainbow_fill_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let c = + gchart_widget.chart_new([20, 60, 100, 40, 80]) + |> gchart_widget.with_fill(gchart_widget.ChartRainbow) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_animated_rainbow_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let c = + gchart_widget.chart_new([20, 60, 100, 40, 80]) + |> gchart_widget.with_fill(gchart_widget.ChartAnimatedRainbow) + let r1 = gchart_widget.render(buf, area, c, 0) + let r2 = gchart_widget.render(buf, area, c, 35) + r1 |> buffer.area |> should.equal(area) + r2 |> buffer.area |> should.equal(area) +} + +pub fn chart_gradient_fill_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let c = + gchart_widget.chart_new([20, 60, 100, 40, 80]) + |> gchart_widget.with_fill( + gchart_widget.ChartGradient([style.Rgb(0, 0, 255), style.Rgb(255, 0, 0)]), + ) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_vertical_gradient_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let c = + gchart_widget.chart_new([20, 60, 100, 40, 80]) + |> gchart_widget.with_fill( + gchart_widget.ChartVerticalGradient([ + style.Rgb(0, 255, 0), + style.Rgb(255, 255, 0), + style.Rgb(255, 0, 0), + ]), + ) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_solid_fill_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let c = + gchart_widget.chart_new([20, 60, 100]) + |> gchart_widget.with_fill( + gchart_widget.ChartSolid([ + style.Rgb(255, 0, 0), + style.Rgb(0, 255, 0), + style.Rgb(0, 0, 255), + ]), + ) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn chart_with_max_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let c = + gchart_widget.chart_new([200, 150, 100]) + |> gchart_widget.with_max(200) + |> gchart_widget.with_bar_width(4) + |> gchart_widget.with_gap(1) + gchart_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// HBar widget tests + +pub fn hbar_render_area_test() { + let area = rect_new(0, 0, 30, 4) + let buf = buffer.buffer_new(area) + let h = + ghbar_widget.hbar_new([ + ghbar_widget.item("a", 50), + ghbar_widget.item("b", 80), + ]) + ghbar_widget.render(buf, area, h, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn hbar_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let h = ghbar_widget.hbar_new([ghbar_widget.item("x", 10)]) + let result = ghbar_widget.render(buf, area, h, 0) + buffer.width(result) |> should.equal(0) +} + +pub fn hbar_empty_items_test() { + let area = rect_new(0, 0, 20, 3) + let buf = buffer.buffer_new(area) + let h = ghbar_widget.hbar_new([]) + ghbar_widget.render(buf, area, h, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn hbar_solid_fill_test() { + let area = rect_new(0, 0, 30, 3) + let buf = buffer.buffer_new(area) + let h = + ghbar_widget.hbar_new([ghbar_widget.item("x", 50)]) + |> ghbar_widget.with_fill( + ghbar_widget.HBarSolid([style.Rgb(255, 0, 0), style.Rgb(0, 255, 0)]), + ) + ghbar_widget.render(buf, area, h, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn hbar_gradient_fill_test() { + let area = rect_new(0, 0, 30, 3) + let buf = buffer.buffer_new(area) + let h = + ghbar_widget.hbar_new([ghbar_widget.item("x", 75)]) + |> ghbar_widget.with_fill( + ghbar_widget.HBarGradient([style.Rgb(0, 0, 255), style.Rgb(255, 0, 0)]), + ) + ghbar_widget.render(buf, area, h, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn hbar_animated_rainbow_test() { + let area = rect_new(0, 0, 30, 3) + let buf = buffer.buffer_new(area) + let h = + ghbar_widget.hbar_new([ghbar_widget.item("x", 60)]) + |> ghbar_widget.with_fill(ghbar_widget.HBarAnimatedRainbow) + |> ghbar_widget.with_period(60) + ghbar_widget.render(buf, area, h, 30) + |> buffer.area + |> should.equal(area) +} + +pub fn hbar_with_max_test() { + let area = rect_new(0, 0, 25, 2) + let buf = buffer.buffer_new(area) + let h = + ghbar_widget.hbar_new([ghbar_widget.item("a", 50)]) + |> ghbar_widget.with_max(200) + ghbar_widget.render(buf, area, h, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn hbar_show_value_off_test() { + let area = rect_new(0, 0, 25, 2) + let buf = buffer.buffer_new(area) + let h = + ghbar_widget.hbar_new([ghbar_widget.item("a", 50)]) + |> ghbar_widget.with_show_value(False) + ghbar_widget.render(buf, area, h, 0) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Canvas widget tests + +pub fn canvas_render_area_test() { + let area = rect_new(0, 0, 20, 4) + let buf = buffer.buffer_new(area) + let c = + gcanvas_widget.canvas_new([ + gcanvas_widget.series_new([10, 50, 30, 70, 20]), + ]) + gcanvas_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn canvas_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let c = gcanvas_widget.canvas_new([gcanvas_widget.series_new([10, 20])]) + let result = gcanvas_widget.render(buf, area, c, 0) + buffer.width(result) |> should.equal(0) +} + +pub fn canvas_empty_series_test() { + let area = rect_new(0, 0, 10, 4) + let buf = buffer.buffer_new(area) + let c = gcanvas_widget.canvas_new([]) + gcanvas_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn canvas_single_point_test() { + let area = rect_new(0, 0, 10, 4) + let buf = buffer.buffer_new(area) + let c = gcanvas_widget.canvas_new([gcanvas_widget.series_new([50])]) + gcanvas_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn canvas_solid_fill_test() { + let area = rect_new(0, 0, 15, 4) + let buf = buffer.buffer_new(area) + let c = + gcanvas_widget.canvas_new([ + gcanvas_widget.series_new([0, 50, 100]) + |> gcanvas_widget.with_series_fill( + gcanvas_widget.SeriesSolid(style.Rgb(0, 200, 255)), + ), + ]) + gcanvas_widget.render(buf, area, c, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn canvas_animated_rainbow_test() { + let area = rect_new(0, 0, 15, 4) + let buf = buffer.buffer_new(area) + let c = + gcanvas_widget.canvas_new([ + gcanvas_widget.series_new([10, 90, 30, 70]) + |> gcanvas_widget.with_series_fill(gcanvas_widget.SeriesAnimatedRainbow), + ]) + |> gcanvas_widget.with_period(60) + gcanvas_widget.render(buf, area, c, 30) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Scene widget tests + +pub fn scene_render_area_test() { + let area = rect_new(0, 0, 20, 6) + let buf = buffer.buffer_new(area) + let s = + gscene_widget.scene_new([ + gscene_widget.CircleOutline( + 20, + 12, + 8, + gscene_widget.SceneSolid(style.Rgb(255, 255, 0)), + ), + ]) + gscene_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn scene_zero_area_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(area) + let s = gscene_widget.scene_new([]) + let result = gscene_widget.render(buf, area, s, 0) + buffer.width(result) |> should.equal(0) +} + +pub fn scene_empty_shapes_test() { + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let s = gscene_widget.scene_new([]) + gscene_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn scene_disc_test() { + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let s = + gscene_widget.scene_new([ + gscene_widget.Disc( + 20, + 10, + 4, + gscene_widget.SceneSolid(style.Rgb(255, 100, 0)), + ), + ]) + gscene_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn scene_planet_test() { + let area = rect_new(0, 0, 20, 5) + let buf = buffer.buffer_new(area) + let s = + gscene_widget.scene_new([ + gscene_widget.Planet( + 20, + 10, + 8, + 2, + gscene_widget.SceneSolid(style.Rgb(60, 140, 255)), + 50, + ), + ]) + gscene_widget.render(buf, area, s, 25) + |> buffer.area + |> should.equal(area) +} + +pub fn scene_mandelbrot_test() { + let area = rect_new(0, 0, 12, 4) + let buf = buffer.buffer_new(area) + let s = gscene_widget.scene_new([gscene_widget.Mandelbrot(12)]) + gscene_widget.render(buf, area, s, 0) + |> buffer.area + |> should.equal(area) +} + +pub fn scene_rainbow_circle_test() { + let area = rect_new(0, 0, 15, 5) + let buf = buffer.buffer_new(area) + let s = + gscene_widget.scene_new([ + gscene_widget.CircleOutline(15, 10, 6, gscene_widget.SceneAnimatedRainbow), + ]) + gscene_widget.render(buf, area, s, 30) + |> buffer.area + |> should.equal(area) +} + +// ───────────────────────────────────────────────────────────────── +// Buffer diff tests + +pub fn diff_identical_buffers_test() { + let area = rect_new(0, 0, 4, 2) + let buf = buffer.buffer_new(area) + buffer.diff(buf, buf) |> should.equal([]) +} + +pub fn diff_single_cell_change_test() { + let area = rect_new(0, 0, 4, 1) + let prev = buffer.buffer_new(area) + let next = + buffer.set_string( + prev, + Position(1, 0), + "X", + style.Default, + style.Default, + style.none(), + ) + let ops = buffer.diff(prev, next) + list.length(ops) |> should.equal(1) +} + +pub fn diff_returns_patch_at_change_position_test() { + let area = rect_new(0, 0, 6, 1) + let prev = buffer.buffer_new(area) + let next = + buffer.set_string( + prev, + Position(2, 0), + "AB", + style.Default, + style.Default, + style.none(), + ) + let ops = buffer.diff(prev, next) + case ops { + [buffer.Patch(pos, _), ..] -> pos.x |> should.equal(2) + _ -> should.fail() + } +} + +pub fn diff_multi_row_change_test() { + let area = rect_new(0, 0, 5, 3) + let prev = buffer.buffer_new(area) + let next = + prev + |> buffer.set_string( + Position(0, 0), + "row0", + style.Default, + style.Default, + style.none(), + ) + |> buffer.set_string( + Position(0, 2), + "row2", + style.Default, + style.Default, + style.none(), + ) + let ops = buffer.diff(prev, next) + // At least one patch per changed row + case list.length(ops) >= 2 { + True -> Nil + False -> should.fail() + } +} + +pub fn diff_same_text_no_op_test() { + let area = rect_new(0, 0, 8, 1) + let prev = + buffer.set_string( + buffer.buffer_new(area), + Position(0, 0), + "hi", + style.Default, + style.Default, + style.none(), + ) + let next = + buffer.set_string( + buffer.buffer_new(area), + Position(0, 0), + "hi", + style.Default, + style.Default, + style.none(), + ) + buffer.diff(prev, next) |> should.equal([]) +} + +// ───────────────────────────────────────────────────────────────── +// Cell-aware text padding tests + +pub fn pad_right_ascii_test() { + text.pad_right("ab", 5) |> should.equal("ab ") +} + +pub fn pad_right_no_pad_when_full_test() { + text.pad_right("abcde", 5) |> should.equal("abcde") +} + +pub fn pad_right_no_pad_when_overflow_test() { + text.pad_right("abcdef", 5) |> should.equal("abcdef") +} + +pub fn pad_left_ascii_test() { + text.pad_left("ab", 5) |> should.equal(" ab") +} + +pub fn align_center_ascii_test() { + text.align("ab", 6, text.Center) |> should.equal(" ab ") +} + +pub fn align_center_odd_pad_test() { + // 5 - 2 = 3 → 1 left, 2 right + text.align("ab", 5, text.Center) |> should.equal(" ab ") +} + +pub fn codepoint_width_ascii_test() { + text.codepoint_cell_width(0x41) |> should.equal(1) +} + +pub fn codepoint_width_cjk_test() { + text.codepoint_cell_width(0x4E2D) |> should.equal(2) +} + +pub fn codepoint_width_emoji_test() { + text.codepoint_cell_width(0x1F600) |> should.equal(2) +} + +pub fn codepoint_width_combining_test() { + text.codepoint_cell_width(0x0301) |> should.equal(0) +} + +pub fn codepoint_width_zwj_test() { + text.codepoint_cell_width(0x200D) |> should.equal(0) +} + +pub fn codepoint_width_control_test() { + text.codepoint_cell_width(0x07) |> should.equal(0) +} + +// strip_ansi extended tests + +pub fn strip_ansi_csi_cursor_test() { + text.strip_ansi("hi\u{001B}[2Athere") |> should.equal("hithere") +} + +pub fn strip_ansi_osc_bel_test() { + text.strip_ansi("a\u{001B}]0;title\u{0007}b") |> should.equal("ab") +} + +pub fn strip_ansi_osc_st_test() { + text.strip_ansi("a\u{001B}]0;title\u{001B}\\b") |> should.equal("ab") +} + +// ───────────────────────────────────────────────────────────────── +// geometry.hit_test + +pub fn hit_test_inside_test() { + geometry.hit_test(rect_new(2, 3, 5, 4), 4, 5) |> should.equal(True) +} + +pub fn hit_test_on_left_edge_test() { + geometry.hit_test(rect_new(2, 3, 5, 4), 2, 3) |> should.equal(True) +} + +pub fn hit_test_outside_right_test() { + geometry.hit_test(rect_new(2, 3, 5, 4), 7, 3) |> should.equal(False) +} + +pub fn hit_test_outside_bottom_test() { + geometry.hit_test(rect_new(2, 3, 5, 4), 2, 7) |> should.equal(False) +} + +// ───────────────────────────────────────────────────────────────── +// text.wrap blank-line fix + +pub fn wrap_blank_line_between_paragraphs_test() { + text.wrap("a\n\nb", 80) |> should.equal(["a", "", "b"]) +} + +pub fn wrap_leading_newline_test() { + text.wrap("\na", 80) |> should.equal(["", "a"]) +} + +pub fn wrap_trailing_newline_test() { + text.wrap("a\n", 80) |> should.equal(["a", ""]) +} + +// ───────────────────────────────────────────────────────────────── +// buffer.to_ansi / diff_to_ansi + +pub fn to_ansi_nonempty_test() { + let area = rect_new(0, 0, 3, 1) + let buf = + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "abc", + style.Default, + style.Default, + style.none(), + ) + let result = buffer.to_ansi(buf) + result |> string.contains("abc") |> should.equal(True) +} + +pub fn diff_to_ansi_identical_buffers_test() { + let area = rect_new(0, 0, 3, 1) + let buf = + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "abc", + style.Default, + style.Default, + style.none(), + ) + buffer.diff_to_ansi(buf, buf) |> should.equal("") +} + +pub fn diff_to_ansi_single_change_test() { + let area = rect_new(0, 0, 3, 1) + let prev = + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "abc", + style.Default, + style.Default, + style.none(), + ) + let curr = + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "axc", + style.Default, + style.Default, + style.none(), + ) + let result = buffer.diff_to_ansi(prev, curr) + result |> string.contains("x") |> should.equal(True) + result |> string.contains("a") |> should.equal(False) +} + +// ───────────────────────────────────────────────────────────────── +// table col_constraints + +pub fn table_col_constraints_resolve_test() { + let area = rect_new(0, 0, 40, 5) + let t = + gtable_widget.table_new([["Alice", "30"], ["Bob", "25"]]) + |> gtable_widget.with_col_constraints([ + geometry.Fill, + geometry.Length(6), + ]) + let buf = buffer.buffer_new(area) + let result = gtable_widget.render(buf, area, t) + buffer.width(result) |> should.equal(40) +} + +// ───────────────────────────────────────────────────────────────── +// keys.match + +pub fn keys_match_arrows_test() { + keys.match("up") |> should.equal(keys.Up) + keys.match("down") |> should.equal(keys.Down) + keys.match("left") |> should.equal(keys.Left) + keys.match("right") |> should.equal(keys.Right) +} + +pub fn keys_match_control_keys_test() { + keys.match("enter") |> should.equal(keys.Enter) + keys.match("backspace") |> should.equal(keys.Backspace) + keys.match("esc") |> should.equal(keys.Escape) + keys.match("tab") |> should.equal(keys.Tab) +} + +pub fn keys_match_function_keys_test() { + keys.match("f1") |> should.equal(keys.F(1)) + keys.match("f12") |> should.equal(keys.F(12)) +} + +pub fn keys_match_ctrl_combo_test() { + keys.match("ctrl+c") |> should.equal(keys.Ctrl("c")) + keys.match("ctrl+d") |> should.equal(keys.Ctrl("d")) +} + +pub fn keys_match_alt_combo_test() { + keys.match("alt+f") |> should.equal(keys.Alt("f")) +} + +pub fn keys_match_char_test() { + keys.match("a") |> should.equal(keys.Char("a")) + keys.match("€") |> should.equal(keys.Char("€")) +} + +pub fn keys_is_char_test() { + keys.is_char(keys.Char("a")) |> should.equal(True) + keys.is_char(keys.Up) |> should.equal(False) +} + +pub fn keys_char_value_test() { + keys.char_value(keys.Char("x")) |> should.equal("x") + keys.char_value(keys.Enter) |> should.equal("") +} + +// ───────────────────────────────────────────────────────────────── +// style additions + +pub fn style_add_modifier_test() { + let s = style.default_style() |> style.add_modifier(style.bold()) + style.has(s.modifier, style.bold()) |> should.equal(True) + style.has(s.modifier, style.italic()) |> should.equal(False) +} + +pub fn style_remove_modifier_test() { + let s = + style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.add(style.bold(), style.italic()), + ) + |> style.remove_modifier(style.bold()) + style.has(s.modifier, style.bold()) |> should.equal(False) + style.has(s.modifier, style.italic()) |> should.equal(True) +} + +pub fn style_italic_dim_underline_style_test() { + style.has(style.italic_style().modifier, style.italic()) |> should.equal(True) + style.has(style.dim_style().modifier, style.dim()) |> should.equal(True) + style.has(style.underline_style().modifier, style.underline()) + |> should.equal(True) +} + +pub fn style_color_from_hex_test() { + style.color_from_hex("#1e1e2e") |> should.equal(Ok(style.Rgb(30, 30, 46))) + style.color_from_hex("ff5555") |> should.equal(Ok(style.Rgb(255, 85, 85))) + style.color_from_hex("FFFFFF") |> should.equal(Ok(style.Rgb(255, 255, 255))) + style.color_from_hex("000000") |> should.equal(Ok(style.Rgb(0, 0, 0))) +} + +pub fn style_color_from_hex_invalid_test() { + style.color_from_hex("xyz") |> should.equal(Error(Nil)) + style.color_from_hex("#12345") |> should.equal(Error(Nil)) + style.color_from_hex("") |> should.equal(Error(Nil)) +} + +// ───────────────────────────────────────────────────────────────── +// geometry additions + +pub fn geometry_split_h_v_test() { + let area = rect_new(0, 0, 100, 20) + let cols = geometry.split_h(area, [geometry.Fill, geometry.Fill]) + list.length(cols) |> should.equal(2) + let col0 = case cols { + [c, ..] -> c + _ -> rect_new(0, 0, 0, 0) + } + col0.size.width |> should.equal(50) + + let rows = geometry.split_v(area, [geometry.Length(5), geometry.Fill]) + let row0 = case rows { + [r, ..] -> r + _ -> rect_new(0, 0, 0, 0) + } + row0.size.height |> should.equal(5) +} + +pub fn geometry_centered_rect_test() { + let area = rect_new(0, 0, 80, 24) + let r = geometry.centered_rect(40, 10, area) + r.size.width |> should.equal(40) + r.size.height |> should.equal(10) + r.position.x |> should.equal(20) + r.position.y |> should.equal(7) +} + +pub fn geometry_centered_rect_clamps_test() { + let area = rect_new(0, 0, 10, 5) + let r = geometry.centered_rect(200, 200, area) + r.size.width |> should.equal(10) + r.size.height |> should.equal(5) +} + +pub fn geometry_percent_rect_test() { + let area = rect_new(0, 0, 100, 40) + let r = geometry.percent_rect(50, 50, area) + r.size.width |> should.equal(50) + r.size.height |> should.equal(20) +} + +// ───────────────────────────────────────────────────────────────── +// span additions + +pub fn span_bold_italic_dim_test() { + style.has(span.span_bold("x").modifier, style.bold()) |> should.equal(True) + style.has(span.span_italic("x").modifier, style.italic()) + |> should.equal(True) + style.has(span.span_dim("x").modifier, style.dim()) |> should.equal(True) + style.has(span.span_underline("x").modifier, style.underline()) + |> should.equal(True) +} + +pub fn span_line_alignment_left_test() { + let l = span.line_new([span.span_plain("hi")]) + l.alignment |> should.equal(text.Left) +} + +pub fn span_line_aligned_center_test() { + let l = span.line_aligned([span.span_plain("hi")], text.Center) + l.alignment |> should.equal(text.Center) +} + +pub fn span_line_render_right_aligned_test() { + let area = rect_new(0, 0, 10, 1) + let buf = buffer.buffer_new(area) + let l = span.line_aligned([span.span_plain("AB")], text.Right) + let result = span.render_line(buf, geometry.Position(x: 0, y: 0), l, 10) + buffer.cell_symbol(buffer.get_cell(result, geometry.Position(x: 8, y: 0))) + |> should.equal("A") + buffer.cell_symbol(buffer.get_cell(result, geometry.Position(x: 9, y: 0))) + |> should.equal("B") +} + +// ───────────────────────────────────────────────────────────────── +// focus + +pub fn focus_new_first_focused_test() { + let ring = focus.focus_new(["a", "b", "c"]) + focus.focused(ring) |> should.equal(Ok("a")) +} + +pub fn focus_next_advances_test() { + let ring = focus.focus_new(["a", "b", "c"]) |> focus.focus_next + focus.focused(ring) |> should.equal(Ok("b")) +} + +pub fn focus_next_wraps_test() { + let ring = + focus.focus_new(["a", "b", "c"]) + |> focus.focus_next + |> focus.focus_next + |> focus.focus_next + focus.focused(ring) |> should.equal(Ok("a")) +} + +pub fn focus_prev_wraps_test() { + let ring = focus.focus_new(["a", "b", "c"]) |> focus.focus_prev + focus.focused(ring) |> should.equal(Ok("c")) +} + +pub fn focus_is_focused_test() { + let ring = focus.focus_new(["a", "b"]) + focus.is_focused(ring, "a") |> should.equal(True) + focus.is_focused(ring, "b") |> should.equal(False) +} + +pub fn focus_id_jump_test() { + let ring = focus.focus_new(["a", "b", "c"]) |> focus.focus_id("c") + focus.focused(ring) |> should.equal(Ok("c")) +} + +pub fn focus_id_not_found_noop_test() { + let ring = focus.focus_new(["a", "b"]) |> focus.focus_id("z") + focus.focused(ring) |> should.equal(Ok("a")) +} + +pub fn focus_empty_ring_test() { + let ring = focus.focus_new([]) + focus.focused(ring) |> should.equal(Error(Nil)) + focus.is_focused(ring, "x") |> should.equal(False) + focus.focus_next(ring) |> focus.focused |> should.equal(Error(Nil)) +} + +pub fn focus_size_test() { + focus.size(focus.focus_new(["a", "b", "c"])) |> should.equal(3) + focus.size(focus.focus_new([])) |> should.equal(0) +} + +pub fn focus_index_test() { + let ring = focus.focus_new(["a", "b", "c"]) |> focus.focus_index(2) + focus.focused(ring) |> should.equal(Ok("c")) + focus.current_index(ring) |> should.equal(2) +} + +// ───────────────────────────────────────────────────────────────── +// textarea + +pub fn textarea_state_new_test() { + let s = textarea.state_new() + textarea.value(s) |> should.equal("") + textarea.line_count(s) |> should.equal(1) +} + +pub fn textarea_insert_char_test() { + let w = textarea.textarea_new() + let s = + textarea.state_new() + |> textarea.insert_char(w, _, "h") + |> textarea.insert_char(w, _, "i") + textarea.value(s) |> should.equal("hi") + s.cursor_x |> should.equal(2) +} + +pub fn textarea_backspace_removes_char_test() { + let w = textarea.textarea_new() + let s = + textarea.state_new() + |> textarea.insert_char(w, _, "a") + |> textarea.insert_char(w, _, "b") + |> textarea.backspace + textarea.value(s) |> should.equal("a") +} + +pub fn textarea_backspace_at_start_noop_test() { + let s = textarea.state_new() |> textarea.backspace + textarea.value(s) |> should.equal("") +} + +pub fn textarea_newline_splits_line_test() { + let w = textarea.textarea_new() + let s = + textarea.state_new() + |> textarea.insert_char(w, _, "a") + |> textarea.insert_char(w, _, "b") + |> textarea.newline(w, _) + |> textarea.insert_char(w, _, "c") + textarea.value(s) |> should.equal("ab\nc") + textarea.line_count(s) |> should.equal(2) +} + +pub fn textarea_backspace_merges_lines_test() { + let w = textarea.textarea_new() + let s = + textarea.state_new() + |> textarea.insert_char(w, _, "a") + |> textarea.newline(w, _) + |> textarea.backspace + textarea.value(s) |> should.equal("a") + textarea.line_count(s) |> should.equal(1) +} + +pub fn textarea_state_from_string_test() { + let s = textarea.state_from_string("hello\nworld") + textarea.value(s) |> should.equal("hello\nworld") + textarea.line_count(s) |> should.equal(2) + s.cursor_y |> should.equal(1) +} + +pub fn textarea_move_cursor_up_down_test() { + let w = textarea.textarea_new() + let s = + textarea.state_new() + |> textarea.insert_char(w, _, "a") + |> textarea.newline(w, _) + |> textarea.insert_char(w, _, "b") + let s2 = textarea.move_cursor_up(s) + s2.cursor_y |> should.equal(0) + let s3 = textarea.move_cursor_down(s2) + s3.cursor_y |> should.equal(1) +} + +pub fn textarea_max_lines_limit_test() { + let w = textarea.textarea_new() |> textarea.with_max_lines(2) + let s = + textarea.state_new() + |> textarea.newline(w, _) + |> textarea.newline(w, _) + textarea.line_count(s) |> should.equal(2) +} + +pub fn textarea_move_to_line_start_end_test() { + let w = textarea.textarea_new() + let s = + textarea.state_new() + |> textarea.insert_char(w, _, "a") + |> textarea.insert_char(w, _, "b") + |> textarea.move_to_line_start + s.cursor_x |> should.equal(0) + let s2 = textarea.move_to_line_end(s) + s2.cursor_x |> should.equal(2) +} + +// ───────────────────────────────────────────────────────────────── +// tree + +pub fn tree_state_new_test() { + let state = tree.state_new() + tree.selected(state) |> should.equal(Error(Nil)) +} + +pub fn tree_state_from_tree_selects_first_test() { + let t = tree.tree_new([tree.leaf("a", "A"), tree.leaf("b", "B")]) + let state = tree.state_from_tree(t) + tree.selected(state) |> should.equal(Ok("a")) +} + +pub fn tree_expand_collapse_test() { + let state = tree.state_new() + let s2 = tree.expand("src", state) + tree.is_expanded(s2, "src") |> should.equal(True) + let s3 = tree.collapse("src", s2) + tree.is_expanded(s3, "src") |> should.equal(False) +} + +pub fn tree_expand_idempotent_test() { + let s = + tree.state_new() + |> tree.expand("src", _) + |> tree.expand("src", _) + list.length(s.expanded) |> should.equal(1) +} + +pub fn tree_toggle_selected_test() { + let t = + tree.tree_new([ + tree.node("src", "src/", [tree.leaf("main", "main.gleam")]), + ]) + let state = tree.state_from_tree(t) + tree.is_expanded(state, "src") |> should.equal(False) + let s2 = tree.toggle_selected(state, t) + tree.is_expanded(s2, "src") |> should.equal(True) + let s3 = tree.toggle_selected(s2, t) + tree.is_expanded(s3, "src") |> should.equal(False) +} + +pub fn tree_toggle_leaf_noop_test() { + let t = tree.tree_new([tree.leaf("readme", "README.md")]) + let state = tree.TreeState(expanded: [], selected: "readme") + let s2 = tree.toggle_selected(state, t) + tree.is_expanded(s2, "readme") |> should.equal(False) +} + +pub fn tree_select_next_test() { + let t = + tree.tree_new([ + tree.leaf("a", "A"), + tree.leaf("b", "B"), + tree.leaf("c", "C"), + ]) + let state = tree.TreeState(expanded: [], selected: "a") + let s2 = tree.select_next(state, t) + tree.selected(s2) |> should.equal(Ok("b")) +} + +pub fn tree_select_prev_test() { + let t = + tree.tree_new([ + tree.leaf("a", "A"), + tree.leaf("b", "B"), + ]) + let state = tree.TreeState(expanded: [], selected: "b") + let s2 = tree.select_prev(state, t) + tree.selected(s2) |> should.equal(Ok("a")) +} + +pub fn tree_select_next_at_end_noop_test() { + let t = tree.tree_new([tree.leaf("a", "A"), tree.leaf("b", "B")]) + let state = tree.TreeState(expanded: [], selected: "b") + let s2 = tree.select_next(state, t) + tree.selected(s2) |> should.equal(Ok("b")) +} + +pub fn tree_expanded_children_visible_in_nav_test() { + let t = + tree.tree_new([ + tree.node("src", "src/", [tree.leaf("main", "main.gleam")]), + tree.leaf("readme", "README.md"), + ]) + let state = + tree.TreeState(expanded: ["src"], selected: "src") + |> tree.select_next(t) + tree.selected(state) |> should.equal(Ok("main")) +} + +// ───────────────────────────────────────────────────────────────── +// scroll_view + +pub fn scroll_view_state_new_test() { + let s = scroll_view.sv_state_new() + s.scroll_x |> should.equal(0) + s.scroll_y |> should.equal(0) +} + +pub fn scroll_view_scroll_down_test() { + let s = scroll_view.sv_state_new() |> scroll_view.scroll_down(5) + s.scroll_y |> should.equal(5) +} + +pub fn scroll_view_scroll_up_clamps_test() { + let s = + scroll_view.sv_state_new() + |> scroll_view.scroll_down(3) + |> scroll_view.scroll_up(10) + s.scroll_y |> should.equal(0) +} + +pub fn scroll_view_scroll_right_left_test() { + let s = + scroll_view.sv_state_new() + |> scroll_view.scroll_right(4) + |> scroll_view.scroll_left(2) + s.scroll_x |> should.equal(2) +} + +pub fn scroll_view_clamp_test() { + let sv = scroll_view.scroll_view_new(20, 20) + let s = scroll_view.scroll_to(scroll_view.sv_state_new(), 100, 100) + let clamped = scroll_view.clamp(s, sv, 10, 10) + clamped.scroll_x |> should.equal(10) + clamped.scroll_y |> should.equal(10) +} + +pub fn scroll_view_scroll_to_test() { + let s = scroll_view.scroll_to(scroll_view.sv_state_new(), 7, 3) + s.scroll_x |> should.equal(7) + s.scroll_y |> should.equal(3) +} + +pub fn scroll_view_pct_y_test() { + let sv = scroll_view.scroll_view_new(100, 100) + let s = scroll_view.scroll_to(scroll_view.sv_state_new(), 0, 50) + scroll_view.scroll_pct_y(s, sv, 10) |> should.equal(55) +} + +pub fn scroll_view_pct_x_test() { + let sv = scroll_view.scroll_view_new(100, 100) + let s = scroll_view.scroll_to(scroll_view.sv_state_new(), 0, 0) + scroll_view.scroll_pct_x(s, sv, 100) |> should.equal(0) +} + +pub fn scroll_view_render_blits_content_test() { + let area = rect_new(0, 0, 5, 3) + let buf = buffer.buffer_new(area) + let sv = scroll_view.scroll_view_new(10, 5) + let state = scroll_view.sv_state_new() + let result = + scroll_view.render(buf, area, sv, state, fn(inner_buf, _inner_area) { + buffer.set_string( + inner_buf, + geometry.Position(x: 0, y: 0), + "Hello", + style.Default, + style.Default, + style.none(), + ) + |> buffer.set_string( + geometry.Position(x: 0, y: 1), + "World", + style.Default, + style.Default, + style.none(), + ) + }) + buffer.width(result) |> should.equal(5) + buffer.height(result) |> should.equal(3) +} + +// ───────────────────────────────────────────────────────────────── +// UndoStack tests + +pub fn undo_new_has_initial_test() { + let s = undo.undo_new("hello", max_size: 10) + undo.current(s) |> should.equal("hello") +} + +pub fn undo_can_undo_false_initially_test() { + let s = undo.undo_new(0, max_size: 10) + undo.can_undo(s) |> should.equal(False) +} + +pub fn undo_can_undo_after_push_test() { + let s = undo.undo_new(0, max_size: 10) |> undo.push(1) + undo.can_undo(s) |> should.equal(True) +} + +pub fn undo_undo_restores_previous_test() { + let s = + undo.undo_new(0, max_size: 10) + |> undo.push(1) + |> undo.push(2) + |> undo.undo + undo.current(s) |> should.equal(1) +} + +pub fn undo_redo_restores_future_test() { + let s = + undo.undo_new(0, max_size: 10) + |> undo.push(1) + |> undo.push(2) + |> undo.undo + |> undo.redo + undo.current(s) |> should.equal(2) +} + +pub fn undo_push_clears_future_test() { + let s = + undo.undo_new(0, max_size: 10) + |> undo.push(1) + |> undo.undo + |> undo.push(2) + undo.can_redo(s) |> should.equal(False) + undo.current(s) |> should.equal(2) +} + +pub fn undo_noop_on_empty_past_test() { + let s = undo.undo_new(42, max_size: 10) |> undo.undo + undo.current(s) |> should.equal(42) +} + +pub fn undo_noop_on_empty_future_test() { + let s = undo.undo_new(42, max_size: 10) |> undo.redo + undo.current(s) |> should.equal(42) +} + +pub fn undo_max_size_trims_past_test() { + let s = + undo.undo_new(0, max_size: 2) + |> undo.push(1) + |> undo.push(2) + |> undo.push(3) + undo.undo_depth(s) |> should.equal(2) +} + +pub fn undo_reset_clears_all_test() { + let s = + undo.undo_new(0, max_size: 10) + |> undo.push(1) + |> undo.push(2) + |> undo.reset(99) + undo.current(s) |> should.equal(99) + undo.can_undo(s) |> should.equal(False) + undo.can_redo(s) |> should.equal(False) +} + +// ───────────────────────────────────────────────────────────────── +// Keymap tests + +pub fn keymap_lookup_found_test() { + let km = + keymap.keymap_new() + |> keymap.bind("ctrl+q", "quit", "Quit") + |> keymap.bind("ctrl+s", "save", "Save") + keymap.lookup(km, "ctrl+q") |> should.equal(Ok("quit")) +} + +pub fn keymap_lookup_not_found_test() { + let km = keymap.keymap_new() |> keymap.bind("ctrl+q", "quit", "Quit") + keymap.lookup(km, "ctrl+x") |> should.equal(Error(Nil)) +} + +pub fn keymap_first_binding_wins_test() { + let km = + keymap.keymap_new() + |> keymap.bind("a", "first", "First") + |> keymap.bind("a", "second", "Second") + keymap.lookup(km, "a") |> should.equal(Ok("first")) +} + +pub fn keymap_unbind_removes_key_test() { + let km = + keymap.keymap_new() + |> keymap.bind("ctrl+q", "quit", "Quit") + |> keymap.unbind("ctrl+q") + keymap.lookup(km, "ctrl+q") |> should.equal(Error(Nil)) +} + +pub fn keymap_merge_combines_bindings_test() { + let km1 = keymap.keymap_new() |> keymap.bind("a", "aa", "A") + let km2 = keymap.keymap_new() |> keymap.bind("b", "bb", "B") + let merged = keymap.merge(km1, km2) + keymap.lookup(merged, "a") |> should.equal(Ok("aa")) + keymap.lookup(merged, "b") |> should.equal(Ok("bb")) +} + +pub fn keymap_help_lines_order_test() { + let km = + keymap.keymap_new() + |> keymap.bind("a", 1, "Alpha") + |> keymap.bind("b", 2, "Beta") + let lines = keymap.help_lines(km) + lines |> should.equal([#("a", "Alpha"), #("b", "Beta")]) +} + +pub fn keymap_filter_by_description_test() { + let km = + keymap.keymap_new() + |> keymap.bind("ctrl+q", "quit", "Quit application") + |> keymap.bind("ctrl+s", "save", "Save file") + |> keymap.bind("ctrl+o", "open", "Open file") + let filtered = keymap.filter(km, "file") + list.length(keymap.help_lines(filtered)) |> should.equal(2) +} + +pub fn keymap_filter_empty_returns_all_test() { + let km = + keymap.keymap_new() + |> keymap.bind("a", 1, "Alpha") + |> keymap.bind("b", 2, "Beta") + let filtered = keymap.filter(km, "") + list.length(keymap.help_lines(filtered)) |> should.equal(2) +} + +// ───────────────────────────────────────────────────────────────── +// Dialog tests + +pub fn dialog_state_initial_focus_confirm_test() { + dialog.state_new().focused |> should.equal(dialog.Confirm) +} + +pub fn dialog_toggle_confirm_to_cancel_test() { + dialog.state_new() + |> dialog.toggle + |> should.equal(dialog.DialogState(focused: dialog.Cancel)) +} + +pub fn dialog_toggle_cancel_to_confirm_test() { + dialog.state_new() + |> dialog.toggle + |> dialog.toggle + |> should.equal(dialog.DialogState(focused: dialog.Confirm)) +} + +pub fn dialog_is_confirmed_true_when_confirm_focused_test() { + dialog.state_new() |> dialog.is_confirmed |> should.equal(True) +} + +pub fn dialog_is_confirmed_false_after_cancel_test() { + dialog.state_new() + |> dialog.cancel + |> dialog.is_confirmed + |> should.equal(False) +} + +pub fn dialog_focus_confirm_sets_confirm_test() { + dialog.state_new() + |> dialog.cancel + |> dialog.focus_confirm + |> dialog.is_confirmed + |> should.equal(True) +} + +pub fn dialog_render_no_crash_test() { + let area = rect_new(0, 0, 40, 10) + let buf = buffer.buffer_new(area) + let d = dialog.dialog_new("Delete?") + let state = dialog.state_new() + let result = dialog.render(buf, area, d, state) + buffer.width(result) |> should.equal(40) +} + +pub fn dialog_render_small_area_no_crash_test() { + let area = rect_new(0, 0, 5, 2) + let buf = buffer.buffer_new(area) + let d = dialog.dialog_new("?") + let result = dialog.render(buf, area, d, dialog.state_new()) + buffer.width(result) |> should.equal(5) +} + +// ───────────────────────────────────────────────────────────────── +// Notification tests + +pub fn notif_push_adds_item_test() { + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.info("hello", ttl: 10)) + gnotif_widget.count(q) |> should.equal(1) +} + +pub fn notif_push_respects_max_test() { + let q = + gnotif_widget.queue_new(max: 2) + |> gnotif_widget.push(gnotif_widget.info("a", ttl: 10)) + |> gnotif_widget.push(gnotif_widget.info("b", ttl: 10)) + |> gnotif_widget.push(gnotif_widget.info("c", ttl: 10)) + gnotif_widget.count(q) |> should.equal(2) +} + +pub fn notif_tick_decrements_ttl_test() { + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.info("hi", ttl: 3)) + |> gnotif_widget.tick + |> gnotif_widget.tick + gnotif_widget.count(q) |> should.equal(1) +} + +pub fn notif_tick_removes_expired_test() { + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.info("bye", ttl: 1)) + |> gnotif_widget.tick + gnotif_widget.count(q) |> should.equal(0) +} + +pub fn notif_persistent_never_expires_test() { + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.persistent("stay", gnotif_widget.Info)) + |> gnotif_widget.tick + |> gnotif_widget.tick + |> gnotif_widget.tick + gnotif_widget.count(q) |> should.equal(1) +} + +pub fn notif_dismiss_all_clears_test() { + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.info("a", ttl: 10)) + |> gnotif_widget.push(gnotif_widget.error("b", ttl: -1)) + |> gnotif_widget.dismiss_all + gnotif_widget.count(q) |> should.equal(0) +} + +pub fn notif_dismiss_level_test() { + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.info("a", ttl: 10)) + |> gnotif_widget.push(gnotif_widget.error("b", ttl: -1)) + |> gnotif_widget.dismiss_level(gnotif_widget.Error) + gnotif_widget.count(q) |> should.equal(1) +} + +pub fn notif_has_notifications_test() { + let q = gnotif_widget.queue_new(max: 5) + gnotif_widget.has_notifications(q) |> should.equal(False) + let q2 = q |> gnotif_widget.push(gnotif_widget.info("x", ttl: 5)) + gnotif_widget.has_notifications(q2) |> should.equal(True) +} + +pub fn notif_render_no_crash_test() { + let area = rect_new(0, 0, 80, 24) + let buf = buffer.buffer_new(area) + let q = + gnotif_widget.queue_new(max: 5) + |> gnotif_widget.push(gnotif_widget.success("Saved!", ttl: 30)) + |> gnotif_widget.push(gnotif_widget.warning("Low disk", ttl: 30)) + let result = gnotif_widget.render(buf, area, q) + buffer.width(result) |> should.equal(80) +} + +// ───────────────────────────────────────────────────────────────── +// Form tests + +pub fn form_initial_empty_test() { + let f = gform_widget.form_new() + gform_widget.values(f) |> should.equal([]) +} + +pub fn form_add_field_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("name", "Name", "") + gform_widget.get_value(f, "name") |> should.equal("") +} + +pub fn form_type_char_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("name", "Name", "") + |> gform_widget.type_char("H") + |> gform_widget.type_char("i") + gform_widget.get_value(f, "name") |> should.equal("Hi") +} + +pub fn form_backspace_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("name", "Name", "") + |> gform_widget.type_char("H") + |> gform_widget.type_char("i") + |> gform_widget.backspace + gform_widget.get_value(f, "name") |> should.equal("H") +} + +pub fn form_focus_next_wraps_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("a", "A", "") + |> gform_widget.add_optional("b", "B", "") + |> gform_widget.focus_next + |> gform_widget.focus_next + f.focused |> should.equal(0) +} + +pub fn form_focus_prev_wraps_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("a", "A", "") + |> gform_widget.add_optional("b", "B", "") + |> gform_widget.focus_prev + f.focused |> should.equal(1) +} + +pub fn form_is_valid_optional_fields_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("a", "A", "") + gform_widget.is_valid(f) |> should.equal(True) +} + +pub fn form_is_valid_required_empty_false_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_required("a", "A", "") + gform_widget.is_valid(f) |> should.equal(False) +} + +pub fn form_is_valid_required_filled_true_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_required("a", "A", "") + |> gform_widget.set_value("a", "hello") + gform_widget.is_valid(f) |> should.equal(True) +} + +pub fn form_submit_marks_submitted_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("a", "A", "value") + |> gform_widget.submit + gform_widget.is_submitted(f) |> should.equal(True) +} + +pub fn form_submit_invalid_not_submitted_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_required("a", "A", "") + |> gform_widget.submit + gform_widget.is_submitted(f) |> should.equal(False) +} + +pub fn form_validate_populates_errors_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_required("a", "A", "") + |> gform_widget.validate + case f.fields { + [field, ..] -> field.error |> should.equal("required") + [] -> should.fail() + } +} + +pub fn form_reset_clears_all_test() { + let f = + gform_widget.form_new() + |> gform_widget.add_optional("a", "A", "") + |> gform_widget.set_value("a", "hello") + |> gform_widget.submit + |> gform_widget.reset + gform_widget.get_value(f, "a") |> should.equal("") + gform_widget.is_submitted(f) |> should.equal(False) +} + +pub fn form_render_no_crash_test() { + let area = rect_new(0, 0, 40, 10) + let buf = buffer.buffer_new(area) + let f = + gform_widget.form_new() + |> gform_widget.add_required("name", "Name", "") + |> gform_widget.add_optional("email", "Email", "") + let result = gform_widget.render(buf, area, f) + buffer.width(result) |> should.equal(40) +} + +// ───────────────────────────────────────────────────────────────── +// split_responsive tests + +pub fn split_responsive_wide_picks_first_breakpoint_test() { + let area = rect_new(0, 0, 100, 10) + let rects = + geometry.split_responsive(area, [ + geometry.Breakpoint(80, [geometry.Percentage(50), geometry.Percentage(50)]), + geometry.Breakpoint(0, [geometry.Percentage(100)]), + ]) + list.length(rects) |> should.equal(2) +} + +pub fn split_responsive_narrow_picks_fallback_test() { + let area = rect_new(0, 0, 40, 10) + let rects = + geometry.split_responsive(area, [ + geometry.Breakpoint(80, [geometry.Percentage(50), geometry.Percentage(50)]), + geometry.Breakpoint(0, [geometry.Percentage(100)]), + ]) + list.length(rects) |> should.equal(1) +} + +pub fn split_responsive_empty_breakpoints_returns_area_test() { + let area = rect_new(0, 0, 80, 10) + let rects = geometry.split_responsive(area, []) + rects |> should.equal([area]) +} + +pub fn split_responsive_exact_boundary_test() { + let area = rect_new(0, 0, 80, 10) + let rects = + geometry.split_responsive(area, [ + geometry.Breakpoint(80, [geometry.Percentage(50), geometry.Percentage(50)]), + geometry.Breakpoint(0, [geometry.Percentage(100)]), + ]) + list.length(rects) |> should.equal(2) +} diff --git a/test/geometry_new_constraints_test.gleam b/test/geometry_new_constraints_test.gleam new file mode 100644 index 0000000..2b73376 --- /dev/null +++ b/test/geometry_new_constraints_test.gleam @@ -0,0 +1,172 @@ +/// Tests for new constraint types: Min, Max, Ratio. +/// Also tests split_with_spacing. +import etui/geometry.{ + Fill, Horizontal, Length, Max, Min, Percentage, Ratio, Vertical, rect_new, + resolve_sizes, split, split_with_spacing, +} +import gleeunit/should + +// ───────────────────────────────────────────────────────────────── +// Ratio + +pub fn ratio_one_third_test() { + resolve_sizes(90, [Ratio(1, 3), Fill]) + |> should.equal([30, 60]) +} + +pub fn ratio_two_thirds_test() { + resolve_sizes(90, [Ratio(2, 3), Fill]) + |> should.equal([60, 30]) +} + +pub fn ratio_half_half_test() { + resolve_sizes(100, [Ratio(1, 2), Ratio(1, 2)]) + |> should.equal([50, 50]) +} + +pub fn ratio_with_length_test() { + // Length(10) fixed, Ratio(1,2) of total=100 = 50, Fill gets 40 + resolve_sizes(100, [Length(10), Ratio(1, 2), Fill]) + |> should.equal([10, 50, 40]) +} + +pub fn ratio_zero_denominator_is_zero_test() { + resolve_sizes(100, [Ratio(1, 0), Fill]) + |> should.equal([0, 100]) +} + +pub fn ratio_overflow_scales_test() { + // Two Ratio(3,4) each want 75 from 100, total demand=150 > 100 + // Scales: each gets 50 + resolve_sizes(100, [Ratio(3, 4), Ratio(3, 4)]) + |> should.equal([50, 50]) +} + +// ───────────────────────────────────────────────────────────────── +// Min + +pub fn min_gets_base_share_test() { + // Two Min(20), flex_budget=100, base=50 ≥ 20, so both get 50 + resolve_sizes(100, [Min(20), Min(20)]) + |> should.equal([50, 50]) +} + +pub fn min_enforces_floor_test() { + // Length(80) used, flex_budget=20. Min(30): base=20 < 30, so gets 30. + resolve_sizes(100, [Length(80), Min(30)]) + |> should.equal([80, 30]) +} + +pub fn min_with_fill_test() { + // flex_budget=80 among Min(10) and Fill. base=40. Min gets max(10,40)=40. Fill gets 40. + resolve_sizes(100, [Length(20), Min(10), Fill]) + |> should.equal([20, 40, 40]) +} + +pub fn min_zero_acts_like_fill_test() { + resolve_sizes(100, [Min(0), Min(0)]) + |> should.equal([50, 50]) +} + +// ───────────────────────────────────────────────────────────────── +// Max + +pub fn max_caps_at_ceiling_test() { + // flex_budget=100, 2 flex slots, base=50. Max(30)→30. Fill gets remainder: 100-30=70. + resolve_sizes(100, [Max(30), Fill]) + |> should.equal([30, 70]) +} + +pub fn max_large_ceiling_acts_like_fill_test() { + // Max(200) — ceiling above base=50, acts like Fill + resolve_sizes(100, [Max(200), Max(200)]) + |> should.equal([50, 50]) +} + +pub fn max_with_length_test() { + // flex_budget=60, 2 flex slots, base=30. Max(20)→20. Fill gets 60-20=40. + resolve_sizes(100, [Length(40), Max(20), Fill]) + |> should.equal([40, 20, 40]) +} + +pub fn max_zero_gets_zero_test() { + // flex_budget=100, 2 flex slots, base=50. Max(0)→0. Fill gets 100-0=100. + resolve_sizes(100, [Max(0), Fill]) + |> should.equal([0, 100]) +} + +// ───────────────────────────────────────────────────────────────── +// Mixed new constraints + +pub fn min_max_fill_together_test() { + // flex_budget=90, 3 flex slots, base=30. Min(10)→30, Max(20)→20. Fill gets 90-30-20=40. + resolve_sizes(90, [Min(10), Max(20), Fill]) + |> should.equal([30, 20, 40]) +} + +pub fn ratio_and_fill_test() { + resolve_sizes(120, [Ratio(1, 4), Fill]) + |> should.equal([30, 90]) +} + +pub fn all_six_constraints_test() { + // Length(10), Percentage(20)→20, Ratio(1,4)→25 + // flex_budget=45, 3 flex slots, base=15 + // Min(5)→15, Max(10)→10. Fill gets 45-15-10=20. + resolve_sizes(100, [ + Length(10), + Percentage(20), + Ratio(1, 4), + Min(5), + Max(10), + Fill, + ]) + |> should.equal([10, 20, 25, 15, 10, 20]) +} + +// ───────────────────────────────────────────────────────────────── +// split_with_spacing + +pub fn spacing_two_cols_test() { + let area = rect_new(0, 0, 101, 1) + let cols = split_with_spacing(Horizontal, area, [Fill, Fill], 1) + case cols { + [a, b] -> { + a.size.width |> should.equal(50) + b.size.width |> should.equal(50) + a.position.x |> should.equal(0) + b.position.x |> should.equal(51) + } + _ -> should.fail() + } +} + +pub fn spacing_three_rows_test() { + let area = rect_new(0, 0, 1, 32) + let rows = split_with_spacing(Vertical, area, [Fill, Fill, Fill], 2) + case rows { + [a, b, c] -> { + // Total gaps = 2*2=4, available=28, 3 fills: 10+9+9=28 + a.size.height |> should.equal(10) + b.size.height |> should.equal(9) + c.size.height |> should.equal(9) + a.position.y |> should.equal(0) + b.position.y |> should.equal(12) + c.position.y |> should.equal(23) + } + _ -> should.fail() + } +} + +pub fn spacing_zero_same_as_split_test() { + let area = rect_new(0, 0, 100, 10) + let with_space = split_with_spacing(Horizontal, area, [Fill, Fill], 0) + let without = split(Horizontal, area, [Fill, Fill]) + with_space |> should.equal(without) +} + +pub fn spacing_single_constraint_no_gap_test() { + let area = rect_new(0, 0, 100, 10) + let r = split_with_spacing(Horizontal, area, [Fill], 5) + r |> should.equal([area]) +} diff --git a/test/geometry_property_test.gleam b/test/geometry_property_test.gleam new file mode 100644 index 0000000..78116ae --- /dev/null +++ b/test/geometry_property_test.gleam @@ -0,0 +1,192 @@ +// Property-based tests for etui/geometry.resolve_sizes. +// Hand-rolled mini quickcheck: deterministic seeds → reproducible CI. +// Properties from ARCHITECTURE.md §11.2. + +import etui/geometry +import gleam/list +import gleeunit/should + +// ─── PRNG ───────────────────────────────────────────────────────── +// Linear congruential generator. Always returns non-negative value. + +fn lcg(seed: Int) -> Int { + let r = { seed * 1_664_525 + 1_013_904_223 } % 2_147_483_647 + case r < 0 { + True -> r + 2_147_483_647 + False -> r + } +} + +// ─── Generators ──────────────────────────────────────────────────── + +fn gen_constraints(seed: Int, n: Int) -> #(List(geometry.Constraint), Int) { + gen_loop(seed, n, []) +} + +fn gen_loop( + seed: Int, + n: Int, + acc: List(geometry.Constraint), +) -> #(List(geometry.Constraint), Int) { + case n <= 0 { + True -> #(list.reverse(acc), seed) + False -> { + let s1 = lcg(seed) + let s2 = lcg(s1) + // s1 picks kind, s2 picks value; s2 advances the seed + let c = case s1 % 3 { + 0 -> geometry.Length(s2 % 80 + 1) + 1 -> geometry.Percentage(s2 % 100 + 1) + _ -> geometry.Fill + } + gen_loop(s2, n - 1, [c, ..acc]) + } + } +} + +// ─── Properties ──────────────────────────────────────────────────── + +fn sum_ints(xs: List(Int)) -> Int { + list.fold(xs, 0, fn(acc, x) { acc + x }) +} + +fn has_fill(cs: List(geometry.Constraint)) -> Bool { + list.any(cs, fn(c) { c == geometry.Fill }) +} + +fn cumsum(xs: List(Int)) -> List(Int) { + let #(_, rev) = + list.fold(xs, #(0, []), fn(st, x) { + let #(cur, acc) = st + #(cur + x, [cur + x, ..acc]) + }) + list.reverse(rev) +} + +fn monotone(a: List(Int), b: List(Int)) -> Bool { + case a, b { + [], [] -> True + [x, ..xs], [y, ..ys] -> y >= x && monotone(xs, ys) + _, _ -> True + } +} + +// Checks all four invariants from §11.2: +// len(result) == len(constraints) +// ∀ s ∈ result: s >= 0 +// sum(result) <= total +// (∃ Fill) → sum(result) == total +fn prop_invariants(total: Int, cs: List(geometry.Constraint)) -> Bool { + let sizes = geometry.resolve_sizes(total, cs) + let sum = sum_ints(sizes) + let fill_ok = case has_fill(cs) { + True -> sum == total + False -> True + } + list.length(sizes) == list.length(cs) + && list.all(sizes, fn(s) { s >= 0 }) + && sum <= total + && fill_ok +} + +// Monotonicity: cumsum(total) ≤ cumsum(total+1) pointwise. +fn prop_monotone(total: Int, cs: List(geometry.Constraint)) -> Bool { + monotone( + cumsum(geometry.resolve_sizes(total, cs)), + cumsum(geometry.resolve_sizes(total + 1, cs)), + ) +} + +// ─── Runner ──────────────────────────────────────────────────────── + +fn run( + seed: Int, + iters: Int, + max_total: Int, + max_n: Int, + prop: fn(Int, List(geometry.Constraint)) -> Bool, +) -> Bool { + run_loop(seed, iters, max_total, max_n, prop) +} + +fn run_loop( + seed: Int, + rem: Int, + max_total: Int, + max_n: Int, + prop: fn(Int, List(geometry.Constraint)) -> Bool, +) -> Bool { + case rem <= 0 { + True -> True + False -> { + let s1 = lcg(seed) + let total = s1 % { max_total + 1 } + let s2 = lcg(s1) + let n = s2 % max_n + 1 + let s3 = lcg(s2) + let #(cs, s4) = gen_constraints(s3, n) + case prop(total, cs) { + False -> False + True -> run_loop(s4, rem - 1, max_total, max_n, prop) + } + } + } +} + +// ─── Tests ───────────────────────────────────────────────────────── + +pub fn prop_basic_invariants_test() { + run(42, 500, 1000, 8, prop_invariants) + |> should.equal(True) +} + +pub fn prop_monotone_test() { + run(137, 500, 500, 6, prop_monotone) + |> should.equal(True) +} + +pub fn prop_invariants_alt_seeds_test() { + run(7919, 300, 1000, 10, prop_invariants) + |> should.equal(True) + run(31_337, 300, 1000, 10, prop_invariants) + |> should.equal(True) +} + +pub fn prop_monotone_alt_seeds_test() { + run(1234, 300, 500, 8, prop_monotone) + |> should.equal(True) + run(99_991, 300, 500, 8, prop_monotone) + |> should.equal(True) +} + +// Edge: total=0 → all zeros regardless of constraints +pub fn prop_zero_total_all_zeros_test() { + let #(cs, _) = gen_constraints(42, 6) + geometry.resolve_sizes(0, cs) + |> list.all(fn(s) { s == 0 }) + |> should.equal(True) +} + +// Edge: total<0 → all zeros +pub fn prop_negative_total_all_zeros_test() { + let #(cs, _) = gen_constraints(99, 5) + geometry.resolve_sizes(-1, cs) + |> list.all(fn(s) { s == 0 }) + |> should.equal(True) +} + +// Edge: no constraints → [] +pub fn prop_empty_constraints_test() { + list.each([0, 1, 50, 100, 999], fn(total) { + geometry.resolve_sizes(total, []) + |> should.equal([]) + }) +} + +// Edge: single Fill absorbs everything +pub fn prop_single_fill_absorbs_all_test() { + list.each([0, 1, 50, 100, 200], fn(total) { + geometry.resolve_sizes(total, [geometry.Fill]) + |> should.equal([total]) + }) +} diff --git a/test/snapshot_test.gleam b/test/snapshot_test.gleam new file mode 100644 index 0000000..af4a588 --- /dev/null +++ b/test/snapshot_test.gleam @@ -0,0 +1,767 @@ +// M3 snapshot tests: verify cell content, not just buffer area. +// Exit criterion: "rendering corretto di hello world in un blocco bordato, no terminale." + +import etui/buffer +import etui/geometry.{Position, rect_new} +import etui/span +import etui/style +import etui/widgets/block +import etui/widgets/clear +import etui/widgets/gauge +import etui/widgets/input as input_widget +import etui/widgets/paragraph +import etui/widgets/scrollbar +import etui/widgets/sparkline +import etui/widgets/table as table_widget +import etui/widgets/tabs +import gleam/list +import gleam/string +import gleeunit/should + +// ─── Helper ──────────────────────────────────────────────────────── + +/// Render buffer region to a multiline string. +/// Each row is one line; rows joined with "\n". +/// Unset cells appear as " " (empty_cell default). +fn buf_str(buf: buffer.Buffer) -> String { + let area = buffer.area(buf) + scan_rows( + buf, + area.position.x, + area.position.y, + area.size.width, + area.size.height, + 0, + [], + ) + |> list.reverse + |> string.join("\n") +} + +fn scan_rows( + buf: buffer.Buffer, + ox: Int, + oy: Int, + w: Int, + h: Int, + row: Int, + acc: List(String), +) -> List(String) { + case row >= h { + True -> acc + False -> + scan_rows(buf, ox, oy, w, h, row + 1, [ + scan_row(buf, ox, oy + row, w, 0, ""), + ..acc + ]) + } +} + +fn scan_row( + buf: buffer.Buffer, + ox: Int, + y: Int, + w: Int, + col: Int, + acc: String, +) -> String { + case col >= w { + True -> acc + False -> { + let cell = buffer.get_cell(buf, Position(x: ox + col, y: y)) + // Skip Continuation cells — the wide grapheme was output at col-1 + let sym = case buffer.is_continuation(cell) { + True -> "" + False -> buffer.cell_symbol(cell) + } + scan_row(buf, ox, y, w, col + 1, acc <> sym) + } + } +} + +// ─── Block border snapshots ──────────────────────────────────────── + +pub fn block_single_border_snapshot_test() { + let area = rect_new(0, 0, 7, 3) + let b = block.block_new() |> block.with_border(block.Single) + buffer.buffer_new(area) + |> block.render(area, b) + |> buf_str + |> should.equal("┌─────┐\n│ │\n└─────┘") +} + +pub fn block_double_border_snapshot_test() { + let area = rect_new(0, 0, 7, 3) + let b = block.block_new() |> block.with_border(block.Double) + buffer.buffer_new(area) + |> block.render(area, b) + |> buf_str + |> should.equal("╔═════╗\n║ ║\n╚═════╝") +} + +pub fn block_rounded_border_snapshot_test() { + let area = rect_new(0, 0, 7, 3) + let b = block.block_new() |> block.with_border(block.Rounded) + buffer.buffer_new(area) + |> block.render(area, b) + |> buf_str + |> should.equal("╭─────╮\n│ │\n╰─────╯") +} + +pub fn block_no_border_snapshot_test() { + // No border: inner area cleared, rest untouched (all spaces) + let area = rect_new(0, 0, 5, 2) + let b = block.block_new() + buffer.buffer_new(area) + |> block.render(area, b) + |> buf_str + |> should.equal(" \n ") +} + +// ─── Block with title ────────────────────────────────────────────── + +pub fn block_title_top_snapshot_test() { + // Title "Hi" written at x=1,y=0, overwrites border chars + let area = rect_new(0, 0, 12, 3) + let b = + block.block_new() + |> block.with_border(block.Single) + |> block.with_title("Hi", block.Top) + buffer.buffer_new(area) + |> block.render(area, b) + |> buf_str + |> should.equal("┌Hi────────┐\n│ │\n└──────────┘") +} + +pub fn block_title_bottom_snapshot_test() { + let area = rect_new(0, 0, 12, 3) + let b = + block.block_new() + |> block.with_border(block.Single) + |> block.with_title("Hi", block.Bottom) + buffer.buffer_new(area) + |> block.render(area, b) + |> buf_str + |> should.equal("┌──────────┐\n│ │\n└Hi────────┘") +} + +// ─── Paragraph snapshot ──────────────────────────────────────────── + +pub fn paragraph_single_line_snapshot_test() { + // "hello" in 10×1: padded right to 10 cells + let area = rect_new(0, 0, 10, 1) + let p = paragraph.paragraph_new("hello") + buffer.buffer_new(area) + |> paragraph.render(area, p) + |> buf_str + |> should.equal("hello ") +} + +pub fn paragraph_wrapped_snapshot_test() { + // "hello world" in 6×2: wraps to "hello " / "world " (aligned to 6) + let area = rect_new(0, 0, 6, 2) + let p = paragraph.paragraph_new("hello world") + buffer.buffer_new(area) + |> paragraph.render(area, p) + |> buf_str + |> should.equal("hello \nworld ") +} + +pub fn paragraph_cjk_snapshot_test() { + // "你好" = 4 cells in 6×1: padded to 6 cells (2 trailing spaces). + // buf_str skips Continuation cells, so the string is 4 chars (你好 + 2 spaces). + let area = rect_new(0, 0, 6, 1) + let p = paragraph.paragraph_new("你好") + buffer.buffer_new(area) + |> paragraph.render(area, p) + |> buf_str + |> should.equal("你好 ") +} + +// ─── M3 exit criterion: block + paragraph ───────────────────────── + +pub fn block_with_paragraph_hello_world_test() { + // THE exit criterion test. + // 12×4 block (Single border) + "hello world" inside. + // Inner area: x=1, y=1, width=10, height=2. + // "hello world" wraps to ["hello", "world"], each padded to 10 cells. + let area = rect_new(0, 0, 12, 4) + let inner = rect_new(1, 1, 10, 2) + let b = block.block_new() |> block.with_border(block.Single) + let p = paragraph.paragraph_new("hello world") + let buf = + buffer.buffer_new(area) + |> block.render(area, b) + |> paragraph.render(inner, p) + buf_str(buf) + |> should.equal("┌──────────┐\n│hello │\n│world │\n└──────────┘") +} + +pub fn block_with_cjk_content_test() { + // 8×3 block + "你好" inside (4 cells), padded to 6 + let area = rect_new(0, 0, 8, 3) + let inner = rect_new(1, 1, 6, 1) + let b = block.block_new() |> block.with_border(block.Single) + let p = paragraph.paragraph_new("你好") + let buf = + buffer.buffer_new(area) + |> block.render(area, b) + |> paragraph.render(inner, p) + // Row 1: │ + 你(2 cells) + 好(2 cells) + 2 spaces + │ = 8 cells. + // buf_str skips Continuation cells → "│你好 │" is 7 chars (not 8). + buf_str(buf) + |> should.equal("┌──────┐\n│你好 │\n└──────┘") +} + +// ─── Diff snapshot tests ─────────────────────────────────────────── + +pub fn diff_identical_buffers_test() { + // Identical buffers → 0 ops + let area = rect_new(0, 0, 5, 2) + let b = block.block_new() |> block.with_border(block.Single) + let buf = buffer.buffer_new(area) |> block.render(area, b) + buffer.diff(buf, buf) |> list.length |> should.equal(0) +} + +pub fn diff_empty_to_block_test() { + // Empty → bordered block: at least 1 op per row with content + let area = rect_new(0, 0, 5, 3) + let empty = buffer.buffer_new(area) + let b = block.block_new() |> block.with_border(block.Single) + let filled = block.render(empty, area, b) + let ops = buffer.diff(empty, filled) + // At minimum one op per row (3 rows, each has changed cells) + { ops != [] } |> should.be_true +} + +pub fn diff_one_cell_changed_test() { + // Manually set one cell: diff should produce exactly 1 op + let area = rect_new(0, 0, 5, 2) + let prev = buffer.buffer_new(area) + let next = + buffer.set_cell( + prev, + Position(x: 2, y: 0), + buffer.Cell( + content: buffer.Content(symbol: "X", width: 1), + fg: style.Default, + bg: style.Default, + modifier: style.none(), + link: "", + ), + ) + let ops = buffer.diff(prev, next) + list.length(ops) |> should.equal(1) + case ops { + [buffer.Patch(pos, cells)] -> { + pos |> should.equal(Position(x: 2, y: 0)) + list.length(cells) |> should.equal(1) + } + _ -> should.fail() + } +} + +pub fn diff_full_row_changed_test() { + // Change all cells in row 0: should produce 1 patch per changed run + let area = rect_new(0, 0, 4, 2) + let prev = buffer.buffer_new(area) + let next = + buffer.set_string( + prev, + Position(x: 0, y: 0), + "abcd", + style.Default, + style.Default, + style.none(), + ) + let ops = buffer.diff(prev, next) + // All 4 cells on row 0 changed and adjacent → 1 op + list.length(ops) |> should.equal(1) + case ops { + [buffer.Patch(pos, cells)] -> { + pos |> should.equal(Position(x: 0, y: 0)) + list.length(cells) |> should.equal(4) + } + _ -> should.fail() + } +} + +pub fn diff_full_screen_changed_test() { + // Full screen change: expect height patches (one per row) + let area = rect_new(0, 0, 4, 3) + let prev = buffer.buffer_new(area) + let next = + buffer.set_string( + prev, + Position(x: 0, y: 0), + "abcd", + style.Default, + style.Default, + style.none(), + ) + |> buffer.set_string( + Position(x: 0, y: 1), + "efgh", + style.Default, + style.Default, + style.none(), + ) + |> buffer.set_string( + Position(x: 0, y: 2), + "ijkl", + style.Default, + style.Default, + style.none(), + ) + let ops = buffer.diff(prev, next) + // 3 rows changed → 3 patches + list.length(ops) |> should.equal(3) +} + +// ─── Buffer: non-zero origin ─────────────────────────────────────── + +pub fn block_at_nonzero_origin_test() { + // Block at (5,2) — offset should not affect rendering + let area = rect_new(5, 2, 7, 3) + let b = block.block_new() |> block.with_border(block.Single) + let buf = buffer.buffer_new(area) + let result = block.render(buf, area, b) + // Corner at (5,2) should be ┌ + buffer.get_cell(result, Position(x: 5, y: 2)) + |> buffer.cell_symbol + |> should.equal("┌") + // Corner at (11,2) should be ┐ + buffer.get_cell(result, Position(x: 11, y: 2)) + |> buffer.cell_symbol + |> should.equal("┐") + // Corner at (5,4) should be └ + buffer.get_cell(result, Position(x: 5, y: 4)) + |> buffer.cell_symbol + |> should.equal("└") + // Corner at (11,4) should be ┘ + buffer.get_cell(result, Position(x: 11, y: 4)) + |> buffer.cell_symbol + |> should.equal("┘") +} + +// ─── Clear widget ────────────────────────────────────────────────── + +pub fn clear_erases_filled_area_test() { + // Fill then clear: all cells become empty (space) + let area = rect_new(0, 0, 5, 2) + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "hello", + style.Default, + style.Default, + style.none(), + ) + |> buffer.set_string( + Position(x: 0, y: 1), + "world", + style.Default, + style.Default, + style.none(), + ) + |> clear.render(area) + |> buf_str + |> should.equal(" \n ") +} + +pub fn clear_partial_area_test() { + // Clear only the bottom row; top row untouched + let area = rect_new(0, 0, 5, 2) + let bottom_row = rect_new(0, 1, 5, 1) + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "hello", + style.Default, + style.Default, + style.none(), + ) + |> buffer.set_string( + Position(x: 0, y: 1), + "world", + style.Default, + style.Default, + style.none(), + ) + |> clear.render(bottom_row) + |> buf_str + |> should.equal("hello\n ") +} + +pub fn clear_empty_area_is_noop_test() { + // clear on a zero-size rect does nothing + let area = rect_new(0, 0, 5, 2) + let noop = rect_new(0, 0, 0, 0) + buffer.buffer_new(area) + |> buffer.set_string( + Position(x: 0, y: 0), + "hello", + style.Default, + style.Default, + style.none(), + ) + |> clear.render(noop) + |> buf_str + |> should.equal("hello\n ") +} + +// ─── Gauge widget ────────────────────────────────────────────────── + +pub fn gauge_50_percent_test() { + // 10-cell wide bar at 50% → 5 filled, 5 empty + let area = rect_new(0, 0, 10, 1) + buffer.buffer_new(area) + |> gauge.render(area, gauge.gauge_new(50)) + |> buf_str + |> should.equal("█████░░░░░") +} + +pub fn gauge_0_percent_test() { + let area = rect_new(0, 0, 8, 1) + buffer.buffer_new(area) + |> gauge.render(area, gauge.gauge_new(0)) + |> buf_str + |> should.equal("░░░░░░░░") +} + +pub fn gauge_100_percent_test() { + let area = rect_new(0, 0, 6, 1) + buffer.buffer_new(area) + |> gauge.render(area, gauge.gauge_new(100)) + |> buf_str + |> should.equal("██████") +} + +pub fn gauge_custom_chars_test() { + let area = rect_new(0, 0, 4, 1) + buffer.buffer_new(area) + |> gauge.render(area, gauge.gauge_new(50) |> gauge.with_chars("=", "-")) + |> buf_str + |> should.equal("==--") +} + +pub fn gauge_zero_area_is_noop_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(rect_new(0, 0, 5, 1)) + gauge.render(buf, area, gauge.gauge_new(50)) + |> buf_str + |> should.equal(" ") +} + +// ─── Tabs widget ─────────────────────────────────────────────────── + +pub fn tabs_renders_labels_test() { + // Three tabs, active=0, width 20 + let area = rect_new(0, 0, 20, 1) + let t = tabs.tabs_new(["Files", "Log", "Help"]) + buffer.buffer_new(area) + |> tabs.render(area, t) + |> buf_str + // active tab has padding 1 each side: " Files " + "│" + " Log " + "│" + " Help " + |> should.equal(" Files │ Log │ Help ") +} + +pub fn tabs_active_index_test() { + // Tab at index 1 is active — navigation helpers work + let t = + tabs.tabs_new(["A", "B", "C"]) + |> tabs.next_tab + t.active + |> should.equal(1) +} + +pub fn tabs_wraps_around_test() { + let t = + tabs.tabs_new(["A", "B", "C"]) + |> tabs.with_active(2) + |> tabs.next_tab + t.active + |> should.equal(0) +} + +pub fn tabs_prev_wraps_test() { + let t = + tabs.tabs_new(["A", "B", "C"]) + |> tabs.prev_tab + t.active + |> should.equal(2) +} + +// ─── Table widget ────────────────────────────────────────────────── + +pub fn table_renders_rows_test() { + // 2 rows × 2 cols, each col 5 wide → "alice│bob \ncarol│dave " + let area = rect_new(0, 0, 11, 2) + let rows = [["alice", "bob"], ["carol", "dave"]] + buffer.buffer_new(area) + |> table_widget.render( + area, + table_widget.table_new(rows) |> table_widget.with_col_widths([5, 5]), + ) + |> buf_str + |> should.equal(" alic│bob \n caro│dave ") +} + +pub fn table_selection_prefix_test() { + // Selected row 0 gets ▶ prefix, row 1 gets space + let area = rect_new(0, 0, 11, 2) + let rows = [["alice", "bob"], ["carol", "dave"]] + let state = table_widget.state_new() + buffer.buffer_new(area) + |> table_widget.render_stateful( + area, + table_widget.table_new(rows) |> table_widget.with_col_widths([5, 5]), + state, + ) + |> buf_str + |> should.equal("▶alic│bob \n caro│dave ") +} + +pub fn table_navigate_test() { + let state = + table_widget.state_new() + |> table_widget.select_next_row(3) + |> table_widget.select_next_row(3) + state.selected_row + |> should.equal(2) + + let state2 = table_widget.select_prev_row(state) + state2.selected_row + |> should.equal(1) +} + +// ─── Sparkline widget ────────────────────────────────────────────── + +pub fn sparkline_empty_data_test() { + // No data → all spaces + let area = rect_new(0, 0, 4, 1) + buffer.buffer_new(area) + |> sparkline.render(area, sparkline.sparkline_new([]), 0) + |> buf_str + |> should.equal(" ") +} + +pub fn sparkline_max_value_test() { + // All values equal max → all "█" + let area = rect_new(0, 0, 3, 1) + buffer.buffer_new(area) + |> sparkline.render( + area, + sparkline.sparkline_new([10, 10, 10]) |> sparkline.with_max(10), + 0, + ) + |> buf_str + |> should.equal("███") +} + +pub fn sparkline_zero_values_test() { + // All zeros → all spaces + let area = rect_new(0, 0, 3, 1) + buffer.buffer_new(area) + |> sparkline.render( + area, + sparkline.sparkline_new([0, 0, 0]) |> sparkline.with_max(10), + 0, + ) + |> buf_str + |> should.equal(" ") +} + +// ─── Span / Line ─────────────────────────────────────────────────── + +pub fn span_plain_renders_test() { + let area = rect_new(0, 0, 5, 1) + let l = span.line_plain("hello") + buffer.buffer_new(area) + |> span.render_line(Position(x: 0, y: 0), l, 5) + |> buf_str + |> should.equal("hello") +} + +pub fn span_multi_renders_test() { + // Two spans concatenated: "hi" + "!!" = "hi!!" + let area = rect_new(0, 0, 4, 1) + let l = span.line_new([span.span_plain("hi"), span.span_plain("!!")]) + buffer.buffer_new(area) + |> span.render_line(Position(x: 0, y: 0), l, 4) + |> buf_str + |> should.equal("hi!!") +} + +pub fn span_clips_to_max_width_test() { + // max_width=3 clips "hello" to "hel" + let area = rect_new(0, 0, 5, 1) + let l = span.line_plain("hello") + buffer.buffer_new(area) + |> span.render_line(Position(x: 0, y: 0), l, 3) + |> buf_str + |> should.equal("hel ") +} + +pub fn span_width_test() { + span.span_width(span.span_plain("hello")) + |> should.equal(5) +} + +pub fn line_width_test() { + let l = span.line_new([span.span_plain("hi"), span.span_plain("!!")]) + span.line_width(l) + |> should.equal(4) +} + +// ─── Input widget ────────────────────────────────────────────────── + +pub fn input_renders_placeholder_test() { + let area = rect_new(0, 0, 10, 1) + let w = input_widget.input_new("search…") + let state = input_widget.state_new() + buffer.buffer_new(area) + |> input_widget.render(area, w, state) + |> buf_str + |> should.equal("search… ") +} + +pub fn input_renders_value_test() { + let area = rect_new(0, 0, 8, 1) + let w = input_widget.input_new("") + let state = input_widget.state_from_string("hello") + buffer.buffer_new(area) + |> input_widget.render(area, w, state) + |> buf_str + |> should.equal("hello ") +} + +pub fn input_insert_char_test() { + let w = input_widget.input_new("") + let s0 = input_widget.state_new() + let s1 = input_widget.insert_char(w, s0, "a") + let s2 = input_widget.insert_char(w, s1, "b") + s2.value + |> should.equal("ab") + s2.cursor + |> should.equal(2) +} + +pub fn input_backspace_test() { + let _w = input_widget.input_new("") + let s = input_widget.state_from_string("hi") + let s2 = input_widget.backspace(s) + s2.value + |> should.equal("h") + s2.cursor + |> should.equal(1) +} + +pub fn input_backspace_at_start_noop_test() { + let s = input_widget.state_new() + let s2 = input_widget.backspace(s) + s2.value + |> should.equal("") +} + +pub fn input_cursor_move_test() { + let s = input_widget.state_from_string("abc") + let s2 = input_widget.move_cursor_left(s) + s2.cursor + |> should.equal(2) + let s3 = input_widget.move_cursor_right(s2) + s3.cursor + |> should.equal(3) +} + +pub fn input_max_length_test() { + let w = input_widget.input_new("") |> input_widget.with_max_length(2) + let s0 = input_widget.state_new() + let s1 = input_widget.insert_char(w, s0, "a") + let s2 = input_widget.insert_char(w, s1, "b") + let s3 = input_widget.insert_char(w, s2, "c") + s3.value + |> should.equal("ab") +} + +// ─── Scrollbar widget ────────────────────────────────────────────── + +pub fn scrollbar_vertical_thumb_at_top_test() { + // 20 items, 4 visible, offset 0 → thumb at top + let area = rect_new(0, 0, 1, 4) + let s = scrollbar.scrollbar_new(20, 4, 0) |> scrollbar.with_arrows("", "") + buffer.buffer_new(area) + |> scrollbar.render_vertical(area, s) + |> buf_str + // thumb size = 4*4/20 = 0 → clamped to 1; at pos 0 + |> should.equal("█\n░\n░\n░") +} + +pub fn scrollbar_vertical_thumb_at_bottom_test() { + // offset = total - visible → thumb at bottom + let area = rect_new(0, 0, 1, 4) + let s = scrollbar.scrollbar_new(20, 4, 16) |> scrollbar.with_arrows("", "") + buffer.buffer_new(area) + |> scrollbar.render_vertical(area, s) + |> buf_str + |> should.equal("░\n░\n░\n█") +} + +pub fn scrollbar_fully_visible_is_all_thumb_test() { + // visible >= total → thumb fills entire track + let area = rect_new(0, 0, 1, 4) + let s = scrollbar.scrollbar_new(4, 4, 0) |> scrollbar.with_arrows("", "") + buffer.buffer_new(area) + |> scrollbar.render_vertical(area, s) + |> buf_str + |> should.equal("█\n█\n█\n█") +} + +pub fn scrollbar_zero_area_noop_test() { + let area = rect_new(0, 0, 0, 0) + let buf = buffer.buffer_new(rect_new(0, 0, 2, 2)) + let s = scrollbar.scrollbar_new(10, 4, 0) + scrollbar.render_vertical(buf, area, s) + |> buf_str + |> should.equal(" \n ") +} + +// ─── paragraph.render_styled ─────────────────────────────────────── + +pub fn paragraph_render_styled_test() { + let area = rect_new(0, 0, 5, 2) + let lines = [ + span.line_plain("hello"), + span.line_plain("world"), + ] + buffer.buffer_new(area) + |> paragraph.render_styled(area, lines) + |> buf_str + |> should.equal("hello\nworld") +} + +pub fn paragraph_render_styled_clips_rows_test() { + // 3 lines into area height 2 → only first 2 rendered + let area = rect_new(0, 0, 5, 2) + let lines = [ + span.line_plain("aaa"), + span.line_plain("bbb"), + span.line_plain("ccc"), + ] + buffer.buffer_new(area) + |> paragraph.render_styled(area, lines) + |> buf_str + |> should.equal("aaa \nbbb ") +} + +pub fn paragraph_render_styled_multi_span_test() { + // Two spans on one row + let area = rect_new(0, 0, 6, 1) + let lines = [ + span.line_new([span.span_plain("foo"), span.span_plain("bar")]), + ] + buffer.buffer_new(area) + |> paragraph.render_styled(area, lines) + |> buf_str + |> should.equal("foobar") +} diff --git a/test/text_unicode_test.gleam b/test/text_unicode_test.gleam new file mode 100644 index 0000000..01634c0 --- /dev/null +++ b/test/text_unicode_test.gleam @@ -0,0 +1,301 @@ +// M2: Unicode-correct cell_width tests. +// Covers real terminal rendering cases: ZWJ, flags, CJK strings, +// combining marks (NFD), fullwidth, mixed content. +// Exit criterion: cell_width matches what a modern terminal displays. + +import etui/text +import gleam/list +import gleam/string +import gleeunit/should + +// ─── cell_width: CJK strings ─────────────────────────────────────── + +pub fn cjk_string_two_chars_test() { + // 你好 = 2 ideographs × 2 cells = 4 + text.cell_width("你好") |> should.equal(4) +} + +pub fn cjk_string_single_char_test() { + text.cell_width("中") |> should.equal(2) +} + +pub fn mixed_ascii_cjk_test() { + // "ab你" = 1+1+2 = 4 + text.cell_width("ab你") |> should.equal(4) +} + +pub fn mixed_cjk_ascii_suffix_test() { + // "你ab" = 2+1+1 = 4 + text.cell_width("你ab") |> should.equal(4) +} + +// ─── cell_width: Hangul ──────────────────────────────────────────── + +pub fn hangul_syllable_test() { + // 한 = U+D55C (in Hangul Syllables AC00–D7A3) = 2 cells + text.cell_width("한") |> should.equal(2) +} + +pub fn hangul_string_test() { + // 안녕 = 2 syllables × 2 cells = 4 + text.cell_width("안녕") |> should.equal(4) +} + +// ─── cell_width: emoji ───────────────────────────────────────────── + +pub fn emoji_smiley_test() { + // 😀 U+1F600 = 2 cells + text.cell_width("😀") |> should.equal(2) +} + +pub fn emoji_rocket_test() { + // 🚀 U+1F680 = 2 cells + text.cell_width("🚀") |> should.equal(2) +} + +pub fn emoji_two_chars_test() { + // "😀😀" = 2+2 = 4 + text.cell_width("😀😀") |> should.equal(4) +} + +pub fn emoji_mixed_ascii_test() { + // "a😀b" = 1+2+1 = 4 + text.cell_width("a😀b") |> should.equal(4) +} + +// ─── cell_width: ZWJ sequences ──────────────────────────────────── +// ZWJ emoji are ONE grapheme cluster: width = first codepoint (always emoji = 2) + +pub fn zwj_family_test() { + // 👨‍👩‍👧‍👦 = man ZWJ woman ZWJ girl ZWJ boy → 1 grapheme → 2 cells + text.cell_width("👨‍👩‍👧‍👦") |> should.equal(2) +} + +pub fn zwj_couple_test() { + // 👩‍❤️‍👨 = 1 grapheme → 2 cells + text.cell_width("👩‍❤️‍👨") |> should.equal(2) +} + +pub fn zwj_profession_test() { + // 👩‍💻 woman technologist = 1 grapheme → 2 cells + text.cell_width("👩‍💻") |> should.equal(2) +} + +// ─── cell_width: flag emoji (regional indicator pairs) ──────────── +// Two regional indicators form one grapheme (if Erlang UAX#29 clusters them). +// First codepoint is in range 1F1E6–1F1FF → 2 cells. + +pub fn flag_italy_test() { + // 🇮🇹 = RI(I) + RI(T) → cell_width = 2 + text.cell_width("🇮🇹") |> should.equal(2) +} + +pub fn flag_us_test() { + // 🇺🇸 = 2 + text.cell_width("🇺🇸") |> should.equal(2) +} + +// ─── cell_width: combining marks (NFD) ──────────────────────────── +// NFD: base char + combining mark → 1 grapheme → width of base char + +pub fn combining_acute_e_test() { + // NFD: U+0065 (e) + U+0301 (combining acute) → grapheme "é" → 1 cell + let nfd_e_acute = "e\u{0301}" + text.cell_width(nfd_e_acute) |> should.equal(1) +} + +pub fn combining_nfc_e_test() { + // NFC: U+00E9 precomposed é → 1 cell + text.cell_width("é") |> should.equal(1) +} + +pub fn combining_string_test() { + // NFD "résumé" → same cell width as NFC (6 cells) + let nfd = "re\u{0301}sume\u{0301}" + text.cell_width(nfd) |> should.equal(6) +} + +// ─── cell_width: fullwidth forms ────────────────────────────────── + +pub fn fullwidth_latin_a_test() { + // A U+FF21 (Fullwidth Forms FF00–FF60) = 2 cells + text.cell_width("A") |> should.equal(2) +} + +pub fn fullwidth_string_test() { + // "AB" = 2+2 = 4 + text.cell_width("AB") |> should.equal(4) +} + +// ─── cell_width: symbols that must be 1-cell ────────────────────── +// Misc Symbols (2600-26FF) and Dingbats (2700-27BF) are Ambiguous/Neutral +// in East Asian Width. Monospace terminals render them as 1 cell. +// This was the bug that caused visual artifacts — verify it stays fixed. + +pub fn star_symbol_one_cell_test() { + // ★ U+2605 (Black Star, Misc Symbols) = 1 cell + text.cell_width("★") |> should.equal(1) +} + +pub fn diamond_symbol_one_cell_test() { + // ✦ U+2726 (Black Four Pointed Star, Dingbats) = 1 cell + text.cell_width("✦") |> should.equal(1) +} + +pub fn checkmark_one_cell_test() { + // ✓ U+2713 (Check Mark, Dingbats) = 1 cell + text.cell_width("✓") |> should.equal(1) +} + +pub fn misc_symbols_string_test() { + // "★✦✓" = 1+1+1 = 3 + text.cell_width("★✦✓") |> should.equal(3) +} + +// ─── cell_width: control / zero-width ───────────────────────────── + +pub fn zero_width_joiner_test() { + // ZWJ U+200D alone = 0 cells + text.cell_width("\u{200D}") |> should.equal(0) +} + +pub fn zero_width_space_test() { + // ZWSP U+200B = 0 cells + text.cell_width("\u{200B}") |> should.equal(0) +} + +// ─── truncate: cell-aware ────────────────────────────────────────── + +pub fn truncate_cjk_test() { + // "你好世界" = 8 cells, truncate to 5 with "…" (1 cell) + // available=4, "你" takes 2, "好" takes 2 → "你好" (4 cells) + "…" = 5 + text.truncate("你好世界", 5, "…") |> should.equal("你好…") +} + +pub fn truncate_cjk_no_partial_wide_test() { + // "你好" = 4 cells, truncate to 3 with "…" (1 cell) + // available=2, "你" takes 2 → "你" + "…" = 3 + text.truncate("你好", 3, "…") |> should.equal("你…") +} + +pub fn truncate_fits_no_ellipsis_test() { + // String fits → no ellipsis added + text.truncate("hi", 10, "…") |> should.equal("hi") +} + +pub fn truncate_exact_fit_test() { + text.truncate("hello", 5, "…") |> should.equal("hello") +} + +pub fn truncate_emoji_test() { + // "😀😀😀" = 6 cells, truncate to 5 with "…" + // available=4, "😀" takes 2, "😀" takes 2 → "😀😀" (4) + "…" = 5 + text.truncate("😀😀😀", 5, "…") |> should.equal("😀😀…") +} + +// ─── pad_right / pad_left: cell-aware ───────────────────────────── + +pub fn pad_right_cjk_test() { + // "你好" = 4 cells, pad to 6 → 2 spaces + text.pad_right("你好", 6) |> should.equal("你好 ") +} + +pub fn pad_left_cjk_test() { + text.pad_left("你好", 6) |> should.equal(" 你好") +} + +pub fn pad_right_emoji_test() { + // "😀" = 2 cells, pad to 5 → 3 spaces + text.pad_right("😀", 5) |> should.equal("😀 ") +} + +pub fn pad_right_already_full_test() { + text.pad_right("你好", 4) |> should.equal("你好") +} + +pub fn pad_right_overflow_test() { + text.pad_right("你好", 3) |> should.equal("你好") +} + +// ─── align: cell-aware ──────────────────────────────────────────── + +pub fn align_left_cjk_test() { + text.align("你好", 6, text.Left) |> should.equal("你好 ") +} + +pub fn align_right_cjk_test() { + text.align("你好", 6, text.Right) |> should.equal(" 你好") +} + +pub fn align_center_cjk_test() { + // "你好" = 4 cells, width=8 → 2 left + 2 right + text.align("你好", 8, text.Center) |> should.equal(" 你好 ") +} + +pub fn align_center_odd_remainder_test() { + // "你好" = 4, width=7 → 1 left + 2 right (total 3, left=1, right=2) + text.align("你好", 7, text.Center) |> should.equal(" 你好 ") +} + +// ─── wrap: cell-aware ───────────────────────────────────────────── + +pub fn wrap_cjk_words_test() { + // "你好 世界" = "你好"(4) + " " + "世界"(4); max_width=5 + // "你好" fits, adding " 世界" = 9 > 5 → new line + text.wrap("你好 世界", 5) |> should.equal(["你好", "世界"]) +} + +pub fn wrap_mixed_cjk_ascii_test() { + // "ab 你好" max_width=4: "ab"(2) fits; "ab 你好" = 2+1+4=7>4 → "你好" on new line + text.wrap("ab 你好", 4) |> should.equal(["ab", "你好"]) +} + +// ─── strip_ansi: extended ───────────────────────────────────────── + +pub fn strip_ansi_osc_bel_test() { + // OSC title sequence terminated by BEL + let osc = "\u{001B}]0;My Terminal Title\u{0007}" + text.strip_ansi(osc) |> should.equal("") +} + +pub fn strip_ansi_osc_st_test() { + // OSC terminated by ST (ESC \) + let osc = "\u{001B}]0;title\u{001B}\\" + text.strip_ansi(osc) |> should.equal("") +} + +pub fn strip_ansi_csi_rgb_test() { + // Truecolor: \e[38;2;255;0;128m + let seq = "\u{001B}[38;2;255;0;128mred\u{001B}[0m" + text.strip_ansi(seq) |> should.equal("red") +} + +pub fn strip_ansi_mixed_unicode_test() { + // ANSI around CJK text + let styled = "\u{001B}[1m你好\u{001B}[0m" + text.strip_ansi(styled) |> should.equal("你好") +} + +pub fn strip_ansi_cell_width_after_strip_test() { + // cell_width of stripped string should be correct + let styled = "\u{001B}[32m😀\u{001B}[0m" + text.strip_ansi(styled) |> text.cell_width |> should.equal(2) +} + +// ─── grapheme counting vs cell counting ─────────────────────────── +// These document the distinction: graphemes ≠ cells. + +pub fn graphemes_vs_cells_cjk_test() { + // "你好" = 2 graphemes but 4 cells + let gs = string.to_graphemes("你好") + list.length(gs) |> should.equal(2) + text.cell_width("你好") |> should.equal(4) +} + +pub fn graphemes_vs_cells_zwj_test() { + // ZWJ family = 1 grapheme = 2 cells + let fam = "👨‍👩‍👧‍👦" + let gs = string.to_graphemes(fam) + list.length(gs) |> should.equal(1) + text.cell_width(fam) |> should.equal(2) +} diff --git a/test/theme_test.gleam b/test/theme_test.gleam new file mode 100644 index 0000000..159759b --- /dev/null +++ b/test/theme_test.gleam @@ -0,0 +1,173 @@ +/// Tests for the theme system: built-in palettes and style helpers. +import etui/style +import etui/theme +import gleeunit/should + +// ───────────────────────────────────────────────────────────────── +// RGB already supported in style + +pub fn rgb_fg_emits_truecolor_sequence_test() { + style.ansi_fg(style.Rgb(255, 128, 0)) + |> should.equal("\u{001B}[38;2;255;128;0m") +} + +pub fn rgb_bg_emits_truecolor_sequence_test() { + style.ansi_bg(style.Rgb(0, 0, 128)) + |> should.equal("\u{001B}[48;2;0;0;128m") +} + +pub fn rgb_zero_is_black_test() { + style.ansi_fg(style.Rgb(0, 0, 0)) + |> should.equal("\u{001B}[38;2;0;0;0m") +} + +pub fn rgb_max_is_white_test() { + style.ansi_fg(style.Rgb(255, 255, 255)) + |> should.equal("\u{001B}[38;2;255;255;255m") +} + +// ───────────────────────────────────────────────────────────────── +// Theme type construction + +pub fn dark_theme_has_indexed_colors_test() { + let t = theme.dark() + // selection_bg uses Indexed(4) — blue + t.selection_bg |> should.equal(style.Indexed(4)) +} + +pub fn light_theme_has_indexed_colors_test() { + let t = theme.light() + t.accent |> should.equal(style.Indexed(4)) +} + +// ───────────────────────────────────────────────────────────────── +// Built-in RGB themes — spot check key slots + +pub fn dracula_bg_is_correct_test() { + theme.dracula().bg |> should.equal(style.Rgb(40, 42, 54)) +} + +pub fn dracula_accent_is_purple_test() { + theme.dracula().accent |> should.equal(style.Rgb(189, 147, 249)) +} + +pub fn nord_bg_test() { + theme.nord().bg |> should.equal(style.Rgb(46, 52, 64)) +} + +pub fn nord_error_test() { + theme.nord().error |> should.equal(style.Rgb(191, 97, 106)) +} + +pub fn catppuccin_mocha_bg_test() { + theme.catppuccin_mocha().bg |> should.equal(style.Rgb(30, 30, 46)) +} + +pub fn catppuccin_latte_bg_is_light_test() { + // Latte bg is bright — high RGB values + let c = theme.catppuccin_latte().bg + let is_light = case c { + style.Rgb(r, g, b) -> r > 200 && g > 200 && b > 200 + _ -> False + } + is_light |> should.equal(True) +} + +pub fn monokai_success_is_green_test() { + theme.monokai().success |> should.equal(style.Rgb(166, 226, 46)) +} + +pub fn gruvbox_dark_bg_test() { + theme.gruvbox_dark().bg |> should.equal(style.Rgb(29, 32, 33)) +} + +pub fn tokyo_night_accent_test() { + theme.tokyo_night().accent |> should.equal(style.Rgb(187, 154, 247)) +} + +pub fn solarized_dark_bg_test() { + theme.solarized_dark().bg |> should.equal(style.Rgb(0, 43, 54)) +} + +// ───────────────────────────────────────────────────────────────── +// Style helpers + +pub fn normal_style_uses_fg_and_bg_test() { + let t = theme.dracula() + let s = theme.normal(t) + s.fg |> should.equal(t.fg) + s.bg |> should.equal(t.bg) + style.is_none(s.modifier) |> should.equal(True) +} + +pub fn selection_style_uses_selection_slots_test() { + let t = theme.nord() + let s = theme.selection(t) + s.fg |> should.equal(t.selection_fg) + s.bg |> should.equal(t.selection_bg) +} + +pub fn error_style_is_bold_test() { + let t = theme.dracula() + let s = theme.error_style(t) + s.fg |> should.equal(t.error) + style.has(s.modifier, style.bold()) |> should.equal(True) +} + +pub fn statusbar_style_uses_statusbar_slots_test() { + let t = theme.tokyo_night() + let s = theme.statusbar_style(t) + s.fg |> should.equal(t.statusbar_fg) + s.bg |> should.equal(t.statusbar_bg) +} + +pub fn muted_style_uses_muted_fg_test() { + let t = theme.gruvbox_dark() + let s = theme.muted_style(t) + s.fg |> should.equal(t.muted) + s.bg |> should.equal(t.bg) +} + +// ───────────────────────────────────────────────────────────────── +// Customisation helpers + +pub fn with_accent_overrides_accent_only_test() { + let base = theme.nord() + let custom = theme.with_accent(base, style.Rgb(255, 165, 0)) + custom.accent |> should.equal(style.Rgb(255, 165, 0)) + // Other slots unchanged + custom.bg |> should.equal(base.bg) + custom.fg |> should.equal(base.fg) +} + +pub fn with_selection_overrides_both_slots_test() { + let base = theme.dracula() + let custom = + theme.with_selection(base, style.Rgb(100, 0, 100), style.Rgb(255, 255, 255)) + custom.selection_bg |> should.equal(style.Rgb(100, 0, 100)) + custom.selection_fg |> should.equal(style.Rgb(255, 255, 255)) + custom.bg |> should.equal(base.bg) +} + +pub fn with_statusbar_overrides_statusbar_slots_test() { + let base = theme.monokai() + let custom = + theme.with_statusbar(base, style.Rgb(0, 0, 0), style.Rgb(200, 200, 200)) + custom.statusbar_bg |> should.equal(style.Rgb(0, 0, 0)) + custom.statusbar_fg |> should.equal(style.Rgb(200, 200, 200)) +} + +pub fn with_base_overrides_bg_and_fg_test() { + let base = theme.solarized_dark() + let custom = + theme.with_base(base, style.Rgb(0, 0, 0), style.Rgb(255, 255, 255)) + custom.bg |> should.equal(style.Rgb(0, 0, 0)) + custom.fg |> should.equal(style.Rgb(255, 255, 255)) + custom.accent |> should.equal(base.accent) +} + +pub fn record_update_syntax_works_test() { + let t = theme.Theme(..theme.nord(), accent: style.Rgb(255, 165, 0)) + t.accent |> should.equal(style.Rgb(255, 165, 0)) + t.bg |> should.equal(theme.nord().bg) +} diff --git a/test/viewport_helpers_test.gleam b/test/viewport_helpers_test.gleam new file mode 100644 index 0000000..7e55408 --- /dev/null +++ b/test/viewport_helpers_test.gleam @@ -0,0 +1,199 @@ +/// Tests for the public viewport helpers added to textarea, table, and tree. +import etui/geometry.{Position, Rect, Size} +import etui/widgets/list as list_w +import etui/widgets/table +import etui/widgets/textarea as ta +import etui/widgets/tree +import gleeunit/should + +// ───────────────────────────────────────────────────────────────── +// textarea.effective_offset + +pub fn textarea_effective_offset_no_scroll_test() { + let state = ta.state_from_string("a\nb\nc") + ta.effective_offset(state, 10) + |> should.equal(0) +} + +pub fn textarea_effective_offset_cursor_at_boundary_test() { + // cursor_y = 9, visible = 10: still fits (0..9), scroll = 0 + let state = ta.TextAreaState(lines: ["x"], cursor_x: 0, cursor_y: 9) + ta.effective_offset(state, 10) + |> should.equal(0) +} + +pub fn textarea_effective_offset_cursor_past_boundary_test() { + // cursor_y = 10, visible = 10: scroll = 10 - 10 + 1 = 1 + let state = ta.TextAreaState(lines: ["x"], cursor_x: 0, cursor_y: 10) + ta.effective_offset(state, 10) + |> should.equal(1) +} + +pub fn textarea_effective_offset_zero_height_test() { + let state = ta.TextAreaState(lines: ["x"], cursor_x: 0, cursor_y: 5) + ta.effective_offset(state, 0) + |> should.equal(0) +} + +// ───────────────────────────────────────────────────────────────── +// textarea.cursor_screen_pos + +pub fn cursor_screen_pos_visible_test() { + let state = ta.TextAreaState(lines: ["hello"], cursor_x: 3, cursor_y: 0) + let area = + Rect(position: Position(x: 2, y: 5), size: Size(width: 20, height: 10)) + ta.cursor_screen_pos(state, area) + |> should.equal(Ok(Position(x: 5, y: 5))) +} + +pub fn cursor_screen_pos_with_scroll_test() { + // cursor_y = 12, visible_h = 10, scroll = 3 → screen_y = 5 + 12 - 3 = 14 + let state = ta.TextAreaState(lines: ["x"], cursor_x: 1, cursor_y: 12) + let area = + Rect(position: Position(x: 0, y: 5), size: Size(width: 80, height: 10)) + ta.cursor_screen_pos(state, area) + |> should.equal(Ok(Position(x: 1, y: 14))) +} + +pub fn cursor_screen_pos_off_screen_horizontal_test() { + // cursor_x >= width → Error(Nil), matching render rule + let state = ta.TextAreaState(lines: ["x"], cursor_x: 20, cursor_y: 0) + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 20, height: 10)) + ta.cursor_screen_pos(state, area) + |> should.equal(Error(Nil)) +} + +pub fn cursor_screen_pos_at_width_boundary_test() { + // cursor_x == width is off-screen (render checks cursor_x < width) + let state = ta.TextAreaState(lines: ["x"], cursor_x: 10, cursor_y: 0) + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 10, height: 5)) + ta.cursor_screen_pos(state, area) + |> should.equal(Error(Nil)) +} + +// ───────────────────────────────────────────────────────────────── +// table.effective_offset + +pub fn table_effective_offset_no_scroll_test() { + let state = table.TableState(selected_row: 3, offset: 0) + table.effective_offset(state, 10) + |> should.equal(0) +} + +pub fn table_effective_offset_scrolled_test() { + // selected = 12, offset = 0, visible = 10 → new offset = 12 - 10 + 1 = 3 + let state = table.TableState(selected_row: 12, offset: 0) + table.effective_offset(state, 10) + |> should.equal(3) +} + +pub fn table_effective_offset_zero_height_test() { + let state = table.TableState(selected_row: 5, offset: 0) + table.effective_offset(state, 0) + |> should.equal(0) +} + +// ───────────────────────────────────────────────────────────────── +// list.effective_offset (already existed; verify contract still holds) + +pub fn list_effective_offset_no_scroll_test() { + let state = list_w.ListState(selected: 2, offset: 0) + list_w.effective_offset(state, 10) + |> should.equal(0) +} + +pub fn list_effective_offset_scrolled_test() { + let state = list_w.ListState(selected: 15, offset: 0) + list_w.effective_offset(state, 10) + |> should.equal(6) +} + +// ───────────────────────────────────────────────────────────────── +// tree.visible_row_count + +pub fn tree_visible_row_count_empty_test() { + let t = tree.tree_new([]) + let state = tree.state_new() + tree.visible_row_count(state, t) + |> should.equal(0) +} + +pub fn tree_visible_row_count_roots_only_test() { + let t = + tree.tree_new([ + tree.leaf("a", "A"), + tree.leaf("b", "B"), + tree.leaf("c", "C"), + ]) + let state = tree.state_new() + tree.visible_row_count(state, t) + |> should.equal(3) +} + +pub fn tree_visible_row_count_collapsed_test() { + // Children hidden while collapsed + let t = + tree.tree_new([ + tree.node("src", "src/", [ + tree.leaf("m", "main.gleam"), + tree.leaf("l", "lib.gleam"), + ]), + tree.leaf("r", "README.md"), + ]) + let state = tree.state_new() + tree.visible_row_count(state, t) + |> should.equal(2) +} + +pub fn tree_visible_row_count_expanded_test() { + let t = + tree.tree_new([ + tree.node("src", "src/", [ + tree.leaf("m", "main.gleam"), + tree.leaf("l", "lib.gleam"), + ]), + tree.leaf("r", "README.md"), + ]) + let state = tree.expand("src", tree.state_new()) + tree.visible_row_count(state, t) + |> should.equal(4) +} + +// ───────────────────────────────────────────────────────────────── +// tree.effective_offset + +pub fn tree_effective_offset_zero_height_test() { + let t = tree.tree_new([tree.leaf("a", "A"), tree.leaf("b", "B")]) + let state = tree.state_from_tree(t) + tree.effective_offset(state, t, 0) + |> should.equal(0) +} + +pub fn tree_effective_offset_no_scroll_test() { + let t = + tree.tree_new([ + tree.leaf("a", "A"), + tree.leaf("b", "B"), + tree.leaf("c", "C"), + ]) + let state = tree.state_from_tree(t) + tree.effective_offset(state, t, 10) + |> should.equal(0) +} + +pub fn tree_effective_offset_scrolled_test() { + // 5 items, selected = last, height = 3 → offset = 5 - 3 = 2 + let t = + tree.tree_new([ + tree.leaf("a", "A"), + tree.leaf("b", "B"), + tree.leaf("c", "C"), + tree.leaf("d", "D"), + tree.leaf("e", "E"), + ]) + let state = tree.TreeState(expanded: [], selected: "e") + tree.effective_offset(state, t, 3) + |> should.equal(2) +} diff --git a/test/widget_extensibility_test.gleam b/test/widget_extensibility_test.gleam new file mode 100644 index 0000000..1637bee --- /dev/null +++ b/test/widget_extensibility_test.gleam @@ -0,0 +1,364 @@ +/// Tests for extensible widget system and new APIs. +/// Validates: custom widgets, composition, stateful/animated widgets, +/// input editing ops, list/table overflow fix, text.wrap with \n, +/// style.Indexed(>15), block.with_bg_fill, popup, statusbar. +import etui/buffer +import etui/geometry.{Position, Rect, Size} +import etui/span +import etui/style +import etui/text +import etui/widget +import etui/widgets/block +import etui/widgets/input as ginput +import etui/widgets/list as glist +import etui/widgets/paragraph +import etui/widgets/popup as gpopup +import etui/widgets/statusbar as gsbar +import gleeunit/should + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn read_row(buf: buffer.Buffer, y: Int, x: Int, n: Int) -> String { + do_read_row(buf, y, x, x + n, "") +} + +fn do_read_row( + buf: buffer.Buffer, + y: Int, + x: Int, + x_end: Int, + acc: String, +) -> String { + case x >= x_end { + True -> acc + False -> { + let sym = buffer.cell_symbol(buffer.get_cell(buf, Position(x: x, y: y))) + do_read_row(buf, y, x + 1, x_end, acc <> sym) + } + } +} + +fn make_buf(w: Int, h: Int) -> buffer.Buffer { + buffer.buffer_new(Rect( + position: Position(x: 0, y: 0), + size: Size(width: w, height: h), + )) +} + +// ───────────────────────────────────────────────────────────────── +// Custom widget: any fn(Buffer, Rect) -> Buffer qualifies + +pub fn custom_widget_is_a_widget_test() { + let buf = make_buf(10, 1) + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 10, height: 1)) + let w: widget.Widget = fn(b, a) { + paragraph.render(b, a, paragraph.paragraph_new("hello")) + } + let result = w(buf, area) + read_row(result, 0, 0, 5) |> should.equal("hello") +} + +// ───────────────────────────────────────────────────────────────── +// StatefulWidget: render with external state + +pub fn stateful_widget_render_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 10, height: 1)) + let buf = make_buf(10, 1) + let w = + widget.StatefulWidget(render: fn(b, a, s: String) { + paragraph.render(b, a, paragraph.paragraph_new(s)) + }) + let result = widget.render_stateful(buf, area, w, "world") + read_row(result, 0, 0, 5) |> should.equal("world") +} + +pub fn freeze_bakes_state_into_stateless_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 8, height: 1)) + let buf = make_buf(8, 1) + let sw = + widget.StatefulWidget(render: fn(b, a, n: Int) { + let s = case n { + 42 -> "life" + _ -> "nope" + } + paragraph.render(b, a, paragraph.paragraph_new(s)) + }) + let w: widget.Widget = widget.freeze(sw, 42) + let result = w(buf, area) + read_row(result, 0, 0, 4) |> should.equal("life") +} + +// ───────────────────────────────────────────────────────────────── +// AnimatedWidget: freeze_frame produces a stateless Widget + +pub fn animated_widget_freeze_frame_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 6, height: 1)) + let buf = make_buf(6, 1) + let aw: widget.AnimatedWidget = fn(b, a, frame) { + let s = case frame { + 7 -> "seven" + _ -> "other" + } + paragraph.render(b, a, paragraph.paragraph_new(s)) + } + let w: widget.Widget = widget.freeze_frame(aw, 7) + let result = w(buf, area) + read_row(result, 0, 0, 5) |> should.equal("seven") +} + +// ───────────────────────────────────────────────────────────────── +// Composition: layer + +pub fn layer_draws_top_over_bottom_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 5, height: 1)) + let buf = make_buf(5, 1) + let bottom: widget.Widget = fn(b, a) { + paragraph.render(b, a, paragraph.paragraph_new("AAAAA")) + } + let top: widget.Widget = fn(b, _a) { + let small = + Rect(position: Position(x: 0, y: 0), size: Size(width: 3, height: 1)) + paragraph.render(b, small, paragraph.paragraph_new("BBB")) + } + let result = widget.layer(bottom, top)(buf, area) + read_row(result, 0, 0, 3) |> should.equal("BBB") + read_row(result, 0, 3, 2) |> should.equal("AA") +} + +// ───────────────────────────────────────────────────────────────── +// Composition: stack + +pub fn stack_renders_all_widgets_in_order_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 6, height: 1)) + let buf = make_buf(6, 1) + let w1: widget.Widget = fn(b, a) { + paragraph.render(b, a, paragraph.paragraph_new("AAABBB")) + } + let w2: widget.Widget = fn(b, _a) { + let sub = + Rect(position: Position(x: 3, y: 0), size: Size(width: 3, height: 1)) + paragraph.render(b, sub, paragraph.paragraph_new("CCC")) + } + let result = widget.stack([w1, w2])(buf, area) + read_row(result, 0, 0, 6) |> should.equal("AAACCC") +} + +// ───────────────────────────────────────────────────────────────── +// Composition: at pins to sub-area + +pub fn at_pins_widget_to_subarea_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 10, height: 1)) + let buf = make_buf(10, 1) + let sub = + Rect(position: Position(x: 4, y: 0), size: Size(width: 4, height: 1)) + let w: widget.Widget = fn(b, a) { + paragraph.render(b, a, paragraph.paragraph_new("XXXX")) + } + let result = widget.at(w, sub)(buf, area) + read_row(result, 0, 4, 4) |> should.equal("XXXX") + // Cells before the pinned area should be empty (space) + let first = buffer.cell_symbol(buffer.get_cell(result, Position(x: 0, y: 0))) + first |> should.equal(" ") +} + +// ───────────────────────────────────────────────────────────────── +// empty() widget + +pub fn empty_widget_is_noop_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 5, height: 1)) + let buf = make_buf(5, 1) + let buf2 = paragraph.render(buf, area, paragraph.paragraph_new("HELLO")) + let result = widget.empty()(buf2, area) + read_row(result, 0, 0, 5) |> should.equal("HELLO") +} + +// ───────────────────────────────────────────────────────────────── +// input: move_to_start / move_to_end / delete_to_end + +pub fn input_move_to_start_test() { + let s = ginput.state_from_string("hello") + let s2 = ginput.move_to_start(s) + s2.cursor |> should.equal(0) + s2.value |> should.equal("hello") +} + +pub fn input_move_to_end_test() { + let s = ginput.state_from_string("hello") + let s2 = ginput.move_to_start(s) |> ginput.move_to_end + s2.cursor |> should.equal(5) +} + +pub fn input_delete_to_end_test() { + let s = ginput.state_from_string("hello") + let s2 = + ginput.move_to_start(s) + |> ginput.move_cursor_right + |> ginput.move_cursor_right + let s3 = ginput.delete_to_end(s2) + s3.value |> should.equal("he") + s3.cursor |> should.equal(2) +} + +pub fn input_insert_wide_char_advances_2_cells_test() { + let w = ginput.input_new("") + let s = ginput.state_new() + let s1 = ginput.insert_char(w, s, "你") + s1.cursor |> should.equal(2) +} + +pub fn input_move_cursor_right_wide_skips_2_cells_test() { + let s0 = ginput.InputState(value: "你ab", cursor: 0) + let s1 = ginput.move_cursor_right(s0) + s1.cursor |> should.equal(2) +} + +pub fn input_move_cursor_left_wide_goes_to_0_test() { + let s0 = ginput.InputState(value: "你ab", cursor: 2) + let s1 = ginput.move_cursor_left(s0) + s1.cursor |> should.equal(0) +} + +// ───────────────────────────────────────────────────────────────── +// list: render_item_line overflow fix + +pub fn list_row_width_is_exactly_area_width_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 10, height: 3)) + let buf = make_buf(10, 3) + let l = glist.list_new(["alpha", "beta", "gamma"]) + let state = glist.state_new() + let result = glist.render_stateful(buf, area, l, state) + // Row 0 selected: prefix "▶ " (2 cells) + padded content = 10 cells total + // Row 1 unselected: prefix " " (2 cells) + padded content = 10 cells total + let row0 = read_row(result, 0, 0, 10) + text.cell_width(row0) |> should.equal(10) + let row1 = read_row(result, 1, 0, 10) + text.cell_width(row1) |> should.equal(10) +} + +// ───────────────────────────────────────────────────────────────── +// text.wrap: explicit \n newlines + +pub fn wrap_explicit_newline_test() { + text.wrap("hello\nworld", 20) |> should.equal(["hello", "world"]) +} + +pub fn wrap_newline_then_word_wrap_test() { + text.wrap("hello world\nfoo bar", 5) + |> should.equal(["hello", "world", "foo", "bar"]) +} + +pub fn wrap_multiple_newlines_test() { + text.wrap("a\nb\nc", 20) |> should.equal(["a", "b", "c"]) +} + +// ───────────────────────────────────────────────────────────────── +// style: Indexed(n>15) emits 256-color sequence + +pub fn style_indexed_200_fg_test() { + style.ansi_fg(style.Indexed(200)) |> should.equal("\u{001B}[38;5;200m") +} + +pub fn style_indexed_128_bg_test() { + style.ansi_bg(style.Indexed(128)) |> should.equal("\u{001B}[48;5;128m") +} + +pub fn style_indexed_0_still_ansi_test() { + style.ansi_fg(style.Indexed(0)) |> should.equal("\u{001B}[30m") +} + +// ───────────────────────────────────────────────────────────────── +// block: with_bg_fill applies bg color to inner cells + +pub fn block_bg_fill_sets_bg_color_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 5, height: 3)) + let buf = make_buf(5, 3) + let blk = + block.block_new() + |> block.with_style(style.Default, style.Indexed(1)) + |> block.with_bg_fill + let result = block.render(buf, area, blk) + // No border on block_new(), so entire area is filled + let cell = buffer.get_cell(result, Position(x: 2, y: 1)) + buffer.cell_bg(cell) |> should.equal(style.Indexed(1)) +} + +// ───────────────────────────────────────────────────────────────── +// popup: centered rect calculation + +pub fn popup_rect_centered_test() { + let screen = + Rect(position: Position(x: 0, y: 0), size: Size(width: 80, height: 24)) + let p = gpopup.popup_new(40, 10) + let r = gpopup.popup_rect(screen, p) + r.position.x |> should.equal(20) + r.position.y |> should.equal(7) + r.size.width |> should.equal(40) + r.size.height |> should.equal(10) +} + +pub fn popup_area_is_inside_border_test() { + let screen = + Rect(position: Position(x: 0, y: 0), size: Size(width: 80, height: 24)) + let p = gpopup.popup_new(40, 10) + let inner = gpopup.popup_area(screen, p) + inner.position.x |> should.equal(21) + inner.position.y |> should.equal(8) + inner.size.width |> should.equal(38) + inner.size.height |> should.equal(8) +} + +pub fn popup_clamps_to_screen_test() { + let screen = + Rect(position: Position(x: 0, y: 0), size: Size(width: 20, height: 10)) + let p = gpopup.popup_new(100, 100) + let r = gpopup.popup_rect(screen, p) + r.size.width |> should.equal(20) + r.size.height |> should.equal(10) +} + +// ───────────────────────────────────────────────────────────────── +// statusbar: renders sections at correct positions + +pub fn statusbar_left_at_start_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 20, height: 1)) + let buf = make_buf(20, 1) + let sb = gsbar.statusbar_new() |> gsbar.with_left([span.line_plain("LEFT")]) + let result = gsbar.render(buf, area, sb) + read_row(result, 0, 0, 4) |> should.equal("LEFT") +} + +pub fn statusbar_right_flush_right_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 20, height: 1)) + let buf = make_buf(20, 1) + let sb = gsbar.statusbar_new() |> gsbar.with_right([span.line_plain("END")]) + let result = gsbar.render(buf, area, sb) + read_row(result, 0, 17, 3) |> should.equal("END") +} + +pub fn statusbar_plain_text_inherits_bar_background_test() { + let area = + Rect(position: Position(x: 0, y: 0), size: Size(width: 12, height: 1)) + let buf = make_buf(12, 1) + let sb = + gsbar.statusbar_new() + |> gsbar.with_left([span.line_plain("LEFT")]) + |> gsbar.with_style(style.Indexed(15), style.Indexed(4)) + let result = gsbar.render(buf, area, sb) + buffer.cell_bg(buffer.get_cell(result, Position(x: 0, y: 0))) + |> should.equal(style.Indexed(4)) + buffer.cell_bg(buffer.get_cell(result, Position(x: 8, y: 0))) + |> should.equal(style.Indexed(4)) +} From 001e8a9da29d49b9c6d5717be90cd73b85f7ef91 Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:02:21 +0200 Subject: [PATCH 02/10] Add etui TUI library initial implementation Introduce the etui terminal UI library for Gleam. Adds core modules plus JS/Erlang backend bindings and FFI shims. App loop implementations include synchronous Erlang loops and async JS/Node/browser variants with buffered, animated, and cursor-aware render paths. --- src/etui.gleam | 81 +++ src/etui/anim.gleam | 168 +++++ src/etui/app.gleam | 969 ++++++++++++++++++++++++++++ src/etui/backend.gleam | 106 +++ src/etui/backend/browser.gleam | 205 ++++++ src/etui/backend/browser_ffi.mjs | 201 ++++++ src/etui/backend/default.gleam | 40 ++ src/etui/backend/erlang.gleam | 369 +++++++++++ src/etui/backend/node.gleam | 206 ++++++ src/etui/backend/node_ffi.mjs | 230 +++++++ src/etui/braille.gleam | 98 +++ src/etui/buffer.gleam | 767 ++++++++++++++++++++++ src/etui/color.gleam | 133 ++++ src/etui/cursor.gleam | 55 ++ src/etui/focus.gleam | 148 +++++ src/etui/geometry.gleam | 804 +++++++++++++++++++++++ src/etui/keymap.gleam | 183 ++++++ src/etui/keys.gleam | 147 +++++ src/etui/span.gleam | 226 +++++++ src/etui/style.gleam | 342 ++++++++++ src/etui/text.gleam | 367 +++++++++++ src/etui/theme.gleam | 395 ++++++++++++ src/etui/undo.gleam | 121 ++++ src/etui/widget.gleam | 183 ++++++ src/etui/widgets/block.gleam | 412 ++++++++++++ src/etui/widgets/canvas.gleam | 235 +++++++ src/etui/widgets/chart.gleam | 310 +++++++++ src/etui/widgets/clear.gleam | 17 + src/etui/widgets/dialog.gleam | 271 ++++++++ src/etui/widgets/form.gleam | 465 +++++++++++++ src/etui/widgets/gauge.gleam | 180 ++++++ src/etui/widgets/gradient_bar.gleam | 236 +++++++ src/etui/widgets/hbar.gleam | 344 ++++++++++ src/etui/widgets/help.gleam | 177 +++++ src/etui/widgets/input.gleam | 259 ++++++++ src/etui/widgets/line.gleam | 96 +++ src/etui/widgets/line_gauge.gleam | 207 ++++++ src/etui/widgets/list.gleam | 294 +++++++++ src/etui/widgets/marquee.gleam | 102 +++ src/etui/widgets/multi_select.gleam | 270 ++++++++ src/etui/widgets/notification.gleam | 271 ++++++++ src/etui/widgets/paginator.gleam | 146 +++++ src/etui/widgets/paragraph.gleam | 160 +++++ src/etui/widgets/popup.gleam | 123 ++++ src/etui/widgets/progress.gleam | 248 +++++++ src/etui/widgets/scene.gleam | 421 ++++++++++++ src/etui/widgets/scroll_view.gleam | 198 ++++++ src/etui/widgets/scrollbar.gleam | 327 ++++++++++ src/etui/widgets/sparkline.gleam | 168 +++++ src/etui/widgets/spinner.gleam | 158 +++++ src/etui/widgets/statusbar.gleam | 172 +++++ src/etui/widgets/table.gleam | 356 ++++++++++ src/etui/widgets/tabs.gleam | 168 +++++ src/etui/widgets/textarea.gleam | 516 +++++++++++++++ src/etui/widgets/tree.gleam | 482 ++++++++++++++ src/etui_buffer_array_ffi.erl | 167 +++++ src/etui_buffer_array_ffi.mjs | 17 + src/etui_run_ffi.erl | 16 + src/etui_terminal_ffi.erl | 305 +++++++++ src/etui_tty_state.erl | 29 + 60 files changed, 14867 insertions(+) create mode 100644 src/etui.gleam create mode 100644 src/etui/anim.gleam create mode 100644 src/etui/app.gleam create mode 100644 src/etui/backend.gleam create mode 100644 src/etui/backend/browser.gleam create mode 100644 src/etui/backend/browser_ffi.mjs create mode 100644 src/etui/backend/default.gleam create mode 100644 src/etui/backend/erlang.gleam create mode 100644 src/etui/backend/node.gleam create mode 100644 src/etui/backend/node_ffi.mjs create mode 100644 src/etui/braille.gleam create mode 100644 src/etui/buffer.gleam create mode 100644 src/etui/color.gleam create mode 100644 src/etui/cursor.gleam create mode 100644 src/etui/focus.gleam create mode 100644 src/etui/geometry.gleam create mode 100644 src/etui/keymap.gleam create mode 100644 src/etui/keys.gleam create mode 100644 src/etui/span.gleam create mode 100644 src/etui/style.gleam create mode 100644 src/etui/text.gleam create mode 100644 src/etui/theme.gleam create mode 100644 src/etui/undo.gleam create mode 100644 src/etui/widget.gleam create mode 100644 src/etui/widgets/block.gleam create mode 100644 src/etui/widgets/canvas.gleam create mode 100644 src/etui/widgets/chart.gleam create mode 100644 src/etui/widgets/clear.gleam create mode 100644 src/etui/widgets/dialog.gleam create mode 100644 src/etui/widgets/form.gleam create mode 100644 src/etui/widgets/gauge.gleam create mode 100644 src/etui/widgets/gradient_bar.gleam create mode 100644 src/etui/widgets/hbar.gleam create mode 100644 src/etui/widgets/help.gleam create mode 100644 src/etui/widgets/input.gleam create mode 100644 src/etui/widgets/line.gleam create mode 100644 src/etui/widgets/line_gauge.gleam create mode 100644 src/etui/widgets/list.gleam create mode 100644 src/etui/widgets/marquee.gleam create mode 100644 src/etui/widgets/multi_select.gleam create mode 100644 src/etui/widgets/notification.gleam create mode 100644 src/etui/widgets/paginator.gleam create mode 100644 src/etui/widgets/paragraph.gleam create mode 100644 src/etui/widgets/popup.gleam create mode 100644 src/etui/widgets/progress.gleam create mode 100644 src/etui/widgets/scene.gleam create mode 100644 src/etui/widgets/scroll_view.gleam create mode 100644 src/etui/widgets/scrollbar.gleam create mode 100644 src/etui/widgets/sparkline.gleam create mode 100644 src/etui/widgets/spinner.gleam create mode 100644 src/etui/widgets/statusbar.gleam create mode 100644 src/etui/widgets/table.gleam create mode 100644 src/etui/widgets/tabs.gleam create mode 100644 src/etui/widgets/textarea.gleam create mode 100644 src/etui/widgets/tree.gleam create mode 100644 src/etui_buffer_array_ffi.erl create mode 100644 src/etui_buffer_array_ffi.mjs create mode 100644 src/etui_run_ffi.erl create mode 100644 src/etui_terminal_ffi.erl create mode 100644 src/etui_tty_state.erl diff --git a/src/etui.gleam b/src/etui.gleam new file mode 100644 index 0000000..b82961f --- /dev/null +++ b/src/etui.gleam @@ -0,0 +1,81 @@ +/// etui, TUI library for Gleam. +/// +/// Correct Unicode, minimal diff, no terminal left broken. +/// +/// ## Quick start +/// +/// ```gleam +/// import etui/app +/// import etui/backend +/// import etui/backend/default +/// import etui/buffer +/// import etui/geometry.{rect_new} +/// +/// pub fn main() { +/// let _ = app.run_buffered( +/// default.new(), +/// Nil, +/// fn(_state, screen) { buffer.buffer_new(screen) }, +/// fn(ev, state) { case ev { backend.KeyPress("q") -> state _ -> state } }, +/// fn(_) { False }, +/// 16, +/// ) +/// } +/// ``` +/// +/// ## Module map +/// +/// | Module | Purpose | +/// |--------|---------| +/// | `etui/app` | Application event loop (`run`, `run_buffered`, `run_animated`, `run_buffered_cursor`) | +/// | `etui/backend` | Terminal event types and render ops | +/// | `etui/backend/default` | Platform-selecting backend (`new()` works on Erlang and JS) | +/// | `etui/buffer` | Cell grid storage, Unicode-aware rendering, diff output | +/// | `etui/geometry` | Layout math: `Rect`, `Constraint`, `split`, `resolve_sizes` | +/// | `etui/style` | Colors (Default / Indexed / Rgb), modifiers, ANSI sequences | +/// | `etui/text` | Grapheme cluster width, truncate, pad, Unicode-correct | +/// | `etui/span` | Inline styled text (`Span`, `Line`) | +/// | `etui/keys` | Key name constants and `match/1` for pattern-based dispatch | +/// | `etui/keymap` | Command-table key dispatch with help-text generation | +/// | `etui/theme` | Built-in colour themes (Dracula, Nord, Catppuccin, Monokai, …) | +/// | `etui/anim` | Animation helpers: lerp, easing, oscillate, keyframe sequences | +/// | `etui/cursor` | Hardware cursor ANSI sequences (show/hide/move/shape) | +/// | `etui/focus` | Focus-ring for multi-panel UIs | +/// | `etui/undo` | Generic undo/redo history stack | +/// | `etui/color` | RGB interpolation, gradients, hue-to-RGB | +/// +/// ### Widgets +/// +/// All stateless unless noted; stateful widgets store state externally. +/// +/// | Widget | Module | +/// |--------|--------| +/// | Block / border | `etui/widgets/block` | +/// | Paragraph (text) | `etui/widgets/paragraph` | +/// | Scrollable list | `etui/widgets/list` *(stateful)* | +/// | Table / grid | `etui/widgets/table` *(stateful)* | +/// | Tree view | `etui/widgets/tree` *(stateful)* | +/// | Single-line input | `etui/widgets/input` *(stateful)* | +/// | Multi-line textarea | `etui/widgets/textarea` *(stateful)* | +/// | Form (multi-field) | `etui/widgets/form` *(stateful)* | +/// | Tabs | `etui/widgets/tabs` | +/// | Dialog | `etui/widgets/dialog` *(stateful)* | +/// | Notification | `etui/widgets/notification` | +/// | Status bar | `etui/widgets/statusbar` | +/// | Progress bar | `etui/widgets/progress` | +/// | Horizontal bar | `etui/widgets/hbar` | +/// | Gradient bar | `etui/widgets/gradient_bar` | +/// | Scrollbar | `etui/widgets/scrollbar` | +/// | Spinner | `etui/widgets/spinner` | +/// | Marquee | `etui/widgets/marquee` | +/// | Scroll view | `etui/widgets/scroll_view` *(stateful)* | +/// | Canvas (pixel) | `etui/widgets/canvas` | +/// | Braille graphics | `etui/braille` | +/// | Chart | `etui/widgets/chart` | +/// | Scene (composition) | `etui/widgets/scene` | +/// | Clear | `etui/widgets/clear` | +/// | Paginator | `etui/widgets/paginator` | +/// | Help (key bindings) | `etui/widgets/help` | +/// | Fieldset | `etui/widgets/fieldset` | +/// | MultiSelect | `etui/widgets/multi_select` *(stateful)* | +pub const version = "1.0.0" diff --git a/src/etui/anim.gleam b/src/etui/anim.gleam new file mode 100644 index 0000000..de1a26f --- /dev/null +++ b/src/etui/anim.gleam @@ -0,0 +1,168 @@ +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Animation state + +pub type AnimState { + AnimState(frame: Int) +} + +pub fn anim_new() -> AnimState { + AnimState(frame: 0) +} + +pub fn tick(state: AnimState) -> AnimState { + AnimState(frame: state.frame + 1) +} + +pub fn reset(_state: AnimState) -> AnimState { + AnimState(frame: 0) +} + +pub fn is_done(state: AnimState, duration: Int) -> Bool { + state.frame >= duration +} + +// ───────────────────────────────────────────────────────────────── +// Interpolation (integer math only, deterministic on all targets) + +/// Linear interpolation from `start` to `end_` over `duration` frames. +pub fn lerp(start: Int, end_: Int, frame: Int, duration: Int) -> Int { + case duration <= 0 { + True -> end_ + False -> { + let t = int.clamp(frame, 0, duration) + start + { end_ - start } * t / duration + } + } +} + +/// EaseOut (fast start, slow end). Quadratic approximation with integers. +pub fn ease_out(start: Int, end_: Int, frame: Int, duration: Int) -> Int { + case duration <= 0 { + True -> end_ + False -> { + let t = int.clamp(frame, 0, duration) * 100 / duration + let curve = t * { 200 - t } / 100 + start + { end_ - start } * curve / 100 + } + } +} + +/// EaseIn (slow start, fast end). Quadratic approximation. +pub fn ease_in(start: Int, end_: Int, frame: Int, duration: Int) -> Int { + case duration <= 0 { + True -> end_ + False -> { + let t = int.clamp(frame, 0, duration) * 100 / duration + let curve = t * t / 100 + start + { end_ - start } * curve / 100 + } + } +} + +/// Oscillate between `min` and `max` with a given `period` (in frames). +/// Returns current value in the triangle wave. +pub fn oscillate(min: Int, max: Int, frame: Int, period: Int) -> Int { + case period <= 0 { + True -> min + False -> { + let range = max - min + let half = period / 2 + let pos = frame % period + case pos < half { + True -> min + range * pos / int.max(1, half) + False -> max - range * { pos - half } / int.max(1, period - half) + } + } + } +} + +/// Returns True during the "on" half of each blink period. +/// `period` ≤ 0 means always on. +pub fn blink(frame: Int, period: Int) -> Bool { + case period <= 0 { + True -> True + False -> frame % period < period / 2 + } +} + +/// Cycle through [0, count) returning the current index for the given frame. +pub fn cycle(frame: Int, count: Int) -> Int { + case count <= 0 { + True -> 0 + False -> frame % count + } +} + +// ───────────────────────────────────────────────────────────────── +// Easing enum + +pub type Easing { + Linear + EaseIn + EaseOut + EaseInOut +} + +/// Unified interpolation with selectable easing curve. +pub fn interpolate( + start: Int, + end_: Int, + frame: Int, + duration: Int, + easing: Easing, +) -> Int { + case easing { + Linear -> lerp(start, end_, frame, duration) + EaseIn -> ease_in(start, end_, frame, duration) + EaseOut -> ease_out(start, end_, frame, duration) + EaseInOut -> ease_in_out(start, end_, frame, duration) + } +} + +/// EaseInOut (slow start, fast middle, slow end). Quadratic approximation. +pub fn ease_in_out(start: Int, end_: Int, frame: Int, duration: Int) -> Int { + case duration <= 0 { + True -> end_ + False -> { + let t = int.clamp(frame, 0, duration) * 100 / duration + let curve = case t < 50 { + True -> t * t / 50 + False -> { + let inv = 100 - t + 100 - inv * inv / 50 + } + } + start + { end_ - start } * curve / 100 + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Keyframe sequences + +pub type Keyframe { + Keyframe(at: Int, value: Int) +} + +/// Interpolate along a keyframe sequence at the given frame. +/// Keyframes are sorted automatically, so order at the call site doesn't matter. +/// Returns the last keyframe value if frame exceeds the sequence. +pub fn sequence(keyframes: List(Keyframe), frame: Int, easing: Easing) -> Int { + let sorted = list.sort(keyframes, fn(a, b) { int.compare(a.at, b.at) }) + find_segment(sorted, frame, easing) +} + +fn find_segment(kfs: List(Keyframe), frame: Int, easing: Easing) -> Int { + case kfs { + [] -> 0 + [Keyframe(_, v)] -> v + [Keyframe(at1, v1), Keyframe(at2, v2), ..rest] -> + case frame < at2 { + True -> interpolate(v1, v2, frame - at1, at2 - at1, easing) + False -> find_segment([Keyframe(at2, v2), ..rest], frame, easing) + } + } +} diff --git a/src/etui/app.gleam b/src/etui/app.gleam new file mode 100644 index 0000000..349572d --- /dev/null +++ b/src/etui/app.gleam @@ -0,0 +1,969 @@ +/// Application event loop. Type-safe, with crash-restore guarantees. +/// +/// `run` wraps the entire loop in a `try...after` (via FFI) so that the +/// terminal is always restored, even if the user's render or event +/// function panics. No more broken TTY on crash. +/// +/// `run_buffered` is the high-level alternative: the render function returns +/// a `Buffer` instead of a list of `RenderOp`s. The app loop handles diffing +/// automatically, only changed cells are emitted each frame. +/// +/// `run_animated` is like `run_buffered` but also passes the current +/// `anim.AnimState` to the render function, auto-ticking every frame. +/// Use it when your UI has spinners, marquees, blinking cursors, or other +/// frame-dependent widgets, no need to store `AnimState` in your model. +import etui/anim +import etui/backend.{type InputEvent, type RenderOp} +import etui/buffer +import etui/cursor +import etui/geometry + +@target(javascript) +import gleam/javascript/promise + +pub type AppResult(state) { + Success(final_state: state) + Error(reason: String) +} + +// Erlang try/after: runs cleanup even on panic. Returns thunk's value. +// JS fallback: cleanup registered via backend's register_cleanup_ffi (signal handlers). +@external(erlang, "etui_run_ffi", "with_cleanup") +fn with_cleanup(thunk: fn() -> a, cleanup: fn() -> Nil) -> a { + let _ = cleanup + thunk() +} + +@target(erlang) +/// Run the app loop. +/// +/// Lifecycle: +/// 1. `b.init()`, enter raw mode, alt screen. +/// 2. Loop: `render(state)` → emit ops → `b.poll()` → `on_event()`. +/// 3. Exit when `should_quit(state)` returns `True`. +/// 4. `b.cleanup()`, always runs, even on panic. +/// +/// ```gleam +/// app.run( +/// default.new(), +/// Model(count: 0), +/// fn(m) { [Write(int.to_string(m.count))] }, +/// fn(ev, m) { case ev { KeyPress("q") -> m KeyPress(_) -> Model(count: m.count + 1) _ -> m } }, +/// fn(m) { m.count >= 10 }, +/// 16, +/// ) +/// ``` +pub fn run( + b: backend.Backend(backend_state), + init_state: state, + render: fn(state) -> List(RenderOp), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> AppResult(state) { + case b.init() { + Ok(bs) -> + // with_cleanup guarantees b.cleanup(bs) runs on both normal exit + // and panic. On normal exit the thunk returns Success(state); + // on panic after runs, terminal is restored, exception re-raises. + with_cleanup( + fn() { + let #(final_state, final_bs) = + loop( + b, + bs, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + ) + b.cleanup(final_bs) + Success(final_state) + }, + fn() { b.cleanup(bs) }, + ) + _ -> Error("Terminal init failed") + } +} + +@target(erlang) +type LoopStep(s, bst) { + StepQuit(state: s, bs: bst) + StepContinue(event: InputEvent, state: s, bs: bst) +} + +// Shared core of the erlang app loops: render ops, poll one event, update +// the model, test for quit. A failed render or poll ends the loop. Each loop +// keeps its own frame-building (diff, anim, cursor). +@target(erlang) +fn step( + b: backend.Backend(backend_state), + bs: backend_state, + state: state, + ops: List(RenderOp), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> LoopStep(state, backend_state) { + case b.render(bs, ops) { + Ok(bs2) -> + case b.poll(bs2, poll_timeout_ms) { + Ok(#(event, bs3)) -> { + let next = on_event(event, state) + case should_quit(next) { + True -> StepQuit(next, bs3) + False -> StepContinue(event, next, bs3) + } + } + _ -> StepQuit(state, bs2) + } + _ -> StepQuit(state, bs) + } +} + +@target(erlang) +fn loop( + b: backend.Backend(backend_state), + bs: backend_state, + state: state, + render: fn(state) -> List(RenderOp), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> #(state, backend_state) { + case + step(b, bs, state, render(state), on_event, should_quit, poll_timeout_ms) + { + StepQuit(s, final_bs) -> #(s, final_bs) + StepContinue(_event, next, bs3) -> + loop(b, bs3, next, render, on_event, should_quit, poll_timeout_ms) + } +} + +// ───────────────────────────────────────────────────────────────── +// Buffered app loop (automatic diff rendering) + +@target(erlang) +/// High-level app loop. The render function produces a `Buffer`; the loop +/// diffs it against the previous frame and emits only the changed cells. +/// +/// First frame: full `to_ansi` (clean slate). Subsequent frames: `diff_to_ansi`. +/// On `Resize`: full re-render at new size. +/// +/// ```gleam +/// app.run_buffered( +/// default.new(), +/// Model(count: 0), +/// fn(m, screen) { +/// buffer.buffer_new(screen) +/// |> paragraph.render(screen, paragraph.paragraph_new(int.to_string(m.count))) +/// }, +/// fn(ev, m) { case ev { KeyPress("q") -> m _ -> m } }, +/// fn(m) { m.quit }, +/// 16, +/// ) +/// ``` +pub fn run_buffered( + b: backend.Backend(backend_state), + init_state: state, + render: fn(state, geometry.Rect) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> AppResult(state) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + // A buffered app draws every cell itself, so the hardware cursor + // would only sit blinking wherever the last write landed. Hide it + // for the session and restore it on exit. + let _ = b.render(bs, [backend.Write(cursor.hide())]) + let #(size, bs2) = case b.next_size(bs) { + Ok(#(sz, bs1)) -> #(sz, bs1) + _ -> #(backend.TerminalSize(width: 80, height: 24), bs) + } + let screen = geometry.rect_new(0, 0, size.width, size.height) + let blank = buffer.buffer_new(screen) + let init_state = + on_event(backend.Resize(size.width, size.height), init_state) + let #(final_state, final_bs) = + loop_buffered( + b, + bs2, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + blank, + True, + ) + let _ = b.render(final_bs, [backend.Write(cursor.show())]) + b.cleanup(final_bs) + Success(final_state) + }, + fn() { + let _ = b.render(bs, [backend.Write(cursor.show())]) + b.cleanup(bs) + }, + ) + _ -> Error("Terminal init failed") + } +} + +@target(erlang) +fn loop_buffered( + b: backend.Backend(backend_state), + bs: backend_state, + state: state, + render: fn(state, geometry.Rect) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, + prev_buf: buffer.Buffer, + first_frame: Bool, +) -> #(state, backend_state) { + let screen = buffer.area(prev_buf) + let curr_buf = render(state, screen) + let ansi = case first_frame { + True -> buffer.to_ansi(curr_buf) + False -> buffer.diff_to_ansi(prev_buf, curr_buf) + } + let ops = case ansi { + "" -> [] + _ -> + case first_frame { + True -> [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(ansi), + ] + False -> [backend.Write(ansi)] + } + } + case step(b, bs, state, ops, on_event, should_quit, poll_timeout_ms) { + StepQuit(s, final_bs) -> #(s, final_bs) + StepContinue(event, next, bs3) -> { + let #(new_prev, is_first) = case event { + backend.Resize(w, h) -> { + let new_screen = geometry.rect_new(0, 0, w, h) + #(buffer.buffer_new(new_screen), True) + } + _ -> #(curr_buf, False) + } + loop_buffered( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + new_prev, + is_first, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Animated buffered app loop (auto-tick AnimState) + +@target(erlang) +/// Like `run_buffered` but passes an `anim.AnimState` to the render function, +/// auto-ticked every frame. Use when your UI has spinners, blinking widgets, +/// marquees, or any frame-dependent animation, no manual tick needed. +/// +/// ```gleam +/// app.run_animated( +/// default.new(), +/// Model(quit: False), +/// fn(m, screen, anim_state) { +/// let frame = anim_state.frame +/// buffer.buffer_new(screen) +/// |> spinner.render(area, spinner.spinner_new() |> spinner.with_frame(frame)) +/// }, +/// fn(ev, m) { case ev { backend.KeyPress("q") -> Model(quit: True) _ -> m } }, +/// fn(m) { m.quit }, +/// 16, +/// ) +/// ``` +pub fn run_animated( + b: backend.Backend(backend_state), + init_state: state, + render: fn(state, geometry.Rect, anim.AnimState) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> AppResult(state) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + let _ = b.render(bs, [backend.Write(cursor.hide())]) + let #(size, bs2) = case b.next_size(bs) { + Ok(#(sz, bs1)) -> #(sz, bs1) + _ -> #(backend.TerminalSize(width: 80, height: 24), bs) + } + let screen = geometry.rect_new(0, 0, size.width, size.height) + let blank = buffer.buffer_new(screen) + let init_state = + on_event(backend.Resize(size.width, size.height), init_state) + let #(final_state, final_bs) = + loop_animated( + b, + bs2, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + blank, + True, + anim.anim_new(), + ) + let _ = b.render(final_bs, [backend.Write(cursor.show())]) + b.cleanup(final_bs) + Success(final_state) + }, + fn() { + let _ = b.render(bs, [backend.Write(cursor.show())]) + b.cleanup(bs) + }, + ) + _ -> Error("Terminal init failed") + } +} + +@target(erlang) +fn loop_animated( + b: backend.Backend(backend_state), + bs: backend_state, + state: state, + render: fn(state, geometry.Rect, anim.AnimState) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, + prev_buf: buffer.Buffer, + first_frame: Bool, + anim_state: anim.AnimState, +) -> #(state, backend_state) { + let screen = buffer.area(prev_buf) + let curr_buf = render(state, screen, anim_state) + let ansi = case first_frame { + True -> buffer.to_ansi(curr_buf) + False -> buffer.diff_to_ansi(prev_buf, curr_buf) + } + let ops = case ansi { + "" -> [] + _ -> + case first_frame { + True -> [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(ansi), + ] + False -> [backend.Write(ansi)] + } + } + let next_anim = anim.tick(anim_state) + case step(b, bs, state, ops, on_event, should_quit, poll_timeout_ms) { + StepQuit(s, final_bs) -> #(s, final_bs) + StepContinue(event, next, bs3) -> { + let #(new_prev, is_first) = case event { + backend.Resize(w, h) -> { + let new_screen = geometry.rect_new(0, 0, w, h) + #(buffer.buffer_new(new_screen), True) + } + _ -> #(curr_buf, False) + } + loop_animated( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + new_prev, + is_first, + next_anim, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Buffered loop with hardware cursor positioning + +@target(erlang) +/// Like `run_buffered` but the render function also returns an optional cursor +/// position as `Result(geometry.Position, Nil)`. +/// +/// - `Ok(pos)`, shows the cursor at `pos` (0-based). Use for text inputs and +/// text areas where the user needs to see the insertion point. +/// - `Error(Nil)`, hides the cursor. Use for read-only views. +/// +/// The cursor is hidden automatically on init and restored on exit. +/// +/// ```gleam +/// app.run_buffered_cursor( +/// default.new(), +/// Model(text: "", cursor: 0), +/// fn(m, screen) { +/// let buf = buffer.buffer_new(screen) |> input.render(area, w, input_state) +/// let cursor_pos = geometry.Position(x: area.x + input_state.cursor_x + 1, y: area.y) +/// #(buf, Ok(cursor_pos)) +/// }, +/// on_event, +/// fn(m) { m.quit }, +/// 16, +/// ) +/// ``` +pub fn run_buffered_cursor( + b: backend.Backend(backend_state), + init_state: state, + render: fn(state, geometry.Rect) -> + #(buffer.Buffer, Result(geometry.Position, Nil)), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> AppResult(state) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + let #(size, bs2) = case b.next_size(bs) { + Ok(#(sz, bs1)) -> #(sz, bs1) + _ -> #(backend.TerminalSize(width: 80, height: 24), bs) + } + let screen = geometry.rect_new(0, 0, size.width, size.height) + let blank = buffer.buffer_new(screen) + // Hide cursor on init; render loop will show it when needed. + let _ = b.render(bs2, [backend.Write(cursor.hide())]) + let init_state = + on_event(backend.Resize(size.width, size.height), init_state) + let #(final_state, final_bs) = + loop_buffered_cursor( + b, + bs2, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + blank, + True, + ) + // Restore cursor visibility on exit. + let _ = b.render(final_bs, [backend.Write(cursor.show())]) + b.cleanup(final_bs) + Success(final_state) + }, + fn() { + let _ = b.render(bs, [backend.Write(cursor.show())]) + b.cleanup(bs) + }, + ) + _ -> Error("Terminal init failed") + } +} + +@target(erlang) +fn loop_buffered_cursor( + b: backend.Backend(backend_state), + bs: backend_state, + state: state, + render: fn(state, geometry.Rect) -> + #(buffer.Buffer, Result(geometry.Position, Nil)), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, + prev_buf: buffer.Buffer, + first_frame: Bool, +) -> #(state, backend_state) { + let screen = buffer.area(prev_buf) + let #(curr_buf, cursor_pos) = render(state, screen) + let ansi = case first_frame { + True -> buffer.to_ansi(curr_buf) + False -> buffer.diff_to_ansi(prev_buf, curr_buf) + } + let cursor_ansi = case cursor_pos { + Ok(pos) -> + cursor.hide() <> cursor.move_to(pos.y + 1, pos.x + 1) <> cursor.show() + _ -> cursor.hide() + } + let ops = case ansi { + "" -> [backend.Write(cursor_ansi)] + _ -> + case first_frame { + True -> [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(ansi <> cursor_ansi), + ] + False -> [backend.Write(ansi <> cursor_ansi)] + } + } + case step(b, bs, state, ops, on_event, should_quit, poll_timeout_ms) { + StepQuit(s, final_bs) -> #(s, final_bs) + StepContinue(event, next, bs3) -> { + let #(new_prev, is_first) = case event { + backend.Resize(w, h) -> { + let new_screen = geometry.rect_new(0, 0, w, h) + #(buffer.buffer_new(new_screen), True) + } + _ -> #(curr_buf, False) + } + loop_buffered_cursor( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + new_prev, + is_first, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// JavaScript async app loops (Node.js target) + +@target(javascript) +pub fn run( + b: backend.AsyncBackend(backend_state), + init_state: state, + render: fn(state) -> List(RenderOp), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> promise.Promise(AppResult(state)) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + promise.await( + loop_js( + b, + bs, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + ), + fn(r) { + let #(final_state, final_bs) = r + b.cleanup(final_bs) + promise.resolve(Success(final_state)) + }, + ) + }, + fn() { b.cleanup(bs) }, + ) + _ -> promise.resolve(Error("Terminal init failed")) + } +} + +@target(javascript) +fn loop_js( + b: backend.AsyncBackend(backend_state), + bs: backend_state, + state: state, + render: fn(state) -> List(RenderOp), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> promise.Promise(#(state, backend_state)) { + case b.render(bs, render(state)) { + Ok(bs2) -> + promise.await(b.poll(bs2, poll_timeout_ms), fn(poll_result) { + case poll_result { + Ok(#(event, bs3)) -> { + let next = on_event(event, state) + case should_quit(next) { + True -> promise.resolve(#(next, bs3)) + False -> + loop_js( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + ) + } + } + _ -> promise.resolve(#(state, bs2)) + } + }) + _ -> promise.resolve(#(state, bs)) + } +} + +@target(javascript) +pub fn run_buffered( + b: backend.AsyncBackend(backend_state), + init_state: state, + render: fn(state, geometry.Rect) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> promise.Promise(AppResult(state)) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + let #(size, bs2) = case b.next_size(bs) { + Ok(#(sz, bs1)) -> #(sz, bs1) + _ -> #(backend.TerminalSize(width: 80, height: 24), bs) + } + let screen = geometry.rect_new(0, 0, size.width, size.height) + let blank = buffer.buffer_new(screen) + let init_state = + on_event(backend.Resize(size.width, size.height), init_state) + promise.await( + loop_buffered_js( + b, + bs2, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + blank, + True, + ), + fn(r) { + let #(final_state, final_bs) = r + b.cleanup(final_bs) + promise.resolve(Success(final_state)) + }, + ) + }, + fn() { b.cleanup(bs) }, + ) + _ -> promise.resolve(Error("Terminal init failed")) + } +} + +@target(javascript) +fn loop_buffered_js( + b: backend.AsyncBackend(backend_state), + bs: backend_state, + state: state, + render: fn(state, geometry.Rect) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, + prev_buf: buffer.Buffer, + first_frame: Bool, +) -> promise.Promise(#(state, backend_state)) { + let screen = buffer.area(prev_buf) + let curr_buf = render(state, screen) + let ansi = case first_frame { + True -> buffer.to_ansi(curr_buf) + False -> buffer.diff_to_ansi(prev_buf, curr_buf) + } + let ops = case ansi { + "" -> [] + _ -> + case first_frame { + True -> [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(ansi), + ] + False -> [backend.Write(ansi)] + } + } + case b.render(bs, ops) { + Ok(bs2) -> + promise.await(b.poll(bs2, poll_timeout_ms), fn(poll_result) { + case poll_result { + Ok(#(event, bs3)) -> { + let next = on_event(event, state) + case should_quit(next) { + True -> promise.resolve(#(next, bs3)) + False -> { + let #(new_prev, is_first) = case event { + backend.Resize(w, h) -> { + let new_screen = geometry.rect_new(0, 0, w, h) + #(buffer.buffer_new(new_screen), True) + } + _ -> #(curr_buf, False) + } + loop_buffered_js( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + new_prev, + is_first, + ) + } + } + } + _ -> promise.resolve(#(state, bs2)) + } + }) + _ -> promise.resolve(#(state, bs)) + } +} + +@target(javascript) +pub fn run_animated( + b: backend.AsyncBackend(backend_state), + init_state: state, + render: fn(state, geometry.Rect, anim.AnimState) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> promise.Promise(AppResult(state)) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + let _ = b.render(bs, [backend.Write(cursor.hide())]) + let #(size, bs2) = case b.next_size(bs) { + Ok(#(sz, bs1)) -> #(sz, bs1) + _ -> #(backend.TerminalSize(width: 80, height: 24), bs) + } + let screen = geometry.rect_new(0, 0, size.width, size.height) + let blank = buffer.buffer_new(screen) + let init_state = + on_event(backend.Resize(size.width, size.height), init_state) + promise.await( + loop_animated_js( + b, + bs2, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + blank, + True, + anim.anim_new(), + ), + fn(r) { + let #(final_state, final_bs) = r + let _ = b.render(final_bs, [backend.Write(cursor.show())]) + b.cleanup(final_bs) + promise.resolve(Success(final_state)) + }, + ) + }, + fn() { + let _ = b.render(bs, [backend.Write(cursor.show())]) + b.cleanup(bs) + }, + ) + _ -> promise.resolve(Error("Terminal init failed")) + } +} + +@target(javascript) +fn loop_animated_js( + b: backend.AsyncBackend(backend_state), + bs: backend_state, + state: state, + render: fn(state, geometry.Rect, anim.AnimState) -> buffer.Buffer, + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, + prev_buf: buffer.Buffer, + first_frame: Bool, + anim_state: anim.AnimState, +) -> promise.Promise(#(state, backend_state)) { + let screen = buffer.area(prev_buf) + let curr_buf = render(state, screen, anim_state) + let ansi = case first_frame { + True -> buffer.to_ansi(curr_buf) + False -> buffer.diff_to_ansi(prev_buf, curr_buf) + } + let ops = case ansi { + "" -> [] + _ -> + case first_frame { + True -> [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(ansi), + ] + False -> [backend.Write(ansi)] + } + } + let next_anim = anim.tick(anim_state) + case b.render(bs, ops) { + Ok(bs2) -> + promise.await(b.poll(bs2, poll_timeout_ms), fn(poll_result) { + case poll_result { + Ok(#(event, bs3)) -> { + let next = on_event(event, state) + case should_quit(next) { + True -> promise.resolve(#(next, bs3)) + False -> { + let #(new_prev, is_first) = case event { + backend.Resize(w, h) -> { + let new_screen = geometry.rect_new(0, 0, w, h) + #(buffer.buffer_new(new_screen), True) + } + _ -> #(curr_buf, False) + } + loop_animated_js( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + new_prev, + is_first, + next_anim, + ) + } + } + } + _ -> promise.resolve(#(state, bs2)) + } + }) + _ -> promise.resolve(#(state, bs)) + } +} + +@target(javascript) +pub fn run_buffered_cursor( + b: backend.AsyncBackend(backend_state), + init_state: state, + render: fn(state, geometry.Rect) -> + #(buffer.Buffer, Result(geometry.Position, Nil)), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, +) -> promise.Promise(AppResult(state)) { + case b.init() { + Ok(bs) -> + with_cleanup( + fn() { + let #(size, bs2) = case b.next_size(bs) { + Ok(#(sz, bs1)) -> #(sz, bs1) + _ -> #(backend.TerminalSize(width: 80, height: 24), bs) + } + let screen = geometry.rect_new(0, 0, size.width, size.height) + let blank = buffer.buffer_new(screen) + let _ = b.render(bs2, [backend.Write(cursor.hide())]) + let init_state = + on_event(backend.Resize(size.width, size.height), init_state) + promise.await( + loop_buffered_cursor_js( + b, + bs2, + init_state, + render, + on_event, + should_quit, + poll_timeout_ms, + blank, + True, + ), + fn(r) { + let #(final_state, final_bs) = r + let _ = b.render(final_bs, [backend.Write(cursor.show())]) + b.cleanup(final_bs) + promise.resolve(Success(final_state)) + }, + ) + }, + fn() { + let _ = b.render(bs, [backend.Write(cursor.show())]) + b.cleanup(bs) + }, + ) + _ -> promise.resolve(Error("Terminal init failed")) + } +} + +@target(javascript) +fn loop_buffered_cursor_js( + b: backend.AsyncBackend(backend_state), + bs: backend_state, + state: state, + render: fn(state, geometry.Rect) -> + #(buffer.Buffer, Result(geometry.Position, Nil)), + on_event: fn(InputEvent, state) -> state, + should_quit: fn(state) -> Bool, + poll_timeout_ms: Int, + prev_buf: buffer.Buffer, + first_frame: Bool, +) -> promise.Promise(#(state, backend_state)) { + let screen = buffer.area(prev_buf) + let #(curr_buf, cursor_pos) = render(state, screen) + let ansi = case first_frame { + True -> buffer.to_ansi(curr_buf) + False -> buffer.diff_to_ansi(prev_buf, curr_buf) + } + let cursor_ansi = case cursor_pos { + Ok(pos) -> + cursor.hide() <> cursor.move_to(pos.y + 1, pos.x + 1) <> cursor.show() + _ -> cursor.hide() + } + let ops = case ansi { + "" -> [backend.Write(cursor_ansi)] + _ -> + case first_frame { + True -> [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(ansi <> cursor_ansi), + ] + False -> [backend.Write(ansi <> cursor_ansi)] + } + } + case b.render(bs, ops) { + Ok(bs2) -> + promise.await(b.poll(bs2, poll_timeout_ms), fn(poll_result) { + case poll_result { + Ok(#(event, bs3)) -> { + let next = on_event(event, state) + case should_quit(next) { + True -> promise.resolve(#(next, bs3)) + False -> { + let #(new_prev, is_first) = case event { + backend.Resize(w, h) -> { + let new_screen = geometry.rect_new(0, 0, w, h) + #(buffer.buffer_new(new_screen), True) + } + _ -> #(curr_buf, False) + } + loop_buffered_cursor_js( + b, + bs3, + next, + render, + on_event, + should_quit, + poll_timeout_ms, + new_prev, + is_first, + ) + } + } + } + _ -> promise.resolve(#(state, bs2)) + } + }) + _ -> promise.resolve(#(state, bs)) + } +} diff --git a/src/etui/backend.gleam b/src/etui/backend.gleam new file mode 100644 index 0000000..415d6a5 --- /dev/null +++ b/src/etui/backend.gleam @@ -0,0 +1,106 @@ +@target(javascript) +/// Terminal backend abstraction. Two implementations: Erlang + JS/Node. +import gleam/javascript/promise + +pub type RenderOp { + MoveCursor(x: Int, y: Int) + Write(String) + ClearScreen + EnterAltScreen + ExitAltScreen + /// Enable SGR mouse tracking (button + scroll events, pixel-precise coords). + EnableMouse + /// Disable all mouse tracking. + DisableMouse +} + +/// Mouse button identifier. +pub type MouseButton { + MouseLeft + MouseMiddle + MouseRight +} + +pub type InputEvent { + KeyPress(key: String) + Resize(width: Int, height: Int) + Tick + /// Mouse button pressed. `x`/`y` are 0-based terminal cell coordinates. + MousePress(x: Int, y: Int, button: MouseButton) + /// Mouse button released. + MouseRelease(x: Int, y: Int, button: MouseButton) + /// Mouse wheel scrolled. `up: True` = scroll up, `False` = scroll down. + MouseScroll(x: Int, y: Int, up: Bool) +} + +pub type TerminalSize { + TerminalSize(width: Int, height: Int) +} + +pub type Backend(state) { + Backend( + init: fn() -> Result(state, Error), + render: fn(state, List(RenderOp)) -> Result(state, Error), + poll: fn(state, Int) -> Result(#(InputEvent, state), Error), + next_size: fn(state) -> Result(#(TerminalSize, state), Error), + cleanup: fn(state) -> Nil, + ) +} + +@target(javascript) +pub type AsyncBackend(state) { + AsyncBackend( + init: fn() -> Result(state, Error), + render: fn(state, List(RenderOp)) -> Result(state, Error), + poll: fn(state, Int) -> promise.Promise(Result(#(InputEvent, state), Error)), + next_size: fn(state) -> Result(#(TerminalSize, state), Error), + cleanup: fn(state) -> Nil, + ) +} + +pub type Error { + TerminalUnsupported(reason: String) + IOError(reason: String) + Interrupted +} + +// ───────────────────────────────────────────────────────────────── +// Protocol operations + +pub fn init(backend: Backend(state)) -> Result(state, Error) { + backend.init() +} + +pub fn render( + backend: Backend(state), + state: state, + ops: List(RenderOp), +) -> Result(state, Error) { + backend.render(state, ops) +} + +pub fn poll( + backend: Backend(state), + state: state, + timeout_ms: Int, +) -> Result(#(InputEvent, state), Error) { + backend.poll(state, timeout_ms) +} + +pub fn next_size( + backend: Backend(state), + state: state, +) -> Result(#(TerminalSize, state), Error) { + backend.next_size(state) +} + +pub fn cleanup(backend: Backend(state), state: state) -> Nil { + backend.cleanup(state) +} + +// ───────────────────────────────────────────────────────────────── +// Render op utilities + +pub fn clear_and_home() -> List(RenderOp) { + [ClearScreen, MoveCursor(0, 0)] +} diff --git a/src/etui/backend/browser.gleam b/src/etui/backend/browser.gleam new file mode 100644 index 0000000..0a76aa7 --- /dev/null +++ b/src/etui/backend/browser.gleam @@ -0,0 +1,205 @@ +@target(javascript) +/// Browser (xterm.js) terminal backend for the JavaScript target. +/// +/// Provides the same `AsyncBackend` interface as `node.gleam` but uses an +/// xterm.js `Terminal` instance instead of Node's stdin/stdout. +/// +/// **Setup:** call `browser_ffi.setup(term)` from JavaScript before calling +/// your app's `main()`. The `priv/components/DinoBrowser.astro` component +/// shows the full wiring. +/// +/// Requirements: +/// - Compiled with `gleam build --target javascript` +/// - An xterm.js Terminal attached to the DOM before `main()` runs +/// +/// Example (JavaScript side): +/// ```javascript +/// import { Terminal } from "xterm"; +/// import { setup } from "./build/dev/javascript/etui/etui/backend/browser_ffi.mjs"; +/// import { main } from "./build/dev/javascript/etui/your_app.mjs"; +/// +/// const term = new Terminal({ cols: 120, rows: 36 }); +/// term.open(document.getElementById("terminal")); +/// setup(term); +/// main(); +/// ``` +import etui/backend.{ + type Error, type InputEvent, type RenderOp, type TerminalSize, ClearScreen, + DisableMouse, EnableMouse, EnterAltScreen, ExitAltScreen, IOError, MoveCursor, + Resize, Write, +} + +@target(javascript) +import gleam/int + +@target(javascript) +import gleam/javascript/promise + +@target(javascript) +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type BrowserState { + BrowserState(cols: Int, rows: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Backend construction + +@target(javascript) +pub fn new() -> backend.AsyncBackend(BrowserState) { + backend.AsyncBackend( + init: init_terminal, + render: render_ops, + poll: poll_input, + next_size: get_terminal_size, + cleanup: cleanup_terminal, + ) +} + +// ───────────────────────────────────────────────────────────────── +// FFI declarations (xterm.js via browser_ffi.mjs) + +@target(javascript) +@external(javascript, "./browser_ffi.mjs", "enterRaw") +fn enter_raw_ffi() -> Nil { + panic as "etui/backend/browser requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./browser_ffi.mjs", "exitRaw") +fn exit_raw_ffi() -> Nil { + panic as "etui/backend/browser requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./browser_ffi.mjs", "writeStdout") +fn write_stdout_ffi(s: String) -> Nil { + let _ = s + panic as "etui/backend/browser requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./browser_ffi.mjs", "windowSize") +fn window_size_ffi() -> Result(#(Int, Int), String) { + panic as "etui/backend/browser requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./browser_ffi.mjs", "pollInput") +fn poll_input_ffi(timeout_ms: Int) -> promise.Promise(Result(InputEvent, Nil)) { + let _ = timeout_ms + panic as "etui/backend/browser requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./browser_ffi.mjs", "registerCleanup") +fn register_cleanup_ffi(cleanup: fn() -> Nil) -> Nil { + let _ = cleanup + panic as "etui/backend/browser requires the JavaScript target" +} + +// ───────────────────────────────────────────────────────────────── +// ANSI sequences (identical to node/erlang backends) + +@target(javascript) +fn render_op_to_ansi(op: RenderOp) -> String { + case op { + Write(s) -> s + MoveCursor(x, y) -> + "\u{001B}[" <> int.to_string(y + 1) <> ";" <> int.to_string(x + 1) <> "H" + ClearScreen -> "\u{001B}[2J\u{001B}[H" + EnterAltScreen -> "\u{001B}[?1049h" + ExitAltScreen -> "\u{001B}[?1049l" + EnableMouse -> "\u{001B}[?1000h\u{001B}[?1002h\u{001B}[?1006h" + DisableMouse -> + "\u{001B}[?1007l\u{001B}[?1015l\u{001B}[?1006l\u{001B}[?1005l\u{001B}[?1003l\u{001B}[?1002l\u{001B}[?1000l" + } +} + +// ───────────────────────────────────────────────────────────────── +// Implementation + +@target(javascript) +fn init_terminal() -> Result(BrowserState, Error) { + enter_raw_ffi() + let ops = [EnterAltScreen, ClearScreen, EnableMouse] + let ansi = list.map(ops, render_op_to_ansi) |> string_join("") + write_stdout_ffi(ansi) + let #(cols, rows) = case window_size_ffi() { + Ok(#(c, r)) -> #(c, r) + Error(_) -> #(80, 24) + } + let state = BrowserState(cols: cols, rows: rows) + register_cleanup_ffi(fn() { + let _ = cleanup_terminal(state) + Nil + }) + Ok(state) +} + +@target(javascript) +fn render_ops( + state: BrowserState, + ops: List(RenderOp), +) -> Result(BrowserState, Error) { + let ansi = list.map(ops, render_op_to_ansi) |> string_join("") + write_stdout_ffi(ansi) + Ok(state) +} + +@target(javascript) +fn poll_input( + state: BrowserState, + timeout_ms: Int, +) -> promise.Promise(Result(#(InputEvent, BrowserState), Error)) { + promise.map(poll_input_ffi(timeout_ms), fn(result) { + case result { + Ok(ev) -> { + let new_state = case ev { + Resize(c, r) -> BrowserState(cols: c, rows: r) + _ -> state + } + Ok(#(ev, new_state)) + } + Error(_) -> Error(IOError("poll failed")) + } + }) +} + +@target(javascript) +fn get_terminal_size( + state: BrowserState, +) -> Result(#(TerminalSize, BrowserState), Error) { + let #(cols, rows) = case window_size_ffi() { + Ok(#(c, r)) -> #(c, r) + Error(_) -> #(state.cols, state.rows) + } + Ok(#( + backend.TerminalSize(width: cols, height: rows), + BrowserState(cols: cols, rows: rows), + )) +} + +@target(javascript) +fn cleanup_terminal(state: BrowserState) -> Nil { + let ops = [DisableMouse, ExitAltScreen] + let ansi = list.map(ops, render_op_to_ansi) |> string_join("") + write_stdout_ffi(ansi) + exit_raw_ffi() + let _ = state + Nil +} + +// ─── String join helper ────────────────────────────────────────── + +@target(javascript) +fn string_join(parts: List(String), sep: String) -> String { + case parts { + [] -> "" + [h] -> h + [h, ..rest] -> h <> sep <> string_join(rest, sep) + } +} diff --git a/src/etui/backend/browser_ffi.mjs b/src/etui/backend/browser_ffi.mjs new file mode 100644 index 0000000..bd87c27 --- /dev/null +++ b/src/etui/backend/browser_ffi.mjs @@ -0,0 +1,201 @@ +// Browser (xterm.js) backend FFI for etui. +// Call setup(term) with an xterm.js Terminal instance BEFORE calling main(). +// Key normalisation is identical to node_ffi.mjs so keys.match works the same. + +import { Ok, Error } from "../../gleam.mjs"; +import { + KeyPress, Resize, Tick, + MousePress, MouseRelease, MouseScroll, + MouseLeft, MouseMiddle, MouseRight, +} from "../backend.mjs"; + +// ─── State ─────────────────────────────────────────────────────── + +let term = null; +let inputBuffer = []; +let inputResolvers = []; +let resizeQueue = []; +let escapeBuffer = null; +let escapeTimer = null; + +// ─── Terminal injection ────────────────────────────────────────── +// Called by the host page before main(). + +export function setup(xtermTerminal) { + term = xtermTerminal; + term.onData(onData); + term.onResize(({ cols, rows }) => { + resizeQueue.push([cols, rows]); + drainResolvers(); + }); +} + +// ─── Terminal control (no-ops in browser) ──────────────────────── + +export function enterRaw() {} // xterm.js is always in "raw" mode + +export function exitRaw() { + if (escapeTimer !== null) { + clearTimeout(escapeTimer); + escapeTimer = null; + escapeBuffer = null; + } +} + +export function writeStdout(s) { + term?.write(s); +} + +export function windowSize() { + const cols = term?.cols ?? 80; + const rows = term?.rows ?? 24; + return new Ok([cols, rows]); +} + +// ─── Input handling ────────────────────────────────────────────── +// xterm.js fires onData with the same raw byte sequences as a real terminal. + +function onData(chunk) { + if (escapeBuffer !== null) { + clearTimeout(escapeTimer); + escapeTimer = null; + const combined = escapeBuffer + chunk; + escapeBuffer = null; + inputBuffer.push(combined); + } else if (chunk === "\x1b") { + escapeBuffer = chunk; + escapeTimer = setTimeout(() => { + escapeBuffer = null; + escapeTimer = null; + inputBuffer.push("\x1b"); + drainResolvers(); + }, 20); + return; + } else { + inputBuffer.push(chunk); + } + drainResolvers(); +} + +function drainResolvers() { + while (inputResolvers.length > 0 && (inputBuffer.length > 0 || resizeQueue.length > 0)) { + inputResolvers.shift()(null); + } +} + +// ─── Poll ───────────────────────────────────────────────────────── + +export async function pollInput(timeoutMs) { + if (resizeQueue.length > 0) { + const [cols, rows] = resizeQueue.shift(); + return new Ok(new Resize(cols, rows)); + } + if (inputBuffer.length > 0) { + return new Ok(parseChunk(inputBuffer.shift())); + } + const result = await Promise.race([ + new Promise((resolve) => inputResolvers.push(resolve)), + new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)), + ]); + if (result === "timeout") return new Ok(new Tick()); + if (resizeQueue.length > 0) { + const [cols, rows] = resizeQueue.shift(); + return new Ok(new Resize(cols, rows)); + } + if (inputBuffer.length > 0) { + return new Ok(parseChunk(inputBuffer.shift())); + } + return new Ok(new Tick()); +} + +// ─── Cleanup ────────────────────────────────────────────────────── + +export function registerCleanup(cleanupFn) { + window.addEventListener("beforeunload", () => { + try { cleanupFn(); } catch (_) {} + }); +} + +// ─── Key normalisation (mirrors erlang.gleam normalise_key) ─────── + +function normaliseKey(raw) { + switch (raw) { + case "\x1b[A": case "\x1bOA": return "up"; + case "\x1b[B": case "\x1bOB": return "down"; + case "\x1b[C": case "\x1bOC": return "right"; + case "\x1b[D": case "\x1bOD": return "left"; + case "\r": case "\n": return "enter"; + case "\x7f": case "\b": return "backspace"; + case "\x1b[3~": return "delete"; + case "\t": return "tab"; + case "\x1b[Z": return "backtab"; + case "\x1b": return "esc"; + case "\x1b[2~": return "insert"; + case "\x1b[5~": return "pageup"; + case "\x1b[6~": return "pagedown"; + case "\x1b[H": case "\x1bOH": case "\x1b[1~": return "home"; + case "\x1b[F": case "\x1bOF": case "\x1b[4~": return "end"; + case "\x1b[11~": case "\x1bOP": return "f1"; + case "\x1b[12~": case "\x1bOQ": return "f2"; + case "\x1b[13~": case "\x1bOR": return "f3"; + case "\x1b[14~": case "\x1bOS": return "f4"; + case "\x1b[15~": return "f5"; + case "\x1b[17~": return "f6"; + case "\x1b[18~": return "f7"; + case "\x1b[19~": return "f8"; + case "\x1b[20~": return "f9"; + case "\x1b[21~": return "f10"; + case "\x1b[23~": return "f11"; + case "\x1b[24~": return "f12"; + case "\x01": return "ctrl+a"; + case "\x02": return "ctrl+b"; + case "\x03": return "ctrl+c"; + case "\x04": return "ctrl+d"; + case "\x05": return "ctrl+e"; + case "\x06": return "ctrl+f"; + case "\x07": return "ctrl+g"; + case "\x0b": return "ctrl+k"; + case "\x0c": return "ctrl+l"; + case "\x0e": return "ctrl+n"; + case "\x0f": return "ctrl+o"; + case "\x10": return "ctrl+p"; + case "\x11": return "ctrl+q"; + case "\x12": return "ctrl+r"; + case "\x13": return "ctrl+s"; + case "\x14": return "ctrl+t"; + case "\x15": return "ctrl+u"; + case "\x16": return "ctrl+v"; + case "\x17": return "ctrl+w"; + case "\x18": return "ctrl+x"; + case "\x19": return "ctrl+y"; + case "\x1a": return "ctrl+z"; + default: + if (raw.length === 2 && raw[0] === "\x1b") return "alt+" + raw[1]; + return raw; + } +} + +function parseChunk(chunk) { + if (chunk.startsWith("\x1b[<")) { + const mouse = parseSgrMouse(chunk.slice(3)); + if (mouse !== null) return mouse; + } + return new KeyPress(normaliseKey(chunk)); +} + +function parseSgrMouse(payload) { + const isPress = payload.endsWith("M"); + const trimmed = payload.slice(0, -1); + const parts = trimmed.split(";"); + if (parts.length !== 3) return null; + const cb = parseInt(parts[0], 10); + const cx = parseInt(parts[1], 10); + const cy = parseInt(parts[2], 10); + if (isNaN(cb) || isNaN(cx) || isNaN(cy)) return null; + const x = cx - 1; + const y = cy - 1; + if (cb === 64) return new MouseScroll(x, y, true); + if (cb === 65) return new MouseScroll(x, y, false); + const btn = [new MouseLeft(), new MouseMiddle(), new MouseRight()][cb % 4] ?? new MouseLeft(); + return isPress ? new MousePress(x, y, btn) : new MouseRelease(x, y, btn); +} diff --git a/src/etui/backend/default.gleam b/src/etui/backend/default.gleam new file mode 100644 index 0000000..bb13c4a --- /dev/null +++ b/src/etui/backend/default.gleam @@ -0,0 +1,40 @@ +/// Auto-selects the correct backend for the current compile target. +/// +/// Use this in demos and apps instead of importing `erlang` or `node` directly. +/// The library picks the right implementation at compile time. +/// +/// ```gleam +/// import etui/app +/// import etui/backend/default +/// +/// pub fn main() { +/// let _ = app.run_animated(default.new(), model, render, update, quit, 16) +/// } +/// ``` +import etui/backend + +@target(erlang) +import etui/backend/erlang + +@target(javascript) +import etui/backend/node + +@target(erlang) +pub fn new() -> backend.Backend(erlang.ErlangTerminalState) { + erlang.new() +} + +@target(erlang) +pub fn new_with_mouse() -> backend.Backend(erlang.ErlangTerminalState) { + erlang.new_with_mouse() +} + +@target(javascript) +pub fn new() -> backend.AsyncBackend(node.NodeState) { + node.new() +} + +@target(javascript) +pub fn new_with_mouse() -> backend.AsyncBackend(node.NodeState) { + node.new() +} diff --git a/src/etui/backend/erlang.gleam b/src/etui/backend/erlang.gleam new file mode 100644 index 0000000..640baf2 --- /dev/null +++ b/src/etui/backend/erlang.gleam @@ -0,0 +1,369 @@ +/// Erlang/BEAM terminal backend with true raw mode. +/// Uses native Erlang modules for terminal control (inspired by Etch). +import etui/backend.{ + type Error, type InputEvent, type RenderOp, type TerminalSize, ClearScreen, + DisableMouse, EnableMouse, EnterAltScreen, ExitAltScreen, IOError, MouseLeft, + MouseMiddle, MousePress, MouseRelease, MouseRight, MouseScroll, MoveCursor, + Write, +} +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type ErlangTerminalState { + ErlangTerminalState(raw_mode_active: Bool, cols: Int, rows: Int, mouse: Bool) +} + +// ───────────────────────────────────────────────────────────────── +// Backend construction + +pub fn new() -> backend.Backend(ErlangTerminalState) { + new_impl(False) +} + +pub fn new_with_mouse() -> backend.Backend(ErlangTerminalState) { + new_impl(True) +} + +fn new_impl(mouse: Bool) -> backend.Backend(ErlangTerminalState) { + backend.Backend( + init: fn() { init_terminal(mouse) }, + render: render_ops, + poll: poll_input, + next_size: get_terminal_size, + cleanup: cleanup_terminal, + ) +} + +// ───────────────────────────────────────────────────────────────── +// FFI declarations (native Erlang) + +@external(erlang, "etui_tty_state", "init") +fn init_tty_state() -> Nil { + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_tty_state", "set_raw") +fn set_raw_state(is_raw: Bool) -> Nil { + let _ = is_raw + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "enter_raw") +fn enter_raw_ffi() -> Nil { + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "exit_raw") +fn exit_raw_ffi() -> Nil { + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "window_size") +fn window_size_ffi() -> Result(#(Int, Int), String) { + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "io", "put_chars") +fn write_string(s: String) -> Nil { + let _ = s + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "read_with_timeout") +fn read_with_timeout_ffi(timeout_ms: Int) -> Result(String, Nil) { + let _ = timeout_ms + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "install_sigint_cleanup") +fn install_sigint_cleanup_ffi(cleanup: fn() -> Nil) -> Nil { + let _ = cleanup + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "uninstall_sigint_cleanup") +fn uninstall_sigint_cleanup_ffi() -> Nil { + panic as "etui/backend/erlang requires the Erlang target" +} + +@external(erlang, "etui_terminal_ffi", "write_cleanup") +fn write_cleanup_ffi() -> Nil { + panic as "etui/backend/erlang requires the Erlang target" +} + +// ───────────────────────────────────────────────────────────────── +// Implementation + +fn init_terminal(mouse: Bool) -> Result(ErlangTerminalState, Error) { + init_tty_state() + let init_ops = case mouse { + True -> [EnterAltScreen, ClearScreen, EnableMouse] + False -> [EnterAltScreen, ClearScreen] + } + case write_ops_to_stdout(init_ops) { + Ok(Nil) -> { + enter_raw_ffi() + set_raw_state(True) + let #(cols, rows) = case window_size_ffi() { + Ok(#(c, r)) -> #(c, r) + Error(_) -> #(80, 24) + } + install_sigint_cleanup_ffi(fn() { terminal_cleanup() }) + Ok(ErlangTerminalState( + raw_mode_active: True, + cols: cols, + rows: rows, + mouse: mouse, + )) + } + Error(reason) -> Error(IOError(reason)) + } +} + +fn render_ops( + state: ErlangTerminalState, + ops: List(RenderOp), +) -> Result(ErlangTerminalState, Error) { + case write_ops_to_stdout(ops) { + Ok(Nil) -> Ok(state) + Error(reason) -> Error(IOError(reason)) + } +} + +fn poll_input( + state: ErlangTerminalState, + timeout_ms: Int, +) -> Result(#(InputEvent, ErlangTerminalState), Error) { + let input_event = case read_with_timeout_ffi(timeout_ms) { + Ok(input) -> parse_input(input) + Error(_) -> backend.Tick + } + case window_size_ffi() { + Ok(#(c, r)) -> + case c == state.cols && r == state.rows { + True -> Ok(#(input_event, state)) + False -> + Ok(#( + backend.Resize(c, r), + ErlangTerminalState(..state, cols: c, rows: r), + )) + } + Error(_) -> Ok(#(input_event, state)) + } +} + +fn get_terminal_size( + state: ErlangTerminalState, +) -> Result(#(TerminalSize, ErlangTerminalState), Error) { + case window_size_ffi() { + Ok(#(w, h)) -> Ok(#(backend.TerminalSize(width: w, height: h), state)) + Error(_) -> Ok(#(backend.TerminalSize(width: 80, height: 24), state)) + } +} + +// Shared cleanup: idempotent, safe to call from both normal exit and SIGINT. +// Order matters: write escape sequences BEFORE exit_raw_ffi so the sequences +// reach the terminal while the I/O group leader is still set up correctly. +fn terminal_cleanup() -> Nil { + uninstall_sigint_cleanup_ffi() + write_cleanup_ffi() + exit_raw_ffi() + set_raw_state(False) + Nil +} + +fn cleanup_terminal(_state: ErlangTerminalState) -> Nil { + terminal_cleanup() +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn write_ops_to_stdout(ops: List(RenderOp)) -> Result(Nil, String) { + let output = + ops + |> list.fold("", fn(acc, op) { acc <> render_op_to_string(op) }) + + case output { + "" -> Ok(Nil) + s -> { + write_string(s) + Ok(Nil) + } + } +} + +fn render_op_to_string(op: RenderOp) -> String { + case op { + MoveCursor(x, y) -> + "\u{001B}[" <> int_to_string(y + 1) <> ";" <> int_to_string(x + 1) <> "H" + Write(s) -> s + ClearScreen -> "\u{001B}[2J\u{001B}[H" + EnterAltScreen -> "\u{001B}[?1049h" + ExitAltScreen -> "\u{001B}[?1049l" + // Enable SGR extended mouse tracking (button + scroll events). + EnableMouse -> "\u{001B}[?1000h\u{001B}[?1006h" + // Clear all common xterm mouse/alt-scroll modes so the shell does not + // inherit wheel/click reporting after the app exits. + DisableMouse -> + "\u{001B}[?1007l\u{001B}[?1015l\u{001B}[?1006l\u{001B}[?1005l\u{001B}[?1003l\u{001B}[?1002l\u{001B}[?1000l" + } +} + +// Parse a raw terminal input string into an InputEvent. +// Normalises escape sequences to friendly key names so keys.match/1 works. +fn parse_input(input: String) -> InputEvent { + case input { + "" -> backend.Tick + // SGR mouse: \e[ + case string.starts_with(input, "\u{001B}[<") { + True -> parse_sgr_mouse(string.drop_start(input, 3)) + False -> backend.KeyPress(normalise_key(input)) + } + } +} + +// Map raw terminal byte sequences to friendly key name strings. +// These match the constants expected by keys.match/1 in keys.gleam. +fn normalise_key(raw: String) -> String { + case raw { + // ── Arrow keys ───────────────────────────────────────────── + "\u{001B}[A" | "\u{001B}OA" -> "up" + "\u{001B}[B" | "\u{001B}OB" -> "down" + "\u{001B}[C" | "\u{001B}OC" -> "right" + "\u{001B}[D" | "\u{001B}OD" -> "left" + // ── Enter / newline ──────────────────────────────────────── + "\r" | "\n" -> "enter" + // ── Backspace / Delete ───────────────────────────────────── + "\u{007F}" | "\u{0008}" -> "backspace" + "\u{001B}[3~" -> "delete" + // ── Tab / Shift-Tab ──────────────────────────────────────── + "\t" -> "tab" + "\u{001B}[Z" -> "backtab" + // ── Escape (lone) ────────────────────────────────────────── + "\u{001B}" -> "esc" + // ── Insert / Page / Home / End ───────────────────────────── + "\u{001B}[2~" -> "insert" + "\u{001B}[5~" -> "pageup" + "\u{001B}[6~" -> "pagedown" + "\u{001B}[H" | "\u{001B}OH" | "\u{001B}[1~" -> "home" + "\u{001B}[F" | "\u{001B}OF" | "\u{001B}[4~" -> "end" + // ── Function keys (xterm VT220 + SS3 variants) ───────────── + "\u{001B}[11~" | "\u{001B}OP" -> "f1" + "\u{001B}[12~" | "\u{001B}OQ" -> "f2" + "\u{001B}[13~" | "\u{001B}OR" -> "f3" + "\u{001B}[14~" | "\u{001B}OS" -> "f4" + "\u{001B}[15~" -> "f5" + "\u{001B}[17~" -> "f6" + "\u{001B}[18~" -> "f7" + "\u{001B}[19~" -> "f8" + "\u{001B}[20~" -> "f9" + "\u{001B}[21~" -> "f10" + "\u{001B}[23~" -> "f11" + "\u{001B}[24~" -> "f12" + // ── Ctrl+letter: codepoints 0x01–0x1A (a–z) ─────────────── + "\u{0001}" -> "ctrl+a" + "\u{0002}" -> "ctrl+b" + "\u{0003}" -> "ctrl+c" + "\u{0004}" -> "ctrl+d" + "\u{0005}" -> "ctrl+e" + "\u{0006}" -> "ctrl+f" + "\u{0007}" -> "ctrl+g" + "\u{000B}" -> "ctrl+k" + "\u{000C}" -> "ctrl+l" + "\u{000E}" -> "ctrl+n" + "\u{000F}" -> "ctrl+o" + "\u{0010}" -> "ctrl+p" + "\u{0011}" -> "ctrl+q" + "\u{0012}" -> "ctrl+r" + "\u{0013}" -> "ctrl+s" + "\u{0014}" -> "ctrl+t" + "\u{0015}" -> "ctrl+u" + "\u{0016}" -> "ctrl+v" + "\u{0017}" -> "ctrl+w" + "\u{0018}" -> "ctrl+x" + "\u{0019}" -> "ctrl+y" + "\u{001A}" -> "ctrl+z" + // ── Alt+letter: ESC followed by a single printable char ──── + s -> + case string.starts_with(s, "\u{001B}") && string.length(s) == 2 { + True -> "alt+" <> string.drop_start(s, 1) + False -> s + } + } +} + +// Parse the payload after "\e[<": "Cb;Cx;CyM" or "Cb;Cx;Cym" +fn parse_sgr_mouse(payload: String) -> InputEvent { + let is_press = string.ends_with(payload, "M") + let trimmed = case is_press { + True -> string.drop_end(payload, 1) + False -> string.drop_end(payload, 1) + } + case string.split(trimmed, ";") { + [cb_str, cx_str, cy_str] -> + case int.parse(cb_str), int.parse(cx_str), int.parse(cy_str) { + Ok(cb), Ok(cx), Ok(cy) -> { + // Coordinates are 1-based in SGR; convert to 0-based. + let x = cx - 1 + let y = cy - 1 + case cb { + // Scroll events (button code 64 = up, 65 = down) + 64 -> MouseScroll(x, y, True) + 65 -> MouseScroll(x, y, False) + // Button press / release + _ -> { + let btn = case cb % 4 { + 0 -> MouseLeft + 1 -> MouseMiddle + 2 -> MouseRight + _ -> MouseLeft + } + case is_press { + True -> MousePress(x, y, btn) + False -> MouseRelease(x, y, btn) + } + } + } + } + _, _, _ -> backend.KeyPress("\u{001B}[<" <> payload) + } + _ -> backend.KeyPress("\u{001B}[<" <> payload) + } +} + +fn int_to_string(n: Int) -> String { + case n { + 0 -> "0" + 1 -> "1" + 2 -> "2" + 3 -> "3" + 4 -> "4" + 5 -> "5" + 6 -> "6" + 7 -> "7" + 8 -> "8" + 9 -> "9" + _ -> { + let digit = case n % 10 { + 0 -> "0" + 1 -> "1" + 2 -> "2" + 3 -> "3" + 4 -> "4" + 5 -> "5" + 6 -> "6" + 7 -> "7" + 8 -> "8" + 9 -> "9" + _ -> "?" + } + int_to_string(n / 10) <> digit + } + } +} diff --git a/src/etui/backend/node.gleam b/src/etui/backend/node.gleam new file mode 100644 index 0000000..8174f6f --- /dev/null +++ b/src/etui/backend/node.gleam @@ -0,0 +1,206 @@ +@target(javascript) +/// Node.js terminal backend for the JavaScript target. +/// +/// Provides the same `Backend` interface as `erlang.gleam` but uses +/// Node.js process.stdin/stdout via ESM FFI. +/// +/// Requirements: +/// - Node.js >= 16 +/// - Running in a TTY (terminal, not a pipe) +/// - Compiled with `gleam build --target javascript` +/// +/// Example: +/// ```gleam +/// import etui/app +/// import etui/backend/node +/// +/// pub fn main() { +/// app.run(node.new(), initial_model, view, update, quit_fn, 16) +/// } +/// ``` +/// +/// ## JS target notes +/// +/// `app.run` is synchronous on the Erlang target but uses async polling +/// on Node.js. The event loop runs via `setTimeout` in Node's event loop. +/// Each `poll_input` call is async-awaited internally by the FFI layer. +import etui/backend.{ + type Error, type InputEvent, type RenderOp, type TerminalSize, ClearScreen, + DisableMouse, EnableMouse, EnterAltScreen, ExitAltScreen, IOError, MoveCursor, + Resize, Write, +} + +@target(javascript) +import gleam/int + +@target(javascript) +import gleam/javascript/promise + +@target(javascript) +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type NodeState { + NodeState(cols: Int, rows: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Backend construction + +@target(javascript) +pub fn new() -> backend.AsyncBackend(NodeState) { + backend.AsyncBackend( + init: init_terminal, + render: render_ops, + poll: poll_input, + next_size: get_terminal_size, + cleanup: cleanup_terminal, + ) +} + +// ───────────────────────────────────────────────────────────────── +// FFI declarations (Node.js ESM) + +@target(javascript) +@external(javascript, "./node_ffi.mjs", "enterRaw") +fn enter_raw_ffi() -> Nil { + panic as "etui/backend/node requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./node_ffi.mjs", "exitRaw") +fn exit_raw_ffi() -> Nil { + panic as "etui/backend/node requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./node_ffi.mjs", "writeStdout") +fn write_stdout_ffi(s: String) -> Nil { + let _ = s + panic as "etui/backend/node requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./node_ffi.mjs", "windowSize") +fn window_size_ffi() -> Result(#(Int, Int), String) { + panic as "etui/backend/node requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./node_ffi.mjs", "pollInput") +fn poll_input_ffi(timeout_ms: Int) -> promise.Promise(Result(InputEvent, Nil)) { + let _ = timeout_ms + panic as "etui/backend/node requires the JavaScript target" +} + +@target(javascript) +@external(javascript, "./node_ffi.mjs", "registerCleanup") +fn register_cleanup_ffi(cleanup: fn() -> Nil) -> Nil { + let _ = cleanup + panic as "etui/backend/node requires the JavaScript target" +} + +// ───────────────────────────────────────────────────────────────── +// ANSI sequences (same as erlang backend) + +@target(javascript) +fn render_op_to_ansi(op: RenderOp) -> String { + case op { + Write(s) -> s + MoveCursor(x, y) -> + "\u{001B}[" <> int.to_string(y + 1) <> ";" <> int.to_string(x + 1) <> "H" + ClearScreen -> "\u{001B}[2J\u{001B}[H" + EnterAltScreen -> "\u{001B}[?1049h" + ExitAltScreen -> "\u{001B}[?1049l" + EnableMouse -> "\u{001B}[?1000h\u{001B}[?1002h\u{001B}[?1006h" + DisableMouse -> + "\u{001B}[?1007l\u{001B}[?1015l\u{001B}[?1006l\u{001B}[?1005l\u{001B}[?1003l\u{001B}[?1002l\u{001B}[?1000l" + } +} + +// ───────────────────────────────────────────────────────────────── +// Implementation + +@target(javascript) +fn init_terminal() -> Result(NodeState, Error) { + enter_raw_ffi() + let ops = [EnterAltScreen, ClearScreen, EnableMouse] + let ansi = list.map(ops, render_op_to_ansi) |> string_join("") + write_stdout_ffi(ansi) + let #(cols, rows) = case window_size_ffi() { + Ok(#(c, r)) -> #(c, r) + Error(_) -> #(80, 24) + } + let state = NodeState(cols: cols, rows: rows) + register_cleanup_ffi(fn() { + let _ = cleanup_terminal(state) + Nil + }) + Ok(state) +} + +@target(javascript) +fn render_ops( + state: NodeState, + ops: List(RenderOp), +) -> Result(NodeState, Error) { + let ansi = list.map(ops, render_op_to_ansi) |> string_join("") + write_stdout_ffi(ansi) + Ok(state) +} + +@target(javascript) +fn poll_input( + state: NodeState, + timeout_ms: Int, +) -> promise.Promise(Result(#(InputEvent, NodeState), Error)) { + promise.map(poll_input_ffi(timeout_ms), fn(result) { + case result { + Ok(ev) -> { + let new_state = case ev { + Resize(c, r) -> NodeState(cols: c, rows: r) + _ -> state + } + Ok(#(ev, new_state)) + } + Error(_) -> Error(IOError("poll failed")) + } + }) +} + +@target(javascript) +fn get_terminal_size( + state: NodeState, +) -> Result(#(TerminalSize, NodeState), Error) { + let #(cols, rows) = case window_size_ffi() { + Ok(#(c, r)) -> #(c, r) + Error(_) -> #(state.cols, state.rows) + } + Ok(#( + backend.TerminalSize(width: cols, height: rows), + NodeState(cols: cols, rows: rows), + )) +} + +@target(javascript) +fn cleanup_terminal(state: NodeState) -> Nil { + let ops = [DisableMouse, ExitAltScreen] + let ansi = list.map(ops, render_op_to_ansi) |> string_join("") + write_stdout_ffi(ansi) + exit_raw_ffi() + let _ = state + Nil +} + +// ─── String join helper (no stdlib dependency) ─────────────────── + +@target(javascript) +fn string_join(parts: List(String), sep: String) -> String { + case parts { + [] -> "" + [h] -> h + [h, ..rest] -> h <> sep <> string_join(rest, sep) + } +} diff --git a/src/etui/backend/node_ffi.mjs b/src/etui/backend/node_ffi.mjs new file mode 100644 index 0000000..6bdd0a1 --- /dev/null +++ b/src/etui/backend/node_ffi.mjs @@ -0,0 +1,230 @@ +// Node.js terminal backend FFI for etui. +// Key normalization mirrors erlang.gleam's normalise_key so keys.match works +// identically on both targets. + +import { Ok, Error } from "../../gleam.mjs"; +import { + KeyPress, Resize, Tick, + MousePress, MouseRelease, MouseScroll, + MouseLeft, MouseMiddle, MouseRight, +} from "../backend.mjs"; + +// ─── State ─────────────────────────────────────────────────────── + +let rawModeActive = false; +let inputBuffer = []; +let inputResolvers = []; +let resizeQueue = []; +let escapeBuffer = null; +let escapeTimer = null; + +// ─── Terminal control ───────────────────────────────────────────── + +export function enterRaw() { + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", onData); + rawModeActive = true; + } +} + +export function exitRaw() { + if (rawModeActive && process.stdin.isTTY) { + process.stdin.removeListener("data", onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + rawModeActive = false; + } + if (escapeTimer !== null) { + clearTimeout(escapeTimer); + escapeTimer = null; + escapeBuffer = null; + } +} + +export function writeStdout(s) { + process.stdout.write(s); +} + +export function windowSize() { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + return new Ok([cols, rows]); +} + +// ─── Resize ─────────────────────────────────────────────────────── + +process.stdout.on("resize", () => { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + resizeQueue.push([cols, rows]); + drainResolvers(); +}); + +// ─── Input handling ─────────────────────────────────────────────── + +function onData(chunk) { + if (escapeBuffer !== null) { + // ESC was pending, combine (handles split \x1b + [B → \x1b[B) + clearTimeout(escapeTimer); + escapeTimer = null; + const combined = escapeBuffer + chunk; + escapeBuffer = null; + inputBuffer.push(combined); + } else if (chunk === "\x1b") { + // Lone ESC: wait 20ms for a follow-up (e.g. "[B" for arrow keys) + escapeBuffer = chunk; + escapeTimer = setTimeout(() => { + escapeBuffer = null; + escapeTimer = null; + inputBuffer.push("\x1b"); + drainResolvers(); + }, 20); + return; + } else { + inputBuffer.push(chunk); + } + drainResolvers(); +} + +function drainResolvers() { + while (inputResolvers.length > 0 && (inputBuffer.length > 0 || resizeQueue.length > 0)) { + const resolve = inputResolvers.shift(); + resolve(null); + } +} + +// ─── Poll ───────────────────────────────────────────────────────── + +export async function pollInput(timeoutMs) { + if (resizeQueue.length > 0) { + const [cols, rows] = resizeQueue.shift(); + return new Ok(new Resize(cols, rows)); + } + if (inputBuffer.length > 0) { + return new Ok(parseChunk(inputBuffer.shift())); + } + const result = await Promise.race([ + new Promise((resolve) => inputResolvers.push(resolve)), + new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)), + ]); + if (result === "timeout") return new Ok(new Tick()); + if (resizeQueue.length > 0) { + const [cols, rows] = resizeQueue.shift(); + return new Ok(new Resize(cols, rows)); + } + if (inputBuffer.length > 0) { + return new Ok(parseChunk(inputBuffer.shift())); + } + return new Ok(new Tick()); +} + +// ─── Key normalisation (mirrors erlang.gleam normalise_key) ─────── + +// Map raw terminal bytes to the same friendly strings keys.match expects. +function normaliseKey(raw) { + switch (raw) { + // Arrow keys + case "\x1b[A": case "\x1bOA": return "up"; + case "\x1b[B": case "\x1bOB": return "down"; + case "\x1b[C": case "\x1bOC": return "right"; + case "\x1b[D": case "\x1bOD": return "left"; + // Enter + case "\r": case "\n": return "enter"; + // Backspace / Delete + case "\x7f": case "\b": return "backspace"; + case "\x1b[3~": return "delete"; + // Tab / Shift-Tab + case "\t": return "tab"; + case "\x1b[Z": return "backtab"; + // Escape (lone) + case "\x1b": return "esc"; + // Navigation + case "\x1b[2~": return "insert"; + case "\x1b[5~": return "pageup"; + case "\x1b[6~": return "pagedown"; + case "\x1b[H": case "\x1bOH": case "\x1b[1~": return "home"; + case "\x1b[F": case "\x1bOF": case "\x1b[4~": return "end"; + // Function keys + case "\x1b[11~": case "\x1bOP": return "f1"; + case "\x1b[12~": case "\x1bOQ": return "f2"; + case "\x1b[13~": case "\x1bOR": return "f3"; + case "\x1b[14~": case "\x1bOS": return "f4"; + case "\x1b[15~": return "f5"; + case "\x1b[17~": return "f6"; + case "\x1b[18~": return "f7"; + case "\x1b[19~": return "f8"; + case "\x1b[20~": return "f9"; + case "\x1b[21~": return "f10"; + case "\x1b[23~": return "f11"; + case "\x1b[24~": return "f12"; + // Ctrl+letter (codepoints 0x01–0x1A) + case "\x01": return "ctrl+a"; + case "\x02": return "ctrl+b"; + case "\x03": return "ctrl+c"; + case "\x04": return "ctrl+d"; + case "\x05": return "ctrl+e"; + case "\x06": return "ctrl+f"; + case "\x07": return "ctrl+g"; + case "\x0b": return "ctrl+k"; + case "\x0c": return "ctrl+l"; + case "\x0e": return "ctrl+n"; + case "\x0f": return "ctrl+o"; + case "\x10": return "ctrl+p"; + case "\x11": return "ctrl+q"; + case "\x12": return "ctrl+r"; + case "\x13": return "ctrl+s"; + case "\x14": return "ctrl+t"; + case "\x15": return "ctrl+u"; + case "\x16": return "ctrl+v"; + case "\x17": return "ctrl+w"; + case "\x18": return "ctrl+x"; + case "\x19": return "ctrl+y"; + case "\x1a": return "ctrl+z"; + default: + // Alt+letter: ESC + single printable char + if (raw.length === 2 && raw[0] === "\x1b") return "alt+" + raw[1]; + return raw; + } +} + +// Parse a chunk: SGR mouse or normalised key. +function parseChunk(chunk) { + if (chunk.startsWith("\x1b[<")) { + const mouse = parseSgrMouse(chunk.slice(3)); + if (mouse !== null) return mouse; + } + return new KeyPress(normaliseKey(chunk)); +} + +// Parse SGR mouse payload after "\x1b[<": "Cb;Cx;CyM" or "Cb;Cx;Cym" +function parseSgrMouse(payload) { + const isPress = payload.endsWith("M"); + const trimmed = payload.slice(0, -1); + const parts = trimmed.split(";"); + if (parts.length !== 3) return null; + const cb = parseInt(parts[0], 10); + const cx = parseInt(parts[1], 10); + const cy = parseInt(parts[2], 10); + if (isNaN(cb) || isNaN(cx) || isNaN(cy)) return null; + const x = cx - 1; + const y = cy - 1; + if (cb === 64) return new MouseScroll(x, y, true); + if (cb === 65) return new MouseScroll(x, y, false); + const btn = [new MouseLeft(), new MouseMiddle(), new MouseRight()][cb % 4] ?? new MouseLeft(); + return isPress ? new MousePress(x, y, btn) : new MouseRelease(x, y, btn); +} + +// ─── Crash-restore ──────────────────────────────────────────────── + +export function registerCleanup(cleanupFn) { + const handler = () => { + try { cleanupFn(); } catch (_) {} + exitRaw(); + }; + process.on("exit", handler); + process.on("SIGINT", () => { handler(); process.exit(0); }); + process.on("SIGTERM", () => { handler(); process.exit(0); }); +} diff --git a/src/etui/braille.gleam b/src/etui/braille.gleam new file mode 100644 index 0000000..9995253 --- /dev/null +++ b/src/etui/braille.gleam @@ -0,0 +1,98 @@ +/// Braille pixel grid utilities. 2×4 dot grid per terminal cell using +/// Unicode braille block U+2800–U+28FF. +/// +/// A "pixel" here is a sub-cell dot. (px / 2, py / 4) maps to terminal cell +/// coordinates; (px % 2, py % 4) maps to dot position within that cell. +/// +/// The pixel dictionary maps cell coords → (bitmask, fg_color). Multiple +/// writes to the same cell OR their bitmasks; the last fg color wins. +import etui/buffer +import etui/geometry +import etui/style +import gleam/dict +import gleam/int +import gleam/string + +pub type Pixels = + dict.Dict(#(Int, Int), #(Int, style.Color)) + +/// Empty pixel grid. +pub fn new() -> Pixels { + dict.new() +} + +/// Set a pixel at terminal-cell (char_x, char_y), accumulating its bit into +/// the existing mask. Color overrides any prior color for that cell. +pub fn set_pixel( + pixels: Pixels, + char_x: Int, + char_y: Int, + bit: Int, + fg: style.Color, +) -> Pixels { + let key = #(char_x, char_y) + let existing = case dict.get(pixels, key) { + Ok(#(m, _)) -> m + Error(_) -> 0 + } + dict.insert(pixels, key, #(int.bitwise_or(existing, bit), fg)) +} + +/// Bitmask bit for the dot at (col, row) inside a 2×4 braille cell. +/// Layout (Unicode braille dot numbering): +/// col 0 col 1 +/// row 0 1 8 +/// row 1 2 16 +/// row 2 4 32 +/// row 3 64 128 +pub fn bit(col: Int, row: Int) -> Int { + case col, row { + 0, 0 -> 1 + 0, 1 -> 2 + 0, 2 -> 4 + 0, 3 -> 64 + 1, 0 -> 8 + 1, 1 -> 16 + 1, 2 -> 32 + 1, 3 -> 128 + _, _ -> 0 + } +} + +/// Write a single pixel at absolute braille-pixel coords (px, py) into the +/// pixel grid. Out-of-bounds coords (px < 0 or py < 0) are dropped. +pub fn put(pixels: Pixels, px: Int, py: Int, fg: style.Color) -> Pixels { + case px < 0 || py < 0 { + True -> pixels + False -> set_pixel(pixels, px / 2, py / 4, bit(px % 2, py % 4), fg) + } +} + +/// Flush the pixel grid into a buffer at the given area's origin. +/// Each populated cell becomes one braille glyph. +pub fn flush( + buf: buffer.Buffer, + area: geometry.Rect, + pixels: Pixels, + bg: style.Color, +) -> buffer.Buffer { + dict.fold(pixels, buf, fn(b, key, val) { + let #(char_x, char_y) = key + let #(mask, fg) = val + let ch = case string.utf_codepoint(0x2800 + mask) { + Ok(cp) -> string.from_utf_codepoints([cp]) + Error(_) -> "·" + } + buffer.set_string( + b, + geometry.Position( + x: area.position.x + char_x, + y: area.position.y + char_y, + ), + ch, + fg, + bg, + style.none(), + ) + }) +} diff --git a/src/etui/buffer.gleam b/src/etui/buffer.gleam new file mode 100644 index 0000000..010485d --- /dev/null +++ b/src/etui/buffer.gleam @@ -0,0 +1,767 @@ +/// Terminal buffer: grid of styled cells + diffing. +/// Dense storage: flat array indexed by `(y - y0) * width + (x - x0)`. +/// Get/set are O(log10 N) on Erlang (array trie), which beats a dict for the +/// integer-keyed, fully-populated buffers that TUI rendering produces. +/// Wide graphemes (CJK, emoji) occupy one Cell + a Continuation marker. +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// External array type (Erlang array / JS flat array) + +pub type CellArray + +@external(erlang, "etui_buffer_array_ffi", "new") +@external(javascript, "../etui_buffer_array_ffi.mjs", "make") +fn array_new(size: Int, default: Cell) -> CellArray + +@external(erlang, "etui_buffer_array_ffi", "get") +@external(javascript, "../etui_buffer_array_ffi.mjs", "get") +fn array_get(index: Int, arr: CellArray) -> Cell + +@external(erlang, "etui_buffer_array_ffi", "set") +@external(javascript, "../etui_buffer_array_ffi.mjs", "set") +fn array_set(index: Int, value: Cell, arr: CellArray) -> CellArray + +/// Bulk-fill all Width×Height cells from a single row text using array:from_list. +/// Erlang only, JS falls back to the Gleam body (repeated fill_graphemes). +/// Faster than fill_string called per-row because the trie is built once. +@external(erlang, "etui_buffer_array_ffi", "fill_all_rows") +fn fill_all_rows_ffi( + width: Int, + height: Int, + str: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + link: String, + default: Cell, +) -> CellArray { + let size = int.max(width * height, 0) + fill_all_rows_gleam( + array_new(size, default), + 0, + height, + width, + str, + fg, + bg, + modifier, + link, + ) +} + +fn fill_all_rows_gleam( + arr: CellArray, + row: Int, + height: Int, + width: Int, + str: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + link: String, +) -> CellArray { + case row >= height { + True -> arr + False -> { + let start = row * width + let arr2 = + fill_graphemes( + arr, + start, + start + width, + string.to_graphemes(str), + fg, + bg, + modifier, + link, + ) + fill_all_rows_gleam( + arr2, + row + 1, + height, + width, + str, + fg, + bg, + modifier, + link, + ) + } + } +} + +/// Fill cells from a string into the array, capped at max_idx (end of row). +/// Erlang: processes binary directly, no Gleam list/fold overhead. +/// Other targets: Gleam fallback using grapheme fold. +@external(erlang, "etui_buffer_array_ffi", "fill_string") +fn fill_string_ffi( + arr: CellArray, + start_idx: Int, + max_idx: Int, + str: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + link: String, +) -> CellArray { + fill_graphemes( + arr, + start_idx, + max_idx, + string.to_graphemes(str), + fg, + bg, + modifier, + link, + ) +} + +fn fill_graphemes( + arr: CellArray, + idx: Int, + max_idx: Int, + gs: List(String), + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + link: String, +) -> CellArray { + case idx >= max_idx { + True -> arr + False -> + case gs { + [] -> arr + [g, ..rest] -> { + let w = text.grapheme_cell_width(g) + let cell = + Cell( + content: Content(symbol: g, width: w), + fg: fg, + bg: bg, + modifier: modifier, + link: link, + ) + let arr2 = array_set(idx, cell, arr) + case w >= 2 { + True -> { + let arr3 = case idx + 1 < max_idx { + True -> + array_set(idx + 1, continuation_cell(fg, bg, modifier), arr2) + False -> arr2 + } + fill_graphemes( + arr3, + idx + 2, + max_idx, + rest, + fg, + bg, + modifier, + link, + ) + } + False -> + fill_graphemes( + arr2, + idx + 1, + max_idx, + rest, + fg, + bg, + modifier, + link, + ) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Content variant for a terminal cell. +pub type CellContent { + /// A normal or wide grapheme. `width` = 1 or 2. + Content(symbol: String, width: Int) + /// Marker for the second cell of a wide grapheme. Never drawn directly. + Continuation +} + +/// One cell in the terminal grid: a grapheme + colors + modifiers + optional hyperlink. +pub type Cell { + Cell( + content: CellContent, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + /// OSC 8 hyperlink URI. Empty string = no link. Emitted on render. + link: String, + ) +} + +pub opaque type Buffer { + Buffer(area: geometry.Rect, cells: CellArray) +} + +/// A diff operation: move cursor to `position`, write a run of `cells`. +pub type BufferOp { + Patch(position: geometry.Position, cells: List(Cell)) +} + +// ───────────────────────────────────────────────────────────────── +// Accessors + +/// The rect this buffer covers. +pub fn area(buf: Buffer) -> geometry.Rect { + buf.area +} + +/// Width in cells. +pub fn width(buf: Buffer) -> Int { + buf.area.size.width +} + +/// Height in rows. +pub fn height(buf: Buffer) -> Int { + buf.area.size.height +} + +/// Symbol string of a cell. Returns " " for Continuation cells. +pub fn cell_symbol(cell: Cell) -> String { + case cell.content { + Content(symbol: s, ..) -> s + Continuation -> " " + } +} + +/// Foreground color of a cell. +pub fn cell_fg(cell: Cell) -> style.Color { + cell.fg +} + +/// Background color of a cell. +pub fn cell_bg(cell: Cell) -> style.Color { + cell.bg +} + +/// Text modifier of a cell. +pub fn cell_modifier(cell: Cell) -> style.Modifier { + cell.modifier +} + +/// True if this cell is the second column of a wide grapheme (never rendered directly). +pub fn is_continuation(cell: Cell) -> Bool { + case cell.content { + Continuation -> True + _ -> False + } +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Empty cell (space, default style, no link). +pub fn empty_cell() -> Cell { + Cell( + content: Content(symbol: " ", width: 1), + fg: style.Default, + bg: style.Default, + modifier: style.none(), + link: "", + ) +} + +/// Continuation cell (second column of a wide grapheme). +pub fn continuation_cell( + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> Cell { + Cell(content: Continuation, fg: fg, bg: bg, modifier: modifier, link: "") +} + +/// Accessor: OSC 8 hyperlink URI of a cell (empty = no link). +pub fn cell_link(cell: Cell) -> String { + cell.link +} + +/// New buffer with given area. All cells start as `empty_cell()`. +pub fn buffer_new(area: geometry.Rect) -> Buffer { + let size = int.max(area.size.width * area.size.height, 0) + Buffer(area: area, cells: array_new(size, empty_cell())) +} + +/// Create a buffer with every row pre-filled with `row_text`. +/// Uses bulk array construction: one pass instead of `buffer_new` followed by +/// a `set_string` for every row. +pub fn buffer_new_filled( + area: geometry.Rect, + row_text: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> Buffer { + let default = empty_cell() + Buffer( + area: area, + cells: fill_all_rows_ffi( + area.size.width, + area.size.height, + row_text, + fg, + bg, + modifier, + "", + default, + ), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Index helpers + +fn pos_to_idx(area: geometry.Rect, pos: geometry.Position) -> Int { + { pos.y - area.position.y } * area.size.width + { pos.x - area.position.x } +} + +// ───────────────────────────────────────────────────────────────── +// Cell operations + +/// Get cell at position. Returns empty_cell() for out-of-bounds. +pub fn get_cell(buffer: Buffer, pos: geometry.Position) -> Cell { + case geometry.contains(buffer.area, pos) { + False -> empty_cell() + True -> array_get(pos_to_idx(buffer.area, pos), buffer.cells) + } +} + +/// Set cell at position. Out-of-bounds writes are ignored. +pub fn set_cell(buffer: Buffer, pos: geometry.Position, cell: Cell) -> Buffer { + case geometry.contains(buffer.area, pos) { + True -> + Buffer( + ..buffer, + cells: array_set(pos_to_idx(buffer.area, pos), cell, buffer.cells), + ) + False -> buffer + } +} + +/// Set cells from a string starting at `pos`. No hyperlink. +/// Wide graphemes (width=2) take one Cell + one Continuation cell. +pub fn set_string( + buffer: Buffer, + pos: geometry.Position, + str: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> Buffer { + set_string_linked(buffer, pos, str, fg, bg, modifier, "") +} + +/// Set cells from a string with an OSC 8 hyperlink URI. +/// Pass `""` for no link (same as `set_string`). +pub fn set_string_linked( + buffer: Buffer, + pos: geometry.Position, + str: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + link: String, +) -> Buffer { + case geometry.contains(buffer.area, pos) { + False -> buffer + True -> { + let start_idx = pos_to_idx(buffer.area, pos) + // Cap at end of row, strings never wrap to the next row + let row_end = + { pos.y - buffer.area.position.y + 1 } * buffer.area.size.width + Buffer( + ..buffer, + cells: fill_string_ffi( + buffer.cells, + start_idx, + row_end, + str, + fg, + bg, + modifier, + link, + ), + ) + } + } +} + +/// Clear all cells in a rect (reset to empty_cell). +pub fn clear(buffer: Buffer, rect: geometry.Rect) -> Buffer { + let y_max = geometry.bottom(rect) + let x_max = geometry.right(rect) + clear_rows(buffer, rect.position.y, y_max, rect.position.x, x_max) +} + +fn clear_rows( + buf: Buffer, + y: Int, + y_max: Int, + x_min: Int, + x_max: Int, +) -> Buffer { + case y >= y_max { + True -> buf + False -> + clear_rows(clear_row(buf, y, x_min, x_max), y + 1, y_max, x_min, x_max) + } +} + +fn clear_row(buf: Buffer, y: Int, x: Int, x_max: Int) -> Buffer { + case x >= x_max { + True -> buf + False -> { + let pos = geometry.Position(x: x, y: y) + let cells = case geometry.contains(buf.area, pos) { + True -> array_set(pos_to_idx(buf.area, pos), empty_cell(), buf.cells) + False -> buf.cells + } + clear_row(Buffer(..buf, cells: cells), y, x + 1, x_max) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Diffing + +// Pre-extracted buffer view, avoids repeated record field accesses and +// geometry.contains checks in the diff and to_ansi inner loops. +type BufView { + BufView( + cells: CellArray, + y0: Int, + x0: Int, + width: Int, + height: Int, + size: Int, + ) +} + +fn buf_view(buf: Buffer) -> BufView { + BufView( + cells: buf.cells, + y0: buf.area.position.y, + x0: buf.area.position.x, + width: buf.area.size.width, + height: buf.area.size.height, + size: buf.area.size.width * buf.area.size.height, + ) +} + +// O(1) cell fetch with cheap bounds guard, no geometry.contains overhead. +fn bv_cell_at(bv: BufView, row_base: Int, x: Int) -> Cell { + let idx = row_base + x - bv.x0 + case idx >= 0 && idx < bv.size { + True -> array_get(idx, bv.cells) + False -> empty_cell() + } +} + +/// Compute minimal diff between two buffers as a list of patches. +pub fn diff(prev: Buffer, next: Buffer) -> List(BufferOp) { + let y_min = min_int(prev.area.position.y, next.area.position.y) + let y_max = max_int(geometry.bottom(prev.area), geometry.bottom(next.area)) + let x_min = min_int(prev.area.position.x, next.area.position.x) + let x_max = max_int(geometry.right(prev.area), geometry.right(next.area)) + diff_rows(buf_view(prev), buf_view(next), y_min, y_max, x_min, x_max, []) +} + +fn diff_rows( + prev: BufView, + next: BufView, + y: Int, + y_max: Int, + x_min: Int, + x_max: Int, + rev_acc: List(BufferOp), +) -> List(BufferOp) { + case y >= y_max { + True -> list.reverse(rev_acc) + False -> { + let prev_rb = { y - prev.y0 } * prev.width + let next_rb = { y - next.y0 } * next.width + let rev_acc2 = + diff_row(prev, next, prev_rb, next_rb, y, x_min, x_max, rev_acc) + diff_rows(prev, next, y + 1, y_max, x_min, x_max, rev_acc2) + } + } +} + +fn diff_row( + prev: BufView, + next: BufView, + prev_rb: Int, + next_rb: Int, + y: Int, + x: Int, + x_max: Int, + rev_acc: List(BufferOp), +) -> List(BufferOp) { + case x >= x_max { + True -> rev_acc + False -> { + let prev_cell = bv_cell_at(prev, prev_rb, x) + let next_cell = bv_cell_at(next, next_rb, x) + case cells_equal(prev_cell, next_cell) { + True -> diff_row(prev, next, prev_rb, next_rb, y, x + 1, x_max, rev_acc) + False -> { + let pos = geometry.Position(x: x, y: y) + let #(run, next_x) = + collect_run(prev, next, prev_rb, next_rb, x, x_max, []) + diff_row(prev, next, prev_rb, next_rb, y, next_x, x_max, [ + Patch(pos, run), + ..rev_acc + ]) + } + } + } + } +} + +fn collect_run( + prev: BufView, + next: BufView, + prev_rb: Int, + next_rb: Int, + x: Int, + x_max: Int, + run: List(Cell), +) -> #(List(Cell), Int) { + case x >= x_max { + True -> #(list.reverse(run), x) + False -> { + let prev_cell = bv_cell_at(prev, prev_rb, x) + let next_cell = bv_cell_at(next, next_rb, x) + case cells_equal(prev_cell, next_cell) { + True -> #(list.reverse(run), x) + False -> + collect_run(prev, next, prev_rb, next_rb, x + 1, x_max, [ + next_cell, + ..run + ]) + } + } + } +} + +// Structural `==` compares every field in one BEAM term-comparison BIF, +// faster than a hand-rolled check in the per-cell diff loop. +fn cells_equal(a: Cell, b: Cell) -> Bool { + a == b +} + +// ───────────────────────────────────────────────────────────────── +// ANSI rendering + +// ─── Style-run tracking ────────────────────────────────────────── +// Tracks the currently applied ANSI style to avoid re-emitting unchanged +// sequences across consecutive cells. The terminal preserves style across +// cursor moves, so we can thread this state across rows and patches. + +type RunStyle { + RunStyle( + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + link: String, + ) +} + +fn blank_run_style() -> RunStyle { + RunStyle( + fg: style.Default, + bg: style.Default, + modifier: style.none(), + link: "", + ) +} + +fn run_style_active(rs: RunStyle) -> Bool { + style.ansi_fg(rs.fg) != "" + || style.ansi_bg(rs.bg) != "" + || style.ansi_modifier(rs.modifier) != "" + || rs.link != "" +} + +// Emit a cell relative to the current RunStyle. +// When style is unchanged: emit only the text. When it changes: emit +// the minimal transition (link-close, reset, new style, link-open) then text. +fn emit_cell(rs: RunStyle, cell: Cell) -> #(String, RunStyle) { + case is_continuation(cell) { + True -> #("", rs) + False -> { + let same = + cell.fg == rs.fg + && cell.bg == rs.bg + && cell.modifier == rs.modifier + && cell.link == rs.link + case same { + True -> #(cell_symbol(cell), rs) + False -> { + let link_close = case rs.link { + "" -> "" + _ -> osc8_close() + } + let reset_seq = case run_style_active(rs) { + True -> style.ansi_reset() + False -> "" + } + let fg_seq = style.ansi_fg(cell.fg) + let bg_seq = style.ansi_bg(cell.bg) + let mod_seq = style.ansi_modifier(cell.modifier) + let link_open = case cell.link { + "" -> "" + uri -> osc8_open(uri) + } + let new_rs = + RunStyle( + fg: cell.fg, + bg: cell.bg, + modifier: cell.modifier, + link: cell.link, + ) + #( + link_close + <> reset_seq + <> fg_seq + <> bg_seq + <> mod_seq + <> link_open + <> cell_symbol(cell), + new_rs, + ) + } + } + } + } +} + +/// Full-buffer render to an ANSI string. +/// Emits a MoveCursor for every row, then each cell with style transitions +/// only when the style actually changes between adjacent cells. +/// Use for the first frame or after a terminal resize. +pub fn to_ansi(buf: Buffer) -> String { + let #(output, final_rs) = + to_ansi_rows(buf_view(buf), 0, blank_run_style(), "") + let trailing = case run_style_active(final_rs) { + True -> style.ansi_reset() + False -> "" + } + output <> trailing +} + +fn to_ansi_rows( + bv: BufView, + row: Int, + rs: RunStyle, + acc: String, +) -> #(String, RunStyle) { + case row >= bv.height { + True -> #(acc, rs) + False -> { + let move = move_cursor_seq(bv.x0, bv.y0 + row) + let #(row_str, new_rs) = to_ansi_row(bv, row * bv.width, 0, rs, "") + to_ansi_rows(bv, row + 1, new_rs, acc <> move <> row_str) + } + } +} + +fn to_ansi_row( + bv: BufView, + row_base: Int, + col: Int, + rs: RunStyle, + acc: String, +) -> #(String, RunStyle) { + case col >= bv.width { + True -> #(acc, rs) + False -> { + let cell = bv_cell_at(bv, row_base, bv.x0 + col) + let #(s, new_rs) = emit_cell(rs, cell) + to_ansi_row(bv, row_base, col + 1, new_rs, acc <> s) + } + } +} + +/// Convert a list of `BufferOp` patches to an ANSI string. +/// Each patch moves the cursor once, then writes a run of cells. +/// Style is tracked across the entire patch list, cursor moves do not +/// reset terminal style, so we avoid redundant escape sequences. +/// Cheaper than `to_ansi` when only a small fraction of cells changed. +pub fn patches_to_ansi(ops: List(BufferOp)) -> String { + case ops { + [] -> "" + _ -> { + let #(output, final_rs) = + list.fold(ops, #("", blank_run_style()), fn(acc, op) { + let #(str, rs) = acc + let move = move_cursor_seq(op.position.x, op.position.y) + let #(cells_str, new_rs) = + list.fold(op.cells, #("", rs), fn(c_acc, cell) { + let #(c_str, c_rs) = c_acc + let #(s, next_rs) = emit_cell(c_rs, cell) + #(c_str <> s, next_rs) + }) + #(str <> move <> cells_str, new_rs) + }) + let trailing = case run_style_active(final_rs) { + True -> style.ansi_reset() + False -> "" + } + output <> trailing + } + } +} + +/// Diff `prev` against `curr` and return the minimal ANSI to bring the +/// terminal from `prev`'s state to `curr`'s state. +/// On the first frame (or after resize) pass an empty buffer as `prev`. +pub fn diff_to_ansi(prev: Buffer, curr: Buffer) -> String { + patches_to_ansi(diff(prev, curr)) +} + +// OSC 8 hyperlink sequences (supported by iTerm2, Kitty, VTE, Windows Terminal). +fn osc8_open(uri: String) -> String { + "\u{001B}]8;;" <> uri <> "\u{001B}\\" +} + +fn osc8_close() -> String { + "\u{001B}]8;;\u{001B}\\" +} + +fn move_cursor_seq(x: Int, y: Int) -> String { + "\u{001B}[" <> int.to_string(y + 1) <> ";" <> int.to_string(x + 1) <> "H" +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn min_int(a: Int, b: Int) -> Int { + case a < b { + True -> a + False -> b + } +} + +fn max_int(a: Int, b: Int) -> Int { + case a > b { + True -> a + False -> b + } +} diff --git a/src/etui/color.gleam b/src/etui/color.gleam new file mode 100644 index 0000000..2e33c44 --- /dev/null +++ b/src/etui/color.gleam @@ -0,0 +1,133 @@ +/// Color math and animation utilities. +/// All arithmetic is integer-only for BEAM determinism. +import etui/anim +import etui/style +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// RGB interpolation + +/// Linear interpolation between two Rgb colors. +/// For non-Rgb inputs, returns whichever endpoint is closer to t. +pub fn lerp_rgb( + a: style.Color, + b: style.Color, + t: Int, + max: Int, +) -> style.Color { + case a, b { + style.Rgb(r1, g1, b1), style.Rgb(r2, g2, b2) -> + style.Rgb( + anim.lerp(r1, r2, t, max), + anim.lerp(g1, g2, t, max), + anim.lerp(b1, b2, t, max), + ) + _, _ -> + case t * 2 < max { + True -> a + False -> b + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Hue → RGB + +/// Convert a hue angle (0–359) to a fully-saturated Rgb color. +/// Implements the HSV→RGB formula with S=1, V=1, integer arithmetic. +pub fn hue_to_rgb(hue: Int) -> style.Color { + let h = { hue % 360 + 360 } % 360 + let sector = h / 60 + let f = h % 60 + let up = f * 255 / 60 + let dn = { 60 - f } * 255 / 60 + case sector { + 0 -> style.Rgb(255, up, 0) + 1 -> style.Rgb(dn, 255, 0) + 2 -> style.Rgb(0, 255, up) + 3 -> style.Rgb(0, dn, 255) + 4 -> style.Rgb(up, 0, 255) + _ -> style.Rgb(255, 0, dn) + } +} + +// ───────────────────────────────────────────────────────────────── +// Rainbow + +/// Returns an Rgb color that cycles through the full hue spectrum +/// with the given period in frames. +pub fn rainbow(frame: Int, period: Int) -> style.Color { + let p = int.max(1, period) + hue_to_rgb(anim.cycle(frame, p) * 360 / p) +} + +// ───────────────────────────────────────────────────────────────── +// Gradient + +/// Color at integer position `pos` within [0, max] across a list of +/// color stops. Stops are distributed evenly. Lerps between adjacent +/// stops. Requires Rgb stops for smooth blending; non-Rgb stops snap. +pub fn gradient(stops: List(style.Color), pos: Int, max: Int) -> style.Color { + let n = list.length(stops) + case n { + 0 -> style.Default + 1 -> + case stops { + [c, ..] -> c + [] -> style.Default + } + _ -> { + let segs = n - 1 + let seg_size = int.max(1, max / segs) + let seg_idx = int.min(segs - 1, pos / seg_size) + let seg_pos = pos - seg_idx * seg_size + lerp_rgb( + get_nth(stops, seg_idx), + get_nth(stops, seg_idx + 1), + seg_pos, + seg_size, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Pulse + +/// Oscillate an Rgb color between half and full brightness. +/// Use `frame + x * phase_step` for a per-cell wave effect. +/// Non-Rgb colors are returned unchanged. +pub fn pulse(c: style.Color, frame: Int, period: Int) -> style.Color { + case c { + style.Rgb(r, g, b) -> { + let bright = anim.oscillate(128, 255, frame, int.max(1, period)) + style.Rgb(r * bright / 255, g * bright / 255, b * bright / 255) + } + _ -> c + } +} + +// ───────────────────────────────────────────────────────────────── +// Darken / brighten + +/// Scale all RGB channels by `factor` / 255. +/// factor=255 → unchanged, factor=128 → half brightness. +pub fn scale(c: style.Color, factor: Int) -> style.Color { + case c { + style.Rgb(r, g, b) -> + style.Rgb(r * factor / 255, g * factor / 255, b * factor / 255) + _ -> c + } +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn get_nth(colors: List(style.Color), n: Int) -> style.Color { + case colors { + [] -> style.Default + [c, ..] if n <= 0 -> c + [_, ..rest] -> get_nth(rest, n - 1) + } +} diff --git a/src/etui/cursor.gleam b/src/etui/cursor.gleam new file mode 100644 index 0000000..8e0a7cb --- /dev/null +++ b/src/etui/cursor.gleam @@ -0,0 +1,55 @@ +/// Terminal cursor shapes and movement helpers. +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Cursor shape + +pub type CursorShape { + BlockBlink + Block + UnderlineBlink + Underline + BarBlink + Bar +} + +/// ANSI sequence to change the cursor shape. +pub fn set_shape(shape: CursorShape) -> String { + case shape { + BlockBlink -> "\u{001B}[1 q" + Block -> "\u{001B}[2 q" + UnderlineBlink -> "\u{001B}[3 q" + Underline -> "\u{001B}[4 q" + BarBlink -> "\u{001B}[5 q" + Bar -> "\u{001B}[6 q" + } +} + +// ───────────────────────────────────────────────────────────────── +// Visibility + +pub fn show() -> String { + "\u{001B}[?25h" +} + +pub fn hide() -> String { + "\u{001B}[?25l" +} + +// ───────────────────────────────────────────────────────────────── +// Movement + +/// Move cursor to 1-based (row, col) position. +pub fn move_to(row: Int, col: Int) -> String { + "\u{001B}[" <> int.to_string(row) <> ";" <> int.to_string(col) <> "H" +} + +/// Save cursor position. +pub fn save() -> String { + "\u{001B}[s" +} + +/// Restore cursor position. +pub fn restore() -> String { + "\u{001B}[u" +} diff --git a/src/etui/focus.gleam b/src/etui/focus.gleam new file mode 100644 index 0000000..d7f0952 --- /dev/null +++ b/src/etui/focus.gleam @@ -0,0 +1,148 @@ +/// Focus ring, cycle keyboard focus between named widget slots. +/// +/// Keep a `FocusRing` in your app model. Route keyboard events to whichever +/// widget `is_focused`. Advance with Tab / Shift-Tab. +/// +/// ```gleam +/// import etui/focus +/// +/// let ring = focus.focus_new(["sidebar", "main", "statusbar"]) +/// +/// // In update: +/// let ring = case event { +/// KeyPress("tab") -> focus.focus_next(ring) +/// KeyPress("backtab") -> focus.focus_prev(ring) // Shift-Tab +/// _ -> ring +/// } +/// +/// // In render: +/// let sidebar_active = focus.is_focused(ring, "sidebar") +/// ``` +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Ordered ring of focus slot IDs. Exactly one slot is active at a time. +pub type FocusRing { + FocusRing(ids: List(String), current: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Create a focus ring from a list of slot IDs. +/// The first slot starts focused. Empty list creates an inert ring. +pub fn focus_new(ids: List(String)) -> FocusRing { + FocusRing(ids: ids, current: 0) +} + +// ───────────────────────────────────────────────────────────────── +// Queries + +/// ID of the currently focused slot, or `None` if the ring is empty. +pub fn focused(ring: FocusRing) -> Result(String, Nil) { + get_at(ring.ids, ring.current) +} + +/// `True` if `id` is the currently focused slot. +pub fn is_focused(ring: FocusRing, id: String) -> Bool { + case focused(ring) { + Ok(current_id) -> current_id == id + Error(_) -> False + } +} + +/// 0-based index of the currently focused slot. +pub fn current_index(ring: FocusRing) -> Int { + ring.current +} + +/// Total number of slots in the ring. +pub fn size(ring: FocusRing) -> Int { + list.length(ring.ids) +} + +// ───────────────────────────────────────────────────────────────── +// Navigation + +/// Move focus to the next slot (wraps around). +pub fn focus_next(ring: FocusRing) -> FocusRing { + let n = list.length(ring.ids) + case n { + 0 -> ring + _ -> + FocusRing( + ..ring, + current: { ring.current + 1 } |> int.modulo(n) |> unwrap_zero, + ) + } +} + +/// Move focus to the previous slot (wraps around). +pub fn focus_prev(ring: FocusRing) -> FocusRing { + let n = list.length(ring.ids) + case n { + 0 -> ring + _ -> + FocusRing( + ..ring, + current: { ring.current + n - 1 } |> int.modulo(n) |> unwrap_zero, + ) + } +} + +/// Move focus to the slot with the given `id`. No-op if `id` not found. +pub fn focus_id(ring: FocusRing, id: String) -> FocusRing { + case index_of(ring.ids, id, 0) { + Ok(i) -> FocusRing(..ring, current: i) + Error(_) -> ring + } +} + +/// Move focus to the slot at the given index (clamped to valid range). +pub fn focus_index(ring: FocusRing, idx: Int) -> FocusRing { + let n = list.length(ring.ids) + case n { + 0 -> ring + _ -> FocusRing(..ring, current: int.clamp(idx, 0, n - 1)) + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn get_at(lst: List(String), idx: Int) -> Result(String, Nil) { + case idx { + i if i < 0 -> Error(Nil) + 0 -> + case lst { + [h, ..] -> Ok(h) + [] -> Error(Nil) + } + _ -> + case lst { + [] -> Error(Nil) + [_, ..rest] -> get_at(rest, idx - 1) + } + } +} + +fn index_of(lst: List(String), target: String, acc: Int) -> Result(Int, Nil) { + case lst { + [] -> Error(Nil) + [h, ..rest] -> + case h == target { + True -> Ok(acc) + False -> index_of(rest, target, acc + 1) + } + } +} + +fn unwrap_zero(r: Result(Int, Nil)) -> Int { + case r { + Ok(n) -> n + Error(_) -> 0 + } +} diff --git a/src/etui/geometry.gleam b/src/etui/geometry.gleam new file mode 100644 index 0000000..c29404e --- /dev/null +++ b/src/etui/geometry.gleam @@ -0,0 +1,804 @@ +/// Pure layout mathematics. Zero dependencies. No terminal knowledge. +/// All functions are deterministic and testable without I/O. +import gleam/int +import gleam/list + +/// Coordinate on the screen. +pub type Position { + Position(x: Int, y: Int) +} + +/// Dimensions in cells. +pub type Size { + Size(width: Int, height: Int) +} + +/// A rectangular area on screen. +pub type Rect { + Rect(position: Position, size: Size) +} + +/// How to split a rectangle when laying out widgets. +pub type Direction { + /// Constraints stacked along X axis (side-by-side columns). + Horizontal + /// Constraints stacked along Y axis (rows). + Vertical +} + +/// Layout constraint: how much space to claim. +/// +/// Priority (highest → lowest): +/// Length > Min/Max > Ratio/Percentage > Fill +pub type Constraint { + /// Fixed cell count. Highest priority. Allocated first. + Length(Int) + /// At least n cells. Participates in flexible distribution with a floor. + Min(Int) + /// At most n cells. Participates in flexible distribution with a ceiling. + Max(Int) + /// Percentage of total (0..100). Computed cumulatively to avoid pixel loss. + Percentage(Int) + /// Rational fraction of total: numerator/denominator. Exact integer math. + /// `Ratio(1, 3)` is one-third of the total space. + Ratio(Int, Int) + /// Flexible. Divides leftover equally after Length + Percentage + Ratio. + Fill +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Create a Rect with clamped width/height to non-negative. +pub fn rect_new(x: Int, y: Int, width: Int, height: Int) -> Rect { + Rect( + position: Position(x: x, y: y), + size: Size(width: int.max(0, width), height: int.max(0, height)), + ) +} + +/// Zero-sized rect at origin. +pub fn rect_zero() -> Rect { + Rect(position: Position(x: 0, y: 0), size: Size(width: 0, height: 0)) +} + +// ───────────────────────────────────────────────────────────────── +// Queries + +/// X coordinate of the right edge (exclusive: x + width). +pub fn right(rect: Rect) -> Int { + rect.position.x + rect.size.width +} + +/// Y coordinate of the bottom edge (exclusive: y + height). +pub fn bottom(rect: Rect) -> Int { + rect.position.y + rect.size.height +} + +/// Area in cells. +pub fn area(rect: Rect) -> Int { + rect.size.width * rect.size.height +} + +/// Check if a position is inside the rect (inclusive of edges). +pub fn contains(rect: Rect, pos: Position) -> Bool { + pos.x >= rect.position.x + && pos.x < right(rect) + && pos.y >= rect.position.y + && pos.y < bottom(rect) +} + +/// True if terminal cell `(x, y)` is inside `rect`. +/// Convenience wrapper over `contains` for use with mouse event coordinates. +pub fn hit_test(rect: Rect, x: Int, y: Int) -> Bool { + contains(rect, Position(x: x, y: y)) +} + +/// Intersection of two rects. Returns the overlapping rect if any. +pub fn intersect(a: Rect, b: Rect) -> Result(Rect, Nil) { + let left = int.max(a.position.x, b.position.x) + let top = int.max(a.position.y, b.position.y) + let right_edge = int.min(right(a), right(b)) + let bottom_edge = int.min(bottom(a), bottom(b)) + + case left < right_edge && top < bottom_edge { + True -> + Ok(Rect( + position: Position(x: left, y: top), + size: Size(width: right_edge - left, height: bottom_edge - top), + )) + False -> Error(Nil) + } +} + +/// Union of two rects: smallest rect that contains both. +pub fn union(a: Rect, b: Rect) -> Rect { + let left = int.min(a.position.x, b.position.x) + let top = int.min(a.position.y, b.position.y) + let right_edge = int.max(right(a), right(b)) + let bottom_edge = int.max(bottom(a), bottom(b)) + + Rect( + position: Position(x: left, y: top), + size: Size(width: right_edge - left, height: bottom_edge - top), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Core algorithm: resolve_sizes + +/// Distribute total space among constraints. +/// +/// Returns a list of sizes (one per constraint) that sum to ≤ total. +/// Sum equals total when Fill (or Min/Max) is present or constraints saturate. +/// +/// Algorithm (three-phase Discrete Cumulative Allocation): +/// 1. Length, exact, allocated first. Clamped to remaining budget in order. +/// 2. Percentage + Ratio, proportional from total. Cumulative to prevent jitter. +/// Scaled proportionally if combined demand exceeds available budget. +/// 3. Fill + Min + Max, divide remaining equally. +/// Min applies a floor; Max applies a ceiling. Fill gets equal share. +pub fn resolve_sizes(total: Int, constraints: List(Constraint)) -> List(Int) { + case total < 0 { + True -> list.map(constraints, fn(_) { 0 }) + False -> resolve_sizes_impl(total, constraints) + } +} + +fn resolve_sizes_impl(total: Int, constraints: List(Constraint)) -> List(Int) { + // Phase 1: Length (exact, highest priority) + let #(length_sizes, length_used) = phase_length(constraints, total, 0, []) + let prop_budget = int.max(0, total - length_used) + + // Phase 2: Percentage (old cumulative algorithm, preserves stability invariant) + // Cumulative targets: floor(base * cumsum_pct / denom). Diffs give exact sizes. + let pct_total_pct = + list.fold(constraints, 0, fn(acc, c) { + case c { + Percentage(p) -> acc + p + _ -> acc + } + }) + let #(denom, pct_base) = case total * pct_total_pct > prop_budget * 100 { + True -> #(pct_total_pct, prop_budget) + False -> #(100, total) + } + let #(pct_sizes, pct_used) = + phase_percentage(constraints, denom, pct_base, 0, 0, []) + + // Phase 2b: Ratio, each Ratio(a, b) desires total * a / b cells. + // Uses demand-based cumulative scaling, allocated from budget after Percentage. + let ratio_budget = int.max(0, prop_budget - pct_used) + let ratio_demands = + list.map(constraints, fn(c) { + case c { + Ratio(a, b) -> + case b { + 0 -> 0 + _ -> total * a / b + } + _ -> 0 + } + }) + let total_ratio_demand = list.fold(ratio_demands, 0, fn(acc, d) { acc + d }) + let #(ratio_sizes, ratio_used) = + phase_proportional( + ratio_demands, + ratio_budget, + total_ratio_demand, + 0, + 0, + [], + ) + + // Phase 3: Fill + Min + Max (flexible, divide remaining) + let flex_budget = int.max(0, total - length_used - pct_used - ratio_used) + let flex_count = + list.count(constraints, fn(c) { + case c { + Fill | Min(_) | Max(_) -> True + _ -> False + } + }) + let flex_sizes = phase_flex(constraints, flex_count, flex_budget, 0, []) + + assemble_sizes( + constraints, + length_sizes, + pct_sizes, + ratio_sizes, + flex_sizes, + [], + ) +} + +fn phase_length( + constraints: List(Constraint), + total: Int, + used: Int, + acc: List(Int), +) -> #(List(Int), Int) { + case constraints { + [] -> #(list.reverse(acc), used) + [c, ..rest] -> { + let #(size, new_used) = case c { + Length(v) -> { + let take = int.min(v, int.max(0, total - used)) + #(take, used + take) + } + _ -> #(0, used) + } + phase_length(rest, total, new_used, [size, ..acc]) + } + } +} + +// Old cumulative Percentage algorithm. Preserves the stability invariant: +// sum(pct_sizes) = floor(base * total_pct / denom). Rounding goes to last element. +fn phase_percentage( + constraints: List(Constraint), + denom: Int, + base: Int, + acc_pct: Int, + prev_target: Int, + acc: List(Int), +) -> #(List(Int), Int) { + case constraints { + [] -> #(list.reverse(acc), prev_target) + [c, ..rest] -> { + let #(size, new_acc, new_target) = case c { + Percentage(p) -> { + let new_acc_pct = acc_pct + p + let target = case denom { + 0 -> 0 + _ -> base * new_acc_pct / denom + } + let s = target - prev_target + #(s, new_acc_pct, target) + } + _ -> #(0, acc_pct, prev_target) + } + phase_percentage(rest, denom, base, new_acc, new_target, [size, ..acc]) + } + } +} + +// Cumulative proportional allocation. Prevents pixel-loss jitter. +// When total_demand <= budget: uses demands as-is (no scaling). +// When total_demand > budget: scales proportionally via cumulative targets. +fn phase_proportional( + demands: List(Int), + budget: Int, + total_demand: Int, + cumsum: Int, + prev_target: Int, + acc: List(Int), +) -> #(List(Int), Int) { + case demands { + [] -> #(list.reverse(acc), prev_target) + [d, ..rest] -> { + let new_cumsum = cumsum + d + let target = case total_demand { + 0 -> 0 + _ -> + case total_demand <= budget { + True -> new_cumsum + False -> budget * new_cumsum / total_demand + } + } + let size = target - prev_target + phase_proportional(rest, budget, total_demand, new_cumsum, target, [ + size, + ..acc + ]) + } + } +} + +// Flexible allocation for Fill, Min, Max. +// Two-sub-pass algorithm so Fill always consumes the full budget: +// 1) Compute each Min/Max's effective size at base share. +// 2) Distribute remaining budget equally among Fill constraints. +// This guarantees sum(flex_sizes) = budget (before build_rects clamping). +fn phase_flex( + constraints: List(Constraint), + flex_count: Int, + budget: Int, + _idx: Int, + _acc: List(Int), +) -> List(Int) { + case flex_count { + 0 -> list.map(constraints, fn(_) { 0 }) + _ -> { + let base = budget / flex_count + // Sub-pass 1: compute total claimed by Min/Max at their effective sizes. + let min_max_used = + list.fold(constraints, 0, fn(acc, c) { + case c { + Min(n) -> acc + int.max(n, base) + Max(n) -> acc + int.min(n, base) + _ -> acc + } + }) + let fill_count = + list.count(constraints, fn(c) { + case c { + Fill -> True + _ -> False + } + }) + let fill_budget = int.max(0, budget - min_max_used) + let fill_base = case fill_count { + 0 -> 0 + _ -> fill_budget / fill_count + } + let fill_rem = case fill_count { + 0 -> 0 + _ -> fill_budget % fill_count + } + // Sub-pass 2: assign sizes. + let #(sizes, _) = + list.fold(constraints, #([], 0), fn(state, c) { + let #(acc, fill_idx) = state + let #(size, new_fill_idx) = case c { + Fill -> { + let s = case fill_idx < fill_rem { + True -> fill_base + 1 + False -> fill_base + } + #(s, fill_idx + 1) + } + Min(n) -> #(int.max(n, base), fill_idx) + Max(n) -> #(int.min(n, base), fill_idx) + _ -> #(0, fill_idx) + } + #([size, ..acc], new_fill_idx) + }) + list.reverse(sizes) + } + } +} + +fn assemble_sizes( + constraints: List(Constraint), + length_sizes: List(Int), + pct_sizes: List(Int), + ratio_sizes: List(Int), + flex_sizes: List(Int), + acc: List(Int), +) -> List(Int) { + case constraints { + [] -> list.reverse(acc) + [c, ..cs] -> { + let size = pick_size(c, length_sizes, pct_sizes, ratio_sizes, flex_sizes) + let ls = case length_sizes { + [_, ..t] -> t + _ -> [] + } + let ps = case pct_sizes { + [_, ..t] -> t + _ -> [] + } + let rs = case ratio_sizes { + [_, ..t] -> t + _ -> [] + } + let fs = case flex_sizes { + [_, ..t] -> t + _ -> [] + } + assemble_sizes(cs, ls, ps, rs, fs, [size, ..acc]) + } + } +} + +fn pick_size( + constraint: Constraint, + lengths: List(Int), + pcts: List(Int), + ratios: List(Int), + flexes: List(Int), +) -> Int { + case constraint { + Length(_) -> + case lengths { + [h, ..] -> h + _ -> 0 + } + Percentage(_) -> + case pcts { + [h, ..] -> h + _ -> 0 + } + Ratio(_, _) -> + case ratios { + [h, ..] -> h + _ -> 0 + } + Fill | Min(_) | Max(_) -> + case flexes { + [h, ..] -> h + _ -> 0 + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Layout: split a rect by constraints + +/// Split horizontally (columns side-by-side). Shorthand for `split(Horizontal, ...)`. +pub fn split_h(area: Rect, constraints: List(Constraint)) -> List(Rect) { + split(Horizontal, area, constraints) +} + +/// Split vertically (rows stacked). Shorthand for `split(Vertical, ...)`. +pub fn split_v(area: Rect, constraints: List(Constraint)) -> List(Rect) { + split(Vertical, area, constraints) +} + +/// Center a rect of `width × height` within `area`. +/// Clamps to area bounds. Common for popup placement. +/// +/// ```gleam +/// let popup_area = geometry.centered_rect(60, 20, screen) +/// ``` +pub fn centered_rect(width: Int, height: Int, area: Rect) -> Rect { + let w = int.min(width, area.size.width) + let h = int.min(height, area.size.height) + let x = area.position.x + { area.size.width - w } / 2 + let y = area.position.y + { area.size.height - h } / 2 + Rect(position: Position(x: x, y: y), size: Size(width: w, height: h)) +} + +/// Center a rect sized as a percentage of `area` (`pct_w` and `pct_h` are 0–100). +/// Useful for responsive popup sizing: +/// +/// ```gleam +/// let popup_area = geometry.percent_rect(60, 40, screen) // 60% wide, 40% tall +/// ``` +pub fn percent_rect(pct_w: Int, pct_h: Int, area: Rect) -> Rect { + let w = area.size.width * int.clamp(pct_w, 0, 100) / 100 + let h = area.size.height * int.clamp(pct_h, 0, 100) / 100 + centered_rect(w, h, area) +} + +/// Split a rect along a direction by applying constraints. +pub fn split( + direction: Direction, + area: Rect, + constraints: List(Constraint), +) -> List(Rect) { + let total = case direction { + Vertical -> area.size.height + Horizontal -> area.size.width + } + + let sizes = resolve_sizes(total, constraints) + + build_rects(direction, area, sizes, 0, []) +} + +fn build_rects( + direction: Direction, + area: Rect, + sizes: List(Int), + cursor: Int, + acc: List(Rect), +) -> List(Rect) { + let limit = case direction { + Vertical -> area.size.height + Horizontal -> area.size.width + } + case sizes { + [] -> list.reverse(acc) + [size, ..rest] -> { + // Clamp so no child Rect extends past the parent boundary. + // This guards against over-budget Min/Max constraints. + let start = int.min(cursor, limit) + let clamped = int.min(size, int.max(0, limit - start)) + let rect = case direction { + Vertical -> + Rect( + position: Position(x: area.position.x, y: area.position.y + start), + size: Size(width: area.size.width, height: clamped), + ) + Horizontal -> + Rect( + position: Position(x: area.position.x + start, y: area.position.y), + size: Size(width: clamped, height: area.size.height), + ) + } + build_rects(direction, area, rest, start + clamped, [rect, ..acc]) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Layout with spacing + +/// Split a rect with `spacing` cells of gap between each child. +/// Gap cells are taken from the total before distributing to constraints. +/// +/// ```gleam +/// // Two columns with a 1-cell gap +/// split_with_spacing(Horizontal, area, [Fill, Fill], 1) +/// ``` +pub fn split_with_spacing( + direction: Direction, + area: Rect, + constraints: List(Constraint), + spacing: Int, +) -> List(Rect) { + let n = list.length(constraints) + case n <= 1 { + True -> split(direction, area, constraints) + False -> { + let gap_total = int.max(0, spacing) * { n - 1 } + let total = case direction { + Vertical -> area.size.height + Horizontal -> area.size.width + } + let available = int.max(0, total - gap_total) + let sizes = resolve_sizes(available, constraints) + build_rects_spaced(direction, area, sizes, int.max(0, spacing), 0, []) + } + } +} + +fn build_rects_spaced( + direction: Direction, + area: Rect, + sizes: List(Int), + spacing: Int, + cursor: Int, + acc: List(Rect), +) -> List(Rect) { + let limit = case direction { + Vertical -> area.size.height + Horizontal -> area.size.width + } + case sizes { + [] -> list.reverse(acc) + [size, ..rest] -> { + let start = int.min(cursor, limit) + let clamped = int.min(size, int.max(0, limit - start)) + let rect = case direction { + Vertical -> + Rect( + position: Position(x: area.position.x, y: area.position.y + start), + size: Size(width: area.size.width, height: clamped), + ) + Horizontal -> + Rect( + position: Position(x: area.position.x + start, y: area.position.y), + size: Size(width: clamped, height: area.size.height), + ) + } + let next_cursor = case rest { + [] -> start + clamped + _ -> start + clamped + spacing + } + build_rects_spaced(direction, area, rest, spacing, next_cursor, [ + rect, + ..acc + ]) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Flex layout + +/// How to distribute leftover space among children in a flex layout. +/// +/// | Justify | Description | +/// |--------------|----------------------------------------------------------| +/// | `FlexStart` | Pack children at the start; leftover space at the end. | +/// | `FlexEnd` | Pack children at the end; leftover space at the start. | +/// | `FlexCenter` | Center children; leftover space split evenly on both sides. | +/// | `FlexBetween`| Children spread out; space between them (none at edges). | +/// | `FlexAround` | Equal space around each child (half at edges). | +pub type FlexJustify { + FlexStart + FlexEnd + FlexCenter + FlexBetween + FlexAround +} + +/// Flex layout: children have fixed sizes (from constraints), leftover space +/// distributed according to `justify`. Use for toolbars, status bars, centering +/// a widget in a larger area, or equal-gap grids. +/// +/// `gap` is the minimum gap between children (cells). Ignored when `justify` +/// provides its own spacing (Between/Around). With `FlexStart`/`End`/`Center`, +/// `gap` acts like `split_with_spacing`'s spacing parameter. +/// +/// ```gleam +/// // Center a 20-wide widget in a 80-wide area: +/// split_flex(Horizontal, area, [Length(20)], FlexCenter, 0) +/// +/// // Three buttons with 2-cell gap between: +/// split_flex(Horizontal, area, [Length(10), Length(10), Length(10)], FlexStart, 2) +/// +/// // Toolbar: left item + right item, space between: +/// split_flex(Horizontal, area, [Length(10), Length(10)], FlexBetween, 0) +/// ``` +pub fn split_flex( + direction: Direction, + area: Rect, + constraints: List(Constraint), + justify: FlexJustify, + gap: Int, +) -> List(Rect) { + let n = list.length(constraints) + case n == 0 { + True -> [] + False -> { + let total = case direction { + Vertical -> area.size.height + Horizontal -> area.size.width + } + let gap_cells = int.max(0, gap) * int.max(0, n - 1) + let available = int.max(0, total - gap_cells) + let sizes = resolve_sizes(available, constraints) + let content_width = + list.fold(sizes, 0, fn(acc, s) { acc + s }) + gap_cells + let leftover = int.max(0, total - content_width) + let offsets = flex_offsets(sizes, justify, gap, leftover, n) + build_flex_rects(direction, area, sizes, offsets, []) + } + } +} + +fn flex_offsets( + sizes: List(Int), + justify: FlexJustify, + gap: Int, + leftover: Int, + n: Int, +) -> List(Int) { + case justify { + FlexStart -> start_offsets(sizes, gap, 0, []) + FlexEnd -> start_offsets(sizes, gap, leftover, []) + FlexCenter -> start_offsets(sizes, gap, leftover / 2, []) + FlexBetween -> between_offsets(sizes, leftover, n, 0, []) + FlexAround -> around_offsets(sizes, leftover, n, 0, []) + } +} + +fn start_offsets( + sizes: List(Int), + gap: Int, + start: Int, + acc: List(Int), +) -> List(Int) { + case sizes { + [] -> list.reverse(acc) + [s, ..rest] -> { + let next = start + s + gap + start_offsets(rest, gap, next, [start, ..acc]) + } + } +} + +fn between_offsets( + sizes: List(Int), + leftover: Int, + n: Int, + cursor: Int, + acc: List(Int), +) -> List(Int) { + let gaps = int.max(1, n - 1) + let gap_size = case gaps { + 0 -> 0 + _ -> leftover / gaps + } + case sizes { + [] -> list.reverse(acc) + [s, ..rest] -> { + let next = cursor + s + gap_size + between_offsets(rest, leftover, n, next, [cursor, ..acc]) + } + } +} + +fn around_offsets( + sizes: List(Int), + leftover: Int, + n: Int, + cursor: Int, + acc: List(Int), +) -> List(Int) { + let slot = case n { + 0 -> 0 + _ -> leftover / n + } + let half = slot / 2 + case sizes { + [] -> list.reverse(acc) + [s, ..rest] -> { + let pos = cursor + half + let next = pos + s + half + slot % 2 + around_offsets(rest, leftover, n, next, [pos, ..acc]) + } + } +} + +fn build_flex_rects( + direction: Direction, + area: Rect, + sizes: List(Int), + offsets: List(Int), + acc: List(Rect), +) -> List(Rect) { + case sizes, offsets { + [], _ | _, [] -> list.reverse(acc) + [size, ..rest_s], [offset, ..rest_o] -> { + let rect = case direction { + Vertical -> + Rect( + position: Position(x: area.position.x, y: area.position.y + offset), + size: Size(width: area.size.width, height: size), + ) + Horizontal -> + Rect( + position: Position(x: area.position.x + offset, y: area.position.y), + size: Size(width: size, height: area.size.height), + ) + } + build_flex_rects(direction, area, rest_s, rest_o, [rect, ..acc]) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Responsive layout + +/// A responsive breakpoint: applies `constraints` when `area` width >= `min_width`. +pub type Breakpoint { + Breakpoint(min_width: Int, constraints: List(Constraint)) +} + +/// Split `area` horizontally using the first breakpoint whose `min_width` <= +/// `area.size.width`, evaluated in descending order. Falls back to the last +/// breakpoint (assumed smallest). Returns `[area]` if `breakpoints` is empty. +/// +/// Example, two columns on wide screens, stacked on narrow: +/// ```gleam +/// geometry.split_responsive(area, [ +/// geometry.Breakpoint(80, [Percentage(50), Percentage(50)]), +/// geometry.Breakpoint(0, [Percentage(100)]), +/// ]) +/// ``` +pub fn split_responsive( + area: Rect, + breakpoints: List(Breakpoint), +) -> List(Rect) { + case breakpoints { + [] -> [area] + _ -> { + let sorted = + list.sort(breakpoints, fn(a, b) { + int.compare(b.min_width, a.min_width) + }) + let chosen = pick_breakpoint(sorted, area.size.width) + split_h(area, chosen) + } + } +} + +fn pick_breakpoint( + sorted_desc: List(Breakpoint), + width: Int, +) -> List(Constraint) { + case sorted_desc { + [] -> [] + [bp] -> bp.constraints + [bp, ..rest] -> + case width >= bp.min_width { + True -> bp.constraints + False -> pick_breakpoint(rest, width) + } + } +} diff --git a/src/etui/keymap.gleam b/src/etui/keymap.gleam new file mode 100644 index 0000000..65c1a49 --- /dev/null +++ b/src/etui/keymap.gleam @@ -0,0 +1,183 @@ +/// Named key bindings with help text generation. +/// +/// Register named commands with their key trigger and description. +/// Use `lookup` to dispatch events, `help_lines` to build a help overlay. +/// +/// ```gleam +/// import etui/keymap +/// import etui/keys +/// +/// type Action { Quit | Save | OpenFile } +/// +/// let km = +/// keymap.keymap_new() +/// |> keymap.bind("ctrl+q", Quit, "Quit") +/// |> keymap.bind("ctrl+s", Save, "Save") +/// |> keymap.bind("ctrl+o", OpenFile, "Open file") +/// +/// // In on_event: +/// case keymap.lookup(km, raw_key_string) { +/// Ok(Quit) -> ... +/// Ok(Save) -> ... +/// _ -> state +/// } +/// +/// // Render a help overlay: +/// let help_buf = keymap.render_help(buf, area, km, style.default_style()) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +/// A single key binding: key → action + description. +pub type Binding(action) { + Binding(key: String, action: action, description: String) +} + +/// Ordered list of key bindings. +pub type Keymap(action) { + Keymap(bindings: List(Binding(action))) +} + +// ───────────────────────────────────────────────────────────────── +// Constructor + +/// Empty keymap. +pub fn keymap_new() -> Keymap(action) { + Keymap(bindings: []) +} + +/// Add a binding to the end of the keymap. +/// The first matching binding wins on `lookup`. +pub fn bind( + km: Keymap(action), + key: String, + action: action, + description: String, +) -> Keymap(action) { + Keymap( + bindings: list.append(km.bindings, [Binding(key, action, description)]), + ) +} + +/// Remove all bindings for a given key. +pub fn unbind(km: Keymap(action), key: String) -> Keymap(action) { + Keymap(bindings: list.filter(km.bindings, fn(b) { b.key != key })) +} + +/// Merge `other` into `km`. Bindings from `other` are appended. +pub fn merge(km: Keymap(a), other: Keymap(a)) -> Keymap(a) { + Keymap(bindings: list.append(km.bindings, other.bindings)) +} + +// ───────────────────────────────────────────────────────────────── +// Lookup + +/// Find the action bound to `key`. Returns `Error(Nil)` if not found. +pub fn lookup(km: Keymap(action), key: String) -> Result(action, Nil) { + case list.find(km.bindings, fn(b) { b.key == key }) { + Ok(b) -> Ok(b.action) + Error(_) -> Error(Nil) + } +} + +/// All bindings as `#(key, description)` pairs, in registration order. +pub fn help_lines(km: Keymap(action)) -> List(#(String, String)) { + list.map(km.bindings, fn(b) { #(b.key, b.description) }) +} + +/// All bindings as `#(key, action)` pairs. +pub fn all_bindings(km: Keymap(action)) -> List(#(String, action)) { + list.map(km.bindings, fn(b) { #(b.key, b.action) }) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render a help table into `area`. +/// Each row shows ` key_col description`. `key_col_width` is the +/// minimum column width for keys (auto-computed if 0). +/// Returns the buffer with the help overlay drawn. +pub fn render_help( + buf: buffer.Buffer, + area: geometry.Rect, + km: Keymap(action), + st: style.Style, +) -> buffer.Buffer { + let lines = help_lines(km) + let key_w = case + list.fold(lines, 0, fn(acc, pair) { + let #(k, _) = pair + case text.cell_width(k) > acc { + True -> text.cell_width(k) + False -> acc + } + }) + { + 0 -> 6 + n -> n + } + render_help_rows(buf, area, lines, key_w, st, 0) +} + +fn render_help_rows( + buf: buffer.Buffer, + area: geometry.Rect, + lines: List(#(String, String)), + key_w: Int, + st: style.Style, + row: Int, +) -> buffer.Buffer { + case row >= area.size.height || list.is_empty(lines) { + True -> buf + False -> + case lines { + [] -> buf + [#(key, desc), ..rest] -> { + let padded_key = text.pad_right(key, key_w) + let row_text = + padded_key + <> " " + <> text.truncate(desc, area.size.width - key_w - 2, "") + let trimmed = text.truncate(row_text, area.size.width, "") + let padded = text.pad_right(trimmed, area.size.width) + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: area.position.y + row), + padded, + st.fg, + st.bg, + st.modifier, + ) + render_help_rows(buf2, area, rest, key_w, st, row + 1) + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Filtering + +/// Keep only bindings whose description contains `query` (case-insensitive). +/// Useful for a live-filter command palette. +pub fn filter(km: Keymap(action), query: String) -> Keymap(action) { + case query { + "" -> km + q -> { + let lower_q = string.lowercase(q) + Keymap( + bindings: list.filter(km.bindings, fn(b) { + string.contains(string.lowercase(b.description), lower_q) + || string.contains(string.lowercase(b.key), lower_q) + }), + ) + } + } +} diff --git a/src/etui/keys.gleam b/src/etui/keys.gleam new file mode 100644 index 0000000..b136e2f --- /dev/null +++ b/src/etui/keys.gleam @@ -0,0 +1,147 @@ +/// Named key constants and pattern-match helper for keyboard events. +/// +/// Instead of comparing raw strings from `backend.KeyPress(key)` everywhere, +/// use these constants for clarity and to avoid typos. +/// +/// ```gleam +/// import etui/keys +/// import etui/backend +/// +/// fn on_event(ev: backend.InputEvent, state: Model) -> Model { +/// case ev { +/// backend.KeyPress(k) -> case keys.match(k) { +/// keys.Up -> Model(..state, selected: state.selected - 1) +/// keys.Down -> Model(..state, selected: state.selected + 1) +/// keys.Enter -> Model(..state, open: True) +/// keys.Char(c) -> handle_char(c, state) +/// _ -> state +/// } +/// _ -> state +/// } +/// } +/// ``` +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Key type + +pub type Key { + /// Plain printable character (single grapheme, not a control key). + Char(String) + Up + Down + Left + Right + Enter + Backspace + Delete + Tab + BackTab + Home + End + PageUp + PageDown + Escape + Insert + /// F1–F12 + F(Int) + /// Ctrl+, e.g. Ctrl("c"), Ctrl("d") + Ctrl(String) + /// Alt+, e.g. Alt("f") + Alt(String) + /// Unknown / unrecognised key string. + Unknown(String) +} + +// ───────────────────────────────────────────────────────────────── +// Match helper + +/// Parse a raw key string (from `backend.KeyPress`) into a `Key`. +/// +/// Raw strings from the Erlang backend follow these conventions: +/// - Printable ASCII/Unicode: the character itself (e.g. `"a"`, `"A"`, `"€"`) +/// - Arrow keys: `"up"`, `"down"`, `"left"`, `"right"` +/// - Control keys: `"enter"`, `"backspace"`, `"delete"`, `"tab"`, `"backtab"`, +/// `"home"`, `"end"`, `"pageup"`, `"pagedown"`, `"esc"`, `"insert"` +/// - Function keys: `"f1"` … `"f12"` +/// - Ctrl combos: `"ctrl+a"` … `"ctrl+z"`, `"ctrl+["`, etc. +/// - Alt combos: `"alt+a"` … `"alt+z"`, etc. +pub fn match(raw: String) -> Key { + case raw { + "up" -> Up + "down" -> Down + "left" -> Left + "right" -> Right + "enter" -> Enter + "backspace" -> Backspace + "delete" -> Delete + "tab" -> Tab + "backtab" -> BackTab + "home" -> Home + "end" -> End + "pageup" -> PageUp + "pagedown" -> PageDown + "esc" -> Escape + "insert" -> Insert + "f1" -> F(1) + "f2" -> F(2) + "f3" -> F(3) + "f4" -> F(4) + "f5" -> F(5) + "f6" -> F(6) + "f7" -> F(7) + "f8" -> F(8) + "f9" -> F(9) + "f10" -> F(10) + "f11" -> F(11) + "f12" -> F(12) + _ -> + case string.starts_with(raw, "ctrl+") { + True -> Ctrl(string.drop_start(raw, 5)) + False -> + case string.starts_with(raw, "alt+") { + True -> Alt(string.drop_start(raw, 4)) + False -> + case string.length(raw) > 0 { + True -> Char(raw) + False -> Unknown(raw) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Convenience predicates + +/// True if key is a printable character (not a control/special key). +pub fn is_char(k: Key) -> Bool { + case k { + Char(_) -> True + _ -> False + } +} + +/// Extract the character string from a `Char` key. Returns `""` for others. +pub fn char_value(k: Key) -> String { + case k { + Char(c) -> c + _ -> "" + } +} + +/// True if the key is a navigation key (arrows, home, end, page up/down). +pub fn is_navigation(k: Key) -> Bool { + case k { + Up | Down | Left | Right | Home | End | PageUp | PageDown -> True + _ -> False + } +} + +/// True if the key is a modifier combo (Ctrl or Alt). +pub fn is_modifier(k: Key) -> Bool { + case k { + Ctrl(_) | Alt(_) -> True + _ -> False + } +} diff --git a/src/etui/span.gleam b/src/etui/span.gleam new file mode 100644 index 0000000..ba60c54 --- /dev/null +++ b/src/etui/span.gleam @@ -0,0 +1,226 @@ +/// Styled text spans: inline mixed-style text for TUI widgets. +/// +/// A `Span` is a styled text fragment. A `Line` is a list of spans +/// rendered left-to-right on a single terminal row. Use `render_line` +/// to draw a `Line` into a buffer at a given position. +/// +/// Example: +/// ```gleam +/// let line = line_new([ +/// span_styled("ERROR", style.bold_style() |> style.with_fg(style.Rgb(255,0,0))), +/// span_plain(" file not found"), +/// ]) +/// span.render_line(buf, pos, line, 40) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +/// A styled text fragment: string content + display style + optional hyperlink. +pub type Span { + Span( + content: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + /// OSC 8 hyperlink URI. Empty string = no link. + link: String, + ) +} + +/// A single terminal row composed of styled spans. +pub type Line { + Line(spans: List(Span), alignment: text.Alignment) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Span with default terminal colors and no modifier. +pub fn span_plain(content: String) -> Span { + Span( + content: content, + fg: style.Default, + bg: style.Default, + modifier: style.none(), + link: "", + ) +} + +/// Span with explicit style applied. +pub fn span_styled(content: String, s: style.Style) -> Span { + Span(content: content, fg: s.fg, bg: s.bg, modifier: s.modifier, link: "") +} + +/// Span with an OSC 8 clickable hyperlink. +/// Terminals that support OSC 8 (iTerm2, Kitty, VTE, Windows Terminal) will +/// render the text as a clickable link. Others display it as plain text. +/// +/// ```gleam +/// span.span_link("docs.gleam.run", "https://docs.gleam.run") +/// ``` +pub fn span_link(content: String, uri: String) -> Span { + Span( + content: content, + fg: style.Default, + bg: style.Default, + modifier: style.none(), + link: uri, + ) +} + +/// Add an OSC 8 hyperlink URI to an existing span. +pub fn with_link(sp: Span, uri: String) -> Span { + Span(..sp, link: uri) +} + +/// Set foreground color on a span. +pub fn span_fg(sp: Span, color: style.Color) -> Span { + Span(..sp, fg: color) +} + +/// Set background color on a span. +pub fn span_bg(sp: Span, color: style.Color) -> Span { + Span(..sp, bg: color) +} + +/// Add a modifier to a span. +pub fn span_modifier(sp: Span, modifier: style.Modifier) -> Span { + Span(..sp, modifier: style.add(sp.modifier, modifier)) +} + +/// Total cell width of a span. +pub fn span_width(sp: Span) -> Int { + text.cell_width(sp.content) +} + +/// Line from a list of spans, left-aligned. +pub fn line_new(spans: List(Span)) -> Line { + Line(spans: spans, alignment: text.Left) +} + +/// Line with a single unstyled string, left-aligned. +pub fn line_plain(content: String) -> Line { + Line(spans: [span_plain(content)], alignment: text.Left) +} + +/// Line from spans with explicit alignment. +pub fn line_aligned(spans: List(Span), alignment: text.Alignment) -> Line { + Line(spans: spans, alignment: alignment) +} + +/// Bold span (default colors + bold modifier). +pub fn span_bold(content: String) -> Span { + Span( + content: content, + fg: style.Default, + bg: style.Default, + modifier: style.bold(), + link: "", + ) +} + +/// Italic span (default colors + italic modifier). +pub fn span_italic(content: String) -> Span { + Span( + content: content, + fg: style.Default, + bg: style.Default, + modifier: style.italic(), + link: "", + ) +} + +/// Dim span (default colors + dim modifier). +pub fn span_dim(content: String) -> Span { + Span( + content: content, + fg: style.Default, + bg: style.Default, + modifier: style.dim(), + link: "", + ) +} + +/// Underline span (default colors + underline modifier). +pub fn span_underline(content: String) -> Span { + Span( + content: content, + fg: style.Default, + bg: style.Default, + modifier: style.underline(), + link: "", + ) +} + +/// Total cell width of a line (sum of span widths). +pub fn line_width(l: Line) -> Int { + list.fold(l.spans, 0, fn(acc, sp) { acc + span_width(sp) }) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render a line into the buffer at `pos`, clipped to `max_width` cells. +/// Each span is drawn with its own fg/bg/modifier. Spans beyond max_width +/// are silently dropped; a span that straddles the boundary is truncated. +/// The line's `alignment` field shifts the start position within the available width. +pub fn render_line( + buf: buffer.Buffer, + pos: geometry.Position, + l: Line, + max_width: Int, +) -> buffer.Buffer { + case max_width <= 0 { + True -> buf + False -> { + let content_width = line_width(l) + let offset = case l.alignment { + text.Left -> 0 + text.Right -> int.max(0, max_width - content_width) + text.Center -> int.max(0, { max_width - content_width } / 2) + } + let start_x = pos.x + offset + render_spans(buf, pos, l.spans, start_x, pos.x + max_width) + } + } +} + +fn render_spans( + buf: buffer.Buffer, + pos: geometry.Position, + spans: List(Span), + x: Int, + x_end: Int, +) -> buffer.Buffer { + case spans { + [] -> buf + [sp, ..rest] -> { + case x >= x_end { + True -> buf + False -> { + let avail = x_end - x + let content = text.truncate(sp.content, avail, "") + let w = text.cell_width(content) + let buf2 = + buffer.set_string_linked( + buf, + geometry.Position(x: x, y: pos.y), + content, + sp.fg, + sp.bg, + sp.modifier, + sp.link, + ) + render_spans(buf2, pos, rest, x + w, x_end) + } + } + } + } +} diff --git a/src/etui/style.gleam b/src/etui/style.gleam new file mode 100644 index 0000000..8f7770f --- /dev/null +++ b/src/etui/style.gleam @@ -0,0 +1,342 @@ +/// Terminal style: colors and text modifiers. +/// Supports 16-color (ANSI), 256-color, and RGB (true color). +/// Modifier is a bitfield: modifiers can be freely combined via `add`/`remove`. +import gleam/int +import gleam/string + +/// Terminal color. `Default` defers to the terminal theme. +/// `Indexed(n)` covers the whole 256-color space: 0 to 15 are the themeable +/// ANSI colors, 16 to 255 the extended palette (the 6x6x6 cube and the +/// grayscale ramp). +/// `Rgb(r, g, b)` uses 24-bit true color and needs a truecolor terminal. +pub type Color { + Default + Indexed(Int) + Rgb(Int, Int, Int) +} + +/// Opaque bitfield for text modifiers. Use constants + `add`/`remove`/`has`. +pub opaque type Modifier { + Modifier(bits: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Modifier constants (bit values) + +/// No modifiers active. +pub fn none() -> Modifier { + Modifier(0) +} + +/// Bold / increased intensity. +pub fn bold() -> Modifier { + Modifier(1) +} + +/// Dim / decreased intensity. +pub fn dim() -> Modifier { + Modifier(2) +} + +/// Italic text. +pub fn italic() -> Modifier { + Modifier(4) +} + +/// Underline. +pub fn underline() -> Modifier { + Modifier(8) +} + +/// Blinking text (terminal support varies). +pub fn blink() -> Modifier { + Modifier(16) +} + +/// Swap foreground and background colors. +pub fn reverse() -> Modifier { + Modifier(32) +} + +/// Strikethrough. +pub fn strikethrough() -> Modifier { + Modifier(64) +} + +// ───────────────────────────────────────────────────────────────── +// Modifier operations + +/// Combine two modifiers (bitwise OR). +pub fn add(a: Modifier, b: Modifier) -> Modifier { + Modifier(int.bitwise_or(a.bits, b.bits)) +} + +/// Remove modifier bits from `a` that are set in `b`. +pub fn remove(a: Modifier, b: Modifier) -> Modifier { + Modifier(int.bitwise_and(a.bits, int.bitwise_not(b.bits))) +} + +/// Check if `flag` bits are set in `m`. +pub fn has(m: Modifier, flag: Modifier) -> Bool { + int.bitwise_and(m.bits, flag.bits) != 0 +} + +/// True when no modifier bits are set. +pub fn is_none(m: Modifier) -> Bool { + m.bits == 0 +} + +/// Structural equality for modifiers. +pub fn modifier_equal(a: Modifier, b: Modifier) -> Bool { + a.bits == b.bits +} + +// ───────────────────────────────────────────────────────────────── +// Composite style + +/// Combined foreground color, background color, and text modifiers. +pub type Style { + Style(fg: Color, bg: Color, modifier: Modifier) +} + +/// Default style: terminal colors, no modifiers. +pub fn default_style() -> Style { + Style(fg: Default, bg: Default, modifier: none()) +} + +/// Set foreground color on a style. +pub fn with_fg(s: Style, fg: Color) -> Style { + Style(..s, fg: fg) +} + +/// Set background color on a style. +pub fn with_bg(s: Style, bg: Color) -> Style { + Style(..s, bg: bg) +} + +/// Set modifier on a style. +pub fn with_modifier(s: Style, m: Modifier) -> Style { + Style(..s, modifier: m) +} + +/// Default colors with bold modifier. +pub fn bold_style() -> Style { + Style(fg: Default, bg: Default, modifier: bold()) +} + +/// Default colors with reverse modifier (swap fg/bg). +pub fn reversed() -> Style { + Style(fg: Default, bg: Default, modifier: reverse()) +} + +/// Default colors with italic modifier. +pub fn italic_style() -> Style { + Style(fg: Default, bg: Default, modifier: italic()) +} + +/// Default colors with dim modifier. +pub fn dim_style() -> Style { + Style(fg: Default, bg: Default, modifier: dim()) +} + +/// Default colors with underline modifier. +pub fn underline_style() -> Style { + Style(fg: Default, bg: Default, modifier: underline()) +} + +/// Add a modifier to a `Style` (bitwise OR). +pub fn add_modifier(s: Style, m: Modifier) -> Style { + Style(..s, modifier: add(s.modifier, m)) +} + +/// Remove modifier bits from a `Style`. +pub fn remove_modifier(s: Style, m: Modifier) -> Style { + Style(..s, modifier: remove(s.modifier, m)) +} + +/// Parse an RGB color from a hex string (`"#RRGGBB"` or `"RRGGBB"`). +/// Returns `Error(Nil)` for malformed input. +/// +/// ```gleam +/// style.color_from_hex("#1e1e2e") // Ok(Rgb(30, 30, 46)) +/// style.color_from_hex("ff5555") // Ok(Rgb(255, 85, 85)) +/// ``` +pub fn color_from_hex(hex: String) -> Result(Color, Nil) { + let s = case string.starts_with(hex, "#") { + True -> string.drop_start(hex, 1) + False -> hex + } + case string.length(s) == 6 { + False -> Error(Nil) + True -> { + let chars = string.to_graphemes(s) + case chars { + [r1, r2, g1, g2, b1, b2] -> + case hex_pair(r1, r2), hex_pair(g1, g2), hex_pair(b1, b2) { + Ok(r), Ok(g), Ok(b) -> Ok(Rgb(r, g, b)) + _, _, _ -> Error(Nil) + } + _ -> Error(Nil) + } + } + } +} + +fn hex_pair(hi: String, lo: String) -> Result(Int, Nil) { + case hex_digit(hi), hex_digit(lo) { + Ok(h), Ok(l) -> Ok(h * 16 + l) + _, _ -> Error(Nil) + } +} + +fn hex_digit(c: String) -> Result(Int, Nil) { + case c { + "0" -> Ok(0) + "1" -> Ok(1) + "2" -> Ok(2) + "3" -> Ok(3) + "4" -> Ok(4) + "5" -> Ok(5) + "6" -> Ok(6) + "7" -> Ok(7) + "8" -> Ok(8) + "9" -> Ok(9) + "a" | "A" -> Ok(10) + "b" | "B" -> Ok(11) + "c" | "C" -> Ok(12) + "d" | "D" -> Ok(13) + "e" | "E" -> Ok(14) + "f" | "F" -> Ok(15) + _ -> Error(Nil) + } +} + +/// Apply `over` on top of `base`. Default fg/bg fall back to `base`. +/// Modifier: if `over` has any bits set, they are OR'd into `base`; +/// `none()` in `over` means "no modifier override" (keep base). +pub fn patch(base: Style, over: Style) -> Style { + let fg = case over.fg { + Default -> base.fg + c -> c + } + let bg = case over.bg { + Default -> base.bg + c -> c + } + let modifier = case is_none(over.modifier) { + True -> base.modifier + False -> add(base.modifier, over.modifier) + } + Style(fg: fg, bg: bg, modifier: modifier) +} + +// ───────────────────────────────────────────────────────────────── +// ANSI sequence generation + +/// Foreground color escape sequence. +pub fn ansi_fg(color: Color) -> String { + case color { + Default -> "" + Indexed(0) -> "\u{001B}[30m" + Indexed(1) -> "\u{001B}[31m" + Indexed(2) -> "\u{001B}[32m" + Indexed(3) -> "\u{001B}[33m" + Indexed(4) -> "\u{001B}[34m" + Indexed(5) -> "\u{001B}[35m" + Indexed(6) -> "\u{001B}[36m" + Indexed(7) -> "\u{001B}[37m" + Indexed(8) -> "\u{001B}[90m" + Indexed(9) -> "\u{001B}[91m" + Indexed(10) -> "\u{001B}[92m" + Indexed(11) -> "\u{001B}[93m" + Indexed(12) -> "\u{001B}[94m" + Indexed(13) -> "\u{001B}[95m" + Indexed(14) -> "\u{001B}[96m" + Indexed(15) -> "\u{001B}[97m" + Indexed(n) -> "\u{001B}[38;5;" <> int.to_string(n) <> "m" + Rgb(r, g, b) -> + "\u{001B}[38;2;" + <> int.to_string(r) + <> ";" + <> int.to_string(g) + <> ";" + <> int.to_string(b) + <> "m" + } +} + +/// Background color escape sequence. +pub fn ansi_bg(color: Color) -> String { + case color { + Default -> "" + Indexed(0) -> "\u{001B}[40m" + Indexed(1) -> "\u{001B}[41m" + Indexed(2) -> "\u{001B}[42m" + Indexed(3) -> "\u{001B}[43m" + Indexed(4) -> "\u{001B}[44m" + Indexed(5) -> "\u{001B}[45m" + Indexed(6) -> "\u{001B}[46m" + Indexed(7) -> "\u{001B}[47m" + Indexed(8) -> "\u{001B}[100m" + Indexed(9) -> "\u{001B}[101m" + Indexed(10) -> "\u{001B}[102m" + Indexed(11) -> "\u{001B}[103m" + Indexed(12) -> "\u{001B}[104m" + Indexed(13) -> "\u{001B}[105m" + Indexed(14) -> "\u{001B}[106m" + Indexed(15) -> "\u{001B}[107m" + Indexed(n) -> "\u{001B}[48;5;" <> int.to_string(n) <> "m" + Rgb(r, g, b) -> + "\u{001B}[48;2;" + <> int.to_string(r) + <> ";" + <> int.to_string(g) + <> ";" + <> int.to_string(b) + <> "m" + } +} + +/// Text modifier escape sequence. Emits all active modifier bits. +pub fn ansi_modifier(m: Modifier) -> String { + case is_none(m) { + True -> "" + False -> { + let parts = [] + let parts = case has(m, bold()) { + True -> ["1", ..parts] + False -> parts + } + let parts = case has(m, dim()) { + True -> ["2", ..parts] + False -> parts + } + let parts = case has(m, italic()) { + True -> ["3", ..parts] + False -> parts + } + let parts = case has(m, underline()) { + True -> ["4", ..parts] + False -> parts + } + let parts = case has(m, blink()) { + True -> ["5", ..parts] + False -> parts + } + let parts = case has(m, reverse()) { + True -> ["7", ..parts] + False -> parts + } + let parts = case has(m, strikethrough()) { + True -> ["9", ..parts] + False -> parts + } + "\u{001B}[" <> string.join(parts, ";") <> "m" + } + } +} + +/// Reset all styles. +pub fn ansi_reset() -> String { + "\u{001B}[0m" +} diff --git a/src/etui/text.gleam b/src/etui/text.gleam new file mode 100644 index 0000000..1aae813 --- /dev/null +++ b/src/etui/text.gleam @@ -0,0 +1,367 @@ +/// TUI-aware text handling. Cell-width semantics for terminals. +/// +/// All `width` values are in **terminal cells**, not graphemes or codepoints: +/// - ASCII printable: 1 cell +/// - CJK / Hangul / Hiragana / Katakana / Fullwidth: 2 cells +/// - Emoji (including ZWJ sequences): 2 cells (first codepoint rule) +/// - Combining marks, ZWJ, variation selectors, zero-width formatters: 0 cells +/// - Ambiguous characters (Misc Symbols U+2600–26FF, Dingbats U+2700–27BF): +/// treated as 1 cell, monospace terminals render them narrow. +/// +/// Grapheme segmentation delegates to Erlang's native Unicode (UAX #29). +/// This correctly clusters ZWJ sequences, flag pairs, and combining marks. +/// +/// **Limitation:** emoji whose width depends on terminal/font (e.g. keycap +/// sequences, skin-tone modifiers) are approximated as 2 cells. Behaviour +/// may differ on terminals that render them as narrow. +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Cell width + +/// Split a string into grapheme clusters (UAX #29, via Erlang Unicode). +/// +/// Each element is one user-perceived character: a base letter, a ZWJ +/// sequence, a flag pair, an emoji with modifiers, etc. +/// +/// ```gleam +/// graphemes("café") // ["c", "a", "f", "é"] +/// graphemes("👨‍👩‍👧‍👦") // ["👨‍👩‍👧‍👦"], one cluster +/// ``` +pub fn graphemes(s: String) -> List(String) { + string.to_graphemes(s) +} + +/// Cell width of a string (sum of grapheme widths). +pub fn cell_width(s: String) -> Int { + s + |> string.to_graphemes + |> list.fold(0, fn(acc, g) { acc + grapheme_cell_width(g) }) +} + +/// Cell width of a single grapheme cluster. +/// Uses the first codepoint's East Asian Width / emoji classification. +/// Subsequent codepoints in a grapheme (combining, ZWJ, variation selectors) +/// contribute 0, so the first determines the visible cell count. +pub fn grapheme_cell_width(g: String) -> Int { + case string.to_utf_codepoints(g) { + [] -> 0 + [cp, ..] -> codepoint_cell_width(string.utf_codepoint_to_int(cp)) + } +} + +/// Cell width of a single Unicode codepoint. +/// Returns 0 for control / combining / zero-width, 2 for wide (CJK / emoji / +/// fullwidth), 1 otherwise. +pub fn codepoint_cell_width(cp: Int) -> Int { + case cp { + // C0 controls + DEL + n if n < 0x20 -> 0 + 0x7F -> 0 + // Combining diacritical marks + n if n >= 0x0300 && n <= 0x036F -> 0 + // Hangul Jamo medial vowels + final consonants (combining) + n if n >= 0x1160 && n <= 0x11FF -> 0 + // Variation selectors + n if n >= 0xFE00 && n <= 0xFE0F -> 0 + n if n >= 0xE0100 && n <= 0xE01EF -> 0 + // Zero-width formatters: ZWSP, ZWNJ, ZWJ, BOM + 0x200B | 0x200C | 0x200D | 0xFEFF -> 0 + + // ── Wide (East Asian Wide / Fullwidth) ───────────────────────── + // Hangul Jamo initial consonants + n if n >= 0x1100 && n <= 0x115F -> 2 + // CJK Radicals + Symbols + Punctuation + n if n >= 0x2E80 && n <= 0x303E -> 2 + // Hiragana, Katakana, Bopomofo, Hangul Compat, CJK Strokes, etc. + n if n >= 0x3041 && n <= 0x33FF -> 2 + // CJK Extension A + n if n >= 0x3400 && n <= 0x4DBF -> 2 + // CJK Unified Ideographs + n if n >= 0x4E00 && n <= 0x9FFF -> 2 + // Yi Syllables/Radicals + n if n >= 0xA000 && n <= 0xA4CF -> 2 + // Hangul Syllables + n if n >= 0xAC00 && n <= 0xD7A3 -> 2 + // CJK Compatibility Ideographs + n if n >= 0xF900 && n <= 0xFAFF -> 2 + // CJK Compatibility Forms + n if n >= 0xFE30 && n <= 0xFE4F -> 2 + // Fullwidth Forms (NOT halfwidth section 0xFF61–0xFFDC which is width 1) + n if n >= 0xFF00 && n <= 0xFF60 -> 2 + // Fullwidth Signs + n if n >= 0xFFE0 && n <= 0xFFE6 -> 2 + + // ── Emoji ────────────────────────────────────────────────────── + // Note: Misc Symbols (0x2600..0x26FF) and Dingbats (0x2700..0x27BF) are + // NOT included here. Most chars in those ranges (✦ ★ ◆ ☆ etc.) are + // rendered as 1 cell in monospace terminals (Ambiguous/Neutral per + // Unicode East Asian Width). Treating them as 2 cells caused buffer + // positions to drift past the actual cursor. + // Regional Indicator Symbols (flags pair into 2 cells) + n if n >= 0x1F1E6 && n <= 0x1F1FF -> 2 + // Misc Symbols and Pictographs + n if n >= 0x1F300 && n <= 0x1F5FF -> 2 + // Emoticons + n if n >= 0x1F600 && n <= 0x1F64F -> 2 + // Transport and Map Symbols + n if n >= 0x1F680 && n <= 0x1F6FF -> 2 + // Alchemical Symbols + n if n >= 0x1F700 && n <= 0x1F77F -> 2 + // Geometric Shapes Extended + n if n >= 0x1F780 && n <= 0x1F7FF -> 2 + // Supplemental Arrows-C + n if n >= 0x1F800 && n <= 0x1F8FF -> 2 + // Supplemental Symbols and Pictographs + n if n >= 0x1F900 && n <= 0x1F9FF -> 2 + // Symbols and Pictographs Extended-A + n if n >= 0x1FA00 && n <= 0x1FAFF -> 2 + + // CJK Extensions B, C, D, E, F, G + n if n >= 0x20000 && n <= 0x2FFFD -> 2 + n if n >= 0x30000 && n <= 0x3FFFD -> 2 + + _ -> 1 + } +} + +// ───────────────────────────────────────────────────────────────── +// Text operations (cell-aware) + +pub type Alignment { + Left + Center + Right +} + +/// Truncate to max_width cells. Appends ellipsis only if truncation occurs. +/// The ellipsis itself counts toward the budget. +pub fn truncate(s: String, max_width: Int, ellipsis: String) -> String { + case max_width { + w if w <= 0 -> "" + _ -> { + let s_width = cell_width(s) + case s_width <= max_width { + True -> s + False -> { + let ellipsis_width = cell_width(ellipsis) + let available = int.max(0, max_width - ellipsis_width) + let gs = string.to_graphemes(s) + take_prefix(gs, available, 0, "") <> ellipsis + } + } + } + } +} + +fn take_prefix( + graphemes: List(String), + available: Int, + width: Int, + acc: String, +) -> String { + case graphemes { + [] -> acc + [g, ..rest] -> { + let g_width = grapheme_cell_width(g) + case width + g_width <= available { + True -> take_prefix(rest, available, width + g_width, acc <> g) + False -> acc + } + } + } +} + +/// Word-wrap to max_width cells. Handles explicit `\n` newlines. +/// Returns list of lines, each padded to max_width cells. +pub fn wrap(s: String, max_width: Int) -> List(String) { + case max_width { + w if w <= 0 -> [] + _ -> + string.split(s, "\n") + |> list.flat_map(fn(para) { wrap_para(para, max_width) }) + } +} + +fn wrap_para(s: String, max_width: Int) -> List(String) { + // An empty paragraph produces one blank line (not zero lines). + case s { + "" -> [""] + _ -> wrap_para_words(s, max_width) + } +} + +fn wrap_para_words(s: String, max_width: Int) -> List(String) { + let words = string.split(s, " ") + // rev_lines holds finished lines newest-first, reversed once at the end, so + // the fold only ever prepends. This keeps wrapping O(n), not O(n^2). + let #(rev_lines, curr) = + list.fold(words, #([], ""), fn(acc, word) { + let #(lines_acc, curr_line) = acc + let w_width = cell_width(word) + let curr_width = cell_width(curr_line) + let space_w = case curr_line { + "" -> 0 + _ -> 1 + } + case curr_width + space_w + w_width <= max_width { + True -> { + let new_line = case curr_line { + "" -> word + _ -> curr_line <> " " <> word + } + #(lines_acc, new_line) + } + False -> { + let lines2 = case curr_line { + "" -> lines_acc + _ -> [curr_line, ..lines_acc] + } + case w_width <= max_width { + True -> #(lines2, word) + False -> { + // Word wider than max_width: hard-break into chunks. The last + // chunk becomes the new current line, the rest are finished. + let chunks = hard_break_word(word, max_width) + case list.reverse(chunks) { + [] -> #(lines2, "") + [last, ..rest_rev] -> #(list.append(rest_rev, lines2), last) + } + } + } + } + } + }) + let all_rev = case curr { + "" -> rev_lines + _ -> [curr, ..rev_lines] + } + list.reverse(all_rev) +} + +// Split a single token into chunks of at most max_width cells. +// Always produces at least one chunk even if a single grapheme is wider than max_width. +fn hard_break_word(s: String, max_width: Int) -> List(String) { + hard_break_acc(string.to_graphemes(s), max_width, 0, "", []) +} + +fn hard_break_acc( + gs: List(String), + max_width: Int, + curr_w: Int, + curr: String, + acc: List(String), +) -> List(String) { + case gs { + [] -> + case curr { + "" -> acc + _ -> list.append(acc, [curr]) + } + [g, ..rest] -> { + let gw = grapheme_cell_width(g) + // Flush when adding g would exceed max_width (but always accept the first grapheme). + case curr_w > 0 && curr_w + gw > max_width { + True -> hard_break_acc(rest, max_width, gw, g, list.append(acc, [curr])) + False -> hard_break_acc(rest, max_width, curr_w + gw, curr <> g, acc) + } + } + } +} + +/// Pad right with spaces to reach `width` cells. Cell-aware. +pub fn pad_right(s: String, width: Int) -> String { + let cw = cell_width(s) + case cw >= width { + True -> s + False -> s <> string.repeat(" ", width - cw) + } +} + +/// Pad left with spaces to reach `width` cells. Cell-aware. +pub fn pad_left(s: String, width: Int) -> String { + let cw = cell_width(s) + case cw >= width { + True -> s + False -> string.repeat(" ", width - cw) <> s + } +} + +/// Align left/center/right within `width` cells. Cell-aware. +pub fn align(s: String, width: Int, alignment: Alignment) -> String { + case alignment { + Left -> pad_right(s, width) + Right -> pad_left(s, width) + Center -> { + let cw = cell_width(s) + case cw >= width { + True -> s + False -> { + let total = width - cw + let left = total / 2 + let right = total - left + string.repeat(" ", left) <> s <> string.repeat(" ", right) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// ANSI stripping + +/// Strip ANSI escape sequences. Handles CSI (`\e[…`) and OSC +/// (`\e]…ST`/`\e]…BEL`) sequences. +pub fn strip_ansi(s: String) -> String { + strip_loop(string.to_graphemes(s), "", Plain) +} + +type StripState { + Plain + EscSeen + CsiBody + OscBody + OscEscSeen +} + +fn strip_loop(gs: List(String), acc: String, state: StripState) -> String { + case gs { + [] -> acc + [g, ..rest] -> { + let #(new_acc, new_state) = case state, g { + Plain, "\u{001B}" -> #(acc, EscSeen) + Plain, _ -> #(acc <> g, Plain) + EscSeen, "[" -> #(acc, CsiBody) + EscSeen, "]" -> #(acc, OscBody) + // Other ESC two-byte sequences (charset switch, etc.), drop both + EscSeen, _ -> #(acc, Plain) + // CSI body terminates on any byte in 0x40..0x7E (final byte) + CsiBody, c -> { + case is_csi_final(c) { + True -> #(acc, Plain) + False -> #(acc, CsiBody) + } + } + // OSC body terminates on BEL (0x07) or ST (ESC \\) + OscBody, "\u{0007}" -> #(acc, Plain) + OscBody, "\u{001B}" -> #(acc, OscEscSeen) + OscBody, _ -> #(acc, OscBody) + OscEscSeen, "\\" -> #(acc, Plain) + OscEscSeen, _ -> #(acc, OscBody) + } + strip_loop(rest, new_acc, new_state) + } + } +} + +fn is_csi_final(g: String) -> Bool { + case string.to_utf_codepoints(g) { + [cp, ..] -> { + let n = string.utf_codepoint_to_int(cp) + n >= 0x40 && n <= 0x7E + } + [] -> False + } +} diff --git a/src/etui/theme.gleam b/src/etui/theme.gleam new file mode 100644 index 0000000..999cda8 --- /dev/null +++ b/src/etui/theme.gleam @@ -0,0 +1,395 @@ +/// Theming system for etui. +/// +/// A `Theme` holds semantic color slots (not raw widget colors). Widgets +/// receive a `Theme` and pull the colors they need by name, so switching +/// themes is a one-line change. +/// +/// ## Built-in themes +/// +/// ```gleam +/// import etui/theme +/// +/// let t = theme.dracula() // dark purple palette, RGB +/// let t = theme.nord() // arctic dark palette, RGB +/// let t = theme.catppuccin_mocha() // pastel dark palette, RGB +/// let t = theme.dark() // generic dark (Indexed, max compatibility) +/// let t = theme.light() // generic light (Indexed) +/// ``` +/// +/// ## Using a theme +/// +/// ```gleam +/// import etui/theme +/// +/// let t = theme.nord() +/// +/// // Get a pre-built Style from the theme +/// let sel_style = theme.selection(t) +/// let err_style = theme.error_style(t) +/// +/// // Apply to widgets +/// block.block_new() +/// |> block.with_style(t.border, t.bg) +/// |> block.with_title("Panel", block.Top) +/// +/// list.list_new(items) +/// |> list.with_highlight_style(theme.selection(t)) +/// ``` +/// +/// ## Custom themes +/// +/// ```gleam +/// let my_theme = theme.Theme( +/// bg: style.Rgb(30, 30, 46), +/// fg: style.Rgb(205, 214, 244), +/// border: style.Rgb(137, 180, 250), +/// title: style.Rgb(166, 227, 161), +/// selection_bg: style.Rgb(69, 71, 90), +/// selection_fg: style.Rgb(205, 214, 244), +/// accent: style.Rgb(137, 180, 250), +/// muted: style.Rgb(108, 112, 134), +/// error: style.Rgb(243, 139, 168), +/// warning: style.Rgb(249, 226, 175), +/// success: style.Rgb(166, 227, 161), +/// info: style.Rgb(137, 220, 235), +/// statusbar_bg: style.Rgb(24, 24, 37), +/// statusbar_fg: style.Rgb(205, 214, 244), +/// ) +/// ``` +import etui/style + +// ───────────────────────────────────────────────────────────────── +// Theme type + +/// Named color slots for a complete UI palette. +pub type Theme { + Theme( + /// Main background. + bg: style.Color, + /// Main foreground. + fg: style.Color, + /// Border lines. + border: style.Color, + /// Border titles. + title: style.Color, + /// Selected item background. + selection_bg: style.Color, + /// Selected item foreground. + selection_fg: style.Color, + /// Primary accent (links, highlights, active elements). + accent: style.Color, + /// Subdued/secondary text. + muted: style.Color, + /// Error messages. + error: style.Color, + /// Warnings. + warning: style.Color, + /// Success messages. + success: style.Color, + /// Informational messages. + info: style.Color, + /// Status bar background. + statusbar_bg: style.Color, + /// Status bar foreground. + statusbar_fg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Style helpers + +/// Normal text: fg on bg. +pub fn normal(t: Theme) -> style.Style { + style.Style(fg: t.fg, bg: t.bg, modifier: style.none()) +} + +/// Selected item: selection_fg on selection_bg. +pub fn selection(t: Theme) -> style.Style { + style.Style(fg: t.selection_fg, bg: t.selection_bg, modifier: style.none()) +} + +/// Accent text: accent on bg. +pub fn accent_style(t: Theme) -> style.Style { + style.Style(fg: t.accent, bg: t.bg, modifier: style.none()) +} + +/// Border color: border on bg. +pub fn border_style(t: Theme) -> style.Style { + style.Style(fg: t.border, bg: t.bg, modifier: style.none()) +} + +/// Title color: title on bg. +pub fn title_style(t: Theme) -> style.Style { + style.Style(fg: t.title, bg: t.bg, modifier: style.none()) +} + +/// Muted/secondary text: muted on bg. +pub fn muted_style(t: Theme) -> style.Style { + style.Style(fg: t.muted, bg: t.bg, modifier: style.none()) +} + +/// Error text: error color on bg, bold. +pub fn error_style(t: Theme) -> style.Style { + style.Style(fg: t.error, bg: t.bg, modifier: style.bold()) +} + +/// Warning text: warning color on bg. +pub fn warning_style(t: Theme) -> style.Style { + style.Style(fg: t.warning, bg: t.bg, modifier: style.none()) +} + +/// Success text: success color on bg. +pub fn success_style(t: Theme) -> style.Style { + style.Style(fg: t.success, bg: t.bg, modifier: style.none()) +} + +/// Info text: info color on bg. +pub fn info_style(t: Theme) -> style.Style { + style.Style(fg: t.info, bg: t.bg, modifier: style.none()) +} + +/// Status bar: statusbar_fg on statusbar_bg. +pub fn statusbar_style(t: Theme) -> style.Style { + style.Style(fg: t.statusbar_fg, bg: t.statusbar_bg, modifier: style.none()) +} + +// ───────────────────────────────────────────────────────────────── +// Built-in themes + +/// Generic dark theme using ANSI 16-color palette. +/// Works on every terminal, even without true-color support. +pub fn dark() -> Theme { + Theme( + bg: style.Default, + fg: style.Default, + border: style.Indexed(8), + title: style.Indexed(15), + selection_bg: style.Indexed(4), + selection_fg: style.Indexed(15), + accent: style.Indexed(12), + muted: style.Indexed(8), + error: style.Indexed(9), + warning: style.Indexed(11), + success: style.Indexed(10), + info: style.Indexed(14), + statusbar_bg: style.Indexed(0), + statusbar_fg: style.Indexed(15), + ) +} + +/// Generic light theme using ANSI 16-color palette. +pub fn light() -> Theme { + Theme( + bg: style.Default, + fg: style.Default, + border: style.Indexed(7), + title: style.Indexed(0), + selection_bg: style.Indexed(12), + selection_fg: style.Indexed(15), + accent: style.Indexed(4), + muted: style.Indexed(7), + error: style.Indexed(1), + warning: style.Indexed(3), + success: style.Indexed(2), + info: style.Indexed(6), + statusbar_bg: style.Indexed(7), + statusbar_fg: style.Indexed(0), + ) +} + +/// Dracula, dark purple palette. +/// Original palette: https://draculatheme.com +pub fn dracula() -> Theme { + Theme( + bg: style.Rgb(40, 42, 54), + fg: style.Rgb(248, 248, 242), + border: style.Rgb(98, 114, 164), + title: style.Rgb(139, 233, 253), + selection_bg: style.Rgb(68, 71, 90), + selection_fg: style.Rgb(248, 248, 242), + accent: style.Rgb(189, 147, 249), + muted: style.Rgb(98, 114, 164), + error: style.Rgb(255, 85, 85), + warning: style.Rgb(255, 184, 108), + success: style.Rgb(80, 250, 123), + info: style.Rgb(139, 233, 253), + statusbar_bg: style.Rgb(33, 34, 44), + statusbar_fg: style.Rgb(248, 248, 242), + ) +} + +/// Nord, arctic, north-bluish dark palette. +/// Original palette: https://www.nordtheme.com +pub fn nord() -> Theme { + Theme( + bg: style.Rgb(46, 52, 64), + fg: style.Rgb(216, 222, 233), + border: style.Rgb(76, 86, 106), + title: style.Rgb(136, 192, 208), + selection_bg: style.Rgb(67, 76, 94), + selection_fg: style.Rgb(236, 239, 244), + accent: style.Rgb(129, 161, 193), + muted: style.Rgb(76, 86, 106), + error: style.Rgb(191, 97, 106), + warning: style.Rgb(235, 203, 139), + success: style.Rgb(163, 190, 140), + info: style.Rgb(143, 188, 187), + statusbar_bg: style.Rgb(36, 41, 51), + statusbar_fg: style.Rgb(216, 222, 233), + ) +} + +/// Catppuccin Mocha, warm pastel dark palette. +/// Original palette: https://catppuccin.com +pub fn catppuccin_mocha() -> Theme { + Theme( + bg: style.Rgb(30, 30, 46), + fg: style.Rgb(205, 214, 244), + border: style.Rgb(88, 91, 112), + title: style.Rgb(166, 227, 161), + selection_bg: style.Rgb(69, 71, 90), + selection_fg: style.Rgb(205, 214, 244), + accent: style.Rgb(137, 180, 250), + muted: style.Rgb(108, 112, 134), + error: style.Rgb(243, 139, 168), + warning: style.Rgb(249, 226, 175), + success: style.Rgb(166, 227, 161), + info: style.Rgb(137, 220, 235), + statusbar_bg: style.Rgb(24, 24, 37), + statusbar_fg: style.Rgb(205, 214, 244), + ) +} + +/// Catppuccin Latte, warm pastel light palette. +/// Original palette: https://catppuccin.com +pub fn catppuccin_latte() -> Theme { + Theme( + bg: style.Rgb(239, 241, 245), + fg: style.Rgb(76, 79, 105), + border: style.Rgb(172, 176, 190), + title: style.Rgb(64, 160, 43), + selection_bg: style.Rgb(188, 192, 204), + selection_fg: style.Rgb(76, 79, 105), + accent: style.Rgb(30, 102, 245), + muted: style.Rgb(172, 176, 190), + error: style.Rgb(210, 15, 57), + warning: style.Rgb(223, 142, 29), + success: style.Rgb(64, 160, 43), + info: style.Rgb(4, 165, 229), + statusbar_bg: style.Rgb(204, 208, 218), + statusbar_fg: style.Rgb(76, 79, 105), + ) +} + +/// Monokai, vibrant dark palette. +/// Inspired by the Monokai color scheme. +pub fn monokai() -> Theme { + Theme( + bg: style.Rgb(39, 40, 34), + fg: style.Rgb(248, 248, 242), + border: style.Rgb(117, 113, 94), + title: style.Rgb(166, 226, 46), + selection_bg: style.Rgb(73, 72, 62), + selection_fg: style.Rgb(248, 248, 242), + accent: style.Rgb(102, 217, 239), + muted: style.Rgb(117, 113, 94), + error: style.Rgb(249, 38, 114), + warning: style.Rgb(253, 151, 31), + success: style.Rgb(166, 226, 46), + info: style.Rgb(102, 217, 239), + statusbar_bg: style.Rgb(30, 30, 27), + statusbar_fg: style.Rgb(248, 248, 242), + ) +} + +/// Solarized Dark, precision dark palette. +/// Original palette by Ethan Schoonover. +pub fn solarized_dark() -> Theme { + Theme( + bg: style.Rgb(0, 43, 54), + fg: style.Rgb(131, 148, 150), + border: style.Rgb(88, 110, 117), + title: style.Rgb(38, 139, 210), + selection_bg: style.Rgb(7, 54, 66), + selection_fg: style.Rgb(147, 161, 161), + accent: style.Rgb(38, 139, 210), + muted: style.Rgb(88, 110, 117), + error: style.Rgb(220, 50, 47), + warning: style.Rgb(181, 137, 0), + success: style.Rgb(133, 153, 0), + info: style.Rgb(42, 161, 152), + statusbar_bg: style.Rgb(0, 26, 33), + statusbar_fg: style.Rgb(131, 148, 150), + ) +} + +/// Gruvbox Dark, retro groove dark palette. +/// Inspired by the Gruvbox color scheme. +pub fn gruvbox_dark() -> Theme { + Theme( + bg: style.Rgb(29, 32, 33), + fg: style.Rgb(235, 219, 178), + border: style.Rgb(80, 73, 69), + title: style.Rgb(184, 187, 38), + selection_bg: style.Rgb(60, 56, 54), + selection_fg: style.Rgb(235, 219, 178), + accent: style.Rgb(215, 153, 33), + muted: style.Rgb(102, 92, 84), + error: style.Rgb(251, 73, 52), + warning: style.Rgb(250, 189, 47), + success: style.Rgb(184, 187, 38), + info: style.Rgb(131, 165, 152), + statusbar_bg: style.Rgb(20, 22, 23), + statusbar_fg: style.Rgb(235, 219, 178), + ) +} + +/// Tokyo Night, dark cool-blue palette. +/// Inspired by the Tokyo Night color scheme. +pub fn tokyo_night() -> Theme { + Theme( + bg: style.Rgb(26, 27, 38), + fg: style.Rgb(169, 177, 214), + border: style.Rgb(65, 72, 104), + title: style.Rgb(122, 162, 247), + selection_bg: style.Rgb(41, 46, 66), + selection_fg: style.Rgb(192, 202, 245), + accent: style.Rgb(187, 154, 247), + muted: style.Rgb(86, 95, 137), + error: style.Rgb(247, 118, 142), + warning: style.Rgb(224, 175, 104), + success: style.Rgb(158, 206, 106), + info: style.Rgb(125, 207, 255), + statusbar_bg: style.Rgb(22, 22, 30), + statusbar_fg: style.Rgb(169, 177, 214), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Customisation helpers + +/// Override individual fields on an existing theme. +/// Use Gleam's record update syntax directly: +/// ```gleam +/// let my = Theme(..theme.nord(), accent: style.Rgb(255, 165, 0)) +/// ``` +/// These helpers cover common single-field tweaks. +/// Replace the accent color. +pub fn with_accent(t: Theme, color: style.Color) -> Theme { + Theme(..t, accent: color) +} + +/// Replace the selection colors. +pub fn with_selection(t: Theme, bg: style.Color, fg: style.Color) -> Theme { + Theme(..t, selection_bg: bg, selection_fg: fg) +} + +/// Replace the status bar colors. +pub fn with_statusbar(t: Theme, bg: style.Color, fg: style.Color) -> Theme { + Theme(..t, statusbar_bg: bg, statusbar_fg: fg) +} + +/// Replace main bg/fg. +pub fn with_base(t: Theme, bg: style.Color, fg: style.Color) -> Theme { + Theme(..t, bg: bg, fg: fg) +} diff --git a/src/etui/undo.gleam b/src/etui/undo.gleam new file mode 100644 index 0000000..bc5e3d6 --- /dev/null +++ b/src/etui/undo.gleam @@ -0,0 +1,121 @@ +/// Generic undo/redo history stack. +/// +/// Keep one `UndoStack` in your app model. Call `push` on every state change +/// you want to be undoable. Call `undo`/`redo` in response to key events. +/// +/// ```gleam +/// import etui/undo +/// +/// // In your model: +/// let history = undo.undo_new("", max_size: 50) +/// +/// // On every edit: +/// let history = undo.push(history, new_text) +/// +/// // On Ctrl+Z: +/// let history = undo.undo(history) +/// let text = undo.current(history) +/// +/// // On Ctrl+Y or Ctrl+Shift+Z: +/// let history = undo.redo(history) +/// ``` +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Type + +/// Generic undo/redo history. +/// +/// - `present`, the current value. +/// - `past`, previous values, most-recent first. +/// - `future`, values undone and available to redo, most-recent first. +/// - `max_size`, maximum entries kept in `past` (0 = unlimited). +pub type UndoStack(a) { + UndoStack(past: List(a), present: a, future: List(a), max_size: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Constructor + +/// Create a new stack with an initial `present` value. +/// `max_size` limits how many past entries are retained (0 = unlimited). +pub fn undo_new(initial: a, max_size max_size: Int) -> UndoStack(a) { + UndoStack(past: [], present: initial, future: [], max_size: max_size) +} + +// ───────────────────────────────────────────────────────────────── +// Queries + +/// The current value. +pub fn current(stack: UndoStack(a)) -> a { + stack.present +} + +/// `True` if there is at least one past state to undo to. +pub fn can_undo(stack: UndoStack(a)) -> Bool { + !list.is_empty(stack.past) +} + +/// `True` if there is at least one future state to redo to. +pub fn can_redo(stack: UndoStack(a)) -> Bool { + !list.is_empty(stack.future) +} + +/// Number of past entries available to undo. +pub fn undo_depth(stack: UndoStack(a)) -> Int { + list.length(stack.past) +} + +// ───────────────────────────────────────────────────────────────── +// Operations + +/// Record `new_value` as the new present, moving the old present into past. +/// Clears the future (redo history) since the branch diverged. +pub fn push(stack: UndoStack(a), new_value: a) -> UndoStack(a) { + let past = [stack.present, ..stack.past] + let trimmed = case stack.max_size > 0 && list.length(past) > stack.max_size { + True -> list.take(past, stack.max_size) + False -> past + } + UndoStack( + past: trimmed, + present: new_value, + future: [], + max_size: stack.max_size, + ) +} + +/// Undo: move present to future, restore the most-recent past as present. +/// No-op if there is nothing to undo. +pub fn undo(stack: UndoStack(a)) -> UndoStack(a) { + case stack.past { + [] -> stack + [prev, ..rest] -> + UndoStack( + past: rest, + present: prev, + future: [stack.present, ..stack.future], + max_size: stack.max_size, + ) + } +} + +/// Redo: move present to past, restore the most-recent future as present. +/// No-op if there is nothing to redo. +pub fn redo(stack: UndoStack(a)) -> UndoStack(a) { + case stack.future { + [] -> stack + [next, ..rest] -> + UndoStack( + past: [stack.present, ..stack.past], + present: next, + future: rest, + max_size: stack.max_size, + ) + } +} + +/// Reset to initial state, clearing all history. +pub fn reset(stack: UndoStack(a), initial: a) -> UndoStack(a) { + UndoStack(past: [], present: initial, future: [], max_size: stack.max_size) +} diff --git a/src/etui/widget.gleam b/src/etui/widget.gleam new file mode 100644 index 0000000..f9efe05 --- /dev/null +++ b/src/etui/widget.gleam @@ -0,0 +1,183 @@ +/// Extensible widget system for etui. +/// +/// ## Ratatui / Cursive feature mapping +/// +/// etui covers the same surface as ratatui's Widget + StatefulWidget traits: +/// +/// | ratatui | etui | +/// |----------------------------|----------------------------------------| +/// | `Widget::render` | `Widget = fn(Buffer, Rect) -> Buffer` | +/// | `StatefulWidget::render` | `StatefulWidget(render: fn(B,R,S)->B)` | +/// | `WidgetRef::render_ref` | `Widget` (functions capture by ref) | +/// | `Frame::render_widget` | `w(buf, area)` direct call | +/// | `Frame::render_stateful_widget` | `render_stateful(buf, area, w, s)`| +/// | Layout + Constraint | `geometry.split` + `geometry.Constraint` | +/// | `Block` widget | `widgets/block.gleam` | +/// | `Paragraph` widget | `widgets/paragraph.gleam` | +/// | `List` + `ListState` | `widgets/list.gleam` | +/// | `Table` + `TableState` | `widgets/table.gleam` | +/// | `Tabs` | `widgets/tabs.gleam` | +/// | `Gauge` / `LineGauge` | `widgets/gauge.gleam` | +/// | `BarChart` | `widgets/hbar.gleam` / `widgets/chart.gleam` | +/// | `Sparkline` | `widgets/sparkline.gleam` | +/// | `Canvas` | `widgets/canvas.gleam` (braille) | +/// | `Clear` | `widgets/clear.gleam` | +/// | `Scrollbar` | `widgets/scrollbar.gleam` | +/// | `Popup` (community crate) | `widgets/popup.gleam` | +/// | `StatusLine` (custom) | `widgets/statusbar.gleam` | +/// | `Spinner` (tui-additions) | `widgets/spinner.gleam` | +/// | `Paginator` (cheese) | `widgets/paginator.gleam` | +/// | `Help` (cheese) | `widgets/help.gleam` | +/// | `Fieldset` (cheese) | `widgets/fieldset.gleam` | +/// | `MultiSelect` (cheese) | `widgets/multi_select.gleam` | +/// | `AnimationState` | `anim.gleam` (lerp, ease, keyframes) | +/// | `Color` (256 + RGB) | `style.Indexed(n)` + `style.Rgb(r,g,b)`| +/// | `Modifier` bitfield | `style.Modifier` (add/remove/has) | +/// +/// ## The Widget type +/// +/// `Widget` is a plain function alias: `fn(Buffer, Rect) -> Buffer`. +/// Any function with that signature *is* a widget, no registration needed. +/// +/// Wrapping a built-in widget: +/// ```gleam +/// let para = paragraph.paragraph_new("hello") |> paragraph.with_style(s) +/// let w: widget.Widget = fn(buf, area) { paragraph.render(buf, area, para) } +/// ``` +/// +/// ## Stateful widgets +/// +/// `StatefulWidget(state)` holds a render function that also takes a state +/// value. Use `freeze` to bake state into a stateless `Widget`, or +/// `render_stateful` to render directly. +/// +/// ## Animated widgets +/// +/// `AnimatedWidget` is like `Widget` but also receives the current frame +/// number. Use `freeze_frame` to produce a `Widget` bound to a frame. +/// +/// ## Composition +/// +/// - `layer(bottom, top)`, draw two widgets in the same area, top on top. +/// - `at(w, sub_area)`, pin a widget to a fixed sub-area (ignores caller's area). +/// - `compose(border_w, inner_area, content_w)`, border fills area, content fills inner. +/// +/// ## Custom widgets +/// +/// Implement `Widget` directly: +/// ```gleam +/// fn clock_widget(buf: buffer.Buffer, area: geometry.Rect) -> buffer.Buffer { +/// paragraph.render(buf, area, paragraph.paragraph_new(get_time())) +/// } +/// // Use it anywhere a Widget is expected. +/// widget.layer(background_w, clock_widget)(buf, screen) +/// ``` +import etui/buffer +import etui/geometry + +// ───────────────────────────────────────────────────────────────── +// Core types + +/// Stateless widget: a pure render function. +/// Any `fn(Buffer, Rect) -> Buffer` satisfies this type. +pub type Widget = + fn(buffer.Buffer, geometry.Rect) -> buffer.Buffer + +/// Stateful widget: carries a render function that also takes state. +/// State is kept external (in your app model) and passed at render time. +pub type StatefulWidget(state) { + StatefulWidget( + render: fn(buffer.Buffer, geometry.Rect, state) -> buffer.Buffer, + ) +} + +/// Animated widget: a render function that also receives the current frame. +pub type AnimatedWidget = + fn(buffer.Buffer, geometry.Rect, Int) -> buffer.Buffer + +// ───────────────────────────────────────────────────────────────── +// Stateful helpers + +/// Render a stateful widget with the given state value. +pub fn render_stateful( + buf: buffer.Buffer, + area: geometry.Rect, + w: StatefulWidget(s), + state: s, +) -> buffer.Buffer { + w.render(buf, area, state) +} + +/// Bake state into a stateless Widget. +/// The resulting Widget ignores any state updates after this call. +pub fn freeze(w: StatefulWidget(s), state: s) -> Widget { + fn(buf, area) { w.render(buf, area, state) } +} + +// ───────────────────────────────────────────────────────────────── +// Animated helpers + +/// Bind a frame number to an AnimatedWidget, producing a stateless Widget. +pub fn freeze_frame(w: AnimatedWidget, frame: Int) -> Widget { + fn(buf, area) { w(buf, area, frame) } +} + +// ───────────────────────────────────────────────────────────────── +// Composition + +/// Draw two widgets in the same area: `bottom` first, then `top` on top. +/// Use for overlaying a popup, cursor, or status indicator over content. +pub fn layer(bottom: Widget, top: Widget) -> Widget { + fn(buf, area) { buf |> bottom(area) |> top(area) } +} + +/// Pin a widget to a fixed `sub_area`, ignoring the caller-supplied area. +/// Use when a widget's position is pre-computed and shouldn't be overridden. +pub fn at(w: Widget, sub_area: geometry.Rect) -> Widget { + fn(buf, _area) { w(buf, sub_area) } +} + +/// Render `border_w` over `area`, then `content_w` over `inner_area`. +/// Convenience for the common "block border + child content" pattern: +/// +/// ```gleam +/// let blk = block.block_new() |> block.with_border(block.Single) +/// let inner = block.inner(area, blk) +/// let composed = widget.compose( +/// fn(buf, a) { block.render(buf, a, blk) }, +/// inner, +/// fn(buf, a) { paragraph.render(buf, a, para) }, +/// ) +/// composed(buf, area) +/// ``` +pub fn compose( + border_w: Widget, + inner_area: geometry.Rect, + content_w: Widget, +) -> Widget { + fn(buf, area) { buf |> border_w(area) |> content_w(inner_area) } +} + +/// Apply a list of widgets to the same area in order (each draws on top of the previous). +pub fn stack(widgets: List(Widget)) -> Widget { + fn(buf, area) { fold_widgets(buf, area, widgets) } +} + +fn fold_widgets( + buf: buffer.Buffer, + area: geometry.Rect, + widgets: List(Widget), +) -> buffer.Buffer { + case widgets { + [] -> buf + [w, ..rest] -> fold_widgets(w(buf, area), area, rest) + } +} + +// ───────────────────────────────────────────────────────────────── +// No-op widget + +/// A widget that renders nothing. Useful as a default or placeholder. +pub fn empty() -> Widget { + fn(buf, _area) { buf } +} diff --git a/src/etui/widgets/block.gleam b/src/etui/widgets/block.gleam new file mode 100644 index 0000000..53e8c98 --- /dev/null +++ b/src/etui/widgets/block.gleam @@ -0,0 +1,412 @@ +/// Block widget: border, title, padding. +import etui/buffer +import etui/geometry +import etui/span +import etui/style +import etui/text +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Box-drawing border style. +pub type Border { + None + Single + Double + Rounded +} + +/// Where to place the block title. +pub type TitlePosition { + Top + Bottom +} + +/// Configuration for a bordered, titled container. +pub type Block { + Block( + border: Border, + title: String, + /// Styled title spans. When non-empty, used instead of `title`. + title_spans: List(span.Span), + title_position: TitlePosition, + title_alignment: text.Alignment, + padding_top: Int, + padding_bottom: Int, + padding_left: Int, + padding_right: Int, + fg: style.Color, + bg: style.Color, + fill_bg: Bool, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New block with no border, no title, no padding, default colors. +pub fn block_new() -> Block { + Block( + border: None, + title: "", + title_spans: [], + title_position: Top, + title_alignment: text.Left, + padding_top: 0, + padding_bottom: 0, + padding_left: 0, + padding_right: 0, + fg: style.Default, + bg: style.Default, + fill_bg: False, + ) +} + +/// Fill the inner area with the block's background color. +pub fn with_bg_fill(blk: Block) -> Block { + Block(..blk, fill_bg: True) +} + +/// Set the border style. `Single` draws ┌─┐│└─┘, `Double` ╔═╗║╚═╝, `Rounded` ╭─╮│╰─╯. +pub fn with_border(blk: Block, border: Border) -> Block { + Block(..blk, border: border) +} + +/// Set a title string and whether it appears on the top or bottom border. +pub fn with_title(blk: Block, title: String, position: TitlePosition) -> Block { + Block(..blk, title: title, title_spans: [], title_position: position) +} + +/// Set a styled title from a list of `span.Span` values. +/// Takes precedence over `with_title` when non-empty. +/// +/// ```gleam +/// block.block_new() +/// |> block.with_border(block.Rounded) +/// |> block.with_title_styled([ +/// span.span_styled("★ ", style.bold_style() |> style.with_fg(style.Rgb(255,215,0))), +/// span.span_plain("Dashboard"), +/// ], block.Top) +/// ``` +pub fn with_title_styled( + blk: Block, + spans: List(span.Span), + position: TitlePosition, +) -> Block { + Block(..blk, title_spans: spans, title: "", title_position: position) +} + +/// Set inner padding (cells between border and content). +pub fn with_padding( + blk: Block, + top: Int, + bottom: Int, + left: Int, + right: Int, +) -> Block { + Block( + ..blk, + padding_top: top, + padding_bottom: bottom, + padding_left: left, + padding_right: right, + ) +} + +/// Set the horizontal alignment of the title within the border. +pub fn with_title_alignment(blk: Block, alignment: text.Alignment) -> Block { + Block(..blk, title_alignment: alignment) +} + +/// Set foreground and background colors for the border and title. +pub fn with_style(blk: Block, fg: style.Color, bg: style.Color) -> Block { + Block(..blk, fg: fg, bg: bg) +} + +/// Alias for `with_style(fg, bg)`, consistent with other widget naming. +pub fn with_colors(blk: Block, fg: style.Color, bg: style.Color) -> Block { + Block(..blk, fg: fg, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render block into buffer at given area. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + blk: Block, +) -> buffer.Buffer { + case blk.border { + None -> render_content(buf, area, blk) + Single -> render_bordered(buf, area, blk, "─", "│", "┌", "┐", "└", "┘") + Double -> render_bordered(buf, area, blk, "═", "║", "╔", "╗", "╚", "╝") + Rounded -> render_bordered(buf, area, blk, "─", "│", "╭", "╮", "╰", "╯") + } +} + +fn render_bordered( + buf: buffer.Buffer, + area: geometry.Rect, + blk: Block, + border_h: String, + border_v: String, + corner_tl: String, + corner_tr: String, + corner_bl: String, + corner_br: String, +) -> buffer.Buffer { + let width = area.size.width + let height = area.size.height + + case width < 2 || height < 2 { + True -> buf + False -> { + let x0 = area.position.x + let y0 = area.position.y + let y_bottom = geometry.bottom(area) - 1 + let x_right = geometry.right(area) - 1 + + let cell_border = + buffer.Cell( + content: buffer.Content(symbol: border_h, width: 1), + fg: blk.fg, + bg: blk.bg, + modifier: style.none(), + link: "", + ) + + let buf1 = + buffer.set_cell( + buf, + geometry.Position(x: x0, y: y0), + buffer.Cell( + content: buffer.Content(symbol: corner_tl, width: 1), + fg: blk.fg, + bg: blk.bg, + modifier: style.none(), + link: "", + ), + ) + let buf2 = + buffer.set_cell( + buf1, + geometry.Position(x: x_right, y: y0), + buffer.Cell( + content: buffer.Content(symbol: corner_tr, width: 1), + fg: blk.fg, + bg: blk.bg, + modifier: style.none(), + link: "", + ), + ) + let buf3 = + buffer.set_cell( + buf2, + geometry.Position(x: x0, y: y_bottom), + buffer.Cell( + content: buffer.Content(symbol: corner_bl, width: 1), + fg: blk.fg, + bg: blk.bg, + modifier: style.none(), + link: "", + ), + ) + let buf4 = + buffer.set_cell( + buf3, + geometry.Position(x: x_right, y: y_bottom), + buffer.Cell( + content: buffer.Content(symbol: corner_br, width: 1), + fg: blk.fg, + bg: blk.bg, + modifier: style.none(), + link: "", + ), + ) + + let buf5 = + draw_horizontal_line(buf4, x0 + 1, x_right - 1, y0, cell_border) + let buf6 = + draw_horizontal_line(buf5, x0 + 1, x_right - 1, y_bottom, cell_border) + + let cell_v = + buffer.Cell( + content: buffer.Content(symbol: border_v, width: 1), + fg: blk.fg, + bg: blk.bg, + modifier: style.none(), + link: "", + ) + let buf7 = draw_vertical_line(buf6, x0, y0 + 1, y_bottom - 1, cell_v) + let buf8 = draw_vertical_line(buf7, x_right, y0 + 1, y_bottom - 1, cell_v) + + let buf9 = render_title(buf8, area, blk) + render_content(buf9, inner_area(area, blk), blk) + } + } +} + +fn render_title( + buf: buffer.Buffer, + area: geometry.Rect, + blk: Block, +) -> buffer.Buffer { + let x0 = area.position.x + 1 + let max_width = area.size.width - 2 + case blk.title_spans { + [_, ..] -> { + let y = case blk.title_position { + Top -> area.position.y + Bottom -> geometry.bottom(area) - 1 + } + let line = span.line_aligned(blk.title_spans, blk.title_alignment) + span.render_line(buf, geometry.Position(x: x0, y: y), line, max_width) + } + [] -> + case blk.title { + "" -> buf + title -> { + let title_width = text.cell_width(title) + let t = case title_width > max_width { + True -> text.truncate(title, max_width, "…") + False -> title + } + let t_width = text.cell_width(t) + let x_offset = case blk.title_alignment { + text.Left -> 0 + text.Right -> int.max(0, max_width - t_width) + text.Center -> int.max(0, { max_width - t_width } / 2) + } + let y = case blk.title_position { + Top -> area.position.y + Bottom -> geometry.bottom(area) - 1 + } + buffer.set_string( + buf, + geometry.Position(x: x0 + x_offset, y: y), + t, + blk.fg, + blk.bg, + style.none(), + ) + } + } + } +} + +fn render_content( + buf: buffer.Buffer, + area: geometry.Rect, + blk: Block, +) -> buffer.Buffer { + case blk.fill_bg { + False -> buffer.clear(buf, area) + True -> fill_bg_area(buf, area, blk.fg, blk.bg, area.position.y) + } +} + +fn fill_bg_area( + buf: buffer.Buffer, + area: geometry.Rect, + fg: style.Color, + bg: style.Color, + y: Int, +) -> buffer.Buffer { + case y >= area.position.y + area.size.height { + True -> buf + False -> { + let row = text.pad_right("", area.size.width) + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + row, + fg, + bg, + style.none(), + ) + fill_bg_area(buf2, area, fg, bg, y + 1) + } + } +} + +/// Inner area (content region) of a block, accounting for border and padding. +/// +/// Use this to get the Rect to pass to child widgets: +/// +/// ```gleam +/// let b = block.block_new() |> block.with_border(block.Single) +/// block.render(buf, area, b) +/// |> paragraph.render(block.inner(area, b), p) +/// ``` +pub fn inner(area: geometry.Rect, blk: Block) -> geometry.Rect { + inner_area(area, blk) +} + +fn inner_area(area: geometry.Rect, blk: Block) -> geometry.Rect { + let border_offset = case blk.border { + None -> 0 + Single -> 1 + Double -> 1 + Rounded -> 1 + } + let x = area.position.x + blk.padding_left + border_offset + let y = area.position.y + blk.padding_top + border_offset + let w = + int.max( + 0, + area.size.width - blk.padding_left - blk.padding_right - border_offset * 2, + ) + let h = + int.max( + 0, + area.size.height + - blk.padding_top + - blk.padding_bottom + - border_offset + * 2, + ) + geometry.Rect( + position: geometry.Position(x: x, y: y), + size: geometry.Size(width: w, height: h), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn draw_horizontal_line( + buf: buffer.Buffer, + x_start: Int, + x_end: Int, + y: Int, + cell: buffer.Cell, +) -> buffer.Buffer { + case x_start > x_end { + True -> buf + False -> { + let buf_new = + buffer.set_cell(buf, geometry.Position(x: x_start, y: y), cell) + draw_horizontal_line(buf_new, x_start + 1, x_end, y, cell) + } + } +} + +fn draw_vertical_line( + buf: buffer.Buffer, + x: Int, + y_start: Int, + y_end: Int, + cell: buffer.Cell, +) -> buffer.Buffer { + case y_start > y_end { + True -> buf + False -> { + let buf_new = + buffer.set_cell(buf, geometry.Position(x: x, y: y_start), cell) + draw_vertical_line(buf_new, x, y_start + 1, y_end, cell) + } + } +} diff --git a/src/etui/widgets/canvas.gleam b/src/etui/widgets/canvas.gleam new file mode 100644 index 0000000..39e8c17 --- /dev/null +++ b/src/etui/widgets/canvas.gleam @@ -0,0 +1,235 @@ +/// 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) + } +} diff --git a/src/etui/widgets/chart.gleam b/src/etui/widgets/chart.gleam new file mode 100644 index 0000000..e5fb2e4 --- /dev/null +++ b/src/etui/widgets/chart.gleam @@ -0,0 +1,310 @@ +/// Vertical bar chart widget. +/// Each data point renders as a column of filled block cells. +/// Supports gradient/rainbow/animated fills per bar. +import etui/buffer +import etui/color +import etui/geometry +import etui/style +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type ChartFill { + /// One color per bar; wraps if more bars than colors. + ChartSolid(colors: List(style.Color)) + /// Gradient applied left-to-right across all bars. + ChartGradient(stops: List(style.Color)) + /// Rainbow, one hue per bar. + ChartRainbow + /// Rainbow that rotates hue over time. + ChartAnimatedRainbow + /// Gradient from bottom (cold) to top (warm), per cell. + ChartVerticalGradient(stops: List(style.Color)) +} + +pub type Chart { + Chart( + data: List(Int), + /// 0 = auto-compute from data. + max_val: Int, + fill: ChartFill, + /// Width in chars of each bar. + bar_width: Int, + /// Width in chars of gap between bars. + gap: Int, + /// Character used for filled cells. + bar_char: String, + bg: style.Color, + /// Animation period in frames. + period: Int, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn chart_new(data: List(Int)) -> Chart { + Chart( + data: data, + max_val: 0, + fill: ChartRainbow, + bar_width: 2, + gap: 1, + bar_char: "█", + bg: style.Default, + period: 60, + ) +} + +pub fn with_fill(c: Chart, fill: ChartFill) -> Chart { + Chart(..c, fill: fill) +} + +pub fn with_max(c: Chart, max: Int) -> Chart { + Chart(..c, max_val: int.max(1, max)) +} + +pub fn with_bar_width(c: Chart, w: Int) -> Chart { + Chart(..c, bar_width: int.max(1, w)) +} + +pub fn with_gap(c: Chart, g: Int) -> Chart { + Chart(..c, gap: int.max(0, g)) +} + +pub fn with_period(c: Chart, period: Int) -> Chart { + Chart(..c, period: int.max(1, period)) +} + +pub fn with_bg(c: Chart, bg: style.Color) -> Chart { + Chart(..c, bg: bg) +} + +pub fn with_style(c: Chart, s: style.Style) -> Chart { + Chart(..c, bg: s.bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render chart into `area`. `frame` drives animated fills. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + c: Chart, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let max = case c.max_val { + 0 -> list.fold(c.data, 1, int.max) + m -> m + } + let n_bars = list.length(c.data) + render_bars(buf, area, c, c.data, 0, n_bars, max, frame) + } + } +} + +fn render_bars( + buf: buffer.Buffer, + area: geometry.Rect, + c: Chart, + data: List(Int), + bar_idx: Int, + n_bars: Int, + max: Int, + frame: Int, +) -> buffer.Buffer { + case data { + [] -> buf + [val, ..rest] -> { + let col_start = bar_idx * { c.bar_width + c.gap } + case col_start >= area.size.width { + True -> buf + False -> { + let h = area.size.height + // how many rows (from bottom) to fill for this bar + let filled_rows = val * h / int.max(1, max) + let buf2 = + render_bar_col( + buf, + area, + c, + bar_idx, + n_bars, + col_start, + h, + filled_rows, + frame, + ) + render_bars(buf2, area, c, rest, bar_idx + 1, n_bars, max, frame) + } + } + } + } +} + +fn render_bar_col( + buf: buffer.Buffer, + area: geometry.Rect, + c: Chart, + bar_idx: Int, + n_bars: Int, + col_start: Int, + height: Int, + filled_rows: Int, + frame: Int, +) -> buffer.Buffer { + render_bar_cols_inner( + buf, + area, + c, + bar_idx, + n_bars, + col_start, + height, + filled_rows, + frame, + 0, + ) +} + +fn render_bar_cols_inner( + buf: buffer.Buffer, + area: geometry.Rect, + c: Chart, + bar_idx: Int, + n_bars: Int, + col_start: Int, + height: Int, + filled_rows: Int, + frame: Int, + dc: Int, +) -> buffer.Buffer { + case dc >= c.bar_width { + True -> buf + False -> { + let x = col_start + dc + case x >= area.size.width { + True -> buf + False -> { + let buf2 = + render_bar_rows( + buf, + area, + c, + bar_idx, + n_bars, + x, + height, + filled_rows, + frame, + 0, + ) + render_bar_cols_inner( + buf2, + area, + c, + bar_idx, + n_bars, + col_start, + height, + filled_rows, + frame, + dc + 1, + ) + } + } + } + } +} + +fn render_bar_rows( + buf: buffer.Buffer, + area: geometry.Rect, + c: Chart, + bar_idx: Int, + n_bars: Int, + x: Int, + height: Int, + filled_rows: Int, + frame: Int, + dy: Int, +) -> buffer.Buffer { + case dy >= height { + True -> buf + False -> { + // dy=0 is top of chart; dy=height-1 is bottom + let rows_from_bottom = height - 1 - dy + let is_filled = rows_from_bottom < filled_rows + let pos = + geometry.Position(x: area.position.x + x, y: area.position.y + dy) + let buf2 = case is_filled { + True -> { + let fg = + bar_color(c.fill, bar_idx, n_bars, dy, height, frame, c.period) + buffer.set_string(buf, pos, c.bar_char, fg, c.bg, style.none()) + } + False -> buf + } + render_bar_rows( + buf2, + area, + c, + bar_idx, + n_bars, + x, + height, + filled_rows, + frame, + dy + 1, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Color dispatch + +fn bar_color( + fill: ChartFill, + bar_idx: Int, + n_bars: Int, + dy: Int, + height: Int, + frame: Int, + period: Int, +) -> style.Color { + let p = int.max(1, period) + let n = int.max(1, n_bars) + let h = int.max(1, height) + case fill { + ChartSolid(colors) -> get_nth_color(colors, bar_idx) + ChartGradient(stops) -> color.gradient(stops, bar_idx, n - 1) + ChartRainbow -> color.hue_to_rgb(bar_idx * 360 / n) + ChartAnimatedRainbow -> + color.hue_to_rgb({ bar_idx * 360 / n + frame * 360 / p } % 360) + ChartVerticalGradient(stops) -> + color.gradient(stops, height - 1 - dy, h - 1) + } +} + +fn get_nth_color(colors: List(style.Color), n: Int) -> style.Color { + let len = list.length(colors) + case len { + 0 -> style.Default + _ -> get_color_at(colors, n % len) + } +} + +fn get_color_at(colors: List(style.Color), n: Int) -> style.Color { + case colors, n { + [], _ -> style.Default + [c, ..], 0 -> c + [_, ..rest], _ -> get_color_at(rest, n - 1) + } +} diff --git a/src/etui/widgets/clear.gleam b/src/etui/widgets/clear.gleam new file mode 100644 index 0000000..8b8e40d --- /dev/null +++ b/src/etui/widgets/clear.gleam @@ -0,0 +1,17 @@ +/// Clear widget: fill a Rect with empty cells. +/// +/// Use this to erase a region before rendering over it, or to clear +/// popups/overlays when they are dismissed. +/// +/// ```gleam +/// buffer.buffer_new(area) +/// |> clear.render(popup_rect) +/// |> block.render(popup_rect, block.block_new() |> block.with_border(block.Single)) +/// ``` +import etui/buffer +import etui/geometry + +/// Fill `area` with empty cells (space, Default colors, no modifier). +pub fn render(buf: buffer.Buffer, area: geometry.Rect) -> buffer.Buffer { + buffer.clear(buf, area) +} diff --git a/src/etui/widgets/dialog.gleam b/src/etui/widgets/dialog.gleam new file mode 100644 index 0000000..1e2ff6b --- /dev/null +++ b/src/etui/widgets/dialog.gleam @@ -0,0 +1,271 @@ +/// Modal dialog widget: message with Confirm / Cancel buttons. +/// +/// Renders a centered popup with a message and two focusable buttons. +/// Drive it with `toggle`, `confirm`, `cancel` and read result via `is_confirmed`. +/// +/// ```gleam +/// import etui/widgets/dialog +/// +/// let d = dialog.dialog_new("Delete this file?") +/// let state = dialog.state_new() +/// +/// // In on_event: +/// let state = case keys.match(k) { +/// keys.Tab -> dialog.toggle(state) +/// keys.Enter -> state // handle below +/// keys.Escape -> dialog.cancel(state) +/// _ -> state +/// } +/// let confirmed = dialog.is_confirmed(state) && key == "enter" +/// +/// // In render: +/// dialog.render(buf, screen_area, d, state) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import etui/widgets/block + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Dialog configuration. +pub type Dialog { + Dialog( + message: String, + confirm_label: String, + cancel_label: String, + /// Dialog box width (0 = auto: max of message width + 4 and 30). + width: Int, + /// Total dialog height including border (0 = auto). + height: Int, + fg: style.Color, + bg: style.Color, + confirm_style: style.Style, + cancel_style: style.Style, + /// Style for the focused button. + focused_style: style.Style, + border: block.Border, + ) +} + +/// Which button is currently focused. +pub type DialogButton { + Confirm + Cancel +} + +/// Mutable dialog state. +pub type DialogState { + DialogState(focused: DialogButton) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Dialog with default labels ("OK" / "Cancel") and a rounded border. +pub fn dialog_new(message: String) -> Dialog { + Dialog( + message: message, + confirm_label: " OK ", + cancel_label: " Cancel ", + width: 0, + height: 0, + fg: style.Default, + bg: style.Default, + confirm_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.none(), + ), + cancel_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.none(), + ), + focused_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + border: block.Rounded, + ) +} + +pub fn state_new() -> DialogState { + DialogState(focused: Confirm) +} + +// ───────────────────────────────────────────────────────────────── +// Builders + +pub fn with_labels(d: Dialog, confirm: String, cancel: String) -> Dialog { + Dialog(..d, confirm_label: confirm, cancel_label: cancel) +} + +pub fn with_size(d: Dialog, width: Int, height: Int) -> Dialog { + Dialog(..d, width: width, height: height) +} + +pub fn with_colors(d: Dialog, fg: style.Color, bg: style.Color) -> Dialog { + Dialog(..d, fg: fg, bg: bg) +} + +pub fn with_style(d: Dialog, s: style.Style) -> Dialog { + Dialog(..d, fg: s.fg, bg: s.bg) +} + +pub fn with_focused_style(d: Dialog, s: style.Style) -> Dialog { + Dialog(..d, focused_style: s) +} + +pub fn with_border(d: Dialog, b: block.Border) -> Dialog { + Dialog(..d, border: b) +} + +// ───────────────────────────────────────────────────────────────── +// State operations + +/// Toggle focus between Confirm and Cancel. +pub fn toggle(state: DialogState) -> DialogState { + case state.focused { + Confirm -> DialogState(focused: Cancel) + Cancel -> DialogState(focused: Confirm) + } +} + +/// Focus the Confirm button. +pub fn focus_confirm(_state: DialogState) -> DialogState { + DialogState(focused: Confirm) +} + +/// Focus the Cancel button. +pub fn focus_cancel(_state: DialogState) -> DialogState { + DialogState(focused: Cancel) +} + +/// Convenience: focus Cancel (same as pressing Escape conceptually). +pub fn cancel(_state: DialogState) -> DialogState { + DialogState(focused: Cancel) +} + +/// `True` if the Confirm button is focused. +pub fn is_confirmed(state: DialogState) -> Bool { + state.focused == Confirm +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the dialog centered within `area`. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + d: Dialog, + state: DialogState, +) -> buffer.Buffer { + let msg_w = text.cell_width(d.message) + let btn_w = + text.cell_width(d.confirm_label) + text.cell_width(d.cancel_label) + 3 + let content_w = case msg_w > btn_w { + True -> msg_w + False -> btn_w + } + let box_w = case d.width > 0 { + True -> d.width + False -> content_w + 4 + } + let box_h = case d.height > 0 { + True -> d.height + False -> 6 + } + let box_w = case box_w < 20 { + True -> 20 + False -> box_w + } + let box_w = case box_w > area.size.width { + True -> area.size.width + False -> box_w + } + let box_h = case box_h > area.size.height { + True -> area.size.height + False -> box_h + } + + let x = area.position.x + { area.size.width - box_w } / 2 + let y = area.position.y + { area.size.height - box_h } / 2 + let box_area = + geometry.Rect( + position: geometry.Position(x: x, y: y), + size: geometry.Size(width: box_w, height: box_h), + ) + + let blk = + block.block_new() + |> block.with_border(d.border) + |> block.with_style(d.fg, d.bg) + |> block.with_bg_fill + let buf1 = block.render(buf, box_area, blk) + let inner = block.inner(box_area, blk) + + let msg_x = + inner.position.x + { inner.size.width - text.cell_width(d.message) } / 2 + let msg_y = inner.position.y + { inner.size.height - 3 } / 2 + let buf2 = case msg_y >= inner.position.y && inner.size.height > 0 { + False -> buf1 + True -> + buffer.set_string( + buf1, + geometry.Position(x: msg_x, y: msg_y), + text.truncate(d.message, inner.size.width, "…"), + d.fg, + d.bg, + style.none(), + ) + } + + let btn_y = inner.position.y + inner.size.height - 1 + case btn_y >= inner.position.y && inner.size.height >= 3 { + False -> buf2 + True -> render_buttons(buf2, inner, d, state, btn_y) + } +} + +fn render_buttons( + buf: buffer.Buffer, + inner: geometry.Rect, + d: Dialog, + state: DialogState, + btn_y: Int, +) -> buffer.Buffer { + let conf_w = text.cell_width(d.confirm_label) + let canc_w = text.cell_width(d.cancel_label) + let total_btn_w = conf_w + 1 + canc_w + let btn_x = inner.position.x + { inner.size.width - total_btn_w } / 2 + + let #(conf_st, canc_st) = case state.focused { + Confirm -> #(d.focused_style, d.cancel_style) + Cancel -> #(d.confirm_style, d.focused_style) + } + + let buf1 = + buffer.set_string( + buf, + geometry.Position(x: btn_x, y: btn_y), + d.confirm_label, + conf_st.fg, + conf_st.bg, + conf_st.modifier, + ) + let buf2 = + buffer.set_string( + buf1, + geometry.Position(x: btn_x + conf_w + 1, y: btn_y), + d.cancel_label, + canc_st.fg, + canc_st.bg, + canc_st.modifier, + ) + buf2 +} diff --git a/src/etui/widgets/form.gleam b/src/etui/widgets/form.gleam new file mode 100644 index 0000000..b0c2822 --- /dev/null +++ b/src/etui/widgets/form.gleam @@ -0,0 +1,465 @@ +/// Multi-field form widget with focus management and validation. +/// +/// Each field has a label, an input value, and an optional validator. +/// Tab/Shift-Tab move focus between fields; Enter submits if all valid. +/// +/// ```gleam +/// import etui/widgets/form +/// +/// type MyField { Name | Email | Age } +/// +/// let f = +/// form.form_new() +/// |> form.add_field(Name, "Name", "", fn(v) { +/// case v { "" -> Error("required") _ -> Ok(Nil) } +/// }) +/// |> form.add_field(Email, "Email", "", fn(v) { +/// case string.contains(v, "@") { +/// True -> Ok(Nil) +/// False -> Error("invalid email") +/// } +/// }) +/// +/// // In on_event: +/// let f = case key { +/// "tab" -> form.focus_next(f) +/// "s-tab" -> form.focus_prev(f) +/// "enter" -> f // check form.is_valid(f) / form.submit(f) +/// ch -> form.type_char(f, ch) +/// } +/// +/// // In render: +/// form.render(buf, area, f) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Validator: Ok(Nil) if valid, Error(String) with message if not. +pub type Validator = + fn(String) -> Result(Nil, String) + +/// A single form field. +pub type Field(id) { + Field( + id: id, + label: String, + value: String, + validator: Validator, + error: String, + /// Number of graphemes / cells allowed (0 = unlimited within display width). + max_length: Int, + ) +} + +/// Form state: ordered list of fields plus focus index. +pub type Form(id) { + Form( + fields: List(Field(id)), + focused: Int, + submitted: Bool, + label_width: Int, + fg: style.Color, + bg: style.Color, + focused_fg: style.Color, + focused_bg: style.Color, + error_fg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Empty form with default styles. +pub fn form_new() -> Form(id) { + Form( + fields: [], + focused: 0, + submitted: False, + label_width: 0, + fg: style.Default, + bg: style.Default, + focused_fg: style.Default, + focused_bg: style.Indexed(4), + error_fg: style.Indexed(1), + ) +} + +/// Append a field with a validator. +pub fn add_field( + f: Form(id), + id: id, + label: String, + default_value: String, + validator: Validator, +) -> Form(id) { + let field = + Field( + id: id, + label: label, + value: default_value, + validator: validator, + error: "", + max_length: 0, + ) + Form(..f, fields: list.append(f.fields, [field])) +} + +/// Append a required text field (non-empty validator). +pub fn add_required( + f: Form(id), + id: id, + label: String, + default_value: String, +) -> Form(id) { + add_field(f, id, label, default_value, fn(v) { + case v { + "" -> Error("required") + _ -> Ok(Nil) + } + }) +} + +/// Append an optional field (always valid). +pub fn add_optional( + f: Form(id), + id: id, + label: String, + default_value: String, +) -> Form(id) { + add_field(f, id, label, default_value, fn(_) { Ok(Nil) }) +} + +/// Set max grapheme length for the most-recently added field. +pub fn with_field_max_length(f: Form(id), max: Int) -> Form(id) { + let fields = + list.index_map(f.fields, fn(field, i) { + case i == list.length(f.fields) - 1 { + True -> Field(..field, max_length: max) + False -> field + } + }) + Form(..f, fields: fields) +} + +/// Override label column width (auto-computed from labels if 0). +pub fn with_label_width(f: Form(id), w: Int) -> Form(id) { + Form(..f, label_width: w) +} + +/// Set base foreground/background. +pub fn with_colors(f: Form(id), fg: style.Color, bg: style.Color) -> Form(id) { + Form(..f, fg: fg, bg: bg) +} + +/// Set focused field highlight colors. +pub fn with_focused_colors( + f: Form(id), + fg: style.Color, + bg: style.Color, +) -> Form(id) { + Form(..f, focused_fg: fg, focused_bg: bg) +} + +/// Set validation error text color. +pub fn with_error_color(f: Form(id), fg: style.Color) -> Form(id) { + Form(..f, error_fg: fg) +} + +// ───────────────────────────────────────────────────────────────── +// Focus + +/// Move focus to the next field (wraps around). +pub fn focus_next(f: Form(id)) -> Form(id) { + let n = list.length(f.fields) + case n { + 0 -> f + _ -> Form(..f, focused: { f.focused + 1 } % n) + } +} + +/// Move focus to the previous field (wraps around). +pub fn focus_prev(f: Form(id)) -> Form(id) { + let n = list.length(f.fields) + case n { + 0 -> f + _ -> + Form(..f, focused: { + let prev = f.focused - 1 + case prev < 0 { + True -> n - 1 + False -> prev + } + }) + } +} + +/// Move focus to a specific field by index. +pub fn focus_index(f: Form(id), idx: Int) -> Form(id) { + let n = list.length(f.fields) + case idx >= 0 && idx < n { + True -> Form(..f, focused: idx) + False -> f + } +} + +// ───────────────────────────────────────────────────────────────── +// Editing + +/// Type a character into the currently focused field. +pub fn type_char(f: Form(id), ch: String) -> Form(id) { + update_focused(f, fn(field) { + case + field.max_length > 0 && text.cell_width(field.value) >= field.max_length + { + True -> field + False -> Field(..field, value: field.value <> ch, error: "") + } + }) +} + +/// Backspace on the currently focused field. +pub fn backspace(f: Form(id)) -> Form(id) { + update_focused(f, fn(field) { + case field.value { + "" -> field + v -> { + let graphemes = text.graphemes(v) + let dropped = list.take(graphemes, list.length(graphemes) - 1) + Field(..field, value: string.concat(dropped), error: "") + } + } + }) +} + +/// Clear the currently focused field's value. +pub fn clear_focused(f: Form(id)) -> Form(id) { + update_focused(f, fn(field) { Field(..field, value: "", error: "") }) +} + +/// Set a field's value by id. +pub fn set_value(f: Form(id), id: id, value: String) -> Form(id) { + let fields = + list.map(f.fields, fn(field) { + case field.id == id { + True -> Field(..field, value: value, error: "") + False -> field + } + }) + Form(..f, fields: fields) +} + +// ───────────────────────────────────────────────────────────────── +// Validation & submission + +/// Validate all fields. Returns form with error messages populated. +pub fn validate(f: Form(id)) -> Form(id) { + let fields = + list.map(f.fields, fn(field) { + case field.validator(field.value) { + Ok(_) -> Field(..field, error: "") + Error(msg) -> Field(..field, error: msg) + } + }) + Form(..f, fields: fields) +} + +/// True if all fields are valid (no errors after validation). +pub fn is_valid(f: Form(id)) -> Bool { + list.all(f.fields, fn(field) { + case field.validator(field.value) { + Ok(_) -> True + Error(_) -> False + } + }) +} + +/// Validate then mark as submitted if valid. Returns the form. +pub fn submit(f: Form(id)) -> Form(id) { + let validated = validate(f) + case is_valid(validated) { + True -> Form(..validated, submitted: True) + False -> validated + } +} + +/// True if the form was successfully submitted. +pub fn is_submitted(f: Form(id)) -> Bool { + f.submitted +} + +/// Reset all fields to empty, clear errors and submitted flag. +pub fn reset(f: Form(id)) -> Form(id) { + let fields = + list.map(f.fields, fn(field) { Field(..field, value: "", error: "") }) + Form(..f, fields: fields, focused: 0, submitted: False) +} + +/// Get a field's current value by id. Returns "" if not found. +pub fn get_value(f: Form(id), id: id) -> String { + case list.find(f.fields, fn(field) { field.id == id }) { + Ok(field) -> field.value + Error(_) -> "" + } +} + +/// Get all field values as `#(id, value)` pairs. +pub fn values(f: Form(id)) -> List(#(id, String)) { + list.map(f.fields, fn(field) { #(field.id, field.value) }) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render all fields as label + value rows. Each field takes 2 rows +/// (value row + optional error row). Focused field is highlighted. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + f: Form(id), +) -> buffer.Buffer { + case + area.size.width <= 0 || area.size.height <= 0 || list.is_empty(f.fields) + { + True -> buf + False -> { + let lw = case f.label_width { + 0 -> compute_label_width(f.fields) + w -> w + } + render_fields(buf, area, f.fields, f, lw, 0, 0) + } + } +} + +fn render_fields( + buf: buffer.Buffer, + area: geometry.Rect, + fields: List(Field(id)), + f: Form(id), + lw: Int, + field_idx: Int, + row: Int, +) -> buffer.Buffer { + let row_height = 2 + case fields { + [] -> buf + [field, ..rest] -> { + let y = area.position.y + row + let fits = y < area.position.y + area.size.height + let buf2 = case fits { + False -> buf + True -> render_field_row(buf, area, field, f, lw, field_idx, y) + } + render_fields(buf2, area, rest, f, lw, field_idx + 1, row + row_height) + } + } +} + +fn render_field_row( + buf: buffer.Buffer, + area: geometry.Rect, + field: Field(id), + f: Form(id), + lw: Int, + field_idx: Int, + y: Int, +) -> buffer.Buffer { + let is_focused = field_idx == f.focused + let label_text = text.pad_right(text.truncate(field.label, lw, ""), lw) <> " " + let value_x = area.position.x + lw + 1 + let value_w = area.size.width - lw - 1 + let value_w = case value_w < 0 { + True -> 0 + False -> value_w + } + + let #(val_fg, val_bg) = case is_focused { + True -> #(f.focused_fg, f.focused_bg) + False -> #(f.fg, f.bg) + } + + let label_modifier = case is_focused { + True -> style.bold() + False -> style.none() + } + + let buf1 = case lw > 0 { + False -> buf + True -> + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + label_text, + f.fg, + f.bg, + label_modifier, + ) + } + + let padded_value = + text.pad_right(text.truncate(field.value, value_w, ""), value_w) + let buf2 = case value_w > 0 { + False -> buf1 + True -> + buffer.set_string( + buf1, + geometry.Position(x: value_x, y: y), + padded_value, + val_fg, + val_bg, + style.none(), + ) + } + + let error_y = y + 1 + case + field.error != "" + && error_y < area.position.y + area.size.height + && value_w > 0 + { + False -> buf2 + True -> + buffer.set_string( + buf2, + geometry.Position(x: value_x, y: error_y), + text.truncate(" " <> field.error, value_w, ""), + f.error_fg, + f.bg, + style.none(), + ) + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn update_focused( + f: Form(id), + updater: fn(Field(id)) -> Field(id), +) -> Form(id) { + let fields = + list.index_map(f.fields, fn(field, i) { + case i == f.focused { + True -> updater(field) + False -> field + } + }) + Form(..f, fields: fields) +} + +fn compute_label_width(fields: List(Field(id))) -> Int { + list.fold(fields, 0, fn(acc, field) { + let w = text.cell_width(field.label) + case w > acc { + True -> w + False -> acc + } + }) +} diff --git a/src/etui/widgets/gauge.gleam b/src/etui/widgets/gauge.gleam new file mode 100644 index 0000000..7cd744b --- /dev/null +++ b/src/etui/widgets/gauge.gleam @@ -0,0 +1,180 @@ +/// Gauge widget: horizontal progress bar with optional centered label. +/// +/// Renders a filled portion (default `█`) and an empty portion (default `░`) +/// proportional to `percent` (0–100). An optional label is overlaid centered. +/// +/// Example: +/// ```gleam +/// gauge_new(60) +/// |> with_label("60%") +/// |> gauge.render(buf, area) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Horizontal progress bar. `percent` is clamped to 0–100. +pub type Gauge { + Gauge( + percent: Int, + label: String, + filled_char: String, + empty_char: String, + fg: style.Color, + bg: style.Color, + filled_modifier: style.Modifier, + empty_modifier: style.Modifier, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New gauge at the given percent (clamped to 0–100). Default chars: `█`/`░`. +pub fn gauge_new(percent: Int) -> Gauge { + Gauge( + percent: int.clamp(percent, 0, 100), + label: "", + filled_char: "█", + empty_char: "░", + fg: style.Default, + bg: style.Default, + filled_modifier: style.none(), + empty_modifier: style.none(), + ) +} + +/// Set a label overlaid centered on the bar. +pub fn with_label(g: Gauge, label: String) -> Gauge { + Gauge(..g, label: label) +} + +/// Set filled and empty characters (single-cell graphemes only). +pub fn with_chars(g: Gauge, filled: String, empty: String) -> Gauge { + Gauge(..g, filled_char: filled, empty_char: empty) +} + +/// Set fg/bg colors for both filled and empty sections. +pub fn with_colors(g: Gauge, fg: style.Color, bg: style.Color) -> Gauge { + Gauge(..g, fg: fg, bg: bg) +} + +/// Apply a modifier (bold, etc.) to the filled section only. +pub fn with_filled_modifier(g: Gauge, modifier: style.Modifier) -> Gauge { + Gauge(..g, filled_modifier: modifier) +} + +/// Apply a style (fg/bg) via a `Style` value. +pub fn with_style(g: Gauge, s: style.Style) -> Gauge { + Gauge(..g, fg: s.fg, bg: s.bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the gauge bar into the first row of `area`. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + g: Gauge, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> render_bar(buf, area, g) + } +} + +fn render_bar( + buf: buffer.Buffer, + area: geometry.Rect, + g: Gauge, +) -> buffer.Buffer { + let width = area.size.width + let filled = int.clamp(width * g.percent / 100, 0, width) + let empty = width - filled + + let buf1 = + fill_cells( + buf, + area.position, + filled, + g.filled_char, + g.fg, + g.bg, + g.filled_modifier, + ) + let buf2 = + fill_cells( + buf1, + geometry.Position(x: area.position.x + filled, y: area.position.y), + empty, + g.empty_char, + g.fg, + g.bg, + g.empty_modifier, + ) + + case g.label { + "" -> buf2 + label -> { + let label_width = text.cell_width(label) + let label_x = area.position.x + int.max(0, { width - label_width } / 2) + buffer.set_string( + buf2, + geometry.Position(x: label_x, y: area.position.y), + text.truncate(label, width, ""), + g.fg, + g.bg, + style.none(), + ) + } + } +} + +fn fill_cells( + buf: buffer.Buffer, + pos: geometry.Position, + count: Int, + char: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> buffer.Buffer { + do_fill(buf, pos, count, 0, char, fg, bg, modifier) +} + +fn do_fill( + buf: buffer.Buffer, + start: geometry.Position, + count: Int, + i: Int, + char: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> buffer.Buffer { + case i >= count { + True -> buf + False -> { + let pos = geometry.Position(x: start.x + i, y: start.y) + let buf_new = + buffer.set_cell( + buf, + pos, + buffer.Cell( + content: buffer.Content(symbol: char, width: 1), + fg: fg, + bg: bg, + modifier: modifier, + link: "", + ), + ) + do_fill(buf_new, start, count, i + 1, char, fg, bg, modifier) + } + } +} diff --git a/src/etui/widgets/gradient_bar.gleam b/src/etui/widgets/gradient_bar.gleam new file mode 100644 index 0000000..16a62e5 --- /dev/null +++ b/src/etui/widgets/gradient_bar.gleam @@ -0,0 +1,236 @@ +import etui/buffer +import etui/color +import etui/geometry +import etui/style +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type GradientFill { + /// Static left-to-right gradient across color stops. + LinearGradient(stops: List(style.Color)) + /// Gradient that scrolls left over time. + AnimatedLinear(stops: List(style.Color)) + /// Static full-spectrum rainbow. + Rainbow + /// Rainbow that rotates hue over time. + AnimatedRainbow + /// Single color that oscillates between half and full brightness. + Pulse(base: style.Color) +} + +pub type GradientBar { + GradientBar( + fill: GradientFill, + filled_char: String, + empty_char: String, + /// 0–100: how much of the bar is filled. + percent: Int, + modifier: style.Modifier, + bg: style.Color, + /// Animation period in frames. + period: Int, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// Static linear gradient bar (full width). +pub fn gradient_bar_new(stops: List(style.Color)) -> GradientBar { + GradientBar( + fill: LinearGradient(stops), + filled_char: "█", + empty_char: "░", + percent: 100, + modifier: style.none(), + bg: style.Default, + period: 60, + ) +} + +/// Animated (scrolling) gradient bar (full width). +pub fn animated_gradient_bar_new(stops: List(style.Color)) -> GradientBar { + GradientBar( + fill: AnimatedLinear(stops), + filled_char: "█", + empty_char: "░", + percent: 100, + modifier: style.none(), + bg: style.Default, + period: 60, + ) +} + +/// Static rainbow bar (full width). +pub fn rainbow_bar() -> GradientBar { + GradientBar( + fill: Rainbow, + filled_char: "█", + empty_char: " ", + percent: 100, + modifier: style.none(), + bg: style.Default, + period: 60, + ) +} + +/// Animated (rotating) rainbow bar (full width). +pub fn animated_rainbow_bar() -> GradientBar { + GradientBar( + fill: AnimatedRainbow, + filled_char: "█", + empty_char: " ", + percent: 100, + modifier: style.none(), + bg: style.Default, + period: 60, + ) +} + +/// Pulsing single-color bar (full width). +pub fn pulse_bar(base: style.Color) -> GradientBar { + GradientBar( + fill: Pulse(base), + filled_char: "█", + empty_char: " ", + percent: 100, + modifier: style.none(), + bg: style.Default, + period: 30, + ) +} + +/// Gradient progress bar: partial fill, static gradient. +pub fn gradient_progress_new( + stops: List(style.Color), + percent: Int, +) -> GradientBar { + GradientBar( + fill: LinearGradient(stops), + filled_char: "█", + empty_char: "░", + percent: int.clamp(percent, 0, 100), + modifier: style.none(), + bg: style.Default, + period: 60, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Config helpers + +pub fn with_percent(g: GradientBar, pct: Int) -> GradientBar { + GradientBar(..g, percent: int.clamp(pct, 0, 100)) +} + +pub fn with_chars( + g: GradientBar, + filled: String, + empty: String, +) -> GradientBar { + GradientBar(..g, filled_char: filled, empty_char: empty) +} + +pub fn with_period(g: GradientBar, period: Int) -> GradientBar { + GradientBar(..g, period: int.max(1, period)) +} + +pub fn with_modifier(g: GradientBar, m: style.Modifier) -> GradientBar { + GradientBar(..g, modifier: m) +} + +pub fn with_bg(g: GradientBar, bg: style.Color) -> GradientBar { + GradientBar(..g, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the gradient bar into `buf` at `area`. `frame` drives animation. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + g: GradientBar, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let width = area.size.width + let fill_width = width * int.clamp(g.percent, 0, 100) / 100 + render_rows(buf, area, g, frame, width, fill_width, 0) + } + } +} + +fn render_rows( + buf: buffer.Buffer, + area: geometry.Rect, + g: GradientBar, + frame: Int, + width: Int, + fill_width: Int, + y: Int, +) -> buffer.Buffer { + case y >= area.size.height { + True -> buf + False -> { + let buf2 = render_row_cells(buf, area, g, frame, width, fill_width, 0, y) + render_rows(buf2, area, g, frame, width, fill_width, y + 1) + } + } +} + +fn render_row_cells( + buf: buffer.Buffer, + area: geometry.Rect, + g: GradientBar, + frame: Int, + width: Int, + fill_width: Int, + x: Int, + y: Int, +) -> buffer.Buffer { + case x >= width { + True -> buf + False -> { + let pos = + geometry.Position(x: area.position.x + x, y: area.position.y + y) + let #(sym, fg) = case x < fill_width { + True -> { + let c = cell_color(g.fill, x, fill_width, frame, g.period) + #(g.filled_char, c) + } + False -> #(g.empty_char, style.Default) + } + let buf2 = buffer.set_string(buf, pos, sym, fg, g.bg, g.modifier) + render_row_cells(buf2, area, g, frame, width, fill_width, x + 1, y) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Color dispatch + +fn cell_color( + fill: GradientFill, + x: Int, + width: Int, + frame: Int, + period: Int, +) -> style.Color { + let p = int.max(1, period) + let w = int.max(1, width) + case fill { + LinearGradient(stops) -> color.gradient(stops, x, w - 1) + AnimatedLinear(stops) -> { + let offset = frame * w / p + color.gradient(stops, { x + offset } % w, w - 1) + } + Rainbow -> color.hue_to_rgb(x * 360 / w) + AnimatedRainbow -> color.hue_to_rgb({ x * 360 / w + frame * 360 / p } % 360) + Pulse(base) -> color.pulse(base, frame + x * p / w, p) + } +} diff --git a/src/etui/widgets/hbar.gleam b/src/etui/widgets/hbar.gleam new file mode 100644 index 0000000..80cac8f --- /dev/null +++ b/src/etui/widgets/hbar.gleam @@ -0,0 +1,344 @@ +/// Horizontal bar chart widget. +/// Each item renders as one labelled row with a left-to-right filled bar. +/// Supports gradient/rainbow/animated fill. +import etui/buffer +import etui/color +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type HBarFill { + /// Cycle through a list of solid colors, one per bar. + HBarSolid(colors: List(style.Color)) + /// Static left-to-right gradient applied to each bar's filled cells. + HBarGradient(stops: List(style.Color)) + /// Each bar gets a different hue; static rainbow. + HBarRainbow + /// Rainbow that rotates hue over time. + HBarAnimatedRainbow +} + +pub type HBarItem { + HBarItem(label: String, value: Int) +} + +pub type HBar { + HBar( + items: List(HBarItem), + /// 0 = auto-compute max from data. + max_val: Int, + fill: HBarFill, + /// Width reserved for labels. 0 = auto (longest label). + label_width: Int, + /// Whether to append the numeric value after the bar. + show_value: Bool, + bar_char: String, + empty_char: String, + bg: style.Color, + period: Int, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn hbar_new(items: List(HBarItem)) -> HBar { + HBar( + items: items, + max_val: 0, + fill: HBarRainbow, + label_width: 0, + show_value: True, + bar_char: "█", + empty_char: "░", + bg: style.Default, + period: 60, + ) +} + +pub fn item(label: String, value: Int) -> HBarItem { + HBarItem(label: label, value: value) +} + +pub fn with_fill(h: HBar, fill: HBarFill) -> HBar { + HBar(..h, fill: fill) +} + +pub fn with_max(h: HBar, max: Int) -> HBar { + HBar(..h, max_val: int.max(1, max)) +} + +pub fn with_label_width(h: HBar, w: Int) -> HBar { + HBar(..h, label_width: int.max(0, w)) +} + +pub fn with_show_value(h: HBar, show: Bool) -> HBar { + HBar(..h, show_value: show) +} + +pub fn with_chars(h: HBar, bar: String, empty: String) -> HBar { + HBar(..h, bar_char: bar, empty_char: empty) +} + +pub fn with_period(h: HBar, period: Int) -> HBar { + HBar(..h, period: int.max(1, period)) +} + +pub fn with_bg(h: HBar, bg: style.Color) -> HBar { + HBar(..h, bg: bg) +} + +pub fn with_style(h: HBar, s: style.Style) -> HBar { + HBar(..h, bg: s.bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + h: HBar, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let max = case h.max_val { + 0 -> list.fold(h.items, 1, fn(acc, it) { int.max(acc, it.value) }) + m -> m + } + let lw = case h.label_width { + 0 -> + list.fold(h.items, 0, fn(acc, it) { + int.max(acc, text.cell_width(it.label)) + }) + w -> w + } + let val_w = case h.show_value { + False -> 0 + True -> num_digits(max) + 2 + } + let n = list.length(h.items) + render_rows(buf, area, h, h.items, 0, n, max, lw, val_w, frame) + } + } +} + +fn render_rows( + buf: buffer.Buffer, + area: geometry.Rect, + h: HBar, + items: List(HBarItem), + idx: Int, + n: Int, + max: Int, + lw: Int, + val_w: Int, + frame: Int, +) -> buffer.Buffer { + case items { + [] -> buf + [it, ..rest] -> { + case idx >= area.size.height { + True -> buf + False -> { + let y = area.position.y + idx + let buf2 = + render_row(buf, area, h, it, idx, n, max, lw, val_w, y, frame) + render_rows(buf2, area, h, rest, idx + 1, n, max, lw, val_w, frame) + } + } + } + } +} + +fn render_row( + buf: buffer.Buffer, + area: geometry.Rect, + h: HBar, + it: HBarItem, + idx: Int, + n: Int, + max: Int, + lw: Int, + val_w: Int, + y: Int, + frame: Int, +) -> buffer.Buffer { + // Label (left-aligned, fixed width) + let label = text.pad_right(text.truncate(it.label, lw, ""), lw) + let buf = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + label, + style.Default, + style.Default, + style.none(), + ) + // Bar area + let bar_x = area.position.x + lw + 1 + let bar_w = int.max(0, area.size.width - lw - 1 - val_w) + let filled = int.clamp(it.value * bar_w / int.max(1, max), 0, bar_w) + let empty = bar_w - filled + // Filled cells with color + let buf = render_filled(buf, h, idx, n, bar_x, y, filled, bar_w, frame) + // Empty cells + let buf = render_empty(buf, h, bar_x + filled, y, empty) + // Value label + case h.show_value && val_w > 0 { + False -> buf + True -> + buffer.set_string( + buf, + geometry.Position(x: bar_x + bar_w + 1, y: y), + int.to_string(it.value), + style.Default, + style.Default, + style.none(), + ) + } +} + +fn render_filled( + buf: buffer.Buffer, + h: HBar, + bar_idx: Int, + n: Int, + base_x: Int, + y: Int, + count: Int, + bar_w: Int, + frame: Int, +) -> buffer.Buffer { + render_filled_loop(buf, h, bar_idx, n, base_x, y, count, bar_w, frame, 0) +} + +fn render_filled_loop( + buf: buffer.Buffer, + h: HBar, + bar_idx: Int, + n: Int, + base_x: Int, + y: Int, + count: Int, + bar_w: Int, + frame: Int, + i: Int, +) -> buffer.Buffer { + case i >= count { + True -> buf + False -> { + let fg = cell_color(h.fill, bar_idx, n, i, bar_w, frame, h.period) + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: base_x + i, y: y), + h.bar_char, + fg, + h.bg, + style.none(), + ) + render_filled_loop( + buf2, + h, + bar_idx, + n, + base_x, + y, + count, + bar_w, + frame, + i + 1, + ) + } + } +} + +fn render_empty( + buf: buffer.Buffer, + h: HBar, + base_x: Int, + y: Int, + count: Int, +) -> buffer.Buffer { + render_empty_loop(buf, h, base_x, y, count, 0) +} + +fn render_empty_loop( + buf: buffer.Buffer, + h: HBar, + base_x: Int, + y: Int, + count: Int, + i: Int, +) -> buffer.Buffer { + case i >= count { + True -> buf + False -> { + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: base_x + i, y: y), + h.empty_char, + style.Default, + h.bg, + style.none(), + ) + render_empty_loop(buf2, h, base_x, y, count, i + 1) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Color dispatch + +fn cell_color( + fill: HBarFill, + bar_idx: Int, + n: Int, + x: Int, + bar_w: Int, + frame: Int, + period: Int, +) -> style.Color { + let p = int.max(1, period) + let nb = int.max(1, n) + let w = int.max(1, bar_w) + case fill { + HBarSolid(colors) -> nth_color(colors, bar_idx) + HBarGradient(stops) -> color.gradient(stops, x, w - 1) + HBarRainbow -> color.hue_to_rgb(bar_idx * 360 / nb) + HBarAnimatedRainbow -> + color.hue_to_rgb({ bar_idx * 360 / nb + frame * 360 / p } % 360) + } +} + +fn nth_color(colors: List(style.Color), n: Int) -> style.Color { + let len = list.length(colors) + case len { + 0 -> style.Default + _ -> color_at(colors, n % len) + } +} + +fn color_at(colors: List(style.Color), n: Int) -> style.Color { + case colors, n { + [], _ -> style.Default + [c, ..], 0 -> c + [_, ..rest], _ -> color_at(rest, n - 1) + } +} + +fn num_digits(n: Int) -> Int { + case n < 10 { + True -> 1 + False -> 1 + num_digits(n / 10) + } +} diff --git a/src/etui/widgets/help.gleam b/src/etui/widgets/help.gleam new file mode 100644 index 0000000..a3b3c39 --- /dev/null +++ b/src/etui/widgets/help.gleam @@ -0,0 +1,177 @@ +/// Keyboard shortcut help view. +/// Two modes: +/// - `Short`: one line, "k1/k2 desc • k3 desc • ..." +/// - `Full`: two columns, one binding per row. +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +/// A single key binding. Multiple keys for the same action go in `keys`. +pub type Binding { + Binding(keys: List(String), description: String) +} + +pub type HelpMode { + Short + Full +} + +pub type Help { + Help( + bindings: List(Binding), + mode: HelpMode, + separator: String, + key_fg: style.Color, + description_fg: style.Color, + bg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn binding(keys: List(String), description: String) -> Binding { + Binding(keys: keys, description: description) +} + +pub fn help_new(bindings: List(Binding)) -> Help { + Help( + bindings: bindings, + mode: Short, + separator: " • ", + key_fg: style.Default, + description_fg: style.Indexed(8), + bg: style.Default, + ) +} + +pub fn with_mode(h: Help, mode: HelpMode) -> Help { + Help(..h, mode: mode) +} + +pub fn toggle_mode(h: Help) -> Help { + case h.mode { + Short -> Help(..h, mode: Full) + Full -> Help(..h, mode: Short) + } +} + +pub fn with_separator(h: Help, sep: String) -> Help { + Help(..h, separator: sep) +} + +pub fn with_key_color(h: Help, fg: style.Color) -> Help { + Help(..h, key_fg: fg) +} + +pub fn with_description_color(h: Help, fg: style.Color) -> Help { + Help(..h, description_fg: fg) +} + +pub fn with_bg(h: Help, bg: style.Color) -> Help { + Help(..h, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + h: Help, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> + case h.mode { + Short -> render_short(buf, area, h) + Full -> render_full(buf, area, h) + } + } +} + +fn render_short( + buf: buffer.Buffer, + area: geometry.Rect, + h: Help, +) -> buffer.Buffer { + let txt = + h.bindings + |> list.map(fn(b) { string.join(b.keys, "/") <> " " <> b.description }) + |> string.join(h.separator) + let line = text.truncate(txt, area.size.width, "") + let padded = text.pad_right(line, area.size.width) + buffer.set_string( + buf, + area.position, + padded, + h.description_fg, + h.bg, + style.none(), + ) +} + +fn render_full( + buf: buffer.Buffer, + area: geometry.Rect, + h: Help, +) -> buffer.Buffer { + let max_key_w = + list.fold(h.bindings, 0, fn(acc, b) { + int.max(acc, text.cell_width(string.join(b.keys, "/"))) + }) + let key_col = int.min(max_key_w, int.max(0, area.size.width / 3)) + let desc_col = int.max(0, area.size.width - key_col - 1) + render_full_rows(buf, area, h, h.bindings, 0, key_col, desc_col) +} + +fn render_full_rows( + buf: buffer.Buffer, + area: geometry.Rect, + h: Help, + bindings: List(Binding), + row: Int, + key_col: Int, + desc_col: Int, +) -> buffer.Buffer { + case row >= area.size.height { + True -> buf + False -> + case bindings { + [] -> buf + [b, ..rest] -> { + let y = area.position.y + row + let key_text = text.truncate(string.join(b.keys, "/"), key_col, "") + let key_padded = text.pad_right(key_text, key_col) + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + key_padded, + h.key_fg, + h.bg, + style.bold(), + ) + let desc_text = text.truncate(b.description, desc_col, "") + let desc_padded = text.pad_right(desc_text, desc_col) + let buf3 = + buffer.set_string( + buf2, + geometry.Position(x: area.position.x + key_col + 1, y: y), + desc_padded, + h.description_fg, + h.bg, + style.none(), + ) + render_full_rows(buf3, area, h, rest, row + 1, key_col, desc_col) + } + } + } +} diff --git a/src/etui/widgets/input.gleam b/src/etui/widgets/input.gleam new file mode 100644 index 0000000..dbaa646 --- /dev/null +++ b/src/etui/widgets/input.gleam @@ -0,0 +1,259 @@ +/// Text input widget with cursor tracking and editing operations. +/// +/// State (`InputState`) is kept external so it persists across renders. +/// Use `insert_char`, `backspace`, `move_cursor_left/right` in your `update` +/// function to mutate state in response to `KeyPress` events. +/// +/// Example: +/// ```gleam +/// let state = input.insert_char(widget, state, "a") +/// input.render(buf, area, widget, state) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Validator: Ok(Nil) = valid, Error(String) = message to show. +pub type Validator = + fn(String) -> Result(Nil, String) + +/// Input widget configuration. +pub type InputWidget { + InputWidget( + max_length: Int, + placeholder: String, + fg: style.Color, + bg: style.Color, + /// Optional validation function; run via `validate/2`. + validator: Validator, + error_fg: style.Color, + /// Prefix shown before the value (e.g. "> ", "$ "). Counts toward width. + prompt: String, + /// When `True`, render each value cell as `mask` instead of the real char. + password: Bool, + /// Mask character used in password mode. Default `"*"`. + mask: String, + ) +} + +/// Mutable editing state: current value and cursor column (in cells). +pub type InputState { + InputState(value: String, cursor: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Widget config constructors + +/// New input widget with placeholder text. Default max length: 256 cells. +pub fn input_new(placeholder: String) -> InputWidget { + InputWidget( + max_length: 256, + placeholder: placeholder, + fg: style.Default, + bg: style.Default, + validator: fn(_) { Ok(Nil) }, + error_fg: style.Indexed(1), + prompt: "", + password: False, + mask: "*", + ) +} + +/// Set a prefix shown before the value (e.g. `"> "`). +pub fn with_prompt(i: InputWidget, prompt: String) -> InputWidget { + InputWidget(..i, prompt: prompt) +} + +/// Render each value cell as the mask character. Use for password fields. +pub fn with_password(i: InputWidget, password: Bool) -> InputWidget { + InputWidget(..i, password: password) +} + +/// Mask character used when `password` is True. Default `"*"`. +pub fn with_mask(i: InputWidget, mask: String) -> InputWidget { + InputWidget(..i, mask: mask) +} + +/// Set a validation function. Call `validate/2` to run it. +pub fn with_validator(i: InputWidget, v: Validator) -> InputWidget { + InputWidget(..i, validator: v) +} + +/// Set the color used to display validation errors. +pub fn with_error_color(i: InputWidget, fg: style.Color) -> InputWidget { + InputWidget(..i, error_fg: fg) +} + +/// Run the validator on `value`. Returns Ok(Nil) or Error(message). +pub fn validate(i: InputWidget, value: String) -> Result(Nil, String) { + i.validator(value) +} + +/// Maximum value width in cells (wide characters count as 2). +pub fn with_max_length(i: InputWidget, len: Int) -> InputWidget { + InputWidget(..i, max_length: len) +} + +pub fn with_colors( + i: InputWidget, + fg: style.Color, + bg: style.Color, +) -> InputWidget { + InputWidget(..i, fg: fg, bg: bg) +} + +pub fn with_style(i: InputWidget, s: style.Style) -> InputWidget { + InputWidget(..i, fg: s.fg, bg: s.bg) +} + +// ───────────────────────────────────────────────────────────────── +// State constructors + +/// Initial state: empty value, cursor at 0. +pub fn state_new() -> InputState { + InputState(value: "", cursor: 0) +} + +/// State pre-populated with a string; cursor placed at the end. +pub fn state_from_string(s: String) -> InputState { + InputState(value: s, cursor: text.cell_width(s)) +} + +// ───────────────────────────────────────────────────────────────── +// Editing operations (operate on state only) + +/// Insert character at cursor. Respects widget max_length. +pub fn insert_char( + widget: InputWidget, + state: InputState, + ch: String, +) -> InputState { + case text.cell_width(state.value) >= widget.max_length { + True -> state + False -> { + let before = text.truncate(state.value, state.cursor, "") + let after = string.drop_start(state.value, string.length(before)) + InputState( + value: before <> ch <> after, + cursor: state.cursor + text.cell_width(ch), + ) + } + } +} + +/// Delete the character immediately before the cursor (backspace semantics). +pub fn backspace(state: InputState) -> InputState { + case state.cursor <= 0 { + True -> state + False -> { + let before = text.truncate(state.value, state.cursor - 1, "") + let graphemes_at_cursor = + string.length(text.truncate(state.value, state.cursor, "")) + let after = string.drop_start(state.value, graphemes_at_cursor) + InputState(value: before <> after, cursor: text.cell_width(before)) + } + } +} + +/// Move cursor one cell left, clamped to 0. +pub fn move_cursor_left(state: InputState) -> InputState { + case state.cursor <= 0 { + True -> state + False -> { + let new_cursor = + text.cell_width(text.truncate(state.value, state.cursor - 1, "")) + InputState(..state, cursor: new_cursor) + } + } +} + +/// Move cursor one cell right, clamped to end of value. +pub fn move_cursor_right(state: InputState) -> InputState { + case state.cursor >= text.cell_width(state.value) { + True -> state + False -> { + let step = grapheme_width_at(state.value, state.cursor) + InputState(..state, cursor: state.cursor + step) + } + } +} + +/// Move cursor to beginning of value. +pub fn move_to_start(state: InputState) -> InputState { + InputState(..state, cursor: 0) +} + +/// Move cursor to end of value. +pub fn move_to_end(state: InputState) -> InputState { + InputState(..state, cursor: text.cell_width(state.value)) +} + +/// Delete from cursor to end of value. +pub fn delete_to_end(state: InputState) -> InputState { + let before = text.truncate(state.value, state.cursor, "") + InputState(..state, value: before) +} + +/// Reset value and cursor to empty. +pub fn clear_state(_state: InputState) -> InputState { + InputState(value: "", cursor: 0) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the input field. Shows `state.value` (bold) or placeholder when empty. +/// Text is truncated to fit `area.size.width` (minus one cell reserved for the +/// trailing cursor). When `password` is True the value is masked. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + widget: InputWidget, + state: InputState, +) -> buffer.Buffer { + case area.size.width <= 0 { + True -> buf + False -> { + let has_value = state.value != "" + let value_display = case widget.password && has_value { + True -> string.repeat(widget.mask, text.cell_width(state.value)) + False -> state.value + } + let display_text = case has_value { + True -> widget.prompt <> value_display + False -> widget.prompt <> widget.placeholder + } + let truncated = text.truncate(display_text, area.size.width - 1, "") + let padded = text.pad_right(truncated, area.size.width) + let modifier = case has_value { + True -> style.bold() + False -> style.none() + } + buffer.set_string( + buf, + area.position, + padded, + widget.fg, + widget.bg, + modifier, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn grapheme_width_at(s: String, cell_pos: Int) -> Int { + let prefix = text.truncate(s, cell_pos, "") + let rest = string.drop_start(s, string.length(prefix)) + case string.to_graphemes(rest) { + [g, ..] -> text.cell_width(g) + [] -> 1 + } +} diff --git a/src/etui/widgets/line.gleam b/src/etui/widgets/line.gleam new file mode 100644 index 0000000..1964d06 --- /dev/null +++ b/src/etui/widgets/line.gleam @@ -0,0 +1,96 @@ +/// Line widget: horizontal and vertical dividers. +import etui/buffer +import etui/geometry +import etui/style + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Drawing style for lines. Currently only `Solid` (─ / │). +pub type LineStyle { + Solid +} + +/// Horizontal or vertical divider with optional color. +pub type Line { + Line(style: LineStyle, fg: style.Color) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New solid line with default terminal color. +pub fn line_new() -> Line { + Line(style: Solid, fg: style.Default) +} + +/// Set the line color. +pub fn with_color(l: Line, color: style.Color) -> Line { + Line(..l, fg: color) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render horizontal line. +pub fn render_horizontal( + buf: buffer.Buffer, + area: geometry.Rect, + l: Line, +) -> buffer.Buffer { + case area.size.width <= 0 { + True -> buf + False -> render_horizontal_line(buf, area, l, 0, "─") + } +} + +fn render_horizontal_line( + buf: buffer.Buffer, + area: geometry.Rect, + l: Line, + x: Int, + char: String, +) -> buffer.Buffer { + case x >= area.size.width { + True -> buf + False -> { + let pos = geometry.Position(x: area.position.x + x, y: area.position.y) + let buf_new = + buffer.set_string(buf, pos, char, l.fg, style.Default, style.none()) + render_horizontal_line(buf_new, area, l, x + 1, char) + } + } +} + +/// Render vertical line. +pub fn render_vertical( + buf: buffer.Buffer, + area: geometry.Rect, + l: Line, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> { + let char = "│" + render_vertical_line(buf, area, l, 0, char) + } + } +} + +fn render_vertical_line( + buf: buffer.Buffer, + area: geometry.Rect, + l: Line, + y: Int, + char: String, +) -> buffer.Buffer { + case y >= area.size.height { + True -> buf + False -> { + let pos = geometry.Position(x: area.position.x, y: area.position.y + y) + let buf_new = + buffer.set_string(buf, pos, char, l.fg, style.Default, style.none()) + render_vertical_line(buf_new, area, l, y + 1, char) + } + } +} diff --git a/src/etui/widgets/line_gauge.gleam b/src/etui/widgets/line_gauge.gleam new file mode 100644 index 0000000..f27ba0a --- /dev/null +++ b/src/etui/widgets/line_gauge.gleam @@ -0,0 +1,207 @@ +/// LineGauge: a thin single-row progress indicator using Unicode line characters. +/// +/// Unlike `Gauge` which fills cells with block chars, LineGauge draws a +/// horizontal line with a ratio indicator, minimal and text-friendly. +/// +/// ```gleam +/// line_gauge_new(75) +/// |> with_label("75%") +/// |> with_line_set(line_gauge.ThinLine) +/// |> line_gauge.render(buf, area) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Character set for the gauge line. +pub type LineSet { + /// Thin Unicode line (─ ╴ ╶) + ThinLine + /// Double Unicode line (═ ╸ ╺) + DoubleLine + /// Thick line (━ ╸ ╺) + ThickLine + /// Braille dots (⣿ ⣀) + BrailleLine + /// ASCII fallback (= -) + AsciiLine +} + +/// Line gauge configuration. +pub type LineGauge { + LineGauge( + /// Progress ratio 0–100 (clamped). + percent: Int, + /// Optional text overlaid in the center. + label: String, + /// Line character set. + line_set: LineSet, + /// Foreground color (filled portion and label). + fg: style.Color, + /// Background color (unfilled portion). + bg: style.Color, + /// Modifier applied to the filled portion. + filled_modifier: style.Modifier, + /// Modifier applied to the unfilled portion. + unfilled_modifier: style.Modifier, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New line gauge at the given percent (clamped to 0–100). +pub fn line_gauge_new(percent: Int) -> LineGauge { + LineGauge( + percent: int.clamp(percent, 0, 100), + label: "", + line_set: ThinLine, + fg: style.Default, + bg: style.Default, + filled_modifier: style.none(), + unfilled_modifier: style.dim(), + ) +} + +/// Set a label shown in the center of the gauge. +pub fn with_label(g: LineGauge, label: String) -> LineGauge { + LineGauge(..g, label: label) +} + +/// Set the line character set. +pub fn with_line_set(g: LineGauge, ls: LineSet) -> LineGauge { + LineGauge(..g, line_set: ls) +} + +/// Set foreground and background colors. +pub fn with_colors( + g: LineGauge, + fg: style.Color, + bg: style.Color, +) -> LineGauge { + LineGauge(..g, fg: fg, bg: bg) +} + +/// Set fg/bg from a Style value. +pub fn with_style(g: LineGauge, s: style.Style) -> LineGauge { + LineGauge(..g, fg: s.fg, bg: s.bg) +} + +/// Apply a modifier to the filled portion. +pub fn with_filled_modifier(g: LineGauge, m: style.Modifier) -> LineGauge { + LineGauge(..g, filled_modifier: m) +} + +// ───────────────────────────────────────────────────────────────── +// Line character sets + +type LineChars { + LineChars(filled: String, unfilled: String) +} + +fn line_chars(ls: LineSet) -> LineChars { + case ls { + ThinLine -> LineChars(filled: "─", unfilled: "─") + DoubleLine -> LineChars(filled: "═", unfilled: "═") + ThickLine -> LineChars(filled: "━", unfilled: "─") + BrailleLine -> LineChars(filled: "⣿", unfilled: "⣀") + AsciiLine -> LineChars(filled: "=", unfilled: "-") + } +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the line gauge into the first row of `area`. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + g: LineGauge, +) -> buffer.Buffer { + case area.size.width <= 0 { + True -> buf + False -> { + let width = area.size.width + let filled_w = width * g.percent / 100 + let unfilled_w = width - filled_w + let chars = line_chars(g.line_set) + let filled_str = repeat_char(chars.filled, filled_w) + let unfilled_str = repeat_char(chars.unfilled, unfilled_w) + + // Optionally overlay label in center + let raw_line = filled_str <> unfilled_str + let line = case g.label { + "" -> raw_line + _ -> overlay_label(raw_line, g.label, width) + } + + let pos = area.position + let y = pos.y + + // Write filled portion + let buf = + buffer.set_string( + buf, + geometry.Position(x: pos.x, y: y), + text.truncate(line, filled_w, ""), + g.fg, + g.bg, + g.filled_modifier, + ) + + // Write unfilled portion + buffer.set_string( + buf, + geometry.Position(x: pos.x + filled_w, y: y), + text.truncate(drop_cells(line, filled_w), unfilled_w, ""), + g.fg, + g.bg, + g.unfilled_modifier, + ) + } + } +} + +fn repeat_char(ch: String, n: Int) -> String { + repeat_char_loop(ch, n, "") +} + +fn repeat_char_loop(ch: String, n: Int, acc: String) -> String { + case n <= 0 { + True -> acc + False -> repeat_char_loop(ch, n - 1, acc <> ch) + } +} + +// Overlay label centered on base string (same total cell width). +fn overlay_label(base: String, label: String, width: Int) -> String { + let lw = text.cell_width(label) + case lw >= width { + True -> text.truncate(label, width, "") + False -> { + let left_pad = { width - lw } / 2 + let right_pad = width - lw - left_pad + let left_str = text.truncate(base, left_pad, "") + let right_str = + text.truncate(drop_cells(base, left_pad + lw), right_pad, "") + left_str <> label <> right_str + } + } +} + +// Drop `n` cell-widths from the start of a string (grapheme-accurate). +fn drop_cells(s: String, n: Int) -> String { + case n <= 0 { + True -> s + False -> { + let prefix = text.truncate(s, n, "") + string.drop_start(s, string.length(prefix)) + } + } +} diff --git a/src/etui/widgets/list.gleam b/src/etui/widgets/list.gleam new file mode 100644 index 0000000..88a4e99 --- /dev/null +++ b/src/etui/widgets/list.gleam @@ -0,0 +1,294 @@ +import etui/anim +import etui/buffer +import etui/geometry +import etui/span +import etui/style +import etui/text +import gleam/int +import gleam/list as glist + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Scrollable list of styled items with selection highlight. +pub type ListWidget { + ListWidget( + items: List(span.Line), + fg: style.Color, + bg: style.Color, + highlight_style: style.Style, + /// Blink period in frames (0 = no blink). + blink_period: Int, + ) +} + +/// Scroll and selection state for a list. Kept external so state persists across renders. +pub type ListState { + ListState(selected: Int, offset: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Widget config constructors + +/// New list from plain strings. Default colors, reverse-video selection. +pub fn list_new(items: List(String)) -> ListWidget { + ListWidget( + items: glist.map(items, span.line_plain), + fg: style.Default, + bg: style.Default, + highlight_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + blink_period: 0, + ) +} + +/// New list from styled `span.Line` items. +/// +/// ```gleam +/// list.list_new_styled([ +/// span.line_new([span.span_styled("ERROR", style.bold_style()), span.span_plain(" file")]), +/// span.line_plain("normal item"), +/// ]) +/// ``` +pub fn list_new_styled(items: List(span.Line)) -> ListWidget { + ListWidget( + items: items, + fg: style.Default, + bg: style.Default, + highlight_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + blink_period: 0, + ) +} + +pub fn with_colors( + l: ListWidget, + fg: style.Color, + bg: style.Color, +) -> ListWidget { + ListWidget(..l, fg: fg, bg: bg) +} + +pub fn with_highlight_style(l: ListWidget, s: style.Style) -> ListWidget { + ListWidget(..l, highlight_style: s) +} + +pub fn with_style(l: ListWidget, s: style.Style) -> ListWidget { + ListWidget(..l, fg: s.fg, bg: s.bg) +} + +/// Blink period in frames. 0 = steady (no blink). Use with `render_animated`. +pub fn with_blink(l: ListWidget, period: Int) -> ListWidget { + ListWidget(..l, blink_period: period) +} + +// ───────────────────────────────────────────────────────────────── +// State constructors and navigation + +pub fn state_new() -> ListState { + ListState(selected: 0, offset: 0) +} + +pub fn select(state: ListState, idx: Int) -> ListState { + ListState(..state, selected: int.max(0, idx)) +} + +pub fn select_next(state: ListState, item_count: Int) -> ListState { + let max_idx = int.max(0, item_count - 1) + ListState(..state, selected: int.min(max_idx, state.selected + 1)) +} + +pub fn select_prev(state: ListState) -> ListState { + ListState(..state, selected: int.max(0, state.selected - 1)) +} + +/// Clamp `selected` to `[0, item_count - 1]`. +/// Call after replacing the item list to avoid a stale selection index. +pub fn clamp_state(state: ListState, item_count: Int) -> ListState { + let max = int.max(0, item_count - 1) + ListState(..state, selected: int.min(state.selected, max)) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + l: ListWidget, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> do_render(buf, area, l, -1, 0, 0) + } +} + +pub fn render_stateful( + buf: buffer.Buffer, + area: geometry.Rect, + l: ListWidget, + state: ListState, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> { + let offset = scroll_offset(state.selected, state.offset, area.size.height) + do_render(buf, area, l, state.selected, offset, 0) + } + } +} + +pub fn render_animated( + buf: buffer.Buffer, + area: geometry.Rect, + l: ListWidget, + state: ListState, + frame: Int, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> { + let offset = scroll_offset(state.selected, state.offset, area.size.height) + let show = anim.blink(frame, l.blink_period) + let sel = case show { + True -> state.selected + False -> -1 + } + do_render(buf, area, l, sel, offset, 0) + } + } +} + +fn do_render( + buf: buffer.Buffer, + area: geometry.Rect, + l: ListWidget, + selected: Int, + offset: Int, + y_offset: Int, +) -> buffer.Buffer { + case y_offset >= area.size.height { + True -> buf + False -> { + let item_idx = offset + y_offset + let y = area.position.y + y_offset + let is_selected = item_idx == selected + let #(row_fg, row_bg, row_mod) = case is_selected { + True -> #( + l.highlight_style.fg, + l.highlight_style.bg, + l.highlight_style.modifier, + ) + False -> #(l.fg, l.bg, style.none()) + } + // Draw background row first so unoccupied cells have correct color. + let bg_row = text.pad_right("", area.size.width) + let buf1 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + bg_row, + row_fg, + row_bg, + row_mod, + ) + let buf2 = case get_item_at(l.items, item_idx) { + Error(_) -> buf1 + Ok(line) -> { + // Prefix: "▶ " when selected, " " otherwise. + let prefix = case is_selected { + True -> "▶ " + False -> " " + } + let prefix_w = text.cell_width(prefix) + let buf3 = + buffer.set_string( + buf1, + geometry.Position(x: area.position.x, y: y), + prefix, + row_fg, + row_bg, + row_mod, + ) + // Spans get their own colors; selected highlight comes from bg row. + let effective_line = case is_selected { + False -> line + True -> apply_highlight_to_line(line, l.highlight_style) + } + span.render_line( + buf3, + geometry.Position(x: area.position.x + prefix_w, y: y), + effective_line, + area.size.width - prefix_w, + ) + } + } + do_render(buf2, area, l, selected, offset, y_offset + 1) + } + } +} + +// When a span uses Default fg/bg, substitute highlight colors so the row +// reads as fully highlighted without overriding intentionally-colored spans. +fn apply_highlight_to_line(line: span.Line, hl: style.Style) -> span.Line { + span.line_new( + glist.map(line.spans, fn(sp) { + let new_fg = case sp.fg { + style.Default -> hl.fg + _ -> sp.fg + } + let new_bg = case sp.bg { + style.Default -> hl.bg + _ -> sp.bg + } + let new_mod = case style.modifier_equal(sp.modifier, style.none()) { + True -> hl.modifier + False -> sp.modifier + } + span.Span(..sp, fg: new_fg, bg: new_bg, modifier: new_mod) + }), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Scroll helpers + +pub fn effective_offset(state: ListState, height: Int) -> Int { + scroll_offset(state.selected, state.offset, height) +} + +fn scroll_offset(selected: Int, offset: Int, height: Int) -> Int { + case selected < offset { + True -> selected + False -> + case height <= 0 { + True -> offset + False -> + case selected >= offset + height { + True -> selected - height + 1 + False -> offset + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn get_item_at(items: List(span.Line), idx: Int) -> Result(span.Line, Nil) { + case idx { + i if i < 0 -> Error(Nil) + 0 -> + case items { + [h, ..] -> Ok(h) + [] -> Error(Nil) + } + _ -> get_item_at(glist.drop(items, 1), idx - 1) + } +} diff --git a/src/etui/widgets/marquee.gleam b/src/etui/widgets/marquee.gleam new file mode 100644 index 0000000..a5fd32d --- /dev/null +++ b/src/etui/widgets/marquee.gleam @@ -0,0 +1,102 @@ +/// Marquee: horizontally scrolling text ticker. +/// The text wraps around continuously. `speed` controls how many frames +/// elapse before advancing one character position. +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type Marquee { + Marquee( + text: String, + /// Frames per character advance. Higher = slower scroll. 0 or 1 = fastest. + speed: Int, + /// String appended between repetitions for visual separation. + separator: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn marquee_new(text: String) -> Marquee { + Marquee( + text: text, + speed: 8, + separator: " · ", + fg: style.Default, + bg: style.Default, + modifier: style.none(), + ) +} + +pub fn with_speed(m: Marquee, speed: Int) -> Marquee { + Marquee(..m, speed: int.max(1, speed)) +} + +pub fn with_separator(m: Marquee, sep: String) -> Marquee { + Marquee(..m, separator: sep) +} + +pub fn with_fg(m: Marquee, fg: style.Color) -> Marquee { + Marquee(..m, fg: fg) +} + +pub fn with_style( + m: Marquee, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> Marquee { + Marquee(..m, fg: fg, bg: bg, modifier: modifier) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the scrolling marquee. `frame` drives scroll position. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + m: Marquee, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let unit = m.text <> m.separator + let unit_cells = text.cell_width(unit) + case unit_cells <= 0 { + True -> buf + False -> { + let speed = int.max(1, m.speed) + let offset_cells = frame / speed % unit_cells + // Double the unit so slicing across the wrap is trivial. + let doubled = unit <> unit + // Skip offset_cells cells using grapheme-accurate drop. + let skip_graphemes = + string.length(text.truncate(doubled, offset_cells, "")) + let available = string.drop_start(doubled, skip_graphemes) + let padded = text.pad_right(available, area.size.width) + let line = text.truncate(padded, area.size.width, "") + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: area.position.y), + line, + m.fg, + m.bg, + m.modifier, + ) + } + } + } + } +} diff --git a/src/etui/widgets/multi_select.gleam b/src/etui/widgets/multi_select.gleam new file mode 100644 index 0000000..b06f811 --- /dev/null +++ b/src/etui/widgets/multi_select.gleam @@ -0,0 +1,270 @@ +/// Multi-select list. Each item can be toggled on or off independently. +/// The cursor moves through the list; your update function calls `toggle` +/// to flip the cursor item. `max` caps the total selected (0 = unlimited). +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type MultiSelectWidget { + MultiSelectWidget( + items: List(String), + cursor_style: style.Style, + selected_style: style.Style, + checked_mark: String, + unchecked_mark: String, + cursor_mark: String, + max: Int, + fg: style.Color, + bg: style.Color, + ) +} + +/// `selected` is kept sorted ascending and contains unique indices. +pub type MultiSelectState { + MultiSelectState(cursor: Int, selected: List(Int), offset: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Widget config constructors + +pub fn multi_select_new(items: List(String)) -> MultiSelectWidget { + MultiSelectWidget( + items: items, + cursor_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + selected_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.bold(), + ), + checked_mark: "[x] ", + unchecked_mark: "[ ] ", + cursor_mark: "▶ ", + max: 0, + fg: style.Default, + bg: style.Default, + ) +} + +/// Cap the number of selected items. 0 = unlimited. +pub fn with_max(w: MultiSelectWidget, m: Int) -> MultiSelectWidget { + MultiSelectWidget(..w, max: int.max(0, m)) +} + +pub fn with_marks( + w: MultiSelectWidget, + checked: String, + unchecked: String, +) -> MultiSelectWidget { + MultiSelectWidget(..w, checked_mark: checked, unchecked_mark: unchecked) +} + +pub fn with_cursor_mark(w: MultiSelectWidget, m: String) -> MultiSelectWidget { + MultiSelectWidget(..w, cursor_mark: m) +} + +pub fn with_cursor_style( + w: MultiSelectWidget, + s: style.Style, +) -> MultiSelectWidget { + MultiSelectWidget(..w, cursor_style: s) +} + +pub fn with_selected_style( + w: MultiSelectWidget, + s: style.Style, +) -> MultiSelectWidget { + MultiSelectWidget(..w, selected_style: s) +} + +pub fn with_colors( + w: MultiSelectWidget, + fg: style.Color, + bg: style.Color, +) -> MultiSelectWidget { + MultiSelectWidget(..w, fg: fg, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// State + +pub fn state_new() -> MultiSelectState { + MultiSelectState(cursor: 0, selected: [], offset: 0) +} + +pub fn select_next( + state: MultiSelectState, + item_count: Int, +) -> MultiSelectState { + let max_idx = int.max(0, item_count - 1) + MultiSelectState(..state, cursor: int.min(state.cursor + 1, max_idx)) +} + +pub fn select_prev(state: MultiSelectState) -> MultiSelectState { + MultiSelectState(..state, cursor: int.max(state.cursor - 1, 0)) +} + +/// Toggle the cursor item. Respects `max` from the widget config. +pub fn toggle(state: MultiSelectState, max: Int) -> MultiSelectState { + case list.contains(state.selected, state.cursor) { + True -> + MultiSelectState( + ..state, + selected: list.filter(state.selected, fn(i) { i != state.cursor }), + ) + False -> + case max > 0 && list.length(state.selected) >= max { + True -> state + False -> + MultiSelectState( + ..state, + selected: insert_sorted(state.selected, state.cursor), + ) + } + } +} + +pub fn is_selected(state: MultiSelectState, idx: Int) -> Bool { + list.contains(state.selected, idx) +} + +pub fn selected_indices(state: MultiSelectState) -> List(Int) { + state.selected +} + +/// Pull the selected item strings in original order. +pub fn selected_values( + items: List(String), + state: MultiSelectState, +) -> List(String) { + items + |> list.index_map(fn(item, i) { #(i, item) }) + |> list.filter_map(fn(pair) { + case list.contains(state.selected, pair.0) { + True -> Ok(pair.1) + False -> Error(Nil) + } + }) +} + +pub fn clear_selection(state: MultiSelectState) -> MultiSelectState { + MultiSelectState(..state, selected: []) +} + +/// Effective scroll offset for a viewport of `height` rows. +pub fn effective_offset(state: MultiSelectState, height: Int) -> Int { + scroll_offset(state.cursor, state.offset, height) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + w: MultiSelectWidget, + state: MultiSelectState, +) -> buffer.Buffer { + case area.size.height <= 0 || area.size.width <= 0 { + True -> buf + False -> { + let offset = scroll_offset(state.cursor, state.offset, area.size.height) + render_rows(buf, area, w, state, offset, 0) + } + } +} + +fn render_rows( + buf: buffer.Buffer, + area: geometry.Rect, + w: MultiSelectWidget, + state: MultiSelectState, + offset: Int, + row_off: Int, +) -> buffer.Buffer { + case row_off >= area.size.height { + True -> buf + False -> { + let item_idx = offset + row_off + case list.drop(w.items, item_idx) { + [] -> buf + [item, ..] -> { + let y = area.position.y + row_off + let is_cursor = item_idx == state.cursor + let is_sel = list.contains(state.selected, item_idx) + let prefix = case is_cursor { + True -> w.cursor_mark + False -> string.repeat(" ", text.cell_width(w.cursor_mark)) + } + let mark = case is_sel { + True -> w.checked_mark + False -> w.unchecked_mark + } + let raw = prefix <> mark <> item + let truncated = text.truncate(raw, area.size.width, "") + let padded = text.pad_right(truncated, area.size.width) + let #(fg, bg, modifier) = case is_cursor, is_sel { + True, _ -> #( + w.cursor_style.fg, + w.cursor_style.bg, + w.cursor_style.modifier, + ) + False, True -> #( + w.selected_style.fg, + w.selected_style.bg, + w.selected_style.modifier, + ) + False, False -> #(w.fg, w.bg, style.none()) + } + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + padded, + fg, + bg, + modifier, + ) + render_rows(buf2, area, w, state, offset, row_off + 1) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn scroll_offset(cursor: Int, offset: Int, height: Int) -> Int { + case cursor < offset { + True -> cursor + False -> + case height <= 0 { + True -> offset + False -> + case cursor >= offset + height { + True -> cursor - height + 1 + False -> offset + } + } + } +} + +fn insert_sorted(lst: List(Int), n: Int) -> List(Int) { + case lst { + [] -> [n] + [h, ..] if n < h -> [n, ..lst] + [h, ..] if n == h -> lst + [h, ..t] -> [h, ..insert_sorted(t, n)] + } +} diff --git a/src/etui/widgets/notification.gleam b/src/etui/widgets/notification.gleam new file mode 100644 index 0000000..5f5cdc1 --- /dev/null +++ b/src/etui/widgets/notification.gleam @@ -0,0 +1,271 @@ +/// Toast notification overlay widget. +/// +/// Displays timed notifications stacked in a corner of the screen. +/// Advance time each frame with `tick/1`; expired notifications are removed. +/// +/// ```gleam +/// import etui/widgets/notification as notif +/// +/// // In your model: +/// let queue = notif.queue_new(max: 5) +/// +/// // Push a message (ttl = frames to show): +/// let queue = notif.push(queue, notif.info("File saved", ttl: 60)) +/// +/// // In update (once per frame): +/// let queue = notif.tick(queue) +/// +/// // In render: +/// notif.render(buf, screen_area, queue) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import etui/widgets/block +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Notification severity level. +pub type Level { + Info + Success + Warning + Error +} + +/// A single notification. +pub type Notification { + Notification( + message: String, + level: Level, + /// Remaining ticks before expiry. 0 = expired, -1 = persistent. + ttl: Int, + ) +} + +/// Active notification queue. +pub type NotificationQueue { + NotificationQueue( + items: List(Notification), + /// Maximum simultaneous notifications shown (oldest dropped first). + max: Int, + /// Corner to stack notifications in. + corner: Corner, + ) +} + +/// Which screen corner to render notifications in. +pub type Corner { + TopRight + TopLeft + BottomRight + BottomLeft +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New empty queue. Max = 5, corner = BottomRight. +pub fn queue_new(max max: Int) -> NotificationQueue { + NotificationQueue(items: [], max: max, corner: BottomRight) +} + +/// Set which corner to stack notifications. +pub fn with_corner(q: NotificationQueue, corner: Corner) -> NotificationQueue { + NotificationQueue(..q, corner: corner) +} + +/// Build an Info notification. +pub fn info(message: String, ttl ttl: Int) -> Notification { + Notification(message: message, level: Info, ttl: ttl) +} + +/// Build a Success notification. +pub fn success(message: String, ttl ttl: Int) -> Notification { + Notification(message: message, level: Success, ttl: ttl) +} + +/// Build a Warning notification. +pub fn warning(message: String, ttl ttl: Int) -> Notification { + Notification(message: message, level: Warning, ttl: ttl) +} + +/// Build an Error notification (persistent by default: ttl = -1). +pub fn error(message: String, ttl ttl: Int) -> Notification { + Notification(message: message, level: Error, ttl: ttl) +} + +/// Persistent notification (never auto-expires; dismiss manually). +pub fn persistent(message: String, level: Level) -> Notification { + Notification(message: message, level: level, ttl: -1) +} + +// ───────────────────────────────────────────────────────────────── +// Queue operations + +/// Add a notification. If queue is full, the oldest is dropped. +pub fn push(q: NotificationQueue, n: Notification) -> NotificationQueue { + let items = list.append(q.items, [n]) + let trimmed = case list.length(items) > q.max { + True -> list.drop(items, list.length(items) - q.max) + False -> items + } + NotificationQueue(..q, items: trimmed) +} + +/// Advance time by one tick. Decrements TTL on all non-persistent +/// notifications and removes expired ones (ttl == 0). +pub fn tick(q: NotificationQueue) -> NotificationQueue { + let items = + q.items + |> list.map(fn(n) { + case n.ttl { + -1 -> n + t -> Notification(..n, ttl: t - 1) + } + }) + |> list.filter(fn(n) { n.ttl != 0 }) + NotificationQueue(..q, items: items) +} + +/// Dismiss all notifications matching `level`. +pub fn dismiss_level(q: NotificationQueue, level: Level) -> NotificationQueue { + NotificationQueue( + ..q, + items: list.filter(q.items, fn(n) { n.level != level }), + ) +} + +/// Dismiss all notifications. +pub fn dismiss_all(q: NotificationQueue) -> NotificationQueue { + NotificationQueue(..q, items: []) +} + +/// Dismiss the oldest notification. +pub fn dismiss_first(q: NotificationQueue) -> NotificationQueue { + case q.items { + [] -> q + [_, ..rest] -> NotificationQueue(..q, items: rest) + } +} + +/// True if there are active notifications. +pub fn has_notifications(q: NotificationQueue) -> Bool { + !list.is_empty(q.items) +} + +/// Count of active notifications. +pub fn count(q: NotificationQueue) -> Int { + list.length(q.items) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render all active notifications stacked in the configured corner. +/// Each notification is a single-row bordered box; they stack inward. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + q: NotificationQueue, +) -> buffer.Buffer { + case list.is_empty(q.items) || area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> render_items(buf, area, q.items, q.corner, 0) + } +} + +fn render_items( + buf: buffer.Buffer, + area: geometry.Rect, + items: List(Notification), + corner: Corner, + index: Int, +) -> buffer.Buffer { + case items { + [] -> buf + [n, ..rest] -> { + let box_h = 3 + let msg_w = text.cell_width(n.message) + let box_w = case msg_w + 4 > 30 { + True -> + case msg_w + 4 > area.size.width { + True -> area.size.width + False -> msg_w + 4 + } + False -> 30 + } + + let #(x, y) = case corner { + TopRight -> #( + area.position.x + area.size.width - box_w, + area.position.y + index * box_h, + ) + TopLeft -> #(area.position.x, area.position.y + index * box_h) + BottomRight -> #( + area.position.x + area.size.width - box_w, + area.position.y + area.size.height - box_h - index * box_h, + ) + BottomLeft -> #( + area.position.x, + area.position.y + area.size.height - box_h - index * box_h, + ) + } + + let fits = + x >= area.position.x + && y >= area.position.y + && x + box_w <= area.position.x + area.size.width + && y + box_h <= area.position.y + area.size.height + + let buf2 = case fits { + False -> buf + True -> { + let box_area = + geometry.Rect( + position: geometry.Position(x: x, y: y), + size: geometry.Size(width: box_w, height: box_h), + ) + let #(fg, bg) = level_colors(n.level) + let blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_style(fg, bg) + |> block.with_bg_fill + let buf_b = block.render(buf, box_area, blk) + let inner = block.inner(box_area, blk) + let msg_x = + inner.position.x + + { inner.size.width - text.cell_width(n.message) } + / 2 + case inner.size.width > 0 && inner.size.height > 0 { + False -> buf_b + True -> + buffer.set_string( + buf_b, + geometry.Position(x: msg_x, y: inner.position.y), + text.truncate(n.message, inner.size.width, "…"), + fg, + bg, + style.none(), + ) + } + } + } + + render_items(buf2, area, rest, corner, index + 1) + } + } +} + +fn level_colors(level: Level) -> #(style.Color, style.Color) { + case level { + Info -> #(style.Indexed(15), style.Indexed(4)) + Success -> #(style.Indexed(15), style.Indexed(2)) + Warning -> #(style.Indexed(0), style.Indexed(3)) + Error -> #(style.Indexed(15), style.Indexed(1)) + } +} diff --git a/src/etui/widgets/paginator.gleam b/src/etui/widgets/paginator.gleam new file mode 100644 index 0000000..f0ecf36 --- /dev/null +++ b/src/etui/widgets/paginator.gleam @@ -0,0 +1,146 @@ +/// Page indicator with dots (●○○○○) or arabic (2/5) modes. +/// Tracks the current page and offers `slice/2` to pull the current page +/// from a flat list of items. +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type PaginatorStyle { + Dots + Arabic +} + +pub type Paginator { + Paginator( + current: Int, + total: Int, + page_size: Int, + style: PaginatorStyle, + active_char: String, + inactive_char: String, + fg: style.Color, + bg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New paginator. `total` is clamped to at least 1. Starts on page 0. +pub fn paginator_new(total: Int) -> Paginator { + Paginator( + current: 0, + total: int.max(1, total), + page_size: 10, + style: Dots, + active_char: "●", + inactive_char: "○", + fg: style.Default, + bg: style.Default, + ) +} + +/// Items per page (used by `slice/2`). Default 10. +pub fn with_page_size(p: Paginator, n: Int) -> Paginator { + Paginator(..p, page_size: int.max(1, n)) +} + +pub fn with_style(p: Paginator, st: PaginatorStyle) -> Paginator { + Paginator(..p, style: st) +} + +pub fn with_chars(p: Paginator, active: String, inactive: String) -> Paginator { + Paginator(..p, active_char: active, inactive_char: inactive) +} + +pub fn with_colors( + p: Paginator, + fg: style.Color, + bg: style.Color, +) -> Paginator { + Paginator(..p, fg: fg, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Navigation + +/// Next page, clamped to last. +pub fn next_page(p: Paginator) -> Paginator { + Paginator(..p, current: int.min(p.current + 1, p.total - 1)) +} + +/// Previous page, clamped to 0. +pub fn prev_page(p: Paginator) -> Paginator { + Paginator(..p, current: int.max(p.current - 1, 0)) +} + +/// Jump to a specific page, clamped to `[0, total - 1]`. +pub fn go_to(p: Paginator, page: Int) -> Paginator { + Paginator(..p, current: int.clamp(page, 0, p.total - 1)) +} + +/// Recompute `total` from an item count and the current `page_size`. +/// Clamps `current` so it stays in range. +pub fn set_item_count(p: Paginator, items: Int) -> Paginator { + let n = int.max(0, items) + let total = int.max(1, { n + p.page_size - 1 } / p.page_size) + let current = int.min(p.current, total - 1) + Paginator(..p, total: total, current: current) +} + +// ───────────────────────────────────────────────────────────────── +// Slice helper + +/// Pull the items belonging to the current page. +pub fn slice(items: List(a), p: Paginator) -> List(a) { + items + |> list.drop(p.current * p.page_size) + |> list.take(p.page_size) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + p: Paginator, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let txt = case p.style { + Dots -> dots_text(p) + Arabic -> int.to_string(p.current + 1) <> "/" <> int.to_string(p.total) + } + let txt_w = text.cell_width(txt) + let x_off = int.max(0, { area.size.width - txt_w } / 2) + buffer.set_string( + buf, + geometry.Position(x: area.position.x + x_off, y: area.position.y), + text.truncate(txt, area.size.width, ""), + p.fg, + p.bg, + style.none(), + ) + } + } +} + +fn dots_text(p: Paginator) -> String { + list.repeat(Nil, p.total) + |> list.index_map(fn(_, i) { + case i == p.current { + True -> p.active_char + False -> p.inactive_char + } + }) + |> string.join(" ") +} diff --git a/src/etui/widgets/paragraph.gleam b/src/etui/widgets/paragraph.gleam new file mode 100644 index 0000000..aee3b0c --- /dev/null +++ b/src/etui/widgets/paragraph.gleam @@ -0,0 +1,160 @@ +/// Paragraph widget: text wrapping, alignment, styling. +/// Also supports `span.Line` for inline mixed-style text via `paragraph_new_lines`. +import etui/buffer +import etui/geometry +import etui/span +import etui/style +import etui/text.{type Alignment, Left} + +/// Word-wrapping text block with alignment and styling. +pub type Paragraph { + Paragraph( + text: String, + alignment: Alignment, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New paragraph with left-aligned text and default colors. +pub fn paragraph_new(text: String) -> Paragraph { + Paragraph( + text: text, + alignment: Left, + fg: style.Default, + bg: style.Default, + modifier: style.none(), + ) +} + +/// Set text alignment (Left, Center, Right). +pub fn with_alignment(p: Paragraph, alignment: Alignment) -> Paragraph { + Paragraph(..p, alignment: alignment) +} + +/// Apply a style (colors + modifier) to the paragraph text. +pub fn with_style(p: Paragraph, s: style.Style) -> Paragraph { + Paragraph(..p, fg: s.fg, bg: s.bg, modifier: s.modifier) +} + +// ───────────────────────────────────────────────────────────────── +// Span-aware variant + +/// Paragraph backed by styled span lines rather than a plain string. +/// Use `paragraph_new_lines` to construct, `render_lines_styled` to render. +pub type SpanParagraph { + SpanParagraph(lines: List(span.Line)) +} + +/// Build a `SpanParagraph` from a list of `span.Line` values. +/// +/// ```gleam +/// paragraph.paragraph_new_lines([ +/// span.line_new([span.span_plain("normal "), span.span_styled("bold", style.bold_style())]), +/// span.line_plain("second line"), +/// ]) +/// |> paragraph.render_lines_styled(buf, area, _) +/// ``` +pub fn paragraph_new_lines(lines: List(span.Line)) -> SpanParagraph { + SpanParagraph(lines: lines) +} + +/// Render a `SpanParagraph` into `area`. Lines beyond area height are clipped. +pub fn render_lines_styled( + buf: buffer.Buffer, + area: geometry.Rect, + para: SpanParagraph, +) -> buffer.Buffer { + render_styled(buf, area, para.lines) +} + +/// Render a list of `span.Line` values, one per row, into `area`. +/// Each `Line` is drawn with per-span styles. Lines beyond area height +/// are clipped; the list may be shorter than the area (remaining rows unchanged). +pub fn render_styled( + buf: buffer.Buffer, + area: geometry.Rect, + lines: List(span.Line), +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> render_span_rows(buf, area, lines, 0) + } +} + +fn render_span_rows( + buf: buffer.Buffer, + area: geometry.Rect, + lines: List(span.Line), + row: Int, +) -> buffer.Buffer { + case lines { + [] -> buf + [line, ..rest] -> + case row >= area.size.height { + True -> buf + False -> { + let pos = + geometry.Position(x: area.position.x, y: area.position.y + row) + let buf2 = span.render_line(buf, pos, line, area.size.width) + render_span_rows(buf2, area, rest, row + 1) + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render paragraph into buffer at `area`. Word-wraps to area width. +/// Rows beyond area height are clipped. Short lines are padded to area width. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + para: Paragraph, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + // Wrap text to area width + let lines = text.wrap(para.text, area.size.width) + // Render lines up to area height + render_lines(buf, area, para, lines, 0) + } + } +} + +fn render_lines( + buf: buffer.Buffer, + area: geometry.Rect, + para: Paragraph, + lines: List(String), + line_idx: Int, +) -> buffer.Buffer { + case lines { + [] -> buf + [line, ..rest] -> { + case line_idx >= area.size.height { + True -> buf + False -> { + let y = area.position.y + line_idx + let aligned_line = text.align(line, area.size.width, para.alignment) + let buf_new = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + aligned_line, + para.fg, + para.bg, + para.modifier, + ) + render_lines(buf_new, area, para, rest, line_idx + 1) + } + } + } + } +} diff --git a/src/etui/widgets/popup.gleam b/src/etui/widgets/popup.gleam new file mode 100644 index 0000000..4f1139f --- /dev/null +++ b/src/etui/widgets/popup.gleam @@ -0,0 +1,123 @@ +/// Centered modal popup overlay widget. +/// +/// Computes a centered `Rect` and renders a block with an optional title. +/// Use `popup_area` to get the inner content rect, then render child widgets into it. +/// +/// Example: +/// ```gleam +/// let pop = popup.popup_new(40, 10) |> popup.with_title("Confirm") +/// let area = popup.popup_area(screen, pop) +/// popup.render(buf, screen, pop) +/// |> paragraph.render(area, content_para) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/widgets/block + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Popup configuration. Width/height are in terminal cells. +pub type Popup { + Popup( + width: Int, + height: Int, + title: String, + border: block.Border, + fg: style.Color, + bg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New popup with given cell dimensions. Default: Rounded border, default colors. +pub fn popup_new(width: Int, height: Int) -> Popup { + Popup( + width: width, + height: height, + title: "", + border: block.Rounded, + fg: style.Default, + bg: style.Default, + ) +} + +/// Set the popup title (shown on top border). +pub fn with_title(p: Popup, title: String) -> Popup { + Popup(..p, title: title) +} + +/// Set the border style. +pub fn with_border(p: Popup, border: block.Border) -> Popup { + Popup(..p, border: border) +} + +/// Set foreground and background colors. +pub fn with_style(p: Popup, fg: style.Color, bg: style.Color) -> Popup { + Popup(..p, fg: fg, bg: bg) +} + +pub fn with_colors(p: Popup, fg: style.Color, bg: style.Color) -> Popup { + Popup(..p, fg: fg, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Layout helpers + +/// Centered rect for this popup within `screen`. +pub fn popup_rect(screen: geometry.Rect, p: Popup) -> geometry.Rect { + let w = int_clamp(p.width, 0, screen.size.width) + let h = int_clamp(p.height, 0, screen.size.height) + let x = screen.position.x + { screen.size.width - w } / 2 + let y = screen.position.y + { screen.size.height - h } / 2 + geometry.Rect( + position: geometry.Position(x: x, y: y), + size: geometry.Size(width: w, height: h), + ) +} + +/// Inner content area (inside border and padding) for child widgets. +pub fn popup_area(screen: geometry.Rect, p: Popup) -> geometry.Rect { + let outer = popup_rect(screen, p) + let blk = to_block(p) + block.inner(outer, blk) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render popup overlay. Draw child widgets into `popup_area` after this. +pub fn render( + buf: buffer.Buffer, + screen: geometry.Rect, + p: Popup, +) -> buffer.Buffer { + let outer = popup_rect(screen, p) + let blk = to_block(p) + block.render(buf, outer, blk) +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn to_block(p: Popup) -> block.Block { + block.block_new() + |> block.with_border(p.border) + |> block.with_title(p.title, block.Top) + |> block.with_style(p.fg, p.bg) + |> block.with_bg_fill +} + +fn int_clamp(v: Int, lo: Int, hi: Int) -> Int { + case v < lo { + True -> lo + False -> + case v > hi { + True -> hi + False -> v + } + } +} diff --git a/src/etui/widgets/progress.gleam b/src/etui/widgets/progress.gleam new file mode 100644 index 0000000..ca36de3 --- /dev/null +++ b/src/etui/widgets/progress.gleam @@ -0,0 +1,248 @@ +/// Animated progress bar widget. +/// Determinate: fill from 0..100%. Indeterminate: bouncing segment. +import etui/anim +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type ProgressMode { + Determinate(percent: Int) + Indeterminate +} + +pub type ProgressBar { + ProgressBar( + mode: ProgressMode, + label: String, + filled_char: String, + empty_char: String, + /// Indeterminate segment size as a percentage of bar width (1–100). + segment_width: Int, + fg: style.Color, + bg: style.Color, + filled_modifier: style.Modifier, + empty_modifier: style.Modifier, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn progress_new(percent: Int) -> ProgressBar { + ProgressBar( + mode: Determinate(int.clamp(percent, 0, 100)), + label: "", + filled_char: "█", + empty_char: "░", + segment_width: 25, + fg: style.Default, + bg: style.Default, + filled_modifier: style.none(), + empty_modifier: style.none(), + ) +} + +pub fn progress_indeterminate() -> ProgressBar { + ProgressBar( + mode: Indeterminate, + label: "", + filled_char: "█", + empty_char: "░", + segment_width: 25, + fg: style.Default, + bg: style.Default, + filled_modifier: style.none(), + empty_modifier: style.none(), + ) +} + +pub fn with_label(p: ProgressBar, label: String) -> ProgressBar { + ProgressBar(..p, label: label) +} + +pub fn with_chars( + p: ProgressBar, + filled: String, + empty: String, +) -> ProgressBar { + ProgressBar(..p, filled_char: filled, empty_char: empty) +} + +/// Indeterminate segment size as a percentage of bar width (1–100). +pub fn with_segment_width(p: ProgressBar, pct: Int) -> ProgressBar { + ProgressBar(..p, segment_width: int.clamp(pct, 1, 100)) +} + +pub fn with_colors( + p: ProgressBar, + fg: style.Color, + bg: style.Color, +) -> ProgressBar { + ProgressBar(..p, fg: fg, bg: bg) +} + +pub fn with_style(p: ProgressBar, s: style.Style) -> ProgressBar { + ProgressBar(..p, fg: s.fg, bg: s.bg) +} + +pub fn with_filled_modifier(p: ProgressBar, m: style.Modifier) -> ProgressBar { + ProgressBar(..p, filled_modifier: m) +} + +pub fn with_empty_modifier(p: ProgressBar, m: style.Modifier) -> ProgressBar { + ProgressBar(..p, empty_modifier: m) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering +// +// `frame` is required even for Determinate bars so the API is uniform. +// Determinate bars ignore the frame; Indeterminate bars use it to +// animate the bouncing segment. + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + p: ProgressBar, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> + case p.mode { + Determinate(pct) -> render_determinate(buf, area, p, pct) + Indeterminate -> render_indeterminate(buf, area, p, frame) + } + } +} + +fn render_determinate( + buf: buffer.Buffer, + area: geometry.Rect, + p: ProgressBar, + pct: Int, +) -> buffer.Buffer { + let width = area.size.width + let filled = int.clamp(width * pct / 100, 0, width) + let empty = width - filled + let buf1 = + fill_cells( + buf, + area.position, + filled, + p.filled_char, + p.fg, + p.bg, + p.filled_modifier, + ) + let buf2 = + fill_cells( + buf1, + geometry.Position(x: area.position.x + filled, y: area.position.y), + empty, + p.empty_char, + p.fg, + p.bg, + p.empty_modifier, + ) + case p.label { + "" -> buf2 + label -> { + let lw = text.cell_width(label) + let lx = area.position.x + int.max(0, { width - lw } / 2) + buffer.set_string( + buf2, + geometry.Position(x: lx, y: area.position.y), + text.truncate(label, width, ""), + p.fg, + p.bg, + style.none(), + ) + } + } +} + +fn render_indeterminate( + buf: buffer.Buffer, + area: geometry.Rect, + p: ProgressBar, + frame: Int, +) -> buffer.Buffer { + let width = area.size.width + let seg = int.max(1, width * p.segment_width / 100) + let max_start = int.max(0, width - seg) + // period = full bounce: 0 → max_start → 0 + let period = int.max(1, { max_start + seg } * 2) + let seg_start = anim.oscillate(0, max_start, frame, period) + // Fill bar with empty chars, then overlay segment + let buf1 = + fill_cells( + buf, + area.position, + width, + p.empty_char, + p.fg, + p.bg, + p.empty_modifier, + ) + fill_cells( + buf1, + geometry.Position(x: area.position.x + seg_start, y: area.position.y), + seg, + p.filled_char, + p.fg, + p.bg, + p.filled_modifier, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn fill_cells( + buf: buffer.Buffer, + pos: geometry.Position, + count: Int, + char: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> buffer.Buffer { + do_fill(buf, pos, count, 0, char, fg, bg, modifier) +} + +fn do_fill( + buf: buffer.Buffer, + start: geometry.Position, + count: Int, + i: Int, + char: String, + fg: style.Color, + bg: style.Color, + modifier: style.Modifier, +) -> buffer.Buffer { + case i >= count { + True -> buf + False -> { + let pos = geometry.Position(x: start.x + i, y: start.y) + let buf_new = + buffer.set_cell( + buf, + pos, + buffer.Cell( + content: buffer.Content(symbol: char, width: 1), + fg: fg, + bg: bg, + modifier: modifier, + link: "", + ), + ) + do_fill(buf_new, start, count, i + 1, char, fg, bg, modifier) + } + } +} diff --git a/src/etui/widgets/scene.gleam b/src/etui/widgets/scene.gleam new file mode 100644 index 0000000..61ea757 --- /dev/null +++ b/src/etui/widgets/scene.gleam @@ -0,0 +1,421 @@ +/// 2D geometric scene widget with braille canvas rendering. +/// Supports circle outlines, animated planet orbits, and Mandelbrot fractal. +/// Pixel resolution: area.width*2 × area.height*4 (2×4 dot-grid per cell). +import etui/braille +import etui/buffer +import etui/color +import etui/geometry +import etui/style +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type SceneFill { + SceneSolid(c: style.Color) + SceneGradient(stops: List(style.Color)) + SceneRainbow + SceneAnimatedRainbow +} + +pub type Shape { + /// Circle outline at braille-pixel coords (cx, cy) with radius r. + CircleOutline(cx: Int, cy: Int, r: Int, fill: SceneFill) + /// Filled disc at braille-pixel coords (cx, cy) with radius r. + Disc(cx: Int, cy: Int, r: Int, fill: SceneFill) + /// Planet: disc orbiting (cx, cy) at orbit_r, animated by frame/period. + Planet( + cx: Int, + cy: Int, + orbit_r: Int, + dot_r: Int, + fill: SceneFill, + period: Int, + ) + /// Mandelbrot set (fills entire canvas area). + Mandelbrot(max_iter: Int) +} + +pub type Scene { + Scene(shapes: List(Shape), bg: style.Color) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn scene_new(shapes: List(Shape)) -> Scene { + Scene(shapes: shapes, bg: style.Default) +} + +pub fn with_bg(s: Scene, bg: style.Color) -> Scene { + Scene(..s, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render scene into `area`. `frame` drives animated shapes. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + scene: Scene, + 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 pixels = + list.fold(scene.shapes, braille.new(), fn(px_dict, shape) { + render_shape(px_dict, shape, pw, ph, frame) + }) + braille.flush(buf, area, pixels, scene.bg) + } + } +} + +fn render_shape( + pixels: braille.Pixels, + shape: Shape, + pw: Int, + ph: Int, + frame: Int, +) -> braille.Pixels { + case shape { + CircleOutline(cx, cy, r, fill) -> + draw_circle_outline(pixels, cx, cy, r, fill, pw, ph, frame, 60) + Disc(cx, cy, r, fill) -> + draw_disc(pixels, cx, cy, r, fill, pw, ph, frame, 60) + Planet(cx, cy, orbit_r, dot_r, fill, period) -> + draw_planet(pixels, cx, cy, orbit_r, dot_r, fill, pw, ph, frame, period) + Mandelbrot(max_iter) -> draw_mandelbrot(pixels, pw, ph, max_iter) + } +} + +// ───────────────────────────────────────────────────────────────── +// Circle outline, Bresenham midpoint algorithm + +fn draw_circle_outline( + pixels: braille.Pixels, + cx: Int, + cy: Int, + r: Int, + fill: SceneFill, + pw: Int, + ph: Int, + frame: Int, + period: Int, +) -> braille.Pixels { + let pts = circle_outline_pts(cx, cy, r) + let n = list.length(pts) + list.index_fold(pts, pixels, fn(px_dict, pt, i) { + let #(bx, by) = pt + case bx >= 0 && by >= 0 && bx < pw && by < ph { + False -> px_dict + True -> { + let fg = scene_color(fill, i, n, frame, period) + braille.put(px_dict, bx, by, fg) + } + } + }) +} + +fn circle_outline_pts(cx: Int, cy: Int, r: Int) -> List(#(Int, Int)) { + case r <= 0 { + True -> [#(cx, cy)] + False -> midpoint_loop(cx, cy, 0, r, 1 - r, []) + } +} + +fn midpoint_loop( + cx: Int, + cy: Int, + y: Int, + x: Int, + d: Int, + acc: List(#(Int, Int)), +) -> List(#(Int, Int)) { + case y > x { + True -> acc + False -> { + let pts = [ + #(cx + x, cy + y), + #(cx - x, cy + y), + #(cx + x, cy - y), + #(cx - x, cy - y), + #(cx + y, cy + x), + #(cx - y, cy + x), + #(cx + y, cy - x), + #(cx - y, cy - x), + ] + let acc = list.append(acc, pts) + let y = y + 1 + let #(x, d) = case d < 0 { + True -> #(x, d + 2 * y + 1) + False -> #(x - 1, d + 2 * { y - x } + 1) + } + midpoint_loop(cx, cy, y, x, d, acc) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Filled disc + +fn draw_disc( + pixels: braille.Pixels, + cx: Int, + cy: Int, + r: Int, + fill: SceneFill, + pw: Int, + ph: Int, + frame: Int, + period: Int, +) -> braille.Pixels { + disc_dx_loop(pixels, cx, cy, r, fill, pw, ph, frame, period, 0 - r) +} + +fn disc_dx_loop( + pixels: braille.Pixels, + cx: Int, + cy: Int, + r: Int, + fill: SceneFill, + pw: Int, + ph: Int, + frame: Int, + period: Int, + dx: Int, +) -> braille.Pixels { + case dx > r { + True -> pixels + False -> { + let pixels = + disc_dy_loop(pixels, cx, cy, r, fill, pw, ph, frame, period, dx, 0 - r) + disc_dx_loop(pixels, cx, cy, r, fill, pw, ph, frame, period, dx + 1) + } + } +} + +fn disc_dy_loop( + pixels: braille.Pixels, + cx: Int, + cy: Int, + r: Int, + fill: SceneFill, + pw: Int, + ph: Int, + frame: Int, + period: Int, + dx: Int, + dy: Int, +) -> braille.Pixels { + case dy > r { + True -> pixels + False -> { + let pixels = case dx * dx + dy * dy <= r * r { + False -> pixels + True -> { + let bx = cx + dx + let by = cy + dy + case bx >= 0 && by >= 0 && bx < pw && by < ph { + False -> pixels + True -> { + let fg = scene_color(fill, bx, pw, frame, period) + braille.put(pixels, bx, by, fg) + } + } + } + } + disc_dy_loop(pixels, cx, cy, r, fill, pw, ph, frame, period, dx, dy + 1) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Planet orbit + +fn draw_planet( + pixels: braille.Pixels, + cx: Int, + cy: Int, + orbit_r: Int, + dot_r: Int, + fill: SceneFill, + pw: Int, + ph: Int, + frame: Int, + period: Int, +) -> braille.Pixels { + let #(px, py) = orbit_position(cx, cy, orbit_r, frame, period) + draw_disc(pixels, px, py, dot_r, fill, pw, ph, frame, period) +} + +/// 32-step integer orbit using precomputed cos/sin × 256. +fn orbit_position( + cx: Int, + cy: Int, + r: Int, + frame: Int, + period: Int, +) -> #(Int, Int) { + let step = { frame * 32 / int.max(1, period) } % 32 + let #(c256, s256) = cos_sin_256(step) + #(cx + r * c256 / 256, cy + r * s256 / 256) +} + +fn cos_sin_256(step: Int) -> #(Int, Int) { + case step % 32 { + 0 -> #(256, 0) + 1 -> #(251, 50) + 2 -> #(236, 98) + 3 -> #(213, 142) + 4 -> #(181, 181) + 5 -> #(142, 213) + 6 -> #(98, 236) + 7 -> #(50, 251) + 8 -> #(0, 256) + 9 -> #(-50, 251) + 10 -> #(-98, 236) + 11 -> #(-142, 213) + 12 -> #(-181, 181) + 13 -> #(-213, 142) + 14 -> #(-236, 98) + 15 -> #(-251, 50) + 16 -> #(-256, 0) + 17 -> #(-251, -50) + 18 -> #(-236, -98) + 19 -> #(-213, -142) + 20 -> #(-181, -181) + 21 -> #(-142, -213) + 22 -> #(-98, -236) + 23 -> #(-50, -251) + 24 -> #(0, -256) + 25 -> #(50, -251) + 26 -> #(98, -236) + 27 -> #(142, -213) + 28 -> #(181, -181) + 29 -> #(213, -142) + 30 -> #(236, -98) + _ -> #(251, -50) + } +} + +// ───────────────────────────────────────────────────────────────── +// Mandelbrot fractal, fixed-point scale 1024 + +fn draw_mandelbrot( + pixels: braille.Pixels, + pw: Int, + ph: Int, + max_iter: Int, +) -> braille.Pixels { + // Map braille pixel space onto complex plane: + // x ∈ [0, pw-1] → real ∈ [-2.5, 1.0] (range 3.5 → fixed: -2560..1024 / 1024) + // y ∈ [0, ph-1] → imag ∈ [1.25, -1.25] (range 2.5, y inverted) + let pw1 = int.max(1, pw - 1) + let ph1 = int.max(1, ph - 1) + mandelbrot_rows(pixels, pw, ph, pw1, ph1, max_iter, 0) +} + +fn mandelbrot_rows( + pixels: braille.Pixels, + pw: Int, + ph: Int, + pw1: Int, + ph1: Int, + max_iter: Int, + by: Int, +) -> braille.Pixels { + case by >= ph { + True -> pixels + False -> { + // ci: imag part in fixed-point × 1024. y=0 → top → +1.25 → 1280 + let ci = 1280 - by * 2560 / ph1 + let pixels = mandelbrot_cols(pixels, pw, pw1, max_iter, by, ci, 0) + mandelbrot_rows(pixels, pw, ph, pw1, ph1, max_iter, by + 1) + } + } +} + +fn mandelbrot_cols( + pixels: braille.Pixels, + pw: Int, + pw1: Int, + max_iter: Int, + by: Int, + ci: Int, + bx: Int, +) -> braille.Pixels { + case bx >= pw { + True -> pixels + False -> { + // cr: real part in fixed-point × 1024. x=0 → -2.5 → -2560 + let cr = -2560 + bx * 3584 / pw1 + let iter = mandelbrot_iter(cr, ci, 0, 0, 0, max_iter) + let pixels = case iter >= max_iter { + True -> pixels + False -> { + let hue = iter * 300 / max_iter + let fg = color.hue_to_rgb(hue) + braille.put(pixels, bx, by, fg) + } + } + mandelbrot_cols(pixels, pw, pw1, max_iter, by, ci, bx + 1) + } + } +} + +fn mandelbrot_iter( + cr: Int, + ci: Int, + zr: Int, + zi: Int, + iter: Int, + max_iter: Int, +) -> Int { + case iter >= max_iter { + True -> iter + False -> { + // Fixed-point: zr2 = zr^2/1024, escape when zr2+zi2 > 4096 (=4×1024) + let zr2 = zr * zr / 1024 + let zi2 = zi * zi / 1024 + case zr2 + zi2 > 4096 { + True -> iter + False -> + mandelbrot_iter( + cr, + ci, + zr2 - zi2 + cr, + 2 * zr * zi / 1024 + ci, + iter + 1, + max_iter, + ) + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Color dispatch + +fn scene_color( + fill: SceneFill, + px: Int, + pw: Int, + frame: Int, + period: Int, +) -> style.Color { + let p = int.max(1, period) + let w = int.max(1, pw) + case fill { + SceneSolid(c) -> c + SceneGradient(stops) -> color.gradient(stops, px, w - 1) + SceneRainbow -> color.hue_to_rgb(px * 360 / w) + SceneAnimatedRainbow -> + color.hue_to_rgb({ px * 360 / w + frame * 360 / p } % 360) + } +} diff --git a/src/etui/widgets/scroll_view.gleam b/src/etui/widgets/scroll_view.gleam new file mode 100644 index 0000000..a9dcb07 --- /dev/null +++ b/src/etui/widgets/scroll_view.gleam @@ -0,0 +1,198 @@ +/// ScrollView widget: render any content into a virtual canvas larger than the +/// visible area, then show a viewport into it. +/// +/// Unlike the `list` or `table` widgets which handle scrolling internally, +/// `ScrollView` works with any widget. The inner widget renders into a buffer +/// sized to `virtual_width × virtual_height`; the scroll view then copies the +/// visible window into the target buffer. +/// +/// Use the `scrollbar` widget alongside the scroll view for visual scroll +/// indicators. +/// +/// ```gleam +/// let sv = scroll_view_new(200, 50) +/// let sv_state = sv_state_new() +/// +/// // Render a paragraph into the virtual canvas: +/// scroll_view.render(buf, area, sv, sv_state, fn(inner_buf, inner_area) { +/// paragraph.render(inner_buf, inner_area, para) +/// }) +/// ``` +import etui/buffer +import etui/geometry +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type ScrollView { + ScrollView(virtual_width: Int, virtual_height: Int) +} + +pub type ScrollViewState { + ScrollViewState(scroll_x: Int, scroll_y: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn scroll_view_new(virtual_width: Int, virtual_height: Int) -> ScrollView { + ScrollView( + virtual_width: int.max(1, virtual_width), + virtual_height: int.max(1, virtual_height), + ) +} + +pub fn sv_state_new() -> ScrollViewState { + ScrollViewState(scroll_x: 0, scroll_y: 0) +} + +pub fn scroll_to(_state: ScrollViewState, x: Int, y: Int) -> ScrollViewState { + ScrollViewState(scroll_x: int.max(0, x), scroll_y: int.max(0, y)) +} + +pub fn scroll_down(state: ScrollViewState, lines: Int) -> ScrollViewState { + ScrollViewState(..state, scroll_y: state.scroll_y + int.max(0, lines)) +} + +pub fn scroll_up(state: ScrollViewState, lines: Int) -> ScrollViewState { + ScrollViewState(..state, scroll_y: int.max(0, state.scroll_y - lines)) +} + +pub fn scroll_right(state: ScrollViewState, cols: Int) -> ScrollViewState { + ScrollViewState(..state, scroll_x: state.scroll_x + int.max(0, cols)) +} + +pub fn scroll_left(state: ScrollViewState, cols: Int) -> ScrollViewState { + ScrollViewState(..state, scroll_x: int.max(0, state.scroll_x - cols)) +} + +/// Clamp scroll offsets so the viewport never goes past the virtual canvas. +pub fn clamp( + state: ScrollViewState, + sv: ScrollView, + visible_w: Int, + visible_h: Int, +) -> ScrollViewState { + let max_x = int.max(0, sv.virtual_width - visible_w) + let max_y = int.max(0, sv.virtual_height - visible_h) + ScrollViewState( + scroll_x: int.min(state.scroll_x, max_x), + scroll_y: int.min(state.scroll_y, max_y), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the scroll view. +/// +/// `render_inner` is called with a virtual buffer sized to +/// `(sv.virtual_width × sv.virtual_height)`. The visible window at +/// `(state.scroll_x, state.scroll_y)` is then blitted into `buf` at `area`. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + sv: ScrollView, + state: ScrollViewState, + render_inner: fn(buffer.Buffer, geometry.Rect) -> buffer.Buffer, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let virtual_area = + geometry.rect_new(0, 0, sv.virtual_width, sv.virtual_height) + let virtual_buf = + buffer.buffer_new(virtual_area) + |> render_inner(virtual_area) + + let vis_w = area.size.width + let vis_h = area.size.height + let ox = int.max(0, state.scroll_x) + let oy = int.max(0, state.scroll_y) + + blit(buf, virtual_buf, area, ox, oy, vis_w, vis_h, 0) + } + } +} + +// Copy cells from virtual_buf at (ox+col, oy+row) into buf at (area.x+col, area.y+row). +fn blit( + buf: buffer.Buffer, + virtual_buf: buffer.Buffer, + area: geometry.Rect, + ox: Int, + oy: Int, + vis_w: Int, + vis_h: Int, + row: Int, +) -> buffer.Buffer { + case row >= vis_h { + True -> buf + False -> + blit( + blit_row(buf, virtual_buf, area, ox, oy, vis_w, row, 0), + virtual_buf, + area, + ox, + oy, + vis_w, + vis_h, + row + 1, + ) + } +} + +fn blit_row( + buf: buffer.Buffer, + virtual_buf: buffer.Buffer, + area: geometry.Rect, + ox: Int, + oy: Int, + vis_w: Int, + row: Int, + col: Int, +) -> buffer.Buffer { + case col >= vis_w { + True -> buf + False -> { + let src_pos = geometry.Position(x: ox + col, y: oy + row) + let dst_pos = + geometry.Position(x: area.position.x + col, y: area.position.y + row) + let cell = buffer.get_cell(virtual_buf, src_pos) + blit_row( + buffer.set_cell(buf, dst_pos, cell), + virtual_buf, + area, + ox, + oy, + vis_w, + row, + col + 1, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Scroll position helpers + +/// How far into the virtual canvas is the viewport (0.0–1.0 × 100). +/// Returns an integer percentage (0–100). Useful for driving scrollbar widgets. +pub fn scroll_pct_y( + state: ScrollViewState, + sv: ScrollView, + visible_h: Int, +) -> Int { + let max_scroll = int.max(1, sv.virtual_height - visible_h) + int.min(100, state.scroll_y * 100 / max_scroll) +} + +pub fn scroll_pct_x( + state: ScrollViewState, + sv: ScrollView, + visible_w: Int, +) -> Int { + let max_scroll = int.max(1, sv.virtual_width - visible_w) + int.min(100, state.scroll_x * 100 / max_scroll) +} diff --git a/src/etui/widgets/scrollbar.gleam b/src/etui/widgets/scrollbar.gleam new file mode 100644 index 0000000..447742b --- /dev/null +++ b/src/etui/widgets/scrollbar.gleam @@ -0,0 +1,327 @@ +/// Scrollbar widget: vertical or horizontal scroll indicator. +/// +/// Renders a track with a thumb that reflects current scroll position. +/// Does not manage state, derive `offset` and `visible` from the widget +/// that owns the scroll (e.g. `ListState.offset` and area height). +/// +/// Example: +/// ```gleam +/// scrollbar_new(total: 50, visible: 10, offset: 5) +/// |> scrollbar.render_vertical(buf, area) +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import gleam/int + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Scrollbar configuration. +pub type Scrollbar { + Scrollbar( + /// Total number of items in the list. + total: Int, + /// Number of items visible at once (viewport height/width). + visible: Int, + /// Current scroll offset (index of first visible item). + offset: Int, + /// Track character (unfilled area). + track_char: String, + /// Thumb character (filled/active area). + thumb_char: String, + /// Arrow characters at start and end. `""` = no arrows. + arrow_start: String, + arrow_end: String, + fg: style.Color, + bg: style.Color, + thumb_fg: style.Color, + thumb_bg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New scrollbar. `total` = total items, `visible` = viewport size, +/// `offset` = first visible item index. +pub fn scrollbar_new(total: Int, visible: Int, offset: Int) -> Scrollbar { + Scrollbar( + total: int.max(1, total), + visible: int.max(1, visible), + offset: int.max(0, offset), + track_char: "░", + thumb_char: "█", + arrow_start: "▲", + arrow_end: "▼", + fg: style.Default, + bg: style.Default, + thumb_fg: style.Default, + thumb_bg: style.Default, + ) +} + +/// Override track and thumb characters. +pub fn with_chars(s: Scrollbar, track: String, thumb: String) -> Scrollbar { + Scrollbar(..s, track_char: track, thumb_char: thumb) +} + +/// Override arrow characters. Pass `""` to hide arrows. +pub fn with_arrows(s: Scrollbar, start: String, end_ch: String) -> Scrollbar { + Scrollbar(..s, arrow_start: start, arrow_end: end_ch) +} + +/// Set colors for the track. +pub fn with_colors( + s: Scrollbar, + fg: style.Color, + bg: style.Color, +) -> Scrollbar { + Scrollbar(..s, fg: fg, bg: bg) +} + +/// Set colors for the thumb. +pub fn with_thumb_colors( + s: Scrollbar, + fg: style.Color, + bg: style.Color, +) -> Scrollbar { + Scrollbar(..s, thumb_fg: fg, thumb_bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render a vertical scrollbar into the first column of `area`. +/// Arrow characters (if non-empty) occupy the top and bottom cells; +/// the track fills the remaining height. +pub fn render_vertical( + buf: buffer.Buffer, + area: geometry.Rect, + s: Scrollbar, +) -> buffer.Buffer { + case area.size.height <= 0 || area.size.width <= 0 { + True -> buf + False -> { + let has_start = s.arrow_start != "" + let has_end = s.arrow_end != "" + let arrow_top = case has_start { + True -> 1 + False -> 0 + } + let arrow_bot = case has_end { + True -> 1 + False -> 0 + } + let track_len = int.max(0, area.size.height - arrow_top - arrow_bot) + let buf = case has_start { + False -> buf + True -> + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: area.position.y), + s.arrow_start, + s.fg, + s.bg, + style.none(), + ) + } + let buf = case has_end { + False -> buf + True -> + buffer.set_string( + buf, + geometry.Position( + x: area.position.x, + y: area.position.y + area.size.height - 1, + ), + s.arrow_end, + s.fg, + s.bg, + style.none(), + ) + } + case track_len <= 0 { + True -> buf + False -> { + let track_area = + geometry.rect_new( + area.position.x, + area.position.y + arrow_top, + area.size.width, + track_len, + ) + let #(thumb_start, thumb_size) = thumb_geometry(s, track_len) + render_vertical_track( + buf, + track_area, + s, + track_len, + thumb_start, + thumb_size, + 0, + ) + } + } + } + } +} + +/// Render a horizontal scrollbar into the first row of `area`. +/// Arrow characters (if non-empty) occupy the leftmost and rightmost cells; +/// the track fills the remaining width. +pub fn render_horizontal( + buf: buffer.Buffer, + area: geometry.Rect, + s: Scrollbar, +) -> buffer.Buffer { + case area.size.height <= 0 || area.size.width <= 0 { + True -> buf + False -> { + let has_start = s.arrow_start != "" + let has_end = s.arrow_end != "" + let arrow_left = case has_start { + True -> 1 + False -> 0 + } + let arrow_right = case has_end { + True -> 1 + False -> 0 + } + let track_len = int.max(0, area.size.width - arrow_left - arrow_right) + let buf = case has_start { + False -> buf + True -> + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: area.position.y), + s.arrow_start, + s.fg, + s.bg, + style.none(), + ) + } + let buf = case has_end { + False -> buf + True -> + buffer.set_string( + buf, + geometry.Position( + x: area.position.x + area.size.width - 1, + y: area.position.y, + ), + s.arrow_end, + s.fg, + s.bg, + style.none(), + ) + } + case track_len <= 0 { + True -> buf + False -> { + let track_area = + geometry.rect_new( + area.position.x + arrow_left, + area.position.y, + track_len, + area.size.height, + ) + let #(thumb_start, thumb_size) = thumb_geometry(s, track_len) + render_horizontal_track( + buf, + track_area, + s, + track_len, + thumb_start, + thumb_size, + 0, + ) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +/// Compute thumb start position and size within a track of `track_len` cells. +fn thumb_geometry(s: Scrollbar, track_len: Int) -> #(Int, Int) { + let total = int.max(1, s.total) + let visible = int.clamp(s.visible, 1, total) + let offset = int.clamp(s.offset, 0, total - visible) + + // Thumb size proportional to visible/total, minimum 1 cell. + let thumb_size = int.max(1, track_len * visible / total) + // Thumb position proportional to offset/(total-visible). + let scrollable = total - visible + let thumb_start = case scrollable { + 0 -> 0 + _ -> { track_len - thumb_size } * offset / scrollable + } + #(thumb_start, thumb_size) +} + +fn render_vertical_track( + buf: buffer.Buffer, + area: geometry.Rect, + s: Scrollbar, + track_len: Int, + thumb_start: Int, + thumb_size: Int, + i: Int, +) -> buffer.Buffer { + case i >= track_len { + True -> buf + False -> { + let pos = geometry.Position(x: area.position.x, y: area.position.y + i) + let is_thumb = i >= thumb_start && i < thumb_start + thumb_size + let #(ch, fg, bg) = case is_thumb { + True -> #(s.thumb_char, s.thumb_fg, s.thumb_bg) + False -> #(s.track_char, s.fg, s.bg) + } + let buf2 = buffer.set_string(buf, pos, ch, fg, bg, style.none()) + render_vertical_track( + buf2, + area, + s, + track_len, + thumb_start, + thumb_size, + i + 1, + ) + } + } +} + +fn render_horizontal_track( + buf: buffer.Buffer, + area: geometry.Rect, + s: Scrollbar, + track_len: Int, + thumb_start: Int, + thumb_size: Int, + i: Int, +) -> buffer.Buffer { + case i >= track_len { + True -> buf + False -> { + let pos = geometry.Position(x: area.position.x + i, y: area.position.y) + let is_thumb = i >= thumb_start && i < thumb_start + thumb_size + let #(ch, fg, bg) = case is_thumb { + True -> #(s.thumb_char, s.thumb_fg, s.thumb_bg) + False -> #(s.track_char, s.fg, s.bg) + } + let buf2 = buffer.set_string(buf, pos, ch, fg, bg, style.none()) + render_horizontal_track( + buf2, + area, + s, + track_len, + thumb_start, + thumb_size, + i + 1, + ) + } + } +} diff --git a/src/etui/widgets/sparkline.gleam b/src/etui/widgets/sparkline.gleam new file mode 100644 index 0000000..d1c4311 --- /dev/null +++ b/src/etui/widgets/sparkline.gleam @@ -0,0 +1,168 @@ +/// Sparkline: single-row bar chart using Unicode block characters. +/// Each data point maps to one of ▁▂▃▄▅▆▇█ based on its value vs max. +/// Supports static or animated gradient fill per column. +import etui/buffer +import etui/color +import etui/geometry +import etui/style +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type SparkFill { + /// Static left-to-right gradient across color stops. + SparkGradient(stops: List(style.Color)) + /// Gradient that scrolls left over time. + SparkAnimated(stops: List(style.Color)) + /// Static full-spectrum rainbow. + SparkRainbow + /// Rainbow that rotates hue over time. + SparkAnimatedRainbow + /// Single solid color. + SparkSolid(c: style.Color) +} + +pub type Sparkline { + Sparkline( + data: List(Int), + /// Expected maximum value. 0 = auto-compute from data. + max_val: Int, + fill: SparkFill, + bg: style.Color, + modifier: style.Modifier, + /// Animation period in frames (for animated fills). + period: Int, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn sparkline_new(data: List(Int)) -> Sparkline { + Sparkline( + data: data, + max_val: 0, + fill: SparkGradient([style.Rgb(0, 180, 255), style.Rgb(0, 255, 180)]), + bg: style.Default, + modifier: style.none(), + period: 60, + ) +} + +pub fn with_fill(s: Sparkline, fill: SparkFill) -> Sparkline { + Sparkline(..s, fill: fill) +} + +pub fn with_max(s: Sparkline, max: Int) -> Sparkline { + Sparkline(..s, max_val: max) +} + +pub fn with_bg(s: Sparkline, bg: style.Color) -> Sparkline { + Sparkline(..s, bg: bg) +} + +pub fn with_period(s: Sparkline, period: Int) -> Sparkline { + Sparkline(..s, period: int.max(1, period)) +} + +pub fn with_modifier(s: Sparkline, m: style.Modifier) -> Sparkline { + Sparkline(..s, modifier: m) +} + +pub fn with_style(s: Sparkline, st: style.Style) -> Sparkline { + Sparkline(..s, bg: st.bg, modifier: st.modifier) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the sparkline. `frame` drives animated fills. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + s: Sparkline, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let max = case s.max_val { + 0 -> list.fold(s.data, 1, int.max) + m -> int.max(1, m) + } + render_cols(buf, area, s, s.data, 0, area.size.width, max, frame) + } + } +} + +fn render_cols( + buf: buffer.Buffer, + area: geometry.Rect, + s: Sparkline, + data: List(Int), + x: Int, + width: Int, + max: Int, + frame: Int, +) -> buffer.Buffer { + case x >= width { + True -> buf + False -> { + let val = case data { + [v, ..] -> v + [] -> 0 + } + let rest = case data { + [_, ..r] -> r + [] -> [] + } + let level = int.min(8, val * 8 / int.max(1, max)) + let ch = bar_char(level) + let fg = cell_color(s.fill, x, width, frame, s.period) + let pos = geometry.Position(x: area.position.x + x, y: area.position.y) + let buf2 = buffer.set_string(buf, pos, ch, fg, s.bg, s.modifier) + render_cols(buf2, area, s, rest, x + 1, width, max, frame) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn bar_char(level: Int) -> String { + case level { + 0 -> " " + 1 -> "▁" + 2 -> "▂" + 3 -> "▃" + 4 -> "▄" + 5 -> "▅" + 6 -> "▆" + 7 -> "▇" + _ -> "█" + } +} + +fn cell_color( + fill: SparkFill, + x: Int, + width: Int, + frame: Int, + period: Int, +) -> style.Color { + let p = int.max(1, period) + let w = int.max(1, width) + case fill { + SparkSolid(c) -> c + SparkGradient(stops) -> color.gradient(stops, x, w - 1) + SparkAnimated(stops) -> { + let offset = frame * w / p + color.gradient(stops, { x + offset } % w, w - 1) + } + SparkRainbow -> color.hue_to_rgb(x * 360 / w) + SparkAnimatedRainbow -> + color.hue_to_rgb({ x * 360 / w + frame * 360 / p } % 360) + } +} diff --git a/src/etui/widgets/spinner.gleam b/src/etui/widgets/spinner.gleam new file mode 100644 index 0000000..dd2386f --- /dev/null +++ b/src/etui/widgets/spinner.gleam @@ -0,0 +1,158 @@ +import etui/anim +import etui/buffer +import etui/geometry +import etui/style +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type SpinnerStyle { + Dots + Line + Circle + Bounce + MiniDot + Jump + Pulse + Points + Globe + Moon + Monkey + Meter + Hamburger + Ellipsis + Custom(frames: List(String)) +} + +pub type Spinner { + Spinner(style: SpinnerStyle, label: String, fg: style.Color, bg: style.Color) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn spinner_new() -> Spinner { + Spinner(style: Dots, label: "", fg: style.Default, bg: style.Default) +} + +pub fn with_style(s: Spinner, spinner_style: SpinnerStyle) -> Spinner { + Spinner(..s, style: spinner_style) +} + +pub fn with_label(s: Spinner, label: String) -> Spinner { + Spinner(..s, label: label) +} + +pub fn with_colors(s: Spinner, fg: style.Color, bg: style.Color) -> Spinner { + Spinner(..s, fg: fg, bg: bg) +} + +pub fn with_render_style(s: Spinner, st: style.Style) -> Spinner { + Spinner(..s, fg: st.fg, bg: st.bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering +// +// `frame` comes from the caller's AnimState.frame, the spinner is +// stateless and purely a function of the current frame number. + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + s: Spinner, + frame: Int, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let char = spin_char(s.style, frame) + let line = case s.label { + "" -> char + label -> char <> " " <> label + } + buffer.set_string(buf, area.position, line, s.fg, s.bg, style.none()) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Frame → character + +fn spin_char(spinner_style: SpinnerStyle, frame: Int) -> String { + case spinner_style { + Dots -> { + let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + nth_frame(frames, anim.cycle(frame, 10)) + } + Line -> { + let frames = ["-", "\\", "|", "/"] + nth_frame(frames, anim.cycle(frame, 4)) + } + Circle -> { + let frames = ["◐", "◓", "◑", "◒"] + nth_frame(frames, anim.cycle(frame, 4)) + } + Bounce -> { + let frames = ["⠁", "⠂", "⠄", "⠂"] + nth_frame(frames, anim.cycle(frame, 4)) + } + MiniDot -> { + let frames = ["⠂", "⠁", "⠈", "⠐", "⠠", "⢀", "⡀", "⠄"] + nth_frame(frames, anim.cycle(frame, 8)) + } + Jump -> { + let frames = ["▀", "▄"] + nth_frame(frames, anim.cycle(frame, 2)) + } + Pulse -> { + let frames = ["█", "▓", "▒", "░", "▒", "▓"] + nth_frame(frames, anim.cycle(frame, 6)) + } + Points -> { + let frames = ["∙∙∙", "●∙∙", "∙●∙", "∙∙●"] + nth_frame(frames, anim.cycle(frame, 4)) + } + Globe -> { + let frames = ["🌍", "🌎", "🌏"] + nth_frame(frames, anim.cycle(frame, 3)) + } + Moon -> { + let frames = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"] + nth_frame(frames, anim.cycle(frame, 8)) + } + Monkey -> { + let frames = ["🙈", "🙉", "🙊"] + nth_frame(frames, anim.cycle(frame, 3)) + } + Meter -> { + let frames = ["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱"] + nth_frame(frames, anim.cycle(frame, 6)) + } + Hamburger -> { + let frames = ["☱", "☲", "☴", "☲"] + nth_frame(frames, anim.cycle(frame, 4)) + } + Ellipsis -> { + let frames = [" ", ". ", ".. ", "..."] + nth_frame(frames, anim.cycle(frame, 4)) + } + Custom(frames) -> { + let count = list.length(frames) + nth_frame(frames, anim.cycle(frame, count)) + } + } +} + +fn nth_frame(frames: List(String), idx: Int) -> String { + do_nth(frames, idx) +} + +fn do_nth(items: List(String), n: Int) -> String { + case items { + [] -> "?" + [h, ..] if n <= 0 -> h + [_, ..rest] -> do_nth(rest, n - 1) + } +} diff --git a/src/etui/widgets/statusbar.gleam b/src/etui/widgets/statusbar.gleam new file mode 100644 index 0000000..6b1a690 --- /dev/null +++ b/src/etui/widgets/statusbar.gleam @@ -0,0 +1,172 @@ +/// Status bar widget: horizontal bar with left, center, and right sections. +/// +/// Each section is a list of `span.Line` for mixed-style text. +/// Sections are laid out flush-left, centered, and flush-right within the bar. +/// +/// Example: +/// ```gleam +/// let bar = statusbar.statusbar_new() +/// |> statusbar.with_left([span.line_new([span.span_new("INSERT", s)])]) +/// |> statusbar.with_right([span.line_new([span.span_new("Ln 42", s)])]) +/// statusbar.render(buf, area, bar) +/// ``` +import etui/buffer +import etui/geometry +import etui/span +import etui/style +import etui/text +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Status bar configuration with left, center, and right span sections. +pub type StatusBar { + StatusBar( + left: List(span.Line), + center: List(span.Line), + right: List(span.Line), + fg: style.Color, + bg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New status bar with empty sections and default colors. +pub fn statusbar_new() -> StatusBar { + StatusBar( + left: [], + center: [], + right: [], + fg: style.Default, + bg: style.Default, + ) +} + +/// Set left section spans. +pub fn with_left(sb: StatusBar, lines: List(span.Line)) -> StatusBar { + StatusBar(..sb, left: lines) +} + +/// Set center section spans. +pub fn with_center(sb: StatusBar, lines: List(span.Line)) -> StatusBar { + StatusBar(..sb, center: lines) +} + +/// Set right section spans. +pub fn with_right(sb: StatusBar, lines: List(span.Line)) -> StatusBar { + StatusBar(..sb, right: lines) +} + +/// Set foreground and background colors for the bar background. +pub fn with_style( + sb: StatusBar, + fg: style.Color, + bg: style.Color, +) -> StatusBar { + StatusBar(..sb, fg: fg, bg: bg) +} + +pub fn with_colors( + sb: StatusBar, + fg: style.Color, + bg: style.Color, +) -> StatusBar { + StatusBar(..sb, fg: fg, bg: bg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render status bar into the first row of `area`. +/// Only one row is used; remaining rows in `area` are untouched. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + sb: StatusBar, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let y = area.position.y + let w = area.size.width + + // Fill background row + let bg_row = text.pad_right("", w) + let buf1 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + bg_row, + sb.fg, + sb.bg, + style.none(), + ) + + // Left section: render from x=0 + let buf2 = render_section(buf1, sb.left, area.position.x, y, w, sb) + + // Right section: measure width, render flush-right + let right_width = section_width(sb.right) + let right_x = area.position.x + w - right_width + let buf3 = case right_x >= area.position.x { + True -> render_section(buf2, sb.right, right_x, y, right_width, sb) + False -> buf2 + } + + // Center section: measure width, render centered + let center_width = section_width(sb.center) + let center_x = area.position.x + { w - center_width } / 2 + render_section(buf3, sb.center, center_x, y, center_width, sb) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn render_section( + buf: buffer.Buffer, + lines: List(span.Line), + x: Int, + y: Int, + max_w: Int, + sb: StatusBar, +) -> buffer.Buffer { + case lines { + [] -> buf + [line, ..] -> + span.render_line( + buf, + geometry.Position(x: x, y: y), + inherit_bar_style(line, sb), + max_w, + ) + } +} + +fn section_width(lines: List(span.Line)) -> Int { + case lines { + [] -> 0 + [line, ..] -> span.line_width(line) + } +} + +fn inherit_bar_style(line: span.Line, sb: StatusBar) -> span.Line { + span.line_aligned( + list.map(line.spans, fn(sp: span.Span) { + let fg = case sp.fg { + style.Default -> sb.fg + _ -> sp.fg + } + let bg = case sp.bg { + style.Default -> sb.bg + _ -> sp.bg + } + span.Span(..sp, fg: fg, bg: bg) + }), + line.alignment, + ) +} diff --git a/src/etui/widgets/table.gleam b/src/etui/widgets/table.gleam new file mode 100644 index 0000000..61f7b6b --- /dev/null +++ b/src/etui/widgets/table.gleam @@ -0,0 +1,356 @@ +/// Table widget: scrollable grid of rows and columns with optional selection. +/// +/// Columns are separated by `│`. Each column is padded/truncated to its width. +/// The first row is optionally treated as a header (rendered with reverse style +/// when `show_header` is true). Use `render_stateful` to track selection. +/// +/// Example: +/// ```gleam +/// table_new([["Alice", "30"], ["Bob", "25"]]) +/// |> with_col_widths([12, 5]) +/// |> table.render(buf, area) +/// ``` +import etui/anim +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list as glist + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Table widget configuration. `col_widths` are in terminal cells. +pub type TableWidget { + TableWidget( + rows: List(List(String)), + /// Fixed column widths in cells. Used when `col_constraints` is empty. + col_widths: List(Int), + /// Constraint-based column widths. When non-empty, resolved at render time + /// from the available area width (separators subtracted automatically). + col_constraints: List(geometry.Constraint), + show_header: Bool, + fg: style.Color, + bg: style.Color, + highlight_style: style.Style, + /// Blink period in frames (0 = no blink). + blink_period: Int, + ) +} + +/// Scroll and selection state. Keep external so state persists across renders. +pub type TableState { + TableState(selected_row: Int, offset: Int) +} + +// ───────────────────────────────────────────────────────────────── +// Widget config constructors + +/// New table. Column widths default to 10 cells each. +pub fn table_new(rows: List(List(String))) -> TableWidget { + let col_widths = case rows { + [] -> [] + [first, ..] -> glist.repeat(10, glist.length(first)) + } + TableWidget( + rows: rows, + col_widths: col_widths, + col_constraints: [], + show_header: False, + fg: style.Default, + bg: style.Default, + highlight_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + blink_period: 0, + ) +} + +/// Override column widths (cell counts). Must match number of columns. +pub fn with_col_widths(t: TableWidget, widths: List(Int)) -> TableWidget { + TableWidget(..t, col_widths: widths) +} + +/// Constraint-based column widths, resolved from area width at render time. +/// When set, takes precedence over `col_widths`. +/// Separator cells (│) are subtracted before resolving. +pub fn with_col_constraints( + t: TableWidget, + constraints: List(geometry.Constraint), +) -> TableWidget { + TableWidget(..t, col_constraints: constraints) +} + +/// When true, `t.rows[0]` is rendered as a bold header row (never selectable). +/// `selected_row` uses absolute indices: 0 = header, 1 = first data row, etc. +/// Initialize state with `select_row(state_new(), 1)` for the first data row. +pub fn with_header(t: TableWidget, show: Bool) -> TableWidget { + TableWidget(..t, show_header: show) +} + +pub fn with_colors( + t: TableWidget, + fg: style.Color, + bg: style.Color, +) -> TableWidget { + TableWidget(..t, fg: fg, bg: bg) +} + +pub fn with_highlight_style(t: TableWidget, s: style.Style) -> TableWidget { + TableWidget(..t, highlight_style: s) +} + +pub fn with_style(t: TableWidget, s: style.Style) -> TableWidget { + TableWidget(..t, fg: s.fg, bg: s.bg) +} + +/// Blink period in frames. 0 = steady (no blink). Use with `render_animated`. +pub fn with_blink(t: TableWidget, period: Int) -> TableWidget { + TableWidget(..t, blink_period: period) +} + +// ───────────────────────────────────────────────────────────────── +// State constructors and navigation + +/// Initial state: selected_row=0, no scroll offset. +/// When using `with_header(True)`, row 0 is the header (not selectable). +/// Use `select_row(state_new(), 1)` to start with the first data row highlighted. +pub fn state_new() -> TableState { + TableState(selected_row: 0, offset: 0) +} + +/// Jump to a specific row (clamped to ≥ 0). +pub fn select_row(state: TableState, idx: Int) -> TableState { + TableState(..state, selected_row: int.max(0, idx)) +} + +/// Move selection down by one, clamped to last row. +pub fn select_next_row(state: TableState, row_count: Int) -> TableState { + let max_idx = int.max(0, row_count - 1) + TableState(..state, selected_row: int.min(max_idx, state.selected_row + 1)) +} + +/// Move selection up by one, clamped to 0. +pub fn select_prev_row(state: TableState) -> TableState { + TableState(..state, selected_row: int.max(0, state.selected_row - 1)) +} + +/// Clamp `selected_row` to `[0, row_count - 1]`. +/// Call after replacing the row list to avoid a stale selection index. +pub fn clamp_state(state: TableState, row_count: Int) -> TableState { + let max = int.max(0, row_count - 1) + TableState(..state, selected_row: int.min(state.selected_row, max)) +} + +/// Effective scroll offset for a viewport of `visible_data_h` data rows. +/// When `show_header` is True, pass `area.size.height - 1`; otherwise pass `area.size.height`. +/// Pass as `offset` to `scrollbar.scrollbar_new`. +pub fn effective_offset(state: TableState, visible_data_h: Int) -> Int { + scroll_offset(state.selected_row, state.offset, visible_data_h) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render without selection highlight. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + t: TableWidget, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> do_render(buf, area, t, -1, 0, 0) + } +} + +/// Render with selection and auto-scrolling from state. +pub fn render_stateful( + buf: buffer.Buffer, + area: geometry.Rect, + t: TableWidget, + state: TableState, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> { + // With a header row, one cell is reserved at y=0; data scrolls in H-1 rows. + let visible_data = case t.show_header { + True -> int.max(0, area.size.height - 1) + False -> area.size.height + } + let offset = scroll_offset(state.selected_row, state.offset, visible_data) + do_render(buf, area, t, state.selected_row, offset, 0) + } + } +} + +/// Like `render_stateful` but supports blinking selection via `t.blink_period`. +/// Pass the current `AnimState.frame`; use `with_blink(t, period)` to configure. +pub fn render_animated( + buf: buffer.Buffer, + area: geometry.Rect, + t: TableWidget, + state: TableState, + frame: Int, +) -> buffer.Buffer { + case area.size.height <= 0 { + True -> buf + False -> { + let visible_data = case t.show_header { + True -> int.max(0, area.size.height - 1) + False -> area.size.height + } + let offset = scroll_offset(state.selected_row, state.offset, visible_data) + let show = anim.blink(frame, t.blink_period) + let sel = case show { + True -> state.selected_row + False -> -1 + } + do_render(buf, area, t, sel, offset, 0) + } + } +} + +fn do_render( + buf: buffer.Buffer, + area: geometry.Rect, + t: TableWidget, + selected: Int, + offset: Int, + y_offset: Int, +) -> buffer.Buffer { + let col_widths = case t.col_constraints { + [] -> t.col_widths + constraints -> { + geometry.resolve_sizes(area.size.width, constraints) + } + } + case y_offset >= area.size.height { + True -> buf + False -> { + let y = area.position.y + y_offset + // When show_header is True, y_offset=0 renders the header row (rows[0]) + // with bold style. Data rows follow at y_offset=1..H-1, and their absolute + // index in t.rows is offset+y_offset (which equals 1+offset+data_i since + // y_offset starts at 1 for the first data row). + let is_header = t.show_header && y_offset == 0 + let row_idx = offset + y_offset + let is_selected = !is_header && row_idx == selected + let row_line = case is_header { + True -> + case get_row_at(t.rows, 0) { + Ok(row) -> render_row_line(row, col_widths, area.size.width, False) + Error(_) -> render_empty_line(area.size.width) + } + False -> + case get_row_at(t.rows, row_idx) { + Ok(row) -> + render_row_line(row, col_widths, area.size.width, is_selected) + Error(_) -> render_empty_line(area.size.width) + } + } + let #(row_fg, row_bg, row_modifier) = case is_header { + True -> #(t.fg, t.bg, style.bold()) + False -> + case is_selected { + True -> #( + t.highlight_style.fg, + t.highlight_style.bg, + t.highlight_style.modifier, + ) + False -> #(t.fg, t.bg, style.none()) + } + } + let buf_new = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + row_line, + row_fg, + row_bg, + row_modifier, + ) + do_render(buf_new, area, t, selected, offset, y_offset + 1) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Scroll helpers + +fn scroll_offset(selected: Int, offset: Int, height: Int) -> Int { + case selected < offset { + True -> selected + False -> + case height <= 0 { + True -> offset + False -> + case selected >= offset + height { + True -> selected - height + 1 + False -> offset + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn get_row_at(rows: List(List(String)), idx: Int) -> Result(List(String), Nil) { + case idx { + i if i < 0 -> Error(Nil) + 0 -> + case rows { + [h, ..] -> Ok(h) + [] -> Error(Nil) + } + _ -> get_row_at(glist.drop(rows, 1), idx - 1) + } +} + +fn render_row_line( + row: List(String), + col_widths: List(Int), + max_width: Int, + is_selected: Bool, +) -> String { + let cells = render_cells(row, col_widths) + let line = + glist.fold(cells, "", fn(acc, cell) { + case acc { + "" -> cell + _ -> acc <> "│" <> cell + } + }) + let prefix = case is_selected { + True -> "▶" + False -> " " + } + text.truncate(prefix <> line, max_width, "") + |> text.pad_right(max_width) +} + +fn render_cells(row: List(String), widths: List(Int)) -> List(String) { + case row, widths { + _, [] -> [] + [], [width, ..rest_widths] -> { + // Row has fewer cells than columns: pad with empty cells. + let formatted = text.pad_right("", int.max(0, width - 1)) + [formatted, ..render_cells([], rest_widths)] + } + [cell, ..rest_row], [width, ..rest_widths] -> { + let formatted = + text.truncate(cell, width - 1, "") + |> text.pad_right(width - 1) + [formatted, ..render_cells(rest_row, rest_widths)] + } + } +} + +fn render_empty_line(width: Int) -> String { + text.pad_right("", width) +} diff --git a/src/etui/widgets/tabs.gleam b/src/etui/widgets/tabs.gleam new file mode 100644 index 0000000..bdbfe54 --- /dev/null +++ b/src/etui/widgets/tabs.gleam @@ -0,0 +1,168 @@ +/// Tab bar widget: horizontal row of labelled tabs. +/// Active tab rendered with active_style; others with normal fg/bg. +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type Tabs { + Tabs( + labels: List(String), + /// 0-based index of the active tab. + active: Int, + fg: style.Color, + bg: style.Color, + active_style: style.Style, + /// String rendered between tabs. + divider: String, + /// Padding spaces inside each tab label. + padding: Int, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn tabs_new(labels: List(String)) -> Tabs { + Tabs( + labels: labels, + active: 0, + fg: style.Default, + bg: style.Default, + active_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.add(style.bold(), style.reverse()), + ), + divider: "│", + padding: 1, + ) +} + +pub fn with_active(t: Tabs, idx: Int) -> Tabs { + Tabs(..t, active: int.max(0, idx)) +} + +pub fn with_active_style(t: Tabs, s: style.Style) -> Tabs { + Tabs(..t, active_style: s) +} + +pub fn with_divider(t: Tabs, div: String) -> Tabs { + Tabs(..t, divider: div) +} + +pub fn with_padding(t: Tabs, p: Int) -> Tabs { + Tabs(..t, padding: int.max(0, p)) +} + +pub fn with_colors(t: Tabs, fg: style.Color, bg: style.Color) -> Tabs { + Tabs(..t, fg: fg, bg: bg) +} + +// Tab navigation helpers + +pub fn next_tab(t: Tabs) -> Tabs { + let n = list.length(t.labels) + Tabs(..t, active: { t.active + 1 } % int.max(1, n)) +} + +pub fn prev_tab(t: Tabs) -> Tabs { + let n = int.max(1, list.length(t.labels)) + Tabs(..t, active: { t.active - 1 + n } % n) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the tab bar into the first row of `area`. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + t: Tabs, +) -> buffer.Buffer { + case area.size.height <= 0 || area.size.width <= 0 { + True -> buf + False -> render_tabs(buf, area, t.labels, 0, area.position.x, t) + } +} + +fn render_tabs( + buf: buffer.Buffer, + area: geometry.Rect, + labels: List(String), + idx: Int, + x: Int, + t: Tabs, +) -> buffer.Buffer { + case labels { + [] -> buf + [label, ..rest] -> { + let pad = make_spaces(t.padding) + let content = pad <> label <> pad + let is_active = idx == t.active + let #(fg, bg, modifier) = case is_active { + True -> #(t.active_style.fg, t.active_style.bg, t.active_style.modifier) + False -> #(t.fg, t.bg, style.none()) + } + let avail = area.position.x + area.size.width - x + let shown = text.truncate(content, avail, "") + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: x, y: area.position.y), + shown, + fg, + bg, + modifier, + ) + let next_x = x + string_length(content) + case rest { + [] -> buf2 + _ -> { + let div_avail = area.position.x + area.size.width - next_x + case div_avail <= 0 { + True -> buf2 + False -> { + let buf3 = + buffer.set_string( + buf2, + geometry.Position(x: next_x, y: area.position.y), + t.divider, + t.fg, + t.bg, + style.none(), + ) + render_tabs( + buf3, + area, + rest, + idx + 1, + next_x + string_length(t.divider), + t, + ) + } + } + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Helpers + +fn make_spaces(n: Int) -> String { + case n <= 0 { + True -> "" + False -> " " <> make_spaces(n - 1) + } +} + +fn string_length(s: String) -> Int { + text.cell_width(s) +} diff --git a/src/etui/widgets/textarea.gleam b/src/etui/widgets/textarea.gleam new file mode 100644 index 0000000..76a254c --- /dev/null +++ b/src/etui/widgets/textarea.gleam @@ -0,0 +1,516 @@ +/// Multi-line text area with wide-char-aware cursor editing. +/// +/// Like `input.gleam` but supports multiple lines, cursor movement up/down, +/// and newline insertion. State is kept external. +/// +/// ```gleam +/// import etui/keys +/// import etui/widgets/textarea as ta +/// +/// let w = ta.textarea_new() |> ta.with_max_lines(20) +/// let state = ta.state_new() +/// +/// let state = case event { +/// backend.KeyPress(k) -> +/// case keys.match(k) { +/// keys.Enter -> ta.newline(w, state) +/// keys.Backspace -> ta.backspace(state) +/// keys.Up -> ta.move_cursor_up(state) +/// keys.Down -> ta.move_cursor_down(state) +/// keys.Left -> ta.move_cursor_left(state) +/// keys.Right -> ta.move_cursor_right(state) +/// keys.Char(c) -> ta.insert_char(w, state, c) +/// _ -> state +/// } +/// _ -> state +/// } +/// +/// let buf = ta.render(buf, area, w, state) +/// let text = ta.value(state) // lines joined with "\n" +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +/// Text area configuration. +pub type TextArea { + TextArea( + /// Maximum lines allowed (0 = unlimited). + max_lines: Int, + /// Maximum line width in cells, wide characters count as 2 (0 = unlimited). + max_line_length: Int, + fg: style.Color, + bg: style.Color, + /// Style applied to the cursor cell. + cursor_style: style.Style, + ) +} + +/// Mutable editing state. +pub type TextAreaState { + TextAreaState( + /// One string per line. Always at least one element. + lines: List(String), + /// Cursor column in cells within the current line. + cursor_x: Int, + /// Cursor row (0-based line index). + cursor_y: Int, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +/// New textarea with default styles and no limits. +pub fn textarea_new() -> TextArea { + TextArea( + max_lines: 0, + max_line_length: 0, + fg: style.Default, + bg: style.Default, + cursor_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + ) +} + +pub fn with_max_lines(w: TextArea, n: Int) -> TextArea { + TextArea(..w, max_lines: n) +} + +pub fn with_max_line_length(w: TextArea, n: Int) -> TextArea { + TextArea(..w, max_line_length: n) +} + +pub fn with_colors(w: TextArea, fg: style.Color, bg: style.Color) -> TextArea { + TextArea(..w, fg: fg, bg: bg) +} + +pub fn with_style(w: TextArea, s: style.Style) -> TextArea { + TextArea(..w, fg: s.fg, bg: s.bg) +} + +pub fn with_cursor_style(w: TextArea, s: style.Style) -> TextArea { + TextArea(..w, cursor_style: s) +} + +// ───────────────────────────────────────────────────────────────── +// State constructors + +/// Empty state: one empty line, cursor at top-left. +pub fn state_new() -> TextAreaState { + TextAreaState(lines: [""], cursor_x: 0, cursor_y: 0) +} + +/// State pre-populated from a string (splits on `\n`). +pub fn state_from_string(s: String) -> TextAreaState { + let lines = string.split(s, "\n") + let row = list.length(lines) - 1 + let last_line = case list.last(lines) { + Ok(l) -> l + Error(_) -> "" + } + TextAreaState( + lines: lines, + cursor_x: text.cell_width(last_line), + cursor_y: int.max(0, row), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Value accessor + +/// All lines joined with `"\n"`. +pub fn value(state: TextAreaState) -> String { + string.join(state.lines, "\n") +} + +/// Number of lines. +pub fn line_count(state: TextAreaState) -> Int { + list.length(state.lines) +} + +/// Effective scroll offset for a viewport of `visible_h` rows. +/// Returns the index of the first visible line so the cursor stays in view. +/// Pass as `offset` to `scrollbar.scrollbar_new`. +pub fn effective_offset(state: TextAreaState, visible_h: Int) -> Int { + scroll_offset(state.cursor_y, visible_h) +} + +/// Screen position of the hardware cursor within `area`. +/// Returns `Error(Nil)` when the cursor column is beyond the area width +/// (mirrors the render rule: no cursor cell is drawn off-screen). +pub fn cursor_screen_pos( + state: TextAreaState, + area: geometry.Rect, +) -> Result(geometry.Position, Nil) { + case state.cursor_x >= area.size.width { + True -> Error(Nil) + False -> { + let scroll = scroll_offset(state.cursor_y, area.size.height) + Ok(geometry.Position( + x: area.position.x + state.cursor_x, + y: area.position.y + state.cursor_y - scroll, + )) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Editing operations + +/// Insert a character at the current cursor position. +pub fn insert_char( + w: TextArea, + state: TextAreaState, + ch: String, +) -> TextAreaState { + let line = get_line(state.lines, state.cursor_y) + let line_cells = text.cell_width(line) + case w.max_line_length > 0 && line_cells >= w.max_line_length { + True -> state + False -> { + let before = text.truncate(line, state.cursor_x, "") + let after = string.drop_start(line, string.length(before)) + let new_line = before <> ch <> after + TextAreaState( + ..state, + lines: set_line(state.lines, state.cursor_y, new_line), + cursor_x: state.cursor_x + text.cell_width(ch), + ) + } + } +} + +/// Delete the character immediately before the cursor. +/// If at column 0, merges the current line with the previous line. +pub fn backspace(state: TextAreaState) -> TextAreaState { + case state.cursor_x > 0 { + True -> { + let line = get_line(state.lines, state.cursor_y) + let before = text.truncate(line, state.cursor_x - 1, "") + let graphemes_before = + string.length(text.truncate(line, state.cursor_x, "")) + let after = string.drop_start(line, graphemes_before) + TextAreaState( + ..state, + lines: set_line(state.lines, state.cursor_y, before <> after), + cursor_x: text.cell_width(before), + ) + } + False -> + case state.cursor_y > 0 { + False -> state + True -> { + let prev = get_line(state.lines, state.cursor_y - 1) + let curr = get_line(state.lines, state.cursor_y) + let merged = prev <> curr + let new_x = text.cell_width(prev) + let new_lines = + delete_line(state.lines, state.cursor_y) + |> set_line(state.cursor_y - 1, merged) + TextAreaState( + lines: new_lines, + cursor_x: new_x, + cursor_y: state.cursor_y - 1, + ) + } + } + } +} + +/// Insert a newline at the cursor. Splits the current line. +pub fn newline(w: TextArea, state: TextAreaState) -> TextAreaState { + let n_lines = list.length(state.lines) + case w.max_lines > 0 && n_lines >= w.max_lines { + True -> state + False -> { + let line = get_line(state.lines, state.cursor_y) + let before = text.truncate(line, state.cursor_x, "") + let after = string.drop_start(line, string.length(before)) + let new_lines = + set_line(state.lines, state.cursor_y, before) + |> insert_line_after(state.cursor_y, after) + TextAreaState(lines: new_lines, cursor_x: 0, cursor_y: state.cursor_y + 1) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Cursor movement + +/// Move cursor one cell left. Wraps to end of previous line. +pub fn move_cursor_left(state: TextAreaState) -> TextAreaState { + case state.cursor_x > 0 { + True -> { + let line = get_line(state.lines, state.cursor_y) + let new_x = text.cell_width(text.truncate(line, state.cursor_x - 1, "")) + TextAreaState(..state, cursor_x: new_x) + } + False -> + case state.cursor_y > 0 { + False -> state + True -> { + let prev = get_line(state.lines, state.cursor_y - 1) + TextAreaState( + ..state, + cursor_x: text.cell_width(prev), + cursor_y: state.cursor_y - 1, + ) + } + } + } +} + +/// Move cursor one cell right. Wraps to start of next line. +pub fn move_cursor_right(state: TextAreaState) -> TextAreaState { + let line = get_line(state.lines, state.cursor_y) + case state.cursor_x < text.cell_width(line) { + True -> { + let step = grapheme_width_at(line, state.cursor_x) + TextAreaState(..state, cursor_x: state.cursor_x + step) + } + False -> { + let n_lines = list.length(state.lines) + case state.cursor_y < n_lines - 1 { + False -> state + True -> + TextAreaState(..state, cursor_x: 0, cursor_y: state.cursor_y + 1) + } + } + } +} + +/// Move cursor up one line, clamping x to the new line's width. +pub fn move_cursor_up(state: TextAreaState) -> TextAreaState { + case state.cursor_y > 0 { + False -> state + True -> { + let new_y = state.cursor_y - 1 + let prev = get_line(state.lines, new_y) + TextAreaState( + ..state, + cursor_x: snap_to_boundary(prev, state.cursor_x), + cursor_y: new_y, + ) + } + } +} + +/// Move cursor down one line, clamping x to the new line's width. +pub fn move_cursor_down(state: TextAreaState) -> TextAreaState { + let n_lines = list.length(state.lines) + case state.cursor_y < n_lines - 1 { + False -> state + True -> { + let new_y = state.cursor_y + 1 + let next = get_line(state.lines, new_y) + TextAreaState( + ..state, + cursor_x: snap_to_boundary(next, state.cursor_x), + cursor_y: new_y, + ) + } + } +} + +/// Move cursor to beginning of current line. +pub fn move_to_line_start(state: TextAreaState) -> TextAreaState { + TextAreaState(..state, cursor_x: 0) +} + +/// Move cursor to end of current line. +pub fn move_to_line_end(state: TextAreaState) -> TextAreaState { + let line = get_line(state.lines, state.cursor_y) + TextAreaState(..state, cursor_x: text.cell_width(line)) +} + +/// Delete from cursor to end of current line. +pub fn delete_to_line_end(state: TextAreaState) -> TextAreaState { + let line = get_line(state.lines, state.cursor_y) + let before = text.truncate(line, state.cursor_x, "") + TextAreaState(..state, lines: set_line(state.lines, state.cursor_y, before)) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the textarea. Scrolls vertically so the cursor line is visible. +/// Highlights the cursor cell with `cursor_style`. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + w: TextArea, + state: TextAreaState, +) -> buffer.Buffer { + case area.size.height <= 0 || area.size.width <= 0 { + True -> buf + False -> { + let visible_h = area.size.height + let scroll = scroll_offset(state.cursor_y, visible_h) + render_lines(buf, area, w, state, scroll, 0) + } + } +} + +fn render_lines( + buf: buffer.Buffer, + area: geometry.Rect, + w: TextArea, + state: TextAreaState, + scroll: Int, + row_offset: Int, +) -> buffer.Buffer { + case row_offset >= area.size.height { + True -> buf + False -> { + let line_idx = scroll + row_offset + let line = get_line(state.lines, line_idx) + let y = area.position.y + row_offset + let is_cursor_row = line_idx == state.cursor_y + let buf2 = render_line(buf, area, w, state, line, y, is_cursor_row) + render_lines(buf2, area, w, state, scroll, row_offset + 1) + } + } +} + +fn render_line( + buf: buffer.Buffer, + area: geometry.Rect, + w: TextArea, + state: TextAreaState, + line: String, + y: Int, + is_cursor_row: Bool, +) -> buffer.Buffer { + let width = area.size.width + let truncated = text.truncate(line, width, "") + let padded = text.pad_right(truncated, width) + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + padded, + w.fg, + w.bg, + style.none(), + ) + case is_cursor_row && state.cursor_x < width { + False -> buf2 + True -> { + let cursor_ch = grapheme_at_cell(line, state.cursor_x) + buffer.set_string( + buf2, + geometry.Position(x: area.position.x + state.cursor_x, y: y), + cursor_ch, + w.cursor_style.fg, + w.cursor_style.bg, + w.cursor_style.modifier, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn get_line(lines: List(String), idx: Int) -> String { + case idx < 0 { + True -> "" + False -> + case list.drop(lines, idx) { + [h, ..] -> h + [] -> "" + } + } +} + +fn set_line(lines: List(String), idx: Int, new_val: String) -> List(String) { + list.index_map(lines, fn(line, i) { + case i == idx { + True -> new_val + False -> line + } + }) +} + +fn delete_line(lines: List(String), idx: Int) -> List(String) { + list.index_fold(lines, [], fn(acc, line, i) { + case i == idx { + True -> acc + False -> list.append(acc, [line]) + } + }) +} + +fn insert_line_after( + lines: List(String), + idx: Int, + new_line: String, +) -> List(String) { + insert_line_after_loop(lines, idx, new_line, 0, []) +} + +fn insert_line_after_loop( + lines: List(String), + idx: Int, + new_line: String, + i: Int, + acc: List(String), +) -> List(String) { + case lines { + [] -> list.reverse(acc) + [h, ..rest] -> { + let acc2 = case i == idx { + True -> [new_line, h, ..acc] + False -> [h, ..acc] + } + insert_line_after_loop(rest, idx, new_line, i + 1, acc2) + } + } +} + +fn scroll_offset(cursor_y: Int, visible_h: Int) -> Int { + case visible_h <= 0 { + True -> 0 + False -> + case cursor_y < visible_h { + True -> 0 + False -> cursor_y - visible_h + 1 + } + } +} + +// Clamp cell_pos to the nearest grapheme boundary ≤ cell_pos in s. +// Prevents cursor landing mid-wide-char (e.g. inside a 2-cell emoji). +fn snap_to_boundary(s: String, cell_pos: Int) -> Int { + let clamped = int.min(cell_pos, text.cell_width(s)) + text.cell_width(text.truncate(s, clamped, "")) +} + +fn grapheme_width_at(s: String, cell_pos: Int) -> Int { + let prefix = text.truncate(s, cell_pos, "") + let rest = string.drop_start(s, string.length(prefix)) + case string.to_graphemes(rest) { + [g, ..] -> text.cell_width(g) + [] -> 1 + } +} + +fn grapheme_at_cell(s: String, cell_pos: Int) -> String { + let prefix = text.truncate(s, cell_pos, "") + let rest = string.drop_start(s, string.length(prefix)) + case string.to_graphemes(rest) { + [g, ..] -> g + [] -> " " + } +} diff --git a/src/etui/widgets/tree.gleam b/src/etui/widgets/tree.gleam new file mode 100644 index 0000000..dca410a --- /dev/null +++ b/src/etui/widgets/tree.gleam @@ -0,0 +1,482 @@ +/// Hierarchical tree widget with expand/collapse and keyboard navigation. +/// +/// Nodes have a unique String `id`, a label, and optional children. +/// State tracks which nodes are expanded and which is selected. +/// +/// ```gleam +/// import etui/widgets/tree +/// +/// let t = +/// tree.tree_new([ +/// tree.node("src", "src/", [ +/// tree.leaf("main", "main.gleam"), +/// tree.leaf("lib", "lib.gleam"), +/// ]), +/// tree.leaf("readme", "README.md"), +/// ]) +/// +/// let state = tree.state_new() +/// let state = tree.expand("src", state) // expand node +/// let state = tree.select_next(state, t) // move selection down +/// +/// let buf = tree.render(buf, area, t, state) +/// +/// // Read selection +/// tree.selected(state) // Option(String), id of selected node +/// ``` +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +/// A tree node, either a leaf or an internal node with children. +/// `count` shows a right-aligned number after the label (e.g. unread count, +/// children count). `Error(Nil)` hides it. +pub type TreeNode { + TreeNode( + id: String, + label: String, + children: List(TreeNode), + count: Result(Int, Nil), + ) +} + +/// Tree widget configuration. +pub type TreeWidget { + TreeWidget( + roots: List(TreeNode), + fg: style.Color, + bg: style.Color, + highlight_style: style.Style, + /// Characters used to render the tree structure. + glyphs: TreeGlyphs, + ) +} + +/// Visual symbols for tree lines and expansion indicators. +pub type TreeGlyphs { + TreeGlyphs( + /// Prefix for collapsed node with children. + collapsed: String, + /// Prefix for expanded node with children. + expanded: String, + /// Prefix for leaf node. + leaf: String, + /// Indent per depth level (repeated). + indent: String, + ) +} + +/// State: which nodes are expanded, which is selected. +pub type TreeState { + TreeState( + /// IDs of expanded nodes. + expanded: List(String), + /// ID of currently selected node. + selected: String, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Glyph sets + +/// Default Unicode glyphs (▶ ▼ and box-drawing indent). +pub fn default_glyphs() -> TreeGlyphs { + TreeGlyphs(collapsed: "▶ ", expanded: "▼ ", leaf: " ", indent: " ") +} + +/// ASCII-safe glyphs for terminals without Unicode support. +pub fn ascii_glyphs() -> TreeGlyphs { + TreeGlyphs(collapsed: "+ ", expanded: "- ", leaf: " ", indent: " ") +} + +// ───────────────────────────────────────────────────────────────── +// Node constructors + +/// Create a leaf node (no children). +pub fn leaf(id: String, label: String) -> TreeNode { + TreeNode(id: id, label: label, children: [], count: Error(Nil)) +} + +/// Create an internal node with children. +pub fn node(id: String, label: String, children: List(TreeNode)) -> TreeNode { + TreeNode(id: id, label: label, children: children, count: Error(Nil)) +} + +/// Leaf with a right-aligned count. +pub fn leaf_with_count(id: String, label: String, count: Int) -> TreeNode { + TreeNode(id: id, label: label, children: [], count: Ok(count)) +} + +/// Internal node with a right-aligned count. +pub fn node_with_count( + id: String, + label: String, + count: Int, + children: List(TreeNode), +) -> TreeNode { + TreeNode(id: id, label: label, children: children, count: Ok(count)) +} + +/// Attach a count to an existing node. +pub fn with_count(n: TreeNode, count: Int) -> TreeNode { + TreeNode(..n, count: Ok(count)) +} + +// ───────────────────────────────────────────────────────────────── +// Widget constructors + +/// New tree widget. The first root node is selected initially. +pub fn tree_new(roots: List(TreeNode)) -> TreeWidget { + TreeWidget( + roots: roots, + fg: style.Default, + bg: style.Default, + highlight_style: style.Style( + fg: style.Default, + bg: style.Default, + modifier: style.reverse(), + ), + glyphs: default_glyphs(), + ) +} + +pub fn with_glyphs(t: TreeWidget, g: TreeGlyphs) -> TreeWidget { + TreeWidget(..t, glyphs: g) +} + +pub fn with_highlight_style(t: TreeWidget, s: style.Style) -> TreeWidget { + TreeWidget(..t, highlight_style: s) +} + +pub fn with_colors( + t: TreeWidget, + fg: style.Color, + bg: style.Color, +) -> TreeWidget { + TreeWidget(..t, fg: fg, bg: bg) +} + +pub fn with_style(t: TreeWidget, s: style.Style) -> TreeWidget { + TreeWidget(..t, fg: s.fg, bg: s.bg) +} + +// ───────────────────────────────────────────────────────────────── +// State constructors + +/// Initial state: first root node selected, all nodes collapsed. +pub fn state_new() -> TreeState { + TreeState(expanded: [], selected: "") +} + +/// Initial state with first root pre-selected. +pub fn state_from_tree(t: TreeWidget) -> TreeState { + let first_id = case t.roots { + [n, ..] -> n.id + [] -> "" + } + TreeState(expanded: [], selected: first_id) +} + +// ───────────────────────────────────────────────────────────────── +// State queries + +/// ID of the currently selected node. `Error(Nil)` if nothing selected. +pub fn selected(state: TreeState) -> Result(String, Nil) { + case state.selected { + "" -> Error(Nil) + id -> Ok(id) + } +} + +/// `True` if the node with the given `id` is expanded. +pub fn is_expanded(state: TreeState, id: String) -> Bool { + list.contains(state.expanded, id) +} + +// ───────────────────────────────────────────────────────────────── +// Expand / collapse + +/// Expand a node (show children). +pub fn expand(id: String, state: TreeState) -> TreeState { + case list.contains(state.expanded, id) { + True -> state + False -> TreeState(..state, expanded: [id, ..state.expanded]) + } +} + +/// Collapse a node (hide children). +pub fn collapse(id: String, state: TreeState) -> TreeState { + TreeState(..state, expanded: list.filter(state.expanded, fn(e) { e != id })) +} + +/// Toggle expand/collapse on the currently selected node. +pub fn toggle_selected(state: TreeState, t: TreeWidget) -> TreeState { + case state.selected { + "" -> state + id -> { + let has_children = node_has_children(t.roots, id) + case has_children { + False -> state + True -> + case is_expanded(state, id) { + True -> collapse(id, state) + False -> expand(id, state) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Navigation + +/// Move selection to the next visible node. +pub fn select_next(state: TreeState, t: TreeWidget) -> TreeState { + let visible = flatten_visible(t.roots, state, 0) + let ids = list.map(visible, fn(row) { row.id }) + case find_next(ids, state.selected) { + Ok(next_id) -> TreeState(..state, selected: next_id) + Error(_) -> state + } +} + +/// Move selection to the previous visible node. +pub fn select_prev(state: TreeState, t: TreeWidget) -> TreeState { + let visible = flatten_visible(t.roots, state, 0) + let ids = list.map(visible, fn(row) { row.id }) + case find_prev(ids, state.selected) { + Ok(prev_id) -> TreeState(..state, selected: prev_id) + Error(_) -> state + } +} + +/// Number of visible rows (respects expand/collapse state). +/// Use as `total` when building a scrollbar. +pub fn visible_row_count(state: TreeState, t: TreeWidget) -> Int { + list.length(flatten_visible(t.roots, state, 0)) +} + +/// Effective scroll offset for a viewport of `height` rows. +/// Use as `offset` when building a scrollbar. +pub fn effective_offset(state: TreeState, t: TreeWidget, height: Int) -> Int { + case height <= 0 { + True -> 0 + False -> { + let rows = flatten_visible(t.roots, state, 0) + visible_scroll(rows, state.selected, height) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +/// Render the tree, scrolling so the selected node is visible. +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + t: TreeWidget, + state: TreeState, +) -> buffer.Buffer { + case area.size.height <= 0 || area.size.width <= 0 { + True -> buf + False -> { + let rows = flatten_visible(t.roots, state, 0) + let scroll = visible_scroll(rows, state.selected, area.size.height) + render_rows(buf, area, t, state, rows, scroll, 0) + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal: flatten visible nodes into rows + +type VisibleRow { + VisibleRow( + id: String, + depth: Int, + label: String, + has_children: Bool, + count: Result(Int, Nil), + ) +} + +fn flatten_visible( + nodes: List(TreeNode), + state: TreeState, + depth: Int, +) -> List(VisibleRow) { + list.flat_map(nodes, fn(n) { + let has_ch = !list.is_empty(n.children) + let row = + VisibleRow( + id: n.id, + depth: depth, + label: n.label, + has_children: has_ch, + count: n.count, + ) + let child_rows = case has_ch && is_expanded(state, n.id) { + True -> flatten_visible(n.children, state, depth + 1) + False -> [] + } + [row, ..child_rows] + }) +} + +fn render_rows( + buf: buffer.Buffer, + area: geometry.Rect, + t: TreeWidget, + state: TreeState, + rows: List(VisibleRow), + scroll: Int, + row_offset: Int, +) -> buffer.Buffer { + case row_offset >= area.size.height { + True -> buf + False -> { + let visible_idx = scroll + row_offset + case list.drop(rows, visible_idx) { + [] -> buf + [row, ..] -> { + let y = area.position.y + row_offset + let is_sel = row.id == state.selected + let prefix = + repeat_string(t.glyphs.indent, row.depth) + <> case row.has_children { + True -> + case is_expanded(state, row.id) { + True -> t.glyphs.expanded + False -> t.glyphs.collapsed + } + False -> t.glyphs.leaf + } + let count_str = case row.count { + Ok(n) -> int.to_string(n) + Error(_) -> "" + } + let count_w = text.cell_width(count_str) + let label_budget = case count_w { + 0 -> area.size.width + _ -> int.max(0, area.size.width - count_w - 1) + } + let label_raw = prefix <> row.label + let label_part = text.truncate(label_raw, label_budget, "") + let label_padded = text.pad_right(label_part, label_budget) + let padded = case count_w { + 0 -> text.pad_right(label_padded, area.size.width) + _ -> label_padded <> " " <> count_str + } + let truncated = text.truncate(padded, area.size.width, "") + let padded = text.pad_right(truncated, area.size.width) + let #(fg, bg, modifier) = case is_sel { + True -> #( + t.highlight_style.fg, + t.highlight_style.bg, + t.highlight_style.modifier, + ) + False -> #(t.fg, t.bg, style.none()) + } + let buf2 = + buffer.set_string( + buf, + geometry.Position(x: area.position.x, y: y), + padded, + fg, + bg, + modifier, + ) + render_rows(buf2, area, t, state, rows, scroll, row_offset + 1) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────── +// Internal helpers + +fn node_has_children(nodes: List(TreeNode), target_id: String) -> Bool { + case nodes { + [] -> False + [n, ..rest] -> + case n.id == target_id { + True -> !list.is_empty(n.children) + False -> + node_has_children(n.children, target_id) + || node_has_children(rest, target_id) + } + } +} + +fn find_next(ids: List(String), current: String) -> Result(String, Nil) { + case ids { + [] -> Error(Nil) + [_] -> Error(Nil) + [h, next, ..rest] -> + case h == current { + True -> Ok(next) + False -> find_next([next, ..rest], current) + } + } +} + +fn find_prev(ids: List(String), current: String) -> Result(String, Nil) { + find_prev_loop(ids, current, Error(Nil)) +} + +fn find_prev_loop( + ids: List(String), + current: String, + prev: Result(String, Nil), +) -> Result(String, Nil) { + case ids { + [] -> Error(Nil) + [h, ..rest] -> + case h == current { + True -> prev + False -> find_prev_loop(rest, current, Ok(h)) + } + } +} + +fn visible_scroll( + rows: List(VisibleRow), + selected: String, + height: Int, +) -> Int { + let idx = find_row_index(rows, selected, 0) + case idx < height { + True -> 0 + False -> idx - height + 1 + } +} + +fn find_row_index(rows: List(VisibleRow), id: String, acc: Int) -> Int { + case rows { + [] -> 0 + [r, ..rest] -> + case r.id == id { + True -> acc + False -> find_row_index(rest, id, acc + 1) + } + } +} + +fn repeat_string(s: String, n: Int) -> String { + repeat_string_loop(s, n, "") +} + +fn repeat_string_loop(s: String, n: Int, acc: String) -> String { + case n <= 0 { + True -> acc + False -> repeat_string_loop(s, n - 1, acc <> s) + } +} diff --git a/src/etui_buffer_array_ffi.erl b/src/etui_buffer_array_ffi.erl new file mode 100644 index 0000000..ecb4642 --- /dev/null +++ b/src/etui_buffer_array_ffi.erl @@ -0,0 +1,167 @@ +-module(etui_buffer_array_ffi). +-export([new/2, get/2, set/3, fill_string/8, fill_all_rows/8]). +-on_load(init_module/0). + +%% Pre-allocate {content, <>, 1} tuples for all 256 bytes on module load. +%% fill_bin receives the table once per row call and uses element/2 (O(1), no alloc) +%% instead of constructing a new Content tuple per character. +init_module() -> + T = list_to_tuple([{content, <>, 1} || B <- lists:seq(0, 255)]), + persistent_term:put(etui_ascii_content_table, T), + ok. + +%% Fixed-size array with a default value for unset indices. +%% Erlang `array` is a sparse persistent trie; get/set are O(log10 N) +%% with tiny constants, much cheaper than dict for integer-keyed dense data. + +new(Size, Default) -> + array:new(Size, [{default, Default}]). + +get(Index, Arr) -> + array:get(Index, Arr). + +set(Index, Value, Arr) -> + array:set(Index, Value, Arr). + +%% Fill cells in Arr[StartIdx..MaxIdx) from a UTF-8 binary string. +%% Cell tuples are constructed directly, avoids Gleam list/fold overhead. +%% +%% Cell format mirrors buffer.gleam's Gleam types: +%% {cell, {content, Symbol, Width}, Fg, Bg, Mod, Link}, normal cell +%% {cell, continuation, Fg, Bg, Mod, <<>>}, wide-char trailer +fill_string(Arr, StartIdx, MaxIdx, Bin, Fg, Bg, Mod, Link) -> + T = persistent_term:get(etui_ascii_content_table), + fill_bin(Arr, StartIdx, MaxIdx, Bin, Fg, Bg, Mod, Link, T). + +fill_bin(Arr, Idx, MaxIdx, _, _, _, _, _, _) when Idx >= MaxIdx -> + Arr; +fill_bin(Arr, _, _, <<>>, _, _, _, _, _) -> + Arr; +%% ASCII printable fast path: cached Content tuple, single Cell alloc per char +fill_bin(Arr, Idx, MaxIdx, <>, Fg, Bg, Mod, Link, T) + when B >= 16#20, B < 16#7F -> + Content = element(B + 1, T), + Cell = {cell, Content, Fg, Bg, Mod, Link}, + fill_bin(array:set(Idx, Cell, Arr), Idx + 1, MaxIdx, Rest, Fg, Bg, Mod, Link, T); +%% Skip non-printable ASCII (control chars, DEL) +fill_bin(Arr, Idx, MaxIdx, <>, Fg, Bg, Mod, Link, T) when B < 16#20 -> + fill_bin(Arr, Idx, MaxIdx, Rest, Fg, Bg, Mod, Link, T); +fill_bin(Arr, Idx, MaxIdx, <<16#7F, Rest/binary>>, Fg, Bg, Mod, Link, T) -> + fill_bin(Arr, Idx, MaxIdx, Rest, Fg, Bg, Mod, Link, T); +%% Non-ASCII: grapheme cluster segmentation + East Asian width. +%% string:next_grapheme/1 returns [Codepoint|Rest] (single cp) +%% or [[Cp,...]|Rest] (ZWJ sequence / multi-cp cluster). +fill_bin(Arr, Idx, MaxIdx, Bin, Fg, Bg, Mod, Link, T) -> + case string:next_grapheme(Bin) of + [] -> Arr; + [G | Rest] when is_integer(G) -> + GBin = unicode:characters_to_binary([G]), + W = cp_width(G), + Cell = {cell, {content, GBin, W}, Fg, Bg, Mod, Link}, + Arr2 = array:set(Idx, Cell, Arr), + case W >= 2 of + true -> + Cont = {cell, continuation, Fg, Bg, Mod, <<>>}, + Arr3 = case Idx + 1 < MaxIdx of + true -> array:set(Idx + 1, Cont, Arr2); + false -> Arr2 + end, + fill_bin(Arr3, Idx + 2, MaxIdx, Rest, Fg, Bg, Mod, Link, T); + false -> + fill_bin(Arr2, Idx + 1, MaxIdx, Rest, Fg, Bg, Mod, Link, T) + end; + [[FirstCp | _] = GList | Rest] -> + GBin = unicode:characters_to_binary(GList), + W = cp_width(FirstCp), + Cell = {cell, {content, GBin, W}, Fg, Bg, Mod, Link}, + Arr2 = array:set(Idx, Cell, Arr), + case W >= 2 of + true -> + Cont = {cell, continuation, Fg, Bg, Mod, <<>>}, + Arr3 = case Idx + 1 < MaxIdx of + true -> array:set(Idx + 1, Cont, Arr2); + false -> Arr2 + end, + fill_bin(Arr3, Idx + 2, MaxIdx, Rest, Fg, Bg, Mod, Link, T); + false -> + fill_bin(Arr2, Idx + 1, MaxIdx, Rest, Fg, Bg, Mod, Link, T) + end + end. + +%% Fill an entire Width×Height buffer from scratch using array:from_list/2. +%% Each row gets the same Bin text. Builds cells as a reversed flat list, +%% reverses once at the end, then constructs the trie in one shot. +%% 3× faster than 60 sequential fill_string calls which rebuild the trie per row. +fill_all_rows(Width, Height, Bin, Fg, Bg, Mod, Link, Default) -> + T = persistent_term:get(etui_ascii_content_table), + RevCells = build_buffer_rev(Width, Height, 0, Bin, Fg, Bg, Mod, Link, T, Default, []), + array:from_list(lists:reverse(RevCells), Default). + +build_buffer_rev(_, Height, Row, _, _, _, _, _, _, _, RevAcc) when Row >= Height -> + RevAcc; +build_buffer_rev(Width, Height, Row, Bin, Fg, Bg, Mod, Link, T, Default, RevAcc) -> + RevAcc2 = build_row_rev(Width, 0, Bin, Fg, Bg, Mod, Link, T, Default, RevAcc), + build_buffer_rev(Width, Height, Row + 1, Bin, Fg, Bg, Mod, Link, T, Default, RevAcc2). + +%% Produces exactly Width cells, padding with Default if Bin is exhausted. +build_row_rev(Width, Col, _, _, _, _, _, _, Default, RevAcc) when Col >= Width -> + RevAcc; +build_row_rev(Width, Col, <<>>, Fg, Bg, Mod, Link, T, Default, RevAcc) -> + fill_rev(Width - Col, Default, RevAcc); +build_row_rev(Width, Col, <>, Fg, Bg, Mod, Link, T, Default, RevAcc) + when B >= 16#20, B < 16#7F -> + Content = element(B + 1, T), + Cell = {cell, Content, Fg, Bg, Mod, Link}, + build_row_rev(Width, Col + 1, Rest, Fg, Bg, Mod, Link, T, Default, [Cell | RevAcc]); +build_row_rev(Width, Col, <<_B, Rest/binary>>, Fg, Bg, Mod, Link, T, Default, RevAcc) -> + build_row_rev(Width, Col, Rest, Fg, Bg, Mod, Link, T, Default, RevAcc). + +fill_rev(0, _, Acc) -> Acc; +fill_rev(N, V, Acc) -> fill_rev(N - 1, V, [V | Acc]). + +%% East Asian Width, mirrors text.gleam's codepoint_cell_width/1. +cp_width(Cp) when Cp < 16#20 -> 0; +cp_width(16#7F) -> 0; +cp_width(Cp) when Cp >= 16#0300, + Cp =< 16#036F -> 0; % combining diacritics +cp_width(Cp) when Cp >= 16#1160, + Cp =< 16#11FF -> 0; % Hangul medial/final combining +cp_width(Cp) when Cp >= 16#FE00, + Cp =< 16#FE0F -> 0; % variation selectors +cp_width(Cp) when Cp >= 16#E0100, + Cp =< 16#E01EF -> 0; % variation selectors ext. +cp_width(16#200B) -> 0; +cp_width(16#200C) -> 0; +cp_width(16#200D) -> 0; +cp_width(16#FEFF) -> 0; +cp_width(Cp) when Cp >= 16#1100, + Cp =< 16#115F -> 2; % Hangul Jamo initial +cp_width(Cp) when Cp >= 16#2E80, + Cp =< 16#303E -> 2; % CJK Radicals / Kangxi +cp_width(Cp) when Cp >= 16#3041, + Cp =< 16#33FF -> 2; % Hiragana/Katakana/CJK compat +cp_width(Cp) when Cp >= 16#3400, + Cp =< 16#4DBF -> 2; % CJK Extension A +cp_width(Cp) when Cp >= 16#4E00, + Cp =< 16#9FFF -> 2; % CJK Unified Ideographs +cp_width(Cp) when Cp >= 16#A000, + Cp =< 16#A4CF -> 2; % Yi +cp_width(Cp) when Cp >= 16#AC00, + Cp =< 16#D7A3 -> 2; % Hangul Syllables +cp_width(Cp) when Cp >= 16#F900, + Cp =< 16#FAFF -> 2; % CJK Compatibility Ideographs +cp_width(Cp) when Cp >= 16#FE30, + Cp =< 16#FE4F -> 2; % CJK Compatibility Forms +cp_width(Cp) when Cp >= 16#FF00, + Cp =< 16#FF60 -> 2; % Fullwidth Forms +cp_width(Cp) when Cp >= 16#FFE0, + Cp =< 16#FFE6 -> 2; % Fullwidth Signs +cp_width(Cp) when Cp >= 16#1F1E6, + Cp =< 16#1F1FF -> 2; % Regional Indicators (flags) +cp_width(Cp) when Cp >= 16#1F300, + Cp =< 16#1FAFF -> 2; % Emoji (misc/pictographs/etc.) +cp_width(Cp) when Cp >= 16#20000, + Cp =< 16#2FFFD -> 2; % CJK Extensions B–F +cp_width(Cp) when Cp >= 16#30000, + Cp =< 16#3FFFD -> 2; % CJK Extension G+ +cp_width(_) -> 1. diff --git a/src/etui_buffer_array_ffi.mjs b/src/etui_buffer_array_ffi.mjs new file mode 100644 index 0000000..b095d31 --- /dev/null +++ b/src/etui_buffer_array_ffi.mjs @@ -0,0 +1,17 @@ +// JS fallback: immutable flat array (copy-on-write). +// Performance is O(N) per set, acceptable for the JS/Node target. + +export function make(size, defaultValue) { + return { data: new Array(size).fill(defaultValue), size, defaultValue }; +} + +export function get(index, arr) { + if (index >= 0 && index < arr.size) return arr.data[index]; + return arr.defaultValue; +} + +export function set(index, value, arr) { + const data = arr.data.slice(); + data[index] = value; + return { data, size: arr.size, defaultValue: arr.defaultValue }; +} diff --git a/src/etui_run_ffi.erl b/src/etui_run_ffi.erl new file mode 100644 index 0000000..3c1f4b9 --- /dev/null +++ b/src/etui_run_ffi.erl @@ -0,0 +1,16 @@ +-module(etui_run_ffi). + +-export([with_cleanup/2]). + +%% Runs Thunk, guaranteeing Cleanup executes on normal return and on any +%% exception (error, throw, or exit). On normal return the thunk's value is +%% propagated; on exception the cleanup runs and the exception re-raises. +%% This gives app.run its crash-restore semantics. Note: a hard erlang:halt +%% bypasses `after`, so abrupt aborts are covered separately by the watchdog +%% in etui_terminal_ffi. +with_cleanup(Thunk, Cleanup) -> + try + Thunk() + after + Cleanup() + end. diff --git a/src/etui_terminal_ffi.erl b/src/etui_terminal_ffi.erl new file mode 100644 index 0000000..b5ce5ef --- /dev/null +++ b/src/etui_terminal_ffi.erl @@ -0,0 +1,305 @@ +-module(etui_terminal_ffi). + +-export([enter_raw/0, exit_raw/0, window_size/0, read_with_timeout/1, + install_sigint_cleanup/1, uninstall_sigint_cleanup/0, + write_cleanup/0]). + +%% Enter raw mode via user_drv. shell:start_interactive({noshell, raw}) +%% routes through user_drv's existing prim_tty instance, no second +%% prim_tty:init call, no linked-process conflicts. +enter_raw() -> + remember_tty_path(), + case shell:start_interactive({noshell, raw}) of + ok -> ok; + {error, already_started} -> ok; + _ -> ok + end. + +%% Restore cooked mode via stty(1). Drain buffered mouse/key events first +%% so they don't leak into the shell after we exit raw mode. +%% We entered through shell:start_interactive({noshell, raw}), so restore the +%% shell reader back to cooked mode as well; stty alone is not symmetric. +exit_raw() -> + drain_input(50), + catch shell:start_interactive({noshell, cooked}), + catch io:setopts(user, [{echo, true}, {binary, false}]), + catch os:cmd("stty sane"), + ok. + +%% Write terminal restore sequences directly to /dev/tty. +%% Fallback to the current group leader if /dev/tty is unavailable. +write_cleanup() -> + write_cleanup_to_tty(false), + kill_watchdog(), + ok. + +%% Read and discard all data in the tty input buffer. +%% Keeps spawning readers until no data arrives within TimeoutMs. +%% Kills the reader process on timeout so it doesn't linger. +drain_input(TimeoutMs) -> + Self = self(), + Ref = make_ref(), + Pid = spawn(fun() -> + Chunk = io:get_chars("", 256), + Self ! {Ref, Chunk} + end), + receive + {Ref, _} -> drain_input(TimeoutMs) + after TimeoutMs -> + exit(Pid, kill), + ok + end. + +%% Install a SIGINT handler that runs CleanupFun then halts with exit code 130. +%% Call once after entering raw mode. +%% +%% Primary path (OTP < 28 / shell mode): os:set_signal(sigint, handle) works. +%% Fallback (OTP 28 noshell): os:set_signal returns badarg. We spawn an +%% OS-level bash watchdog that detects the Erlang VM dying and sends the +%% terminal-cleanup sequences to /dev/tty. The watchdog also handles the +%% case where the user presses 'a' in the BEAM break handler (erlang:halt). +install_sigint_cleanup(CleanupFun) -> + case erlang:whereis(etui_sigint_watcher) of + undefined -> ok; + Pid -> exit(Pid, replace) + end, + reset_watchdog(), + install_watchdog(), + SetSignalResult = catch os:set_signal(sigint, handle), + case SetSignalResult of + ok -> + %% Erlang-level cleanup watcher. + %% + %% Two distinct failure paths need handling: + %% 1. A real OS SIGINT routed via os:set_signal/2. + %% 2. The app process dying asynchronously (for example because + %% user_drv exits it with reason 'interrupt' before normal + %% cleanup runs). + Owner = self(), + spawn(fun() -> + catch erlang:register(etui_sigint_watcher, self()), + receive + {signal, sigint} -> + catch CleanupFun(), + write_cleanup_to_tty(true), + kill_watchdog(), + erlang:halt(130); + {owner_down, Reason} -> + case Reason of + normal -> ok; + shutdown -> ok; + _ -> + catch CleanupFun(), + write_cleanup_to_tty(true), + kill_watchdog() + end; + stop -> + ok + end + end), + spawn(fun() -> + erlang:monitor(process, Owner), + receive_signals(catch erlang:whereis(etui_sigint_watcher)) + end), + ok; + _ -> + %% OTP 28 noshell: os:set_signal(sigint, handle) can fail. + %% The watchdog is already installed above and covers abrupt VM exit. + ok + end. + +receive_signals(Watcher) -> + receive + {signal, sigint} -> + Watcher ! {signal, sigint}; + {'DOWN', _, process, _, Reason} -> + Watcher ! {owner_down, Reason} + end. + +%% Spawn a bash watchdog that detects the Erlang VM dying and sends +%% terminal cleanup sequences to /dev/tty. +%% +%% The bash parent process starts an orphan background subshell and exits +%% immediately. The Erlang port therefore points at a process that dies +%% almost instantly, port_close / erlang:halt SIGKILL is a no-op. The +%% orphan subshell runs under launchd (macOS) or init (Linux), immune to +%% any signal the Erlang VM sends. +%% +%% A flag file distinguishes normal exit (Q) from abnormal exit (halt / +%% Ctrl+C): kill_watchdog/0 creates the file before the VM exits; the +%% subshell checks for it and skips cleanup if found. +%% +%% Uses sleep (not read -t) to stay compatible with bash 3.2 (macOS). +install_watchdog() -> + MyPid = os:getpid(), + Flag = "/tmp/etui_cleanup_" ++ MyPid, + TTYPath = shell_quote(tty_path()), + %% $'\x1b' uses ANSI C quoting supported by bash 3.2+. + Inner = + "trap '' INT HUP TERM" ++ + "; P=" ++ MyPid ++ + "; F=" ++ shell_quote(Flag) ++ + "; while kill -0 \"$P\" 2>/dev/null; do sleep 0.05; done" ++ + "; [ -f \"$F\" ] && { rm -f \"$F\"; exit 0; }" ++ + "; printf $'\\x1b[?1007l\\x1b[?1015l\\x1b[?1006l\\x1b[?1005l\\x1b[?1003l\\x1b[?1002l\\x1b[?1000l\\x1b[?1049l\\x1b[0m\\x1b[?25h'" ++ + " 2>/dev/null > " ++ TTYPath ++ + "; stty sane < " ++ TTYPath ++ " > " ++ TTYPath ++ " 2>/dev/null", + %% Outer bash: launch orphan subshell and exit immediately. + Script = "(" ++ Inner ++ ") &", + spawn(fun() -> + case catch open_port( + {spawn_executable, "/bin/bash"}, + [{args, ["-c", Script]}, binary, exit_status] + ) of + Port when is_port(Port) -> + catch erlang:register(etui_watchdog_owner, self()), + watchdog_loop(Port, Flag); + _ -> + ok + end + end). + +watchdog_loop(Port, Flag) -> + receive + stop -> + %% Normal cleanup: create flag so orphan exits without firing. + catch file:write_file(Flag, <<>>), + catch port_close(Port); + {Port, _} -> + watchdog_loop(Port, Flag) + end. + +kill_watchdog() -> + Flag = "/tmp/etui_cleanup_" ++ os:getpid(), + catch file:write_file(Flag, <<>>), + case erlang:whereis(etui_watchdog_owner) of + undefined -> ok; + Pid -> Pid ! stop + end. + +reset_watchdog() -> + Flag = "/tmp/etui_cleanup_" ++ os:getpid(), + catch file:delete(Flag), + case erlang:whereis(etui_watchdog_owner) of + undefined -> ok; + Pid -> Pid ! stop + end. + +%% Restore default SIGINT behaviour and stop the watcher/watchdog. +uninstall_sigint_cleanup() -> + catch os:set_signal(sigint, default), + case erlang:whereis(etui_sigint_watcher) of + undefined -> ok; + Pid -> Pid ! stop + end, + ok. + +cleanup_sequence() -> + "\e[?1007l\e[?1015l\e[?1006l\e[?1005l\e[?1003l\e[?1002l\e[?1000l\e[?1049l\e[0m\e[?25h". + +write_cleanup_to_tty(WithNewline) -> + Suffix = case WithNewline of + true -> "\r\n"; + false -> "" + end, + Seq = cleanup_sequence() ++ Suffix, + Bin = unicode:characters_to_binary(Seq), + Path = tty_path(), + case file:write_file(Path, Bin) of + ok -> + ok; + _ -> + case file:write_file("/dev/tty", Bin) of + ok -> + ok; + _ -> + catch io:put_chars(Seq) + end + end. + +remember_tty_path() -> + case detect_tty_path() of + {ok, Path} -> + persistent_term:put({?MODULE, tty_path}, Path); + error -> ok + end. + +tty_path() -> + case persistent_term:get({?MODULE, tty_path}, undefined) of + undefined -> + case detect_tty_path() of + {ok, Path} -> + persistent_term:put({?MODULE, tty_path}, Path), + Path; + error -> "/dev/tty" + end; + Path -> Path + end. + +detect_tty_path() -> + %% os:cmd/1 runs the command with stdin redirected from /dev/null, so + %% `tty` is not reliable here. Ask ps(1) for the controlling tty of the + %% current BEAM process instead. + Cmd = "ps -o tty= -p " ++ os:getpid(), + case catch string:trim(os:cmd(Cmd)) of + TTY when is_list(TTY) -> + normalise_tty_path(TTY); + _ -> error + end. + +normalise_tty_path("") -> + error; +normalise_tty_path([$?|_]) -> + error; +normalise_tty_path("not a tty") -> + error; +normalise_tty_path("/dev/" ++ _ = Path) -> + {ok, Path}; +normalise_tty_path(TTY) -> + {ok, "/dev/" ++ TTY}. + +shell_quote(Path) -> + "'" ++ lists:flatten(string:replace(Path, "'", "'\"'\"'", all)) ++ "'". + +window_size() -> + case io:columns() of + {ok, Cols} -> + case io:rows() of + {ok, Rows} -> + {ok, {Cols, Rows}}; + _ -> + {error, could_not_get_window_size} + end; + _ -> + {error, could_not_get_window_size} + end. + +%% Non-blocking read via io:get_chars (routed through user_drv's raw-mode reader). +read_with_timeout(TimeoutMs) -> + read_io_timeout(TimeoutMs). + +read_io_timeout(TimeoutMs) -> + Self = self(), + Ref = erlang:make_ref(), + Pid = spawn(fun() -> + Raw = io:get_chars("", 128), + Self ! {Ref, input, to_binary(Raw)} + end), + receive + {Ref, input, Bin} -> {ok, Bin} + after TimeoutMs -> + exit(Pid, kill), + {error, nil} + end. + +to_binary(Raw) -> + case Raw of + eof -> <<>>; + B when is_binary(B) -> B; + L when is_list(L) -> + case unicode:characters_to_binary(L) of + Encoded when is_binary(Encoded) -> Encoded; + _ -> iolist_to_binary(L) + end; + _ -> <<>> + end. diff --git a/src/etui_tty_state.erl b/src/etui_tty_state.erl new file mode 100644 index 0000000..946243e --- /dev/null +++ b/src/etui_tty_state.erl @@ -0,0 +1,29 @@ +-module(etui_tty_state). + +-export([init/0, set_raw/1, is_raw_mode/0]). + +init() -> + case ets:whereis(etui_tty_state) of + undefined -> + ets:new(etui_tty_state, [named_table, public, set]), + ets:insert(etui_tty_state, {raw_mode, false}); + _ -> + ok + end. + +set_raw(IsRaw) -> + case ets:whereis(etui_tty_state) of + undefined -> + ets:new(etui_tty_state, [named_table, public, set]); + _ -> + ok + end, + ets:insert(etui_tty_state, {raw_mode, IsRaw}). + +is_raw_mode() -> + case ets:lookup(etui_tty_state, raw_mode) of + [{raw_mode, true}] -> + true; + _ -> + false + end. From 1502812c86547f2a423a50ea316828e7b74d16fa Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:02:31 +0200 Subject: [PATCH 03/10] Create fieldset.gleam --- src/etui/widgets/fieldset.gleam | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/etui/widgets/fieldset.gleam diff --git a/src/etui/widgets/fieldset.gleam b/src/etui/widgets/fieldset.gleam new file mode 100644 index 0000000..987fc0a --- /dev/null +++ b/src/etui/widgets/fieldset.gleam @@ -0,0 +1,140 @@ +/// Horizontal rule with an optional inline title. +/// Renders one row, full width: `── Title ────────────`. +/// Title alignment can be left, center or right. +import etui/buffer +import etui/geometry +import etui/style +import etui/text +import gleam/int +import gleam/string + +// ───────────────────────────────────────────────────────────────── +// Types + +pub type FieldsetAlign { + AlignLeft + AlignCenter + AlignRight +} + +pub type Fieldset { + Fieldset( + title: String, + align: FieldsetAlign, + line_char: String, + /// Padding rule chars on the title side. Ignored when align is `AlignCenter`. + pad: Int, + fg: style.Color, + bg: style.Color, + title_fg: style.Color, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Constructors + +pub fn fieldset_new(title: String) -> Fieldset { + Fieldset( + title: title, + align: AlignLeft, + line_char: "─", + pad: 2, + fg: style.Default, + bg: style.Default, + title_fg: style.Default, + ) +} + +pub fn with_align(fs: Fieldset, a: FieldsetAlign) -> Fieldset { + Fieldset(..fs, align: a) +} + +pub fn with_line_char(fs: Fieldset, c: String) -> Fieldset { + Fieldset(..fs, line_char: c) +} + +pub fn with_pad(fs: Fieldset, p: Int) -> Fieldset { + Fieldset(..fs, pad: int.max(0, p)) +} + +pub fn with_colors(fs: Fieldset, fg: style.Color, bg: style.Color) -> Fieldset { + Fieldset(..fs, fg: fg, bg: bg) +} + +pub fn with_title_color(fs: Fieldset, fg: style.Color) -> Fieldset { + Fieldset(..fs, title_fg: fg) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +pub fn render( + buf: buffer.Buffer, + area: geometry.Rect, + fs: Fieldset, +) -> buffer.Buffer { + case area.size.width <= 0 || area.size.height <= 0 { + True -> buf + False -> { + let w = area.size.width + let title_w = text.cell_width(fs.title) + case title_w { + 0 -> { + let line = string.repeat(fs.line_char, w) + buffer.set_string( + buf, + area.position, + line, + fs.fg, + fs.bg, + style.none(), + ) + } + _ -> { + // 1-cell space each side of title. + let label_w = title_w + 2 + let avail = int.max(0, w - label_w) + let #(left_n, right_n) = case fs.align { + AlignLeft -> #(fs.pad, int.max(0, avail - fs.pad)) + AlignRight -> #(int.max(0, avail - fs.pad), fs.pad) + AlignCenter -> { + let half = avail / 2 + #(half, avail - half) + } + } + let left = string.repeat(fs.line_char, left_n) + let right = string.repeat(fs.line_char, right_n) + let buf2 = + buffer.set_string( + buf, + area.position, + left, + fs.fg, + fs.bg, + style.none(), + ) + let buf3 = + buffer.set_string( + buf2, + geometry.Position(x: area.position.x + left_n, y: area.position.y), + " " <> fs.title <> " ", + fs.title_fg, + fs.bg, + style.bold(), + ) + buffer.set_string( + buf3, + geometry.Position( + x: area.position.x + left_n + label_w, + y: area.position.y, + ), + right, + fs.fg, + fs.bg, + style.none(), + ) + } + } + } + } +} From 283b70c13d5b69c91f01efbf84550ceb1ee8600c Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:06:17 +0200 Subject: [PATCH 04/10] Add FUNDING.yml and test GitHub Actions workflow Add repository funding metadata and a CI workflow. --- .github/FUNDING.yml | 15 +++++++++++++++ .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d0db492 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: lupodevelop +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..85e4822 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.16.0" + rebar3-version: "3" + # elixir-version: "1" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test dev + - run: gleam run --target javascript -m etui_js_smoke From 8fe4aae2bcf39f6f9fdcabc3f39815e7f0b2ed45 Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:08:08 +0200 Subject: [PATCH 05/10] Add dev example TUIs for etui demos Add a set of development/example terminal UI programs under dev/ --- dev/etui_dev.gleam | 221 ++++ dev/etui_dino.gleam | 676 ++++++++++++ dev/etui_filebrowser.gleam | 435 ++++++++ dev/etui_gleamfall.gleam | 1268 +++++++++++++++++++++ dev/etui_interactive.gleam | 2074 +++++++++++++++++++++++++++++++++++ dev/etui_js_smoke.gleam | 89 ++ dev/etui_new_features.gleam | 560 ++++++++++ dev/etui_nexus.gleam | 1350 +++++++++++++++++++++++ dev/etui_showcase.gleam | 1268 +++++++++++++++++++++ 9 files changed, 7941 insertions(+) create mode 100644 dev/etui_dev.gleam create mode 100644 dev/etui_dino.gleam create mode 100644 dev/etui_filebrowser.gleam create mode 100644 dev/etui_gleamfall.gleam create mode 100644 dev/etui_interactive.gleam create mode 100644 dev/etui_js_smoke.gleam create mode 100644 dev/etui_new_features.gleam create mode 100644 dev/etui_nexus.gleam create mode 100644 dev/etui_showcase.gleam diff --git a/dev/etui_dev.gleam b/dev/etui_dev.gleam new file mode 100644 index 0000000..7df7bab --- /dev/null +++ b/dev/etui_dev.gleam @@ -0,0 +1,221 @@ +/// Development example: demonstrates all Etui widgets and layout system. +import etui/buffer +import etui/geometry.{ + type Rect, Fill, Horizontal, Length, Position, Rect, Size, Vertical, +} +import etui/text +import etui/widgets/block +import etui/widgets/input as ginput_widget +import etui/widgets/line +import etui/widgets/list as glist_widget +import etui/widgets/paragraph +import etui/widgets/table as gtable_widget +import gleam/io +import gleam/list +import gleam/string + +pub fn main() -> Nil { + let screen = Rect(Position(0, 0), Size(80, 24)) + let buf = buffer.buffer_new(screen) + + // Split screen: 20% sidebar, 80% main + let chunks = + geometry.split(Horizontal, screen, [ + Length(20), + Fill, + ]) + + let sidebar = case chunks { + [s, ..] -> s + [] -> screen + } + + let main = case chunks { + [_, m, ..] -> m + _ -> screen + } + + // Render sidebar with list + let buf_with_sidebar = render_sidebar(buf, sidebar) + + // Render main content + let buf_final = render_main(buf_with_sidebar, main) + + // Visualize buffer + io.println("=== Etui v0.2.0 Demo (Widgets: List, Block, Paragraph, Line) ===") + io.println("") + buffer_to_lines(buf_final) + |> list.each(io.println) + io.println("") +} + +fn buffer_to_lines(buf: buffer.Buffer) -> List(String) { + let height = buffer.height(buf) + range(0, height) + |> list.map(fn(y) { + let width = buffer.width(buf) + range(0, width) + |> list.map(fn(x) { + let cell = buffer.get_cell(buf, geometry.Position(x: x, y: y)) + case buffer.is_continuation(cell) { + True -> "" + False -> buffer.cell_symbol(cell) + } + }) + |> string.concat + }) +} + +fn range(start: Int, end: Int) -> List(Int) { + case start >= end { + True -> [] + False -> [start, ..range(start + 1, end)] + } +} + +fn render_sidebar(buf: buffer.Buffer, area: Rect) -> buffer.Buffer { + // Split sidebar into header and list + let chunks = + geometry.split(Vertical, area, [ + Length(2), + Fill, + ]) + + let header_area = case chunks { + [h, ..] -> h + [] -> area + } + + let list_area = case chunks { + [_, l, ..] -> l + _ -> area + } + + // Render header block with Double border + let header_block = + block.block_new() + |> block.with_border(block.Double) + |> block.with_title("Menu", block.Top) + + let buf_with_header = block.render(buf, header_area, header_block) + + // Render list with selection + let items = ["Home", "Items", "Settings", "About"] + let list_widget = glist_widget.list_new(items) + let list_state = glist_widget.state_new() |> glist_widget.select(1) + + glist_widget.render_stateful( + buf_with_header, + list_area, + list_widget, + list_state, + ) +} + +fn render_main(buf: buffer.Buffer, area: Rect) -> buffer.Buffer { + // Split main into header and content + let chunks = + geometry.split(Vertical, area, [ + Length(3), + Fill, + ]) + + let header = case chunks { + [h, ..] -> h + [] -> area + } + + let content = case chunks { + [_, c, ..] -> c + _ -> area + } + + // Render header with title and line + let buf_with_header = render_header(buf, header) + + // Render content area + let buf_final = render_content(buf_with_header, content) + + buf_final +} + +fn render_header(buf: buffer.Buffer, area: Rect) -> buffer.Buffer { + let title = "Etui v0.2.0: List Widget Demo" + let p = + paragraph.paragraph_new(title) + |> paragraph.with_alignment(text.Center) + + let buf_with_para = paragraph.render(buf, area, p) + + // Add divider line + let line_area = + Rect( + Position(area.position.x, area.position.y + 2), + Size(area.size.width, 1), + ) + + let divider = line.line_new() + line.render_horizontal(buf_with_para, line_area, divider) +} + +fn render_content(buf: buffer.Buffer, area: Rect) -> buffer.Buffer { + // Split content into description, table, and input + let chunks = + geometry.split(Vertical, area, [ + Length(6), + Length(6), + Fill, + ]) + + let desc_area = case chunks { + [d, ..] -> d + [] -> area + } + + let table_area = case chunks { + [_, t, ..] -> t + _ -> area + } + + let input_area = case chunks { + [_, _, i, ..] -> i + _ -> area + } + + // Render description + let content_text = + "Etui v0.2.0 adds Table and Input widgets:\n• Table: Rows, columns, and selection\n• Input: Text field with editing support" + + let p = + paragraph.paragraph_new(content_text) + |> paragraph.with_alignment(text.Left) + + let buf_with_desc = paragraph.render(buf, desc_area, p) + + // Render table + let table_rows = [ + ["Widget", "Status", "Tests"], + ["List", "✓ Done", "4"], + ["Table", "✓ Done", "5"], + ["Input", "✓ Done", "7"], + ] + + let table_widget = + gtable_widget.table_new(table_rows) + |> gtable_widget.with_col_widths([12, 10, 8]) + let table_state = gtable_widget.state_new() |> gtable_widget.select_row(1) + + let buf_with_table = + gtable_widget.render_stateful( + buf_with_desc, + table_area, + table_widget, + table_state, + ) + + // Render input field + let input_widget = ginput_widget.input_new("Search...") + let input_state = ginput_widget.state_from_string("query") + + ginput_widget.render(buf_with_table, input_area, input_widget, input_state) +} diff --git a/dev/etui_dino.gleam b/dev/etui_dino.gleam new file mode 100644 index 0000000..8ac937a --- /dev/null +++ b/dev/etui_dino.gleam @@ -0,0 +1,676 @@ +/// GATUI DINO, Chrome T-Rex runner in the terminal. +/// Run: gleam run -m etui_dino +/// SPACE / ↑ to jump · q / ESC to quit · r to restart +import etui/anim +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect, rect_new} +import etui/keys +import etui/span +import etui/style +import etui/widgets/paragraph +import gleam/int +import gleam/list +import gleam/string + +// ─── Physics (fixed-point × 4) ─────────────────────────────────── + +const jump_vy = 20 + +// initial upward velocity in fp units (= 5 rows/frame) + +const gravity = 3 + +// downward acceleration per frame + +const initial_speed = 5 + +// obstacle speed in fp units/frame (= 1.25 cols/frame) + +// ─── Palette ───────────────────────────────────────────────────── + +const c_dino = style.Indexed(213) + +const c_dino_dead = style.Indexed(196) + +const c_cactus = style.Indexed(76) + +const c_ground = style.Indexed(244) + +const c_cloud = style.Indexed(237) + +const c_score = style.Indexed(255) + +const c_best = style.Indexed(214) + +const c_dim = style.Indexed(238) + +const c_hi = style.Indexed(226) + +// ─── FFI: read actual terminal size before entering raw mode ───── + +@external(erlang, "etui_terminal_ffi", "window_size") +fn tty_size() -> Result(#(Int, Int), String) { + Error("not erlang") +} + +// ─── Types ─────────────────────────────────────────────────────── + +type State { + Title + Playing + Dead +} + +// Cactus variant: determines sprite shape +type CKind { + CSpike + // 1 wide, pointed tip + CSingle + // 2 wide, one arm + CBig + // 3 wide, two arms +} + +type Obs { + Obs(x_fp: Int, h: Int, kind: CKind) +} + +type Model { + Model( + state: State, + y_fp: Int, + vy_fp: Int, + obs: List(Obs), + frame: Int, + next_obs: Int, + speed: Int, + score: Int, + best: Int, + width: Int, + height: Int, + quit: Bool, + ) +} + +fn initial_size() -> #(Int, Int) { + case tty_size() { + Ok(#(c, r)) -> #(c, r) + Error(_) -> #(80, 24) + } +} + +fn blank_model() -> Model { + let #(w, h) = initial_size() + Model( + state: Title, + y_fp: 0, + vy_fp: 0, + obs: [], + frame: 0, + next_obs: 50, + speed: initial_speed, + score: 0, + best: 0, + width: w, + height: h, + quit: False, + ) +} + +fn restart(m: Model) -> Model { + Model( + ..m, + state: Playing, + y_fp: 0, + vy_fp: 0, + obs: [], + frame: 0, + next_obs: 50, + speed: initial_speed, + score: 0, + ) +} + +// ─── Layout ────────────────────────────────────────────────────── + +fn ground_row(h: Int) -> Int { + h - 5 +} + +const dino_col = 8 + +const dino_h = 4 + +fn dino_top(h: Int, y_fp: Int) -> Int { + ground_row(h) - dino_h - y_fp / 4 +} + +fn obs_width(kind: CKind) -> Int { + case kind { + CSpike -> 1 + CSingle -> 2 + CBig -> 3 + } +} + +// ─── PRNG ──────────────────────────────────────────────────────── + +fn prng(seed: Int) -> Int { + let v = seed * 1_664_525 + 1_013_904_223 + case v < 0 { + True -> -v + False -> v + } +} + +fn irange(n: Int) -> List(Int) { + irange_acc(0, n, []) +} + +fn irange_acc(i: Int, n: Int, acc: List(Int)) -> List(Int) { + case i >= n { + True -> list.reverse(acc) + False -> irange_acc(i + 1, n, [i, ..acc]) + } +} + +// ─── Game tick ─────────────────────────────────────────────────── + +fn tick(m: Model) -> Model { + // Gravity + let new_vy = m.vy_fp - gravity + let new_y = m.y_fp + new_vy + let #(y_fp, vy_fp) = case new_y <= 0 { + True -> #(0, 0) + False -> #(new_y, new_vy) + } + + // Move obstacles left, remove offscreen + let obs = + m.obs + |> list.map(fn(o) { Obs(..o, x_fp: o.x_fp - m.speed) }) + |> list.filter(fn(o) { o.x_fp > -{ obs_width(o.kind) + 2 } * 4 }) + + let score = m.score + 1 + + // Speed increases every 200 points, max +5 + let speed = initial_speed + int.min(score / 200, 5) + + // Spawn obstacle + let #(obs, next_obs) = case m.frame >= m.next_obs { + False -> #(obs, m.next_obs) + True -> { + let seed = m.frame * 31 + score * 7 + let gap = int.max(30 - speed * 2, 15) + prng(seed) % 25 + let h = 2 + prng(seed + 3) % 3 + let kind = case prng(seed + 11) % 3 { + 0 -> CSpike + 1 -> CSingle + _ -> CBig + } + let new_obs = Obs(x_fp: m.width * 4, h: h, kind: kind) + #(list.append(obs, [new_obs]), m.frame + gap) + } + } + + // Collision: dino body cols [dino_col+1 .. dino_col+3] + // Dino clears obstacle when y_fp/4 >= obs.h - 1 (jumped high enough) + let dino_left = dino_col + 1 + let dino_right = dino_col + 3 + + let hit = + list.any(obs, fn(o) { + let ox = o.x_fp / 4 + let ow = obs_width(o.kind) + // x overlap: cactus body overlaps dino body columns + let x_hit = ox + ow - 1 >= dino_left && ox <= dino_right + // y overlap: clear when jumped at least obs.h-1 rows above ground + let y_hit = y_fp / 4 < o.h - 1 + x_hit && y_hit + }) + + let best = int.max(m.best, score) + + case hit { + True -> + Model( + ..m, + state: Dead, + y_fp: y_fp, + vy_fp: 0, + obs: obs, + score: score, + best: best, + ) + False -> + Model( + ..m, + y_fp: y_fp, + vy_fp: vy_fp, + obs: obs, + frame: m.frame + 1, + next_obs: next_obs, + speed: speed, + score: score, + best: best, + ) + } +} + +fn do_jump(m: Model) -> Model { + case m.y_fp == 0 { + True -> Model(..m, vy_fp: jump_vy) + False -> m + } +} + +// ─── Span helpers ──────────────────────────────────────────────── + +fn sp(s: String, c: style.Color) -> span.Span { + span.span_plain(s) |> span.span_fg(c) +} + +fn sp_b(s: String, c: style.Color) -> span.Span { + sp(s, c) |> span.span_modifier(style.bold()) +} + +fn row( + buf: buffer.Buffer, + x: Int, + y: Int, + w: Int, + spans: List(span.Span), +) -> buffer.Buffer { + case y < 0 || x < 0 { + True -> buf + False -> + paragraph.render_styled(buf, rect_new(x, y, w, 1), [span.line_new(spans)]) + } +} + +fn pad0(n: Int, d: Int) -> String { + string.pad_start(int.to_string(n), d, "0") +} + +// ─── Star sprite (5 wide × 4 tall) ────────────────────────────── +// +// ★ row 0, top spike (★/✦ alternating twinkle) +// ███ row 1, upper body +// ◄███► row 2, body with left/right spikes +// ▼ ▼ row 3, lower spikes A / ▼ ▼ B + +fn draw_dino( + buf: buffer.Buffer, + top: Int, + anim_frame: Int, + dead: Bool, +) -> buffer.Buffer { + let c = case dead { + True -> c_dino_dead + False -> c_dino + } + let tip = case dead { + True -> " ✕ " + False -> + case anim_frame / 8 % 2 { + 0 -> " ★ " + _ -> " ✦ " + } + } + let legs = case dead { + True -> " ▼ ▼ " + False -> + case anim_frame / 4 % 2 { + 0 -> " ▼ ▼ " + _ -> "▼ ▼" + } + } + let buf = row(buf, dino_col, top, 6, [sp_b(tip, c)]) + let buf = row(buf, dino_col, top + 1, 6, [sp_b(" ███ ", c)]) + let buf = row(buf, dino_col, top + 2, 6, [sp_b("◄███►", c)]) + row(buf, dino_col, top + 3, 6, [sp_b(legs, c)]) +} + +// ─── Cactus sprites ────────────────────────────────────────────── +// +// CSpike (1w): pointed narrow spike +// ▲ +// █ +// █ +// +// CSingle (2w): cactus with one arm +// ▐█ +// ██ ← arm row uses "▐█" or "▐█▌" etc. +// ██ +// +// CBig (3w): branching cactus +// ▲ +// ▐█▌ +// █ +// █ + +fn draw_cactus( + buf: buffer.Buffer, + col: Int, + top: Int, + h: Int, + kind: CKind, +) -> buffer.Buffer { + let c = c_cactus + case kind { + CSpike -> + list.fold(irange(h), buf, fn(b, i) { + let s = case i { + 0 -> "▲" + _ -> "█" + } + row(b, col, top + i, 2, [sp_b(s, c)]) + }) + + CSingle -> + list.fold(irange(h), buf, fn(b, i) { + // arm appears at the 2nd-from-top row + let s = case i { + 0 -> "▗█" + 1 -> "▐█" + _ -> "██" + } + row(b, col, top + i, 3, [sp_b(s, c)]) + }) + + CBig -> + list.fold(irange(h), buf, fn(b, i) { + let s = case i { + 0 -> " ▲ " + 1 -> "▐█▌" + _ -> " █ " + } + row(b, col, top + i, 4, [sp_b(s, c)]) + }) + } +} + +// ─── Ground ────────────────────────────────────────────────────── + +fn draw_ground( + buf: buffer.Buffer, + gr: Int, + w: Int, + frame: Int, +) -> buffer.Buffer { + // Ground line + let buf = row(buf, 0, gr, w, [sp_b(string.repeat("─", w), c_ground)]) + // Rolling pebbles / terrain below + let pebbles = + irange(w) + |> list.map(fn(i) { + case prng(i * 13 + frame / 4) % 10 { + 0 -> "·" + 1 -> "▫" + _ -> " " + } + }) + |> string.concat + row(buf, 0, gr + 1, w, [sp(pebbles, c_dim)]) +} + +// ─── Clouds ────────────────────────────────────────────────────── + +fn draw_clouds( + buf: buffer.Buffer, + w: Int, + h: Int, + frame: Int, +) -> buffer.Buffer { + let gr = ground_row(h) + let x1 = { w * 2 - frame / 5 % w } % w + let x2 = { w * 3 - frame / 10 % w } % w + let x3 = { w + w / 2 - frame / 7 % w } % w + let buf = case x1 + 8 < w { + True -> row(buf, x1, gr - 10, 9, [sp(" ░▒▒▒░ ", c_cloud)]) + False -> buf + } + let buf = case x2 + 7 < w { + True -> row(buf, x2, gr - 16, 8, [sp("░▒▒▒▒░", c_cloud)]) + False -> buf + } + case x3 + 6 < w { + True -> row(buf, x3, gr - 13, 7, [sp(" ░▒▒░ ", c_cloud)]) + False -> buf + } +} + +// ─── Distant hills ─────────────────────────────────────────────── + +const mountain_tile = " ▁▂▃▄▄▃▂▁ ▁▁▂▃▃▂▁▁ ▁▂▄▄▂▁ " + +fn draw_mountains( + buf: buffer.Buffer, + w: Int, + h: Int, + frame: Int, +) -> buffer.Buffer { + let gr = ground_row(h) + let y = gr - 3 + case y > 0 { + False -> buf + True -> { + let tlen = string.length(mountain_tile) + let offset = frame / 25 % tlen + let full = string.repeat(mountain_tile, w / tlen + 3) + let line = string.slice(full, offset, w) + row(buf, 0, y, w, [sp(line, style.Indexed(235))]) + } + } +} + +// ─── Birds ─────────────────────────────────────────────────────── + +fn draw_birds(buf: buffer.Buffer, w: Int, h: Int, frame: Int) -> buffer.Buffer { + let gr = ground_row(h) + let x1 = { w * 3 - frame / 6 % { w + 5 } } % w + let x2 = { w * 2 - frame / 11 % { w + 8 } } % w + let y1 = gr - 18 + let y2 = gr - 12 + let c = style.Indexed(240) + let buf = case y1 > 2 && x1 + 4 < w { + True -> row(buf, x1, y1, 4, [sp("v v", c)]) + False -> buf + } + case y2 > 2 && x2 + 4 < w { + True -> row(buf, x2, y2, 4, [sp("v v", c)]) + False -> buf + } +} + +// ─── HUD ───────────────────────────────────────────────────────── + +fn draw_hud(buf: buffer.Buffer, m: Model) -> buffer.Buffer { + // Speed as multiplier + let s10 = m.speed * 10 / initial_speed + let speed_str = + "×" <> int.to_string(s10 / 10) <> "." <> int.to_string(s10 % 10) + // Left: name + speed + let buf = + row(buf, 1, 0, 24, [ + sp_b("GATUI DINO", c_dino), + sp(" " <> speed_str, c_dim), + ]) + // Right: best + score + let score_col = int.max(m.width - 26, 28) + row(buf, score_col, 0, 25, [ + sp("BEST ", c_dim), + sp_b(pad0(m.best, 5), c_best), + sp(" SCORE ", c_dim), + sp_b(pad0(m.score, 5), c_score), + ]) +} + +// ─── Obstacles render ──────────────────────────────────────────── + +fn draw_obs( + buf: buffer.Buffer, + obs: List(Obs), + gr: Int, + w: Int, +) -> buffer.Buffer { + list.fold(obs, buf, fn(b, o) { + let col = o.x_fp / 4 + let ow = obs_width(o.kind) + case col + ow > 0 && col < w { + True -> draw_cactus(b, col, gr - o.h, o.h, o.kind) + False -> b + } + }) +} + +// ─── Screens ───────────────────────────────────────────────────── + +fn screen_title(m: Model, frame: Int) -> buffer.Buffer { + let buf = buffer.buffer_new(rect_new(0, 0, m.width, m.height)) + let gr = ground_row(m.height) + let buf = draw_mountains(buf, m.width, m.height, frame) + let buf = draw_clouds(buf, m.width, m.height, frame) + let buf = draw_birds(buf, m.width, m.height, frame) + let buf = draw_ground(buf, gr, m.width, frame) + let buf = draw_dino(buf, gr - dino_h, frame, False) + // Title + let cx = int.max(m.width / 2 - 14, 2) + let ty = gr - 12 + let blink = frame / 8 % 2 == 0 + let buf = row(buf, cx, ty, 30, [sp_b(" ▶ GATUI DINO ◀ ", c_dino)]) + let buf = + row(buf, cx, ty + 1, 30, [sp(" T-Rex runner in the terminal", c_dim)]) + let buf = + row(buf, cx, ty + 3, 30, [ + case blink { + True -> sp_b(" ▶ SPACE to start ◀ ", c_hi) + False -> sp(" ▶ SPACE to start ◀ ", c_dim) + }, + ]) + row(buf, cx, ty + 5, 30, [sp(" ↑ / SPACE jump · q quit", c_dim)]) +} + +fn screen_playing(m: Model, frame: Int) -> buffer.Buffer { + let buf = buffer.buffer_new(rect_new(0, 0, m.width, m.height)) + let gr = ground_row(m.height) + let buf = draw_mountains(buf, m.width, m.height, frame) + let buf = draw_clouds(buf, m.width, m.height, frame) + let buf = draw_birds(buf, m.width, m.height, frame) + let buf = draw_hud(buf, m) + let buf = draw_ground(buf, gr, m.width, frame) + let buf = draw_obs(buf, m.obs, gr, m.width) + draw_dino(buf, dino_top(m.height, m.y_fp), frame, False) +} + +fn screen_dead(m: Model, frame: Int) -> buffer.Buffer { + let buf = buffer.buffer_new(rect_new(0, 0, m.width, m.height)) + let gr = ground_row(m.height) + let buf = draw_mountains(buf, m.width, m.height, frame) + let buf = draw_clouds(buf, m.width, m.height, frame) + let buf = draw_birds(buf, m.width, m.height, frame) + let buf = draw_hud(buf, m) + let buf = draw_ground(buf, gr, m.width, frame) + let buf = draw_obs(buf, m.obs, gr, m.width) + // Dead dino, frozen legs, red color + let buf = draw_dino(buf, dino_top(m.height, m.y_fp), frame, True) + // Game over overlay + let cx = int.max(m.width / 2 - 13, 2) + let oy = gr - 11 + let blink = frame / 8 % 2 == 0 + let buf = row(buf, cx, oy, 28, [sp_b(" ✖ GAME OVER ", c_dino_dead)]) + let buf = + row(buf, cx, oy + 2, 28, [ + sp(" SCORE ", c_dim), + sp_b(pad0(m.score, 5), c_score), + ]) + let buf = + row(buf, cx, oy + 3, 28, [ + sp(" BEST ", c_dim), + sp_b(pad0(m.best, 5), c_best), + ]) + row(buf, cx, oy + 5, 28, [ + case blink { + True -> sp_b(" ▶ R to restart ◀ ", c_hi) + False -> sp(" ▶ R to restart ◀ ", c_dim) + }, + ]) +} + +// ─── Render ────────────────────────────────────────────────────── + +fn render(m: Model, screen: Rect, anim_st: anim.AnimState) -> buffer.Buffer { + let m = Model(..m, width: screen.size.width, height: screen.size.height) + let frame = anim_st.frame + case m.state { + Title -> screen_title(m, frame) + Playing -> screen_playing(m, frame) + Dead -> screen_dead(m, frame) + } +} + +// ─── Update ────────────────────────────────────────────────────── + +fn update(ev: backend.InputEvent, m: Model) -> Model { + case m.state { + Title -> + case ev { + backend.Resize(w, h) -> Model(..m, width: w, height: h) + backend.KeyPress(raw) -> + case keys.match(raw) { + keys.Char(" ") | keys.Up | keys.Enter -> restart(m) + keys.Char("q") | keys.Escape | keys.Ctrl("c") -> + Model(..m, quit: True) + _ -> m + } + _ -> m + } + + Playing -> { + let m = tick(m) + case ev { + backend.Resize(w, h) -> Model(..m, width: w, height: h) + backend.KeyPress(raw) -> + case keys.match(raw) { + keys.Char(" ") | keys.Up -> do_jump(m) + keys.Char("q") | keys.Escape | keys.Ctrl("c") -> + Model(..m, quit: True) + _ -> m + } + _ -> m + } + } + + Dead -> + case ev { + backend.Resize(w, h) -> Model(..m, width: w, height: h) + backend.KeyPress(raw) -> + case keys.match(raw) { + // Only R restarts, no accidental restart from other keys + keys.Char("r") -> restart(m) + keys.Char("q") | keys.Escape | keys.Ctrl("c") -> + Model(..m, quit: True) + _ -> m + } + _ -> m + } + } +} + +// ─── Entry point ───────────────────────────────────────────────── + +pub fn main() -> Nil { + let _ = + app.run_animated( + default.new(), + blank_model(), + render, + update, + fn(m) { m.quit }, + 50, + ) + Nil +} diff --git a/dev/etui_filebrowser.gleam b/dev/etui_filebrowser.gleam new file mode 100644 index 0000000..a650527 --- /dev/null +++ b/dev/etui_filebrowser.gleam @@ -0,0 +1,435 @@ +/// File browser TUI, M7 exit criterion. +/// +/// Demonstrates: geometry.split, block, paragraph, list, scrollbar, +/// span/Line for colored details, mouse support (click + scroll), +/// app.run event loop, and fio for real filesystem access. +/// +/// Run: gleam run -m etui_filebrowser +/// Keys: j/↓ down, k/↑ up, Enter cd into dir, u/h go up, q quit. +/// Mouse: scroll wheel to navigate, left-click to select, double-click Enter to cd. +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{rect_new} +import etui/keys +import etui/span +import etui/style +import etui/widgets/block +import etui/widgets/list as list_widget +import etui/widgets/paragraph +import etui/widgets/scrollbar +import fio +import fio/path as fio_path +import fio/types as fio_types +import gleam/int +import gleam/list +import gleam/string + +// ─── Model ──────────────────────────────────────────────────────── + +pub type Entry { + Entry(name: String, is_dir: Bool, size: Int) +} + +pub type Model { + Model( + path: String, + entries: List(Entry), + list_state: list_widget.ListState, + width: Int, + height: Int, + quit: Bool, + ) +} + +// ─── Filesystem helpers ─────────────────────────────────────────── + +fn load_dir(path: String) -> Result(List(Entry), Nil) { + case fio.list(path) { + Error(_) -> Error(Nil) + Ok(names) -> { + let sorted_names = list.sort(names, string.compare) + let entries = + list.filter_map(sorted_names, fn(name) { + let full_path = fio_path.join(path, name) + case fio.file_info(full_path) { + Error(_) -> Error(Nil) + Ok(info) -> { + let is_dir = case fio_types.file_info_type(info) { + fio_types.Directory -> True + _ -> False + } + Ok(Entry(name: name, is_dir: is_dir, size: info.size)) + } + } + }) + let dirs = list.filter(entries, fn(e) { e.is_dir }) + let files = list.filter(entries, fn(e) { !e.is_dir }) + Ok(list.append(dirs, files)) + } + } +} + +fn initial_model() -> Model { + let path = case fio.current_directory() { + Ok(p) -> p + Error(_) -> "." + } + let entries = case load_dir(path) { + Ok(es) -> es + Error(_) -> [] + } + Model( + path: path, + entries: entries, + list_state: list_widget.state_new(), + width: 80, + height: 24, + quit: False, + ) +} + +fn get_selected_entry(model: Model) -> Result(Entry, Nil) { + list_at(model.entries, model.list_state.selected) +} + +fn list_at(items: List(a), idx: Int) -> Result(a, Nil) { + case idx < 0 { + True -> Error(Nil) + False -> + case items { + [] -> Error(Nil) + [h, ..] if idx == 0 -> Ok(h) + [_, ..rest] -> list_at(rest, idx - 1) + } + } +} + +fn go_up(model: Model) -> Model { + let parent = fio_path.directory_name(model.path) + case parent == model.path { + True -> model + False -> + case load_dir(parent) { + Ok(entries) -> + Model( + ..model, + path: parent, + entries: entries, + list_state: list_widget.state_new(), + ) + Error(_) -> model + } + } +} + +fn enter_selected(model: Model) -> Model { + case get_selected_entry(model) { + Ok(entry) if entry.is_dir -> { + let new_path = fio_path.join(model.path, entry.name) + case load_dir(new_path) { + Ok(entries) -> + Model( + ..model, + path: new_path, + entries: entries, + list_state: list_widget.state_new(), + ) + Error(_) -> model + } + } + _ -> model + } +} + +// ─── Layout helpers (shared between render and update) ──────────── + +fn layout( + model: Model, +) -> #( + geometry.Rect, + geometry.Rect, + geometry.Rect, + geometry.Rect, + geometry.Rect, +) { + let screen = rect_new(0, 0, model.width, model.height) + let sections = + geometry.split(geometry.Vertical, screen, [ + geometry.Fill, + geometry.Length(1), + ]) + let #(main_area, status_area) = case sections { + [m, s, ..] -> #(m, s) + [m] -> #(m, rect_new(0, model.height - 1, model.width, 1)) + [] -> #(screen, rect_new(0, 0, 0, 0)) + } + let panels = + geometry.split(geometry.Horizontal, main_area, [ + geometry.Percentage(40), + geometry.Fill, + ]) + let #(left_area, right_area) = case panels { + [l, r, ..] -> #(l, r) + [l] -> #(l, rect_new(l.size.width, 0, 0, main_area.size.height)) + [] -> #(main_area, rect_new(0, 0, 0, 0)) + } + + let files_block = + block.block_new() + |> block.with_border(block.Single) + + let list_inner = block.inner(left_area, files_block) + // Split list inner: [Fill, Length(1)] → list column + scrollbar column + let inner_cols = + geometry.split(geometry.Horizontal, list_inner, [ + geometry.Fill, + geometry.Length(1), + ]) + let #(list_col, scroll_col) = case inner_cols { + [lc, sc, ..] -> #(lc, sc) + [lc] -> #(lc, rect_new(lc.size.width, lc.position.y, 0, lc.size.height)) + [] -> #(list_inner, rect_new(0, 0, 0, 0)) + } + #(left_area, right_area, status_area, list_col, scroll_col) +} + +// ─── Rendering ──────────────────────────────────────────────────── + +fn format_size(bytes: Int) -> String { + case bytes { + n if n >= 1_073_741_824 -> int.to_string(n / 1_073_741_824) <> " GB" + n if n >= 1_048_576 -> int.to_string(n / 1_048_576) <> " MB" + n if n >= 1024 -> int.to_string(n / 1024) <> " KB" + n -> int.to_string(n) <> " B" + } +} + +fn entry_display_name(e: Entry) -> String { + case e.is_dir { + True -> e.name <> "/" + False -> e.name + } +} + +// Returns detail lines as span.Line for colored display. +fn detail_span_lines(entry: Result(Entry, Nil)) -> List(span.Line) { + let label_style = + style.Style(fg: style.Default, bg: style.Default, modifier: style.bold()) + case entry { + Error(_) -> [span.line_plain("(nothing selected)")] + Ok(e) -> { + let type_color = case e.is_dir { + True -> style.Indexed(12) + False -> style.Default + } + let size_color = style.Indexed(11) + [ + span.line_new([ + span.span_styled("Name: ", label_style), + span.span_plain(e.name), + ]), + span.line_new([ + span.span_styled("Type: ", label_style), + span.span_plain(case e.is_dir { + True -> "Directory" + False -> "File" + }) + |> span.span_fg(type_color), + ]), + span.line_new([ + span.span_styled("Size: ", label_style), + span.span_plain(format_size(e.size)) + |> span.span_fg(size_color), + ]), + ] + } + } +} + +fn render(model: Model) -> List(backend.RenderOp) { + let screen = rect_new(0, 0, model.width, model.height) + let #(left_area, right_area, status_area, list_col, scroll_col) = + layout(model) + + let files_block = + block.block_new() + |> block.with_border(block.Single) + |> block.with_title("Files (↑↓/jk →/Enter ←/u q)", block.Top) + + let details_block = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title("Details", block.Top) + + let item_names = list.map(model.entries, entry_display_name) + let file_list = list_widget.list_new(item_names) + + let detail_inner = block.inner(right_area, details_block) + let detail_lines = detail_span_lines(get_selected_entry(model)) + + let status_text = + " " + <> model.path + <> " (" + <> int.to_string(list.length(model.entries)) + <> " items)" + let status_para = paragraph.paragraph_new(status_text) + + let sb = + scrollbar.scrollbar_new( + list.length(model.entries), + list_col.size.height, + list_widget.effective_offset(model.list_state, list_col.size.height), + ) + |> scrollbar.with_arrows("", "") + + let buf = + buffer.buffer_new(screen) + |> block.render(left_area, files_block) + |> list_widget.render_stateful(list_col, file_list, model.list_state) + |> scrollbar.render_vertical(scroll_col, sb) + |> block.render(right_area, details_block) + |> paragraph.render_styled(detail_inner, detail_lines) + |> paragraph.render(status_area, status_para) + + [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(buf_to_ansi(buf)), + ] +} + +// ─── Buffer → ANSI string ───────────────────────────────────────── + +fn move_cursor_seq(x: Int, y: Int) -> String { + "\u{001B}[" <> int.to_string(y + 1) <> ";" <> int.to_string(x + 1) <> "H" +} + +fn buf_to_ansi(buf: buffer.Buffer) -> String { + let area = buffer.area(buf) + let x0 = area.position.x + let y0 = area.position.y + let w = area.size.width + let h = area.size.height + rows_to_ansi(buf, x0, y0, w, h, 0, "") +} + +fn rows_to_ansi( + buf: buffer.Buffer, + x0: Int, + y0: Int, + w: Int, + h: Int, + row: Int, + acc: String, +) -> String { + case row >= h { + True -> acc <> style.ansi_reset() + False -> { + let row_str = + move_cursor_seq(x0, y0 + row) + <> row_to_ansi(buf, x0, y0 + row, w, 0, "") + rows_to_ansi(buf, x0, y0, w, h, row + 1, acc <> row_str) + } + } +} + +fn row_to_ansi( + buf: buffer.Buffer, + x0: Int, + y: Int, + w: Int, + col: Int, + acc: String, +) -> String { + case col >= w { + True -> acc + False -> { + let pos = geometry.Position(x: x0 + col, y: y) + let cell = buffer.get_cell(buf, pos) + let s = case buffer.is_continuation(cell) { + True -> "" + False -> { + let fg_seq = style.ansi_fg(buffer.cell_fg(cell)) + let bg_seq = style.ansi_bg(buffer.cell_bg(cell)) + let mod_seq = style.ansi_modifier(buffer.cell_modifier(cell)) + let needs_reset = fg_seq != "" || bg_seq != "" || mod_seq != "" + let reset = case needs_reset { + True -> style.ansi_reset() + False -> "" + } + fg_seq <> bg_seq <> mod_seq <> buffer.cell_symbol(cell) <> reset + } + } + row_to_ansi(buf, x0, y, w, col + 1, acc <> s) + } + } +} + +// ─── Event handler ──────────────────────────────────────────────── + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char("q") | keys.Char("Q") -> Model(..model, quit: True) + keys.Down | keys.Char("j") -> + Model( + ..model, + list_state: list_widget.select_next( + model.list_state, + list.length(model.entries), + ), + ) + keys.Up | keys.Char("k") -> + Model(..model, list_state: list_widget.select_prev(model.list_state)) + keys.Enter | keys.Right -> enter_selected(model) + keys.Left | keys.Char("u") | keys.Char("h") -> go_up(model) + _ -> model + } + backend.Resize(w, h) -> Model(..model, width: w, height: h) + backend.MouseScroll(_, _, True) -> + Model(..model, list_state: list_widget.select_prev(model.list_state)) + backend.MouseScroll(_, _, False) -> + Model( + ..model, + list_state: list_widget.select_next( + model.list_state, + list.length(model.entries), + ), + ) + backend.MousePress(x, y, backend.MouseLeft) -> { + let #(_left, _right, _status, list_col, _scroll) = layout(model) + let in_col = + x >= list_col.position.x + && x < list_col.position.x + list_col.size.width + && y >= list_col.position.y + && y < list_col.position.y + list_col.size.height + case in_col { + False -> model + True -> { + let effective = + list_widget.effective_offset(model.list_state, list_col.size.height) + let clicked = y - list_col.position.y + effective + let clamped = int.clamp(clicked, 0, list.length(model.entries) - 1) + Model( + ..model, + list_state: list_widget.select(model.list_state, clamped), + ) + } + } + } + _ -> model + } +} + +// ─── Entry point ────────────────────────────────────────────────── + +pub fn main() -> Nil { + let model = initial_model() + let b = default.new() + let _ = app.run(b, model, render, update, fn(m) { m.quit }, 16) + Nil +} diff --git a/dev/etui_gleamfall.gleam b/dev/etui_gleamfall.gleam new file mode 100644 index 0000000..66a9a0c --- /dev/null +++ b/dev/etui_gleamfall.gleam @@ -0,0 +1,1268 @@ +/// GLEAMFALL TUI mock, phosphor-green retro NASA NEO tracker. +/// +/// Run: gleam run -m etui_gleamfall +/// Screens: Boot → KeyPrompt → Loading → NeoListView ↔ Detail +/// NeoListView → SearchPrompt (/) → NeoListView +/// NeoListView → ChartsView (c) → NeoListView +/// Keys: j/k navigate; ↵ detail; / search; h haz; s sort; c charts; x reset; q quit. +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{rect_new} +import etui/keys +import etui/span +import etui/style +import etui/widgets/gauge +import etui/widgets/input as input_widget +import etui/widgets/list as list_widget +import etui/widgets/paragraph +import etui/widgets/scrollbar +import gleam/float +import gleam/int +import gleam/list +import gleam/string + +// ─── Color palette ──────────────────────────────────────────────── + +const c_phos = style.Indexed(46) + +const c_dphos = style.Indexed(34) + +const c_pink = style.Indexed(213) + +const c_spink = style.Indexed(218) + +const c_dim = style.Indexed(240) + +// ─── Span helpers ───────────────────────────────────────────────── + +fn phos(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_phos) +} + +fn phos_b(s: String) -> span.Span { + phos(s) |> span.span_modifier(style.bold()) +} + +fn phos_r(s: String) -> span.Span { + phos(s) |> span.span_modifier(style.reverse()) +} + +fn dphos(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_dphos) +} + +fn pk(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_pink) +} + +fn pk_b(s: String) -> span.Span { + pk(s) |> span.span_modifier(style.bold()) +} + +fn pk_r(s: String) -> span.Span { + pk(s) |> span.span_modifier(style.reverse()) +} + +fn spk(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_spink) +} + +fn dim(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_dim) +} + +fn dim_b(s: String) -> span.Span { + dim(s) |> span.span_modifier(style.bold()) +} + +fn gap(n: Int) -> span.Span { + span.span_plain(string.repeat(" ", n)) +} + +fn hint(key: String, label: String) -> List(span.Span) { + [phos_r(" " <> key <> " "), dim(" " <> label)] +} + +fn hint_pk(key: String, label: String) -> List(span.Span) { + [pk_r(" " <> key <> " "), dim(" " <> label)] +} + +fn put1( + buf: buffer.Buffer, + x: Int, + y: Int, + w: Int, + spans: List(span.Span), +) -> buffer.Buffer { + paragraph.render_styled(buf, rect_new(x, y, w, 1), [span.line_new(spans)]) +} + +// ─── Fake NEO data ──────────────────────────────────────────────── + +pub type Neo { + Neo( + name: String, + magnitude: Float, + diameter_km: Float, + velocity_kms: Float, + is_hazardous: Bool, + is_sentry: Bool, + approach_date: String, + miss_dist_ld: Float, + ) +} + +const fake_neos: List(Neo) = [ + Neo("(2024 YR4)", 26.7, 0.057, 17.3, True, True, "2024-01-01", 0.06), + Neo("(2003 QQ47)", 20.5, 1.2, 22.1, True, False, "2024-01-01", 3.2), + Neo("(2024 BX1)", 29.3, 0.009, 8.6, False, False, "2024-01-02", 0.001), + Neo("433 Eros", 11.2, 16.8, 24.4, False, False, "2024-01-02", 44.0), + Neo("(2024 PT5)", 27.1, 0.011, 0.5, False, False, "2024-01-03", 0.003), + Neo("(2025 AA1)", 24.8, 0.085, 31.2, True, False, "2024-01-03", 15.8), + Neo("(2023 DW)", 25.1, 0.049, 15.7, False, True, "2024-01-04", 1.2), + Neo("(2024 MK)", 23.9, 0.18, 19.5, True, False, "2024-01-04", 8.4), + Neo("(2019 OK)", 22.8, 0.26, 24.0, True, False, "2024-01-05", 0.62), + Neo("(2021 UA1)", 28.4, 0.002, 0.2, False, False, "2024-01-05", 0.0002), + Neo("(2020 SW)", 26.9, 0.004, 7.9, False, False, "2024-01-06", 0.013), + Neo("(2022 AP7)", 17.9, 1.1, 30.6, True, True, "2024-01-06", 70.0), + Neo("(2023 TL4)", 25.5, 0.031, 11.3, False, False, "2024-01-06", 2.1), + Neo("(2024 GJ2)", 24.2, 0.13, 20.8, True, False, "2024-01-07", 5.3), + Neo("(2018 LV3)", 23.4, 0.17, 16.5, False, False, "2024-01-07", 12.7), +] + +// ─── Filter & Sort ──────────────────────────────────────────────── + +pub type SortKey { + SortName + SortMagnitude + SortDiameter + SortVelocity +} + +pub type Filter { + Filter(hazard_only: Bool, sort_by: SortKey, search: String) +} + +fn apply_filter(neos: List(Neo), f: Filter) -> List(Neo) { + neos + |> list.filter(fn(n) { + case f.hazard_only { + True -> n.is_hazardous + False -> True + } + }) + |> list.filter(fn(n) { + case f.search { + "" -> True + q -> string.contains(string.lowercase(n.name), string.lowercase(q)) + } + }) + |> sort_neos(f.sort_by) +} + +fn sort_neos(neos: List(Neo), by: SortKey) -> List(Neo) { + case by { + SortName -> list.sort(neos, fn(a, b) { string.compare(a.name, b.name) }) + SortMagnitude -> + list.sort(neos, fn(a, b) { float.compare(a.magnitude, b.magnitude) }) + SortDiameter -> + list.sort(neos, fn(a, b) { float.compare(b.diameter_km, a.diameter_km) }) + SortVelocity -> + list.sort(neos, fn(a, b) { float.compare(b.velocity_kms, a.velocity_kms) }) + } +} + +fn cycle_sort(s: SortKey) -> SortKey { + case s { + SortName -> SortMagnitude + SortMagnitude -> SortDiameter + SortDiameter -> SortVelocity + SortVelocity -> SortName + } +} + +fn sort_label(s: SortKey) -> String { + case s { + SortName -> "NAME" + SortMagnitude -> "MAG" + SortDiameter -> "SIZE" + SortVelocity -> "VEL" + } +} + +// ─── Model ──────────────────────────────────────────────────────── + +pub type Screen { + Boot + KeyPrompt(api_input: input_widget.InputState) + Loading(progress: Int) + NeoListView(cursor: Int, offset: Int) + SearchPrompt(buffer: String, back_cursor: Int, back_offset: Int) + ChartsView(back_cursor: Int, back_offset: Int) + Detail(neo: Neo, back_cursor: Int, back_offset: Int) +} + +pub type Model { + Model(screen: Screen, filter: Filter, width: Int, height: Int, quit: Bool) +} + +fn initial_model() -> Model { + Model( + screen: Boot, + filter: Filter(hazard_only: False, sort_by: SortName, search: ""), + width: 80, + height: 24, + quit: False, + ) +} + +// ─── Boot screen ───────────────────────────────────────────────── + +const banner_lines: List(String) = [ + " ██████ ██ ███████ █████ ███ ███ ███████ █████ ██ ██", + "██ ██ ██ ██ ██ ████ ████ ██ ██ ██ ██ ██", + "██ ███ ██ █████ ███████ ██ ████ ██ █████ ███████ ██ ██", + "██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██", + " ██████ ███████ ███████ ██ ██ ██ ██ ██ ██ ██ ███████ ███████", +] + +const boot_checks: List(String) = [ + "POWER-ON SELF-TEST", + "LOAD ASTROMETRIC LIBRARY", + "ESTABLISH NASA NEoWs LINK", + "INITIALIZE TERMLINK INTERFACE", + "MOUNT VAULT-TEC SUBSYSTEM", +] + +fn render_boot(model: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let content_h = + list.length(banner_lines) + 3 + 1 + list.length(boot_checks) + 3 + let top_y = int.max(model.height / 2 - content_h / 2, 1) + let indent = int.max(model.width / 2 - 36, 2) + let content_w = model.width - indent * 2 + + let banner_lines_ = + list.map(banner_lines, fn(line) { span.line_new([phos_b(line)]) }) + let buf = + paragraph.render_styled( + buf, + rect_new(indent, top_y, content_w, list.length(banner_lines)), + banner_lines_, + ) + + let info_y = top_y + list.length(banner_lines) + 1 + let buf = + put1(buf, indent, info_y, content_w, [ + phos_b("ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL"), + ]) + let buf = + put1(buf, indent, info_y + 1, content_w, [ + dphos("NEAR-EARTH OBJECT TRACKING SUBSYSTEM v0.1.0"), + ]) + let buf = + put1(buf, indent, info_y + 2, content_w, [ + dim("-- COPYRIGHT 2026 GLEAMFALL (MOCK) --"), + ]) + + let checks_y = info_y + 4 + let check_lines = + list.map(boot_checks, fn(label) { + let dotted = string.pad_end(label, 38, ".") + span.line_new([ + dim("> "), + phos(dotted), + phos(" ["), + phos_b("OK"), + phos("]"), + ]) + }) + let buf = + paragraph.render_styled( + buf, + rect_new(indent, checks_y, content_w, list.length(boot_checks)), + check_lines, + ) + + let prompt_y = checks_y + list.length(boot_checks) + 2 + put1(buf, indent, prompt_y, content_w, [ + pk_b("> PRESS ANY KEY TO PROCEED "), + pk_r(" ▌ "), + ]) +} + +// ─── KeyPrompt screen ───────────────────────────────────────────── + +fn render_key_prompt( + model: Model, + inp: input_widget.InputState, +) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let frame_w = int.min(60, model.width - 4) + let cx = int.max(model.width / 2 - frame_w / 2, 2) + let cy = int.max(model.height / 2 - 5, 1) + + let title_pad = int.max(frame_w - 4 - 20, 0) + let top = "═══ ENTER NASA API KEY " <> string.repeat("═", title_pad) <> "═" + let bottom = string.repeat("═", frame_w) + + let buf = put1(buf, cx, cy, frame_w, [phos_b(top)]) + + let #(key_text, key_span) = case inp.value { + "" -> #(string.pad_end("(start typing...)", frame_w - 12, " "), fn(s) { + dim(s) + }) + v -> #( + string.pad_end(string.repeat("•", string.length(v)), frame_w - 12, " "), + fn(s) { pk(s) }, + ) + } + let buf = + put1(buf, cx + 4, cy + 2, frame_w - 4, [ + dim("KEY ▸ "), + key_span(key_text), + pk_r(" "), + ]) + + let hints = + list.flatten([ + hint("ENTER", "CONFIRM"), + [gap(3)], + hint("ESC", "DEMO KEY"), + [gap(3)], + hint("^C", "QUIT"), + ]) + let buf = put1(buf, cx + 4, cy + 4, frame_w - 4, hints) + let buf = put1(buf, cx, cy + 6, frame_w, [phos(bottom)]) + put1(buf, cx + 4, cy + 7, frame_w - 4, [ + dim("✎ KEY WILL BE SAVED TO "), + dphos("./.env"), + dim(" (NASA_API_KEY=...)"), + ]) +} + +// ─── Loading screen ─────────────────────────────────────────────── + +const loading_steps: List(String) = [ + "INITIALIZING...", + "QUERYING api.nasa.gov...", + "PARSING ORBITAL DATA...", + "SORTING BY APPROACH DATE...", + "READY.", +] + +fn render_loading(model: Model, progress: Int) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let frame_w = int.min(64, model.width - 4) + let cx = int.max(model.width / 2 - frame_w / 2, 2) + let cy = int.max(model.height / 2 - 4, 1) + + let pad = int.max(frame_w - 21, 0) + let top = "═══ LOADING NEO FEED " <> string.repeat("═", pad) + let bottom = string.repeat("═", frame_w) + + let buf = put1(buf, cx, cy, frame_w, [pk_b(top)]) + let buf = + put1(buf, cx + 4, cy + 2, frame_w - 4, [ + pk_b("⠿ "), + phos_b("FETCHING NEAR-EARTH OBJECTS"), + ]) + let buf = + put1(buf, cx + 4, cy + 3, frame_w - 4, [ + dim(" range "), + dphos("2024-01-01 → 2024-01-07"), + ]) + + let g = + gauge.gauge_new(progress) + |> gauge.with_label(int.to_string(progress) <> "%") + |> gauge.with_colors(c_pink, style.Default) + let buf = gauge.render(buf, rect_new(cx + 4, cy + 5, frame_w - 8, 1), g) + + let n_steps = list.length(loading_steps) + let step_idx = int.min(progress * n_steps / 101, n_steps - 1) + let step_label = case list.drop(loading_steps, step_idx) { + [s, ..] -> s + [] -> "READY." + } + let buf = put1(buf, cx + 4, cy + 6, frame_w - 4, [dim(step_label)]) + put1(buf, cx, cy + 8, frame_w, [pk(bottom)]) +} + +// ─── NeoListView screen ─────────────────────────────────────────── + +fn neo_row(neo: Neo, selected: Bool, max_dia: Float) -> span.Line { + let cursor_s = case selected { + True -> pk_b("▌▌ ") + False -> span.span_plain(" ") + } + let glyph_s = case neo.is_hazardous, neo.is_sentry { + True, _ -> pk_b("☢") + False, True -> spk("◎") + False, False -> dim("·") + } + let name_field = string.pad_end(neo.name, 26, " ") + let name_s = case selected { + True -> pk_r(name_field) + False -> phos(name_field) + } + span.line_new([ + cursor_s, + glyph_s, + gap(2), + name_s, + gap(2), + dphos(string.pad_end(float_1(neo.magnitude), 5, " ")), + gap(2), + pk(diameter_bar(neo.diameter_km, max_dia, 10)), + gap(2), + dphos(string.pad_end(diameter_label(neo.diameter_km), 8, " ")), + gap(2), + dphos(float_2(neo.velocity_kms)), + ]) +} + +fn build_filter_spans(f: Filter, visible: Int, total: Int) -> List(span.Span) { + let haz_part = case f.hazard_only { + False -> [] + True -> [pk_b("[HAZ]"), gap(1)] + } + let search_part = case f.search { + "" -> [] + q -> [pk("[/" <> q <> "]"), gap(1)] + } + let sort_part = [dim("[sort:"), spk(sort_label(f.sort_by)), dim("]")] + let count_part = case visible == total { + True -> [] + False -> [ + gap(1), + dim("("), + pk(int.to_string(visible)), + dim("/"), + dim(int.to_string(total)), + dim(")"), + ] + } + list.flatten([haz_part, search_part, sort_part, count_part]) +} + +fn render_neo_list(model: Model, cursor: Int, offset: Int) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let all_neos = fake_neos + let visible_neos = apply_filter(all_neos, model.filter) + let total_all = list.length(all_neos) + let total_vis = list.length(visible_neos) + let hazardous = list.length(list.filter(all_neos, fn(n) { n.is_hazardous })) + let sentries = list.length(list.filter(all_neos, fn(n) { n.is_sentry })) + let max_dia = compute_max_dia(visible_neos) + let w = model.width - 4 + + let buf = + put1(buf, 2, 1, w, [ + phos_b("GLEAMFALL"), + dim(" :: "), + dphos("NEO TRACKING"), + gap(4), + dim("2024-01-01 → 2024-01-07"), + ]) + let buf = put1(buf, 2, 2, w, [phos(string.repeat("═", w))]) + + let filter_spans = build_filter_spans(model.filter, total_vis, total_all) + let buf = + put1( + buf, + 2, + 3, + w, + list.flatten([ + [ + dim("TRACKED "), + phos_b(int.to_string(total_all)), + dim(" ☢ "), + case hazardous { + 0 -> dim("0") + n -> pk_b(int.to_string(n)) + }, + dim(" ◎ "), + case sentries { + 0 -> dim("0") + n -> spk(int.to_string(n)) + }, + gap(3), + ], + filter_spans, + ]), + ) + + let header_cols = [ + gap(4), + dim_b("ST "), + dim_b(string.pad_end("NAME", 28, " ")), + dim_b(string.pad_end("MAG", 7, " ")), + dim_b(string.pad_end("REL.SIZE", 12, " ")), + dim_b(string.pad_end("DIAMETER", 10, " ")), + dim_b("V(km/s)"), + ] + let buf = put1(buf, 2, 4, w, header_cols) + let buf = put1(buf, 2, 5, w, [dim(string.repeat("─", w))]) + + let row_count = int.max(model.height - 9, 3) + let buf = case total_vis { + 0 -> { + let msg_y = model.height / 2 - 1 + let msg_x = int.max(model.width / 2 - 18, 2) + let buf = + put1(buf, msg_x, msg_y, 38, [pk("· NO RESULTS — CHANGE FILTERS ·")]) + put1( + buf, + msg_x, + msg_y + 2, + 36, + list.flatten([ + hint("x", "CLEAR FILTERS"), + [gap(2)], + hint("h", "TOGGLE HAZ"), + ]), + ) + } + _ -> { + let visible = visible_neos |> list.drop(offset) |> list.take(row_count) + let row_lines = + list.index_map(visible, fn(neo, i) { + neo_row(neo, offset + i == cursor, max_dia) + }) + paragraph.render_styled(buf, rect_new(2, 6, w - 1, row_count), row_lines) + } + } + + let sb = + scrollbar.scrollbar_new(total_vis, row_count, offset) + |> scrollbar.with_arrows("", "") + let buf = + scrollbar.render_vertical( + buf, + rect_new(model.width - 2, 6, 1, row_count), + sb, + ) + + let buf = put1(buf, 2, model.height - 2, w, [dim(string.repeat("─", w))]) + let cursor_display = case total_vis { + 0 -> "0/0" + _ -> int.to_string(cursor + 1) <> "/" <> int.to_string(total_vis) + } + let footer = + list.flatten([ + hint("↑↓", "MOVE"), + [gap(2)], + hint("↵", "DETAIL"), + [gap(2)], + hint("/", "FIND"), + [gap(2)], + hint_pk("h", "HAZ"), + [gap(2)], + hint_pk("s", "SORT:" <> sort_label(model.filter.sort_by)), + [gap(2)], + hint_pk("c", "CHARTS"), + [gap(2)], + hint("x", "RESET"), + [gap(2)], + hint("q", "QUIT"), + [gap(2)], + [dim(cursor_display)], + ]) + put1(buf, 2, model.height - 1, w, footer) +} + +// ─── SearchPrompt screen ────────────────────────────────────────── + +fn render_search(model: Model, search_buf: String) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let frame_w = int.min(60, model.width - 4) + let cx = int.max(model.width / 2 - frame_w / 2, 2) + let cy = int.max(model.height / 2 - 4, 1) + + let title_pad = int.max(frame_w - 4 - 16, 0) + let top = "═══ FILTER BY NAME " <> string.repeat("═", title_pad) <> "═" + let bottom = string.repeat("═", frame_w) + + let buf = put1(buf, cx, cy, frame_w, [pk_b(top)]) + let input_pad = + string.repeat(" ", int.max(frame_w - 12 - string.length(search_buf), 0)) + let buf = + put1(buf, cx + 4, cy + 2, frame_w - 4, [ + dim("MATCH ▸ "), + pk(search_buf), + pk_r(" "), + dim(input_pad), + ]) + let buf = + put1( + buf, + cx + 4, + cy + 4, + frame_w - 4, + list.flatten([ + hint("ENTER", "APPLY"), + [gap(3)], + hint("ESC", "CANCEL"), + [gap(3)], + hint("⌫", "DELETE"), + ]), + ) + let buf = put1(buf, cx, cy + 6, frame_w, [pk(bottom)]) + put1(buf, cx + 4, cy + 7, frame_w - 4, [ + dim("substring match · case-insensitive · 2024-01-01 → 2024-01-07"), + ]) +} + +// ─── ChartsView screen ──────────────────────────────────────────── + +type DayBucket { + DayBucket(date: String, count: Int, hazardous: Int, max_miss_ld: Float) +} + +const chart_dates: List(String) = [ + "2024-01-01", + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + "2024-01-06", + "2024-01-07", +] + +fn bucket_by_day(neos: List(Neo)) -> List(DayBucket) { + list.map(chart_dates, fn(date) { + let day_neos = list.filter(neos, fn(n) { n.approach_date == date }) + let count = list.length(day_neos) + let haz = list.length(list.filter(day_neos, fn(n) { n.is_hazardous })) + let max_ld = + list.fold(day_neos, 0.0, fn(acc, n) { + case n.miss_dist_ld >. acc { + True -> n.miss_dist_ld + False -> acc + } + }) + DayBucket(date: date, count: count, hazardous: haz, max_miss_ld: max_ld) + }) +} + +fn short_date(iso: String) -> String { + case string.split(iso, "-") { + [_, mm, dd] -> mm <> "/" <> dd + _ -> iso + } +} + +fn render_distance_scatter( + neos: List(Neo), + chart_w: Int, +) -> List(List(span.Span)) { + let buckets = bucket_by_day(neos) + let max_ld = + list.fold(buckets, 0.0, fn(acc, b) { + case b.max_miss_ld >. acc { + True -> b.max_miss_ld + False -> acc + } + }) + let bar_w = int.max(chart_w - 16, 10) + list.map(buckets, fn(b) { + let scale = case max_ld >. 0.0 { + True -> int.to_float(bar_w) /. max_ld + False -> 1.0 + } + let bar_len = int.clamp(float_round(b.max_miss_ld *. scale), 0, bar_w) + let bar_chars = string.repeat("█", bar_len) + let pad_chars = string.repeat(" ", int.max(bar_w - bar_len, 0)) + let bar_span = case b.hazardous > 0 { + True -> pk(bar_chars) + False -> phos(bar_chars) + } + let haz_tag = case b.hazardous { + 0 -> [gap(2), dim(" ")] + n -> [gap(2), pk_b("☢" <> int.to_string(n))] + } + let count_s = string.pad_start("(" <> int.to_string(b.count) <> ")", 4, " ") + let ld_s = case b.count { + 0 -> string.pad_end("─", 8, " ") + _ -> string.pad_end(float_1(b.max_miss_ld) <> " LD", 8, " ") + } + list.flatten([ + [ + dphos(short_date(b.date)), + gap(2), + bar_span, + span.span_plain(pad_chars), + gap(1), + dim(count_s), + gap(2), + dphos(ld_s), + ], + haz_tag, + ]) + }) +} + +fn render_size_histogram( + neos: List(Neo), + chart_w: Int, +) -> List(List(span.Span)) { + let bins = [ + #("< 50 m", fn(d: Float) { d <. 0.05 }), + #("50 – 200 m", fn(d) { d >=. 0.05 && d <. 0.2 }), + #("200 m – 1 km", fn(d) { d >=. 0.2 && d <. 1.0 }), + #("1 – 5 km", fn(d) { d >=. 1.0 && d <. 5.0 }), + #("> 5 km", fn(d) { d >=. 5.0 }), + ] + let counts = + list.map(bins, fn(b) { + let #(label, pred) = b + #(label, list.length(list.filter(neos, fn(n) { pred(n.diameter_km) }))) + }) + let max_count = + list.fold(counts, 0, fn(acc, c) { + case c.1 > acc { + True -> c.1 + False -> acc + } + }) + let bar_w = int.max(chart_w - 22, 8) + list.map(counts, fn(c) { + let #(label, count) = c + let filled = case max_count { + 0 -> 0 + m -> count * bar_w / m + } + let bar_s = string.repeat("█", filled) + let empty_s = string.repeat("░", int.max(bar_w - filled, 0)) + let pad_label = string.pad_end(label, 16, " ") + let count_str = "(" <> int.to_string(count) <> ")" + case count { + 0 -> [dim(pad_label), gap(2), dim(empty_s), gap(2), dim(count_str)] + _ -> [ + dphos(pad_label), + gap(2), + pk(bar_s), + dim(empty_s), + gap(2), + dim(count_str), + ] + } + }) +} + +fn render_charts(model: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let neos = apply_filter(fake_neos, model.filter) + let inner_w = model.width - 4 + let chart_w = int.max(inner_w - 6, 24) + + let buf = + put1(buf, 2, 1, inner_w, [ + phos_b("GLEAMFALL"), + dim(" :: "), + dphos("DATA VISUALIZATION"), + gap(4), + dim("2024-01-01 → 2024-01-07"), + ]) + let buf = put1(buf, 2, 2, inner_w, [phos(string.repeat("═", inner_w))]) + + let buf = + put1(buf, 2, 3, inner_w, [ + dphos("─── CLOSEST APPROACH PER DAY "), + dim("(bar = max miss distance in LD)"), + ]) + let buf = + put1(buf, 4, 4, inner_w - 2, [ + dim("longer bar = farther "), + pk("pink"), + dim(" = day with ☢ NEO"), + ]) + + let scatter_lines = render_distance_scatter(neos, chart_w) + let scatter_h = list.length(scatter_lines) + let buf = + paragraph.render_styled( + buf, + rect_new(4, 5, inner_w - 2, scatter_h), + list.map(scatter_lines, fn(line) { span.line_new(line) }), + ) + + let hist_y = 5 + scatter_h + 1 + let buf = + put1(buf, 2, hist_y, inner_w, [ + dphos("─── SIZE DISTRIBUTION "), + dim("(NEO count per diameter bin)"), + ]) + let hist_lines = render_size_histogram(neos, chart_w) + let hist_h = list.length(hist_lines) + let buf = + paragraph.render_styled( + buf, + rect_new(4, hist_y + 1, inner_w - 2, hist_h), + list.map(hist_lines, fn(line) { span.line_new(line) }), + ) + + let buf = + put1(buf, 2, model.height - 2, inner_w, [ + dim(string.repeat("─", inner_w)), + ]) + let footer = + list.flatten([ + hint("b/ESC/c", "BACK"), + [gap(3)], + hint("q", "QUIT"), + ]) + put1(buf, 2, model.height - 1, inner_w, footer) +} + +// ─── Detail screen ──────────────────────────────────────────────── + +fn render_detail(model: Model, neo: Neo) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let frame_w = int.min(68, model.width - 4) + let cx = int.max(model.width / 2 - frame_w / 2, 2) + let cy = int.max(model.height / 2 - 8, 1) + + let title_pad = int.max(frame_w - 30, 0) + let top = + "╔══ NEAR-EARTH OBJECT DETAIL " <> string.repeat("═", title_pad) <> "╗" + let side_l = "║ " + let bottom = "╚" <> string.repeat("═", frame_w - 2) <> "╝" + + let buf = put1(buf, cx, cy, frame_w, [phos_b(top)]) + let buf = + put1(buf, cx, cy + 2, frame_w, [ + phos(side_l), + pk_b(string.pad_end(neo.name, frame_w - 6, " ")), + phos(" ║"), + ]) + + let status_text = case neo.is_hazardous, neo.is_sentry { + True, True -> [pk_b("☢ HAZARDOUS"), gap(2), spk("◎ SENTRY WATCH")] + True, False -> [pk_b("☢ HAZARDOUS")] + False, True -> [spk("◎ SENTRY WATCH")] + False, False -> [dphos("· SAFE")] + } + let status_field_w = frame_w - 18 + let buf = + put1(buf, cx, cy + 3, frame_w, [ + phos(side_l), + dim_b("STATUS "), + ..list.append(status_text, [ + span.span_plain(string.repeat( + " ", + int.max(status_field_w - neo_status_width(neo), 0), + )), + phos(" ║"), + ]) + ]) + + let data_rows = [ + #("MAGNITUDE ", float_1(neo.magnitude), ""), + #("DIAMETER ", diameter_label(neo.diameter_km), ""), + #("VELOCITY ", float_2(neo.velocity_kms), " km/s"), + #("APPROACH ", neo.approach_date, ""), + #("MISS DIST ", float_2(neo.miss_dist_ld), " LD"), + ] + let buf = + list.index_fold(data_rows, buf, fn(b, row, i) { + let #(label, value, suffix) = row + let pad = + int.max( + frame_w - 6 - 12 - string.length(value) - string.length(suffix), + 0, + ) + put1(b, cx, cy + 5 + i, frame_w, [ + phos(side_l), + dim_b(label), + phos_b(value), + dphos(suffix), + gap(pad), + phos(" ║"), + ]) + }) + + let buf = + put1(buf, cx, cy + 11, frame_w, [ + phos(side_l <> string.repeat(" ", frame_w - 4) <> "║"), + ]) + let buf = put1(buf, cx, cy + 12, frame_w, [phos(bottom)]) + + let buf = + put1(buf, cx, cy + 14, frame_w, [ + dim("MOCK — real detail would query "), + dphos("api.nasa.gov/neo/{id}"), + ]) + let back_hints = + list.flatten([hint("←/h/ESC", "BACK TO LIST"), [gap(2)], hint("q", "QUIT")]) + put1(buf, cx, cy + 15, frame_w, back_hints) +} + +fn neo_status_width(neo: Neo) -> Int { + case neo.is_hazardous, neo.is_sentry { + True, True -> 27 + True, False -> 11 + False, True -> 14 + False, False -> 6 + } +} + +// ─── Rendering dispatcher ───────────────────────────────────────── + +fn render(model: Model) -> List(backend.RenderOp) { + let buf = case model.screen { + Boot -> render_boot(model) + KeyPrompt(inp) -> render_key_prompt(model, inp) + Loading(pct) -> render_loading(model, pct) + NeoListView(cursor, off) -> render_neo_list(model, cursor, off) + SearchPrompt(search_buf, _, _) -> render_search(model, search_buf) + ChartsView(_, _) -> render_charts(model) + Detail(neo, _, _) -> render_detail(model, neo) + } + let screen = rect_new(0, 0, model.width, model.height) + [ + backend.ClearScreen, + backend.MoveCursor(0, 0), + backend.Write(buf_to_ansi(buf, screen)), + ] +} + +// ─── Update ─────────────────────────────────────────────────────── + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.Resize(w, h) -> Model(..model, width: w, height: h) + _ -> + case model.screen { + Boot -> + case event { + backend.KeyPress(_) -> + Model(..model, screen: KeyPrompt(input_widget.state_new())) + _ -> model + } + + KeyPrompt(inp) -> + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char("q") -> Model(..model, quit: True) + keys.Enter | keys.Escape -> Model(..model, screen: Loading(0)) + keys.Backspace | keys.Delete -> + Model(..model, screen: KeyPrompt(input_widget.backspace(inp))) + keys.Char(c) -> + Model( + ..model, + screen: KeyPrompt(input_widget.insert_char( + input_widget.input_new("API KEY"), + inp, + c, + )), + ) + _ -> model + } + _ -> model + } + + Loading(pct) -> + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + backend.KeyPress(_) -> Model(..model, screen: NeoListView(0, 0)) + backend.Tick -> + case pct >= 100 { + True -> Model(..model, screen: NeoListView(0, 0)) + False -> Model(..model, screen: Loading(int.min(pct + 5, 100))) + } + _ -> model + } + + NeoListView(cursor, off) -> { + let visible = apply_filter(fake_neos, model.filter) + let total = list.length(visible) + let row_count = int.max(model.height - 9, 3) + let adj = fn(c) { + list_widget.effective_offset( + list_widget.ListState(selected: c, offset: off), + row_count, + ) + } + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char("q") -> Model(..model, quit: True) + keys.Down | keys.Char("j") -> { + let c = int.min(cursor + 1, int.max(total - 1, 0)) + Model(..model, screen: NeoListView(c, adj(c))) + } + keys.Up | keys.Char("k") -> { + let c = int.max(cursor - 1, 0) + Model(..model, screen: NeoListView(c, adj(c))) + } + keys.Enter -> { + case list.drop(visible, cursor) { + [neo, ..] -> + Model(..model, screen: Detail(neo, cursor, off)) + [] -> model + } + } + keys.Char("/") -> + Model( + ..model, + screen: SearchPrompt(model.filter.search, cursor, off), + ) + keys.Char("h") -> { + let nf = + Filter( + ..model.filter, + hazard_only: !model.filter.hazard_only, + ) + Model(..model, filter: nf, screen: NeoListView(0, 0)) + } + keys.Char("s") -> { + let nf = + Filter( + ..model.filter, + sort_by: cycle_sort(model.filter.sort_by), + ) + Model(..model, filter: nf, screen: NeoListView(0, 0)) + } + keys.Char("c") -> + Model(..model, screen: ChartsView(cursor, off)) + keys.Char("x") -> { + let reset = + Filter(hazard_only: False, sort_by: SortName, search: "") + Model(..model, filter: reset, screen: NeoListView(0, 0)) + } + _ -> model + } + backend.MouseScroll(_, _, True) -> { + let c = int.max(cursor - 1, 0) + Model(..model, screen: NeoListView(c, adj(c))) + } + backend.MouseScroll(_, _, False) -> { + let c = int.min(cursor + 1, int.max(total - 1, 0)) + Model(..model, screen: NeoListView(c, adj(c))) + } + backend.MousePress(_, y, backend.MouseLeft) -> { + let row_y = y - 6 + case row_y >= 0 && row_y < row_count { + True -> { + let c = int.clamp(off + row_y, 0, int.max(total - 1, 0)) + Model(..model, screen: NeoListView(c, adj(c))) + } + False -> model + } + } + _ -> model + } + } + + SearchPrompt(search_buf, back_c, back_o) -> + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char("q") -> Model(..model, quit: True) + keys.Enter -> { + let nf = + Filter(..model.filter, search: string.trim(search_buf)) + Model(..model, filter: nf, screen: NeoListView(0, 0)) + } + keys.Escape -> + Model(..model, screen: NeoListView(back_c, back_o)) + keys.Backspace | keys.Delete -> { + let new_buf = case string.length(search_buf) { + 0 -> "" + n -> string.slice(search_buf, 0, n - 1) + } + Model(..model, screen: SearchPrompt(new_buf, back_c, back_o)) + } + keys.Char(c) -> + Model( + ..model, + screen: SearchPrompt(search_buf <> c, back_c, back_o), + ) + _ -> model + } + _ -> model + } + + ChartsView(back_c, back_o) -> + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char("q") -> Model(..model, quit: True) + keys.Escape | keys.Char("b") | keys.Char("c") -> + Model(..model, screen: NeoListView(back_c, back_o)) + _ -> model + } + _ -> model + } + + Detail(_, back_c, back_o) -> + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char("q") -> Model(..model, quit: True) + keys.Left | keys.Char("h") | keys.Escape -> + Model(..model, screen: NeoListView(back_c, back_o)) + _ -> model + } + _ -> model + } + } + } +} + +// ─── ANSI helpers ───────────────────────────────────────────────── + +fn buf_to_ansi(buf: buffer.Buffer, area: geometry.Rect) -> String { + rows_to_ansi( + buf, + area.position.x, + area.position.y, + area.size.width, + area.size.height, + 0, + "", + ) +} + +fn rows_to_ansi( + buf: buffer.Buffer, + x0: Int, + y0: Int, + w: Int, + h: Int, + row: Int, + acc: String, +) -> String { + case row >= h { + True -> acc <> style.ansi_reset() + False -> + rows_to_ansi( + buf, + x0, + y0, + w, + h, + row + 1, + acc + <> move_cursor_seq(x0, y0 + row) + <> row_to_ansi(buf, x0, y0 + row, w, 0, ""), + ) + } +} + +fn row_to_ansi( + buf: buffer.Buffer, + x0: Int, + y: Int, + w: Int, + col: Int, + acc: String, +) -> String { + case col >= w { + True -> acc + False -> { + let pos = geometry.Position(x: x0 + col, y: y) + let cell = buffer.get_cell(buf, pos) + let s = case buffer.is_continuation(cell) { + True -> "" + False -> { + let fg_seq = style.ansi_fg(buffer.cell_fg(cell)) + let bg_seq = style.ansi_bg(buffer.cell_bg(cell)) + let mod_seq = style.ansi_modifier(buffer.cell_modifier(cell)) + case fg_seq != "" || bg_seq != "" || mod_seq != "" { + True -> + fg_seq + <> bg_seq + <> mod_seq + <> buffer.cell_symbol(cell) + <> style.ansi_reset() + False -> buffer.cell_symbol(cell) + } + } + } + row_to_ansi(buf, x0, y, w, col + 1, acc <> s) + } + } +} + +fn move_cursor_seq(x: Int, y: Int) -> String { + "\u{001B}[" <> int.to_string(y + 1) <> ";" <> int.to_string(x + 1) <> "H" +} + +// ─── Float & size formatting ────────────────────────────────────── + +fn float_1(f: Float) -> String { + let w = float_floor(f) + let frac = float_round({ f -. int.to_float(w) } *. 10.0) + int.to_string(w) <> "." <> int.to_string(frac) +} + +fn float_2(f: Float) -> String { + let w = float_floor(f) + let frac = float_round({ f -. int.to_float(w) } *. 100.0) + int.to_string(w) + <> "." + <> case frac < 10 { + True -> "0" <> int.to_string(frac) + False -> int.to_string(frac) + } +} + +fn diameter_bar(d_km: Float, max_km: Float, width: Int) -> String { + let filled = case max_km >. 0.0 { + True -> + int.clamp(float_round(d_km /. max_km *. int.to_float(width)), 0, width) + False -> 0 + } + string.repeat("█", filled) <> string.repeat("░", int.max(width - filled, 0)) +} + +fn diameter_label(d_km: Float) -> String { + case d_km <. 1.0 { + True -> float_0(d_km *. 1000.0) <> "m" + False -> float_1(d_km) <> "km" + } +} + +fn float_0(f: Float) -> String { + int.to_string(float_round(f)) +} + +fn compute_max_dia(neos: List(Neo)) -> Float { + list.fold(neos, 0.0, fn(acc, n) { + case n.diameter_km >. acc { + True -> n.diameter_km + False -> acc + } + }) +} + +fn float_floor(f: Float) -> Int { + float.truncate(float.floor(f)) +} + +fn float_round(f: Float) -> Int { + float.round(f) +} + +// ─── Entry point ────────────────────────────────────────────────── + +pub fn main() -> Nil { + let model = initial_model() + let b = default.new() + let _ = app.run(b, model, render, update, fn(m) { m.quit }, 50) + Nil +} diff --git a/dev/etui_interactive.gleam b/dev/etui_interactive.gleam new file mode 100644 index 0000000..1b087c6 --- /dev/null +++ b/dev/etui_interactive.gleam @@ -0,0 +1,2074 @@ +/// Interactive Etui demo, full feature showcase. +/// TAB/←→=pagina ↑↓=nav b=blink q=quit +import etui/anim +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect, Position, Rect, Size} +import etui/style +import etui/text +import etui/widgets/block as gblock_widget +import etui/widgets/canvas as gcanvas_widget +import etui/widgets/chart as gchart_widget +import etui/widgets/gauge as ggauge_widget +import etui/widgets/gradient_bar as ggradient_widget +import etui/widgets/hbar as ghbar_widget +import etui/widgets/input as ginput_widget +import etui/widgets/list as glist_widget +import etui/widgets/marquee as gmarquee_widget +import etui/widgets/progress as gprogress_widget +import etui/widgets/scene as gscene_widget +import etui/widgets/sparkline as gspark_widget +import etui/widgets/spinner as gspinner_widget +import etui/widgets/table as gtable_widget +import etui/widgets/tabs as gtabs_widget +import gleam/int +import gleam/list + +// ───────────────────────────────────────────────────────────────── +// Types + +type Focus { + FocusList + FocusTable + FocusSearch + FocusCursor + FocusProgress + FocusGauge + FocusChart + FocusAnimations + FocusHBar + FocusCanvas + FocusScene +} + +type AppState { + AppState( + focus: Focus, + list_state: glist_widget.ListState, + table_state: gtable_widget.TableState, + input_state: ginput_widget.InputState, + cursor_idx: Int, + progress_pct: Int, + gauge_pct: Int, + chart_fill_idx: Int, + blink_list: Bool, + blink_table: Bool, + anim: anim.AnimState, + quit: Bool, + ) +} + +// ───────────────────────────────────────────────────────────────── +// Main + +pub fn main() -> Nil { + let state = + AppState( + focus: FocusList, + list_state: glist_widget.state_new(), + table_state: gtable_widget.state_new(), + input_state: ginput_widget.state_new(), + cursor_idx: 0, + progress_pct: 50, + gauge_pct: 65, + chart_fill_idx: 0, + blink_list: False, + blink_table: False, + anim: anim.anim_new(), + quit: False, + ) + let _ = + app.run_buffered_cursor( + default.new(), + state, + fn(s, screen) { #(render(s, screen), Error(Nil)) }, + on_event, + fn(s) { s.quit }, + 100, + ) + Nil +} + +// ───────────────────────────────────────────────────────────────── +// Event handler + +fn on_event(event: backend.InputEvent, state: AppState) -> AppState { + case event { + backend.KeyPress("q") -> AppState(..state, quit: True) + backend.KeyPress("tab") -> AppState(..state, focus: next_focus(state.focus)) + backend.KeyPress("up") -> handle_up(state) + backend.KeyPress("down") -> handle_down(state) + backend.KeyPress("right") -> handle_right(state) + backend.KeyPress("left") -> handle_left(state) + backend.KeyPress("backspace") -> handle_backspace(state) + backend.KeyPress(c) -> handle_char(state, c) + backend.Tick -> AppState(..state, anim: anim.tick(state.anim)) + _ -> state + } +} + +fn handle_up(state: AppState) -> AppState { + case state.focus { + FocusList -> + AppState(..state, list_state: glist_widget.select_prev(state.list_state)) + FocusTable -> + AppState( + ..state, + table_state: gtable_widget.select_prev_row(state.table_state), + ) + FocusCursor -> + AppState(..state, cursor_idx: int.max(0, state.cursor_idx - 1)) + FocusProgress -> + AppState( + ..state, + progress_pct: int.clamp(state.progress_pct + 10, 0, 100), + ) + FocusGauge -> + AppState(..state, gauge_pct: int.clamp(state.gauge_pct + 10, 0, 100)) + FocusChart -> + AppState(..state, chart_fill_idx: { state.chart_fill_idx + 4 } % 5) + FocusSearch | FocusAnimations | FocusHBar | FocusCanvas | FocusScene -> + state + } +} + +fn handle_down(state: AppState) -> AppState { + case state.focus { + FocusList -> + AppState( + ..state, + list_state: glist_widget.select_next(state.list_state, 10), + ) + FocusTable -> + AppState( + ..state, + table_state: gtable_widget.select_next_row(state.table_state, 15), + ) + FocusCursor -> + AppState(..state, cursor_idx: int.min(5, state.cursor_idx + 1)) + FocusProgress -> + AppState( + ..state, + progress_pct: int.clamp(state.progress_pct - 10, 0, 100), + ) + FocusGauge -> + AppState(..state, gauge_pct: int.clamp(state.gauge_pct - 10, 0, 100)) + FocusChart -> + AppState(..state, chart_fill_idx: { state.chart_fill_idx + 1 } % 5) + FocusSearch | FocusAnimations | FocusHBar | FocusCanvas | FocusScene -> + state + } +} + +fn handle_right(state: AppState) -> AppState { + case state.focus { + FocusProgress -> + AppState(..state, progress_pct: int.min(100, state.progress_pct + 1)) + FocusGauge -> + AppState(..state, gauge_pct: int.min(100, state.gauge_pct + 1)) + _ -> AppState(..state, focus: next_focus(state.focus)) + } +} + +fn handle_left(state: AppState) -> AppState { + case state.focus { + FocusProgress -> + AppState(..state, progress_pct: int.max(0, state.progress_pct - 1)) + FocusGauge -> AppState(..state, gauge_pct: int.max(0, state.gauge_pct - 1)) + _ -> AppState(..state, focus: prev_focus(state.focus)) + } +} + +fn handle_char(state: AppState, c: String) -> AppState { + case state.focus { + FocusSearch -> { + let widget = + ginput_widget.input_new("Ricerca...") + |> ginput_widget.with_max_length(50) + AppState( + ..state, + input_state: ginput_widget.insert_char(widget, state.input_state, c), + ) + } + _ -> + case c { + "b" -> + AppState( + ..state, + blink_list: !state.blink_list, + blink_table: !state.blink_table, + ) + _ -> state + } + } +} + +fn handle_backspace(state: AppState) -> AppState { + case state.focus { + FocusSearch -> + AppState(..state, input_state: ginput_widget.backspace(state.input_state)) + _ -> state + } +} + +fn next_focus(f: Focus) -> Focus { + case f { + FocusList -> FocusTable + FocusTable -> FocusSearch + FocusSearch -> FocusCursor + FocusCursor -> FocusProgress + FocusProgress -> FocusGauge + FocusGauge -> FocusChart + FocusChart -> FocusAnimations + FocusAnimations -> FocusHBar + FocusHBar -> FocusCanvas + FocusCanvas -> FocusScene + FocusScene -> FocusList + } +} + +fn prev_focus(f: Focus) -> Focus { + case f { + FocusList -> FocusScene + FocusTable -> FocusList + FocusSearch -> FocusTable + FocusCursor -> FocusSearch + FocusProgress -> FocusCursor + FocusGauge -> FocusProgress + FocusChart -> FocusGauge + FocusAnimations -> FocusChart + FocusHBar -> FocusAnimations + FocusCanvas -> FocusHBar + FocusScene -> FocusCanvas + } +} + +// ───────────────────────────────────────────────────────────────── +// Cursor shape helpers + +fn cursor_shape_name(idx: Int) -> String { + case idx { + 0 -> "Bar blink" + 1 -> "Bar steady" + 2 -> "Block blink" + 3 -> "Block steady" + 4 -> "Underline blink" + _ -> "Underline steady" + } +} + +fn cursor_shape_code(idx: Int) -> String { + case idx { + 0 -> "\\e[5 q" + 1 -> "\\e[6 q" + 2 -> "\\e[1 q" + 3 -> "\\e[2 q" + 4 -> "\\e[3 q" + _ -> "\\e[4 q" + } +} + +fn cursor_shape_preview(idx: Int) -> String { + case idx { + 0 | 1 -> "▎" + 2 | 3 -> "█" + _ -> "▁" + } +} + +// ───────────────────────────────────────────────────────────────── +// Layout helper: inner area of a bordered block (1px border) + +fn inner(area: Rect) -> Rect { + Rect( + Position(area.position.x + 1, area.position.y + 1), + Size(int.max(0, area.size.width - 2), int.max(0, area.size.height - 2)), + ) +} + +// ───────────────────────────────────────────────────────────────── +// Rendering + +fn render(state: AppState, screen: Rect) -> buffer.Buffer { + let w = screen.size.width + let h = screen.size.height + let buf = buffer.buffer_new(screen) + let content = Rect(Position(0, 1), Size(w, h - 2)) + + // Tab bar (row 0) + let active_tab = case state.focus { + FocusList -> 0 + FocusTable -> 1 + FocusSearch -> 2 + FocusCursor -> 3 + FocusProgress -> 4 + FocusGauge -> 5 + FocusChart -> 6 + FocusAnimations -> 7 + FocusHBar -> 8 + FocusCanvas -> 9 + FocusScene -> 10 + } + let buf = + gtabs_widget.render( + buf, + Rect(Position(0, 0), Size(w, 1)), + gtabs_widget.tabs_new([ + "LISTA", "TABELLA", "RICERCA", "CURSORI", "PROG", "GAUGE", "CHART", + "ANIM", "HBAR", "CANVAS", "SCENA", + ]) + |> gtabs_widget.with_active(active_tab) + |> gtabs_widget.with_divider("│") + |> gtabs_widget.with_padding(1), + ) + + // Page content + let buf = case state.focus { + FocusList -> page_lista(buf, content, state) + FocusTable -> page_tabella(buf, content, state) + FocusSearch -> page_ricerca(buf, content, state) + FocusCursor -> page_cursori(buf, content, state) + FocusProgress -> page_progress(buf, content, state) + FocusGauge -> page_gauge(buf, content, state) + FocusChart -> page_chart(buf, content, state) + FocusAnimations -> page_animazioni(buf, content, state) + FocusHBar -> page_hbar(buf, content, state) + FocusCanvas -> page_canvas(buf, content, state) + FocusScene -> page_scene(buf, content, state) + } + + // Status bar (row h-1) + buffer.set_string( + buf, + Position(0, h - 1), + make_status(state, w), + style.Default, + style.Default, + style.reverse(), + ) +} + +fn make_status(state: AppState, w: Int) -> String { + let s = case state.focus { + FocusList -> "[LISTA] TAB/←→=pagina ↑↓=nav b=blink q=quit" + FocusTable -> "[TABELLA] TAB/←→=pagina ↑↓=nav b=blink q=quit" + FocusSearch -> "[RICERCA] TAB/←→=pagina digita=input ⌫=del q=quit" + FocusCursor -> "[CURSORI] TAB/←→=pagina ↑↓=seleziona forma q=quit" + FocusProgress -> "[PROG] TAB/←→=pagina ↑↓=±10 ←→=±1 q=quit" + FocusGauge -> "[GAUGE] TAB/←→=pagina ↑↓=±10 ←→=±1 q=quit" + FocusChart -> "[CHART] TAB/←→=pagina ↑↓=cambia fill q=quit" + FocusAnimations -> "[ANIM] TAB/←→=pagina q=quit" + FocusHBar -> "[HBAR] TAB/←→=pagina q=quit" + FocusCanvas -> "[CANVAS] TAB/←→=pagina q=quit" + FocusScene -> "[SCENA] TAB/←→=pagina q=quit" + } + text.pad_right(s, w) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: LISTA, lista widget + sparklines + spinners + +fn page_lista( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let lw = int.min(32, w / 3) + let rw = int.max(1, w - lw - 1) + let rx = area.position.x + lw + 1 + + // Left: bordered lista + let left = Rect(Position(area.position.x, area.position.y), Size(lw, ch)) + let buf = + gblock_widget.render( + buf, + left, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Rounded) + |> gblock_widget.with_title("LISTA ↑↓=nav", gblock_widget.Top), + ) + let list_items = [ + "Home", "Widgets", "Sparkline", "Chart", "Gauge", "Cursori", "Progress", + "Animazioni", "Ricerca", "Impostazioni", + ] + let blink_p = case state.blink_list { + True -> 6 + False -> 0 + } + let buf = + glist_widget.render_animated( + buf, + inner(left), + glist_widget.list_new(list_items) |> glist_widget.with_blink(blink_p), + state.list_state, + state.anim.frame, + ) + + // Right: sparklines + let buf = + buffer.set_string( + buf, + Position(rx, area.position.y), + text.pad_right("SPARKLINE WIDGET (5 varianti fill)", rw), + style.Default, + style.Default, + style.bold(), + ) + let wave = fn(phase) { + range(0, rw) + |> list.map(fn(x) { + anim.oscillate(0, 100, state.anim.frame + x * 3 + phase, 50) + }) + } + let fills = [ + #(gspark_widget.SparkAnimatedRainbow, "SparkAnimatedRainbow period=60", 2), + #( + gspark_widget.SparkAnimated([ + style.Rgb(0, 80, 220), + style.Rgb(80, 220, 80), + style.Rgb(220, 80, 0), + ]), + "SparkAnimated blu→verde→rosso", + 5, + ), + #(gspark_widget.SparkRainbow, "SparkRainbow (hue statico per colonna)", 8), + #( + gspark_widget.SparkGradient([ + style.Rgb(220, 0, 120), + style.Rgb(255, 140, 0), + style.Rgb(255, 255, 0), + ]), + "SparkGradient fucsia→arancio→giallo", + 11, + ), + #( + gspark_widget.SparkSolid(style.Rgb(0, 200, 255)), + "SparkSolid rgb(0,200,255)", + 14, + ), + ] + let buf = + list.fold(fills, buf, fn(b, f) { + let #(fill, label, dy) = f + let b2 = + gspark_widget.render( + b, + Rect(Position(rx, area.position.y + dy), Size(rw, 1)), + gspark_widget.sparkline_new(wave(dy * 4)) + |> gspark_widget.with_fill(fill) + |> gspark_widget.with_period(60), + state.anim.frame, + ) + buffer.set_string( + b2, + Position(rx, area.position.y + dy + 1), + label, + style.Rgb(150, 150, 150), + style.Default, + style.none(), + ) + }) + + // Spinners + let buf = + buffer.set_string( + buf, + Position(rx, area.position.y + 17), + text.pad_right("SPINNER WIDGET (4 stili)", rw), + style.Default, + style.Default, + style.bold(), + ) + let spinner_row = area.position.y + 18 + let sp_col = int.max(18, rw / 2) + let spinners = [ + #(gspinner_widget.Dots, "Dots", 0, 0), + #(gspinner_widget.Line, "Line", sp_col, 0), + #(gspinner_widget.Circle, "Circle", 0, 1), + #(gspinner_widget.Bounce, "Bounce", sp_col, 1), + ] + let buf = + list.fold(spinners, buf, fn(b, sp) { + let #(style_val, label, dx, dy) = sp + gspinner_widget.render( + b, + Rect(Position(rx + dx, spinner_row + dy), Size(18, 1)), + gspinner_widget.spinner_new() + |> gspinner_widget.with_style(style_val) + |> gspinner_widget.with_label(label), + state.anim.frame, + ) + }) + + let bl = case state.blink_list { + True -> "blink:∎ ON" + False -> "blink:□ off" + } + buffer.set_string( + buf, + Position(rx, area.position.y + ch - 1), + bl <> " b=toggle frame=" <> int.to_string(state.anim.frame), + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: TABELLA, table widget + API reference + +fn page_tabella( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let tw = int.min(58, w * 2 / 3) + let rw = int.max(1, w - tw - 1) + let rx = area.position.x + tw + 1 + + let table_area = + Rect(Position(area.position.x, area.position.y), Size(tw, ch)) + let buf = + gblock_widget.render( + buf, + table_area, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Single) + |> gblock_widget.with_title( + "TABELLA ↑↓=nav b=blink", + gblock_widget.Top, + ), + ) + let table_rows = [ + ["Widget", "Modulo", "Anim"], + ["Lista", "list.gleam", "sì"], + ["Tabella", "table.gleam", "sì"], + ["Input", "input.gleam", "no"], + ["Cursore", "cursor.gleam", "—"], + ["Progress", "progress.gleam", "sì"], + ["Gradient", "gradient_bar.gleam", "sì"], + ["Sparkline", "sparkline.gleam", "sì"], + ["Marquee", "marquee.gleam", "sì"], + ["Chart", "chart.gleam", "sì"], + ["Gauge", "gauge.gleam", "no"], + ["Block", "block.gleam", "no"], + ["Paragraph", "paragraph.gleam", "no"], + ["Spinner", "spinner.gleam", "sì"], + ["Tabs", "tabs.gleam", "no"], + ] + let blink_p = case state.blink_table { + True -> 6 + False -> 0 + } + let buf = + gtable_widget.render_animated( + buf, + inner(table_area), + gtable_widget.table_new(table_rows) + |> gtable_widget.with_col_widths([12, 22, 5]) + |> gtable_widget.with_blink(blink_p), + state.table_state, + state.anim.frame, + ) + + // Right: API reference + let buf = + buffer.set_string( + buf, + Position(rx, area.position.y), + text.pad_right("TABLE API", rw), + style.Default, + style.Default, + style.bold(), + ) + let api_lines = [ + "table_new(rows) → TableWidget", + "with_col_widths([w1,w2,...])", + "with_blink(period)", + "with_header_style(style)", + "", + "render_animated(buf, area,", + " widget, state, frame)", + "", + "state_new() → TableState", + "select_next_row(state, n)", + "select_prev_row(state)", + "", + "TableState:", + " .selected_row Int", + " .offset Int", + ] + let buf = + list.index_fold(api_lines, buf, fn(b, line, i) { + buffer.set_string( + b, + Position(rx, area.position.y + 2 + i), + line, + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + }) + let bl = case state.blink_table { + True -> "blink:∎ ON" + False -> "blink:□ off" + } + buffer.set_string( + buf, + Position(rx, area.position.y + ch - 1), + bl <> " b=toggle row=" <> int.to_string(state.table_state.selected_row), + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: RICERCA, input widget + API reference + +fn page_ricerca( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let iw = int.min(60, w - 4) + let ix = area.position.x + { w - iw } / 2 + + // Input block centered + let input_area = Rect(Position(ix, area.position.y + 1), Size(iw, 3)) + let buf = + gblock_widget.render( + buf, + input_area, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Single) + |> gblock_widget.with_title("RICERCA digita qui", gblock_widget.Top), + ) + let buf = + ginput_widget.render( + buf, + Rect(Position(ix + 1, area.position.y + 2), Size(iw - 2, 1)), + ginput_widget.input_new("digita qui...") + |> ginput_widget.with_max_length(50), + state.input_state, + ) + + // Current value display + let val = state.input_state.value + let display = case val { + "" -> "(vuoto)" + s -> "\"" <> s <> "\"" + } + let buf = + buffer.set_string( + buf, + Position(ix, area.position.y + 5), + "valore: " <> display, + style.Rgb(180, 255, 180), + style.Default, + style.none(), + ) + let buf = + buffer.set_string( + buf, + Position(ix, area.position.y + 6), + "cursore: " <> int.to_string(state.input_state.cursor), + style.Rgb(180, 255, 180), + style.Default, + style.none(), + ) + let buf = + buffer.set_string( + buf, + Position(ix, area.position.y + 7), + "lunghezza: " <> int.to_string(text.cell_width(val)), + style.Rgb(180, 255, 180), + style.Default, + style.none(), + ) + + // API reference + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y + 9), + text.pad_right("INPUT API", w), + style.Default, + style.Default, + style.bold(), + ) + let api_lines = [ + "input_new(placeholder) → InputWidget", + "with_max_length(n) → InputWidget", + "with_colors(fg, bg) → InputWidget", + "", + "state_new() → InputState", + "state_from_string(s) → InputState", + "insert_char(widget, state, ch) → InputState", + "backspace(state) → InputState", + "move_cursor_left(state) → InputState", + "move_cursor_right(state) → InputState", + "clear_state(state) → InputState", + "", + "InputState: .value String .cursor Int", + ] + let buf = + list.index_fold(api_lines, buf, fn(b, line, i) { + buffer.set_string( + b, + Position(area.position.x + 2, area.position.y + 11 + i), + line, + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + }) + buffer.set_string( + buf, + Position(area.position.x, area.position.y + ch - 1), + "digita=inserisci ⌫=cancella TAB=pagina successiva", + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: CURSORI, cursor shapes + DECSCUSR + +fn page_cursori( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let lw = int.min(42, w / 2) + let rw = int.max(1, w - lw - 1) + let rx = area.position.x + lw + 1 + + // Left: cursor shapes list + let left = Rect(Position(area.position.x, area.position.y), Size(lw, ch)) + let buf = + gblock_widget.render( + buf, + left, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Rounded) + |> gblock_widget.with_title("CURSORI ↑↓=seleziona", gblock_widget.Top), + ) + let shapes = [ + #(0, "Bar blink", style.blink(), "\\e[5 q"), + #(1, "Bar steady", style.none(), "\\e[6 q"), + #(2, "Block blink", style.blink(), "\\e[1 q"), + #(3, "Block steady", style.none(), "\\e[2 q"), + #(4, "Underline blink", style.blink(), "\\e[3 q"), + #(5, "Underline steady", style.none(), "\\e[4 q"), + ] + let buf = + list.fold(shapes, buf, fn(b, shape) { + let #(idx, name, preview_mod, code) = shape + let is_sel = idx == state.cursor_idx + let ry = area.position.y + 2 + idx + let sel_ch = case is_sel { + True -> "▶ " + False -> " " + } + let row_mod = case is_sel { + True -> style.reverse() + False -> style.none() + } + let row_text = text.pad_right(sel_ch <> name <> " " <> code, lw - 4) + let b2 = + buffer.set_string( + b, + Position(area.position.x + 2, ry), + row_text, + style.Default, + style.Default, + row_mod, + ) + // Preview char at right edge with actual blink modifier + let prev_fg = case is_sel { + True -> style.Rgb(255, 220, 0) + False -> style.Rgb(100, 100, 100) + } + buffer.set_string( + b2, + Position(area.position.x + lw - 2, ry), + cursor_shape_preview(idx), + prev_fg, + style.Default, + preview_mod, + ) + }) + + // Right: preview panel + let right = Rect(Position(rx, area.position.y), Size(rw, ch)) + let buf = + gblock_widget.render( + buf, + right, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Single) + |> gblock_widget.with_title("ANTEPRIMA", gblock_widget.Top), + ) + let cur_name = cursor_shape_name(state.cursor_idx) + let cur_code = cursor_shape_code(state.cursor_idx) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 2), + "Forma attiva:", + style.Default, + style.Default, + style.dim(), + ) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 3), + cur_name, + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 4), + "DECSCUSR: " <> cur_code, + style.Rgb(180, 255, 180), + style.Default, + style.none(), + ) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 6), + "Il cursore del terminale è posizionato", + style.Default, + style.Default, + style.none(), + ) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 7), + "nella lista a sinistra ← sulla riga", + style.Default, + style.Default, + style.none(), + ) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 8), + "selezionata. Cambia forma con ↑↓.", + style.Default, + style.Default, + style.none(), + ) + let buf = + buffer.set_string( + buf, + Position(rx + 2, area.position.y + 10), + "CURSOR API:", + style.Default, + style.Default, + style.bold(), + ) + let api = [ + "set_shape(shape) → String", + "CursorShape variants:", + " Block (2) BlockBlink (1)", + " Bar (6) BarBlink (5)", + " Underline(4) UnderlineBlink(3)", + "", + "Emette: \\e[N q (DECSCUSR)", + "Supporto: dipende dal terminale.", + ] + let buf = + list.index_fold(api, buf, fn(b, line, i) { + buffer.set_string( + b, + Position(rx + 2, area.position.y + 11 + i), + line, + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + }) + buffer.set_string( + buf, + Position(rx + 2, area.position.y + ch - 2), + "↑↓=seleziona TAB=pagina successiva", + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: PROGRESS, progress widget + gradient bar widget + +fn page_progress( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let lab_w = 20 + let bar_x = area.position.x + lab_w + let bar_w = int.max(1, w - lab_w) + let pct = state.progress_pct + let pct_str = int.to_string(pct) <> "%" + + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right( + "PROGRESS + GRADIENT BAR valore: " <> pct_str <> " ↑↓=±10 ←→=±1", + w, + ), + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + + // PROGRESS WIDGET section + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y + 2), + "── progress widget ──", + style.Default, + style.Default, + style.dim(), + ) + let prog_rows = [ + #("progress_new", fn(b, y) { + gprogress_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + gprogress_widget.progress_new(pct) + |> gprogress_widget.with_label(pct_str), + state.anim.frame, + ) + }), + #("filled_mod Bold", fn(b, y) { + gprogress_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + gprogress_widget.progress_new(pct) + |> gprogress_widget.with_label(pct_str) + |> gprogress_widget.with_filled_modifier(style.bold()), + state.anim.frame, + ) + }), + #("indeterminate", fn(b, y) { + gprogress_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + gprogress_widget.progress_indeterminate() + |> gprogress_widget.with_label("caricamento..."), + state.anim.frame, + ) + }), + ] + let buf = + list.index_fold(prog_rows, buf, fn(b, row, i) { + let #(label, render_fn) = row + let y = area.position.y + 3 + i + let b2 = + buffer.set_string( + b, + Position(area.position.x, y), + text.pad_right(label, lab_w), + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + render_fn(b2, y) + }) + + // GRADIENT BAR WIDGET section + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y + 7), + "── gradient_bar widget ──", + style.Default, + style.Default, + style.dim(), + ) + let grad_stops = [ + style.Rgb(0, 100, 220), + style.Rgb(0, 200, 160), + style.Rgb(80, 220, 0), + style.Rgb(230, 180, 0), + style.Rgb(220, 40, 0), + ] + let grad_rows = [ + #("gradient_prog", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.gradient_progress_new(grad_stops, pct), + state.anim.frame, + ) + }), + #("LinearGradient", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.gradient_bar_new(grad_stops) + |> ggradient_widget.with_percent(pct), + state.anim.frame, + ) + }), + #("AnimatedLinear", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.animated_gradient_bar_new(grad_stops) + |> ggradient_widget.with_percent(pct) + |> ggradient_widget.with_period(80), + state.anim.frame, + ) + }), + #("Rainbow", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.rainbow_bar() |> ggradient_widget.with_percent(pct), + state.anim.frame, + ) + }), + #("AnimatedRainbow", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.animated_rainbow_bar() + |> ggradient_widget.with_percent(pct), + state.anim.frame, + ) + }), + #("Pulse p=40", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.pulse_bar(style.Rgb(0, 180, 255)) + |> ggradient_widget.with_percent(pct) + |> ggradient_widget.with_period(40), + state.anim.frame, + ) + }), + #("Pulse cyan p=20", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.pulse_bar(style.Rgb(0, 255, 180)) + |> ggradient_widget.with_percent(pct) + |> ggradient_widget.with_period(20), + state.anim.frame, + ) + }), + #("Pulse magenta", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggradient_widget.pulse_bar(style.Rgb(220, 0, 180)) + |> ggradient_widget.with_percent(pct) + |> ggradient_widget.with_period(30), + state.anim.frame, + ) + }), + ] + let buf = + list.index_fold(grad_rows, buf, fn(b, row, i) { + let #(label, render_fn) = row + let y = area.position.y + 8 + i + let b2 = + buffer.set_string( + b, + Position(area.position.x, y), + text.pad_right(label, lab_w), + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + render_fn(b2, y) + }) + + buffer.set_string( + buf, + Position(area.position.x, area.position.y + ch - 1), + text.pad_right( + "↑↓=±10 ←→=±1 valore=" <> pct_str <> " TAB=pagina successiva", + w, + ), + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: GAUGE, gauge widget showcase + +fn page_gauge( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let lab_w = 22 + let bar_x = area.position.x + lab_w + let bar_w = int.max(1, w - lab_w) + let pct = state.gauge_pct + let pct_str = int.to_string(pct) <> "%" + + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right( + "GAUGE WIDGET valore: " <> pct_str <> " ↑↓=±10 ←→=±1", + w, + ), + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y + 1), + text.pad_right("──────────────────────────────────", w), + style.Default, + style.Default, + style.dim(), + ) + + let gauge_rows = [ + #("gauge_new", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct), + ) + }), + #("with_label(pct%)", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) |> ggauge_widget.with_label(pct_str), + ) + }), + #("chars ▓░", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) + |> ggauge_widget.with_chars("▓", "░") + |> ggauge_widget.with_label(pct_str), + ) + }), + #("chars ▪ ·", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) + |> ggauge_widget.with_chars("▪", "·") + |> ggauge_widget.with_label(pct_str), + ) + }), + #("chars ━ ─", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) + |> ggauge_widget.with_chars("━", "─") + |> ggauge_widget.with_label(pct_str), + ) + }), + #("color rgb(0,180,255)", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) + |> ggauge_widget.with_colors(style.Rgb(0, 180, 255), style.Default) + |> ggauge_widget.with_label(pct_str), + ) + }), + #("color gold + label", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) + |> ggauge_widget.with_colors(style.Rgb(255, 220, 0), style.Default) + |> ggauge_widget.with_label("GOLD " <> pct_str), + ) + }), + #("modifier Bold", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(pct) + |> ggauge_widget.with_filled_modifier(style.bold()) + |> ggauge_widget.with_label(pct_str), + ) + }), + #("animated (osc.)", fn(b, y) { + let anim_pct = anim.oscillate(10, 100, state.anim.frame, 120) + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(anim_pct) + |> ggauge_widget.with_label(int.to_string(anim_pct) <> "% (animato)"), + ) + }), + #("0% (empty)", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(0) |> ggauge_widget.with_label("0%"), + ) + }), + #("100% (full)", fn(b, y) { + ggauge_widget.render( + b, + Rect(Position(bar_x, y), Size(bar_w, 1)), + ggauge_widget.gauge_new(100) |> ggauge_widget.with_label("100%"), + ) + }), + ] + let buf = + list.index_fold(gauge_rows, buf, fn(b, row, i) { + let #(label, render_fn) = row + case i >= ch - 3 { + True -> b + False -> { + let y = area.position.y + 2 + i + let b2 = + buffer.set_string( + b, + Position(area.position.x, y), + text.pad_right(label, lab_w), + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + render_fn(b2, y) + } + } + }) + + buffer.set_string( + buf, + Position(area.position.x, area.position.y + ch - 1), + text.pad_right( + "↑↓=±10 ←→=±1 valore=" <> pct_str <> " TAB=pagina successiva", + w, + ), + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: CHART, bar chart widget, fill cycling + +fn page_chart( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let info_w = 30 + let chart_w = int.max(1, w - info_w - 1) + let ix = area.position.x + chart_w + 1 + + // Chart area (left/center) + let chart_bars = int.max(1, chart_w / 3) + let chart_data = + range(0, chart_bars) + |> list.map(fn(i) { anim.oscillate(5, 100, state.anim.frame + i * 11, 70) }) + let fill = chart_fill_for_idx(state.chart_fill_idx) + let buf = + gchart_widget.render( + buf, + Rect( + Position(area.position.x, area.position.y + 1), + Size(chart_w, ch - 2), + ), + gchart_widget.chart_new(chart_data) + |> gchart_widget.with_fill(fill) + |> gchart_widget.with_bar_width(3) + |> gchart_widget.with_gap(0) + |> gchart_widget.with_period(70), + state.anim.frame, + ) + + // Title above chart + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right( + "CHART fill: " <> chart_fill_name(state.chart_fill_idx), + chart_w, + ), + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + + // Info panel (right) + let info_area = Rect(Position(ix, area.position.y), Size(info_w, ch)) + let buf = + gblock_widget.render( + buf, + info_area, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Single) + |> gblock_widget.with_title("CHART API", gblock_widget.Top), + ) + let fill_names = [ + "0 AnimatedRainbow ←", + "1 Rainbow", + "2 ChartGradient", + "3 ChartVertGradient", + "4 ChartSolid", + ] + let buf = + buffer.set_string( + buf, + Position(ix + 1, area.position.y + 2), + "ChartFill varianti:", + style.Default, + style.Default, + style.bold(), + ) + let buf = + list.index_fold(fill_names, buf, fn(b, name, i) { + let is_active = i == state.chart_fill_idx + let mod = case is_active { + True -> style.reverse() + False -> style.none() + } + let fg = case is_active { + True -> style.Default + False -> style.Rgb(180, 180, 180) + } + buffer.set_string( + b, + Position(ix + 1, area.position.y + 3 + i), + text.pad_right(name, info_w - 2), + fg, + style.Default, + mod, + ) + }) + let api = [ + "", + "chart_new(data)", + "with_fill(ChartFill)", + "with_bar_width(n)", + "with_gap(n)", + "with_max(n)", + "with_period(n)", + "", + "render(buf,area,", + " chart,frame)", + ] + let buf = + list.index_fold(api, buf, fn(b, line, i) { + buffer.set_string( + b, + Position(ix + 1, area.position.y + 9 + i), + line, + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + }) + buffer.set_string( + buf, + Position(ix + 1, area.position.y + ch - 2), + "↑↓=cambia fill", + style.Default, + style.Default, + style.dim(), + ) +} + +fn chart_fill_for_idx(idx: Int) -> gchart_widget.ChartFill { + case idx { + 0 -> gchart_widget.ChartAnimatedRainbow + 1 -> gchart_widget.ChartRainbow + 2 -> + gchart_widget.ChartGradient([ + style.Rgb(0, 100, 220), + style.Rgb(80, 220, 0), + style.Rgb(220, 40, 0), + ]) + 3 -> + gchart_widget.ChartVerticalGradient([ + style.Rgb(0, 80, 220), + style.Rgb(220, 40, 0), + ]) + _ -> + gchart_widget.ChartSolid([ + style.Rgb(255, 220, 0), + style.Rgb(0, 200, 255), + style.Rgb(220, 0, 120), + ]) + } +} + +fn chart_fill_name(idx: Int) -> String { + case idx { + 0 -> "AnimatedRainbow" + 1 -> "Rainbow" + 2 -> "ChartGradient" + 3 -> "ChartVerticalGradient" + _ -> "ChartSolid (3 colori)" + } +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: ANIMAZIONI, marquee + rainbow bars + spinners + blink demo + +fn page_animazioni( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + + // Section: MARQUEE + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right("MARQUEE WIDGET (3 varianti)", w), + style.Default, + style.Default, + style.bold(), + ) + let marquee_text = + "etui ✦ sparkline ✦ marquee ✦ gradient ✦ rainbow ✦ pulse ✦ progress ✦ spinner ✦ cursori ✦ liste ✦ tabelle ✦ input" + let buf = + gmarquee_widget.render( + buf, + Rect(Position(area.position.x, area.position.y + 1), Size(w, 1)), + gmarquee_widget.marquee_new(marquee_text) + |> gmarquee_widget.with_speed(4) + |> gmarquee_widget.with_separator(" ◆ ") + |> gmarquee_widget.with_style( + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ), + state.anim.frame, + ) + let buf = + gmarquee_widget.render( + buf, + Rect(Position(area.position.x, area.position.y + 2), Size(w, 1)), + gmarquee_widget.marquee_new(marquee_text) + |> gmarquee_widget.with_speed(8) + |> gmarquee_widget.with_separator(" ── ") + |> gmarquee_widget.with_style( + style.Rgb(0, 200, 255), + style.Default, + style.none(), + ), + state.anim.frame, + ) + let buf = + gmarquee_widget.render( + buf, + Rect(Position(area.position.x, area.position.y + 3), Size(w, 1)), + gmarquee_widget.marquee_new("FAST ★ " <> marquee_text) + |> gmarquee_widget.with_speed(1) + |> gmarquee_widget.with_separator(" ★ ") + |> gmarquee_widget.with_style( + style.Rgb(220, 80, 220), + style.Default, + style.none(), + ), + state.anim.frame, + ) + + // Section: GRADIENT BAR showcase + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y + 5), + text.pad_right("GRADIENT BAR (full width, animati)", w), + style.Default, + style.Default, + style.bold(), + ) + let grad_stops = [ + style.Rgb(0, 100, 220), + style.Rgb(0, 200, 160), + style.Rgb(80, 220, 0), + style.Rgb(230, 180, 0), + style.Rgb(220, 40, 0), + ] + let anim_bars = [ + #("AnimatedRainbow", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(area.position.x, y), Size(w, 1)), + ggradient_widget.animated_rainbow_bar(), + state.anim.frame, + ) + }), + #("AnimatedLinear p=80", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(area.position.x, y), Size(w, 1)), + ggradient_widget.animated_gradient_bar_new(grad_stops) + |> ggradient_widget.with_period(80), + state.anim.frame, + ) + }), + #("Pulse(0,180,255) p=40", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(area.position.x, y), Size(w, 1)), + ggradient_widget.pulse_bar(style.Rgb(0, 180, 255)) + |> ggradient_widget.with_period(40), + state.anim.frame, + ) + }), + #("Pulse(220,0,180) p=25", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(area.position.x, y), Size(w, 1)), + ggradient_widget.pulse_bar(style.Rgb(220, 0, 180)) + |> ggradient_widget.with_period(25), + state.anim.frame, + ) + }), + #("LinearGradient (static)", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(area.position.x, y), Size(w, 1)), + ggradient_widget.gradient_bar_new(grad_stops), + state.anim.frame, + ) + }), + #("Rainbow (static)", fn(b, y) { + ggradient_widget.render( + b, + Rect(Position(area.position.x, y), Size(w, 1)), + ggradient_widget.rainbow_bar(), + state.anim.frame, + ) + }), + ] + let buf = + list.index_fold(anim_bars, buf, fn(b, row, i) { + let #(label, render_fn) = row + let base_y = area.position.y + 6 + i * 2 + case base_y + 1 >= area.position.y + ch - 2 { + True -> b + False -> { + let b2 = render_fn(b, base_y) + buffer.set_string( + b2, + Position(area.position.x, base_y + 1), + label, + style.Rgb(150, 150, 150), + style.Default, + style.none(), + ) + } + } + }) + + // Section: style modifiers demo + let mod_y = area.position.y + ch - 4 + let buf = + buffer.set_string( + buf, + Position(area.position.x, mod_y), + text.pad_right("STYLE MODIFIERS", w), + style.Default, + style.Default, + style.bold(), + ) + let mod_demos = [ + #("Normal", style.none()), + #(" Bold ", style.bold()), + #(" Dim ", style.dim()), + #(" Blink", style.blink()), + #("Reverse", style.reverse()), + #("RevBlink", style.add(style.reverse(), style.blink())), + #("BoldRev", style.add(style.bold(), style.reverse())), + ] + let buf = + list.index_fold(mod_demos, buf, fn(b, demo, i) { + let #(label, mod) = demo + buffer.set_string( + b, + Position(area.position.x + i * 12, mod_y + 1), + " " <> label <> " ", + style.Default, + style.Default, + mod, + ) + }) + + buffer.set_string( + buf, + Position(area.position.x, area.position.y + ch - 1), + text.pad_right( + "frame=" + <> int.to_string(state.anim.frame) + <> " TAB=pagina successiva q=quit", + w, + ), + style.Default, + style.Default, + style.dim(), + ) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: HBAR, horizontal bar chart, 4 fill variants + +fn page_hbar(buf: buffer.Buffer, area: Rect, state: AppState) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let info_w = int.min(30, w / 4) + let chart_w = int.max(1, w - info_w - 1) + let ix = area.position.x + chart_w + 1 + + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right("HBAR WIDGET (4 varianti fill)", chart_w), + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + + // Dynamic data per panel (different phase so bars move differently) + let items = fn(phase) { + let f = state.anim.frame + [ + ghbar_widget.item("cpu0", anim.oscillate(10, 100, f + phase, 80)), + ghbar_widget.item("cpu1", anim.oscillate(5, 90, f + phase + 10, 65)), + ghbar_widget.item("cpu2", anim.oscillate(20, 80, f + phase + 20, 70)), + ghbar_widget.item("mem ", anim.oscillate(40, 95, f + phase + 5, 120)), + ghbar_widget.item("disk", anim.oscillate(15, 60, f + phase + 15, 200)), + ] + } + + let panel_h = int.max(2, { ch - 2 } / 4) + let variants = [ + #("HBarAnimatedRainbow", fn(b, y, h) { + ghbar_widget.render( + b, + Rect(Position(area.position.x, y), Size(chart_w, h)), + ghbar_widget.hbar_new(items(0)) + |> ghbar_widget.with_fill(ghbar_widget.HBarAnimatedRainbow) + |> ghbar_widget.with_period(80), + state.anim.frame, + ) + }), + #("HBarRainbow", fn(b, y, h) { + ghbar_widget.render( + b, + Rect(Position(area.position.x, y), Size(chart_w, h)), + ghbar_widget.hbar_new(items(20)) + |> ghbar_widget.with_fill(ghbar_widget.HBarRainbow), + state.anim.frame, + ) + }), + #("HBarGradient blu→verde→rosso", fn(b, y, h) { + ghbar_widget.render( + b, + Rect(Position(area.position.x, y), Size(chart_w, h)), + ghbar_widget.hbar_new(items(40)) + |> ghbar_widget.with_fill( + ghbar_widget.HBarGradient([ + style.Rgb(0, 80, 220), + style.Rgb(0, 220, 140), + style.Rgb(220, 80, 0), + ]), + ), + state.anim.frame, + ) + }), + #("HBarSolid giallo/ciano/magenta", fn(b, y, h) { + ghbar_widget.render( + b, + Rect(Position(area.position.x, y), Size(chart_w, h)), + ghbar_widget.hbar_new(items(60)) + |> ghbar_widget.with_fill( + ghbar_widget.HBarSolid([ + style.Rgb(255, 220, 0), + style.Rgb(0, 200, 255), + style.Rgb(220, 0, 120), + ]), + ), + state.anim.frame, + ) + }), + ] + + let buf = + list.index_fold(variants, buf, fn(b, v, i) { + let #(label, render_fn) = v + let y = area.position.y + 1 + i * panel_h + case y >= area.position.y + ch - 2 { + True -> b + False -> { + let h = int.min(panel_h - 1, area.position.y + ch - 1 - y - 1) + let b2 = + buffer.set_string( + b, + Position(area.position.x, y), + label, + style.Rgb(180, 220, 255), + style.Default, + style.dim(), + ) + render_fn(b2, y + 1, int.max(1, h)) + } + } + }) + + // Info panel (right) + let info_area = Rect(Position(ix, area.position.y), Size(info_w, ch)) + let buf = + gblock_widget.render( + buf, + info_area, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Single) + |> gblock_widget.with_title("HBAR API", gblock_widget.Top), + ) + let api = [ + "hbar_new(items)", + "item(label, value)", + "with_fill(HBarFill)", + "with_max(n)", + "with_label_width(n)", + "with_show_value(bool)", + "with_chars(bar, empty)", + "with_period(n)", + "", + "HBarFill:", + " HBarSolid(colors)", + " HBarGradient(stops)", + " HBarRainbow", + " HBarAnimatedRainbow", + "", + "render(buf,area,", + " hbar,frame)", + ] + list.index_fold(api, buf, fn(b, line, i) { + buffer.set_string( + b, + Position(ix + 1, area.position.y + 2 + i), + line, + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + }) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: CANVAS, braille line chart, multi-series + +fn page_canvas( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let info_w = int.min(28, w / 4) + let canvas_w = int.max(1, w - info_w - 1) + let ix = area.position.x + canvas_w + 1 + let canvas_h = int.max(1, ch - 2) + let n = canvas_w * 2 + + let wave = fn(phase, amp_min, amp_max, period) { + range(0, n) + |> list.map(fn(x) { + anim.oscillate(amp_min, amp_max, state.anim.frame + x * 2 + phase, period) + }) + } + + let series = [ + gcanvas_widget.series_new(wave(0, 10, 90, 60)) + |> gcanvas_widget.with_series_fill(gcanvas_widget.SeriesAnimatedRainbow), + gcanvas_widget.series_new(wave(30, 20, 80, 45)) + |> gcanvas_widget.with_series_fill( + gcanvas_widget.SeriesGradient([ + style.Rgb(0, 180, 255), + style.Rgb(0, 255, 140), + ]), + ), + gcanvas_widget.series_new(wave(60, 5, 50, 35)) + |> gcanvas_widget.with_series_fill( + gcanvas_widget.SeriesSolid(style.Rgb(255, 120, 0)), + ), + ] + + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right( + "CANVAS WIDGET braille 2×4 dot grid risoluzione: " + <> int.to_string(canvas_w * 2) + <> "×" + <> int.to_string(canvas_h * 4) + <> " pixel", + canvas_w, + ), + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + + let buf = + gcanvas_widget.render( + buf, + Rect( + Position(area.position.x, area.position.y + 1), + Size(canvas_w, canvas_h), + ), + gcanvas_widget.canvas_new(series) |> gcanvas_widget.with_period(60), + state.anim.frame, + ) + + // Info panel (right) + let info_area = Rect(Position(ix, area.position.y), Size(info_w, ch)) + let buf = + gblock_widget.render( + buf, + info_area, + gblock_widget.block_new() + |> gblock_widget.with_border(gblock_widget.Single) + |> gblock_widget.with_title("CANVAS API", gblock_widget.Top), + ) + let legend = [ + "Serie 1:", + " SeriesAnimatedRainbow", + "Serie 2:", + " SeriesGradient", + " blu→ciano", + "Serie 3:", + " SeriesSolid arancio", + "", + "canvas_new(series)", + "series_new(data)", + "with_series_fill(f)", + "with_max(n)", + "with_bg(color)", + "with_period(n)", + "", + "SeriesFill:", + " SeriesSolid(c)", + " SeriesGradient(stops)", + " SeriesRainbow", + " SeriesAnimatedRainbow", + ] + list.index_fold(legend, buf, fn(b, line, i) { + case i >= ch - 3 { + True -> b + False -> + buffer.set_string( + b, + Position(ix + 1, area.position.y + 2 + i), + line, + style.Rgb(180, 220, 255), + style.Default, + style.none(), + ) + } + }) +} + +// ───────────────────────────────────────────────────────────────── +// PAGE: SCENA, sistema solare (braille) + Mandelbrot + +fn page_scene( + buf: buffer.Buffer, + area: Rect, + state: AppState, +) -> buffer.Buffer { + let w = area.size.width + let ch = area.size.height + let solar_w = w * 3 / 5 + let mandel_w = int.max(1, w - solar_w - 1) + let mx = area.position.x + solar_w + 1 + let canvas_h = int.max(1, ch - 1) + + // Titles + let buf = + buffer.set_string( + buf, + Position(area.position.x, area.position.y), + text.pad_right( + "SISTEMA SOLARE braille " + <> int.to_string(solar_w * 2) + <> "×" + <> int.to_string(canvas_h * 4) + <> "px", + solar_w, + ), + style.Rgb(255, 220, 0), + style.Default, + style.bold(), + ) + let buf = + buffer.set_string( + buf, + Position(mx, area.position.y), + text.pad_right("MANDELBROT iter=20", mandel_w), + style.Rgb(180, 220, 255), + style.Default, + style.bold(), + ) + + // Solar system canvas + let solar_area = + Rect( + Position(area.position.x, area.position.y + 1), + Size(solar_w, canvas_h), + ) + let pw = solar_w * 2 + let ph = canvas_h * 4 + let cx = pw / 2 + let cy = ph / 2 + let r1 = int.max(4, pw / 9) + let r2 = int.max(6, pw / 6) + let r3 = int.max(8, pw / 4) + let r4 = int.max(10, pw * 3 / 8) + let solar_shapes = [ + // Orbit rings (dim) + gscene_widget.CircleOutline( + cx, + cy, + r1, + gscene_widget.SceneSolid(style.Rgb(40, 40, 40)), + ), + gscene_widget.CircleOutline( + cx, + cy, + r2, + gscene_widget.SceneSolid(style.Rgb(40, 40, 40)), + ), + gscene_widget.CircleOutline( + cx, + cy, + r3, + gscene_widget.SceneSolid(style.Rgb(40, 40, 40)), + ), + gscene_widget.CircleOutline( + cx, + cy, + r4, + gscene_widget.SceneSolid(style.Rgb(40, 40, 40)), + ), + // Sun + gscene_widget.Disc( + cx, + cy, + 5, + gscene_widget.SceneSolid(style.Rgb(255, 220, 0)), + ), + // Mercury, grey, fast + gscene_widget.Planet( + cx, + cy, + r1, + 2, + gscene_widget.SceneSolid(style.Rgb(160, 140, 120)), + 38, + ), + // Venus, pale gold + gscene_widget.Planet( + cx, + cy, + r2, + 3, + gscene_widget.SceneSolid(style.Rgb(220, 190, 120)), + 68, + ), + // Earth, blue + gscene_widget.Planet( + cx, + cy, + r3, + 3, + gscene_widget.SceneSolid(style.Rgb(60, 140, 255)), + 100, + ), + // Mars, red + gscene_widget.Planet( + cx, + cy, + r4, + 2, + gscene_widget.SceneSolid(style.Rgb(210, 70, 30)), + 158, + ), + ] + let buf = + gscene_widget.render( + buf, + solar_area, + gscene_widget.scene_new(solar_shapes), + state.anim.frame, + ) + + // Mandelbrot canvas + let mandel_area = + Rect(Position(mx, area.position.y + 1), Size(mandel_w, canvas_h)) + let buf = + gscene_widget.render( + buf, + mandel_area, + gscene_widget.scene_new([gscene_widget.Mandelbrot(20)]), + state.anim.frame, + ) + + buf +} + +fn range(start: Int, end: Int) -> List(Int) { + case start >= end { + True -> [] + False -> [start, ..range(start + 1, end)] + } +} diff --git a/dev/etui_js_smoke.gleam b/dev/etui_js_smoke.gleam new file mode 100644 index 0000000..4ea17a4 --- /dev/null +++ b/dev/etui_js_smoke.gleam @@ -0,0 +1,89 @@ +/// JS smoke test, pure geometry, buffer, and widget operations. +/// Run: gleam run --target javascript -m etui_js_smoke +import etui/buffer +import etui/geometry.{Fill, Length, Percentage, rect_new} +import etui/span +import etui/style +import etui/widgets/paragraph +import gleam/io +import gleam/list +import gleam/string + +fn check(label: String, cond: Bool) -> Nil { + case cond { + True -> io.println(" PASS: " <> label) + False -> panic as { "FAIL: " <> label } + } +} + +pub fn main() -> Nil { + io.println("etui JS smoke test") + io.println(string.repeat("─", 40)) + + // geometry + let r = rect_new(0, 0, 80, 24) + check("rect_new width=80", r.size.width == 80) + check("rect_new height=24", r.size.height == 24) + + let sizes = geometry.resolve_sizes(100, [Length(20), Percentage(50), Fill]) + check("resolve_sizes length=3", list.length(sizes) == 3) + check("resolve_sizes Length=20", list.first(sizes) == Ok(20)) + check("resolve_sizes Fill=30", list.last(sizes) == Ok(30)) + + // buffer + let area = rect_new(0, 0, 10, 3) + let buf = buffer.buffer_new(area) + check("buffer_new width=10", buffer.area(buf).size.width == 10) + + let pos = geometry.Position(2, 1) + let buf2 = + buffer.set_string( + buf, + pos, + "hi", + style.Default, + style.Default, + style.none(), + ) + let cell = buffer.get_cell(buf2, pos) + let cell_sym = case cell.content { + buffer.Content(s, _) -> s + buffer.Continuation -> "" + } + check("set_string cell(2,1)='h'", cell_sym == "h") + + let same = buffer.diff_to_ansi(buf, buf) + check("diff_to_ansi identical=''", same == "") + + let changed = buffer.diff_to_ansi(buf, buf2) + check("diff_to_ansi changed non-empty", string.length(changed) > 0) + + let full = buffer.to_ansi(buf2) + check("to_ansi non-empty", string.length(full) > 0) + + // paragraph + let parea = rect_new(0, 0, 20, 5) + let pbuf = buffer.buffer_new(parea) + let p = paragraph.paragraph_new("hello") + let pbuf2 = paragraph.render(pbuf, parea, p) + let pcell = buffer.get_cell(pbuf2, geometry.Position(0, 0)) + let pcell_sym = case pcell.content { + buffer.Content(s, _) -> s + buffer.Continuation -> "" + } + check("paragraph 'hello' at (0,0)='h'", pcell_sym == "h") + + // span line + let s = span.span_plain("world") + let line = span.line_new([s]) + let sbuf = span.render_line(pbuf, geometry.Position(0, 1), line, 20) + let scell = buffer.get_cell(sbuf, geometry.Position(0, 1)) + let scell_sym = case scell.content { + buffer.Content(s, _) -> s + buffer.Continuation -> "" + } + check("span render_line 'world' at (0,1)='w'", scell_sym == "w") + + io.println(string.repeat("─", 40)) + io.println("All smoke tests passed.") +} diff --git a/dev/etui_new_features.gleam b/dev/etui_new_features.gleam new file mode 100644 index 0000000..a7b7bf7 --- /dev/null +++ b/dev/etui_new_features.gleam @@ -0,0 +1,560 @@ +/// File viewer + showcase of the v1.0.0 widgets. +/// +/// Top half: filesystem tree (with child counts on directories) plus a +/// textarea editor for the selected file. Bottom: a showcase strip with +/// spinner, paginator, multi_select, masked input, plus a fieldset divider +/// and a short help bar. +/// +/// Keys: +/// Tab switch focus between tree and editor +/// ↑↓ / j k navigate tree or move cursor in editor +/// Space expand/collapse directory (tree) or toggle item (multi) +/// Enter open file into editor (tree) or newline (editor) +/// ←→ prev/next page on the paginator +/// Backspace delete char in editor +/// ? toggle help short/full +/// q quit +/// +/// Run: gleam run -m etui_new_features +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/focus +import etui/geometry.{ + type Position, type Rect, Fill, Horizontal, Length, Vertical, +} +import etui/span +import etui/style +import etui/text +import etui/widgets/block +import etui/widgets/fieldset +import etui/widgets/help +import etui/widgets/input +import etui/widgets/multi_select +import etui/widgets/paginator +import etui/widgets/scrollbar +import etui/widgets/spinner +import etui/widgets/statusbar +import etui/widgets/textarea as ta +import etui/widgets/tree +import fio +import fio/path as fio_path +import fio/types as fio_types +import gleam/int +import gleam/list +import gleam/string + +// ─── Model ──────────────────────────────────────────────────────── + +pub type Model { + Model( + ring: focus.FocusRing, + roots: List(tree.TreeNode), + tree_state: tree.TreeState, + editor_state: ta.TextAreaState, + open_path: String, + status_msg: String, + width: Int, + height: Int, + quit: Bool, + // Showcase state + frame: Int, + paginator: paginator.Paginator, + multi_state: multi_select.MultiSelectState, + password_state: input.InputState, + help_mode: help.HelpMode, + ) +} + +// ─── Filesystem helpers ─────────────────────────────────────────── + +fn load_tree(path: String, depth: Int) -> List(tree.TreeNode) { + case depth <= 0 { + True -> [] + False -> + case fio.list(path) { + Error(_) -> [] + Ok(names) -> { + let sorted = list.sort(names, string.compare) + let entries = + list.filter_map(sorted, fn(name) { + let full = fio_path.join(path, name) + case fio.file_info(full) { + Error(_) -> Error(Nil) + Ok(info) -> + case fio_types.file_info_type(info) { + fio_types.Directory -> { + let children = load_tree(full, depth - 1) + let n = + tree.node(full, name <> "/", children) + |> tree.with_count(list.length(children)) + Ok(n) + } + _ -> Ok(tree.leaf(full, name)) + } + } + }) + let dirs = + list.filter(entries, fn(n) { + case n { + tree.TreeNode(children: [_, ..], ..) -> True + tree.TreeNode(children: [], ..) -> + string.ends_with(n.label, "/") + } + }) + let files = list.filter(entries, fn(n) { !list.contains(dirs, n) }) + list.append(dirs, files) + } + } + } +} + +fn open_file(path: String) -> #(ta.TextAreaState, String) { + case fio.file_info(path) { + Error(_) -> #(ta.state_new(), "Error: cannot stat " <> path) + Ok(info) -> + case info.size > 512_000 { + True -> #( + ta.state_from_string( + "(file too large: " <> int.to_string(info.size) <> " bytes)", + ), + "Opened (truncated): " <> path, + ) + False -> + case fio.read(path) { + Error(_) -> #(ta.state_new(), "Error: cannot read " <> path) + Ok(content) -> #(ta.state_from_string(content), "Opened: " <> path) + } + } + } +} + +// ─── Init ───────────────────────────────────────────────────────── + +fn init_model() -> Model { + let cwd = case fio.current_directory() { + Ok(p) -> p + Error(_) -> "." + } + let roots = load_tree(cwd, 2) + let t_widget = make_tree_widget(roots) + // Demo paginator: 5 pages, currently on page 2. + let p = paginator.paginator_new(5) |> paginator.go_to(1) + // Demo multi_select: "Gleam" pre-checked. + let ms = + multi_select.state_new() + |> multi_select.select_next(4) + |> multi_select.toggle(0) + // Demo password: prefill with a fake password. + let pw = input.state_from_string("hunter2") + Model( + ring: focus.focus_new(["tree", "editor", "paginator", "multi", "password"]), + roots: roots, + tree_state: tree.state_from_tree(t_widget), + editor_state: ta.state_from_string( + "// Select a file in the tree and press Enter.\n// Tab cycles focus through every panel.", + ), + open_path: "", + status_msg: "cwd: " <> cwd, + width: 80, + height: 24, + quit: False, + frame: 0, + paginator: p, + multi_state: ms, + password_state: pw, + help_mode: help.Short, + ) +} + +fn make_tree_widget(roots: List(tree.TreeNode)) -> tree.TreeWidget { + tree.tree_new(roots) + |> tree.with_highlight_style(style.Style( + fg: style.Indexed(15), + bg: style.Indexed(4), + modifier: style.bold(), + )) +} + +fn multi_items() -> List(String) { + ["Bash", "Gleam", "Erlang", "Rust"] +} + +fn help_bindings() -> List(help.Binding) { + [ + help.binding(["tab"], "switch focus"), + help.binding(["j", "k", "↑", "↓"], "navigate"), + help.binding(["enter"], "open / newline"), + help.binding([" "], "expand / toggle"), + help.binding(["←", "→"], "prev / next page"), + help.binding(["?"], "toggle help"), + help.binding(["q"], "quit"), + ] +} + +fn password_widget() -> input.InputWidget { + input.input_new("password") + |> input.with_prompt("> ") + |> input.with_password(True) + |> input.with_mask("●") +} + +// ─── Update ─────────────────────────────────────────────────────── + +fn update(event: backend.InputEvent, m: Model) -> Model { + case event { + backend.Tick -> Model(..m, frame: m.frame + 1) + backend.KeyPress("q") -> Model(..m, quit: True) + backend.KeyPress("tab") -> Model(..m, ring: focus.focus_next(m.ring)) + backend.KeyPress("?") -> + Model(..m, help_mode: toggle_help_mode(m.help_mode)) + backend.Resize(w, h) -> Model(..m, width: w, height: h) + backend.MouseScroll(_, _, up) -> + case focus.focused(m.ring) { + Ok("tree") -> scroll_tree(up, m) + Ok("editor") -> scroll_editor(up, m) + Ok("multi") -> scroll_multi(up, m) + _ -> m + } + backend.KeyPress(k) -> + case focus.focused(m.ring) { + Ok("tree") -> update_tree(k, m) + Ok("editor") -> update_editor(k, m) + Ok("paginator") -> update_paginator(k, m) + Ok("multi") -> update_multi(k, m) + Ok("password") -> update_password(k, m) + _ -> m + } + _ -> m + } +} + +fn toggle_help_mode(mode: help.HelpMode) -> help.HelpMode { + case mode { + help.Short -> help.Full + help.Full -> help.Short + } +} + +fn scroll_tree(up: Bool, m: Model) -> Model { + let t = make_tree_widget(m.roots) + case up { + True -> Model(..m, tree_state: tree.select_prev(m.tree_state, t)) + False -> Model(..m, tree_state: tree.select_next(m.tree_state, t)) + } +} + +fn scroll_editor(up: Bool, m: Model) -> Model { + let step = 3 + let s = case up { + True -> + list.fold(list.repeat(Nil, step), m.editor_state, fn(s, _) { + ta.move_cursor_up(s) + }) + False -> + list.fold(list.repeat(Nil, step), m.editor_state, fn(s, _) { + ta.move_cursor_down(s) + }) + } + Model(..m, editor_state: s) +} + +fn scroll_multi(up: Bool, m: Model) -> Model { + let count = list.length(multi_items()) + let s = case up { + True -> multi_select.select_prev(m.multi_state) + False -> multi_select.select_next(m.multi_state, count) + } + Model(..m, multi_state: s) +} + +fn update_tree(k: String, m: Model) -> Model { + let t = make_tree_widget(m.roots) + case k { + "up" | "k" -> Model(..m, tree_state: tree.select_prev(m.tree_state, t)) + "down" | "j" -> Model(..m, tree_state: tree.select_next(m.tree_state, t)) + " " -> Model(..m, tree_state: tree.toggle_selected(m.tree_state, t)) + "enter" -> + case tree.selected(m.tree_state) { + Error(_) -> m + Ok(path) -> + case fio.is_directory(path) { + Ok(True) -> + Model(..m, tree_state: tree.toggle_selected(m.tree_state, t)) + _ -> { + let #(new_editor, msg) = open_file(path) + Model( + ..m, + editor_state: new_editor, + open_path: path, + status_msg: msg, + ring: focus.focus_id(m.ring, "editor"), + ) + } + } + } + _ -> m + } +} + +fn update_editor(k: String, m: Model) -> Model { + let w = ta.textarea_new() |> ta.with_max_line_length(500) + let s = case k { + "enter" -> ta.newline(w, m.editor_state) + "backspace" -> ta.backspace(m.editor_state) + "up" -> ta.move_cursor_up(m.editor_state) + "down" -> ta.move_cursor_down(m.editor_state) + "left" -> ta.move_cursor_left(m.editor_state) + "right" -> ta.move_cursor_right(m.editor_state) + "home" -> ta.move_to_line_start(m.editor_state) + "end" -> ta.move_to_line_end(m.editor_state) + c -> { + let printable = text.cell_width(c) > 0 && string.length(c) == 1 + case printable { + True -> ta.insert_char(w, m.editor_state, c) + False -> m.editor_state + } + } + } + Model(..m, editor_state: s) +} + +fn update_paginator(k: String, m: Model) -> Model { + case k { + "left" | "h" -> Model(..m, paginator: paginator.prev_page(m.paginator)) + "right" | "l" -> Model(..m, paginator: paginator.next_page(m.paginator)) + _ -> m + } +} + +fn update_multi(k: String, m: Model) -> Model { + let count = list.length(multi_items()) + let s = case k { + "up" | "k" -> multi_select.select_prev(m.multi_state) + "down" | "j" -> multi_select.select_next(m.multi_state, count) + " " | "enter" -> multi_select.toggle(m.multi_state, 0) + _ -> m.multi_state + } + Model(..m, multi_state: s) +} + +fn update_password(k: String, m: Model) -> Model { + let w = password_widget() |> input.with_max_length(32) + let s = case k { + "backspace" -> input.backspace(m.password_state) + "left" -> input.move_cursor_left(m.password_state) + "right" -> input.move_cursor_right(m.password_state) + "home" -> input.move_to_start(m.password_state) + "end" -> input.move_to_end(m.password_state) + c -> { + let printable = text.cell_width(c) > 0 && string.length(c) == 1 + case printable { + True -> input.insert_char(w, m.password_state, c) + False -> m.password_state + } + } + } + Model(..m, password_state: s) +} + +// ─── Render ─────────────────────────────────────────────────────── + +fn render(m: Model, screen: Rect) -> #(buffer.Buffer, Result(Position, Nil)) { + let buf = buffer.buffer_new(screen) + + // Vertical layout: content / fieldset / showcase / help / statusbar + let help_h = case m.help_mode { + help.Short -> 1 + help.Full -> int.min(8, list.length(help_bindings())) + } + let rows = + geometry.split(Vertical, screen, [ + Fill, + Length(1), + Length(3), + Length(help_h), + Length(1), + ]) + let #(content_area, fs_area, demo_area, help_area, status_area) = case rows { + [c, f, d, h, s, ..] -> #(c, f, d, h, s) + _ -> #(screen, screen, screen, screen, screen) + } + + let tree_w = int.min(32, m.width / 3) + let cols = geometry.split(Horizontal, content_area, [Length(tree_w), Fill]) + let #(tree_area, editor_area) = case cols { + [t, e, ..] -> #(t, e) + _ -> #(content_area, content_area) + } + + // ── Tree panel (with counts) ────────────────────────────────── + let tree_focused = focus.is_focused(m.ring, "tree") + let tree_blk = + block.block_new() + |> block.with_border(block.Single) + |> block.with_title("Files", block.Top) + |> block.with_style(panel_border_fg(tree_focused), style.Default) + let tree_inner = block.inner(tree_area, tree_blk) + let buf = + block.render(buf, tree_area, tree_blk) + |> tree.render(tree_inner, make_tree_widget(m.roots), m.tree_state) + + // ── Editor panel ────────────────────────────────────────────── + let editor_focused = focus.is_focused(m.ring, "editor") + let file_name = case m.open_path { + "" -> "Editor" + p -> short_path(p) + } + let line_count = ta.line_count(m.editor_state) + let editor_title = file_name <> " [" <> int.to_string(line_count) <> "L]" + let editor_blk = + block.block_new() + |> block.with_border(block.Single) + |> block.with_title(editor_title, block.Top) + |> block.with_style(panel_border_fg(editor_focused), style.Default) + let editor_inner = block.inner(editor_area, editor_blk) + let inner_cols = geometry.split(Horizontal, editor_inner, [Fill, Length(1)]) + let #(text_area, sb_area) = case inner_cols { + [ta_a, sb, ..] -> #(ta_a, sb) + _ -> #(editor_inner, editor_inner) + } + let e_widget = + ta.textarea_new() + |> ta.with_max_line_length(500) + |> ta.with_cursor_style(style.Style( + fg: style.Indexed(0), + bg: style.Rgb(80, 140, 220), + modifier: style.none(), + )) + let visible_h = text_area.size.height + let scroll = ta.effective_offset(m.editor_state, visible_h) + let sb_widget = scrollbar.scrollbar_new(line_count, visible_h, scroll) + let buf = + block.render(buf, editor_area, editor_blk) + |> ta.render(text_area, e_widget, m.editor_state) + |> scrollbar.render_vertical(sb_area, sb_widget) + + // ── Fieldset divider ────────────────────────────────────────── + let fs = + fieldset.fieldset_new("Showcase") + |> fieldset.with_align(fieldset.AlignCenter) + |> fieldset.with_line_char("─") + |> fieldset.with_title_color(style.Rgb(120, 200, 255)) + let buf = fieldset.render(buf, fs_area, fs) + + // ── Showcase strip (4 columns: spinner / paginator / multi / password) ─ + let demo_cols = + geometry.split(Horizontal, demo_area, [ + Length(18), + Length(20), + Fill, + Length(20), + ]) + let #(spin_area, pag_area, multi_area, pw_area) = case demo_cols { + [a, b, c, d, ..] -> #(a, b, c, d) + _ -> #(demo_area, demo_area, demo_area, demo_area) + } + let spin_w = + spinner.spinner_new() + |> spinner.with_style(spinner.Dots) + |> spinner.with_label("loading") + |> spinner.with_colors(style.Rgb(120, 200, 255), style.Default) + let buf = spinner.render(buf, spin_area, spin_w, m.frame) + + let pag_focused = focus.is_focused(m.ring, "paginator") + let pag = case pag_focused { + True -> + m.paginator + |> paginator.with_colors(style.Rgb(255, 180, 80), style.Default) + False -> m.paginator + } + let buf = paginator.render(buf, pag_area, pag) + + let multi_focused = focus.is_focused(m.ring, "multi") + let multi_w = + multi_select.multi_select_new(multi_items()) + |> multi_select.with_cursor_style(style.Style( + fg: style.Indexed(0), + bg: case multi_focused { + True -> style.Rgb(255, 180, 80) + False -> style.Rgb(100, 100, 100) + }, + modifier: style.bold(), + )) + let buf = multi_select.render(buf, multi_area, multi_w, m.multi_state) + + let pw_focused = focus.is_focused(m.ring, "password") + let pw_w = + password_widget() + |> input.with_colors( + case pw_focused { + True -> style.Rgb(255, 180, 80) + False -> style.Default + }, + style.Default, + ) + let buf = input.render(buf, pw_area, pw_w, m.password_state) + + // ── Help bar ────────────────────────────────────────────────── + let h = + help.help_new(help_bindings()) + |> help.with_mode(m.help_mode) + |> help.with_key_color(style.Rgb(255, 180, 80)) + |> help.with_description_color(style.Indexed(7)) + let buf = help.render(buf, help_area, h) + + // ── Status bar ──────────────────────────────────────────────── + let focus_label = case focus.focused(m.ring) { + Ok(id) -> string.uppercase(id) + _ -> "" + } + let bar = + statusbar.statusbar_new() + |> statusbar.with_left([span.line_plain(" " <> focus_label)]) + |> statusbar.with_center([span.line_plain(m.status_msg)]) + |> statusbar.with_right([span.line_plain("? help q quit ")]) + |> statusbar.with_style(style.Indexed(15), style.Indexed(4)) + let buf = statusbar.render(buf, status_area, bar) + + // Hardware cursor: visible when editor or password focused. + let cursor_pos = case focus.focused(m.ring) { + Ok("editor") -> ta.cursor_screen_pos(m.editor_state, text_area) + Ok("password") -> { + let x = pw_area.position.x + 2 + m.password_state.cursor + Ok(geometry.Position(x: x, y: pw_area.position.y)) + } + _ -> Error(Nil) + } + #(buf, cursor_pos) +} + +fn panel_border_fg(focused: Bool) -> style.Color { + case focused { + True -> style.Rgb(80, 160, 255) + False -> style.Indexed(8) + } +} + +fn short_path(p: String) -> String { + let parts = string.split(p, "/") + case list.last(parts) { + Ok(name) -> name + Error(_) -> p + } +} + +// ─── Main ───────────────────────────────────────────────────────── + +pub fn main() -> Nil { + let _ = + app.run_buffered_cursor( + default.new_with_mouse(), + init_model(), + render, + update, + fn(m) { m.quit }, + 16, + ) + Nil +} diff --git a/dev/etui_nexus.gleam b/dev/etui_nexus.gleam new file mode 100644 index 0000000..57f880d --- /dev/null +++ b/dev/etui_nexus.gleam @@ -0,0 +1,1350 @@ +/// GATUI NEXUS, infrastructure operations dashboard. +/// +/// Run: gleam run -m etui_nexus +/// Tabs: TAB=next j/k ↑↓=navigate ↵=detail ESC/h/←=back q=quit +import etui/anim +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect, rect_new} +import etui/span +import etui/style +import etui/widgets/gauge +import etui/widgets/list as list_widget +import etui/widgets/paragraph +import etui/widgets/scrollbar +import etui/widgets/sparkline +import etui/widgets/tabs as tabs_widget +import gleam/int +import gleam/list +import gleam/string + +// ─── Palette ───────────────────────────────────────────────────── + +const c_cyan = style.Indexed(51) + +const c_dcyan = style.Indexed(37) + +const c_cyan2 = style.Indexed(45) + +const c_cyan3 = style.Indexed(39) + +const c_blue = style.Indexed(27) + +const c_amber = style.Indexed(214) + +const c_red = style.Indexed(196) + +const c_pink = style.Indexed(213) + +const c_green = style.Indexed(82) + +const c_dim = style.Indexed(240) + +const c_white = style.Indexed(255) + +// ─── Span helpers ──────────────────────────────────────────────── + +fn cy(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_cyan) +} + +fn cy_b(s: String) -> span.Span { + cy(s) |> span.span_modifier(style.bold()) +} + +fn cy_r(s: String) -> span.Span { + cy(s) |> span.span_modifier(style.reverse()) +} + +fn dcy(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_dcyan) +} + +fn amb(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_amber) +} + +fn amb_b(s: String) -> span.Span { + amb(s) |> span.span_modifier(style.bold()) +} + +fn red_b(s: String) -> span.Span { + span.span_plain(s) + |> span.span_fg(c_red) + |> span.span_modifier(style.bold()) +} + +fn pk(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_pink) +} + +fn pk_b(s: String) -> span.Span { + pk(s) |> span.span_modifier(style.bold()) +} + +fn grn(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_green) +} + +fn grn_b(s: String) -> span.Span { + grn(s) |> span.span_modifier(style.bold()) +} + +fn wht_b(s: String) -> span.Span { + span.span_plain(s) + |> span.span_fg(c_white) + |> span.span_modifier(style.bold()) +} + +fn dim(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_dim) +} + +fn dim_b(s: String) -> span.Span { + dim(s) |> span.span_modifier(style.bold()) +} + +fn gap(n: Int) -> span.Span { + span.span_plain(string.repeat(" ", n)) +} + +fn put1( + buf: buffer.Buffer, + x: Int, + y: Int, + w: Int, + spans: List(span.Span), +) -> buffer.Buffer { + paragraph.render_styled(buf, rect_new(x, y, w, 1), [span.line_new(spans)]) +} + +fn hint(key: String, label: String) -> List(span.Span) { + [cy_r(" " <> key <> " "), dim(" " <> label)] +} + +fn pad2(n: Int) -> String { + string.pad_start(int.to_string(n), 2, "0") +} + +// ─── Data ──────────────────────────────────────────────────────── + +type ServiceStatus { + Up + Down + Warn +} + +type Service { + Service( + name: String, + host: String, + status: ServiceStatus, + cpu_pct: Int, + mem_pct: Int, + uptime: String, + version: String, + info: String, + ) +} + +type LogLevel { + LInfo + LWarn + LError + LDebug +} + +type LogEntry { + LogEntry(time: String, level: LogLevel, service: String, message: String) +} + +const services: List(Service) = [ + Service( + "api-gateway", + "gw01.prod", + Up, + 23, + 41, + "47d 3h", + "nginx/2.8.1", + "Reverse proxy, 12k req/s, 6 upstreams active", + ), + Service( + "auth-service", + "auth01.prod", + Up, + 8, + 29, + "12d 6h", + "auth/3.1.0", + "JWT issuer + OIDC provider, 3k active sessions", + ), + Service( + "user-db", + "pg01.prod", + Up, + 67, + 78, + "180d 2h", + "postgres/16.1", + "Primary Postgres, 2.1M rows, 8 connections", + ), + Service( + "cache", + "redis01.prod", + Warn, + 2, + 89, + "3d 14h", + "redis/7.2", + "LRU cache — memory at 89% of limit, eviction elevated", + ), + Service( + "worker-queue", + "rmq01.prod", + Up, + 12, + 33, + "22d 9h", + "rabbitmq/3.12", + "3 queues, 41 consumers, 120 msg/s throughput", + ), + Service( + "ml-inference", + "gpu01.prod", + Up, + 91, + 62, + "5d 11h", + "triton/1.4.2", + "3 models loaded: bert-v2, clip, embed-v1", + ), + Service( + "cdn-origin", + "cdn01.prod", + Down, + 0, + 0, + "—", + "cdn/1.0.9", + "OFFLINE since 14:32 UTC — upstream unreachable", + ), + Service( + "monitor", + "mon01.prod", + Up, + 4, + 18, + "90d 0h", + "prom/2.3.0", + "Prometheus + Grafana, 8/8 targets healthy", + ), +] + +const log_entries: List(LogEntry) = [ + LogEntry( + "14:32:01", + LError, + "cdn-origin", + "Connection reset: upstream unreachable", + ), + LogEntry( + "14:32:05", + LError, + "cdn-origin", + "Health check failed (3/3) — marking DOWN", + ), + LogEntry("14:32:05", LWarn, "api-gateway", "Upstream cdn-origin marked DOWN"), + LogEntry( + "14:31:44", + LInfo, + "auth-service", + "Token refresh: user=521a3b expires=+1h", + ), + LogEntry( + "14:31:39", + LInfo, + "worker-queue", + "Job enqueued: email_send id=8fc9d1", + ), + LogEntry( + "14:31:22", + LInfo, + "user-db", + "Checkpoint complete — 4821 buffers written", + ), + LogEntry("14:30:58", LWarn, "cache", "Used memory 89% of maxmemory"), + LogEntry("14:30:41", LInfo, "ml-inference", "Model bert-v2 loaded: 1.2 GB"), + LogEntry( + "14:30:30", + LDebug, + "auth-service", + "OIDC discovery endpoint refreshed", + ), + LogEntry( + "14:30:11", + LInfo, + "api-gateway", + "Config reload: 0 errors, 12 upstreams", + ), + LogEntry("14:29:55", LInfo, "monitor", "Scrape cycle OK: 8/8 targets healthy"), + LogEntry("14:29:33", LInfo, "user-db", "VACUUM: 12000 dead rows removed"), + LogEntry( + "14:29:01", + LError, + "cdn-origin", + "SSL handshake timeout: peer=203.x.x.x", + ), + LogEntry("14:28:47", LInfo, "worker-queue", "Consumer ack: job=8fb1 t=42ms"), + LogEntry( + "14:28:22", + LInfo, + "auth-service", + "Login ok: user=d3f1a ip=10.0.1.4", + ), + LogEntry("14:27:59", LWarn, "cache", "Eviction rate elevated: 120 keys/s"), + LogEntry( + "14:27:33", + LInfo, + "api-gateway", + "Upstream ml-inference marked healthy", + ), + LogEntry("14:27:01", LDebug, "monitor", "Alert rule eval: 0 rules firing"), + LogEntry("14:26:45", LInfo, "user-db", "New connection from auth-service"), +] + +const req_data: List(Int) = [ + 120, 145, 132, 167, 155, 180, 172, 195, 188, 210, 198, 225, 215, 198, 220, 242, + 235, 218, 200, 212, +] + +const lat_data: List(Int) = [ + 8, 9, 7, 12, 10, 8, 11, 14, 12, 9, 8, 10, 13, 11, 9, 8, 10, 12, 9, 7, +] + +const err_data: List(Int) = [ + 0, 0, 1, 0, 0, 2, 1, 0, 0, 0, 3, 1, 0, 0, 5, 3, 1, 0, 0, 0, +] + +// ─── Model ─────────────────────────────────────────────────────── + +type Tab { + TabServices + TabEvents + TabMetrics + TabAbout +} + +type Screen { + Boot + Dashboard(tab: Tab, svc_cursor: Int, svc_offset: Int, log_offset: Int) + Detail(svc: Service, back_cursor: Int, back_offset: Int) +} + +type Model { + Model(screen: Screen, width: Int, height: Int, quit: Bool) +} + +fn initial_model() -> Model { + Model(screen: Boot, width: 80, height: 24, quit: False) +} + +// ─── Helpers ───────────────────────────────────────────────────── + +fn tab_index(tab: Tab) -> Int { + case tab { + TabServices -> 0 + TabEvents -> 1 + TabMetrics -> 2 + TabAbout -> 3 + } +} + +fn next_tab(tab: Tab) -> Tab { + case tab { + TabServices -> TabEvents + TabEvents -> TabMetrics + TabMetrics -> TabAbout + TabAbout -> TabServices + } +} + +fn color_for_pct(pct: Int) -> style.Color { + case pct >= 85 { + True -> c_red + False -> + case pct >= 60 { + True -> c_amber + False -> c_green + } + } +} + +fn mini_bar(pct: Int, color: style.Color) -> span.Span { + let width = 7 + let filled = case pct { + 0 -> 0 + 100 -> width + _ -> int.max(pct * width / 100, 1) + } + let bar = string.repeat("█", filled) <> string.repeat("░", width - filled) + span.span_plain(bar) |> span.span_fg(color) +} + +fn rotate_data(data: List(Int), offset: Int) -> List(Int) { + let len = list.length(data) + case len { + 0 -> [] + _ -> { + let off = offset % len + list.append(list.drop(data, off), list.take(data, off)) + } + } +} + +fn last_value(data: List(Int)) -> Int { + list.fold(data, 0, fn(_, v) { v }) +} + +fn svc_status_chars(s: ServiceStatus) -> Int { + case s { + Up -> 8 + Down -> 9 + Warn -> 10 + } +} + +fn fake_clock(frame: Int) -> String { + let total_secs = frame * 80 / 1000 + let secs = total_secs % 60 + let mins = { 32 + total_secs / 60 } % 60 + "14:" <> pad2(mins) <> ":" <> pad2(secs) +} + +fn spin_char(frame: Int) -> String { + let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + case list.drop(frames, frame % list.length(frames)) { + [s, ..] -> s + [] -> "·" + } +} + +// Animated status badge: UP static, WARN pulses, DOWN blinks +fn status_badge(s: ServiceStatus, frame: Int) -> span.Span { + case s { + Up -> grn_b("● UP ") + Warn -> + case frame / 8 % 2 { + 0 -> amb_b("▲ WARN ") + _ -> amb("▲ WARN ") + } + Down -> + case frame / 12 % 2 { + 0 -> red_b("✖ DOWN ") + _ -> span.span_plain("✖ DOWN ") |> span.span_fg(c_red) + } + } +} + +// Pulsing cursor for selected list rows +fn list_cursor(selected: Bool, frame: Int) -> span.Span { + case selected { + False -> span.span_plain(" ") + True -> + case frame / 5 % 2 { + 0 -> cy_b("▌▌ ") + _ -> cy("▌▌ ") + } + } +} + +// ─── Shared layout ─────────────────────────────────────────────── + +fn draw_tab_bar(buf: buffer.Buffer, active: Tab, w: Int) -> buffer.Buffer { + let t = + tabs_widget.tabs_new(["SERVICES", "EVENTS", "METRICS", "ABOUT"]) + |> tabs_widget.with_active(tab_index(active)) + |> tabs_widget.with_colors(c_cyan, style.Default) + let buf = tabs_widget.render(buf, rect_new(0, 0, w, 1), t) + put1(buf, 0, 1, w, [cy(string.repeat("━", w))]) +} + +fn draw_footer( + buf: buffer.Buffer, + w: Int, + h: Int, + hints: List(span.Span), +) -> buffer.Buffer { + let buf = put1(buf, 0, h - 2, w, [dim(string.repeat("─", w))]) + put1(buf, 1, h - 1, w - 2, hints) +} + +// ─── Boot screen ───────────────────────────────────────────────── + +const boot_banner: List(String) = [ + " ███╗ ██╗███████╗██╗ ██╗██╗ ██╗███████╗ ", + " ████╗ ██║██╔════╝╚██╗██╔╝██║ ██║██╔════╝ ", + " ██╔██╗ ██║█████╗ ╚███╔╝ ██║ ██║███████╗ ", + " ██║╚██╗██║██╔══╝ ██╔██╗ ██║ ██║╚════██║ ", + " ██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║ ", + " ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ", +] + +const load_messages: List(String) = [ + "CONNECTING TO MONITORING SUBSYSTEM...", + "LOADING INFRASTRUCTURE REGISTRY...", + "SYNCING METRICS PIPELINE...", + "ATTACHING EVENT LOG STREAM...", + "FINALIZING INTERFACES...", + "SYSTEM READY", +] + +// Banner fully typed at frame ~24, loading starts then. +// Loading completes at frame 24 + 35 = 59 (~4.7s total boot). +const banner_done_at = 24 + +const load_frames = 35 + +fn render_boot(model: Model, anim_st: anim.AnimState) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let w = model.width + let h = model.height + let frame = anim_st.frame + let banner_h = list.length(boot_banner) + let total_h = banner_h + 7 + let top_y = int.max(h / 2 - total_h / 2, 1) + let indent = int.max(w / 2 - 24, 1) + let cw = w - indent * 2 + + // Phase 1: Banner types in, 4 graphemes per frame, line i starts at frame i*2 + let buf = + list.index_fold(boot_banner, buf, fn(b, line, i) { + let line_frame = int.max(frame - i * 2, 0) + let n = int.min(line_frame * 4, string.length(line)) + case n { + 0 -> b + _ -> { + let tail_len = int.min(4, n) + let head = string.slice(line, 0, n - tail_len) + let tail = string.slice(line, n - tail_len, tail_len) + put1(b, indent, top_y + i, cw, [dcy(head), cy_b(tail)]) + } + } + }) + + // Subtitle appears when banner is complete + let sub_y = top_y + banner_h + 1 + let buf = case frame >= banner_done_at { + False -> buf + True -> + put1(buf, indent, sub_y, cw, [ + dcy("INFRASTRUCTURE MONITOR"), + gap(4), + dim("v1.0.0 — etui demo"), + ]) + } + + // Phase 2: Loading gauge fills up + let load_start = banner_done_at + let progress = case frame < load_start { + True -> 0 + False -> int.min({ frame - load_start } * 100 / load_frames, 100) + } + let all_done = frame >= load_start + load_frames + + let gauge_y = sub_y + 2 + let gauge_w = int.min(cw - 4, 50) + let gauge_x = indent + 2 + + let buf = case frame >= load_start { + False -> buf + True -> { + // Spinner + message + let n_msg = list.length(load_messages) + let msg_idx = int.min(progress * n_msg / 101, n_msg - 1) + let msg = case list.drop(load_messages, msg_idx) { + [s, ..] -> s + [] -> "SYSTEM READY" + } + let buf = + put1(buf, gauge_x, gauge_y, gauge_w + 2, [ + case all_done { + True -> grn_b("✓") + False -> amb_b(spin_char(frame)) + }, + gap(2), + case all_done { + True -> grn(msg) + False -> dim(msg) + }, + ]) + + // Gauge bar + let gauge_color = case progress { + p if p < 40 -> c_cyan + p if p < 70 -> c_cyan2 + _ -> c_green + } + let g = + gauge.gauge_new(progress) + |> gauge.with_label(int.to_string(progress) <> "%") + |> gauge.with_colors(gauge_color, style.Default) + gauge.render(buf, rect_new(gauge_x, gauge_y + 1, gauge_w, 1), g) + } + } + + // Phase 3: Prompt, blinks when ready + let prompt_y = gauge_y + 3 + case all_done { + False -> buf + True -> + put1(buf, indent, prompt_y, cw, [ + cy_b("> PRESS ANY KEY TO START "), + case frame / 6 % 2 { + 0 -> cy_r(" ▌ ") + _ -> cy(" ▌ ") + }, + ]) + } +} + +// ─── Services tab ──────────────────────────────────────────────── + +fn svc_name_span(svc: Service, selected: Bool, frame: Int) -> span.Span { + let f = string.pad_end(svc.name, 18, " ") + case svc.status { + Up -> + case selected { + True -> + case frame / 5 % 2 { + 0 -> wht_b(f) + _ -> cy_b(f) + } + False -> cy(f) + } + Down -> + case selected { + True -> red_b(f) + False -> span.span_plain(f) |> span.span_fg(c_red) + } + Warn -> + case selected { + True -> + case frame / 7 % 2 { + 0 -> amb_b(f) + _ -> wht_b(f) + } + False -> amb(f) + } + } +} + +fn render_services( + model: Model, + cursor: Int, + offset: Int, + frame: Int, +) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let w = model.width + let h = model.height + let row_count = int.max(h - 8, 3) + let total = list.length(services) + let n_up = list.length(list.filter(services, fn(s) { s.status == Up })) + let n_down = list.length(list.filter(services, fn(s) { s.status == Down })) + let n_warn = list.length(list.filter(services, fn(s) { s.status == Warn })) + + let buf = draw_tab_bar(buf, TabServices, w) + + // Title + right health badge (pulses red when DOWN services exist) + let health_str = + "● " + <> int.to_string(n_up) + <> " ✖ " + <> int.to_string(n_down) + <> " ▲ " + <> int.to_string(n_warn) + let health_w = string.length(health_str) + let down_color = case n_down > 0 && frame / 8 % 2 == 0 { + True -> c_red + False -> c_dim + } + let buf = + put1(buf, 2, 2, w - health_w - 4, [ + cy_b("SERVICES"), + gap(3), + dim(int.to_string(total) <> " services"), + ]) + let buf = + put1(buf, w - health_w - 2, 2, health_w + 2, [ + grn_b("● "), + grn_b(int.to_string(n_up)), + span.span_plain(" ✖ ") |> span.span_fg(down_color), + span.span_plain(int.to_string(n_down)) + |> span.span_fg(down_color) + |> span.span_modifier(style.bold()), + dim(" ▲ "), + amb_b(int.to_string(n_warn)), + ]) + let buf = put1(buf, 0, 3, w, [dim(string.repeat("─", w))]) + + // Column headers + let buf = + put1(buf, 1, 4, w - 2, [ + span.span_plain(" "), + dim_b("STATUS "), + gap(1), + dim_b(string.pad_end("NAME", 18, " ")), + gap(1), + dim_b(string.pad_end("HOST", 14, " ")), + gap(1), + dim_b("CPU "), + gap(1), + dim_b("MEM "), + gap(1), + dim_b("UPTIME"), + ]) + let buf = put1(buf, 0, 5, w, [dim(string.repeat("─", w))]) + + // Rows + let visible = services |> list.drop(offset) |> list.take(row_count) + let row_lines = + list.index_map(visible, fn(svc, i) { + let selected = offset + i == cursor + span.line_new([ + list_cursor(selected, frame), + status_badge(svc.status, frame), + gap(1), + svc_name_span(svc, selected, frame), + gap(1), + dim(string.pad_end(svc.host, 14, " ")), + gap(1), + mini_bar(svc.cpu_pct, color_for_pct(svc.cpu_pct)), + gap(1), + mini_bar(svc.mem_pct, color_for_pct(svc.mem_pct)), + gap(1), + case svc.status { + Down -> dim(" — ") + _ -> dcy(string.pad_end(svc.uptime, 7, " ")) + }, + ]) + }) + let buf = + paragraph.render_styled(buf, rect_new(1, 6, w - 3, row_count), row_lines) + + let sb = + scrollbar.scrollbar_new(total, row_count, offset) + |> scrollbar.with_arrows("", "") + let buf = scrollbar.render_vertical(buf, rect_new(w - 1, 6, 1, row_count), sb) + + let footer = + list.flatten([ + hint("↑↓ jk", "MOVE"), + [gap(2)], + hint("↵", "DETAIL"), + [gap(2)], + hint("TAB", "SWITCH"), + [gap(2)], + hint("q", "QUIT"), + [gap(3)], + [dim(int.to_string(cursor + 1) <> "/" <> int.to_string(total))], + ]) + draw_footer(buf, w, h, footer) +} + +// ─── Events tab ────────────────────────────────────────────────── + +fn render_events(model: Model, log_offset: Int, frame: Int) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let w = model.width + let h = model.height + let total = list.length(log_entries) + let n_err = list.length(list.filter(log_entries, fn(e) { e.level == LError })) + let n_warn = list.length(list.filter(log_entries, fn(e) { e.level == LWarn })) + + let buf = draw_tab_bar(buf, TabEvents, w) + + // Animated LIVE badge (● pulses green ↔ dim) + let live_dot = case frame / 6 % 2 { + 0 -> grn_b("●") + _ -> grn("○") + } + let buf = + put1(buf, 2, 2, w - 14, [ + cy_b("EVENTS"), + gap(3), + dim(int.to_string(total) <> " entries"), + ]) + let buf = put1(buf, w - 10, 2, 10, [live_dot, grn(" LIVE ")]) + let buf = put1(buf, 0, 3, w, [dim(string.repeat("─", w))]) + + // Alert bar: pulses when errors present + let buf = case n_err > 0 { + False -> buf + True -> { + let alert_bright = frame / 10 % 2 == 0 + let err_col = case alert_bright { + True -> c_red + False -> c_dim + } + put1(buf, 2, 4, w - 4, [ + span.span_plain("✖ " <> int.to_string(n_err) <> " error") + |> span.span_fg(err_col) + |> span.span_modifier(style.bold()), + case n_err == 1 { + True -> dim("") + False -> dim("s") + }, + case n_warn > 0 { + True -> + amb( + " ▲ " + <> int.to_string(n_warn) + <> " warning" + <> case n_warn == 1 { + True -> "" + False -> "s" + }, + ) + False -> span.span_plain("") + }, + ]) + } + } + + let col_y = case n_err > 0 { + True -> 5 + False -> 4 + } + let data_start = col_y + 2 + let row_count = int.max(h - data_start - 2, 3) + + let buf = + put1(buf, 1, col_y, w - 2, [ + dim_b("TIME "), + gap(1), + dim_b(" LEVEL "), + gap(1), + dim_b(string.pad_end("SERVICE", 17, " ")), + dim_b("MESSAGE"), + ]) + let buf = put1(buf, 0, col_y + 1, w, [dim(string.repeat("─", w))]) + + let visible = log_entries |> list.drop(log_offset) |> list.take(row_count) + let rows = + list.map(visible, fn(entry) { + let time_s = case entry.level { + LError -> { + // Error timestamps pulse + case frame / 8 % 2 { + 0 -> span.span_plain(entry.time) |> span.span_fg(c_red) + _ -> + span.span_plain(entry.time) + |> span.span_fg(c_red) + |> span.span_modifier(style.bold()) + } + } + LWarn -> amb(entry.time) + _ -> dim(entry.time) + } + span.line_new([ + time_s, + gap(1), + case entry.level { + LInfo -> grn(" INFO ") + LWarn -> amb(" WARN ") + LError -> red_b(" ERR ") + LDebug -> dim(" DBG ") + }, + gap(1), + dcy(string.pad_end(entry.service, 17, " ")), + case entry.level { + LError -> span.span_plain(entry.message) |> span.span_fg(c_red) + LWarn -> amb(entry.message) + _ -> dim(entry.message) + }, + ]) + }) + let buf = + paragraph.render_styled( + buf, + rect_new(1, data_start, w - 3, row_count), + rows, + ) + + let sb = + scrollbar.scrollbar_new(total, row_count, log_offset) + |> scrollbar.with_arrows("", "") + let buf = + scrollbar.render_vertical( + buf, + rect_new(w - 1, data_start, 1, row_count), + sb, + ) + + let footer = + list.flatten([ + hint("↑↓ jk", "SCROLL"), + [gap(2)], + hint("TAB", "SWITCH"), + [gap(2)], + hint("q", "QUIT"), + ]) + draw_footer(buf, w, h, footer) +} + +// ─── Metrics tab ───────────────────────────────────────────────── + +fn render_metrics(model: Model, anim_st: anim.AnimState) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let w = model.width + let h = model.height + let frame = anim_st.frame + let data_off = frame / 5 + let clock = fake_clock(frame) + + let buf = draw_tab_bar(buf, TabMetrics, w) + + // Ticking clock in header + let buf = + put1(buf, 2, 2, w - 14, [ + cy_b("METRICS"), + gap(3), + dim("20s window"), + ]) + let buf = + put1(buf, w - 12, 2, 12, [ + case frame / 6 % 2 { + 0 -> grn_b("●") + _ -> grn("●") + }, + grn(" "), + dim(clock), + ]) + let buf = put1(buf, 0, 3, w, [dim(string.repeat("─", w))]) + + let chart_w = int.max(w - 16, 20) + let val_x = chart_w + 3 + let val_w = int.max(w - chart_w - 5, 8) + + // REQ/S, animated gradient cyan → teal → blue + let req = rotate_data(req_data, data_off) + let cur_req = last_value(req) + let buf = + put1(buf, 2, 4, w - 4, [ + cy_b("REQ/S"), + gap(2), + dim("req per second · "), + dcy("peak 242"), + ]) + let buf = + sparkline.render( + buf, + rect_new(2, 5, chart_w, 4), + sparkline.sparkline_new(req) + |> sparkline.with_fill( + sparkline.SparkAnimated([c_blue, c_dcyan, c_cyan, c_cyan2]), + ), + frame, + ) + let buf = + put1(buf, val_x, 6, val_w, [cy_b(int.to_string(cur_req)), dim(" r/s")]) + + // P95 LATENCY, animated gradient dark → cyan + let lat = rotate_data(lat_data, data_off) + let cur_lat = last_value(lat) + let buf = + put1(buf, 2, 10, w - 4, [ + cy_b("P95 ms"), + gap(2), + dim("response latency · "), + dcy("peak 14ms"), + ]) + let buf = + sparkline.render( + buf, + rect_new(2, 11, chart_w, 4), + sparkline.sparkline_new(lat) + |> sparkline.with_fill( + sparkline.SparkAnimated([c_dcyan, c_cyan3, c_cyan]), + ), + frame, + ) + let lat_color = case cur_lat >= 12 { + True -> c_amber + False -> c_dcyan + } + let buf = + put1(buf, val_x, 12, val_w, [ + span.span_plain(int.to_string(cur_lat)) |> span.span_fg(lat_color), + dim(" ms"), + ]) + + // ERRORS/S, animated rainbow (very dramatic) + let err = rotate_data(err_data, data_off) + let cur_err = last_value(err) + let buf = + put1(buf, 2, 16, w - 4, [ + cy_b("ERRORS"), + gap(2), + dim("errors per second · "), + case cur_err { + 0 -> grn("all clear") + _ -> red_b("active!") + }, + ]) + let buf = + sparkline.render( + buf, + rect_new(2, 17, chart_w, 4), + sparkline.sparkline_new(err) + |> sparkline.with_fill(sparkline.SparkAnimatedRainbow), + frame, + ) + let buf = + put1(buf, val_x, 18, val_w, [ + case cur_err { + 0 -> grn_b("0") + n -> red_b(int.to_string(n)) + }, + dim(" e/s"), + ]) + + let footer = + list.flatten([ + hint("TAB", "SWITCH"), + [gap(2)], + hint("q", "QUIT"), + [gap(4)], + [dim("gradients animate in real time")], + ]) + draw_footer(buf, w, h, footer) +} + +// ─── About tab ─────────────────────────────────────────────────── + +fn render_about(model: Model, frame: Int) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let w = model.width + let h = model.height + let indent = int.max(w / 2 - 28, 2) + let cw = w - indent * 2 + + let buf = draw_tab_bar(buf, TabAbout, w) + let buf = + put1(buf, 2, 2, w - 4, [ + cy_b("ABOUT"), + gap(3), + dim("etui — TUI framework for Gleam"), + ]) + let buf = put1(buf, 0, 3, w, [dim(string.repeat("─", w))]) + + let buf = + put1(buf, indent, 5, cw, [ + cy_b("GATUI"), + gap(3), + case frame / 8 % 2 { + 0 -> pk_b("The TUI framework for Gleam on BEAM") + _ -> pk("The TUI framework for Gleam on BEAM") + }, + ]) + let buf = + put1(buf, indent, 6, cw, [ + dcy("Pure Gleam · Type-safe · No terminal left broken"), + ]) + let buf = put1(buf, indent, 7, cw, [dim(string.repeat("─", int.min(cw, 52)))]) + + let features = [ + #("run_animated", "event loop with auto-managed AnimState"), + #("sparkline", "block-char charts — animated gradient or rainbow fill"), + #("gauge", "progress bar, custom color, label overlay"), + #("tabs", "tab bar with active highlight and color control"), + #("span + line", "rich inline styled text, color + modifier per span"), + #("buffer + diff", "dense cell grid, minimal ANSI patches per frame"), + #("scrollbar", "scroll indicator overlay, configurable arrows"), + #("paragraph", "word-wrap, alignment, styled line rendering"), + ] + let buf = put1(buf, indent, 9, cw, [dim_b("WIDGETS IN USE")]) + let buf = + list.index_fold(features, buf, fn(b, f, i) { + let #(name, desc) = f + put1(b, indent, 10 + i, cw, [ + cy_b(string.pad_end(name, 14, " ")), + dim(desc), + ]) + }) + + let key_hints = [ + #("↑↓ j k", "navigate lists"), + #("↵", "open service detail"), + #("TAB", "cycle tabs"), + #("q", "quit"), + ] + let buf = put1(buf, indent, 20, cw, [dim_b("KEYS")]) + let buf = + list.index_fold(key_hints, buf, fn(b, kh, i) { + let #(key, label) = kh + put1(b, indent, 21 + i, cw, [ + cy_r(" " <> key <> " "), + gap(2), + dim(label), + ]) + }) + + let footer = + list.flatten([hint("TAB", "SWITCH"), [gap(2)], hint("q", "QUIT")]) + draw_footer(buf, w, h, footer) +} + +// ─── Detail screen ─────────────────────────────────────────────── + +fn render_detail(model: Model, svc: Service, frame: Int) -> buffer.Buffer { + let screen = rect_new(0, 0, model.width, model.height) + let buf = buffer.buffer_new(screen) + let w = model.width + let h = model.height + let frame_w = int.min(64, w - 4) + let cx = int.max(w / 2 - frame_w / 2, 2) + let cy_ = int.max(h / 2 - 10, 1) + let inner_w = frame_w - 6 + + // Box drawing + let top = + "╔══ SERVICE DETAIL " <> string.repeat("═", int.max(frame_w - 20, 0)) <> "╗" + let mid = "╠" <> string.repeat("═", frame_w - 2) <> "╣" + let bottom = "╚" <> string.repeat("═", frame_w - 2) <> "╝" + let side = "║ " + let side_r = " ║" + let blank = side <> string.repeat(" ", frame_w - 4) <> "║" + + // Box color pulses for WARN/DOWN + let box_col = case svc.status { + Up -> c_cyan + Down -> + case frame / 8 % 2 { + 0 -> c_red + _ -> c_dim + } + Warn -> + case frame / 10 % 2 { + 0 -> c_amber + _ -> c_dim + } + } + let bx = fn(s: String) { span.span_plain(s) |> span.span_fg(box_col) } + + let buf = put1(buf, cx, cy_, frame_w, [bx(top)]) + + // Service name, colored by status + let name_col = case svc.status { + Up -> wht_b(string.pad_end(svc.name, inner_w, " ")) + Down -> red_b(string.pad_end(svc.name, inner_w, " ")) + Warn -> amb_b(string.pad_end(svc.name, inner_w, " ")) + } + let buf = put1(buf, cx, cy_ + 1, frame_w, [bx(side), name_col, bx(side_r)]) + let buf = + put1(buf, cx, cy_ + 2, frame_w, [ + bx(side), + dim(string.pad_end(svc.version, inner_w, " ")), + bx(side_r), + ]) + + // Status row with animated badge + let buf = put1(buf, cx, cy_ + 3, frame_w, [bx(mid)]) + let status_s = status_badge(svc.status, frame) + let buf = + put1(buf, cx, cy_ + 4, frame_w, [ + bx(side), + dim_b(string.pad_end("STATUS", 10, " ")), + status_s, + span.span_plain(string.repeat( + " ", + int.max(inner_w - 10 - svc_status_chars(svc.status), 0), + )), + bx(side_r), + ]) + + // Data fields + let data_rows = [ + #("HOST", svc.host), + #("UPTIME", svc.uptime), + #("INFO", svc.info), + ] + let buf = + list.index_fold(data_rows, buf, fn(b, dr, i) { + let #(label, value) = dr + let val_w = int.min(string.length(value), inner_w - 10) + let pad = int.max(inner_w - 10 - val_w, 0) + put1(b, cx, cy_ + 5 + i, frame_w, [ + bx(side), + dim_b(string.pad_end(label, 10, " ")), + cy_b(string.slice(value, 0, val_w)), + span.span_plain(string.repeat(" ", pad)), + bx(side_r), + ]) + }) + + // Resource gauges + let buf = put1(buf, cx, cy_ + 8, frame_w, [bx(mid)]) + let gauge_w = frame_w - 8 + + let buf = put1(buf, cx, cy_ + 9, frame_w, [bx(blank)]) + let cpu_g = + gauge.gauge_new(svc.cpu_pct) + |> gauge.with_label("CPU " <> int.to_string(svc.cpu_pct) <> "%") + |> gauge.with_colors(color_for_pct(svc.cpu_pct), style.Default) + let buf = gauge.render(buf, rect_new(cx + 4, cy_ + 9, gauge_w, 1), cpu_g) + + let buf = put1(buf, cx, cy_ + 10, frame_w, [bx(blank)]) + let mem_g = + gauge.gauge_new(svc.mem_pct) + |> gauge.with_label("MEM " <> int.to_string(svc.mem_pct) <> "%") + |> gauge.with_colors(color_for_pct(svc.mem_pct), style.Default) + let buf = gauge.render(buf, rect_new(cx + 4, cy_ + 10, gauge_w, 1), mem_g) + + let buf = put1(buf, cx, cy_ + 11, frame_w, [bx(blank)]) + let buf = put1(buf, cx, cy_ + 12, frame_w, [bx(bottom)]) + + let back_hints = + list.flatten([hint("← h ESC", "BACK"), [gap(2)], hint("q", "QUIT")]) + put1(buf, cx, cy_ + 13, frame_w, back_hints) +} + +// ─── Render dispatcher ─────────────────────────────────────────── + +fn render( + model: Model, + screen: Rect, + anim_st: anim.AnimState, +) -> buffer.Buffer { + let model = + Model(..model, width: screen.size.width, height: screen.size.height) + let frame = anim_st.frame + case model.screen { + Boot -> render_boot(model, anim_st) + Dashboard(tab, c, o, lo) -> + case tab { + TabServices -> render_services(model, c, o, frame) + TabEvents -> render_events(model, lo, frame) + TabMetrics -> render_metrics(model, anim_st) + TabAbout -> render_about(model, frame) + } + Detail(svc, _, _) -> render_detail(model, svc, frame) + } +} + +// ─── Update ────────────────────────────────────────────────────── + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.Resize(w, h) -> Model(..model, width: w, height: h) + _ -> + case model.screen { + Boot -> + case event { + backend.KeyPress(_) -> + Model(..model, screen: Dashboard(TabServices, 0, 0, 0)) + _ -> model + } + + Dashboard(tab, c, o, lo) -> { + let row_count = int.max(model.height - 8, 3) + let total_svcs = list.length(services) + let total_logs = list.length(log_entries) + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + backend.KeyPress("tab") -> + Model(..model, screen: Dashboard(next_tab(tab), c, o, lo)) + backend.KeyPress("j") | backend.KeyPress("down") -> + case tab { + TabServices -> { + let nc = int.min(c + 1, int.max(total_svcs - 1, 0)) + Model( + ..model, + screen: Dashboard( + tab, + nc, + list_widget.effective_offset( + list_widget.ListState(selected: nc, offset: o), + row_count, + ), + lo, + ), + ) + } + TabEvents -> { + let nlo = int.min(lo + 1, int.max(total_logs - row_count, 0)) + Model(..model, screen: Dashboard(tab, c, o, nlo)) + } + _ -> model + } + backend.KeyPress("k") | backend.KeyPress("up") -> + case tab { + TabServices -> { + let nc = int.max(c - 1, 0) + Model( + ..model, + screen: Dashboard( + tab, + nc, + list_widget.effective_offset( + list_widget.ListState(selected: nc, offset: o), + row_count, + ), + lo, + ), + ) + } + TabEvents -> { + let nlo = int.max(lo - 1, 0) + Model(..model, screen: Dashboard(tab, c, o, nlo)) + } + _ -> model + } + backend.KeyPress("enter") -> + case tab { + TabServices -> + case list.drop(services, c) { + [svc, ..] -> Model(..model, screen: Detail(svc, c, o)) + [] -> model + } + _ -> model + } + _ -> model + } + } + + Detail(_, back_c, back_o) -> + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + backend.KeyPress("h") + | backend.KeyPress("esc") + | backend.KeyPress("left") -> + Model(..model, screen: Dashboard(TabServices, back_c, back_o, 0)) + _ -> model + } + } + } +} + +// ─── Entry point ───────────────────────────────────────────────── + +pub fn main() -> Nil { + let _ = + app.run_animated( + default.new(), + initial_model(), + render, + update, + fn(m) { m.quit }, + 80, + ) + Nil +} diff --git a/dev/etui_showcase.gleam b/dev/etui_showcase.gleam new file mode 100644 index 0000000..51f7ff1 --- /dev/null +++ b/dev/etui_showcase.gleam @@ -0,0 +1,1268 @@ +/// GATUI SHOWCASE, interactive widget explorer. +/// Run: gleam run -m etui_showcase +/// TAB=switch j/k=navigate ↵=select/submit TAB/S-TAB=form fields +/// d=dialog n=info e=error r=reset form q=quit +import etui/anim +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect, rect_new} +import etui/keys +import etui/span +import etui/style +import etui/widgets/block +import etui/widgets/dialog as dlg_w +import etui/widgets/form +import etui/widgets/gradient_bar +import etui/widgets/hbar +import etui/widgets/list as list_w +import etui/widgets/marquee +import etui/widgets/notification as notif +import etui/widgets/paragraph +import etui/widgets/progress +import etui/widgets/scrollbar +import etui/widgets/spinner +import etui/widgets/statusbar +import etui/widgets/table +import etui/widgets/tabs as tabs_w +import etui/widgets/tree +import gleam/int +import gleam/list +import gleam/string + +// ─── Palette ───────────────────────────────────────────────────── + +const c_cyan = style.Indexed(51) + +const c_dcyan = style.Indexed(37) + +const c_green = style.Indexed(82) + +const c_amber = style.Indexed(214) + +const c_red = style.Indexed(196) + +const c_blue = style.Indexed(27) + +const c_pink = style.Indexed(213) + +const c_violet = style.Indexed(135) + +const c_dim = style.Indexed(240) + +// ─── Span helpers ──────────────────────────────────────────────── + +fn cy(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_cyan) +} + +fn cy_b(s: String) -> span.Span { + cy(s) |> span.span_modifier(style.bold()) +} + +fn cy_r(s: String) -> span.Span { + cy(s) |> span.span_modifier(style.reverse()) +} + +fn grn(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_green) +} + +fn grn_b(s: String) -> span.Span { + grn(s) |> span.span_modifier(style.bold()) +} + +fn amb(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_amber) +} + +fn red_b(s: String) -> span.Span { + span.span_plain(s) + |> span.span_fg(c_red) + |> span.span_modifier(style.bold()) +} + +fn pk(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_pink) +} + +fn vio(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_violet) +} + +fn dim(s: String) -> span.Span { + span.span_plain(s) |> span.span_fg(c_dim) +} + +fn dim_b(s: String) -> span.Span { + dim(s) |> span.span_modifier(style.bold()) +} + +fn gap(n: Int) -> span.Span { + span.span_plain(string.repeat(" ", n)) +} + +fn put1( + buf: buffer.Buffer, + x: Int, + y: Int, + w: Int, + spans: List(span.Span), +) -> buffer.Buffer { + paragraph.render_styled(buf, rect_new(x, y, w, 1), [span.line_new(spans)]) +} + +// ─── Data ──────────────────────────────────────────────────────── + +type PkgStatus { + Stable + Beta + Deprecated +} + +type Package { + Package( + name: String, + version: String, + description: String, + author: String, + downloads: Int, + license: String, + updated: String, + status: PkgStatus, + ) +} + +const packages: List(Package) = [ + Package( + "gleam_stdlib", + "0.46.0", + "Standard library", + "lpil", + 2_847_291, + "Apache-2.0", + "2 days ago", + Stable, + ), + Package( + "gleam_erlang", + "0.27.0", + "Erlang stdlib bindings", + "lpil", + 1_234_567, + "Apache-2.0", + "1 week ago", + Stable, + ), + Package( + "gleam_otp", + "0.12.0", + "OTP actor utilities", + "lpil", + 987_654, + "Apache-2.0", + "3 weeks ago", + Stable, + ), + Package( + "gleam_json", + "2.3.0", + "JSON encode/decode", + "lpil", + 876_543, + "Apache-2.0", + "1 month ago", + Stable, + ), + Package( + "mist", + "4.0.2", + "HTTP/1.1 + HTTP/2 server", + "rawhat", + 543_210, + "Apache-2.0", + "2 days ago", + Stable, + ), + Package( + "wisp", + "1.4.0", + "Web framework", + "lpil", + 432_109, + "Apache-2.0", + "1 week ago", + Stable, + ), + Package( + "birl", + "1.7.1", + "Date + time library", + "massivefermion", + 321_098, + "Apache-2.0", + "2 months ago", + Stable, + ), + Package( + "simplifile", + "2.2.0", + "Cross-platform file I/O", + "hayleigh-t", + 234_567, + "MIT", + "3 weeks ago", + Stable, + ), + Package( + "gleam_http", + "3.6.0", + "HTTP types and client", + "lpil", + 198_765, + "Apache-2.0", + "1 month ago", + Stable, + ), + Package( + "lustre", + "4.4.0", + "Front-end framework", + "hayleigh-t", + 123_456, + "MIT", + "1 week ago", + Stable, + ), + Package( + "snag", + "0.3.0", + "Ergonomic errors", + "kierangill", + 67_890, + "MIT", + "6 months ago", + Stable, + ), + Package( + "gleam_pgo", + "1.1.0", + "PostgreSQL client", + "lpil", + 87_654, + "Apache-2.0", + "3 months ago", + Stable, + ), + Package( + "glisten", + "5.0.1", + "TCP/SSL server", + "rawhat", + 45_678, + "Apache-2.0", + "1 month ago", + Beta, + ), + Package( + "gleam_crypto", + "1.4.0", + "Cryptographic functions", + "lpil", + 145_678, + "Apache-2.0", + "2 months ago", + Stable, + ), +] + +fn pkg_list_items() -> List(span.Line) { + list.map(packages, fn(p) { + let badge = case p.status { + Stable -> grn(" ● ") + Beta -> amb(" ◆ ") + Deprecated -> red_b(" ✖ ") + } + let ver = dim(string.pad_end(p.version, 8, " ")) + span.line_new([badge, cy(string.pad_end(p.name, 18, " ")), ver]) + }) +} + +fn pkg_table_rows() -> List(List(String)) { + let header = ["PACKAGE", "AUTHOR", "DOWNLOADS", "LICENSE", "UPDATED"] + let rows = + list.map(packages, fn(p) { + [ + p.name, + p.author, + int_commas(p.downloads), + p.license, + p.updated, + ] + }) + [header, ..rows] +} + +fn int_commas(n: Int) -> String { + let s = int.to_string(n) + let len = string.length(s) + case len <= 3 { + True -> s + False -> int_commas(n / 1000) <> "," <> string.slice(s, len - 3, 3) + } +} + +fn make_tree() -> tree.TreeWidget { + tree.tree_new([ + tree.node("src", "src/", [ + tree.node("etui", "etui/", [ + tree.node("backend", "backend/", [ + tree.leaf("erlang", "erlang.gleam"), + tree.leaf("node", "node.gleam"), + ]), + tree.node("widgets", "widgets/", [ + tree.leaf("block", "block.gleam"), + tree.leaf("dialog", "dialog.gleam"), + tree.leaf("form", "form.gleam"), + tree.leaf("gauge", "gauge.gleam"), + tree.leaf("gradient_bar", "gradient_bar.gleam"), + tree.leaf("hbar", "hbar.gleam"), + tree.leaf("list", "list.gleam"), + tree.leaf("marquee", "marquee.gleam"), + tree.leaf("notification", "notification.gleam"), + tree.leaf("progress", "progress.gleam"), + tree.leaf("scrollbar", "scrollbar.gleam"), + tree.leaf("sparkline", "sparkline.gleam"), + tree.leaf("spinner", "spinner.gleam"), + tree.leaf("statusbar", "statusbar.gleam"), + tree.leaf("table", "table.gleam"), + tree.leaf("tabs", "tabs.gleam"), + tree.leaf("tree", "tree.gleam"), + ]), + tree.leaf("app", "app.gleam"), + tree.leaf("buffer", "buffer.gleam"), + tree.leaf("geometry", "geometry.gleam"), + tree.leaf("keys", "keys.gleam"), + tree.leaf("span", "span.gleam"), + tree.leaf("style", "style.gleam"), + ]), + ]), + tree.node("dev", "dev/", [ + tree.leaf("nexus", "etui_nexus.gleam"), + tree.leaf("showcase", "etui_showcase.gleam"), + ]), + tree.node("test", "test/", [ + tree.leaf("tests", "etui_test.gleam"), + ]), + tree.leaf("gleam_toml", "gleam.toml"), + tree.leaf("readme", "README.md"), + ]) +} + +// ─── Model ─────────────────────────────────────────────────────── + +type Tab { + TabForm + TabList + TabTree + TabLive + TabAbout +} + +type FormField { + FieldName + FieldHost + FieldTag +} + +type ListFocus { + FocusList + FocusTable +} + +type Model { + Model( + tab: Tab, + form: form.Form(FormField), + pkg_list_st: list_w.ListState, + pkg_table_st: table.TableState, + list_focus: ListFocus, + tree_widget: tree.TreeWidget, + tree_st: tree.TreeState, + dlg_open: Bool, + dlg_st: dlg_w.DialogState, + notifs: notif.NotificationQueue, + width: Int, + height: Int, + quit: Bool, + ) +} + +fn make_form() -> form.Form(FormField) { + form.form_new() + |> form.with_label_width(8) + |> form.with_focused_colors(c_cyan, style.Indexed(235)) + |> form.add_required(FieldName, "Name", "") + |> form.add_field(FieldHost, "Host", "prod.example.com", fn(v) { + case string.contains(v, ".") { + True -> Ok(Nil) + False -> Error("must be a valid hostname") + } + }) + |> form.add_required(FieldTag, "Tag", "") +} + +fn initial_model() -> Model { + let tw = make_tree() + let ts = + tree.state_new() + |> tree.expand("src", _) + |> tree.expand("etui", _) + |> tree.expand("widgets", _) + Model( + tab: TabForm, + form: make_form(), + pkg_list_st: list_w.state_new(), + // start at row 1: row 0 is the header + pkg_table_st: table.select_row(table.state_new(), 1), + list_focus: FocusList, + tree_widget: tw, + tree_st: ts, + dlg_open: False, + dlg_st: dlg_w.state_new(), + notifs: notif.queue_new(max: 4), + width: 80, + height: 24, + quit: False, + ) +} + +fn tab_index(t: Tab) -> Int { + case t { + TabForm -> 0 + TabList -> 1 + TabTree -> 2 + TabLive -> 3 + TabAbout -> 4 + } +} + +fn next_tab(t: Tab) -> Tab { + case t { + TabForm -> TabList + TabList -> TabTree + TabTree -> TabLive + TabLive -> TabAbout + TabAbout -> TabForm + } +} + +fn prev_tab(t: Tab) -> Tab { + case t { + TabForm -> TabAbout + TabList -> TabForm + TabTree -> TabList + TabLive -> TabTree + TabAbout -> TabLive + } +} + +// ─── Shared chrome ─────────────────────────────────────────────── + +fn draw_tabs(buf: buffer.Buffer, active: Tab, w: Int) -> buffer.Buffer { + let t = + tabs_w.tabs_new(["FORM", "LIST", "TREE", "LIVE", "ABOUT"]) + |> tabs_w.with_active(tab_index(active)) + |> tabs_w.with_colors(c_cyan, style.Default) + let buf = tabs_w.render(buf, rect_new(0, 0, w, 1), t) + put1(buf, 0, 1, w, [cy(string.repeat("━", w))]) +} + +fn draw_statusbar(buf: buffer.Buffer, m: Model, frame: Int) -> buffer.Buffer { + let tab_name = case m.tab { + TabForm -> "FORM" + TabList -> "LIST" + TabTree -> "TREE" + TabLive -> "LIVE" + TabAbout -> "ABOUT" + } + let live_dot = case frame / 8 % 2 { + 0 -> grn_b("●") + _ -> grn("●") + } + let right_hints = case m.tab { + TabForm -> + span.line_new([ + dim("TAB fields "), + dim("↵ submit "), + dim("r reset "), + cy("ESC back"), + gap(1), + ]) + TabList -> + span.line_new([ + dim("jk nav "), + dim("hl panel "), + dim("TAB switch "), + cy("q quit"), + gap(1), + ]) + TabTree -> + span.line_new([ + dim("jk nav "), + dim("↵ toggle "), + dim("TAB switch "), + cy("q quit"), + gap(1), + ]) + TabLive | TabAbout -> + span.line_new([ + dim("TAB switch "), + dim("d dialog "), + dim("n/e notify "), + cy("q quit"), + gap(1), + ]) + } + let sb = + statusbar.statusbar_new() + |> statusbar.with_colors(c_dim, style.Indexed(234)) + |> statusbar.with_left([ + span.line_new([gap(1), cy_b("GATUI"), dim(" EXPLORER")]), + ]) + |> statusbar.with_center([ + span.line_new([live_dot, dim(" " <> tab_name)]), + ]) + |> statusbar.with_right([right_hints]) + statusbar.render(buf, rect_new(0, m.height - 1, m.width, 1), sb) +} + +// ─── FORM tab ──────────────────────────────────────────────────── + +fn render_form(m: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, m.width, m.height) + let buf = buffer.buffer_new(screen) + let w = m.width + let h = m.height + let buf = draw_tabs(buf, TabForm, w) + + let box_w = int.min(54, w - 4) + let bx = int.max(w / 2 - box_w / 2, 2) + let by = int.max(h / 2 - 8, 3) + + let blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" NEW SERVICE ", block.Top) + |> block.with_colors(c_cyan, style.Default) + let blk_area = rect_new(bx, by, box_w, 14) + let inner = block.inner(blk_area, blk) + let buf = block.render(buf, blk_area, blk) + + let submitted = form.is_submitted(m.form) + + let buf = case submitted { + True -> { + let name = form.get_value(m.form, FieldName) + let host = form.get_value(m.form, FieldHost) + let tag = form.get_value(m.form, FieldTag) + let mid_y = inner.position.y + inner.size.height / 2 - 2 + let buf = + put1(buf, inner.position.x, mid_y, inner.size.width, [ + grn_b(" ✓ DEPLOYED SUCCESSFULLY"), + ]) + let buf = + put1(buf, inner.position.x, mid_y + 2, inner.size.width, [ + dim(" name "), + cy_b(name), + ]) + let buf = + put1(buf, inner.position.x, mid_y + 3, inner.size.width, [ + dim(" host "), + cy(host), + ]) + let buf = + put1(buf, inner.position.x, mid_y + 4, inner.size.width, [ + dim(" tag "), + cy(tag), + ]) + put1(buf, inner.position.x, mid_y + 6, inner.size.width, [ + dim(" press "), + cy_r(" r "), + dim(" to reset"), + ]) + } + False -> { + let form_area = + rect_new( + inner.position.x + 1, + inner.position.y + 1, + inner.size.width - 2, + inner.size.height - 4, + ) + let buf = form.render(buf, form_area, m.form) + let valid = form.is_valid(m.form) + let submit_y = inner.position.y + inner.size.height - 2 + put1(buf, inner.position.x, submit_y, inner.size.width, [ + gap(4), + case valid { + True -> cy_r(" ↵ DEPLOY ") + False -> dim(" ↵ DEPLOY ") + }, + gap(3), + cy_r(" r RESET "), + ]) + } + } + + let buf = + put1(buf, bx, by + 15, box_w, [ + dim(" TAB / S-TAB"), + cy(" ─ "), + dim("move fields "), + dim("backspace"), + cy(" ─ "), + dim("delete"), + ]) + + buf +} + +// ─── LIST tab ──────────────────────────────────────────────────── + +fn render_list(m: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, m.width, m.height) + let buf = buffer.buffer_new(screen) + let w = m.width + let h = m.height + let buf = draw_tabs(buf, TabList, w) + + let list_w_px = w * 2 / 5 + let table_x = list_w_px + 1 + let table_w = w - table_x + let content_h = h - 4 + + // Left panel, package list + let list_active = m.list_focus == FocusList + let list_blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" PACKAGES ", block.Top) + |> block.with_colors( + case list_active { + True -> c_cyan + False -> c_dim + }, + style.Default, + ) + let list_area = rect_new(0, 2, list_w_px, content_h) + let list_inner = block.inner(list_area, list_blk) + let buf = block.render(buf, list_area, list_blk) + + let pkg_lw = + list_w.list_new_styled(pkg_list_items()) + |> list_w.with_highlight_style(style.Style( + fg: c_cyan, + bg: style.Indexed(235), + modifier: style.none(), + )) + let buf = list_w.render_stateful(buf, list_inner, pkg_lw, m.pkg_list_st) + + // Scrollbar for list + let sb = + scrollbar.scrollbar_new( + list.length(packages), + list_inner.size.height, + list_w.effective_offset(m.pkg_list_st, list_inner.size.height), + ) + |> scrollbar.with_arrows("", "") + let buf = + scrollbar.render_vertical( + buf, + rect_new( + list_inner.position.x + list_inner.size.width, + list_inner.position.y, + 1, + list_inner.size.height, + ), + sb, + ) + + // Right panel, package detail table + let table_active = m.list_focus == FocusTable + let tbl_blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" DETAILS ", block.Top) + |> block.with_colors( + case table_active { + True -> c_cyan + False -> c_dim + }, + style.Default, + ) + let tbl_area = rect_new(table_x, 2, table_w, content_h) + let tbl_inner = block.inner(tbl_area, tbl_blk) + let buf = block.render(buf, tbl_area, tbl_blk) + + let col_w = [14, 10, 10, 10, 10] + let tbl = + table.table_new(pkg_table_rows()) + |> table.with_col_widths(col_w) + |> table.with_header(True) + |> table.with_highlight_style(style.Style( + fg: c_cyan, + bg: style.Indexed(235), + modifier: style.none(), + )) + let buf = table.render_stateful(buf, tbl_inner, tbl, m.pkg_table_st) + + // Focus hint + put1(buf, 1, h - 2, w - 2, [ + dim(" "), + cy_r(" h "), + dim(" list "), + cy_r(" l "), + dim(" table "), + dim("j/k navigate"), + ]) +} + +// ─── TREE tab ──────────────────────────────────────────────────── + +fn render_tree(m: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, m.width, m.height) + let buf = buffer.buffer_new(screen) + let w = m.width + let h = m.height + let buf = draw_tabs(buf, TabTree, w) + + let tree_w = int.min(42, w - 2) + let info_x = tree_w + 2 + let info_w = w - info_x - 1 + let content_h = h - 4 + + // Tree panel + let tree_blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" PROJECT TREE ", block.Top) + |> block.with_colors(c_cyan, style.Default) + let tree_area = rect_new(0, 2, tree_w, content_h) + let tree_inner = block.inner(tree_area, tree_blk) + let buf = block.render(buf, tree_area, tree_blk) + + let tw = + m.tree_widget + |> tree.with_colors(c_dcyan, style.Default) + |> tree.with_highlight_style(style.Style( + fg: c_cyan, + bg: style.Indexed(235), + modifier: style.none(), + )) + let buf = tree.render(buf, tree_inner, tw, m.tree_st) + + // Info panel + let info_blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" SELECTION ", block.Top) + |> block.with_colors(c_dim, style.Default) + let info_area = rect_new(info_x, 2, info_w, content_h) + let info_inner = block.inner(info_area, info_blk) + let buf = block.render(buf, info_area, info_blk) + + let selected_id = case tree.selected(m.tree_st) { + Ok(id) -> id + Error(_) -> "(none)" + } + let buf = + put1( + buf, + info_inner.position.x, + info_inner.position.y, + info_inner.size.width, + [ + dim("selected"), + ], + ) + let buf = + put1( + buf, + info_inner.position.x, + info_inner.position.y + 1, + info_inner.size.width, + [cy_b(selected_id)], + ) + + let buf = + put1( + buf, + info_inner.position.x, + info_inner.position.y + 3, + info_inner.size.width, + [dim("keys")], + ) + let key_hints = [ + #("↵", "expand/collapse"), + #("j k", "move selection"), + ] + list.index_fold(key_hints, buf, fn(b, kh, i) { + let #(k, l) = kh + put1( + b, + info_inner.position.x, + info_inner.position.y + 4 + i, + info_inner.size.width, + [cy_r(" " <> k <> " "), dim(" " <> l)], + ) + }) +} + +// ─── LIVE tab ──────────────────────────────────────────────────── + +fn render_live(m: Model, frame: Int) -> buffer.Buffer { + let screen = rect_new(0, 0, m.width, m.height) + let buf = buffer.buffer_new(screen) + let w = m.width + let h = m.height + let buf = draw_tabs(buf, TabLive, w) + + let bar_w = int.min(w - 6, 60) + let lx = 3 + + // ── SPINNERS ────────────────────────────────────────── + let buf = put1(buf, lx, 3, w - 4, [dim_b("SPINNERS")]) + let spin_styles = [ + #(spinner.Dots, "Dots", c_cyan), + #(spinner.Line, "Line", c_green), + #(spinner.Circle, "Circle", c_amber), + #(spinner.Bounce, "Bounce", c_pink), + ] + let buf = + list.index_fold(spin_styles, buf, fn(b, ss, i) { + let #(style_, label, color) = ss + let sx = lx + i * 16 + let sp = + spinner.spinner_new() + |> spinner.with_style(style_) + |> spinner.with_label(" " <> label) + |> spinner.with_colors(color, style.Default) + spinner.render(b, rect_new(sx, 4, 15, 1), sp, frame) + }) + + // ── PROGRESS BARS ───────────────────────────────────── + let buf = put1(buf, lx, 6, w - 4, [dim_b("PROGRESS")]) + + let pct = frame * 100 / 120 % 101 + let p1 = + progress.progress_new(pct) + |> progress.with_label(int.to_string(pct) <> "%") + |> progress.with_colors(c_cyan, style.Default) + let buf = progress.render(buf, rect_new(lx, 7, bar_w, 1), p1, frame) + + let p2 = + progress.progress_indeterminate() + |> progress.with_colors(c_dcyan, style.Default) + let buf = progress.render(buf, rect_new(lx, 8, bar_w, 1), p2, frame) + + // ── GRADIENT BARS ───────────────────────────────────── + let buf = put1(buf, lx, 10, w - 4, [dim_b("GRADIENT BARS")]) + + let g1 = gradient_bar.pulse_bar(c_cyan) + let buf = gradient_bar.render(buf, rect_new(lx, 11, bar_w, 1), g1, frame) + + let g2 = gradient_bar.animated_rainbow_bar() + let buf = gradient_bar.render(buf, rect_new(lx, 12, bar_w, 1), g2, frame) + + let g3 = + gradient_bar.gradient_progress_new( + [c_blue, c_cyan, c_green], + frame * 100 / 80 % 101, + ) + let buf = gradient_bar.render(buf, rect_new(lx, 13, bar_w, 1), g3, frame) + + // ── HBAR ────────────────────────────────────────────── + let buf = put1(buf, lx, 15, w - 4, [dim_b("HBAR")]) + let hb = + hbar.hbar_new([ + hbar.item("widgets", 28), + hbar.item("core", 12), + hbar.item("backend", 4), + hbar.item("demo", 3), + ]) + |> hbar.with_show_value(True) + |> hbar.with_max(50) + |> hbar.with_label_width(9) + let buf = hbar.render(buf, rect_new(lx, 16, bar_w, 4), hb, frame) + + // ── MARQUEE ─────────────────────────────────────────── + let buf = put1(buf, lx, h - 4, w - 4, [dim_b("TICKER")]) + let mq = + marquee.marquee_new( + "etui · pure gleam · type-safe · animated · no terminal left broken · sparklines · forms · trees · tables · notifications · dialogs ·", + ) + |> marquee.with_speed(4) + |> marquee.with_fg(c_dcyan) + marquee.render(buf, rect_new(lx, h - 3, w - 6, 1), mq, frame) +} + +// ─── ABOUT tab ─────────────────────────────────────────────────── + +fn render_about(m: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, m.width, m.height) + let buf = buffer.buffer_new(screen) + let w = m.width + let h = m.height + let buf = draw_tabs(buf, TabAbout, w) + + let left_w = int.min(38, w / 2) + let right_x = left_w + 2 + let right_w = w - right_x - 1 + let content_h = h - 4 + + // Left: widget list + let widget_blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" WIDGETS ", block.Top) + |> block.with_colors(c_cyan, style.Default) + let widget_area = rect_new(0, 2, left_w, content_h) + let widget_inner = block.inner(widget_area, widget_blk) + let buf = block.render(buf, widget_area, widget_blk) + + let widget_rows = [ + #("block", "border, title, padding"), + #("buffer", "cell grid + ANSI diff"), + #("dialog", "confirm / cancel modal"), + #("form", "multi-field + validation"), + #("gauge", "filled progress bar"), + #("gradient_bar", "animated color bars"), + #("hbar", "horizontal bar chart"), + #("list", "scrollable item list"), + #("marquee", "scrolling text ticker"), + #("notification", "toast overlay queue"), + #("paragraph", "wrapped styled text"), + #("progress", "progress / indeterminate"), + #("scrollbar", "scroll position overlay"), + #("sparkline", "block-char time-series"), + #("spinner", "animated loading char"), + #("statusbar", "L / C / R status strip"), + #("table", "grid with header + scroll"), + #("tabs", "tab-bar navigation"), + #("tree", "expand/collapse tree"), + ] + let buf = + list.index_fold(widget_rows, buf, fn(b, wr, i) { + let #(name, desc) = wr + case i < widget_inner.size.height { + False -> b + True -> + put1( + b, + widget_inner.position.x, + widget_inner.position.y + i, + widget_inner.size.width, + [ + cy(string.pad_end(name, 14, " ")), + dim(string.slice(desc, 0, widget_inner.size.width - 15)), + ], + ) + } + }) + + // Right: about text + hbar + let info_blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title(" GATUI ", block.Top) + |> block.with_colors(c_dim, style.Default) + let info_area = rect_new(right_x, 2, right_w, content_h) + let info_inner = block.inner(info_area, info_blk) + let buf = block.render(buf, info_area, info_blk) + + let ix = info_inner.position.x + let iy = info_inner.position.y + let iw = info_inner.size.width + + let buf = put1(buf, ix, iy, iw, [pk("The TUI framework for Gleam on BEAM.")]) + let buf = + put1(buf, ix, iy + 1, iw, [ + dim("Pure Gleam · Type-safe · Crash-safe"), + ]) + let buf = put1(buf, ix, iy + 3, iw, [dim_b("FEATURES")]) + let features = [ + "Animated event loop (run_animated)", + "Automatic buffer diff — minimal redraws", + "Crash-safe: TTY always restored", + "SIGINT handler — no broken terminal", + "Mouse support (SGR protocol)", + "19 built-in widgets", + "Erlang + Node.js backends", + ] + let buf = + list.index_fold(features, buf, fn(b, feat, i) { + put1(b, ix, iy + 4 + i, iw, [dim("· "), vio(feat)]) + }) + + let buf = put1(buf, ix, iy + 12, iw, [dim_b("FILES")]) + let hb = + hbar.hbar_new([ + hbar.item("widgets", 19), + hbar.item("core", 8), + hbar.item("backends", 3), + hbar.item("demo", 3), + ]) + |> hbar.with_show_value(True) + |> hbar.with_max(25) + |> hbar.with_label_width(10) + hbar.render(buf, rect_new(ix, iy + 13, iw, 4), hb, 0) +} + +// ─── Overlays ──────────────────────────────────────────────────── + +fn draw_dialog(buf: buffer.Buffer, m: Model) -> buffer.Buffer { + case m.dlg_open { + False -> buf + True -> { + let screen = rect_new(0, 0, m.width, m.height) + let d = + dlg_w.dialog_new("Quit GATUI EXPLORER?") + |> dlg_w.with_labels(" QUIT ", " CANCEL ") + |> dlg_w.with_border(block.Rounded) + |> dlg_w.with_colors(c_cyan, style.Default) + |> dlg_w.with_focused_style(style.Style( + fg: style.Default, + bg: style.Indexed(235), + modifier: style.bold(), + )) + dlg_w.render(buf, screen, d, m.dlg_st) + } + } +} + +fn draw_notifs(buf: buffer.Buffer, m: Model) -> buffer.Buffer { + let screen = rect_new(0, 0, m.width, m.height) + notif.render(buf, screen, m.notifs) +} + +// ─── Render ────────────────────────────────────────────────────── + +fn render(m: Model, screen: Rect, anim_st: anim.AnimState) -> buffer.Buffer { + let m = Model(..m, width: screen.size.width, height: screen.size.height) + let frame = anim_st.frame + let buf = case m.tab { + TabForm -> render_form(m) + TabList -> render_list(m) + TabTree -> render_tree(m) + TabLive -> render_live(m, frame) + TabAbout -> render_about(m) + } + let buf = draw_statusbar(buf, m, frame) + let buf = draw_notifs(buf, m) + draw_dialog(buf, m) +} + +// ─── Update ────────────────────────────────────────────────────── + +fn update(event: backend.InputEvent, m: Model) -> Model { + let m = Model(..m, notifs: notif.tick(m.notifs)) + case event { + backend.Resize(w, h) -> Model(..m, width: w, height: h) + backend.KeyPress(raw) -> handle_key(keys.match(raw), m) + _ -> m + } +} + +fn handle_key(k: keys.Key, m: Model) -> Model { + // Dialog steals all input + case m.dlg_open { + True -> + case k { + keys.Tab | keys.Left | keys.Right -> + Model(..m, dlg_st: dlg_w.toggle(m.dlg_st)) + keys.Escape -> Model(..m, dlg_open: False, dlg_st: dlg_w.state_new()) + keys.Enter -> + case dlg_w.is_confirmed(m.dlg_st) { + True -> Model(..m, quit: True) + False -> Model(..m, dlg_open: False, dlg_st: dlg_w.state_new()) + } + _ -> m + } + False -> + case k { + // Ctrl+C always quits + keys.Ctrl("c") -> Model(..m, quit: True) + // Tab: advance form field when on form tab; otherwise switch tab + keys.Tab -> + case m.tab { + TabForm -> Model(..m, form: form.focus_next(m.form)) + _ -> Model(..m, tab: next_tab(m.tab)) + } + keys.BackTab -> + case m.tab { + TabForm -> Model(..m, form: form.focus_prev(m.form)) + _ -> Model(..m, tab: prev_tab(m.tab)) + } + // F-keys jump directly to a tab from anywhere + keys.F(1) -> Model(..m, tab: TabForm) + keys.F(2) -> Model(..m, tab: TabList) + keys.F(3) -> Model(..m, tab: TabTree) + keys.F(4) -> Model(..m, tab: TabLive) + keys.F(5) -> Model(..m, tab: TabAbout) + // All other keys are tab-specific + _ -> handle_tab_key(k, m) + } + } +} + +fn handle_tab_key(k: keys.Key, m: Model) -> Model { + case m.tab { + TabForm -> handle_form(k, m) + TabList -> handle_list(k, m) + TabTree -> handle_tree(k, m) + TabLive | TabAbout -> handle_view(k, m) + } +} + +// Form tab: typing keys reach the form; no single-letter shortcuts conflict +fn handle_form(k: keys.Key, m: Model) -> Model { + case k { + keys.Escape -> Model(..m, tab: TabList) + keys.Backspace -> Model(..m, form: form.backspace(m.form)) + keys.Enter -> { + let f = form.submit(m.form) + let m2 = Model(..m, form: f) + case form.is_submitted(f) { + True -> + Model( + ..m2, + notifs: notif.push( + m2.notifs, + notif.success( + "Deployed: " <> form.get_value(f, FieldName), + ttl: 100, + ), + ), + ) + False -> m2 + } + } + // r resets form, can't type 'r' in fields, acceptable for a demo + keys.Char("r") -> Model(..m, form: make_form()) + keys.Char(c) -> Model(..m, form: form.type_char(m.form, c)) + _ -> m + } +} + +fn open_dialog(m: Model) -> Model { + Model(..m, dlg_open: True, dlg_st: dlg_w.focus_cancel(dlg_w.state_new())) +} + +// List tab: j/k always navigate both panels in sync; h/l switch visual focus +fn handle_list(k: keys.Key, m: Model) -> Model { + let n = list.length(packages) + case k { + keys.Char("q") -> Model(..m, quit: True) + keys.Char("d") -> open_dialog(m) + keys.Char("n") -> + Model( + ..m, + notifs: notif.push( + m.notifs, + notif.info("Package index refreshed", ttl: 80), + ), + ) + keys.Char("e") -> + Model( + ..m, + notifs: notif.push( + m.notifs, + notif.error("Registry unreachable", ttl: 120), + ), + ) + keys.Char("h") | keys.Left -> Model(..m, list_focus: FocusList) + keys.Char("l") | keys.Right -> Model(..m, list_focus: FocusTable) + keys.Down | keys.Char("j") -> + Model( + ..m, + pkg_list_st: list_w.select_next(m.pkg_list_st, n), + // +1 row offset because row 0 is the header + pkg_table_st: table.select_next_row(m.pkg_table_st, n + 1), + ) + keys.Up | keys.Char("k") -> + Model( + ..m, + pkg_list_st: list_w.select_prev(m.pkg_list_st), + pkg_table_st: table.select_prev_row(m.pkg_table_st), + ) + _ -> m + } +} + +fn handle_tree(k: keys.Key, m: Model) -> Model { + case k { + keys.Char("q") -> Model(..m, quit: True) + keys.Char("d") -> open_dialog(m) + keys.Char("n") -> + Model( + ..m, + notifs: notif.push(m.notifs, notif.info("Tree refreshed", ttl: 60)), + ) + keys.Char("e") -> + Model( + ..m, + notifs: notif.push( + m.notifs, + notif.error("Watch error: permission denied", ttl: 120), + ), + ) + keys.Down | keys.Char("j") -> + Model(..m, tree_st: tree.select_next(m.tree_st, m.tree_widget)) + keys.Up | keys.Char("k") -> + Model(..m, tree_st: tree.select_prev(m.tree_st, m.tree_widget)) + keys.Enter | keys.Char(" ") -> + Model(..m, tree_st: tree.toggle_selected(m.tree_st, m.tree_widget)) + _ -> m + } +} + +fn handle_view(k: keys.Key, m: Model) -> Model { + case k { + keys.Char("q") -> Model(..m, quit: True) + keys.Char("d") -> open_dialog(m) + keys.Char("n") -> + Model( + ..m, + notifs: notif.push( + m.notifs, + notif.info("Service restarted successfully", ttl: 80), + ), + ) + keys.Char("e") -> + Model( + ..m, + notifs: notif.push( + m.notifs, + notif.error("Connection refused: cdn-origin", ttl: 120), + ), + ) + _ -> m + } +} + +// ─── Entry point ───────────────────────────────────────────────── + +pub fn main() -> Nil { + let _ = + app.run_animated( + default.new(), + initial_model(), + render, + update, + fn(m) { m.quit }, + 80, + ) + Nil +} From d0da4376f46d35c5b4a4cdc4b84607d22603bedb Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:08:55 +0200 Subject: [PATCH 06/10] Add examples: minimal, counter, and snippets Add an examples/ tree containing two runnable Gleam apps (minimal and counter) with their gleam.toml, source files, and READMEs, plus a snippets.md cookbook of reusable UI patterns. --- examples/README.md | 43 ++++ examples/counter/README.md | 9 + examples/counter/gleam.toml | 11 + examples/counter/src/counter.gleam | 53 +++++ examples/minimal/README.md | 11 + examples/minimal/gleam.toml | 11 + examples/minimal/src/minimal.gleam | 38 ++++ examples/snippets.md | 337 +++++++++++++++++++++++++++++ 8 files changed, 513 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/counter/README.md create mode 100644 examples/counter/gleam.toml create mode 100644 examples/counter/src/counter.gleam create mode 100644 examples/minimal/README.md create mode 100644 examples/minimal/gleam.toml create mode 100644 examples/minimal/src/minimal.gleam create mode 100644 examples/snippets.md diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..c043e0c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,43 @@ +# Examples (repo only — not published on Hex) + +Runnable Gleam apps that depend on **étui**. They live in this repository so you can clone and run immediately; they are **not** included in the Hex package (only `src/` ships). + +## Quick pick + +| Path | What you learn | +| --- | --- | +| [minimal/](minimal/) | Smallest `run_buffered` app, quit with `q` | +| [counter/](counter/) | Split layout, block + paragraph, space to increment | +| [snippets.md](snippets.md) | Copy-paste fragments (not full projects) | + +## Run from clone (path dependency) + +```sh +cd examples/minimal +gleam run +``` + +Each example’s `gleam.toml` uses `etui = { path = "../.." }` (or `../../` from nested paths). After `gleam publish`, you can switch to: + +```toml +etui = ">= 1.0.0 and < 2.0.0" +``` + +## Full demos (library repo) + +Larger demos stay under [`dev/`](../dev/) and run from the **repository root**: + +```sh +gleam run -m etui_showcase +gleam run -m etui_filebrowser +``` + +## Widget tours (browser) + +Interactive widget reference with ASCII previews and snippets: + +**https://etui.altumdream.com/widgets** + +API reference: **https://hexdocs.pm/etui** + +Guides in markdown: [`docs/`](../docs/) diff --git a/examples/counter/README.md b/examples/counter/README.md new file mode 100644 index 0000000..81db562 --- /dev/null +++ b/examples/counter/README.md @@ -0,0 +1,9 @@ +# counter + +Two-column layout with blocks and a counter. + +```sh +gleam run +``` + +Keys: `space` increment, `q` quit. diff --git a/examples/counter/gleam.toml b/examples/counter/gleam.toml new file mode 100644 index 0000000..db31f97 --- /dev/null +++ b/examples/counter/gleam.toml @@ -0,0 +1,11 @@ +name = "counter" +version = "1.0.0" +description = "Split layout counter — block, paragraph, keys" +licences = ["MIT"] + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +etui = { path = "../.." } + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/examples/counter/src/counter.gleam b/examples/counter/src/counter.gleam new file mode 100644 index 0000000..17b8a2a --- /dev/null +++ b/examples/counter/src/counter.gleam @@ -0,0 +1,53 @@ +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect, Fill, Horizontal, Percentage} +import etui/widgets/block +import etui/widgets/paragraph +import gleam/int + +pub type Model { + Model(count: Int, quit: Bool, width: Int, height: Int) +} + +pub fn main() { + let _ = + app.run_buffered( + default.new(), + Model(count: 0, quit: False, width: 80, height: 24), + view, + update, + fn(m) { m.quit }, + 16, + ) +} + +fn view(model: Model, screen: Rect) -> buffer.Buffer { + let chunks = geometry.split(Horizontal, screen, [Percentage(30), Fill]) + let left = case chunks { [l, ..] -> l _ -> screen } + let right = case chunks { [_, r, ..] -> r _ -> screen } + let para = + paragraph.paragraph_new("Count: " <> int.to_string(model.count) <> " (space +1, q quit)") + let blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title("Main", block.Top) + let sidebar = + block.block_new() + |> block.with_border(block.Single) + |> block.with_title("Side", block.Top) + buffer.buffer_new(screen) + |> block.render(left, sidebar) + |> block.render(right, blk) + |> paragraph.render(block.inner(right, blk), para) +} + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + backend.KeyPress(" ") -> Model(..model, count: model.count + 1) + backend.Resize(w, h) -> Model(..model, width: w, height: h) + _ -> model + } +} diff --git a/examples/minimal/README.md b/examples/minimal/README.md new file mode 100644 index 0000000..40acecc --- /dev/null +++ b/examples/minimal/README.md @@ -0,0 +1,11 @@ +# minimal + +Smallest runnable étui app. + +```sh +gleam run +``` + +Keys: `q` quit. + +Same code as [docs/getting-started.md](../../docs/getting-started.md). diff --git a/examples/minimal/gleam.toml b/examples/minimal/gleam.toml new file mode 100644 index 0000000..e192585 --- /dev/null +++ b/examples/minimal/gleam.toml @@ -0,0 +1,11 @@ +name = "minimal" +version = "1.0.0" +description = "Minimal étui app: hello world, quit with q" +licences = ["MIT"] + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +etui = { path = "../.." } + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/examples/minimal/src/minimal.gleam b/examples/minimal/src/minimal.gleam new file mode 100644 index 0000000..5b53fb8 --- /dev/null +++ b/examples/minimal/src/minimal.gleam @@ -0,0 +1,38 @@ +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect} +import etui/widgets/paragraph + +pub type Model { + Model(quit: Bool, width: Int, height: Int) +} + +pub fn main() { + let _ = + app.run_buffered( + default.new(), + Model(quit: False, width: 80, height: 24), + view, + update, + fn(m) { m.quit }, + 16, + ) +} + +fn view(_model: Model, screen: Rect) -> buffer.Buffer { + buffer.buffer_new(screen) + |> paragraph.render( + screen, + paragraph.paragraph_new("Hello, étui! Press q to quit."), + ) +} + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.Resize(w, h) -> Model(..model, width: w, height: h) + backend.KeyPress("q") -> Model(..model, quit: True) + _ -> model + } +} diff --git a/examples/snippets.md b/examples/snippets.md new file mode 100644 index 0000000..2fbac88 --- /dev/null +++ b/examples/snippets.md @@ -0,0 +1,337 @@ +# étui Code Snippets + +This document contains copy-pasteable code blocks to help you build interfaces with **étui**. + +Since `.gleam` files cannot contain top-level floating variables (a `let` binding must live within a function block), these snippets are grouped as Markdown recipes. You can copy the code directly into your application's `view` or `update` loops. + +--- + +## 1. Splitting Layouts + +Use `geometry.split` to divide an area into multiple smaller areas (chunks). This is the building block of multi-pane terminal user interfaces. + +```gleam +import etui/geometry.{Fill, Horizontal, Length, Percentage, Vertical} + +// Horizontal split: Sidebar (30%) and Main content (remaining space) +let chunks = geometry.split(Horizontal, screen_area, [Percentage(30), Fill]) +let sidebar_area = case chunks { + [l, ..] -> l + _ -> screen_area +} +let main_area = case chunks { + [_, r, ..] -> r + _ -> screen_area +} + +// Vertical split: Header (fixed 3 lines), Body (fill), Footer (fixed 1 line) +let rows = geometry.split(Vertical, main_area, [Length(3), Fill, Length(1)]) +let #(header_area, body_area, footer_area) = case rows { + [h, b, f, ..] -> #(h, b, f) + _ -> #(main_area, main_area, main_area) +} +``` + +--- + +## 2. Text Input Widget (With Cursor Support) + +To build a text input field, use `app.run_buffered_cursor` instead of `run_buffered`. This tells the application loop to track and show the hardware cursor at the correct position. + +### Application State & Model +```gleam +import etui/geometry +import etui/widgets/input + +pub type Model { + Model( + input_state: input.InputState, + quit: Bool, + ) +} +``` + +### Update function +```gleam +import etui/backend + +pub fn update(event: backend.InputEvent, model: Model) -> Model { + let widget = input.input_new("Type here...") + + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + + // Handle typing characters + backend.TextInput(ch) -> { + let next_state = input.insert_char(widget, model.input_state, ch) + Model(..model, input_state: next_state) + } + + // Handle editing keys + backend.KeyPress("Backspace") -> { + let next_state = input.backspace(model.input_state) + Model(..model, input_state: next_state) + } + backend.KeyPress("Left") -> { + let next_state = input.move_cursor_left(model.input_state) + Model(..model, input_state: next_state) + } + backend.KeyPress("Right") -> { + let next_state = input.move_cursor_right(model.input_state) + Model(..model, input_state: next_state) + } + _ -> model + } +} +``` + +### View function (rendering text and placing cursor) +```gleam +import etui/buffer +import etui/geometry + +pub fn view(model: Model, screen: geometry.Rect) -> #(buffer.Buffer, Result(geometry.Position, Nil)) { + let area = geometry.Rect( + position: geometry.Position(x: 2, y: 2), + size: geometry.Size(width: 30, height: 1) + ) + + let widget = input.input_new("Type here...") + |> input.with_prompt("> ") + + let buf = buffer.buffer_new(screen) + |> input.render(area, widget, model.input_state) + + // Place the terminal cursor at the end of the text input + let cursor_pos = geometry.Position( + x: area.position.x + model.input_state.cursor + 2, // Account for prompt length + y: area.position.y, + ) + + #(buf, Ok(cursor_pos)) +} +``` + +--- + +## 3. Stateful Scrollable List + +Use `widgets/list` for vertical lists of selectable options. The list automatically manages internal scroll offsets if items overflow the height of the rendering area. + +### Application State & Model +```gleam +import etui/widgets/list as glist + +pub type Model { + Model( + items: List(String), + list_state: glist.ListState, + quit: Bool, + ) +} +``` + +### Update function +```gleam +import etui/backend +import gleam/list + +pub fn update(event: backend.InputEvent, model: Model) -> Model { + let count = list.length(model.items) + + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + + // Navigate list items + backend.KeyPress("Up") -> { + let next_state = glist.select_prev(model.list_state) + Model(..model, list_state: next_state) + } + backend.KeyPress("Down") -> { + let next_state = glist.select_next(model.list_state, count) + Model(..model, list_state: next_state) + } + _ -> model + } +} +``` + +### View function +```gleam +import etui/buffer +import etui/geometry +import etui/widgets/list as glist + +pub fn view(model: Model, screen: geometry.Rect) -> buffer.Buffer { + let area = geometry.Rect( + position: geometry.Position(x: 2, y: 2), + size: geometry.Size(width: 40, height: 10) + ) + + let widget = glist.list_new(model.items) + + buffer.buffer_new(screen) + |> glist.render_stateful(area, widget, model.list_state) +} +``` + +--- + +## 4. Horizontal Tab Bar + +Tabs let users switch between different sub-views easily. They automatically highlight the selected option and handle horizontal layout dividers. + +### Application State & Model +```gleam +import etui/widgets/tabs + +pub type Model { + Model( + tabs: tabs.Tabs, + quit: Bool, + ) +} +``` + +### Update function +```gleam +import etui/backend + +pub fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + + // Cycle tabs + backend.KeyPress("Tab") -> Model(..model, tabs: tabs.next_tab(model.tabs)) + backend.KeyPress("Right") -> Model(..model, tabs: tabs.next_tab(model.tabs)) + backend.KeyPress("Left") -> Model(..model, tabs: tabs.prev_tab(model.tabs)) + _ -> model + } +} +``` + +### View function +```gleam +import etui/buffer +import etui/geometry +import etui/widgets/tabs + +pub fn view(model: Model, screen: geometry.Rect) -> buffer.Buffer { + let area = geometry.Rect( + position: geometry.Position(x: 0, y: 0), + size: geometry.Size(width: screen.size.width, height: 1) + ) + + buffer.buffer_new(screen) + |> tabs.render(area, model.tabs) +} +``` + +--- + +## 5. Modal Centered Dialog + +Centered dialog boxes are helpful for overlaying confirmation modal flows (like "OK/Cancel" prompts) on top of the existing screen. + +### Application State & Model +```gleam +import etui/widgets/dialog + +pub type Model { + Model( + dialog_state: dialog.DialogState, + show_dialog: Bool, + quit: Bool, + ) +} +``` + +### Update function +```gleam +import etui/backend +import etui/widgets/dialog + +pub fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.KeyPress("q") -> Model(..model, quit: True) + + // Dialog interaction + backend.KeyPress("Tab") -> { + let next_state = dialog.toggle(model.dialog_state) + Model(..model, dialog_state: next_state) + } + backend.KeyPress("Enter") -> { + let is_ok = dialog.is_confirmed(model.dialog_state) + case is_ok { + True -> // User selected OK + Model(..model, show_dialog: False, quit: True) + False -> // User selected Cancel + Model(..model, show_dialog: False) + } + } + backend.KeyPress("Escape") -> { + Model(..model, show_dialog: False) + } + _ -> model + } +} +``` + +### View function +```gleam +import etui/buffer +import etui/geometry +import etui/widgets/dialog + +pub fn view(model: Model, screen: geometry.Rect) -> buffer.Buffer { + let buf = buffer.buffer_new(screen) + + // Render main screen first... + + // Overlay dialog on top if active + case model.show_dialog { + True -> { + let modal = dialog.dialog_new("Are you sure you want to quit?") + |> dialog.with_labels("Yes, Quit", "Stay") + + dialog.render(buf, screen, modal, model.dialog_state) + } + False -> buf + } +} +``` + +--- + +## 6. Panel Border with Title and Text Paragraph + +A classic layout pattern is a structured border (Block) containing styled text inside it. + +```gleam +import etui/buffer +import etui/geometry +import etui/widgets/block +import etui/widgets/paragraph + +pub fn view(_model: Model, screen: geometry.Rect) -> buffer.Buffer { + let panel_area = geometry.Rect( + position: geometry.Position(x: 5, y: 3), + size: geometry.Size(width: 40, height: 10) + ) + + // Define a block with a rounded border and title + let blk = block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title("About étui", block.Top) + |> block.with_bg_fill + + // Define a text paragraph inside the block + let para = paragraph.paragraph_new("étui is a modern, reactive, type-safe terminal user interface library for Gleam terminal applications.") + + buffer.buffer_new(screen) + // 1. Render the block border on the layout area + |> block.render(panel_area, blk) + // 2. Render paragraph in the inner content area (adjusted for border offsets) + |> paragraph.render(block.inner(panel_area, blk), para) +} +``` From 73659044c861a4813273ffff1636594987b6c9ab Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:49:56 +0200 Subject: [PATCH 07/10] Add comprehensive etui documentation Add a new docs/ set for the etui library, introducing user guides and a full widgets reference. --- docs/README.md | 27 ++ docs/animation.md | 143 +++++++++ docs/custom-widgets.md | 172 ++++++++++ docs/focus.md | 101 ++++++ docs/getting-started.md | 142 +++++++++ docs/layout.md | 113 +++++++ docs/styling.md | 114 +++++++ docs/themes.md | 183 +++++++++++ docs/widgets.md | 674 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1669 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/animation.md create mode 100644 docs/custom-widgets.md create mode 100644 docs/focus.md create mode 100644 docs/getting-started.md create mode 100644 docs/layout.md create mode 100644 docs/styling.md create mode 100644 docs/themes.md create mode 100644 docs/widgets.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c6dce1b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# étui documentation + +Guides for using the library. API details also live in `///` doc comments on each public function (HexDocs after publish). + +| Guide | Contents | +| --- | --- | +| [Getting started](getting-started.md) | Dependency, minimal app, app loop, crash-restore | +| [Layout](layout.md) | `geometry.split`, constraints, spacing | +| [Styling](styling.md) | Colors, modifiers, spans | +| [Themes](themes.md) | Built-in palettes and runtime switching | +| [Widgets](widgets.md) | Full widget reference | +| [Custom widgets](custom-widgets.md) | Composition, stateful/animated helpers | +| [Animation](animation.md) | `run_animated`, `anim` helpers | +| [Focus](focus.md) | `FocusRing` for multi-panel UIs | + +## Targets + +| Target | Backend | App loop | +| --- | --- | --- | +| Erlang (default) | `etui/backend/erlang` via `default.new()` | `app.run_buffered` → `AppResult` | +| JavaScript (Node) | `etui/backend/node` via `default.new()` | `app.run_buffered` → `Promise(AppResult)` | + +Use `gleam run --target erlang` for terminal apps and `gleam run --target javascript` for the JS smoke path. + +## Examples in this repo + +Runnable demos live under `dev/` (not shipped on Hex): `etui_showcase`, `etui_filebrowser`, `etui_interactive`, etc. diff --git a/docs/animation.md b/docs/animation.md new file mode 100644 index 0000000..b53ac63 --- /dev/null +++ b/docs/animation.md @@ -0,0 +1,143 @@ +# Animation + +## AnimState + +Frame counter. Advance once per render tick. + +```gleam +import etui/anim + +let state = anim.anim_new() // frame = 0 +let state = anim.tick(state) // frame + 1 +let state = anim.reset(state) // frame = 0 + +anim.is_done(state, 60) // True when frame >= 60 +``` + +Integrate into your app model: + +```gleam +pub type Model { Model(anim: anim.AnimState, ...) } + +fn update(event, model) { + case event { + backend.Tick -> Model(..model, anim: anim.tick(model.anim)) + _ -> model + } +} +``` + +## Interpolation + +All interpolation uses integer math. Results are identical on every BEAM target. + +```gleam +// Linear: from 0 to 100 over 60 frames +anim.lerp(0, 100, model.anim.frame, 60) + +// EaseOut: fast start, slow end +anim.ease_out(0, 100, model.anim.frame, 60) + +// EaseIn: slow start, fast end +anim.ease_in(0, 100, model.anim.frame, 60) +``` + +All clamp `frame` to `[0, duration]`. At `frame == duration`, returns `end_`. + +## AnimatedWidget + +`widget.AnimatedWidget = fn(Buffer, Rect, Int) -> Buffer` + +The frame integer drives all animation logic inside the widget. The widget stays pure, with no mutable state. + +```gleam +import etui/widget +import etui/widgets/spinner + +// Spinner is an AnimatedWidget +let spin_w: widget.AnimatedWidget = fn(buf, area, frame) { + spinner.render(buf, area, spinner.spinner_new() |> spinner.with_style(spinner.Dots), frame) +} + +// Bind current frame at render time +let w: widget.Widget = widget.freeze_frame(spin_w, model.anim.frame) +w(buf, area) +``` + +## Color animation + +```gleam +import etui/color + +// Lerp between two Rgb colors over 60 frames +let c = color.lerp_rgb( + style.Rgb(255, 0, 0), // start: red + style.Rgb(0, 0, 255), // end: blue + model.anim.frame, + 60, +) +let s = style.Style(fg: c, bg: style.Default, modifier: style.none()) +``` + +`color.lerp_rgb` interpolates R, G, B channels independently using integer math. + +## Spinner built-in frames + +```gleam +spinner.Dots // ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ +spinner.Braille // braille rotation +spinner.Arc // ◜ ◠ ◝ ◞ ◡ ◟ +spinner.Line // – \ | / +spinner.Bounce // ⠁ ⠂ ⠄ ⠂ +``` + +Advance frame each tick; spinner wraps automatically. + +## Marquee (scrolling text) + +```gleam +import etui/widgets/marquee + +let m = + marquee.marquee_new(" scrolling content ") + |> marquee.with_speed(1) // cells advanced per frame + +// frame drives the scroll offset (cell-accurate for wide chars) +marquee.render(buf, area, m, model.anim.frame) +``` + +## Full animation example + +```gleam +import etui/backend + +pub type Model { + Model(anim: anim.AnimState, width: Int, height: Int) +} + +fn view(model: Model, screen: geometry.Rect) -> buffer.Buffer { + let frame = model.anim.frame + + // Pulse color: 0→255→0 over 120 frames + let v = anim.ease_out(0, 255, frame % 120, 60) + let color = case frame % 120 < 60 { + True -> style.Rgb(v, 0, 0) + False -> style.Rgb(255 - v, 0, 0) + } + + let para = + paragraph.paragraph_new("etui") + |> paragraph.with_style(style.Style(fg: color, bg: style.Default, modifier: style.bold())) + + buffer.buffer_new(screen) + |> paragraph.render(screen, para) +} + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.Tick -> Model(..model, anim: anim.tick(model.anim)) + backend.Resize(w, h) -> Model(..model, width: w, height: h) + _ -> model + } +} +``` diff --git a/docs/custom-widgets.md b/docs/custom-widgets.md new file mode 100644 index 0000000..9438ecc --- /dev/null +++ b/docs/custom-widgets.md @@ -0,0 +1,172 @@ +# Custom Widgets + +## Widget type + +A widget is any function with signature `fn(Buffer, Rect) -> Buffer`. No registration, no traits. + +```gleam +import etui/buffer +import etui/geometry +import etui/widget +import etui/widgets/paragraph + +// This function is already a widget, no wrapping needed +fn clock_widget(buf: buffer.Buffer, area: geometry.Rect) -> buffer.Buffer { + paragraph.render(buf, area, paragraph.paragraph_new(get_current_time())) +} + +// Use it anywhere a Widget is expected +widget.layer(background_w, clock_widget)(buf, screen) +``` + +`widget.Widget` is a type alias: `fn(buffer.Buffer, geometry.Rect) -> buffer.Buffer`. + +## Stateful widgets + +State lives in your app model. The widget receives it at render time. + +```gleam +import etui/widget + +pub type CounterState { CounterState(count: Int) } + +let counter_w = widget.StatefulWidget(render: fn(buf, area, state: CounterState) { + let text = "Count: " <> int.to_string(state.count) + paragraph.render(buf, area, paragraph.paragraph_new(text)) +}) + +// Render with state from model +widget.render_stateful(buf, area, counter_w, my_state) + +// Or bake state in (makes it stateless) +let frozen: widget.Widget = widget.freeze(counter_w, CounterState(42)) +frozen(buf, area) +``` + +## Animated widgets + +`AnimatedWidget = fn(Buffer, Rect, Int) -> Buffer`. The third argument is the frame number. + +```gleam +let pulse_w: widget.AnimatedWidget = fn(buf, area, frame) { + let bright = frame % 30 < 15 + let color = case bright { True -> style.Rgb(255, 255, 0) False -> style.Rgb(128, 128, 0) } + let s = style.Style(fg: color, bg: style.Default, modifier: style.none()) + paragraph.render(buf, area, paragraph.paragraph_new("●") |> paragraph.with_style(s)) +} + +// Bind frame at render time +let w: widget.Widget = widget.freeze_frame(pulse_w, anim_state.frame) +w(buf, area) +``` + +## Composition helpers + +### layer + +Draw two widgets in the same area. `bottom` first, then `top` on top. + +```gleam +let w = widget.layer(background_w, overlay_w) +``` + +### stack + +Draw a list of widgets in the same area, in order. + +```gleam +let w = widget.stack([bg_w, content_w, cursor_w, highlight_w]) +``` + +### at + +Pin a widget to a fixed sub-area. Ignores the caller-supplied area. + +```gleam +let sub = geometry.Rect(position: Position(x: 4, y: 1), size: Size(width: 20, height: 1)) +let w = widget.at(label_w, sub) +// w(buf, any_area) always renders into sub +``` + +### compose + +Border fills `area`, content fills `inner_area`. The common "block + child" pattern. + +```gleam +let blk = block.block_new() |> block.with_border(block.Single) +let inner = block.inner(area, blk) + +let composed = widget.compose( + fn(buf, a) { block.render(buf, a, blk) }, + inner, + fn(buf, a) { paragraph.render(buf, a, para) }, +) +composed(buf, area) +``` + +### empty + +No-op widget. Renders nothing. + +```gleam +let placeholder: widget.Widget = widget.empty() +``` + +## Real example: progress panel + +```gleam +fn progress_panel(model: Model) -> widget.Widget { + let blk = block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title("Progress", block.Top) + + fn(buf, area) { + let inner = block.inner(area, blk) + let gauge_area = geometry.Rect( + position: inner.position, + size: geometry.Size(width: inner.size.width, height: 1), + ) + let g = gauge.gauge_new(model.percent) + |> gauge.with_label(int.to_string(model.percent) <> "%") + buf + |> block.render(area, blk) + |> gauge.render(gauge_area, g) + } +} +``` + +The function closes over `model` directly. Read-only data does not need a state wrapper. + +## Building from buffer primitives + +```gleam +import etui/buffer +import etui/geometry +import etui/style + +fn custom_render(buf: buffer.Buffer, area: geometry.Rect) -> buffer.Buffer { + let pos = area.position + buffer.set_string(buf, pos, "custom", style.Default, style.Default, style.none()) +} +``` + +`buffer.set_string` writes a string left-to-right starting at `pos`, respecting cell widths for wide characters. Wide chars leave a `Continuation` cell automatically. + +## Testing custom widgets + +```gleam +import gleeunit/should +import etui/buffer +import etui/geometry.{Position, Rect, Size} + +fn read_row(buf, y, x, n) { ... } // see test/widget_extensibility_test.gleam + +pub fn my_widget_test() { + let area = Rect(position: Position(x: 0, y: 0), size: Size(width: 10, height: 1)) + let buf = buffer.buffer_new(area) + let result = my_widget(buf, area) + read_row(result, 0, 0, 5) |> should.equal("hello") +} +``` + +No terminal process needed. All tests run headless. diff --git a/docs/focus.md b/docs/focus.md new file mode 100644 index 0000000..c48f433 --- /dev/null +++ b/docs/focus.md @@ -0,0 +1,101 @@ +# Focus Management + +`etui/focus` gives you a `FocusRing`: an ordered set of named widget slots +where exactly one slot is active at a time. + +## Basic usage + +```gleam +import etui/focus + +// Define slots in tab order +let ring = focus.focus_new(["sidebar", "editor", "statusbar"]) + +// In update: +let ring = case event { + KeyPress("tab") -> focus.focus_next(ring) + KeyPress("backtab") -> focus.focus_prev(ring) + _ -> ring +} + +// In render, route events only to the focused slot: +let in_sidebar = focus.is_focused(ring, "sidebar") +let in_editor = focus.is_focused(ring, "editor") +``` + +## API + +```gleam +// Constructors +focus.focus_new(ids: List(String)) -> FocusRing // first slot starts focused + +// Queries +focus.focused(ring) // Result(String, Nil), current slot ID +focus.is_focused(ring, "id") // Bool +focus.current_index(ring) // Int, 0-based +focus.size(ring) // Int, slot count + +// Navigation +focus.focus_next(ring) // advance (wraps) +focus.focus_prev(ring) // retreat (wraps) +focus.focus_id(ring, "editor") // jump to a specific slot +focus.focus_index(ring, 2) // jump to an index (clamped) +``` + +## Pattern: conditional border style + +```gleam +let border_style = fn(id) { + case focus.is_focused(ring, id) { + True -> block.with_style(style.Rgb(100, 200, 255), style.Default) + False -> block.with_style(style.Default, style.Default) + } +} + +let sidebar_block = + block.block_new() + |> block.with_border(block.Single) + |> border_style("sidebar") +``` + +## Pattern: full multi-panel app + +```gleam +import etui/keys + +type Model { + Model(ring: FocusRing, list_state: ListState, input_state: InputState, item_count: Int) +} + +// input_widget is a module-level constant or a value from your model +const input_widget = input.input_new("") + +fn update(event: backend.InputEvent, m: Model) -> Model { + case event { + backend.KeyPress("tab") -> Model(..m, ring: focus.focus_next(m.ring)) + _ -> + case focus.focused(m.ring) { + Ok("list") -> + case event { + backend.KeyPress("j") -> Model(..m, list_state: list.select_next(m.list_state, m.item_count)) + backend.KeyPress("k") -> Model(..m, list_state: list.select_prev(m.list_state)) + _ -> m + } + Ok("input") -> + case event { + backend.KeyPress("backspace") -> Model(..m, input_state: input.backspace(m.input_state)) + backend.KeyPress(k) -> + case keys.match(k) { + keys.Char(c) -> Model(..m, input_state: input.insert_char(input_widget, m.input_state, c)) + _ -> m + } + _ -> m + } + _ -> m + } + } +} +``` + +Focus wraps around at both ends. An empty ring is inert: every query returns +`Error(Nil)` or `False`. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..365fb12 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,142 @@ +# Getting Started + +## Add dependency + +```toml +# gleam.toml +[dependencies] +etui = ">= 1.0.0 and < 2.0.0" +``` + +## Minimal app + +```gleam +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect} +import etui/widgets/paragraph + +pub type Model { + Model(quit: Bool, width: Int, height: Int) +} + +pub fn main() { + let _ = + app.run_buffered( + default.new(), + Model(quit: False, width: 80, height: 24), + view, + update, + fn(m) { m.quit }, + 16, // poll every 16ms (~60fps) + ) +} + +fn view(_model: Model, screen: Rect) -> buffer.Buffer { + buffer.buffer_new(screen) + |> paragraph.render(screen, paragraph.paragraph_new("Hello, etui! Press q to quit.")) +} + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.Resize(w, h) -> Model(..model, width: w, height: h) + backend.KeyPress("q") -> Model(..model, quit: True) + _ -> model + } +} +``` + +## App loop API + +```gleam +app.run_buffered( + backend, // default.new() + initial_model, + view_fn, // fn(model, Rect) -> Buffer + update_fn, // fn(InputEvent, model) -> model + quit_fn, // fn(model) -> Bool, return True to exit + poll_ms, // event poll interval in milliseconds +) +``` + +### InputEvent + +```gleam +backend.KeyPress(key) // key string: "a", "A", " ", "\r", etc. +backend.Resize(w, h) // terminal was resized +backend.Tick // emitted each poll interval (no input) +backend.MousePress(x, y, button) // optional: use default.new_with_mouse() +backend.MouseRelease(x, y, button) +backend.MouseScroll(x, y, up) +``` + +### App loop variants + +| Function | Use when | +| --- | --- | +| `run_buffered` | Default: you return a `Buffer`, diffing is automatic | +| `run_buffered_cursor` | Text fields: also return cursor `Position` | +| `run_animated` | Spinners / marquees: receives `AnimState` each frame | +| `run` | Low-level: you emit `List(RenderOp)` yourself | + +On the **JavaScript** target (Node), these return `Promise(AppResult(_))` instead of `AppResult`. + +### Keyboard handling with `keys.match` + +`keys.match` parses a raw key string into a typed `Key`. It avoids typos and +handles arrow, function, and Ctrl/Alt keys uniformly. Given a model with a +`selected` index and a `quit` flag: + +```gleam +import etui/keys + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.KeyPress(k) -> + case keys.match(k) { + keys.Up -> Model(..model, selected: model.selected - 1) + keys.Down -> Model(..model, selected: model.selected + 1) + keys.Char("q") -> Model(..model, quit: True) + keys.Ctrl("c") -> Model(..model, quit: True) + _ -> model + } + _ -> model + } +} +``` + +## Crash-restore guarantee + +`app.run` wraps the event loop in Erlang `try...after` via FFI. If `view_fn` or `update_fn` raises, the terminal is restored (raw mode off, alt screen exit) before the exception propagates. + +On the Erlang target, `gleam run` inherits the BEAM default for `Ctrl+C`: by default it opens the BREAK handler instead of terminating the process immediately. Etui restores the terminal on normal exits, exceptions, and supported abort paths, but if you want `Ctrl+C` to terminate the session directly, start the runtime with `ERL_AFLAGS="+Bd"`, for example: + +```sh +ERL_AFLAGS="+Bd" gleam run -m your_module +``` + +## Manual drive (no app loop) + +```gleam +import etui/backend +import etui/backend/erlang +import gleam/list + +let b = erlang.new() +case backend.init(b) { + Error(_) -> Nil + Ok(state) -> { + let ops = + list.append(backend.clear_and_home(), [backend.Write("Hello")]) + let assert Ok(state) = backend.render(b, state, ops) + let assert Ok(#(_event, state)) = backend.poll(b, state, 16) + backend.cleanup(b, state) + } +} +``` + +`backend.init` enters raw mode and alt screen. `backend.cleanup` restores the terminal. + +For new apps, prefer `etui/backend/default` and `app.run_buffered` instead of hand-rolling the loop. diff --git a/docs/layout.md b/docs/layout.md new file mode 100644 index 0000000..27243a2 --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,113 @@ +# Layout + +Layout is pure math in `etui/geometry`. No rendering, no dependencies on the rest of etui. + +## Rect + +```gleam +import etui/geometry.{Position, Rect, Size} + +let area = geometry.rect_new(0, 0, 80, 24) +// or +let area = Rect(position: Position(x: 0, y: 0), size: Size(width: 80, height: 24)) +``` + +Rects don't overlap by default. The layout system produces non-overlapping children from a parent. + +## Constraints + +```gleam +import etui/geometry.{Fill, Length, Max, Min, Percentage, Ratio} + +Length(20) // exactly 20 cells, highest priority, allocated first +Min(10) // at least 10 cells, joins the flexible pool +Max(40) // at most 40 cells, joins the flexible pool +Percentage(30) // 30% of total, cumulative, no jitter +Ratio(1, 3) // one third of total, exact integer arithmetic +Fill // remaining space after all other constraints +``` + +### Allocation order (highest to lowest priority) + +1. **`Length`** is exact and clamped in order. It never gives up space. +2. **`Percentage`** is cumulative: `floor(total * cumsum_pct / 100)`. It scales down proportionally when the percentages sum past 100%. +3. **`Ratio(a, b)`** wants `total * a / b` cells, taken from the budget left after Percentage. It also scales down on overflow. +4. **`Fill`, `Min`, `Max`** split whatever remains. `Min(n)` sets a floor, `Max(n)` sets a ceiling, `Fill` takes an equal share of the rest. + +When `Max` caps a slot below its equal share, the surplus goes to the `Fill` slots. The flexible pass runs in two sub-passes so `Fill` always consumes the full remaining budget. + +## Split + +```gleam +import etui/geometry.{Horizontal, Vertical} + +// Split horizontally into three columns +let cols = geometry.split(Horizontal, area, [Length(20), Percentage(50), Fill]) +// => List(Rect) with non-overlapping positioned Rects + +// Split vertically into a header and content area +let rows = geometry.split(Vertical, area, [Length(3), Fill]) + +let header = case rows { [h, ..] -> h _ -> area } +let content = case rows { [_, c, ..] -> c _ -> area } +``` + +`split` always returns exactly as many `Rect`s as constraints given. + +## resolve_sizes + +```gleam +geometry.resolve_sizes(100, [Length(20), Percentage(30), Fill]) +// => [20, 30, 50] + +// Percentage overflow scales down +geometry.resolve_sizes(100, [Percentage(60), Percentage(60)]) +// => [50, 50] + +// Ratio: exact fraction +geometry.resolve_sizes(90, [Ratio(1, 3), Fill]) +// => [30, 60] + +// Min: at least n +geometry.resolve_sizes(100, [Length(80), Min(30)]) +// => [80, 30] (Min gets its floor even beyond budget) + +// Max: at most n +geometry.resolve_sizes(100, [Max(30), Fill]) +// => [30, 50] (Max capped, Fill gets its base share) +``` + +Returns integer sizes (cell counts), not `Rect`s. Useful when you need sizes without positions. + +## No-jitter guarantee + +Sizes are computed from cumulative boundaries, not individual widths. Resizing one column doesn't shift others due to floating-point rounding. + +## Spacing between splits + +```gleam +// 1-cell gap between each column +geometry.split_with_spacing(Horizontal, area, [Fill, Fill, Fill], 1) + +// 2-cell gap between rows +geometry.split_with_spacing(Vertical, area, [Length(3), Fill], 2) +``` + +Gap cells are subtracted from the total before constraints are applied. With spacing, the positioned rects skip the gap columns/rows. + +## Rect helpers + +```gleam +geometry.intersect(a, b) // overlap Rect (Error(Nil) if no intersection) +geometry.union(a, b) // bounding Rect +geometry.contains(r, pos) // True if Position is inside Rect +``` + +## Nested layouts + +```gleam +let [header, body] = geometry.split(Vertical, screen, [Length(1), Fill]) +let [sidebar, main] = geometry.split(Horizontal, body, [Length(20), Fill]) +``` + +Compose splits freely: each call takes a `Rect` and returns child `Rect`s at absolute positions. diff --git a/docs/styling.md b/docs/styling.md new file mode 100644 index 0000000..b7e1cb1 --- /dev/null +++ b/docs/styling.md @@ -0,0 +1,114 @@ +# Styling + +## Colors + +```gleam +import etui/style + +style.Default // inherits terminal default +style.Indexed(0) // ANSI color 0 (black), 16-color palette +style.Indexed(200) // 256-color extended palette (16–255) +style.Rgb(255, 128, 0) // 24-bit true color +``` + +### 16-color palette (Indexed 0–15) + +| Index | Name | Index | Name | +| --- | --- | --- | --- | +| 0 | Black | 8 | Bright Black | +| 1 | Red | 9 | Bright Red | +| 2 | Green | 10 | Bright Green | +| 3 | Yellow | 11 | Bright Yellow | +| 4 | Blue | 12 | Bright Blue | +| 5 | Magenta | 13 | Bright Magenta | +| 6 | Cyan | 14 | Bright Cyan | +| 7 | White | 15 | Bright White | + +### 256-color extended palette (Indexed 16–255) + +- 16–231: 6×6×6 color cube +- 232–255: grayscale ramp (dark to light) + +## Modifiers + +```gleam +style.bold() +style.italic() +style.underline() +style.reverse() // swap fg/bg +style.dim() +style.blink() +style.strikethrough() + +// Combine +let m = style.add(style.bold(), style.underline()) + +// Remove +let m2 = style.remove(m, style.underline()) + +// Check +style.has(m, style.bold()) // True +``` + +## Style record + +```gleam +let s = style.Style( + fg: style.Rgb(255, 255, 255), + bg: style.Indexed(4), + modifier: style.add(style.bold(), style.italic()), +) +``` + +Most widgets accept style via `with_style(s)`. + +## ANSI output + +```gleam +style.ansi_fg(style.Rgb(255, 0, 0)) // "\e[38;2;255;0;0m" +style.ansi_fg(style.Indexed(1)) // "\e[31m" +style.ansi_fg(style.Indexed(200)) // "\e[38;5;200m" +style.ansi_bg(style.Indexed(4)) // "\e[44m" +style.ansi_reset() // "\e[0m" +``` + +## Styled spans + +For mixed-style inline text, use `etui/span`: + +```gleam +import etui/span + +// Plain text span +let s1 = span.span_plain("normal text") + +// Styled span +let s2 = span.span_styled("bold red", style.Style( + fg: style.Indexed(1), + bg: style.Default, + modifier: style.bold(), +)) + +// Assemble a line +let line = span.line_new([s1, s2]) + +// Measure total cell width +span.line_width(line) + +// Render to buffer at position +span.render_line(buf, pos, line, max_width) +``` + +`span.Line` is used by `statusbar`, `list` (item spans), and any widget that needs mixed-style text on one row. + +## Block styling + +```gleam +import etui/widgets/block + +block.block_new() +|> block.with_style(style.Indexed(7), style.Indexed(0)) // fg, bg +|> block.with_bg_fill // fill inner area with bg color +``` + +`with_bg_fill` paints every cell inside the border with the block's background color. Useful for popup backgrounds and highlighted panels. diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 0000000..0be0dfe --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,183 @@ +# Themes + +`etui/theme` provides a semantic color palette system. One import and one function call swap the entire UI palette. + +## Built-in themes + +| Theme | Style | Colors | +| --- | --- | --- | +| `dark()` | Generic dark | ANSI 16-color (max compatibility) | +| `light()` | Generic light | ANSI 16-color (max compatibility) | +| `dracula()` | Purple dark | RGB | +| `nord()` | Arctic dark | RGB | +| `catppuccin_mocha()` | Pastel dark | RGB | +| `catppuccin_latte()` | Pastel light | RGB | +| `monokai()` | Vibrant dark | RGB | +| `gruvbox_dark()` | Retro groove dark | RGB | +| `tokyo_night()` | Cool blue dark | RGB | +| `solarized_dark()` | Precision dark | RGB | + +ANSI themes (`dark`, `light`) work on every terminal including those without true-color support. RGB themes require a truecolor terminal (most modern terminals support this). + +## Usage + +```gleam +import etui/theme +import etui/widgets/block +import etui/widgets/list as glist + +let t = theme.dracula() + +// Use colors directly on widgets +let blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_style(t.border, t.bg) + |> block.with_title("Panel", block.Top) + +// Use style helpers +let l = + glist.list_new(["item 1", "item 2"]) + |> glist.with_highlight_style(theme.selection(t)) +``` + +## Color slots + +```gleam +t.bg // main background +t.fg // main foreground +t.border // border lines +t.title // border titles +t.selection_bg // selected item background +t.selection_fg // selected item foreground +t.accent // primary accent (links, highlights) +t.muted // subdued/secondary text +t.error // error messages +t.warning // warnings +t.success // success messages +t.info // informational messages +t.statusbar_bg // status bar background +t.statusbar_fg // status bar foreground +``` + +## Style helpers + +Pre-built `style.Style` values from a theme: + +```gleam +theme.normal(t) // fg on bg +theme.selection(t) // selection_fg on selection_bg +theme.accent_style(t) // accent on bg +theme.border_style(t) // border on bg +theme.title_style(t) // title on bg +theme.muted_style(t) // muted on bg +theme.error_style(t) // error on bg, bold +theme.warning_style(t) // warning on bg +theme.success_style(t) // success on bg +theme.info_style(t) // info on bg +theme.statusbar_style(t) // statusbar_fg on statusbar_bg +``` + +## Custom themes + +Option 1: full definition. + +```gleam +let my_theme = theme.Theme( + bg: style.Rgb(30, 30, 46), + fg: style.Rgb(205, 214, 244), + border: style.Rgb(137, 180, 250), + title: style.Rgb(166, 227, 161), + selection_bg: style.Rgb(69, 71, 90), + selection_fg: style.Rgb(205, 214, 244), + accent: style.Rgb(137, 180, 250), + muted: style.Rgb(108, 112, 134), + error: style.Rgb(243, 139, 168), + warning: style.Rgb(249, 226, 175), + success: style.Rgb(166, 227, 161), + info: style.Rgb(137, 220, 235), + statusbar_bg: style.Rgb(24, 24, 37), + statusbar_fg: style.Rgb(205, 214, 244), +) +``` + +Option 2: derive from a built-in and override specific slots. + +```gleam +// Gleam record update syntax +let my_theme = theme.Theme(..theme.nord(), accent: style.Rgb(255, 165, 0)) + +// Or use helpers +let my_theme = + theme.nord() + |> theme.with_accent(style.Rgb(255, 165, 0)) + |> theme.with_statusbar(style.Rgb(0, 0, 0), style.Rgb(255, 255, 255)) + |> theme.with_selection(style.Rgb(60, 80, 120), style.Rgb(240, 240, 240)) +``` + +## RGB colors + +`style.Rgb(r, g, b)` uses 24-bit true color. Emits `\e[38;2;r;g;bm` (fg) or `\e[48;2;r;g;bm` (bg). + +```gleam +style.Rgb(255, 128, 0) // orange +style.Rgb(0, 0, 0) // black +style.Rgb(255, 255, 255) // white +``` + +Requires a truecolor-capable terminal. When in doubt, use `dark()` or `light()` (ANSI Indexed colors) for maximum compatibility. + +## Full app example + +```gleam +import etui/theme +import etui/style +import etui/widgets/block +import etui/widgets/paragraph +import etui/widgets/statusbar +import etui/span + +pub type Model { + Model(theme: theme.Theme, ...) +} + +fn view(model: Model, screen: geometry.Rect) -> buffer.Buffer { + let t = model.theme + + let header = + statusbar.statusbar_new() + |> statusbar.with_left([span.line_plain("myapp")]) + |> statusbar.with_right([span.line_plain("q: quit")]) + |> statusbar.with_style(t.statusbar_fg, t.statusbar_bg) + + let content_block = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_style(t.border, t.bg) + |> block.with_bg_fill + + let para = + paragraph.paragraph_new("Hello!") + |> paragraph.with_style(theme.normal(t)) + + let #(header_area, body_area) = case geometry.split_v(screen, [Length(1), Fill]) { + [h, b, ..] -> #(h, b) + _ -> #(screen, screen) + } + + buffer.buffer_new(screen) + |> statusbar.render(header_area, header) + |> block.render(body_area, content_block) + |> paragraph.render(block.inner(body_area, content_block), para) +} + +fn update(event, model) { + case event { + // Switch theme at runtime + KeyPress("d") -> Model(..model, theme: theme.dracula()) + KeyPress("n") -> Model(..model, theme: theme.nord()) + KeyPress("g") -> Model(..model, theme: theme.gruvbox_dark()) + _ -> model + } +} +``` diff --git a/docs/widgets.md b/docs/widgets.md new file mode 100644 index 0000000..a45a58c --- /dev/null +++ b/docs/widgets.md @@ -0,0 +1,674 @@ +# Widgets Reference + +All widgets are pure render functions. Typical signature: `render(buf, area, widget) -> Buffer`. +Stateful widgets use `render_stateful(buf, area, widget, state) -> Buffer`. + +--- + +## Block + +Draws a border, optional title, and optional padding. The foundation for most compound widgets. + +```gleam +import etui/widgets/block + +let blk = + block.block_new() + |> block.with_border(block.Single) // ┌─┐│└─┘ + |> block.with_border(block.Double) // ╔═╗║╚═╝ + |> block.with_border(block.Rounded) // ╭─╮│╰─╯ + |> block.with_title("Title", block.Top) // top or block.Bottom + |> block.with_padding(1, 1, 2, 2) // top, bottom, left, right + |> block.with_style(fg, bg) + |> block.with_bg_fill // fill inner area with bg color + +block.render(buf, area, blk) + +// Get inner content area (excluding border + padding) +let inner = block.inner(area, blk) +``` + +--- + +## Paragraph + +Word-wrapping text with alignment. + +```gleam +import etui/widgets/paragraph + +let para = + paragraph.paragraph_new("Text that wraps at area width") + |> paragraph.with_alignment(text.Left) // Left, Center, Right + |> paragraph.with_style(style.Style(...)) + +paragraph.render(buf, area, para) +``` + +CJK text wraps on character boundaries. Explicit `\n` forces a new line. + +--- + +## List + +Scrollable, selectable item list. State keeps selected index and scroll offset. + +```gleam +import etui/widgets/list as glist + +let items = ["item 1", "item 2", "item 3"] +let l = + glist.list_new(items) + |> glist.with_highlight_style(style.Style(...)) + +let state = glist.state_new() +// Navigate +let state = glist.select_next(state, list.length(items)) +let state = glist.select_prev(state) + +// Render +let buf = glist.render_stateful(buf, area, l, state) + +// Read selection +state.selected // Int, 0-based index +``` + +Each row is `width` cells wide exactly. Selection prefix `▶` (plus a space) is included in width budget. + +--- + +## Table + +Grid with optional header and row selection. + +```gleam +import etui/widgets/table + +let t = + table.table_new([ + ["Name", "Age"], // header row (if show_header: True) + ["Alice", "30"], + ["Bob", "25"], + ]) + |> table.with_col_widths([12, 5]) + |> table.with_header(True) + +let state = table.state_new() +table.render_stateful(buf, area, t, state) +``` + +Columns separated by `│`. Each column padded/truncated to its width. + +--- + +## Tabs + +Horizontal tab bar. + +```gleam +import etui/widgets/tabs + +let t = + tabs.tabs_new(["Files", "Git", "Logs"]) + |> tabs.with_active(1) // 0-based index + |> tabs.with_divider(" | ") + +tabs.render(buf, area, t) +``` + +Active tab renders with reverse+bold by default. Customise with `with_active_style`. + +--- + +## Gauge + +Horizontal progress bar. + +```gleam +import etui/widgets/gauge + +let g = + gauge.gauge_new(60) // 0–100 + |> gauge.with_label("60%") // centered overlay + |> gauge.with_chars("█", "░") // filled, empty chars + +gauge.render(buf, area, g) +``` + +--- + +## LineGauge + +Thin single-row progress indicator using Unicode line characters. Lighter than `Gauge`. + +```gleam +import etui/widgets/line_gauge + +let g = + line_gauge.line_gauge_new(75) // 0–100 + |> line_gauge.with_label("75%") // centered overlay + |> line_gauge.with_line_set(line_gauge.ThinLine) // ThinLine, ThickLine, DoubleLine, BrailleLine, AsciiLine + |> line_gauge.with_colors(style.Indexed(2), style.Default) + +line_gauge.render(buf, area, g) +``` + +--- + +## HBar + +Horizontal bar chart. + +```gleam +import etui/widgets/hbar + +hbar.render(buf, area, hbar.hbar_new([ + hbar.Bar("Rust", 90), + hbar.Bar("Gleam", 75), + hbar.Bar("Python", 60), +])) +``` + +--- + +## Sparkline + +Inline data trend (single row). + +```gleam +import etui/widgets/sparkline + +sparkline.render(buf, area, sparkline.sparkline_new([1, 4, 2, 8, 5, 7, 3])) +``` + +Uses braille dots by default. Values auto-scaled to area height. + +--- + +## Canvas (Braille) + +2×4 braille dot canvas. Each terminal cell = 2×4 pixel grid. + +```gleam +import etui/widgets/canvas +import etui/braille + +let c = canvas.canvas_new(area.size.width * 2, area.size.height * 4) +let c = canvas.set_pixel(c, 10, 5, True) +let c = canvas.line(c, 0, 0, 40, 20, True) + +canvas.render(buf, area, c) +``` + +Braille block: U+2800–U+28FF. Pixel (col, row) maps to cell (col/2, row/4). + +--- + +## Input + +Single-line text input with wide-char aware cursor. + +```gleam +import etui/widgets/input as ginput + +let w = ginput.input_new("placeholder") |> ginput.with_max_length(80) +let state = ginput.state_new() + +// Keyboard handling with keys.match instead of raw strings +import etui/keys + +let state = case event { + backend.KeyPress(k) -> case keys.match(k) { + keys.Enter -> state // submit + keys.Backspace -> ginput.backspace(state) + keys.Left -> ginput.move_cursor_left(state) + keys.Right -> ginput.move_cursor_right(state) + keys.Char(c) -> ginput.insert_char(w, state, c) + _ -> state + } + _ -> state +} + +ginput.render(buf, area, w, state) + +// Read value +state.value // String +state.cursor // Int, cell position (not grapheme index) +``` + +### Extra cursor ops + +```gleam +ginput.move_to_start(state) +ginput.move_to_end(state) +ginput.delete_to_end(state) +``` + +### Prompt and password mode + +```gleam +let w = + ginput.input_new("username") + |> ginput.with_prompt("> ") // prefix shown before the value + +let pw = + ginput.input_new("password") + |> ginput.with_password(True) // mask each cell of the value + |> ginput.with_mask("•") // default mask is "*" +``` + +--- + +## Scrollbar + +Scroll indicator (vertical or horizontal). + +```gleam +import etui/widgets/scrollbar + +// total = item count, visible = viewport height/width, offset = first visible index +let sb = scrollbar.scrollbar_new(total, visible, offset) + |> scrollbar.with_chars("░", "█") // track, thumb + |> scrollbar.with_arrows("▲", "▼") // pass "" to hide + +scrollbar.render_vertical(buf, area, sb) // vertical (right-side column) +scrollbar.render_horizontal(buf, area, sb) // horizontal (bottom row) +``` + +Derive `offset` from the widget that owns the scroll: + +```gleam +// List +let offset = glist.effective_offset(list_state, area.size.height) +// Table (subtract 1 when show_header is True) +let offset = table.effective_offset(table_state, area.size.height - 1) +// Tree +let total = tree.visible_row_count(tree_state, t) +let offset = tree.effective_offset(tree_state, t, area.size.height) +// TextArea +let offset = ta.effective_offset(editor_state, area.size.height) +``` + +--- + +## Spinner + +Animated loading indicator. 14 built-in presets. + +```gleam +import etui/widgets/spinner + +let s = + spinner.spinner_new() + |> spinner.with_style(spinner.Dots) + |> spinner.with_label("loading...") + +spinner.render(buf, area, s, frame) // frame from AnimState +``` + +Available styles: `Dots`, `Line`, `Circle`, `Bounce`, `MiniDot`, `Jump`, +`Pulse`, `Points`, `Globe`, `Moon`, `Monkey`, `Meter`, `Hamburger`, `Ellipsis`, +and `Custom(frames)` for your own frame list. + +--- + +## Marquee + +Scrolling text ticker. + +```gleam +import etui/widgets/marquee + +let m = marquee.marquee_new("scrolling content ") + |> marquee.with_speed(2) // cells per frame advance + +marquee.render(buf, area, m, frame) +``` + +Wide-char aware: scroll offset is cell-accurate. + +--- + +## Popup + +Centered modal overlay. + +```gleam +import etui/widgets/popup + +let p = + popup.popup_new(40, 10) + |> popup.with_title("Confirm") + |> popup.with_border(block.Rounded) + |> popup.with_style(style.Default, style.Indexed(0)) + +// Render the popup border +let buf = popup.render(buf, screen, p) + +// Get inner content area for child widgets +let inner = popup.popup_area(screen, p) +let buf = paragraph.render(buf, inner, content_para) +``` + +`popup_rect(screen, p)` returns the outer border rect. `popup_area(screen, p)` returns the inner content rect. Both clamp to screen bounds. + +--- + +## StatusBar + +Horizontal bar with left, center, and right span sections. + +```gleam +import etui/widgets/statusbar + +let bar = + statusbar.statusbar_new() + |> statusbar.with_left([span.line_plain("INSERT")]) + |> statusbar.with_center([span.line_plain("my-file.txt")]) + |> statusbar.with_right([span.line_plain("Ln 42 Col 8")]) + |> statusbar.with_style(style.Default, style.Indexed(4)) + +statusbar.render(buf, area, bar) +``` + +Left section is flush-left. Right section is flush-right. Center section is centered. Sections use `span.Line` for mixed-style text. + +--- + +## Line + +Horizontal or vertical divider. + +```gleam +import etui/widgets/line + +line.render(buf, area, line.line_new(line.Horizontal)) +line.render(buf, area, line.line_new(line.Vertical)) +``` + +--- + +## GradientBar + +Color-gradient horizontal bar. + +```gleam +import etui/widgets/gradient_bar + +gradient_bar.render(buf, area, gradient_bar.gradient_bar_new( + from: style.Rgb(255, 0, 0), + to: style.Rgb(0, 0, 255), +)) +``` + +--- + +## Progress + +Multi-step progress tracker. + +```gleam +import etui/widgets/progress + +let p = progress.progress_new(steps: 5, current: 2) +progress.render(buf, area, p) +``` + +--- + +## Clear + +Erase all cells in area to space/default style. + +```gleam +import etui/widgets/clear + +clear.render(buf, area) +``` + +--- + +## Scene + +Pre-composed static layout. Attach multiple widgets to named areas, render all at once. + +```gleam +import etui/widgets/scene + +let s = + scene.scene_new() + |> scene.add("header", header_w, header_area) + |> scene.add("body", body_w, body_area) + +scene.render(buf, s) +``` + +--- + +## TextArea + +Multi-line text editor with wide-char-aware cursor and vertical scroll. Lines are truncated (not wrapped) at the area width. + +```gleam +import etui/widgets/textarea as ta + +let w = ta.textarea_new() + |> ta.with_max_lines(100) + |> ta.with_max_line_length(200) + +let state = ta.state_new() + +// Keyboard handling with keys.match instead of raw strings +import etui/keys + +let state = case event { + backend.KeyPress(k) -> case keys.match(k) { + keys.Enter -> ta.newline(w, state) + keys.Backspace -> ta.backspace(state) + keys.Up -> ta.move_cursor_up(state) + keys.Down -> ta.move_cursor_down(state) + keys.Left -> ta.move_cursor_left(state) + keys.Right -> ta.move_cursor_right(state) + keys.Home -> ta.move_to_line_start(state) + keys.End -> ta.move_to_line_end(state) + keys.Char(c) -> ta.insert_char(w, state, c) + _ -> state + } + _ -> state +} + +ta.render(buf, area, w, state) + +// Read value +ta.value(state) // String, lines joined with "\n" +ta.line_count(state) // Int +``` + +Cursor wraps across line boundaries on left/right. Up/down clamp `cursor_x` to the new line's width. The cursor cell is highlighted with `cursor_style` (default: reverse video). + +### Extra TextArea cursor ops + +```gleam +ta.move_to_line_start(state) +ta.move_to_line_end(state) +ta.delete_to_line_end(state) +ta.state_from_string("pre-filled\ncontent") +``` + +--- + +## Tree + +Hierarchical list with expand/collapse nodes and keyboard navigation. + +```gleam +import etui/widgets/tree + +let t = + tree.tree_new([ + tree.node("src", "src/", [ + tree.leaf("main", "main.gleam"), + tree.leaf("lib", "lib.gleam"), + ]), + tree.leaf("readme", "README.md"), + ]) + |> tree.with_highlight_style(style.Style(...)) + +let state = tree.state_from_tree(t) // first root selected + +// Navigation +let state = tree.select_next(state, t) +let state = tree.select_prev(state, t) +let state = tree.toggle_selected(state, t) // expand/collapse +let state = tree.expand("src", state) +let state = tree.collapse("src", state) + +// Render +let buf = tree.render(buf, area, t, state) + +// Read selection +tree.selected(state) // Result(String, Nil), selected node ID +tree.is_expanded(state, "src") // Bool +``` + +Use `tree.ascii_glyphs()` for ASCII-only terminals; default uses Unicode `▶`/`▼`. + +### Per-node counts + +Attach a right-aligned count to any node (unread emails, child totals, etc.): + +```gleam +tree.node_with_count("inbox", "Inbox", 12, [ + tree.leaf_with_count("important", "Important", 3), + tree.leaf("archive", "Archive"), +]) + +// Or attach after construction +tree.leaf("drafts", "Drafts") |> tree.with_count(2) +``` + +Rendered as: + +```text +▼ Inbox 12 + Important 3 + Archive + Drafts 2 +``` + +--- + +## Paginator + +Page indicator with dot (`● ○ ○ ○ ○`) or arabic (`2/5`) display modes. Tracks +the current page and slices a list to the current window. + +```gleam +import etui/widgets/paginator + +let p = + paginator.paginator_new(5) + |> paginator.with_page_size(10) + |> paginator.with_style(paginator.Dots) + +// Navigation +let p = paginator.next_page(p) +let p = paginator.prev_page(p) +let p = paginator.go_to(p, 3) + +// Slice items for the current page +let visible = paginator.slice(all_items, p) + +// Recompute total when the item list changes +let p = paginator.set_item_count(p, list.length(all_items)) + +paginator.render(buf, area, p) +``` + +--- + +## Help + +Keyboard binding cheat sheet. Two layouts: `Short` (one line, separator +between bindings) and `Full` (key column + description column, one per row). + +```gleam +import etui/widgets/help + +let h = + help.help_new([ + help.binding(["k", "up"], "move up"), + help.binding(["j", "down"], "move down"), + help.binding(["?"], "toggle help"), + help.binding(["q", "ctrl+c"], "quit"), + ]) + +// Toggle Short/Full on a key press +let h = case event { + backend.KeyPress("?") -> help.toggle_mode(h) + _ -> h +} + +help.render(buf, area, h) +``` + +Customise: `with_separator`, `with_key_color`, `with_description_color`, +`with_bg`. + +--- + +## Fieldset + +Horizontal rule with an inline title. Acts as a lightweight section divider. + +```gleam +import etui/widgets/fieldset + +let fs = + fieldset.fieldset_new("Connections") + |> fieldset.with_align(fieldset.AlignCenter) + |> fieldset.with_line_char("═") + +fieldset.render(buf, area, fs) +``` + +Rendered (center, area width 40): + +```text +══════════════ Connections ════════════ +``` + +Alignment: `AlignLeft` (default, with `with_pad` setting the leading rule +count), `AlignCenter`, `AlignRight`. + +--- + +## MultiSelect + +Toggle list. Cursor scrolls through items; your update handler calls +`toggle/2` to flip the cursor item. + +```gleam +import etui/widgets/multi_select + +let w = + multi_select.multi_select_new(["Bash", "Gleam", "Erlang", "Rust"]) + |> multi_select.with_max(2) // optional cap, 0 = unlimited + +let state = multi_select.state_new() + +let state = case event { + backend.KeyPress("j") -> multi_select.select_next(state, 4) + backend.KeyPress("k") -> multi_select.select_prev(state) + backend.KeyPress(" ") -> multi_select.toggle(state, w.max) + backend.KeyPress("c") -> multi_select.clear_selection(state) + _ -> state +} + +multi_select.render(buf, area, w, state) + +// Read selection +multi_select.selected_indices(state) // List(Int) +multi_select.selected_values(w.items, state) // List(String) +multi_select.is_selected(state, 2) // Bool +``` From bafcd11b6bf634d430f3fb9a7a9109944cfd21ff Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 12:50:34 +0200 Subject: [PATCH 08/10] Add Gleam project config and manifest Add gleam.toml to define the new 'etui' Gleam project (name, version, description, license, repository/links) and declare runtime and dev dependencies. Also add the generated manifest.toml to capture resolved package versions for reproducible builds. --- gleam.toml | 21 +++++++++++++++++++++ manifest.toml | 16 ++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 gleam.toml create mode 100644 manifest.toml diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..1ea5926 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,21 @@ +name = "etui" +version = "1.0.0" +description = "A TUI library for Gleam: correct Unicode, minimal-diff rendering, clean terminal teardown." +licences = ["MIT"] +repository = { type = "github", user = "lupodevelop", repo = "etui" } +links = [ + { title = "API reference (HexDocs)", href = "https://hexdocs.pm/etui" }, + { title = "Widget tour", href = "https://etui.altumdream.com/widgets" }, + { title = "Guides (GitHub)", href = "https://github.com/lupodevelop/etui/tree/main/docs" }, + { title = "Examples (GitHub)", href = "https://github.com/lupodevelop/etui/tree/main/examples" }, + { title = "Repository", href = "https://github.com/lupodevelop/etui" }, + { title = "Changelog", href = "https://github.com/lupodevelop/etui/blob/main/CHANGELOG.md" }, +] + +[dependencies] +gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_javascript = ">= 1.0.0 and < 2.0.0" + +[dev_dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +fio = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..c35f955 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,16 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "fio", version = "1.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "fio", source = "hex", outer_checksum = "5D4A4B3855692BD9280824996AD8BC61DBAEEADBD7D68BF6FD08C8BD031510AB" }, + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, + { name = "gleam_stdlib", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "960090C2FB391784BB34267B099DC9315CC1B1F6013E7415BC763CEF1905D7D3" }, + { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" }, +] + +[requirements] +fio = { version = ">= 1.0.0 and < 2.0.0" } +gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } From a060515b6f7cfdaec02e671b23cdc726e1136956 Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 13:01:23 +0200 Subject: [PATCH 09/10] Add CHANGELOG and CONTRIBUTING files Add initial project documentation: CHANGELOG.md documents the 1.0.0 release and notable features --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9970f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to étui are listed here. + +## 1.0.0 - 2026-05-27 + +First public release. + +### Added + +- Buffer-diff rendering with cell-accurate Unicode (UAX #29 grapheme clusters). +- Layout primitives: `Length`, `Min`, `Max`, `Percentage`, `Ratio`, `Fill`, + plus `split_with_spacing`, `split_flex`, `split_responsive`. +- 32 widgets: block, paragraph, list, table, tabs, gauge, line_gauge, hbar, + chart, sparkline, canvas, input, textarea, tree, scrollbar, popup, statusbar, + spinner, marquee, dialog, form, notification, scene, progress, gradient_bar, + line, clear, scroll_view, paginator, help, fieldset, multi_select. +- Bubbletea-inspired additions (port of ratatui-cheese ideas): + - Spinner gains 10 presets (MiniDot, Jump, Pulse, Points, Globe, Moon, + Monkey, Meter, Hamburger, Ellipsis) on top of Dots, Line, Circle, Bounce. + - Tree supports a right-aligned count per node via `leaf_with_count`, + `node_with_count` and `with_count`. + - Input gains `with_prompt`, `with_password`, `with_mask` for prompt + prefixes and masked password fields. + - Paginator: dot or arabic page indicator, with `slice/2` helper. + - Help: short single-line and full multi-column key bindings view. + - Fieldset: horizontal rule with inline title (left/center/right). + - MultiSelect: toggle list with optional `max` cap and cursor scrolling. +- 10 built-in themes: dracula, nord, catppuccin_mocha, catppuccin_latte, + monokai, solarized_dark, gruvbox_dark, tokyo_night, dark, light. +- App loops with crash-restore on Erlang `try/after`: `run`, `run_buffered`, + `run_animated`, `run_buffered_cursor`. +- Backends for Erlang/BEAM, Node.js and the browser. `etui/backend/default` + picks one at compile time. +- Typed keyboard input via `keys.match`, command tables via `keymap`, + multi-slot focus via `focus`, integer-math easing via `anim`. +- Composition helpers in `etui/widget`: `layer`, `at`, `compose`, `stack`, + `StatefulWidget`, `AnimatedWidget`. +- Test mock backend for app-loop coverage (`test/app_loop_test`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..12e195c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing + +Thanks for helping improve étui. This is a **library** (not an application): keep changes small, tested, and API-stable after 1.0. + +## Setup + +- Gleam **1.16+** (see CI). +- Erlang/OTP **26+** for terminal development. +- Node **22+** only if you work on the JavaScript target or run `etui_js_smoke`. + +```sh +gleam deps download +gleam test +gleam format src test dev +``` + +## Layout + +| Path | Role | +| --- | --- | +| `src/etui/` | Public library code (published to Hex). | +| `src/etui/widgets/` | Widget renderers. | +| `test/` | gleeunit tests (headless). | +| `dev/` | Demos, benches, JS smoke — **not** published. | +| `docs/` | User-facing guides (keep in sync with API). | + +## Conventions + +1. **Widget render signature:** `fn(Buffer, Rect, Widget) -> Buffer` (or `render_stateful` with external state). +2. **Pipe style:** `buffer.buffer_new(area) |> widget.render(area, w)` — buffer first via `|>`. +3. **Pure core:** `geometry`, `text`, `buffer`, widgets must not import `backend`. +4. **Docs:** update `docs/` and `doc_snippets_compile_test` in `test/app_loop_test.gleam` when changing public API snippets. +5. **Format:** `gleam format` before opening a PR. + +## Tests + +- Add unit/snapshot tests under `test/` for behavior you change. +- Property tests for `geometry` when touching layout math. +- Erlang-only loop tests use `@target(erlang)` (see `test/app_loop_test.gleam`). + +## Demos + +Run from repo root: + +```sh +gleam run -m etui_showcase +gleam run -m etui_filebrowser +gleam run --target javascript -m etui_js_smoke +``` + +## Publishing +Contributors do not need to run `gleam publish`. From 46ca25177b909bc74bf92d40055ac14aa0fe2f63 Mon Sep 17 00:00:00 2001 From: Daniele Date: Wed, 27 May 2026 13:01:57 +0200 Subject: [PATCH 10/10] Add README and project logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comprehensive README for the Étui TUI library with badges, requirements, highlights, installation, a quickstart example, widget reference, layout/styling/themes, API usage, examples, docs links, and license. Also add assets/logo.png referenced by the README. --- README.md | 267 ++++++++++++++++++++++++++++++++++++++++++++++++ assets/logo.png | Bin 0 -> 67627 bytes 2 files changed, 267 insertions(+) create mode 100644 README.md create mode 100644 assets/logo.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec24c22 --- /dev/null +++ b/README.md @@ -0,0 +1,267 @@ +

+ Étui logo +

+ +

+ Hex version + HexDocs + CI + License +

+ +# Étui + +A TUI library for Gleam. Pure functions, composable widgets, correct Unicode. + +Inspired by [ratatui](https://ratatui.rs/): buffer-diff rendering, layout constraints, and an extensible widget system, all on the Erlang/BEAM. + +> *étui* (French): a small, fitted case that holds and protects delicate instruments. This library is that case for your terminal: a snug shell around buffers, widgets, and Unicode, so your app stays clean inside. + +**Requirements:** Gleam 1.16+, Erlang/OTP 26+ for terminal apps. Node 22+ only for the JavaScript target smoke path. + +```text +┌─ Sidebar ──┐┌─ Main ──────────────────────────┐ +│ > item 1 ││ Count: 42 │ +│ item 2 ││ あいうえお CJK = 2 cells each │ +│ item 3 ││ 👨‍👩‍👧‍👦 ZWJ family = 2 cells │ +└────────────┘└─────────────────────────────────┘ +``` + +## Highlights + +**Unicode-correct.** Cell width, not codepoints. `cell_width("你好") == 4`. Grapheme clusters come from Erlang's native UAX #29 segmentation. ZWJ sequences, combining marks, and regional indicators all cluster correctly. + +**Crash-restore.** `app.run` wraps the event loop in Erlang `try...after`. The terminal is restored before any exception propagates, and on normal exit and supported abort paths. + +**No-jitter layout.** `geometry.resolve_sizes` allocates on boundaries, not widths. Rounding errors don't accumulate across columns. + +**Testable without a terminal.** Geometry and buffer diffing are pure functions. Tests run headless. + +## Install + +```sh +gleam add etui +``` + +Or in `gleam.toml`: + +```toml +[dependencies] +etui = ">= 1.0.0 and < 2.0.0" +``` + +## Quickstart + +```gleam +import etui/app +import etui/backend +import etui/backend/default +import etui/buffer +import etui/geometry.{type Rect, Fill, Horizontal, Percentage} +import etui/widgets/block +import etui/widgets/paragraph +import gleam/int + +pub type Model { + Model(count: Int, width: Int, height: Int) +} + +pub fn main() { + let _ = + app.run_buffered( + default.new(), + Model(0, 80, 24), + view, + update, + fn(m) { m.count >= 10 }, + 16, + ) +} + +fn view(model: Model, screen: Rect) -> buffer.Buffer { + let chunks = geometry.split(Horizontal, screen, [Percentage(30), Fill]) + let left = case chunks { [l, ..] -> l _ -> screen } + let right = case chunks { [_, r, ..] -> r _ -> screen } + let para = paragraph.paragraph_new("Count: " <> int.to_string(model.count)) + let blk = + block.block_new() + |> block.with_border(block.Rounded) + |> block.with_title("App", block.Top) + buffer.buffer_new(screen) + |> block.render(left, block.block_new() |> block.with_border(block.Single)) + |> block.render(right, blk) + |> paragraph.render(block.inner(right, blk), para) +} + +fn update(event: backend.InputEvent, model: Model) -> Model { + case event { + backend.KeyPress("q") -> Model(..model, count: 10) + backend.KeyPress(" ") -> Model(..model, count: model.count + 1) + backend.Resize(w, h) -> Model(..model, width: w, height: h) + _ -> model + } +} +``` + +## Widgets + +| Widget | Module | Description | +| --- | --- | --- | +| Block | `widgets/block` | Borders, title, padding, bg fill | +| Paragraph | `widgets/paragraph` | Wrapping text, alignment | +| List | `widgets/list` | Scrollable, selectable items | +| Table | `widgets/table` | Grid with header, selection | +| Tabs | `widgets/tabs` | Horizontal tab bar | +| Gauge | `widgets/gauge` | Progress bar with label | +| HBar | `widgets/hbar` | Horizontal bar chart | +| Chart | `widgets/chart` | Line chart | +| Sparkline | `widgets/sparkline` | Inline data trend | +| Canvas | `widgets/canvas` | Braille pixel drawing | +| Input | `widgets/input` | Text input, wide-char cursor | +| Scrollbar | `widgets/scrollbar` | Scroll indicator | +| Spinner | `widgets/spinner` | Animated loading indicator | +| Marquee | `widgets/marquee` | Scrolling text ticker | +| Popup | `widgets/popup` | Centered modal overlay | +| StatusBar | `widgets/statusbar` | Left/center/right status line | +| Line | `widgets/line` | Horizontal/vertical dividers | +| Progress | `widgets/progress` | Multi-step progress tracker | +| GradientBar | `widgets/gradient_bar` | Color-gradient bar | +| Clear | `widgets/clear` | Erase area | +| Scene | `widgets/scene` | Static composed layout | +| TextArea | `widgets/textarea` | Multi-line editor | +| Tree | `widgets/tree` | Expand/collapse hierarchy, optional counts | +| Dialog | `widgets/dialog` | Modal with buttons | +| Form | `widgets/form` | Multi-field input form | +| Notification | `widgets/notification` | Toast/banner | +| ScrollView | `widgets/scroll_view` | Scrollable region wrapper | +| Paginator | `widgets/paginator` | Page indicator (dots / arabic) | +| Help | `widgets/help` | Key binding help, short and full | +| Fieldset | `widgets/fieldset` | Horizontal rule with title | +| MultiSelect | `widgets/multi_select` | Checkbox list with optional cap | + +## Layout + +```gleam +import etui/geometry.{Horizontal, Vertical, Length, Percentage, Fill} + +// Constraints: Length(n) fixed cells, Percentage(n) of total, Fill = remainder +let cols = geometry.split(Horizontal, area, [Length(20), Percentage(50), Fill]) +let rows = geometry.split(Vertical, area, [Length(3), Fill]) +``` + +## Styling + +```gleam +import etui/style + +style.Indexed(1) // 16-color palette +style.Rgb(255, 128, 0) // 24-bit true color +style.bold() // modifier +style.italic() +style.underline() +style.reverse() +``` + +## Themes + +```gleam +import etui/theme + +let t = theme.dracula() // dark purple, RGB +let t = theme.nord() // arctic dark, RGB +let t = theme.catppuccin_mocha() // pastel dark, RGB +let t = theme.gruvbox_dark() // retro groove, RGB +let t = theme.tokyo_night() // cool blue, RGB +let t = theme.dark() // ANSI 16-color (max compatibility) + +// Use color slots directly +block.block_new() |> block.with_style(t.border, t.bg) + +// Or use pre-built Style helpers +list_widget |> glist.with_highlight_style(theme.selection(t)) + +// Customize from a base +let custom = theme.Theme(..theme.nord(), accent: style.Rgb(255, 165, 0)) +``` + +10 built-in themes. RGB (`style.Rgb(r,g,b)`) and 256-color (`style.Indexed(n)`) both supported. ANSI themes for terminals without true-color. + +## Widget system + +Any `fn(Buffer, Rect) -> Buffer` is a widget. No registration, no traits. + +```gleam +import etui/widget + +// Compose: border + inner content +let w = widget.compose(border_w, block.inner(area, blk), content_w) + +// Layer: draw top over bottom +let w = widget.layer(background_w, overlay_w) + +// Stack: multiple widgets in same area, in order +let w = widget.stack([bg_w, content_w, cursor_w]) + +// Stateful widget +let sw = widget.StatefulWidget(render: fn(buf, area, state: MyState) { ... }) +widget.render_stateful(buf, area, sw, my_state) + +// Animated widget +let aw: widget.AnimatedWidget = fn(buf, area, frame) { ... } +widget.freeze_frame(aw, current_frame)(buf, area) +``` + +## App loop + +Most apps use **`run_buffered`**: you return a `Buffer`, étui diffs it each frame. + +```gleam +app.run_buffered( + default.new(), + model, + fn(m, screen) { /* build buffer */ }, + fn(ev, m) { /* update model */ }, + fn(m) { m.quit }, + 16, +) +``` + +| API | When | +| --- | --- | +| `run_buffered` | Default full-screen UI | +| `run_buffered_cursor` | Inputs with visible hardware cursor | +| `run_animated` | Frame-based widgets (`AnimState` passed to `view`) | +| `run` | Low-level `List(RenderOp)` control | + +On **JavaScript** (Node), the same functions return `Promise(AppResult(_))`. + +Low-level `RenderOp` values: `Write`, `MoveCursor`, `ClearScreen`, `EnterAltScreen`, `ExitAltScreen`, `EnableMouse`, `DisableMouse`. Enable mouse with `default.new_with_mouse()`. + +## Examples in this repo + +Demos under `dev/` (not published to Hex): + +```sh +gleam run -m etui_showcase +gleam run -m etui_filebrowser +gleam run --target javascript -m etui_js_smoke +``` + +## Docs + +See [`docs/`](docs/) (index: [docs/README.md](docs/README.md)): + +- [Getting started](docs/getting-started.md) +- [Layout](docs/layout.md) +- [Styling](docs/styling.md) +- [Themes](docs/themes.md) +- [Widgets](docs/widgets.md) +- [Custom widgets](docs/custom-widgets.md) +- [Animation](docs/animation.md) +- [Focus](docs/focus.md) + +Contributors: [CONTRIBUTING.md](CONTRIBUTING.md) + +## License + +MIT diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3e74b77ea66e03f29c1e462f5af74959ec2010cf GIT binary patch literal 67627 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�r80?%vxJ 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