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}, #{}))).