diff --git a/README.md b/README.md index 0c8216b..0f1021f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ view(N) -> --- -## Status — v0.1.0 +## Status — v0.1.1 Everything below is implemented and covered by tests (103 EUnit cases) plus PTY integration checks on macOS and Linux: @@ -60,7 +60,7 @@ Add beamtea as a rebar3 dependency: ```erlang %% rebar.config -{deps, [{beamtea, {git, "https://github.com/tsirysndr/beamtea.git", {tag, "0.1.0"}}}]}. +{deps, [{beamtea, {git, "https://github.com/tsirysndr/beamtea.git", {tag, "0.1.1"}}}]}. ``` Or clone and build locally: @@ -145,6 +145,30 @@ Return a command from `init/2` or `update/2` to ask the runtime to do something: - `alt_screen => boolean()` (default `true`) — use the alternate screen buffer - `catch_ctrl_c => boolean()` (default `true`) — quit on `Ctrl-C` instead of passing it to `update/2` +- `layout => top_left | center | {place, Opts}` (default `top_left`) — position the whole view within the terminal (see **Filling the terminal** below) + +### Filling the terminal + +beamtea renders exactly what `view/1` returns, anchored top-left — so a small view sits in the corner and the rest of the screen is blank. To use the whole terminal you have two options: + +**1. Let the runtime place your view** — the simplest way to centre or corner-pin a view. The runtime knows the terminal size and re-flows on resize: + +```erlang +beamtea:start(?MODULE, undefined, #{layout => center}). +%% or: #{layout => {place, #{halign => center, valign => bottom}}} +``` + +(All the examples use `#{layout => center}` — that's why they fill the screen.) + +**2. Lay out yourself** — for real full-screen UIs (headers, footers, sidebars). Your `update/2` receives the terminal size as `{resize, {Cols, Rows}}` — **including once at startup**, before the first frame — so store it and build your `view/1` to those dimensions. `beamtea_layout` (`place/3`, `center/2`, `top_center/2`) and `beamtea_util:visible_width/1` (ANSI-aware) help you position and size content. + +```erlang +init(_) -> {#st{w = 80, h = 24}, beamtea:none()}. +update({resize, {W, H}}, St) -> {St#st{w = W, h = H}, beamtea:none()}; +%% ... +view(#st{w = W, h = H} = St) -> + beamtea_layout:center(my_panel(St), {W, H}). +``` --- diff --git a/examples/table_demo.erl b/examples/table_demo.erl index d91ebdf..fb8dcde 100644 --- a/examples/table_demo.erl +++ b/examples/table_demo.erl @@ -9,6 +9,7 @@ main() -> beamtea:start(?MODULE). init(_Flags) -> + {_, H} = beamtea:window_size(), Cols = [{<<"Framework">>, 14}, {<<"Language">>, 10}, {<<"Stars">>, 7}], Rows = [[<<"beamtea">>, <<"Erlang">>, <<"new!">>], [<<"Bubble Tea">>, <<"Go">>, <<"27k">>], @@ -18,7 +19,9 @@ init(_Flags) -> [<<"Ink">>, <<"JS">>, <<"23k">>], [<<"tview">>, <<"Go">>, <<"10k">>], [<<"notcurses">>, <<"C">>, <<"3k">>]], - T = beamtea_table:new(Cols, Rows, #{height => 5}), + %% Taller table: scale visible rows to the terminal height. + VisibleRows = max(8, H - 9), + T = beamtea_table:new(Cols, Rows, #{height => VisibleRows}), {T, beamtea:none()}. update({key, {char, $q}}, T) -> {T, beamtea:quit()}; diff --git a/src/beamtea.app.src b/src/beamtea.app.src index 3e922f7..1dedf91 100644 --- a/src/beamtea.app.src +++ b/src/beamtea.app.src @@ -1,6 +1,6 @@ {application, beamtea, [ {description, "A delightful terminal UI framework for the BEAM - Bubble Tea for Erlang. The Elm Architecture on prim_tty, with reusable bubbles (spinner, textinput, table, and more)."}, - {vsn, "0.1.0"}, + {vsn, "0.1.1"}, {registered, [beamtea_runtime]}, {applications, [ kernel, diff --git a/src/beamtea.erl b/src/beamtea.erl index dac5587..e8794c4 100644 --- a/src/beamtea.erl +++ b/src/beamtea.erl @@ -31,6 +31,9 @@ -export([none/0, quit/0, batch/1, sequence/1, msg/1, tick/2, every/2, task/1]). +%% Terminal info +-export([window_size/0]). + %% Internal (used by the runtime) -export([normalize/1]). @@ -100,6 +103,10 @@ start(Program, Flags) -> start(Program, Flags, #{}). %% screen buffer %%
  • `catch_ctrl_c => boolean()' (default `true') — quit on Ctrl-C %% instead of passing it to `update/2'
  • +%%
  • `layout => top_left | center | {place, Opts}' (default `top_left') +%% — position the whole view within the terminal, so a small view can +%% fill/centre the screen. See {@link beamtea_layout}. `center' and +%% `{place, ...}' re-flow automatically on resize.
  • %% -spec start(program(), term(), map()) -> {ok, model()} | {error, term()}. start(Program, Flags, Opts) -> @@ -164,6 +171,18 @@ every(Ms, Msg) -> {every, Ms, Msg}. -spec task(fun(() -> msg())) -> cmd(). task(Fun) -> {task, Fun}. +%% @doc The current terminal size `{Cols, Rows}'. Callable from `init/1' and +%% `view/1' (they run in the runtime process), so a view can size its content +%% to the terminal without threading the size through the model. The size also +%% arrives in `update/2' as `{resize, {Cols, Rows}}' if you prefer to store it. +%% Returns `{80, 24}' if called outside a running program. +-spec window_size() -> {pos_integer(), pos_integer()}. +window_size() -> + case get(beamtea_size) of + {Cols, Rows} when is_integer(Cols), is_integer(Rows) -> {Cols, Rows}; + _ -> {80, 24} + end. + %%% =================================================================== %%% Internal %%% =================================================================== diff --git a/src/beamtea_filepicker.erl b/src/beamtea_filepicker.erl index 8835624..3092ad8 100644 --- a/src/beamtea_filepicker.erl +++ b/src/beamtea_filepicker.erl @@ -2,23 +2,30 @@ %%% charmbracelet/bubbles. %%% %%% Browse directories with the arrows, descend with Enter/→, ascend with -%%% ←/Backspace. Selecting a file records it; read it back with -%%% {@link did_select_file/1}. +%%% ←/Backspace. Each row shows a Unix-style permissions column, a size column +%%% and the name — directories in purple, files in gray. Selecting a file +%%% records it; read it back with {@link did_select_file/1}. -module(beamtea_filepicker). +-include_lib("kernel/include/file.hrl"). + -export([new/0, new/1, update/2, view/1, path/1, did_select_file/1, highlighted/1]). -export_type([model/0]). +%% Each entry: {Name, IsDir, SizeBytes, PermsString}. +-type entry() :: {binary(), boolean(), non_neg_integer(), binary()}. + -opaque model() :: #{type := filepicker, path := file:filename(), - entries := [{binary(), boolean()}], %% {Name, IsDir} + entries := [entry()], cursor := non_neg_integer(), offset := non_neg_integer(), height := pos_integer(), selected := none | file:filename(), dir_color := 0..255, + file_color := 0..255, select_color := 0..255}. %% @equiv new(#{}) @@ -26,7 +33,8 @@ new() -> new(#{}). %% @doc Options: `path' (starting directory, default cwd), `height' -%% (visible rows, default 12), colours. +%% (visible rows, default 12), `dir_color' (default purple), `file_color' +%% (default gray), `select_color'. -spec new(map()) -> model(). new(Opts) -> Path = maps:get(path, Opts, cwd()), @@ -37,7 +45,8 @@ new(Opts) -> offset => 0, height => maps:get(height, Opts, 12), selected => none, - dir_color => maps:get(dir_color, Opts, beamtea_color:c(sky)), + dir_color => maps:get(dir_color, Opts, beamtea_color:c(purple)), + file_color => maps:get(file_color, Opts, beamtea_color:c(gray)), select_color => maps:get(select_color, Opts, beamtea_color:c(pink))}, load(Base). @@ -66,7 +75,7 @@ update(_Msg, M) -> %% Descend into a directory or select a file. open(#{entries := []} = M) -> M; open(#{entries := Es, cursor := Cur, path := Path} = M) -> - {Name, IsDir} = lists:nth(Cur + 1, Es), + {Name, IsDir, _Size, _Perms} = lists:nth(Cur + 1, Es), Full = filename:join(Path, unicode:characters_to_list(Name)), case IsDir of true -> load(M#{path := Full, cursor := 0, offset := 0}); @@ -85,18 +94,26 @@ load(#{path := Path} = M) -> Entries = case file:list_dir(Path) of {ok, Names} -> - Tagged = [{list_to_binary(N), - filelib:is_dir(filename:join(Path, N))} - || N <- Names], + Tagged = [entry_info(Path, N) || N <- Names], lists:sort(fun sort_entries/2, Tagged); {error, _} -> [] end, M#{entries := Entries}. -sort_entries({NA, true}, {NB, false}) when NA =/= NB -> true; -sort_entries({_NA, false}, {_NB, true}) -> false; -sort_entries({NA, _}, {NB, _}) -> NA =< NB. +%% Read type, size and permissions for one directory entry. +entry_info(Path, Name) -> + Full = filename:join(Path, Name), + case file:read_file_info(Full, [{time, posix}]) of + {ok, #file_info{type = Type, size = Size, mode = Mode}} -> + {list_to_binary(Name), Type =:= directory, Size, perms(Type, Mode)}; + {error, _} -> + {list_to_binary(Name), filelib:is_dir(Full), 0, <<"??????????">>} + end. + +sort_entries({_, true, _, _}, {_, false, _, _}) -> true; +sort_entries({_, false, _, _}, {_, true, _, _}) -> false; +sort_entries({NA, _, _, _}, {NB, _, _, _}) -> NA =< NB. scroll(#{cursor := Cur, offset := Off, height := H} = M) -> Off1 = if @@ -109,29 +126,34 @@ scroll(#{cursor := Cur, offset := Off, height := H} = M) -> %% @doc Render the current path and the visible window of entries. -spec view(model()) -> iodata(). view(#{path := Path, entries := Es, cursor := Cur, offset := Off, height := H, - dir_color := DC, select_color := SC}) -> + dir_color := DC, file_color := FC, select_color := SC}) -> Header = beamtea_term:bold(beamtea_color:fg(cyan, list_to_binary(Path))), Body = case Es of [] -> [<<"\n">>, beamtea_term:faint(<<" (empty)">>)]; _ -> Window = lists:sublist(lists:nthtail(min(Off, length(Es)), Es), H), - Rows = [render_entry(E, Off + Idx - 1 =:= Cur, DC, SC) + Rows = [render_entry(E, Off + Idx - 1 =:= Cur, DC, FC, SC) || {Idx, E} <- enumerate(Window)], [<<"\n">>, lists:join(<<"\n">>, Rows)] end, [Header, Body]. -render_entry({Name, IsDir}, Selected, DC, SC) -> - Glyph = case IsDir of true -> <<"📁 "/utf8>>; false -> <<"📄 "/utf8>> end, - Label = case IsDir of - true -> beamtea_term:paint([38, 5, DC], Name); - false -> Name - end, - case Selected of - true -> beamtea_term:paint([38, 5, SC], [<<"> ">>, Glyph, Name]); - false -> [<<" ">>, Glyph, Label] - end. +%% A row: cursor marker, permissions, right-aligned size, then the coloured +%% name (purple for dirs, gray for files; bold + pink marker when selected). +render_entry({Name, IsDir, Size, Perms}, Selected, DC, FC, SC) -> + NameColor = case IsDir of true -> DC; false -> FC end, + PermsCol = beamtea_term:faint(Perms), + SizeCol = beamtea_term:faint(pad_left(human_size(IsDir, Size), 7)), + {Marker, NameCol} = + case Selected of + true -> + {beamtea_term:paint([38, 5, SC], <<"> ">>), + beamtea_term:bold(beamtea_term:paint([38, 5, NameColor], Name))}; + false -> + {<<" ">>, beamtea_term:paint([38, 5, NameColor], Name)} + end, + [Marker, PermsCol, <<" ">>, SizeCol, <<" ">>, NameCol]. %%% --- accessors -------------------------------------------------------- @@ -145,7 +167,9 @@ did_select_file(#{selected := Sel}) -> Sel. %% @doc The `{Name, IsDir}' currently under the cursor, or `none'. -spec highlighted(model()) -> {binary(), boolean()} | none. highlighted(#{entries := []}) -> none; -highlighted(#{entries := Es, cursor := Cur}) -> lists:nth(Cur + 1, Es). +highlighted(#{entries := Es, cursor := Cur}) -> + {Name, IsDir, _Size, _Perms} = lists:nth(Cur + 1, Es), + {Name, IsDir}. %%% --- helpers ---------------------------------------------------------- @@ -156,3 +180,40 @@ cwd() -> end. enumerate(List) -> lists:zip(lists:seq(1, length(List)), List). + +%% A Unix-style permission string, e.g. "drwxr-xr-x". +perms(Type, Mode) -> + Bits = [{8#400, $r}, {8#200, $w}, {8#100, $x}, + {8#040, $r}, {8#020, $w}, {8#010, $x}, + {8#004, $r}, {8#002, $w}, {8#001, $x}], + Chars = [case Mode band B of 0 -> $-; _ -> C end || {B, C} <- Bits], + list_to_binary([type_char(Type) | Chars]). + +type_char(directory) -> $d; +type_char(symlink) -> $l; +type_char(device) -> $c; +type_char(_) -> $-. + +%% Human-readable size; directories show "-". +human_size(true, _Size) -> <<"-">>; +human_size(false, Size) -> + if + Size < 1024 -> <<(integer_to_binary(Size))/binary, "B">>; + Size < 1024 * 1024 -> unit(Size / 1024, "K"); + Size < 1024 * 1024 * 1024 -> unit(Size / (1024 * 1024), "M"); + true -> unit(Size / (1024 * 1024 * 1024), "G") + end. + +unit(V, Suffix) -> + %% one decimal place, e.g. "1.2K" + Whole = trunc(V), + Tenth = trunc((V - Whole) * 10), + iolist_to_binary([integer_to_binary(Whole), $., integer_to_binary(Tenth), Suffix]). + +pad_left(Bin0, Width) -> + Bin = iolist_to_binary(Bin0), + Len = byte_size(Bin), + case Width > Len of + true -> <<(binary:copy(<<" ">>, Width - Len))/binary, Bin/binary>>; + false -> Bin + end. diff --git a/src/beamtea_layout.erl b/src/beamtea_layout.erl new file mode 100644 index 0000000..8591759 --- /dev/null +++ b/src/beamtea_layout.erl @@ -0,0 +1,95 @@ +%%% @doc Position a block of content within the full terminal. +%%% +%%% beamtea renders exactly what your `view/1' returns, anchored at the +%%% top-left. To use the whole screen you place your content inside the +%%% terminal's `{Cols, Rows}' — centre it, pin it to a corner, etc. Widths are +%%% measured ignoring ANSI colour codes (via {@link beamtea_util:visible_width/1}), +%%% so coloured content still aligns. +%%% +%%% The runtime can apply this for you: `beamtea:start(Mod, Flags, +%%% #{layout => center})'. Or call it yourself in `view/1' when you track the +%%% size via the `{resize, {Cols, Rows}}' message. +-module(beamtea_layout). + +-export([place/3, center/2, top_center/2, frame/3]). + +-export_type([halign/0, valign/0]). + +-type halign() :: left | center | right. +-type valign() :: top | middle | bottom. + +%% @doc Place `Content' within a `{Cols, Rows}' area. Options: +%% `halign' (`left' | `center' | `right', default `center') and +%% `valign' (`top' | `middle' | `bottom', default `middle'). The block keeps +%% its internal left-alignment; it is positioned as a unit. +-spec place(iodata(), {pos_integer(), pos_integer()}, + #{halign => halign(), valign => valign()}) -> iodata(). +place(Content, {Cols, Rows}, Opts) -> + HAlign = maps:get(halign, Opts, center), + VAlign = maps:get(valign, Opts, middle), + Lines = beamtea_render:lines(Content), + BlockW = lists:max([0 | [beamtea_util:visible_width(L) || L <- Lines]]), + LeftPad = spaces(hpad(HAlign, Cols, BlockW)), + Placed = [[LeftPad, L] || L <- Lines], + TopPad = lists:duplicate(vpad(VAlign, Rows, length(Lines)), <<>>), + lists:join(<<"\n">>, TopPad ++ Placed). + +%% @doc Centre `Content' both horizontally and vertically. +-spec center(iodata(), {pos_integer(), pos_integer()}) -> iodata(). +center(Content, Size) -> + place(Content, Size, #{halign => center, valign => middle}). + +%% @doc Centre horizontally, pinned to the top. +-spec top_center(iodata(), {pos_integer(), pos_integer()}) -> iodata(). +top_center(Content, Size) -> + place(Content, Size, #{halign => center, valign => top}). + +%% @doc Draw a bordered panel that fills the whole `{Cols, Rows}' terminal and +%% place `Content' inside it (top-left, with one column of padding). This is +%% what makes a small view "fit the terminal". Options: `border_color' +%% (default charmple) and `padding' (inner columns/rows, default 1). Lines +%% wider than the inner width are truncated; the panel is padded to full height. +-spec frame(iodata(), {pos_integer(), pos_integer()}, map()) -> iodata(). +frame(Content, {Cols, Rows}, Opts) when Cols >= 4, Rows >= 3 -> + BC = maps:get(border_color, Opts, beamtea_color:c(charmple)), + Pad = maps:get(padding, Opts, 1), + ContentW = max(0, Cols - 2 - 2 * Pad), + InnerH = max(0, Rows - 2 - 2 * Pad), + Lines = [fit_line(L, ContentW) || L <- beamtea_render:lines(Content)], + Kept = lists:sublist(Lines, InnerH), + Blank = fit_line(<<>>, ContentW), + VPad = lists:duplicate(Pad, Blank), + Filled = VPad ++ Kept + ++ lists:duplicate(max(0, InnerH - length(Kept)), Blank) + ++ VPad, + Side = border(<<"│"/utf8>>, BC), + PadSp = spaces(Pad), + Body = [[Side, PadSp, L, PadSp, Side] || L <- Filled], + Bar = beamtea_term:rule(Cols - 2), + Top = border([<<"┌"/utf8>>, Bar, <<"┐"/utf8>>], BC), + Bottom = border([<<"└"/utf8>>, Bar, <<"┘"/utf8>>], BC), + lists:join(<<"\n">>, [Top | Body] ++ [Bottom]); +frame(Content, _Size, _Opts) -> + Content. + +%%% --- helpers ---------------------------------------------------------- + +border(IoData, Color) -> beamtea_term:paint([38, 5, Color], IoData). + +%% Pad (or truncate) a line to exactly `W' visible columns. +fit_line(Line, W) -> + case beamtea_util:visible_width(Line) of + Vis when Vis =< W -> [Line, spaces(W - Vis)]; + _ -> beamtea_util:truncate(beamtea_util:strip_ansi(Line), W) + end. + +hpad(left, _Cols, _BlockW) -> 0; +hpad(center, Cols, BlockW) -> max(0, (Cols - BlockW) div 2); +hpad(right, Cols, BlockW) -> max(0, Cols - BlockW). + +vpad(top, _Rows, _N) -> 0; +vpad(middle, Rows, N) -> max(0, (Rows - N) div 2); +vpad(bottom, Rows, N) -> max(0, Rows - N). + +spaces(0) -> <<>>; +spaces(N) -> binary:copy(<<" ">>, N). diff --git a/src/beamtea_progress.erl b/src/beamtea_progress.erl index 1bbe153..5056ff0 100644 --- a/src/beamtea_progress.erl +++ b/src/beamtea_progress.erl @@ -12,23 +12,30 @@ -export([new/0, new/1, set/2, incr/2, percent/1, view/1]). --export_type([model/0]). +-export_type([model/0, rgb/0]). + +-type rgb() :: {0..255, 0..255, 0..255}. -opaque model() :: #{type := progress, percent := float(), width := pos_integer(), full := binary(), empty := binary(), - gradient := {0..255, 0..255}, + gradient := {rgb(), rgb()}, empty_color := 0..255, show_percent := boolean()}. +%% Charm-style default gradient: purple (#7D56F4) -> pink (#EE6FF8). +-define(GRAD_FROM, {16#7D, 16#56, 16#F4}). +-define(GRAD_TO, {16#EE, 16#6F, 16#F8}). + %% @equiv new(#{}) -spec new() -> model(). new() -> new(#{}). %% @doc Options: `width' (default 40), `full'/`empty' glyphs, `gradient' -%% ({FromColor, ToColor} xterm-256 indices), `empty_color', `show_percent'. +%% (`{FromRGB, ToRGB}', each an `{R,G,B}' triple — interpolated in 24-bit +%% truecolor), `empty_color' (xterm-256 index), `show_percent'. -spec new(map()) -> model(). new(Opts) -> #{type => progress, @@ -36,8 +43,7 @@ new(Opts) -> width => maps:get(width, Opts, 40), full => maps:get(full, Opts, <<"█"/utf8>>), empty => maps:get(empty, Opts, <<"░"/utf8>>), - gradient => maps:get(gradient, Opts, - {beamtea_color:c(purple), beamtea_color:c(hotpink)}), + gradient => maps:get(gradient, Opts, {?GRAD_FROM, ?GRAD_TO}), empty_color => maps:get(empty_color, Opts, beamtea_color:c(dim)), show_percent => maps:get(show_percent, Opts, true)}. @@ -70,13 +76,20 @@ view(#{percent := P, width := W, full := Full, empty := Empty, %%% --- helpers ---------------------------------------------------------- -%% Interpolate a colour across the bar so the fill fades from `From' to `To'. +%% Interpolate a truecolor across the bar so the fill fades from `From' to `To'. +%% Interpolation is linear in RGB space between the two endpoints — this is what +%% gives a smooth purple->pink ramp (unlike walking xterm-256 index numbers, +%% which passes through unrelated hues). gradient_cell(Glyph, I, W, From, To) when W > 1 -> T = (I - 1) / (W - 1), - Color = round(From + (To - From) * T), - beamtea_term:paint([38, 5, Color], Glyph); + beamtea_term:paint_rgb(lerp_rgb(From, To, T), Glyph); gradient_cell(Glyph, _I, _W, From, _To) -> - beamtea_term:paint([38, 5, From], Glyph). + beamtea_term:paint_rgb(From, Glyph). + +lerp_rgb({R1, G1, B1}, {R2, G2, B2}, T) -> + {lerp(R1, R2, T), lerp(G1, G2, T), lerp(B1, B2, T)}. + +lerp(A, B, T) -> round(A + (B - A) * T). pct_label(P) -> Whole = round(P * 100), diff --git a/src/beamtea_runtime.erl b/src/beamtea_runtime.erl index 4c8387d..f5519d3 100644 --- a/src/beamtea_runtime.erl +++ b/src/beamtea_runtime.erl @@ -27,6 +27,7 @@ size :: {pos_integer(), pos_integer()}, %% {Cols, Rows} alt :: boolean(), catch_ctrl_c :: boolean(), + layout :: top_left | center | fill | {frame, map()} | {place, map()}, buf = <<>> :: binary() %% leftover, un-parsed input bytes }). @@ -43,13 +44,18 @@ run(#beamtea_prog{} = Prog, Flags, Opts) -> enter_screen(TTY0, Alt), try Size = window_size(TTY0), + put(beamtea_size, Size), %% make it readable from init/view via beamtea:window_size/0 {InitModel, Cmd} = (Prog#beamtea_prog.init)(Flags), St0 = #st{tty = TTY0, prog = Prog, model = InitModel, opts = Opts, - size = Size, alt = Alt, catch_ctrl_c = CatchC}, + size = Size, alt = Alt, catch_ctrl_c = CatchC, + layout = maps:get(layout, Opts, top_left)}, St1 = perform(beamtea_cmd:to_effects(Cmd), St0), - render(St1), + %% Hand the program its initial size up-front (as a resize) so it can + %% lay out to the full terminal on the very first frame. + St2 = deliver({resize, Size}, St1), + render(St2), ok = prim_tty:read(TTY0), - loop(St1) + loop(St2) after leave_screen(TTY0, Alt) end. @@ -154,10 +160,21 @@ run_effect({task, Fun}, St) -> %%% Terminal I/O %%% =================================================================== -render(#st{tty = TTY, prog = Prog, model = Model}) -> - Frame = (Prog#beamtea_prog.view)(Model), +render(#st{tty = TTY, prog = Prog, model = Model, size = Size, layout = Layout}) -> + put(beamtea_size, Size), %% current size, readable from view via beamtea:window_size/0 + Frame0 = (Prog#beamtea_prog.view)(Model), + Frame = apply_layout(Layout, Frame0, Size), ok = prim_tty:write(TTY, beamtea_render:to_frame(Frame)). +%% Position the view within the terminal. `top_left' (the default) renders as +%% written; `center' and `{place, Opts}' use the current size, so they track +%% terminal resizes automatically. +apply_layout(top_left, Frame, _Size) -> Frame; +apply_layout(center, Frame, Size) -> beamtea_layout:center(Frame, Size); +apply_layout(fill, Frame, Size) -> beamtea_layout:frame(Frame, Size, #{}); +apply_layout({frame, Opts}, Frame, Size) -> beamtea_layout:frame(Frame, Size, Opts); +apply_layout({place, Opts}, Frame, Size) -> beamtea_layout:place(Frame, Size, Opts). + window_size(TTY) -> case prim_tty:window_size(TTY) of {ok, {Cols, Rows}} -> {Cols, Rows}; diff --git a/src/beamtea_term.erl b/src/beamtea_term.erl index fcf4585..01dc217 100644 --- a/src/beamtea_term.erl +++ b/src/beamtea_term.erl @@ -10,8 +10,9 @@ clear/0, clear_line/0, clear_below/0, home/0, move/2, reset/0, sgr/1, - fg/1, bg/1, - bold/1, faint/1, italic/1, underline/1, reverse/1, paint/2]). + fg/1, bg/1, fg_rgb/3, bg_rgb/3, paint_rgb/2, + bold/1, faint/1, italic/1, underline/1, reverse/1, paint/2, + rule/1, rule/2]). %%% =================================================================== %%% Screen / cursor control @@ -74,6 +75,18 @@ fg(N) -> sgr([38, 5, N]). -spec bg(0..255) -> iodata(). bg(N) -> sgr([48, 5, N]). +%% @doc 24-bit truecolor foreground. +-spec fg_rgb(0..255, 0..255, 0..255) -> iodata(). +fg_rgb(R, G, B) -> sgr([38, 2, R, G, B]). + +%% @doc 24-bit truecolor background. +-spec bg_rgb(0..255, 0..255, 0..255) -> iodata(). +bg_rgb(R, G, B) -> sgr([48, 2, R, G, B]). + +%% @doc Paint `Text' in a truecolor `{R, G, B}' foreground and reset. +-spec paint_rgb({0..255, 0..255, 0..255}, iodata()) -> iodata(). +paint_rgb({R, G, B}, Text) -> paint([38, 2, R, G, B], Text). + -spec bold(iodata()) -> iodata(). bold(Text) -> paint([1], Text). @@ -92,3 +105,11 @@ reverse(Text) -> paint([7], Text). %% @doc Wrap `Text' in the given SGR codes and reset afterwards. -spec paint([non_neg_integer()], iodata()) -> iodata(). paint(Codes, Text) -> [sgr(Codes), Text, reset()]. + +%% @equiv rule(Width, <<"─"/utf8>>) +-spec rule(non_neg_integer()) -> iodata(). +rule(Width) -> rule(Width, <<"─"/utf8>>). + +%% @doc A horizontal rule: `Glyph' repeated to `Width' columns. +-spec rule(non_neg_integer(), binary()) -> iodata(). +rule(Width, Glyph) -> lists:duplicate(Width, Glyph). diff --git a/test/beamtea_layout_tests.erl b/test/beamtea_layout_tests.erl new file mode 100644 index 0000000..82d86a2 --- /dev/null +++ b/test/beamtea_layout_tests.erl @@ -0,0 +1,74 @@ +-module(beamtea_layout_tests). + +-include_lib("eunit/include/eunit.hrl"). + +lines(IoData) -> beamtea_render:lines(IoData). + +center_horizontally_test() -> + %% "ab" (width 2) in 10 cols -> left pad (10-2)/2 = 4 spaces. + Out = beamtea_layout:place(<<"ab">>, {10, 1}, #{halign => center, valign => top}), + ?assertEqual([<<" ab">>], lines(Out)). + +center_vertically_test() -> + %% one line in 5 rows -> (5-1)/2 = 2 blank lines on top. + Out = beamtea_layout:place(<<"x">>, {1, 5}, #{halign => left, valign => middle}), + ?assertEqual([<<>>, <<>>, <<"x">>], lines(Out)). + +center_both_test() -> + Out = beamtea_layout:center(<<"hi">>, {8, 3}), + %% vertical: (3-1)/2 = 1 blank; horizontal: (8-2)/2 = 3 spaces + ?assertEqual([<<>>, <<" hi">>], lines(Out)). + +block_keeps_internal_alignment_test() -> + %% Two lines of different width are shifted by the same amount (block width). + Out = beamtea_layout:place(<<"aa\nb">>, {10, 2}, #{halign => center, valign => top}), + %% block width = 2 -> pad = (10-2)/2 = 4 for both lines + ?assertEqual([<<" aa">>, <<" b">>], lines(Out)). + +right_and_bottom_test() -> + Out = beamtea_layout:place(<<"ab">>, {6, 3}, #{halign => right, valign => bottom}), + %% right: 6-2 = 4 spaces; bottom: 3-1 = 2 blank lines on top + ?assertEqual([<<>>, <<>>, <<" ab">>], lines(Out)). + +ignores_ansi_width_test() -> + %% Coloured "ab" has the visible width of 2, not the escape length. + Colored = beamtea_term:bold(<<"ab">>), + Out = beamtea_layout:place(Colored, {10, 1}, #{halign => center, valign => top}), + [Line] = lines(Out), + %% 4 leading spaces then the (styled) content + ?assertMatch(<<" ", _/binary>>, Line), + ?assertEqual(2 + 4, beamtea_util:visible_width(Line)). + +no_negative_padding_test() -> + %% Content wider/taller than the area: no padding, no crash. + Out = beamtea_layout:center(<<"abcdefgh">>, {4, 1}), + ?assertEqual([<<"abcdefgh">>], lines(Out)). + +%%% --- frame ----------------------------------------------------------- + +frame_fills_full_size_test() -> + Out = beamtea_layout:frame(<<"hi">>, {20, 6}, #{padding => 0}), + Rows = lines(Out), + %% exactly Rows lines tall + ?assertEqual(6, length(Rows)), + %% every rendered row is exactly Cols visible columns wide + [?assertEqual(20, beamtea_util:visible_width(R)) || R <- Rows], + ok. + +frame_has_borders_test() -> + Out = iolist_to_binary(beamtea_layout:frame(<<"x">>, {12, 6}, #{})), + ?assertNotEqual(nomatch, binary:match(Out, <<"┌"/utf8>>)), + ?assertNotEqual(nomatch, binary:match(Out, <<"┐"/utf8>>)), + ?assertNotEqual(nomatch, binary:match(Out, <<"└"/utf8>>)), + ?assertNotEqual(nomatch, binary:match(Out, <<"┘"/utf8>>)), + ?assertNotEqual(nomatch, binary:match(Out, <<"x">>)). + +frame_places_content_inside_test() -> + %% content sits on the first inner row with padding=0 (border glyphs are + %% colour-wrapped, so compare with ANSI stripped) + Rows = lines(beamtea_layout:frame(<<"ab">>, {8, 4}, #{padding => 0})), + Row = beamtea_util:strip_ansi(lists:nth(2, Rows)), + ?assertMatch(<<"│ab"/utf8, _/binary>>, Row). + +frame_too_small_returns_content_test() -> + ?assertEqual(<<"hi">>, iolist_to_binary(beamtea_layout:frame(<<"hi">>, {2, 2}, #{}))).