Current section

Files

Jump to
telega src telega@dialog@widget.erl
Raw

src/telega@dialog@widget.erl

-module(telega@dialog@widget).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/telega/dialog/widget.gleam").
-export([current_page/1, pager/3, select/4, radio_value/1, radio/3, multiselect_values/1, multiselect/5, paged_select/5, store_data_key/2, stash_stores/1, clear_stash/0, widget_store/3, seed_store/3]).
-export_type([select_item/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Built-in managed keyboard widgets for dialogs.\n"
"\n"
" A widget renders extra button rows for a window and handles their events\n"
" itself (see `KeyboardWidget` in `telega/dialog/types`): the dialog engine\n"
" appends widget rows after the window's own buttons and routes widget\n"
" button presses (`w:<widget_id>:<cmd>` action ids) to the widget, bypassing\n"
" the window's `on_action`. Widget state lives in a per-widget `WidgetStore`\n"
" persisted with the dialog instance, so selections and page positions\n"
" survive restarts.\n"
"\n"
" Built-ins:\n"
"\n"
" - `pager` — page navigation row (`‹ 2/5 ›`); read the page with\n"
" `current_page` to slice content in the window's render.\n"
" - `select` — one-shot choice: a press calls `on_selected` (usually a\n"
" `Goto`).\n"
" - `radio` — single choice kept in the store, marked with\n"
" `labels.checked`; read with `radio_value`.\n"
" - `multiselect` — a set of choices with checkboxes and a `done` button\n"
" shown only while the selection count is within `min`/`max`; read with\n"
" `multiselect_values`.\n"
" - `paged_select` — `select` and `pager` combined: items are sliced by the\n"
" widget itself.\n"
"\n"
" Reading widget state from window handlers (`on_action`, `on_text`,\n"
" `on_done`) and renders goes through `dialog.widget_store`:\n"
"\n"
" ```gleam\n"
" let zone =\n"
" dialog.widget_store(ctx, window_id: \"prefs\", widget_id: \"zone\")\n"
" |> widget.radio_value\n"
" |> option.unwrap(\"hall\")\n"
" ```\n"
"\n"
" All user-facing symbols (`‹`, `›`, `●`, `☑`, \"done\") come from the\n"
" dialog's `Labels` — override them with `dialog.with_labels` for i18n.\n"
).
-type select_item() :: {select_item, binary(), binary()}.
-file("src/telega/dialog/widget.gleam", 334).
-spec action(binary(), binary()) -> binary().
action(Widget_id, Cmd) ->
<<<<<<"w:"/utf8, Widget_id/binary>>/binary, ":"/utf8>>/binary, Cmd/binary>>.
-file("src/telega/dialog/widget.gleam", 387).
-spec clamp_page(integer(), integer()) -> integer().
clamp_page(Page, Pages) ->
gleam@int:clamp(Page, 0, gleam@int:max(Pages - 1, 0)).
-file("src/telega/dialog/widget.gleam", 100).
?DOC(" The current 0-based page kept by a `pager`/`paged_select` store.\n").
-spec current_page(telega@dialog@types:widget_store()) -> integer().
current_page(Store) ->
_pipe = telega@dialog@types:store_get(Store, <<"page"/utf8>>),
_pipe@1 = gleam@option:map(_pipe, fun gleam_stdlib:parse_int/1),
_pipe@2 = gleam@option:map(_pipe@1, fun gleam@option:from_result/1),
_pipe@3 = gleam@option:flatten(_pipe@2),
gleam@option:unwrap(_pipe@3, 0).
-file("src/telega/dialog/widget.gleam", 391).
-spec turn_page(telega@dialog@types:widget_store(), binary(), integer()) -> telega@dialog@types:widget_store().
turn_page(Store, Cmd, Pages) ->
Page = current_page(Store),
Page@1 = case Cmd of
<<"prev"/utf8>> ->
Page - 1;
<<"next"/utf8>> ->
Page + 1;
_ ->
Page
end,
telega@dialog@types:store_set(
Store,
<<"page"/utf8>>,
erlang:integer_to_binary(clamp_page(Page@1, Pages))
).
-file("src/telega/dialog/widget.gleam", 380).
-spec page_count(integer(), integer()) -> integer().
page_count(Total, Page_size) ->
case (Total =< 0) orelse (Page_size =< 0) of
true ->
1;
false ->
case Page_size of
0 -> 0;
Gleam@denominator -> ((Total + Page_size) - 1) div Gleam@denominator
end
end.
-file("src/telega/dialog/widget.gleam", 401).
-spec pager_row(binary(), integer(), integer(), telega@dialog@types:labels()) -> list(list(telega@dialog@types:dialog_button())).
pager_row(Widget_id, Page, Pages, Labels) ->
case Pages > 1 of
false ->
[];
true ->
Info = begin
_pipe = erlang:element(4, Labels),
_pipe@1 = gleam@string:replace(
_pipe,
<<"{current}"/utf8>>,
erlang:integer_to_binary(Page + 1)
),
gleam@string:replace(
_pipe@1,
<<"{total}"/utf8>>,
erlang:integer_to_binary(Pages)
)
end,
[[{action_button,
erlang:element(2, Labels),
action(Widget_id, <<"prev"/utf8>>)},
{noop_button, Info},
{action_button,
erlang:element(3, Labels),
action(Widget_id, <<"next"/utf8>>)}]]
end.
-file("src/telega/dialog/widget.gleam", 74).
?DOC(
" Page navigation: the store keeps the current page, the row renders as\n"
" `‹ 2/5 ›` (the counter is a no-op button). `total` returns the number of\n"
" items; the row disappears when everything fits on one page. Slice your\n"
" content in the window's render with `current_page`.\n"
).
-spec pager(
binary(),
integer(),
fun((AWKJ, telega@bot:context(AWKK, AWKL, AWKM)) -> integer())
) -> telega@dialog@types:keyboard_widget(AWKJ, AWKK, AWKL, AWKM).
pager(Id, Page_size, Total) ->
{keyboard_widget,
Id,
fun(Wctx) ->
Pages = page_count(
Total(erlang:element(2, Wctx), erlang:element(5, Wctx)),
Page_size
),
pager_row(
Id,
clamp_page(current_page(erlang:element(3, Wctx)), Pages),
Pages,
erlang:element(4, Wctx)
)
end,
fun(Wctx@1, Cmd, _) ->
Pages@1 = page_count(
Total(erlang:element(2, Wctx@1), erlang:element(5, Wctx@1)),
Page_size
),
{ok,
{store_updated,
turn_page(erlang:element(3, Wctx@1), Cmd, Pages@1)}}
end,
[],
[action(Id, <<"prev"/utf8>>), action(Id, <<"next"/utf8>>)]}.
-file("src/telega/dialog/widget.gleam", 372).
-spec offers(
fun((AWOE, telega@bot:context(AWOF, AWOG, AWOH)) -> list(select_item())),
telega@dialog@types:widget_ctx(AWOE, AWOF, AWOG, AWOH),
binary()
) -> boolean().
offers(Items, Wctx, Item_id) ->
gleam@list:any(
Items(erlang:element(2, Wctx), erlang:element(5, Wctx)),
fun(Item) -> erlang:element(2, Item) =:= Item_id end
).
-file("src/telega/dialog/widget.gleam", 353).
?DOC(
" A picked id is only trusted if the widget currently offers it — callback\n"
" data arrives from the client and can be forged or outdated, so an unknown\n"
" id is ignored (re-render) instead of reaching `on_selected`.\n"
).
-spec pick_or_ignore(
telega@dialog@types:widget_ctx(AWNI, AWNJ, AWNK, AWNL),
binary(),
gleam@option:option(binary()),
fun((AWNI, telega@bot:context(AWNJ, AWNK, AWNL)) -> list(select_item())),
fun((AWNI, binary(), telega@bot:context(AWNJ, AWNK, AWNL)) -> {ok,
telega@dialog@types:dialog_action(AWNI)} |
{error, AWNK})
) -> {ok, telega@dialog@types:widget_result(AWNI)} | {error, AWNK}.
pick_or_ignore(Wctx, Cmd, Arg, Items, On_selected) ->
case {Cmd, Arg} of
{<<"pick"/utf8>>, {some, Item_id}} ->
case offers(Items, Wctx, Item_id) of
true ->
_pipe = On_selected(
erlang:element(2, Wctx),
Item_id,
erlang:element(5, Wctx)
),
gleam@result:map(_pipe, fun(Field@0) -> {emit, Field@0} end);
false ->
{ok, {store_updated, erlang:element(3, Wctx)}}
end;
{_, _} ->
{ok, {store_updated, erlang:element(3, Wctx)}}
end.
-file("src/telega/dialog/widget.gleam", 338).
-spec item_grid(list(select_item()), binary(), integer()) -> list(list(telega@dialog@types:dialog_button())).
item_grid(Items, Widget_id, Columns) ->
_pipe = Items,
_pipe@1 = gleam@list:map(
_pipe,
fun(Item) ->
{action_arg_button,
erlang:element(3, Item),
action(Widget_id, <<"pick"/utf8>>),
erlang:element(2, Item)}
end
),
gleam@list:sized_chunk(_pipe@1, gleam@int:max(Columns, 1)).
-file("src/telega/dialog/widget.gleam", 114).
?DOC(
" One-shot choice: each item is a button, a press calls `on_selected` with\n"
" the item id and the resulting `DialogAction` is applied (usually a\n"
" `Goto`). Nothing is stored. `columns` lays the buttons out in a grid\n"
" (`1` = one item per row).\n"
).
-spec select(
binary(),
fun((AWKU, telega@bot:context(AWKV, AWKW, AWKX)) -> list(select_item())),
integer(),
fun((AWKU, binary(), telega@bot:context(AWKV, AWKW, AWKX)) -> {ok,
telega@dialog@types:dialog_action(AWKU)} |
{error, AWKW})
) -> telega@dialog@types:keyboard_widget(AWKU, AWKV, AWKW, AWKX).
select(Id, Items, Columns, On_selected) ->
{keyboard_widget,
Id,
fun(Wctx) ->
_pipe = Items(erlang:element(2, Wctx), erlang:element(5, Wctx)),
item_grid(_pipe, Id, Columns)
end,
fun(Wctx@1, Cmd, Arg) ->
pick_or_ignore(Wctx@1, Cmd, Arg, Items, On_selected)
end,
[],
[action(Id, <<"pick"/utf8>>)]}.
-file("src/telega/dialog/widget.gleam", 189).
?DOC(" The item id picked in a `radio` store, `None` until the first press.\n").
-spec radio_value(telega@dialog@types:widget_store()) -> gleam@option:option(binary()).
radio_value(Store) ->
telega@dialog@types:store_get(Store, <<"value"/utf8>>).
-file("src/telega/dialog/widget.gleam", 145).
?DOC(
" Single choice kept in the widget store: the selected item is marked with\n"
" `labels.checked`, the rest with `labels.unchecked`. `default` is only a\n"
" visual pre-selection — `radio_value` stays `None` until the user actually\n"
" picks, so apply the same default when reading.\n"
).
-spec radio(
binary(),
fun((AWLM, telega@bot:context(AWLN, AWLO, AWLP)) -> list(select_item())),
gleam@option:option(binary())
) -> telega@dialog@types:keyboard_widget(AWLM, AWLN, AWLO, AWLP).
radio(Id, Items, Default) ->
{keyboard_widget,
Id,
fun(Wctx) ->
Selected = begin
_pipe = radio_value(erlang:element(3, Wctx)),
gleam@option:'or'(_pipe, Default)
end,
_pipe@1 = Items(erlang:element(2, Wctx), erlang:element(5, Wctx)),
gleam@list:map(
_pipe@1,
fun(Item) ->
Mark = case Selected =:= {some, erlang:element(2, Item)} of
true ->
erlang:element(5, erlang:element(4, Wctx));
false ->
erlang:element(6, erlang:element(4, Wctx))
end,
[{action_arg_button,
<<Mark/binary, (erlang:element(3, Item))/binary>>,
action(Id, <<"pick"/utf8>>),
erlang:element(2, Item)}]
end
)
end,
fun(Wctx@1, Cmd, Arg) -> case {Cmd, Arg} of
{<<"pick"/utf8>>, {some, Item_id}} ->
case offers(Items, Wctx@1, Item_id) of
true ->
{ok,
{store_updated,
telega@dialog@types:store_set(
erlang:element(3, Wctx@1),
<<"value"/utf8>>,
Item_id
)}};
false ->
{ok, {store_updated, erlang:element(3, Wctx@1)}}
end;
{_, _} ->
{ok, {store_updated, erlang:element(3, Wctx@1)}}
end end,
[],
[action(Id, <<"pick"/utf8>>)]}.
-file("src/telega/dialog/widget.gleam", 257).
?DOC(" The item ids currently selected in a `multiselect` store, in pick order.\n").
-spec multiselect_values(telega@dialog@types:widget_store()) -> list(binary()).
multiselect_values(Store) ->
_pipe = telega@dialog@types:store_get(Store, <<"values"/utf8>>),
_pipe@2 = gleam@option:map(
_pipe,
fun(Raw) ->
_pipe@1 = gleam@json:parse(
Raw,
gleam@dynamic@decode:list(
{decoder, fun gleam@dynamic@decode:decode_string/1}
)
),
gleam@result:unwrap(_pipe@1, [])
end
),
gleam@option:unwrap(_pipe@2, []).
-file("src/telega/dialog/widget.gleam", 283).
-spec within(integer(), integer(), integer()) -> boolean().
within(Count, Min, Max) ->
(Min =< Count) andalso (Count =< Max).
-file("src/telega/dialog/widget.gleam", 266).
-spec toggle(telega@dialog@types:widget_store(), binary(), integer()) -> telega@dialog@types:widget_store().
toggle(Store, Item_id, Max) ->
Values = multiselect_values(Store),
Values@1 = case gleam@list:contains(Values, Item_id) of
true ->
gleam@list:filter(Values, fun(Value) -> Value /= Item_id end);
false ->
case erlang:length(Values) >= Max of
true ->
Values;
false ->
lists:append(Values, [Item_id])
end
end,
telega@dialog@types:store_set(
Store,
<<"values"/utf8>>,
gleam@json:to_string(
gleam@json:array(Values@1, fun gleam@json:string/1)
)
).
-file("src/telega/dialog/widget.gleam", 199).
?DOC(
" A set of choices with checkbox marks. Toggling above `max` is ignored;\n"
" the `labels.done` button renders only while the count is within\n"
" `min`/`max` and emits `Goto(done, state)` when pressed. `done` must be an\n"
" existing window id — validated by `dialog.build()`.\n"
).
-spec multiselect(
binary(),
fun((AWMA, telega@bot:context(AWMB, AWMC, AWMD)) -> list(select_item())),
integer(),
integer(),
binary()
) -> telega@dialog@types:keyboard_widget(AWMA, AWMB, AWMC, AWMD).
multiselect(Id, Items, Min, Max, Done) ->
{keyboard_widget,
Id,
fun(Wctx) ->
Selected = multiselect_values(erlang:element(3, Wctx)),
Rows = begin
_pipe = Items(erlang:element(2, Wctx), erlang:element(5, Wctx)),
gleam@list:map(
_pipe,
fun(Item) ->
Mark = case gleam@list:contains(
Selected,
erlang:element(2, Item)
) of
true ->
erlang:element(7, erlang:element(4, Wctx));
false ->
erlang:element(8, erlang:element(4, Wctx))
end,
[{action_arg_button,
<<Mark/binary,
(erlang:element(3, Item))/binary>>,
action(Id, <<"tgl"/utf8>>),
erlang:element(2, Item)}]
end
)
end,
case within(erlang:length(Selected), Min, Max) of
true ->
lists:append(
Rows,
[[{action_button,
erlang:element(9, erlang:element(4, Wctx)),
action(Id, <<"done"/utf8>>)}]]
);
false ->
Rows
end
end,
fun(Wctx@1, Cmd, Arg) -> case {Cmd, Arg} of
{<<"tgl"/utf8>>, {some, Item_id}} ->
case offers(Items, Wctx@1, Item_id) of
true ->
{ok,
{store_updated,
toggle(
erlang:element(3, Wctx@1),
Item_id,
Max
)}};
false ->
{ok, {store_updated, erlang:element(3, Wctx@1)}}
end;
{<<"done"/utf8>>, _} ->
case within(
erlang:length(
multiselect_values(erlang:element(3, Wctx@1))
),
Min,
Max
) of
true ->
{ok,
{emit, {goto, Done, erlang:element(2, Wctx@1)}}};
false ->
{ok, {store_updated, erlang:element(3, Wctx@1)}}
end;
{_, _} ->
{ok, {store_updated, erlang:element(3, Wctx@1)}}
end end,
[Done],
[action(Id, <<"tgl"/utf8>>), action(Id, <<"done"/utf8>>)]}.
-file("src/telega/dialog/widget.gleam", 292).
?DOC(
" `select` and `pager` in one widget: items are sliced to the current page\n"
" by the widget itself (`page_size` counts items, not rows), the pager row\n"
" appears only when there is more than one page.\n"
).
-spec paged_select(
binary(),
fun((AWMN, telega@bot:context(AWMO, AWMP, AWMQ)) -> list(select_item())),
integer(),
integer(),
fun((AWMN, binary(), telega@bot:context(AWMO, AWMP, AWMQ)) -> {ok,
telega@dialog@types:dialog_action(AWMN)} |
{error, AWMP})
) -> telega@dialog@types:keyboard_widget(AWMN, AWMO, AWMP, AWMQ).
paged_select(Id, Items, Page_size, Columns, On_selected) ->
{keyboard_widget,
Id,
fun(Wctx) ->
All = Items(erlang:element(2, Wctx), erlang:element(5, Wctx)),
Pages = page_count(erlang:length(All), Page_size),
Page = clamp_page(current_page(erlang:element(3, Wctx)), Pages),
Rows = begin
_pipe = All,
_pipe@1 = gleam@list:drop(_pipe, Page * Page_size),
_pipe@2 = gleam@list:take(_pipe@1, Page_size),
item_grid(_pipe@2, Id, Columns)
end,
lists:append(
Rows,
pager_row(Id, Page, Pages, erlang:element(4, Wctx))
)
end,
fun(Wctx@1, Cmd, Arg) -> case Cmd of
<<"pick"/utf8>> ->
pick_or_ignore(Wctx@1, Cmd, Arg, Items, On_selected);
_ ->
Pages@1 = page_count(
erlang:length(
Items(
erlang:element(2, Wctx@1),
erlang:element(5, Wctx@1)
)
),
Page_size
),
{ok,
{store_updated,
turn_page(erlang:element(3, Wctx@1), Cmd, Pages@1)}}
end end,
[],
[action(Id, <<"pick"/utf8>>),
action(Id, <<"prev"/utf8>>),
action(Id, <<"next"/utf8>>)]}.
-file("src/telega/dialog/widget.gleam", 440).
?DOC(false).
-spec store_data_key(binary(), binary()) -> binary().
store_data_key(Window_id, Widget_id) ->
<<<<<<"__dialog_widget:"/utf8, Window_id/binary>>/binary, ":"/utf8>>/binary,
Widget_id/binary>>.
-file("src/telega/dialog/widget.gleam", 449).
?DOC(false).
-spec stash_stores(gleam@dict:dict(binary(), binary())) -> nil.
stash_stores(Data) ->
_ = erlang:put(<<"__telega_dialog_widget_stores"/utf8>>, Data),
nil.
-file("src/telega/dialog/widget.gleam", 457).
?DOC(false).
-spec clear_stash() -> nil.
clear_stash() ->
_ = erlang:erase(<<"__telega_dialog_widget_stores"/utf8>>),
nil.
-file("src/telega/dialog/widget.gleam", 493).
-spec stashed_stores() -> gleam@dict:dict(binary(), binary()).
stashed_stores() ->
_pipe = erlang:get(<<"__telega_dialog_widget_stores"/utf8>>),
_pipe@1 = gleam@dynamic@decode:run(
_pipe,
gleam@dynamic@decode:dict(
{decoder, fun gleam@dynamic@decode:decode_string/1},
{decoder, fun gleam@dynamic@decode:decode_string/1}
)
),
gleam@result:unwrap(_pipe@1, maps:new()).
-file("src/telega/dialog/widget.gleam", 465).
?DOC(
" Read a widget's store from inside a window render or handler. Returns an\n"
" empty store when the widget has no state yet. Prefer the typed readers on\n"
" top: `radio_value`, `multiselect_values`, `current_page`.\n"
).
-spec widget_store(telega@bot:context(any(), any(), any()), binary(), binary()) -> telega@dialog@types:widget_store().
widget_store(_, Window_id, Widget_id) ->
_pipe = stashed_stores(),
_pipe@1 = gleam_stdlib:map_get(_pipe, store_data_key(Window_id, Widget_id)),
_pipe@2 = gleam@result:'try'(
_pipe@1,
fun(Raw) -> telega@dialog@types:decode_store(Raw) end
),
gleam@result:unwrap(_pipe@2, telega@dialog@types:new_store()).
-file("src/telega/dialog/widget.gleam", 478).
?DOC(
" Seed a widget store for pure render tests — the runtime equivalent is the\n"
" engine's automatic stash before user code. Merges into the current stash.\n"
).
-spec seed_store(binary(), binary(), telega@dialog@types:widget_store()) -> nil.
seed_store(Window_id, Widget_id, Store) ->
Stores = gleam@dict:insert(
stashed_stores(),
store_data_key(Window_id, Widget_id),
telega@dialog@types:encode_store(Store)
),
_ = erlang:put(<<"__telega_dialog_widget_stores"/utf8>>, Stores),
nil.