summaryrefslogtreecommitdiff
path: root/lib/chessh/ssh/client
diff options
context:
space:
mode:
authorSimponic <loganhunt@simponic.xyz>2023-01-20 23:12:23 -0700
committerSimponic <loganhunt@simponic.xyz>2023-01-20 23:12:23 -0700
commit06f1ca76037397fb61c69319802ed029ac73e715 (patch)
tree37cdf1ebf5fcbaefda58d377bb6b4e3a8a11410b /lib/chessh/ssh/client
parente0058fedfb191db8802a95548cbaf96b6fe58f80 (diff)
downloadchessh-06f1ca76037397fb61c69319802ed029ac73e715.tar.gz
chessh-06f1ca76037397fb61c69319802ed029ac73e715.zip
Add pagination menus
Diffstat (limited to 'lib/chessh/ssh/client')
-rw-r--r--lib/chessh/ssh/client/client.ex4
-rw-r--r--lib/chessh/ssh/client/game/game.ex5
-rw-r--r--lib/chessh/ssh/client/game/renderer.ex103
-rw-r--r--lib/chessh/ssh/client/menu.ex144
-rw-r--r--lib/chessh/ssh/client/menus/game_selector.ex24
-rw-r--r--lib/chessh/ssh/client/menus/main_menu.ex45
-rw-r--r--lib/chessh/ssh/client/menus/select_current_game.ex85
-rw-r--r--lib/chessh/ssh/client/menus/select_joinable_game.ex87
-rw-r--r--lib/chessh/ssh/client/menus/select_paginate_poller.ex263
9 files changed, 585 insertions, 175 deletions
diff --git a/lib/chessh/ssh/client/client.ex b/lib/chessh/ssh/client/client.ex
index 3aaed07..5c45294 100644
--- a/lib/chessh/ssh/client/client.ex
+++ b/lib/chessh/ssh/client/client.ex
@@ -25,8 +25,8 @@ defmodule Chessh.SSH.Client do
def init([%State{player_session: player_session} = state]) do
send(
self(),
- {:set_screen_process, Chessh.SSH.Client.Menu,
- %Chessh.SSH.Client.Menu.State{player_session: player_session}}
+ {:set_screen_process, Chessh.SSH.Client.MainMenu,
+ %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}
)
{:ok, state}
diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex
index 9dbde7f..4e2f6ae 100644
--- a/lib/chessh/ssh/client/game/game.ex
+++ b/lib/chessh/ssh/client/game/game.ex
@@ -171,6 +171,11 @@ defmodule Chessh.SSH.Client.Game do
{:noreply, new_state}
end
+ def handle_info(x, state) do
+ Logger.debug("unknown message in game process #{inspect(x)}")
+ {:noreply, state}
+ end
+
def input(
width,
height,
diff --git a/lib/chessh/ssh/client/game/renderer.ex b/lib/chessh/ssh/client/game/renderer.ex
index d7c5b26..5b85a3b 100644
--- a/lib/chessh/ssh/client/game/renderer.ex
+++ b/lib/chessh/ssh/client/game/renderer.ex
@@ -2,7 +2,6 @@ defmodule Chessh.SSH.Client.Game.Renderer do
alias IO.ANSI
alias Chessh.{Utils, Player}
alias Chessh.SSH.Client.Game
- require Logger
@chess_board_height 8
@chess_board_width 8
@@ -64,36 +63,11 @@ defmodule Chessh.SSH.Client.Game.Renderer do
height: _height,
highlighted: highlighted,
flipped: flipped,
- game: %Chessh.Game{
- dark_player: %Player{username: dark_player},
- light_player: %Player{username: light_player},
- turn: turn,
- status: status,
- winner: winner
- }
+ game: %Chessh.Game{} = game
}) do
rendered = [
- Enum.join(
- [
- ANSI.clear_line(),
- ANSI.format_fragment([@light_piece_color, light_player]),
- "#{ANSI.default_color()} --vs-- ",
- ANSI.format_fragment([@dark_piece_color, dark_player]),
- ANSI.default_color(),
- case status do
- :continue ->
- ", #{ANSI.format_fragment([if(turn == :light, do: @light_piece_color, else: @dark_piece_color), if(turn == :dark, do: dark_player, else: light_player)])} to move"
-
- :draw ->
- "ended in a draw"
-
- :winner ->
- ", #{ANSI.format_fragment([if(winner == :light, do: @light_piece_color, else: @dark_piece_color), if(winner == :dark, do: dark_player, else: light_player)])} won!"
- end,
- ANSI.default_color()
- ],
- ""
- )
+ ANSI.clear_line(),
+ make_status_line(game, true)
| draw_board(
fen,
{@tile_width, @tile_height},
@@ -111,6 +85,77 @@ defmodule Chessh.SSH.Client.Game.Renderer do
)
end
+ def make_status_line(
+ %Chessh.Game{
+ light_player: light_player
+ } = game,
+ fancy
+ )
+ when is_nil(light_player),
+ do:
+ make_status_line(
+ %Chessh.Game{game | light_player: %Player{username: "(no opponent)"}},
+ fancy
+ )
+
+ def make_status_line(
+ %Chessh.Game{
+ dark_player: dark_player
+ } = game,
+ fancy
+ )
+ when is_nil(dark_player),
+ do:
+ make_status_line(
+ %Chessh.Game{game | dark_player: %Player{username: "(no opponent)"}},
+ fancy
+ )
+
+ def make_status_line(
+ %Chessh.Game{
+ id: game_id,
+ dark_player: %Player{username: dark_player},
+ light_player: %Player{username: light_player},
+ turn: turn,
+ status: status,
+ winner: winner,
+ moves: moves
+ },
+ fancy
+ ) do
+ Enum.join(
+ [
+ if(fancy,
+ do: ANSI.clear_line(),
+ else: ""
+ ),
+ "Game #{game_id} - ",
+ if(fancy,
+ do: ANSI.format_fragment([@light_piece_color, light_player]),
+ else: "#{light_player} (L)"
+ ),
+ "#{if fancy, do: ANSI.default_color(), else: ""} --vs-- ",
+ if(fancy,
+ do: ANSI.format_fragment([@dark_piece_color, dark_player]),
+ else: "#{dark_player} (D)"
+ ),
+ if(fancy, do: ANSI.default_color(), else: ""),
+ case status do
+ :continue ->
+ ", #{moves} moves, #{ANSI.format_fragment([if(fancy, do: if(turn == :light, do: @light_piece_color, else: @dark_piece_color), else: ""), if(turn == :dark, do: dark_player, else: light_player)])} to move"
+
+ :draw ->
+ "ended in a draw after #{moves} moves"
+
+ :winner ->
+ ", #{ANSI.format_fragment([if(fancy, do: if(winner == :light, do: @light_piece_color, else: @dark_piece_color), else: ""), if(winner == :dark, do: dark_player, else: light_player)])} won after #{moves} moves!"
+ end,
+ if(fancy, do: ANSI.default_color(), else: "")
+ ],
+ ""
+ )
+ end
+
defp draw_board(
fen,
{tile_width, tile_height} = tile_dims,
diff --git a/lib/chessh/ssh/client/menu.ex b/lib/chessh/ssh/client/menu.ex
deleted file mode 100644
index b69340c..0000000
--- a/lib/chessh/ssh/client/menu.ex
+++ /dev/null
@@ -1,144 +0,0 @@
-defmodule Chessh.SSH.Client.Menu do
- alias IO.ANSI
- alias Chessh.{Utils, Repo, Game}
- import Ecto.Query
-
- require Logger
-
- defmodule State do
- defstruct client_pid: nil,
- selected: 0,
- player_session: nil,
- options: []
- end
-
- @logo " Simponic's
- dP MP\"\"\"\"\"\"`MM MP\"\"\"\"\"\"`MM M\"\"MMMMM\"\"MM
- 88 M mmmmm..M M mmmmm..M M MMMMM MM
-.d8888b. 88d888b. .d8888b. M. `YM M. `YM M `M
-88' `\"\" 88' `88 88ooood8 MMMMMMM. M MMMMMMM. M M MMMMM MM
-88. ... 88 88 88. ... M. .MMM' M M. .MMM' M M MMMMM MM
-`88888P' dP dP `88888P' Mb. .dM Mb. .dM M MMMMM MM
- MMMMMMMMMMM MMMMMMMMMMM MMMMMMMMMMMM"
- use Chessh.SSH.Client.Screen
-
- def init([%State{} = state | _]) do
- {:ok, %State{state | options: options(state)}}
- end
-
- def options(%State{player_session: player_session}) do
- current_games =
- Repo.all(
- from(g in Game,
- where: g.light_player_id == ^player_session.player_id,
- or_where: g.dark_player_id == ^player_session.player_id,
- where: g.status == :continue
- )
- )
-
- joinable_games =
- Repo.all(
- from(g in Game,
- where: is_nil(g.light_player_id),
- or_where: is_nil(g.dark_player_id)
- )
- )
-
- [
- {"Start A Game As Light",
- {Chessh.SSH.Client.Game,
- %Chessh.SSH.Client.Game.State{player_session: player_session, color: :light}}},
- {"Start A Game As Dark",
- {Chessh.SSH.Client.Game,
- %Chessh.SSH.Client.Game.State{player_session: player_session, color: :dark}}}
- ] ++
- Enum.map(current_games, fn game ->
- {"Current Game - #{game.id}",
- {Chessh.SSH.Client.Game,
- %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}}
- end) ++
- Enum.map(joinable_games, fn game ->
- {"Joinable Game - #{game.id}",
- {Chessh.SSH.Client.Game,
- %Chessh.SSH.Client.Game.State{player_session: player_session, game: game}}}
- end) ++
- [
- {"Help", {}}
- ]
- end
-
- def input(
- width,
- height,
- action,
- %State{options: options, client_pid: client_pid, selected: selected} = state
- ) do
- new_state =
- case(action) do
- :up ->
- %State{
- state
- | selected: Utils.wrap_around(selected, -1, length(options))
- }
-
- :down ->
- %State{state | selected: Utils.wrap_around(selected, 1, length(options))}
-
- :return ->
- {_option, {module, state}} = Enum.at(options, selected)
- send(client_pid, {:set_screen_process, module, state})
- state
-
- _ ->
- state
- end
-
- if !(action == :return) do
- send(client_pid, {:send_to_ssh, render_state(width, height, new_state)})
- end
-
- new_state
- end
-
- def render(width, height, %State{client_pid: client_pid} = state) do
- send(client_pid, {:send_to_ssh, render_state(width, height, state)})
- state
- end
-
- defp render_state(
- width,
- height,
- %State{options: options, selected: selected}
- ) do
- logo_lines = String.split(@logo, "\n")
- {logo_width, logo_height} = Utils.text_dim(@logo)
- {y, x} = Utils.center_rect({logo_width, logo_height + length(logo_lines)}, {width, height})
-
- [ANSI.clear()] ++
- Enum.flat_map(
- Enum.zip(1..length(logo_lines), logo_lines),
- fn {i, line} ->
- [
- ANSI.cursor(y + i, x),
- line
- ]
- end
- ) ++
- Enum.flat_map(
- Enum.zip(0..(length(options) - 1), options),
- fn {i, {option, _}} ->
- [
- ANSI.cursor(y + length(logo_lines) + i + 1, x),
- if(i == selected,
- do:
- ANSI.format_fragment(
- [:light_cyan, :bright, "> #{option} <", :reset],
- true
- ),
- else: option
- )
- ]
- end
- ) ++ [ANSI.home()]
- end
-end
diff --git a/lib/chessh/ssh/client/menus/game_selector.ex b/lib/chessh/ssh/client/menus/game_selector.ex
new file mode 100644
index 0000000..7792082
--- /dev/null
+++ b/lib/chessh/ssh/client/menus/game_selector.ex
@@ -0,0 +1,24 @@
+defmodule Chessh.SSH.Client.GameSelector do
+ import Ecto.Query
+ alias Chessh.Repo
+
+ def paginate_ish_query(query, current_id, direction) do
+ sorted_query =
+ if direction == :desc,
+ do: from(g in query, order_by: [desc: g.id]),
+ else: from(g in query, order_by: [asc: g.id])
+
+ results =
+ if !is_nil(current_id) do
+ if direction == :desc,
+ do: from(g in sorted_query, where: g.id < ^current_id),
+ else: from(g in sorted_query, where: g.id > ^current_id)
+ else
+ sorted_query
+ end
+ |> Repo.all()
+ |> Repo.preload([:light_player, :dark_player])
+
+ if direction == :desc, do: results, else: Enum.reverse(results)
+ end
+end
diff --git a/lib/chessh/ssh/client/menus/main_menu.ex b/lib/chessh/ssh/client/menus/main_menu.ex
new file mode 100644
index 0000000..167a0ef
--- /dev/null
+++ b/lib/chessh/ssh/client/menus/main_menu.ex
@@ -0,0 +1,45 @@
+defmodule Chessh.SSH.Client.MainMenu do
+ alias IO.ANSI
+ alias Chessh.PlayerSession
+
+ require Logger
+
+ @logo " Simponic's
+ dP MP\"\"\"\"\"\"`MM MP\"\"\"\"\"\"`MM M\"\"MMMMM\"\"MM
+ 88 M mmmmm..M M mmmmm..M M MMMMM MM
+.d8888b. 88d888b. .d8888b. M. `YM M. `YM M `M
+88' `\"\" 88' `88 88ooood8 MMMMMMM. M MMMMMMM. M M MMMMM MM
+88. ... 88 88 88. ... M. .MMM' M M. .MMM' M M MMMMM MM
+`88888P' dP dP `88888P' Mb. .dM Mb. .dM M MMMMM MM
+ MMMMMMMMMMM MMMMMMMMMMM MMMMMMMMMMMM" |> String.split("\n")
+ @logo_cols @logo |> Enum.map(&String.length/1) |> Enum.max()
+
+ use Chessh.SSH.Client.SelectPaginatePoller
+
+ def dynamic_options(), do: false
+ def tick_delay_ms(), do: 1000
+ def max_displayed_options(), do: 4
+ def max_box_cols(), do: @logo_cols
+ def title(), do: @logo
+
+ def initial_options(%State{player_session: %PlayerSession{} = player_session}) do
+ [
+ {"Start A Game (Light)",
+ {Chessh.SSH.Client.Game,
+ %Chessh.SSH.Client.Game.State{player_session: player_session, color: :light}}},
+ {"Start A Game (Dark)",
+ {Chessh.SSH.Client.Game,
+ %Chessh.SSH.Client.Game.State{player_session: player_session, color: :dark}}},
+ {"Current Games",
+ {Chessh.SSH.Client.SelectCurrentGame,
+ %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}},
+ {"Joinable Games (lobby)",
+ {Chessh.SSH.Client.SelectJoinableGame,
+ %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}}
+ ]
+ end
+
+ def make_process_tuple(selected, _state) do
+ selected
+ end
+end
diff --git a/lib/chessh/ssh/client/menus/select_current_game.ex b/lib/chessh/ssh/client/menus/select_current_game.ex
new file mode 100644
index 0000000..ff1eb30
--- /dev/null
+++ b/lib/chessh/ssh/client/menus/select_current_game.ex
@@ -0,0 +1,85 @@
+defmodule Chessh.SSH.Client.SelectCurrentGame do
+ alias Chessh.{Utils, Repo, Game, PlayerSession}
+ alias Chessh.SSH.Client.GameSelector
+ import Ecto.Query
+ require Logger
+
+ use Chessh.SSH.Client.SelectPaginatePoller
+
+ def refresh_options_ms(), do: 4000
+ def max_displayed_options(), do: 10
+ def title(), do: ["-- Current Games --"]
+ def dynamic_options(), do: true
+
+ def get_player_sorted_current_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do
+ GameSelector.paginate_ish_query(
+ Game
+ |> where([g], g.status == :continue)
+ |> where([g], g.light_player_id == ^player_id or g.dark_player_id == ^player_id)
+ |> limit(^max_displayed_options()),
+ current_id,
+ direction
+ )
+ end
+
+ def format_game_selection_tuple(%Game{id: game_id} = game) do
+ {Chessh.SSH.Client.Game.Renderer.make_status_line(game, false), game_id}
+ end
+
+ def next_page_options(%State{
+ options: options,
+ player_session: %PlayerSession{player_id: player_id}
+ }) do
+ {_label, previous_last_game_id} = List.last(options)
+ next_games = get_player_sorted_current_games_with_id(player_id, previous_last_game_id, :desc)
+
+ if length(next_games) > 0,
+ do:
+ next_games
+ |> Enum.map(&format_game_selection_tuple/1),
+ else: options
+ end
+
+ def previous_page_options(%State{
+ options: options,
+ player_session: %PlayerSession{player_id: player_id}
+ }) do
+ {_label, previous_first_game_id} = List.first(options)
+
+ previous_games =
+ get_player_sorted_current_games_with_id(player_id, previous_first_game_id, :asc)
+
+ if length(previous_games) > 0,
+ do:
+ previous_games
+ |> Enum.map(&format_game_selection_tuple/1),
+ else: options
+ end
+
+ def initial_options(%State{player_session: %PlayerSession{player_id: player_id}}) do
+ get_player_sorted_current_games_with_id(player_id)
+ |> Enum.map(&format_game_selection_tuple/1)
+ end
+
+ def refresh_options(%State{options: options}) do
+ from(g in Game,
+ where: g.id in ^Enum.map(options, fn {_, id} -> id end),
+ order_by: [desc: g.id]
+ )
+ |> Repo.all()
+ |> Repo.preload([:light_player, :dark_player])
+ |> Enum.map(&format_game_selection_tuple/1)
+ end
+
+ def make_process_tuple(selected_id, %State{
+ player_session: player_session
+ }) do
+ game = Repo.get(Game, selected_id)
+
+ {Chessh.SSH.Client.Game,
+ %Chessh.SSH.Client.Game.State{
+ player_session: player_session,
+ game: game
+ }}
+ end
+end
diff --git a/lib/chessh/ssh/client/menus/select_joinable_game.ex b/lib/chessh/ssh/client/menus/select_joinable_game.ex
new file mode 100644
index 0000000..3b3e249
--- /dev/null
+++ b/lib/chessh/ssh/client/menus/select_joinable_game.ex
@@ -0,0 +1,87 @@
+defmodule Chessh.SSH.Client.SelectJoinableGame do
+ alias Chessh.{Utils, Repo, Game, PlayerSession}
+ alias Chessh.SSH.Client.GameSelector
+ import Ecto.Query
+
+ use Chessh.SSH.Client.SelectPaginatePoller
+
+ def refresh_options_ms(), do: 4000
+ def max_displayed_options(), do: 1
+ def title(), do: ["-- Joinable Games --"]
+ def dynamic_options(), do: true
+
+ def get_player_joinable_games_with_id(player_id, current_id \\ nil, direction \\ :desc) do
+ GameSelector.paginate_ish_query(
+ Game
+ |> where([g], g.status == :continue)
+ |> where(
+ [g],
+ (is_nil(g.dark_player_id) or g.dark_player_id != ^player_id) and
+ (is_nil(g.light_player_id) or g.light_player_id != ^player_id)
+ )
+ |> limit(^max_displayed_options()),
+ current_id,
+ direction
+ )
+ end
+
+ def format_game_selection_tuple(%Game{id: game_id} = game) do
+ {Chessh.SSH.Client.Game.Renderer.make_status_line(game, false), game_id}
+ end
+
+ def next_page_options(%State{
+ options: options,
+ player_session: %PlayerSession{player_id: player_id}
+ }) do
+ {_label, previous_last_game_id} = List.last(options)
+ next_games = get_player_joinable_games_with_id(player_id, previous_last_game_id, :desc)
+
+ if length(next_games) > 0,
+ do:
+ next_games
+ |> Enum.map(&format_game_selection_tuple/1),
+ else: options
+ end
+
+ def previous_page_options(%State{
+ options: options,
+ player_session: %PlayerSession{player_id: player_id}
+ }) do
+ {_label, previous_first_game_id} = List.first(options)
+
+ previous_games = get_player_joinable_games_with_id(player_id, previous_first_game_id, :asc)
+
+ if length(previous_games) > 0,
+ do:
+ previous_games
+ |> Enum.map(&format_game_selection_tuple/1),
+ else: options
+ end
+
+ def initial_options(%State{player_session: %PlayerSession{player_id: player_id}}) do
+ get_player_joinable_games_with_id(player_id)
+ |> Enum.map(&format_game_selection_tuple/1)
+ end
+
+ def refresh_options(%State{options: options}) do
+ from(g in Game,
+ where: g.id in ^Enum.map(options, fn {_, id} -> id end),
+ order_by: [desc: g.id]
+ )
+ |> Repo.all()
+ |> Repo.preload([:light_player, :dark_player])
+ |> Enum.map(&format_game_selection_tuple/1)
+ end
+
+ def make_process_tuple(selected_id, %State{
+ player_session: player_session
+ }) do
+ game = Repo.get(Game, selected_id)
+
+ {Chessh.SSH.Client.Game,
+ %Chessh.SSH.Client.Game.State{
+ player_session: player_session,
+ game: game
+ }}
+ end
+end
diff --git a/lib/chessh/ssh/client/menus/select_paginate_poller.ex b/lib/chessh/ssh/client/menus/select_paginate_poller.ex
new file mode 100644
index 0000000..c6f9e1d
--- /dev/null
+++ b/lib/chessh/ssh/client/menus/select_paginate_poller.ex
@@ -0,0 +1,263 @@
+defmodule Chessh.SSH.Client.SelectPaginatePoller do
+ @callback dynamic_options() :: boolean()
+
+ @callback tick_delay_ms() :: integer()
+ @callback max_displayed_options() :: integer()
+ @callback max_box_cols() :: integer()
+ @callback make_process_tuple(selected :: any(), state :: any()) ::
+ {module :: module(), state :: any()}
+
+ @callback initial_options(state :: any()) ::
+ [{line :: any(), selected :: any()}]
+
+ @callback refresh_options_ms() :: integer()
+ @callback refresh_options(state :: any()) ::
+ [{line :: any(), selected :: any()}]
+ @callback next_page_options(state :: any()) ::
+ [{line :: any(), selected :: any()}]
+ @callback previous_page_options(state :: any()) ::
+ [{line :: any(), selected :: any()}]
+
+ @callback title() :: [any()]
+
+ defmodule State do
+ defstruct client_pid: nil,
+ selected_option_idx: 0,
+ player_session: nil,
+ options: [],
+ tick: 0,
+ cursor: nil
+ end
+
+ defmacro __using__(_) do
+ quote do
+ @behaviour Chessh.SSH.Client.SelectPaginatePoller
+ use Chessh.SSH.Client.Screen
+
+ alias IO.ANSI
+ alias Chessh.{Utils, PlayerSession}
+ alias Chessh.SSH.Client.SelectPaginatePoller.State
+ alias Chessh.SSH.Client.SelectPaginatePoller
+
+ require Logger
+
+ def init([%State{} = state | _]) do
+ if dynamic_options() do
+ Process.send_after(self(), :refresh_options, refresh_options_ms())
+ end
+
+ Process.send_after(self(), :tick, tick_delay_ms())
+
+ {:ok, %State{state | options: initial_options(state)}}
+ end
+
+ def handle_info(
+ :refresh_options,
+ %State{
+ selected_option_idx: selected_option_idx,
+ tick: tick,
+ client_pid: client_pid
+ } = state
+ ) do
+ if dynamic_options() do
+ options = refresh_options(state)
+ Process.send_after(self(), :refresh_options, refresh_options_ms())
+
+ {:noreply,
+ %State{
+ state
+ | selected_option_idx: min(selected_option_idx, length(options) - 1),
+ options: options
+ }}
+ else
+ {:noreply, state}
+ end
+ end
+
+ def handle_info(
+ :tick,
+ %State{
+ tick: tick,
+ client_pid: client_pid
+ } = state
+ ) do
+ Process.send_after(self(), :tick, tick_delay_ms())
+
+ if client_pid do
+ send(client_pid, :refresh)
+ end
+
+ {:noreply, %State{state | tick: tick + 1}}
+ end
+
+ def handle_info(
+ x,
+ state
+ ) do
+ Logger.debug("unknown message in pagination poller - #{inspect(x)}")
+
+ {:noreply, state}
+ end
+
+ def render(
+ width,
+ height,
+ %State{
+ client_pid: client_pid
+ } = state
+ ) do
+ send(
+ client_pid,
+ {:send_to_ssh, render_state(width, height, state)}
+ )
+
+ state
+ end
+
+ def input(
+ width,
+ height,
+ action,
+ %State{
+ client_pid: client_pid,
+ options: options,
+ selected_option_idx: selected_option_idx
+ } = state
+ ) do
+ max_item = min(length(options), max_displayed_options())
+
+ new_state =
+ if(max_item > 0,
+ do:
+ case action do
+ :up ->
+ %State{
+ state
+ | selected_option_idx: Utils.wrap_around(selected_option_idx, -1, max_item),
+ tick: 0
+ }
+
+ :down ->
+ %State{
+ state
+ | selected_option_idx: Utils.wrap_around(selected_option_idx, 1, max_item),
+ tick: 0
+ }
+
+ :left ->
+ if dynamic_options(),
+ do: %State{
+ state
+ | options: previous_page_options(state),
+ selected_option_idx: 0,
+ tick: 0
+ }
+
+ :right ->
+ if dynamic_options(),
+ do: %State{
+ state
+ | options: next_page_options(state),
+ selected_option_idx: 0,
+ tick: 0
+ }
+
+ :return ->
+ {_, selected} = Enum.at(options, selected_option_idx)
+ {module, state} = make_process_tuple(selected, state)
+ send(client_pid, {:set_screen_process, module, state})
+ state
+
+ _ ->
+ nil
+ end
+ ) || state
+
+ if !(action == :return) do
+ render(width, height, new_state)
+ end
+
+ new_state
+ end
+
+ defp render_state(
+ width,
+ height,
+ %State{} = state
+ ) do
+ lines =
+ title() ++
+ [""] ++
+ render_lines(width, height, state) ++
+ if dynamic_options(), do: ["", "<= Previous | Next =>"], else: []
+
+ {y, x} = Utils.center_rect({min(width, max_box_cols()), length(lines)}, {width, height})
+
+ [ANSI.clear()] ++
+ Enum.flat_map(
+ Enum.zip(0..(length(lines) - 1), lines),
+ fn {i, line} ->
+ [
+ ANSI.cursor(y + i, x),
+ line
+ ]
+ end
+ ) ++ [ANSI.home()]
+ end
+
+ defp render_lines(width, _height, %State{
+ tick: tick,
+ options: options,
+ selected_option_idx: selected_option_idx
+ }) do
+ if options && length(options) > 0 do
+ Enum.map(
+ Enum.zip(0..(max_displayed_options() - 1), options),
+ fn {i, {line, _}} ->
+ box_cols = min(max_box_cols(), width)
+ linelen = String.length(line)
+
+ line =
+ if linelen > box_cols do
+ delta = max(box_cols - 3 - 1, 0)
+ overflow = linelen - delta
+ start = if i == selected_option_idx, do: rem(tick, overflow), else: 0
+ "#{String.slice(line, start..(start + delta))}..."
+ else
+ line
+ end
+
+ if i == selected_option_idx do
+ ANSI.format_fragment(
+ [:light_cyan, :bright, "> #{line} <", :reset],
+ true
+ )
+ else
+ line
+ end
+ end
+ )
+ else
+ ["Looks like there's nothing here.", "Use Ctrl+b to go back"]
+ end
+ end
+
+ def refresh_options_ms(), do: 3000
+ def next_page_options(%State{options: options}), do: options
+ def previous_page_options(%State{options: options}), do: options
+ def refresh_options(%State{options: options}), do: options
+
+ def tick_delay_ms(), do: 1000
+ def max_displayed_options(), do: 10
+ def max_box_cols(), do: 90
+
+ defoverridable refresh_options_ms: 0,
+ next_page_options: 1,
+ previous_page_options: 1,
+ refresh_options: 1,
+ tick_delay_ms: 0,
+ max_displayed_options: 0,
+ max_box_cols: 0
+ end
+ end
+end