From 06f1ca76037397fb61c69319802ed029ac73e715 Mon Sep 17 00:00:00 2001 From: Simponic Date: Fri, 20 Jan 2023 23:12:23 -0700 Subject: [PATCH] Add pagination menus --- lib/chessh/schema/player_session.ex | 16 +- lib/chessh/ssh/client/client.ex | 4 +- lib/chessh/ssh/client/game/game.ex | 5 + lib/chessh/ssh/client/game/renderer.ex | 103 +++++-- lib/chessh/ssh/client/menu.ex | 144 ---------- lib/chessh/ssh/client/menus/game_selector.ex | 24 ++ lib/chessh/ssh/client/menus/main_menu.ex | 45 +++ .../ssh/client/menus/select_current_game.ex | 85 ++++++ .../ssh/client/menus/select_joinable_game.ex | 87 ++++++ .../client/menus/select_paginate_poller.ex | 263 ++++++++++++++++++ 10 files changed, 594 insertions(+), 182 deletions(-) delete mode 100644 lib/chessh/ssh/client/menu.ex create mode 100644 lib/chessh/ssh/client/menus/game_selector.ex create mode 100644 lib/chessh/ssh/client/menus/main_menu.ex create mode 100644 lib/chessh/ssh/client/menus/select_current_game.ex create mode 100644 lib/chessh/ssh/client/menus/select_joinable_game.ex create mode 100644 lib/chessh/ssh/client/menus/select_paginate_poller.ex diff --git a/lib/chessh/schema/player_session.ex b/lib/chessh/schema/player_session.ex index f12387a..cb06715 100644 --- a/lib/chessh/schema/player_session.ex +++ b/lib/chessh/schema/player_session.ex @@ -86,10 +86,7 @@ defmodule Chessh.PlayerSession do "Player #{player.username} has #{length(expired_sessions)} expired sessions - attempting to close them" ) - Enum.map(expired_sessions, fn session_id -> - :syn.publish(:player_sessions, {:session, session_id}, :session_closed) - end) - + Enum.map(expired_sessions, &close_session/1) Repo.delete_all(from(p in PlayerSession, where: p.id in ^expired_sessions)) end @@ -112,8 +109,13 @@ defmodule Chessh.PlayerSession do def close_all_player_sessions(player) do player_sessions = Repo.all(from(p in PlayerSession, where: p.player_id == ^player.id)) - Enum.map(player_sessions, fn session -> - :syn.publish(:player_sessions, {:session, session.id}, :session_closed) - end) + Enum.map(player_sessions, &close_session(&1.id)) + end + + defp close_session(id) do + case :syn_backbone.get_table_name(:syn_pg_by_name, :player_sessions) do + :undefined -> nil + _ -> :syn.publish(:player_sessions, {:session, id}, :session_closed) + end end end 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