Add pagination menus

This commit is contained in:
Simponic 2023-01-20 23:12:23 -07:00
parent e0058fedfb
commit 06f1ca7603
Signed by untrusted user who does not match committer: simponic
GPG Key ID: 52B3774857EB24B1
10 changed files with 594 additions and 182 deletions

View File

@ -86,10 +86,7 @@ defmodule Chessh.PlayerSession do
"Player #{player.username} has #{length(expired_sessions)} expired sessions - attempting to close them" "Player #{player.username} has #{length(expired_sessions)} expired sessions - attempting to close them"
) )
Enum.map(expired_sessions, fn session_id -> Enum.map(expired_sessions, &close_session/1)
:syn.publish(:player_sessions, {:session, session_id}, :session_closed)
end)
Repo.delete_all(from(p in PlayerSession, where: p.id in ^expired_sessions)) Repo.delete_all(from(p in PlayerSession, where: p.id in ^expired_sessions))
end end
@ -112,8 +109,13 @@ defmodule Chessh.PlayerSession do
def close_all_player_sessions(player) do def close_all_player_sessions(player) do
player_sessions = Repo.all(from(p in PlayerSession, where: p.player_id == ^player.id)) player_sessions = Repo.all(from(p in PlayerSession, where: p.player_id == ^player.id))
Enum.map(player_sessions, fn session -> Enum.map(player_sessions, &close_session(&1.id))
:syn.publish(:player_sessions, {:session, session.id}, :session_closed) end
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
end end

View File

@ -25,8 +25,8 @@ defmodule Chessh.SSH.Client do
def init([%State{player_session: player_session} = state]) do def init([%State{player_session: player_session} = state]) do
send( send(
self(), self(),
{:set_screen_process, Chessh.SSH.Client.Menu, {:set_screen_process, Chessh.SSH.Client.MainMenu,
%Chessh.SSH.Client.Menu.State{player_session: player_session}} %Chessh.SSH.Client.SelectPaginatePoller.State{player_session: player_session}}
) )
{:ok, state} {:ok, state}

View File

@ -171,6 +171,11 @@ defmodule Chessh.SSH.Client.Game do
{:noreply, new_state} {:noreply, new_state}
end end
def handle_info(x, state) do
Logger.debug("unknown message in game process #{inspect(x)}")
{:noreply, state}
end
def input( def input(
width, width,
height, height,

View File

@ -2,7 +2,6 @@ defmodule Chessh.SSH.Client.Game.Renderer do
alias IO.ANSI alias IO.ANSI
alias Chessh.{Utils, Player} alias Chessh.{Utils, Player}
alias Chessh.SSH.Client.Game alias Chessh.SSH.Client.Game
require Logger
@chess_board_height 8 @chess_board_height 8
@chess_board_width 8 @chess_board_width 8
@ -64,36 +63,11 @@ defmodule Chessh.SSH.Client.Game.Renderer do
height: _height, height: _height,
highlighted: highlighted, highlighted: highlighted,
flipped: flipped, flipped: flipped,
game: %Chessh.Game{ game: %Chessh.Game{} = game
dark_player: %Player{username: dark_player},
light_player: %Player{username: light_player},
turn: turn,
status: status,
winner: winner
}
}) do }) do
rendered = [ rendered = [
Enum.join( ANSI.clear_line(),
[ make_status_line(game, true)
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()
],
""
)
| draw_board( | draw_board(
fen, fen,
{@tile_width, @tile_height}, {@tile_width, @tile_height},
@ -111,6 +85,77 @@ defmodule Chessh.SSH.Client.Game.Renderer do
) )
end 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( defp draw_board(
fen, fen,
{tile_width, tile_height} = tile_dims, {tile_width, tile_height} = tile_dims,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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