Rename crate
This commit is contained in:
		
							
								
								
									
										31
									
								
								rust/sea_battle_cli_player/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								rust/sea_battle_cli_player/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "sea_battle_cli_player"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
edition = "2021"
 | 
			
		||||
license = "GPL-2.0-or-later"
 | 
			
		||||
description = "A Sea Battle game shell client"
 | 
			
		||||
repository = "https://gitea.communiquons.org/pierre/SeaBattle"
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
 | 
			
		||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
sea_battle_backend = { path = "../sea_battle_backend" }
 | 
			
		||||
clap = { version = "4.0.15", features = ["derive"] }
 | 
			
		||||
log = "0.4.17"
 | 
			
		||||
env_logger = "0.9.0"
 | 
			
		||||
tui = "0.19.0"
 | 
			
		||||
crossterm = "0.25.0"
 | 
			
		||||
lazy_static = "1.4.0"
 | 
			
		||||
tokio = "1.21.2"
 | 
			
		||||
num = "0.4.0"
 | 
			
		||||
num-traits = "0.2.15"
 | 
			
		||||
num-derive = "0.3.3"
 | 
			
		||||
textwrap = "0.15.1"
 | 
			
		||||
tokio-tungstenite = { version = "0.17.2", features = ["__rustls-tls", "rustls-tls-native-roots"] }
 | 
			
		||||
serde_urlencoded = "0.7.1"
 | 
			
		||||
futures = "0.3.23"
 | 
			
		||||
serde_json = "1.0.85"
 | 
			
		||||
hostname = "0.3.1"
 | 
			
		||||
rustls = "0.20.6"
 | 
			
		||||
hyper-rustls = { version = "0.23.0", features = ["rustls-native-certs"] }
 | 
			
		||||
							
								
								
									
										3
									
								
								rust/sea_battle_cli_player/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								rust/sea_battle_cli_player/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
# Sea battle player
 | 
			
		||||
 | 
			
		||||
A sea battle shell client player for the `sea_battle_backend` crate.
 | 
			
		||||
							
								
								
									
										63
									
								
								rust/sea_battle_cli_player/src/cli_args.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								rust/sea_battle_cli_player/src/cli_args.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
use clap::{Parser, ValueEnum};
 | 
			
		||||
 | 
			
		||||
#[derive(Clone, Copy, Debug, ValueEnum)]
 | 
			
		||||
pub enum TestDevScreen {
 | 
			
		||||
    Popup,
 | 
			
		||||
    Input,
 | 
			
		||||
    Confirm,
 | 
			
		||||
    SelectBotType,
 | 
			
		||||
    SelectPlayMode,
 | 
			
		||||
    SetBoatsLayout,
 | 
			
		||||
    ConfigureGameRules,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Parser, Debug)]
 | 
			
		||||
pub struct CliArgs {
 | 
			
		||||
    /// Upstream server to use
 | 
			
		||||
    #[clap(
 | 
			
		||||
        short,
 | 
			
		||||
        long,
 | 
			
		||||
        value_parser,
 | 
			
		||||
        default_value = "https://seabattleapi.communiquons.org"
 | 
			
		||||
    )]
 | 
			
		||||
    pub remote_server_uri: String,
 | 
			
		||||
 | 
			
		||||
    /// Local server listen address
 | 
			
		||||
    #[clap(short, long, default_value = "127.0.0.1:5679")]
 | 
			
		||||
    pub listen_address: String,
 | 
			
		||||
 | 
			
		||||
    #[clap(long, value_enum)]
 | 
			
		||||
    pub dev_screen: Option<TestDevScreen>,
 | 
			
		||||
 | 
			
		||||
    /// Run as server instead of as client
 | 
			
		||||
    #[clap(long, short)]
 | 
			
		||||
    pub serve: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl CliArgs {
 | 
			
		||||
    /// Get local listen port
 | 
			
		||||
    pub fn listen_port(&self) -> u16 {
 | 
			
		||||
        self.listen_address
 | 
			
		||||
            .rsplit(':')
 | 
			
		||||
            .next()
 | 
			
		||||
            .expect("Failed to split listen address!")
 | 
			
		||||
            .parse::<u16>()
 | 
			
		||||
            .expect("Failed to parse listen port!")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get local server address
 | 
			
		||||
    pub fn local_server_address(&self) -> String {
 | 
			
		||||
        format!("http://localhost:{}", self.listen_port())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
lazy_static::lazy_static! {
 | 
			
		||||
    static ref ARGS: CliArgs = {
 | 
			
		||||
        CliArgs::parse()
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Get parsed command line arguments
 | 
			
		||||
pub fn cli_args() -> &'static CliArgs {
 | 
			
		||||
    &ARGS
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										206
									
								
								rust/sea_battle_cli_player/src/client.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								rust/sea_battle_cli_player/src/client.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,206 @@
 | 
			
		||||
use crate::cli_args::cli_args;
 | 
			
		||||
use crate::server;
 | 
			
		||||
use futures::stream::{SplitSink, SplitStream};
 | 
			
		||||
use futures::{SinkExt, StreamExt};
 | 
			
		||||
use hyper_rustls::ConfigBuilderExt;
 | 
			
		||||
use sea_battle_backend::data::GameRules;
 | 
			
		||||
use sea_battle_backend::human_player_ws::{ClientMessage, ServerMessage};
 | 
			
		||||
use sea_battle_backend::server::{
 | 
			
		||||
    AcceptInviteQuery, BotPlayQuery, CreateInviteQuery, PlayRandomQuery,
 | 
			
		||||
};
 | 
			
		||||
use sea_battle_backend::utils::res_utils::{boxed_error, Res};
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
use std::sync::mpsc::TryRecvError;
 | 
			
		||||
use std::sync::{mpsc, Arc};
 | 
			
		||||
use tokio::net::TcpStream;
 | 
			
		||||
use tokio_tungstenite::tungstenite::Message;
 | 
			
		||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
 | 
			
		||||
 | 
			
		||||
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
 | 
			
		||||
 | 
			
		||||
/// Connection client
 | 
			
		||||
///
 | 
			
		||||
/// This structure acts as a wrapper around websocket connection that handles automatically parsing
 | 
			
		||||
/// of incoming messages and encoding of outgoing messages
 | 
			
		||||
pub struct Client {
 | 
			
		||||
    sink: SplitSink<WsStream, Message>,
 | 
			
		||||
    receiver: mpsc::Receiver<ServerMessage>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Client {
 | 
			
		||||
    /// Start to play against a bot
 | 
			
		||||
    ///
 | 
			
		||||
    /// When playing against a bot, local server is always used
 | 
			
		||||
    pub async fn start_bot_play(rules: &GameRules) -> Res<Self> {
 | 
			
		||||
        server::start_server_if_missing().await;
 | 
			
		||||
 | 
			
		||||
        Self::connect_url(
 | 
			
		||||
            &cli_args().local_server_address(),
 | 
			
		||||
            &format!(
 | 
			
		||||
                "/play/bot?{}",
 | 
			
		||||
                serde_urlencoded::to_string(&BotPlayQuery {
 | 
			
		||||
                    rules: rules.clone(),
 | 
			
		||||
                    player_name: "Human".to_string()
 | 
			
		||||
                })
 | 
			
		||||
                .unwrap()
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        .await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start to play against a random player
 | 
			
		||||
    pub async fn start_random_play<D: Display>(player_name: D) -> Res<Self> {
 | 
			
		||||
        Self::connect_url(
 | 
			
		||||
            &cli_args().remote_server_uri,
 | 
			
		||||
            &format!(
 | 
			
		||||
                "/play/random?{}",
 | 
			
		||||
                serde_urlencoded::to_string(&PlayRandomQuery {
 | 
			
		||||
                    player_name: player_name.to_string()
 | 
			
		||||
                })
 | 
			
		||||
                .unwrap()
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        .await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start a play by creating an invite
 | 
			
		||||
    pub async fn start_create_invite<D: Display>(rules: &GameRules, player_name: D) -> Res<Self> {
 | 
			
		||||
        Self::connect_url(
 | 
			
		||||
            &cli_args().remote_server_uri,
 | 
			
		||||
            &format!(
 | 
			
		||||
                "/play/create_invite?{}",
 | 
			
		||||
                serde_urlencoded::to_string(&CreateInviteQuery {
 | 
			
		||||
                    rules: rules.clone(),
 | 
			
		||||
                    player_name: player_name.to_string()
 | 
			
		||||
                })
 | 
			
		||||
                .unwrap()
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        .await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start a play by accepting an invite
 | 
			
		||||
    pub async fn start_accept_invite<D: Display>(code: String, player_name: D) -> Res<Self> {
 | 
			
		||||
        Self::connect_url(
 | 
			
		||||
            &cli_args().remote_server_uri,
 | 
			
		||||
            &format!(
 | 
			
		||||
                "/play/accept_invite?{}",
 | 
			
		||||
                serde_urlencoded::to_string(&AcceptInviteQuery {
 | 
			
		||||
                    code,
 | 
			
		||||
                    player_name: player_name.to_string()
 | 
			
		||||
                })
 | 
			
		||||
                .unwrap()
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        .await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Do connect to a server, returning
 | 
			
		||||
    async fn connect_url(server: &str, uri: &str) -> Res<Self> {
 | 
			
		||||
        let mut ws_url = server.replace("http", "ws");
 | 
			
		||||
        ws_url.push_str(uri);
 | 
			
		||||
        log::debug!("Connecting to {}", ws_url);
 | 
			
		||||
 | 
			
		||||
        let (socket, _) = if ws_url.starts_with("wss") {
 | 
			
		||||
            // Perform a connection over TLS
 | 
			
		||||
            let config = rustls::ClientConfig::builder()
 | 
			
		||||
                .with_safe_defaults()
 | 
			
		||||
                .with_native_roots()
 | 
			
		||||
                .with_no_client_auth();
 | 
			
		||||
            let connector = tokio_tungstenite::Connector::Rustls(Arc::new(config));
 | 
			
		||||
 | 
			
		||||
            tokio_tungstenite::connect_async_tls_with_config(ws_url, None, Some(connector)).await?
 | 
			
		||||
        } else {
 | 
			
		||||
            // Perform an unsecure connection
 | 
			
		||||
            tokio_tungstenite::connect_async(ws_url).await?
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        let (sink, mut stream) = socket.split();
 | 
			
		||||
 | 
			
		||||
        // Receive server message on a separate task
 | 
			
		||||
        let (sender, receiver) = mpsc::channel();
 | 
			
		||||
        tokio::task::spawn(async move {
 | 
			
		||||
            loop {
 | 
			
		||||
                match Self::recv_next_msg(&mut stream).await {
 | 
			
		||||
                    Ok(msg) => {
 | 
			
		||||
                        if let Err(e) = sender.send(msg.clone()) {
 | 
			
		||||
                            log::debug!("Failed to forward ws message! {} (msg={:?})", e, msg);
 | 
			
		||||
                            break;
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        log::debug!("Failed receive next message from websocket! {}", e);
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        Ok(Self { sink, receiver })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Receive next message from stream
 | 
			
		||||
    async fn recv_next_msg(stream: &mut SplitStream<WsStream>) -> Res<ServerMessage> {
 | 
			
		||||
        loop {
 | 
			
		||||
            let chunk = match stream.next().await {
 | 
			
		||||
                None => return Err(boxed_error("No more message in queue!")),
 | 
			
		||||
                Some(d) => d,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            match chunk? {
 | 
			
		||||
                Message::Text(t) => {
 | 
			
		||||
                    log::debug!("TEXT Got a text message from server!");
 | 
			
		||||
                    let msg: ServerMessage = serde_json::from_str(&t)?;
 | 
			
		||||
                    return Ok(msg);
 | 
			
		||||
                }
 | 
			
		||||
                Message::Binary(_) => {
 | 
			
		||||
                    log::debug!("BINARY Got an unexpected binary message");
 | 
			
		||||
                    return Err(boxed_error("Received an unexpected binary message!"));
 | 
			
		||||
                }
 | 
			
		||||
                Message::Ping(_) => {
 | 
			
		||||
                    log::debug!("PING Got a ping message from server");
 | 
			
		||||
                }
 | 
			
		||||
                Message::Pong(_) => {
 | 
			
		||||
                    log::debug!("PONG Got a pong message");
 | 
			
		||||
                }
 | 
			
		||||
                Message::Close(_) => {
 | 
			
		||||
                    log::debug!("CLOSE Got a close websocket message");
 | 
			
		||||
                    return Err(boxed_error("Server requested to close connection!"));
 | 
			
		||||
                }
 | 
			
		||||
                Message::Frame(_) => {
 | 
			
		||||
                    log::debug!("FRAME Got an unexpected frame from server!");
 | 
			
		||||
                    return Err(boxed_error("Got an unexpected frame!"));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Send a message through the stream
 | 
			
		||||
    pub async fn send_message(&mut self, msg: &ClientMessage) -> Res {
 | 
			
		||||
        self.sink
 | 
			
		||||
            .send(Message::Text(serde_json::to_string(&msg)?))
 | 
			
		||||
            .await?;
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Try to receive next message from websocket, in a non-blocking way
 | 
			
		||||
    pub async fn try_recv_next_message(&self) -> Res<Option<ServerMessage>> {
 | 
			
		||||
        match self.receiver.try_recv() {
 | 
			
		||||
            Ok(msg) => Ok(Some(msg)),
 | 
			
		||||
            Err(TryRecvError::Empty) => Ok(None),
 | 
			
		||||
            Err(TryRecvError::Disconnected) => Err(boxed_error("Receiver channel disconnected!")),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Block until the next message from websocket is availabl
 | 
			
		||||
    pub async fn recv_next_message(&self) -> Res<ServerMessage> {
 | 
			
		||||
        Ok(self.receiver.recv()?)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Close connection
 | 
			
		||||
    pub async fn close_connection(&mut self) {
 | 
			
		||||
        if let Err(e) = self.sink.send(Message::Close(None)).await {
 | 
			
		||||
            log::debug!("Failed to close WS connection! {:?}", e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								rust/sea_battle_cli_player/src/constants.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								rust/sea_battle_cli_player/src/constants.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
use std::time::Duration;
 | 
			
		||||
use tui::style::Color;
 | 
			
		||||
 | 
			
		||||
pub const TICK_RATE: Duration = Duration::from_millis(250);
 | 
			
		||||
 | 
			
		||||
pub const HIGHLIGHT_COLOR: Color = Color::Green;
 | 
			
		||||
							
								
								
									
										8
									
								
								rust/sea_battle_cli_player/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								rust/sea_battle_cli_player/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
extern crate core;
 | 
			
		||||
 | 
			
		||||
pub mod cli_args;
 | 
			
		||||
pub mod client;
 | 
			
		||||
pub mod constants;
 | 
			
		||||
pub mod server;
 | 
			
		||||
pub mod ui_screens;
 | 
			
		||||
pub mod ui_widgets;
 | 
			
		||||
							
								
								
									
										196
									
								
								rust/sea_battle_cli_player/src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								rust/sea_battle_cli_player/src/main.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,196 @@
 | 
			
		||||
use std::error::Error;
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::io::ErrorKind;
 | 
			
		||||
 | 
			
		||||
use crossterm::event::DisableMouseCapture;
 | 
			
		||||
use crossterm::event::EnableMouseCapture;
 | 
			
		||||
use crossterm::execute;
 | 
			
		||||
use crossterm::terminal::EnterAlternateScreen;
 | 
			
		||||
use crossterm::terminal::LeaveAlternateScreen;
 | 
			
		||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
 | 
			
		||||
use env_logger::Env;
 | 
			
		||||
use tui::backend::{Backend, CrosstermBackend};
 | 
			
		||||
use tui::Terminal;
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::consts::{
 | 
			
		||||
    INVITE_CODE_LENGTH, MAX_PLAYER_NAME_LENGTH, MIN_PLAYER_NAME_LENGTH,
 | 
			
		||||
};
 | 
			
		||||
use sea_battle_backend::data::GameRules;
 | 
			
		||||
use sea_battle_backend::utils::res_utils::Res;
 | 
			
		||||
use sea_battle_cli_player::cli_args::{cli_args, TestDevScreen};
 | 
			
		||||
use sea_battle_cli_player::client::Client;
 | 
			
		||||
use sea_battle_cli_player::server::run_server;
 | 
			
		||||
use sea_battle_cli_player::ui_screens::configure_game_rules::GameRulesConfigurationScreen;
 | 
			
		||||
use sea_battle_cli_player::ui_screens::game_screen::GameScreen;
 | 
			
		||||
use sea_battle_cli_player::ui_screens::input_screen::InputScreen;
 | 
			
		||||
use sea_battle_cli_player::ui_screens::popup_screen::PopupScreen;
 | 
			
		||||
use sea_battle_cli_player::ui_screens::select_play_mode_screen::{
 | 
			
		||||
    SelectPlayModeResult, SelectPlayModeScreen,
 | 
			
		||||
};
 | 
			
		||||
use sea_battle_cli_player::ui_screens::*;
 | 
			
		||||
 | 
			
		||||
/// Test code screens
 | 
			
		||||
async fn run_dev<B: Backend>(
 | 
			
		||||
    terminal: &mut Terminal<B>,
 | 
			
		||||
    d: TestDevScreen,
 | 
			
		||||
) -> Result<(), Box<dyn Error>> {
 | 
			
		||||
    let res = match d {
 | 
			
		||||
        TestDevScreen::Popup => popup_screen::PopupScreen::new("Welcome there!!")
 | 
			
		||||
            .show(terminal)?
 | 
			
		||||
            .as_string(),
 | 
			
		||||
        TestDevScreen::Input => input_screen::InputScreen::new("What it your name ?")
 | 
			
		||||
            .set_title("A custom title")
 | 
			
		||||
            .show(terminal)?
 | 
			
		||||
            .as_string(),
 | 
			
		||||
        TestDevScreen::Confirm => {
 | 
			
		||||
            confirm_dialog_screen::ConfirmDialogScreen::new("Do you really want to quit game?")
 | 
			
		||||
                .show(terminal)?
 | 
			
		||||
                .as_string()
 | 
			
		||||
        }
 | 
			
		||||
        TestDevScreen::SelectBotType => select_bot_type_screen::SelectBotTypeScreen::default()
 | 
			
		||||
            .show(terminal)?
 | 
			
		||||
            .as_string(),
 | 
			
		||||
        TestDevScreen::SelectPlayMode => select_play_mode_screen::SelectPlayModeScreen::default()
 | 
			
		||||
            .show(terminal)?
 | 
			
		||||
            .as_string(),
 | 
			
		||||
        TestDevScreen::SetBoatsLayout => {
 | 
			
		||||
            let rules = GameRules {
 | 
			
		||||
                boats_can_touch: true,
 | 
			
		||||
                ..Default::default()
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            set_boats_layout_screen::SetBoatsLayoutScreen::new(&rules)
 | 
			
		||||
                .show(terminal)?
 | 
			
		||||
                .as_string()
 | 
			
		||||
        }
 | 
			
		||||
        TestDevScreen::ConfigureGameRules => {
 | 
			
		||||
            configure_game_rules::GameRulesConfigurationScreen::new(GameRules::default())
 | 
			
		||||
                .show(terminal)?
 | 
			
		||||
                .as_string()
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    Err(io::Error::new(
 | 
			
		||||
        ErrorKind::Other,
 | 
			
		||||
        format!("DEV result: {:?}", res),
 | 
			
		||||
    ))?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Ask the user to specify the name he should be identified with
 | 
			
		||||
fn query_player_name<B: Backend>(terminal: &mut Terminal<B>) -> Res<String> {
 | 
			
		||||
    let mut hostname = hostname::get()?.to_string_lossy().to_string();
 | 
			
		||||
    if hostname.len() > MAX_PLAYER_NAME_LENGTH {
 | 
			
		||||
        hostname = hostname[0..MAX_PLAYER_NAME_LENGTH].to_string();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let res =
 | 
			
		||||
        InputScreen::new("Please specify the name to which other players should identify you:")
 | 
			
		||||
            .set_title("Player name")
 | 
			
		||||
            .set_value(&hostname)
 | 
			
		||||
            .set_min_length(MIN_PLAYER_NAME_LENGTH)
 | 
			
		||||
            .set_max_length(MAX_PLAYER_NAME_LENGTH)
 | 
			
		||||
            .show(terminal)?;
 | 
			
		||||
 | 
			
		||||
    Ok(res.value().unwrap_or(hostname))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Res {
 | 
			
		||||
    if let Some(d) = cli_args().dev_screen {
 | 
			
		||||
        return run_dev(terminal, d).await;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let mut rules = GameRules::default();
 | 
			
		||||
 | 
			
		||||
    let mut username = "".to_string();
 | 
			
		||||
 | 
			
		||||
    loop {
 | 
			
		||||
        let choice = SelectPlayModeScreen::default().show(terminal)?;
 | 
			
		||||
 | 
			
		||||
        if let ScreenResult::Ok(c) = choice {
 | 
			
		||||
            if c.need_player_name() && username.is_empty() {
 | 
			
		||||
                username = query_player_name(terminal)?;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if c.need_custom_rules() {
 | 
			
		||||
                rules = match GameRulesConfigurationScreen::new(rules.clone()).show(terminal)? {
 | 
			
		||||
                    ScreenResult::Ok(r) => r,
 | 
			
		||||
                    ScreenResult::Canceled => continue,
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        PopupScreen::new("🔌 Connecting...").show_once(terminal)?;
 | 
			
		||||
 | 
			
		||||
        let client = match choice {
 | 
			
		||||
            ScreenResult::Ok(SelectPlayModeResult::PlayRandom) => {
 | 
			
		||||
                Client::start_random_play(&username).await?
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Play against bot
 | 
			
		||||
            ScreenResult::Ok(SelectPlayModeResult::PlayAgainstBot) => {
 | 
			
		||||
                Client::start_bot_play(&rules).await?
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Create invite
 | 
			
		||||
            ScreenResult::Ok(SelectPlayModeResult::CreateInvite) => {
 | 
			
		||||
                Client::start_create_invite(&rules, &username).await?
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Join invite
 | 
			
		||||
            ScreenResult::Ok(SelectPlayModeResult::AcceptInvite) => {
 | 
			
		||||
                let code = match InputScreen::new("Invite code")
 | 
			
		||||
                    .set_min_length(INVITE_CODE_LENGTH)
 | 
			
		||||
                    .set_max_length(INVITE_CODE_LENGTH)
 | 
			
		||||
                    .show(terminal)?
 | 
			
		||||
                    .value()
 | 
			
		||||
                {
 | 
			
		||||
                    None => continue,
 | 
			
		||||
                    Some(v) => v,
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                PopupScreen::new("🔌 Connecting...").show_once(terminal)?;
 | 
			
		||||
                Client::start_accept_invite(code, &username).await?
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ScreenResult::Canceled | ScreenResult::Ok(SelectPlayModeResult::Exit) => return Ok(()),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Display game screen
 | 
			
		||||
        GameScreen::new(client).show(terminal).await?;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[tokio::main]
 | 
			
		||||
pub async fn main() -> Result<(), Box<dyn Error>> {
 | 
			
		||||
    env_logger::Builder::from_env(Env::default()).init();
 | 
			
		||||
 | 
			
		||||
    if cli_args().serve {
 | 
			
		||||
        run_server().await;
 | 
			
		||||
        return Ok(());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // setup terminal
 | 
			
		||||
    enable_raw_mode()?;
 | 
			
		||||
    let mut stdout = io::stdout();
 | 
			
		||||
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
 | 
			
		||||
    let backend = CrosstermBackend::new(stdout);
 | 
			
		||||
    let mut terminal = Terminal::new(backend)?;
 | 
			
		||||
 | 
			
		||||
    // create app and run it
 | 
			
		||||
    let res = run_app(&mut terminal).await;
 | 
			
		||||
 | 
			
		||||
    // restore terminal
 | 
			
		||||
    disable_raw_mode()?;
 | 
			
		||||
    execute!(
 | 
			
		||||
        terminal.backend_mut(),
 | 
			
		||||
        LeaveAlternateScreen,
 | 
			
		||||
        DisableMouseCapture
 | 
			
		||||
    )?;
 | 
			
		||||
    terminal.show_cursor()?;
 | 
			
		||||
 | 
			
		||||
    if let Err(err) = res {
 | 
			
		||||
        println!("{:?}", err)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								rust/sea_battle_cli_player/src/server.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								rust/sea_battle_cli_player/src/server.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
use tokio::runtime::Builder;
 | 
			
		||||
use tokio::task;
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::args::Args;
 | 
			
		||||
use sea_battle_backend::utils::network_utils;
 | 
			
		||||
 | 
			
		||||
use crate::cli_args::cli_args;
 | 
			
		||||
 | 
			
		||||
pub async fn run_server() {
 | 
			
		||||
    let local_set = task::LocalSet::new();
 | 
			
		||||
 | 
			
		||||
    local_set
 | 
			
		||||
        .run_until(async move {
 | 
			
		||||
            sea_battle_backend::server::start_server(Args {
 | 
			
		||||
                listen_address: cli_args().listen_address.clone(),
 | 
			
		||||
                cors: None,
 | 
			
		||||
            })
 | 
			
		||||
            .await
 | 
			
		||||
            .expect("Failed to run local server!")
 | 
			
		||||
        })
 | 
			
		||||
        .await;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn start_server_if_missing() {
 | 
			
		||||
    if !network_utils::is_port_open(cli_args().listen_port()).await {
 | 
			
		||||
        log::info!(
 | 
			
		||||
            "Local server will start on {}...",
 | 
			
		||||
            cli_args().listen_address
 | 
			
		||||
        );
 | 
			
		||||
        std::thread::spawn(move || {
 | 
			
		||||
            let rt = Builder::new_current_thread().enable_all().build().unwrap();
 | 
			
		||||
 | 
			
		||||
            rt.block_on(run_server());
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        network_utils::wait_for_port(cli_args().listen_port()).await;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,300 @@
 | 
			
		||||
extern crate num as num_renamed;
 | 
			
		||||
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode};
 | 
			
		||||
use sea_battle_backend::consts::{
 | 
			
		||||
    MAX_BOATS_NUMBER, MAX_MAP_HEIGHT, MAX_MAP_WIDTH, MAX_STRIKE_TIMEOUT,
 | 
			
		||||
};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::layout::{Constraint, Direction, Layout, Margin};
 | 
			
		||||
use tui::style::{Color, Style};
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::data::GameRules;
 | 
			
		||||
 | 
			
		||||
use crate::constants::{HIGHLIGHT_COLOR, TICK_RATE};
 | 
			
		||||
use crate::ui_screens::popup_screen::show_screen_too_small_popup;
 | 
			
		||||
use crate::ui_screens::select_bot_type_screen::SelectBotTypeScreen;
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crate::ui_widgets::button_widget::ButtonWidget;
 | 
			
		||||
use crate::ui_widgets::checkbox_widget::CheckboxWidget;
 | 
			
		||||
use crate::ui_widgets::text_editor_widget::TextEditorWidget;
 | 
			
		||||
 | 
			
		||||
#[derive(num_derive::FromPrimitive, num_derive::ToPrimitive, Eq, PartialEq)]
 | 
			
		||||
enum EditingField {
 | 
			
		||||
    MapWidth = 0,
 | 
			
		||||
    MapHeight,
 | 
			
		||||
    BoatsList,
 | 
			
		||||
    StrikeTimeout,
 | 
			
		||||
    BoatsCanTouch,
 | 
			
		||||
    PlayerContinueOnHit,
 | 
			
		||||
    BotType,
 | 
			
		||||
    Cancel,
 | 
			
		||||
    OK,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct GameRulesConfigurationScreen {
 | 
			
		||||
    rules: GameRules,
 | 
			
		||||
    curr_field: EditingField,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl GameRulesConfigurationScreen {
 | 
			
		||||
    pub fn new(rules: GameRules) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            rules,
 | 
			
		||||
            curr_field: EditingField::OK,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn show<B: Backend>(
 | 
			
		||||
        mut self,
 | 
			
		||||
        terminal: &mut Terminal<B>,
 | 
			
		||||
    ) -> io::Result<ScreenResult<GameRules>> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        loop {
 | 
			
		||||
            terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                let mut cursor_pos = self.curr_field as i32;
 | 
			
		||||
 | 
			
		||||
                if let Event::Key(key) = event::read()? {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        // Quit app
 | 
			
		||||
                        KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
 | 
			
		||||
 | 
			
		||||
                        // Navigate between fields
 | 
			
		||||
                        KeyCode::Up | KeyCode::Left => cursor_pos -= 1,
 | 
			
		||||
                        KeyCode::Down | KeyCode::Right | KeyCode::Tab => cursor_pos += 1,
 | 
			
		||||
 | 
			
		||||
                        // Submit results
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            if self.curr_field == EditingField::BotType {
 | 
			
		||||
                                if let ScreenResult::Ok(t) =
 | 
			
		||||
                                    SelectBotTypeScreen::default().show(terminal)?
 | 
			
		||||
                                {
 | 
			
		||||
                                    self.rules.bot_type = t;
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::Cancel {
 | 
			
		||||
                                return Ok(ScreenResult::Canceled);
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::OK && self.rules.is_valid() {
 | 
			
		||||
                                return Ok(ScreenResult::Ok(self.rules));
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        KeyCode::Char(' ') => {
 | 
			
		||||
                            if self.curr_field == EditingField::BoatsCanTouch {
 | 
			
		||||
                                self.rules.boats_can_touch = !self.rules.boats_can_touch;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::PlayerContinueOnHit {
 | 
			
		||||
                                self.rules.player_continue_on_hit =
 | 
			
		||||
                                    !self.rules.player_continue_on_hit;
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        KeyCode::Backspace => {
 | 
			
		||||
                            if self.curr_field == EditingField::MapWidth {
 | 
			
		||||
                                self.rules.map_width /= 10;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::MapHeight {
 | 
			
		||||
                                self.rules.map_height /= 10;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::BoatsList
 | 
			
		||||
                                && !self.rules.boats_list().is_empty()
 | 
			
		||||
                            {
 | 
			
		||||
                                self.rules.remove_last_boat();
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::StrikeTimeout {
 | 
			
		||||
                                match self.rules.strike_timeout.unwrap_or(0) / 10 {
 | 
			
		||||
                                    0 => self.rules.strike_timeout = None,
 | 
			
		||||
                                    v => self.rules.strike_timeout = Some(v),
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        KeyCode::Char(c) if ('0'..='9').contains(&c) => {
 | 
			
		||||
                            let val = c.to_string().parse::<usize>().unwrap_or_default();
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::MapWidth
 | 
			
		||||
                                && self.rules.map_width <= MAX_MAP_WIDTH
 | 
			
		||||
                            {
 | 
			
		||||
                                self.rules.map_width *= 10;
 | 
			
		||||
                                self.rules.map_width += val;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::MapHeight
 | 
			
		||||
                                && self.rules.map_height <= MAX_MAP_HEIGHT
 | 
			
		||||
                            {
 | 
			
		||||
                                self.rules.map_height *= 10;
 | 
			
		||||
                                self.rules.map_height += val;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::BoatsList
 | 
			
		||||
                                && self.rules.boats_list().len() < MAX_BOATS_NUMBER
 | 
			
		||||
                            {
 | 
			
		||||
                                self.rules.add_boat(val);
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if self.curr_field == EditingField::StrikeTimeout {
 | 
			
		||||
                                let mut timeout = self.rules.strike_timeout.unwrap_or(0);
 | 
			
		||||
                                if timeout <= MAX_STRIKE_TIMEOUT {
 | 
			
		||||
                                    timeout *= 10;
 | 
			
		||||
                                    timeout += val as u64;
 | 
			
		||||
                                    self.rules.strike_timeout = Some(timeout);
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Apply new cursor position
 | 
			
		||||
                self.curr_field = if cursor_pos < 0 {
 | 
			
		||||
                    EditingField::OK
 | 
			
		||||
                } else {
 | 
			
		||||
                    num_renamed::FromPrimitive::from_u64(cursor_pos as u64)
 | 
			
		||||
                        .unwrap_or(EditingField::MapWidth)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
 | 
			
		||||
        let (w, h) = (50, 23);
 | 
			
		||||
 | 
			
		||||
        if f.size().width < w || f.size().height < h {
 | 
			
		||||
            show_screen_too_small_popup(f);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let area = centered_rect_size(w, h, &f.size());
 | 
			
		||||
 | 
			
		||||
        let block = Block::default()
 | 
			
		||||
            .title("📓 Game rules")
 | 
			
		||||
            .borders(Borders::ALL);
 | 
			
		||||
        f.render_widget(block, area);
 | 
			
		||||
 | 
			
		||||
        let chunks = Layout::default()
 | 
			
		||||
            .direction(Direction::Vertical)
 | 
			
		||||
            .constraints([
 | 
			
		||||
                Constraint::Length(3), // Map width
 | 
			
		||||
                Constraint::Length(3), // Map height
 | 
			
		||||
                Constraint::Length(3), // Boats list
 | 
			
		||||
                Constraint::Length(3), // Strike timeout
 | 
			
		||||
                Constraint::Length(1), // Boats can touch
 | 
			
		||||
                Constraint::Length(1), // Player continue on hit
 | 
			
		||||
                Constraint::Length(3), // Bot type
 | 
			
		||||
                Constraint::Length(1), // Margin
 | 
			
		||||
                Constraint::Length(1), // Buttons
 | 
			
		||||
                Constraint::Length(1), // Error message (if any)
 | 
			
		||||
            ])
 | 
			
		||||
            .split(area.inner(&Margin {
 | 
			
		||||
                horizontal: 2,
 | 
			
		||||
                vertical: 1,
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        let editor = TextEditorWidget::new(
 | 
			
		||||
            "Map width",
 | 
			
		||||
            &self.rules.map_width.to_string(),
 | 
			
		||||
            self.curr_field == EditingField::MapWidth,
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(editor, chunks[EditingField::MapWidth as usize]);
 | 
			
		||||
 | 
			
		||||
        let editor = TextEditorWidget::new(
 | 
			
		||||
            "Map height",
 | 
			
		||||
            &self.rules.map_height.to_string(),
 | 
			
		||||
            self.curr_field == EditingField::MapHeight,
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(editor, chunks[EditingField::MapHeight as usize]);
 | 
			
		||||
 | 
			
		||||
        let editor = TextEditorWidget::new(
 | 
			
		||||
            "Boats list",
 | 
			
		||||
            &self
 | 
			
		||||
                .rules
 | 
			
		||||
                .boats_list()
 | 
			
		||||
                .iter()
 | 
			
		||||
                .map(usize::to_string)
 | 
			
		||||
                .collect::<Vec<_>>()
 | 
			
		||||
                .join("; "),
 | 
			
		||||
            self.curr_field == EditingField::BoatsList,
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(editor, chunks[EditingField::BoatsList as usize]);
 | 
			
		||||
 | 
			
		||||
        let editor = TextEditorWidget::new(
 | 
			
		||||
            "Strike timeout (0 to disable)",
 | 
			
		||||
            &self.rules.strike_timeout.unwrap_or(0).to_string(),
 | 
			
		||||
            self.curr_field == EditingField::StrikeTimeout,
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(editor, chunks[EditingField::StrikeTimeout as usize]);
 | 
			
		||||
 | 
			
		||||
        let editor = CheckboxWidget::new(
 | 
			
		||||
            "Boats can touch",
 | 
			
		||||
            self.rules.boats_can_touch,
 | 
			
		||||
            self.curr_field == EditingField::BoatsCanTouch,
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(editor, chunks[EditingField::BoatsCanTouch as usize]);
 | 
			
		||||
 | 
			
		||||
        let editor = CheckboxWidget::new(
 | 
			
		||||
            "Player continue on hit",
 | 
			
		||||
            self.rules.player_continue_on_hit,
 | 
			
		||||
            self.curr_field == EditingField::PlayerContinueOnHit,
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(editor, chunks[EditingField::PlayerContinueOnHit as usize]);
 | 
			
		||||
 | 
			
		||||
        // Select bot type
 | 
			
		||||
        let bot_type_text = format!("Bot type: {}", self.rules.bot_type.description().name);
 | 
			
		||||
        let text = Paragraph::new(bot_type_text.as_str()).style(
 | 
			
		||||
            match self.curr_field == EditingField::BotType {
 | 
			
		||||
                false => Style::default(),
 | 
			
		||||
                true => Style::default().fg(HIGHLIGHT_COLOR),
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
        f.render_widget(
 | 
			
		||||
            text,
 | 
			
		||||
            chunks[EditingField::BotType as usize].inner(&Margin {
 | 
			
		||||
                horizontal: 0,
 | 
			
		||||
                vertical: 1,
 | 
			
		||||
            }),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Buttons
 | 
			
		||||
        let buttons_chunk = Layout::default()
 | 
			
		||||
            .direction(Direction::Horizontal)
 | 
			
		||||
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
 | 
			
		||||
            .split(chunks[EditingField::OK as usize]);
 | 
			
		||||
 | 
			
		||||
        let button = ButtonWidget::new("Cancel", self.curr_field == EditingField::Cancel);
 | 
			
		||||
        f.render_widget(button, buttons_chunk[0]);
 | 
			
		||||
 | 
			
		||||
        let button = ButtonWidget::new("OK", self.curr_field == EditingField::OK)
 | 
			
		||||
            .set_disabled(!self.rules.is_valid());
 | 
			
		||||
        f.render_widget(button, buttons_chunk[1]);
 | 
			
		||||
 | 
			
		||||
        // Error message (if any)
 | 
			
		||||
        if let Some(msg) = self.rules.get_errors().first() {
 | 
			
		||||
            let area = centered_rect_size(msg.len() as u16, 1, chunks.last().unwrap());
 | 
			
		||||
            let err = Paragraph::new(*msg).style(Style::default().fg(Color::Red));
 | 
			
		||||
            f.render_widget(err, area);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,126 @@
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::layout::*;
 | 
			
		||||
use tui::text::*;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use crate::constants::*;
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crate::ui_widgets::button_widget::ButtonWidget;
 | 
			
		||||
 | 
			
		||||
/// Convenience function to ask for user confirmation
 | 
			
		||||
pub fn confirm<B: Backend>(terminal: &mut Terminal<B>, msg: &str) -> bool {
 | 
			
		||||
    matches!(
 | 
			
		||||
        ConfirmDialogScreen::new(msg)
 | 
			
		||||
            .show(terminal)
 | 
			
		||||
            .unwrap_or(ScreenResult::Canceled),
 | 
			
		||||
        ScreenResult::Ok(true)
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct ConfirmDialogScreen<'a> {
 | 
			
		||||
    title: &'a str,
 | 
			
		||||
    msg: &'a str,
 | 
			
		||||
    is_confirm: bool,
 | 
			
		||||
    can_cancel: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> ConfirmDialogScreen<'a> {
 | 
			
		||||
    pub fn new(msg: &'a str) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            title: "Confirmation Request",
 | 
			
		||||
            msg,
 | 
			
		||||
            is_confirm: true,
 | 
			
		||||
            can_cancel: false,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn show<B: Backend>(
 | 
			
		||||
        mut self,
 | 
			
		||||
        terminal: &mut Terminal<B>,
 | 
			
		||||
    ) -> io::Result<ScreenResult<bool>> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        loop {
 | 
			
		||||
            terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                if let Event::Key(key) = event::read()? {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        KeyCode::Esc | KeyCode::Char('q') if self.can_cancel => {
 | 
			
		||||
                            return Ok(ScreenResult::Canceled)
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        // Toggle selected choice
 | 
			
		||||
                        KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
 | 
			
		||||
                            self.is_confirm = !self.is_confirm
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        // Submit choice
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            return Ok(ScreenResult::Ok(self.is_confirm));
 | 
			
		||||
                        }
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
 | 
			
		||||
        // Preprocess message
 | 
			
		||||
        let lines = textwrap::wrap(self.msg, f.size().width as usize - 20);
 | 
			
		||||
        let line_max_len = lines.iter().map(|l| l.len()).max().unwrap();
 | 
			
		||||
 | 
			
		||||
        let area = centered_rect_size(line_max_len as u16 + 4, 5 + lines.len() as u16, &f.size());
 | 
			
		||||
 | 
			
		||||
        let block = Block::default().borders(Borders::ALL).title(self.title);
 | 
			
		||||
        f.render_widget(block, area);
 | 
			
		||||
 | 
			
		||||
        // Create two chunks with equal horizontal screen space
 | 
			
		||||
        let chunks = Layout::default()
 | 
			
		||||
            .direction(Direction::Vertical)
 | 
			
		||||
            .constraints(
 | 
			
		||||
                [
 | 
			
		||||
                    Constraint::Length(lines.len() as u16),
 | 
			
		||||
                    Constraint::Length(3),
 | 
			
		||||
                ]
 | 
			
		||||
                .as_ref(),
 | 
			
		||||
            )
 | 
			
		||||
            .split(area.inner(&Margin {
 | 
			
		||||
                horizontal: 2,
 | 
			
		||||
                vertical: 1,
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        let text = lines
 | 
			
		||||
            .iter()
 | 
			
		||||
            .map(|s| Spans::from(s.as_ref()))
 | 
			
		||||
            .collect::<Vec<_>>();
 | 
			
		||||
        let paragraph = Paragraph::new(text);
 | 
			
		||||
        f.render_widget(paragraph, chunks[0]);
 | 
			
		||||
 | 
			
		||||
        // Buttons
 | 
			
		||||
        let buttons_area = Layout::default()
 | 
			
		||||
            .direction(Direction::Horizontal)
 | 
			
		||||
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
 | 
			
		||||
            .split(chunks[1]);
 | 
			
		||||
 | 
			
		||||
        let cancel_button = ButtonWidget::new("Cancel", true).set_disabled(self.is_confirm);
 | 
			
		||||
        f.render_widget(cancel_button, buttons_area[0]);
 | 
			
		||||
 | 
			
		||||
        let ok_button = ButtonWidget::new("Confirm", true).set_disabled(!self.is_confirm);
 | 
			
		||||
        f.render_widget(ok_button, buttons_area[1]);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										620
									
								
								rust/sea_battle_cli_player/src/ui_screens/game_screen.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										620
									
								
								rust/sea_battle_cli_player/src/ui_screens/game_screen.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,620 @@
 | 
			
		||||
use std::cmp::max;
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::layout::{Constraint, Direction, Layout};
 | 
			
		||||
use tui::style::Color;
 | 
			
		||||
use tui::widgets::Paragraph;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::data::{Coordinates, CurrentGameMapStatus, CurrentGameStatus};
 | 
			
		||||
use sea_battle_backend::human_player_ws::{ClientMessage, ServerMessage};
 | 
			
		||||
use sea_battle_backend::utils::res_utils::Res;
 | 
			
		||||
use sea_battle_backend::utils::time_utils::time;
 | 
			
		||||
 | 
			
		||||
use crate::client::Client;
 | 
			
		||||
use crate::constants::*;
 | 
			
		||||
use crate::ui_screens::confirm_dialog_screen::confirm;
 | 
			
		||||
use crate::ui_screens::popup_screen::{show_screen_too_small_popup, PopupScreen};
 | 
			
		||||
use crate::ui_screens::set_boats_layout_screen::SetBoatsLayoutScreen;
 | 
			
		||||
use crate::ui_screens::utils::{
 | 
			
		||||
    centered_rect_size, centered_rect_size_horizontally, centered_text,
 | 
			
		||||
};
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crate::ui_widgets::button_widget::ButtonWidget;
 | 
			
		||||
use crate::ui_widgets::game_map_widget::{ColoredCells, GameMapWidget};
 | 
			
		||||
 | 
			
		||||
type CoordinatesMapper = HashMap<Coordinates, Coordinates>;
 | 
			
		||||
 | 
			
		||||
#[derive(Eq, PartialEq, Ord, PartialOrd)]
 | 
			
		||||
enum GameStatus {
 | 
			
		||||
    Connecting,
 | 
			
		||||
    WaitingForAnotherPlayer,
 | 
			
		||||
    OpponentConnected,
 | 
			
		||||
    WaitingForOpponentBoatsConfig,
 | 
			
		||||
    OpponentReady,
 | 
			
		||||
    Starting,
 | 
			
		||||
    MustFire,
 | 
			
		||||
    OpponentMustFire,
 | 
			
		||||
    WonGame,
 | 
			
		||||
    LostGame,
 | 
			
		||||
    RematchRequestedByOpponent,
 | 
			
		||||
    RematchRequestedByPlayer,
 | 
			
		||||
    RematchAccepted,
 | 
			
		||||
    RematchRejected,
 | 
			
		||||
    OpponentLeftGame,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl GameStatus {
 | 
			
		||||
    pub fn can_show_game_maps(&self) -> bool {
 | 
			
		||||
        self > &GameStatus::Starting
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn status_text(&self) -> &str {
 | 
			
		||||
        match self {
 | 
			
		||||
            GameStatus::Connecting => "🔌 Connecting...",
 | 
			
		||||
            GameStatus::WaitingForAnotherPlayer => "🕑 Waiting for another player...",
 | 
			
		||||
            GameStatus::OpponentConnected => "✅ Opponent connected!",
 | 
			
		||||
            GameStatus::WaitingForOpponentBoatsConfig => "🕑 Waiting for ### boats configuration",
 | 
			
		||||
            GameStatus::OpponentReady => "✅ ### is ready!",
 | 
			
		||||
            GameStatus::Starting => "🕑 Game is starting...",
 | 
			
		||||
            GameStatus::MustFire => "🚨 You must fire!",
 | 
			
		||||
            GameStatus::OpponentMustFire => "💣 ### must fire!",
 | 
			
		||||
            GameStatus::WonGame => "🎉 You win the game!",
 | 
			
		||||
            GameStatus::LostGame => "😿 ### wins the game. You loose.",
 | 
			
		||||
            GameStatus::RematchRequestedByOpponent => "❓ Rematch requested by ###",
 | 
			
		||||
            GameStatus::RematchRequestedByPlayer => "❓ Rematch requested by you",
 | 
			
		||||
            GameStatus::RematchAccepted => "✅ Rematch accepted!",
 | 
			
		||||
            GameStatus::RematchRejected => "❌ Rematch rejected!",
 | 
			
		||||
            GameStatus::OpponentLeftGame => "⛔ Opponent left game!",
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
 | 
			
		||||
enum Buttons {
 | 
			
		||||
    RequestRematch,
 | 
			
		||||
    AcceptRematch,
 | 
			
		||||
    RejectRematch,
 | 
			
		||||
    QuitGame,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Buttons {
 | 
			
		||||
    pub fn text(&self) -> &str {
 | 
			
		||||
        match self {
 | 
			
		||||
            Buttons::RequestRematch => "❓ Request rematch",
 | 
			
		||||
            Buttons::AcceptRematch => "✅ Accept rematch",
 | 
			
		||||
            Buttons::RejectRematch => "❌ Reject rematch",
 | 
			
		||||
            Buttons::QuitGame => "❌ Quit game",
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct GameScreen {
 | 
			
		||||
    client: Client,
 | 
			
		||||
    invite_code: Option<String>,
 | 
			
		||||
    status: GameStatus,
 | 
			
		||||
    opponent_name: Option<String>,
 | 
			
		||||
    game_last_update: u64,
 | 
			
		||||
    game: CurrentGameStatus,
 | 
			
		||||
    curr_shoot_position: Coordinates,
 | 
			
		||||
    last_opponent_fire_position: Coordinates,
 | 
			
		||||
    curr_button: usize,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl GameScreen {
 | 
			
		||||
    pub fn new(client: Client) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            client,
 | 
			
		||||
            invite_code: None,
 | 
			
		||||
            status: GameStatus::Connecting,
 | 
			
		||||
            opponent_name: None,
 | 
			
		||||
            game_last_update: 0,
 | 
			
		||||
            game: Default::default(),
 | 
			
		||||
            curr_shoot_position: Coordinates::new(0, 0),
 | 
			
		||||
            last_opponent_fire_position: Coordinates::invalid(),
 | 
			
		||||
            curr_button: 0,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub async fn show<B: Backend>(mut self, terminal: &mut Terminal<B>) -> Res<ScreenResult> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
 | 
			
		||||
        let mut coordinates_mapper = CoordinatesMapper::new();
 | 
			
		||||
 | 
			
		||||
        loop {
 | 
			
		||||
            if !self.visible_buttons().is_empty() {
 | 
			
		||||
                self.curr_button %= self.visible_buttons().len();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Update UI
 | 
			
		||||
            terminal.draw(|f| coordinates_mapper = self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            // Handle terminal events
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                let event = event::read()?;
 | 
			
		||||
 | 
			
		||||
                // Keyboard event
 | 
			
		||||
                if let Event::Key(key) = &event {
 | 
			
		||||
                    let mut new_shoot_pos = self.curr_shoot_position;
 | 
			
		||||
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        // Leave game
 | 
			
		||||
                        KeyCode::Char('q')
 | 
			
		||||
                            if confirm(terminal, "Do you really want to leave game?") =>
 | 
			
		||||
                        {
 | 
			
		||||
                            self.client.close_connection().await;
 | 
			
		||||
                            return Ok(ScreenResult::Canceled);
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        // Move  shoot cursor
 | 
			
		||||
                        KeyCode::Left if self.can_fire() => new_shoot_pos = new_shoot_pos.add_x(-1),
 | 
			
		||||
                        KeyCode::Right if self.can_fire() => new_shoot_pos = new_shoot_pos.add_x(1),
 | 
			
		||||
                        KeyCode::Up if self.can_fire() => new_shoot_pos = new_shoot_pos.add_y(-1),
 | 
			
		||||
                        KeyCode::Down if self.can_fire() => new_shoot_pos = new_shoot_pos.add_y(1),
 | 
			
		||||
 | 
			
		||||
                        // Shoot
 | 
			
		||||
                        KeyCode::Enter if self.can_fire() => {
 | 
			
		||||
                            if self.game.can_fire_at_location(self.curr_shoot_position) {
 | 
			
		||||
                                self.client
 | 
			
		||||
                                    .send_message(&ClientMessage::Fire {
 | 
			
		||||
                                        location: self.curr_shoot_position,
 | 
			
		||||
                                    })
 | 
			
		||||
                                    .await?;
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        // Change buttons
 | 
			
		||||
                        KeyCode::Left if self.game_over() => {
 | 
			
		||||
                            self.curr_button += self.visible_buttons().len() - 1
 | 
			
		||||
                        }
 | 
			
		||||
                        KeyCode::Right if self.game_over() => self.curr_button += 1,
 | 
			
		||||
                        KeyCode::Tab if self.game_over() => self.curr_button += 1,
 | 
			
		||||
 | 
			
		||||
                        // Submit button
 | 
			
		||||
                        KeyCode::Enter if self.game_over() => match self.curr_button() {
 | 
			
		||||
                            Buttons::RequestRematch => {
 | 
			
		||||
                                self.client
 | 
			
		||||
                                    .send_message(&ClientMessage::RequestRematch)
 | 
			
		||||
                                    .await?;
 | 
			
		||||
                                self.status = GameStatus::RematchRequestedByPlayer;
 | 
			
		||||
                            }
 | 
			
		||||
                            Buttons::AcceptRematch => {
 | 
			
		||||
                                self.client
 | 
			
		||||
                                    .send_message(&ClientMessage::AcceptRematch)
 | 
			
		||||
                                    .await?;
 | 
			
		||||
                                self.status = GameStatus::RematchAccepted;
 | 
			
		||||
                            }
 | 
			
		||||
                            Buttons::RejectRematch => {
 | 
			
		||||
                                self.client
 | 
			
		||||
                                    .send_message(&ClientMessage::RejectRematch)
 | 
			
		||||
                                    .await?;
 | 
			
		||||
                                self.status = GameStatus::RematchRejected;
 | 
			
		||||
                            }
 | 
			
		||||
                            Buttons::QuitGame => {
 | 
			
		||||
                                self.client.close_connection().await;
 | 
			
		||||
                                return Ok(ScreenResult::Ok(()));
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if new_shoot_pos.is_valid(&self.game.rules) {
 | 
			
		||||
                        self.curr_shoot_position = new_shoot_pos;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Mouse event
 | 
			
		||||
                if let Event::Mouse(mouse) = event {
 | 
			
		||||
                    if mouse.kind == MouseEventKind::Up(MouseButton::Left) {
 | 
			
		||||
                        if let Some(c) =
 | 
			
		||||
                            coordinates_mapper.get(&Coordinates::new(mouse.column, mouse.row))
 | 
			
		||||
                        {
 | 
			
		||||
                            self.curr_shoot_position = *c;
 | 
			
		||||
 | 
			
		||||
                            if self.can_fire()
 | 
			
		||||
                                && self.game.can_fire_at_location(self.curr_shoot_position)
 | 
			
		||||
                            {
 | 
			
		||||
                                self.client
 | 
			
		||||
                                    .send_message(&ClientMessage::Fire {
 | 
			
		||||
                                        location: self.curr_shoot_position,
 | 
			
		||||
                                    })
 | 
			
		||||
                                    .await?;
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Handle incoming messages
 | 
			
		||||
            while let Some(msg) = self.client.try_recv_next_message().await? {
 | 
			
		||||
                match msg {
 | 
			
		||||
                    ServerMessage::SetInviteCode { code } => {
 | 
			
		||||
                        self.status = GameStatus::WaitingForAnotherPlayer;
 | 
			
		||||
                        self.invite_code = Some(code);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::InvalidInviteCode => {
 | 
			
		||||
                        PopupScreen::new("❌ Invalid invite code!").show(terminal)?;
 | 
			
		||||
                        return Ok(ScreenResult::Ok(()));
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::WaitingForAnotherPlayer => {
 | 
			
		||||
                        self.status = GameStatus::WaitingForAnotherPlayer;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentConnected => {
 | 
			
		||||
                        self.status = GameStatus::OpponentConnected;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::SetOpponentName { name } => self.opponent_name = Some(name),
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::QueryBoatsLayout { rules } => {
 | 
			
		||||
                        match SetBoatsLayoutScreen::new(&rules)
 | 
			
		||||
                            .set_confirm_on_cancel(true)
 | 
			
		||||
                            .show(terminal)?
 | 
			
		||||
                        {
 | 
			
		||||
                            ScreenResult::Ok(layout) => {
 | 
			
		||||
                                self.client
 | 
			
		||||
                                    .send_message(&ClientMessage::BoatsLayout { layout })
 | 
			
		||||
                                    .await?
 | 
			
		||||
                            }
 | 
			
		||||
                            ScreenResult::Canceled => {
 | 
			
		||||
                                self.client.close_connection().await;
 | 
			
		||||
                                return Ok(ScreenResult::Canceled);
 | 
			
		||||
                            }
 | 
			
		||||
                        };
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::RejectedBoatsLayout { .. } => {
 | 
			
		||||
                        PopupScreen::new("Server rejected boats layout!! (is your version of SeaBattle up to date?)")
 | 
			
		||||
                            .show(terminal)?;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::WaitingForOtherPlayerConfiguration => {
 | 
			
		||||
                        self.status = GameStatus::WaitingForOpponentBoatsConfig;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentReady => {
 | 
			
		||||
                        self.status = GameStatus::OpponentReady;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::GameStarting => {
 | 
			
		||||
                        self.status = GameStatus::Starting;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentMustFire { status } => {
 | 
			
		||||
                        self.status = GameStatus::OpponentMustFire;
 | 
			
		||||
                        self.game_last_update = time();
 | 
			
		||||
                        self.game = status;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::RequestFire { status } => {
 | 
			
		||||
                        self.status = GameStatus::MustFire;
 | 
			
		||||
                        self.game_last_update = time();
 | 
			
		||||
                        self.game = status;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::FireResult { .. } => { /* not used */ }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentFireResult { pos, .. } => {
 | 
			
		||||
                        self.last_opponent_fire_position = pos;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::LostGame { status } => {
 | 
			
		||||
                        self.game_last_update = time();
 | 
			
		||||
                        self.game = status;
 | 
			
		||||
                        self.status = GameStatus::LostGame;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::WonGame { status } => {
 | 
			
		||||
                        self.game_last_update = time();
 | 
			
		||||
                        self.game = status;
 | 
			
		||||
                        self.status = GameStatus::WonGame;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentRequestedRematch => {
 | 
			
		||||
                        self.status = GameStatus::RematchRequestedByOpponent;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentAcceptedRematch => {
 | 
			
		||||
                        self.status = GameStatus::RematchAccepted;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentRejectedRematch => {
 | 
			
		||||
                        self.status = GameStatus::RematchRejected;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentLeftGame => {
 | 
			
		||||
                        self.status = GameStatus::OpponentLeftGame;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    ServerMessage::OpponentReplacedByBot => {
 | 
			
		||||
                        PopupScreen::new("Opponent was replaced by a bot.").show(terminal)?;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn can_fire(&self) -> bool {
 | 
			
		||||
        matches!(self.status, GameStatus::MustFire)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn game_over(&self) -> bool {
 | 
			
		||||
        self.game.is_game_over()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn visible_buttons(&self) -> Vec<Buttons> {
 | 
			
		||||
        let mut buttons = vec![];
 | 
			
		||||
        if self.game_over() && self.status != GameStatus::RematchAccepted {
 | 
			
		||||
            // Respond to rematch request / quit
 | 
			
		||||
            if self.status == GameStatus::RematchRequestedByOpponent {
 | 
			
		||||
                buttons.push(Buttons::AcceptRematch);
 | 
			
		||||
                buttons.push(Buttons::RejectRematch);
 | 
			
		||||
            } else if self.status != GameStatus::OpponentLeftGame
 | 
			
		||||
                && self.status != GameStatus::RematchRejected
 | 
			
		||||
                && self.status != GameStatus::RematchRequestedByPlayer
 | 
			
		||||
            {
 | 
			
		||||
                buttons.push(Buttons::RequestRematch);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            buttons.push(Buttons::QuitGame);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        buttons
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn opponent_name(&self) -> &str {
 | 
			
		||||
        self.opponent_name.as_deref().unwrap_or("opponent")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn curr_button(&self) -> Buttons {
 | 
			
		||||
        self.visible_buttons()[self.curr_button]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn player_map(&self, map: &CurrentGameMapStatus, opponent_map: bool) -> GameMapWidget {
 | 
			
		||||
        let mut map_widget = GameMapWidget::new(&self.game.rules).set_default_empty_char(' ');
 | 
			
		||||
 | 
			
		||||
        // Current shoot position
 | 
			
		||||
        if opponent_map {
 | 
			
		||||
            map_widget = map_widget.add_colored_cells(ColoredCells {
 | 
			
		||||
                color: match (
 | 
			
		||||
                    self.game.can_fire_at_location(self.curr_shoot_position),
 | 
			
		||||
                    self.game
 | 
			
		||||
                        .opponent_map
 | 
			
		||||
                        .successful_strikes
 | 
			
		||||
                        .contains(&self.curr_shoot_position),
 | 
			
		||||
                ) {
 | 
			
		||||
                    (true, _) => Color::Green,
 | 
			
		||||
                    (false, false) => Color::LightYellow,
 | 
			
		||||
                    (false, true) => Color::LightRed,
 | 
			
		||||
                },
 | 
			
		||||
                cells: vec![self.curr_shoot_position],
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            map_widget = map_widget.add_colored_cells(ColoredCells {
 | 
			
		||||
                color: Color::Green,
 | 
			
		||||
                cells: vec![self.last_opponent_fire_position],
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Sunk boats
 | 
			
		||||
        for b in &map.sunk_boats {
 | 
			
		||||
            for c in b.all_coordinates() {
 | 
			
		||||
                map_widget =
 | 
			
		||||
                    map_widget.set_char(c, b.len.to_string().chars().next().unwrap_or('9'));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        let sunk_boats = ColoredCells {
 | 
			
		||||
            color: Color::LightRed,
 | 
			
		||||
            cells: map
 | 
			
		||||
                .sunk_boats
 | 
			
		||||
                .iter()
 | 
			
		||||
                .flat_map(|b| b.all_coordinates())
 | 
			
		||||
                .collect::<Vec<_>>(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Touched boats
 | 
			
		||||
        for b in &map.successful_strikes {
 | 
			
		||||
            map_widget = map_widget.set_char_no_overwrite(*b, 'T');
 | 
			
		||||
        }
 | 
			
		||||
        let touched_areas = ColoredCells {
 | 
			
		||||
            color: Color::Red,
 | 
			
		||||
            cells: map.successful_strikes.clone(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Failed strikes
 | 
			
		||||
        for b in &map.failed_strikes {
 | 
			
		||||
            map_widget = map_widget.set_char_no_overwrite(*b, '.');
 | 
			
		||||
        }
 | 
			
		||||
        let failed_strikes = ColoredCells {
 | 
			
		||||
            color: Color::Black,
 | 
			
		||||
            cells: map.failed_strikes.clone(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Boats
 | 
			
		||||
        for b in &map.boats.0 {
 | 
			
		||||
            for c in b.all_coordinates() {
 | 
			
		||||
                map_widget = map_widget.set_char_no_overwrite(c, 'B');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        let boats = ColoredCells {
 | 
			
		||||
            color: Color::Blue,
 | 
			
		||||
            cells: map
 | 
			
		||||
                .boats
 | 
			
		||||
                .0
 | 
			
		||||
                .iter()
 | 
			
		||||
                .flat_map(|b| b.all_coordinates())
 | 
			
		||||
                .collect::<Vec<_>>(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        map_widget
 | 
			
		||||
            .add_colored_cells(sunk_boats)
 | 
			
		||||
            .add_colored_cells(touched_areas)
 | 
			
		||||
            .add_colored_cells(failed_strikes)
 | 
			
		||||
            .add_colored_cells(boats)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) -> CoordinatesMapper {
 | 
			
		||||
        let mut status_text = self
 | 
			
		||||
            .status
 | 
			
		||||
            .status_text()
 | 
			
		||||
            .replace("###", self.opponent_name());
 | 
			
		||||
 | 
			
		||||
        // If the game is in a state where game maps can not be shown
 | 
			
		||||
        if !self.status.can_show_game_maps() {
 | 
			
		||||
            if self.status == GameStatus::WaitingForAnotherPlayer {
 | 
			
		||||
                if let Some(code) = &self.invite_code {
 | 
			
		||||
                    status_text.push_str(&format!("\n\n🎫 Invite code: {}", code));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            PopupScreen::new(&status_text).show_in_frame(f);
 | 
			
		||||
            return HashMap::default();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add timeout (if required)
 | 
			
		||||
        let mut timeout_str = String::new();
 | 
			
		||||
        if self.status == GameStatus::MustFire || self.status == GameStatus::OpponentMustFire {
 | 
			
		||||
            if let Some(remaining) = self.game.remaining_time_for_strike {
 | 
			
		||||
                let timeout = self.game_last_update + remaining;
 | 
			
		||||
                if time() < timeout {
 | 
			
		||||
                    timeout_str = format!(" {} seconds left", timeout - time());
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Draw main ui (default play UI)
 | 
			
		||||
        let player_map = self
 | 
			
		||||
            .player_map(&self.game.your_map, false)
 | 
			
		||||
            .set_title("YOUR map");
 | 
			
		||||
 | 
			
		||||
        let mut coordinates_mapper = HashMap::new();
 | 
			
		||||
        let mut opponent_map = self
 | 
			
		||||
            .player_map(&self.game.opponent_map, true)
 | 
			
		||||
            .set_title(self.opponent_name())
 | 
			
		||||
            .set_yield_func(|c, r| {
 | 
			
		||||
                for i in 0..r.width {
 | 
			
		||||
                    for j in 0..r.height {
 | 
			
		||||
                        coordinates_mapper.insert(Coordinates::new(r.x + i, r.y + j), c);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        if self.can_fire() {
 | 
			
		||||
            opponent_map = opponent_map
 | 
			
		||||
                .set_legend("Use arrows + Enter\nor click on the place\nwhere you want\nto shoot");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Prepare buttons
 | 
			
		||||
        let buttons = self
 | 
			
		||||
            .visible_buttons()
 | 
			
		||||
            .iter()
 | 
			
		||||
            .map(|b| ButtonWidget::new(b.text(), self.curr_button() == *b))
 | 
			
		||||
            .collect::<Vec<_>>();
 | 
			
		||||
 | 
			
		||||
        // Show both maps if there is enough room on the screen
 | 
			
		||||
        let player_map_size = player_map.estimated_size();
 | 
			
		||||
        let opponent_map_size = opponent_map.estimated_size();
 | 
			
		||||
        let both_maps_width = player_map_size.0 + opponent_map_size.0 + 3;
 | 
			
		||||
        let show_both_maps = both_maps_width <= f.size().width;
 | 
			
		||||
 | 
			
		||||
        let maps_height = max(player_map_size.1, opponent_map_size.1);
 | 
			
		||||
        let maps_width = match show_both_maps {
 | 
			
		||||
            true => both_maps_width,
 | 
			
		||||
            false => max(player_map_size.0, opponent_map_size.0),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        let buttons_width = buttons.iter().fold(0, |a, b| a + b.estimated_size().0 + 4);
 | 
			
		||||
 | 
			
		||||
        let max_width = max(maps_width, status_text.len() as u16)
 | 
			
		||||
            .max(buttons_width)
 | 
			
		||||
            .max(timeout_str.len() as u16);
 | 
			
		||||
        let total_height = 3 + 1 + maps_height + 3;
 | 
			
		||||
 | 
			
		||||
        // Check if frame is too small
 | 
			
		||||
        if max_width > f.size().width || total_height > f.size().height {
 | 
			
		||||
            show_screen_too_small_popup(f);
 | 
			
		||||
            return HashMap::default();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let chunks = Layout::default()
 | 
			
		||||
            .direction(Direction::Vertical)
 | 
			
		||||
            .constraints([
 | 
			
		||||
                Constraint::Length(2),
 | 
			
		||||
                Constraint::Length(2),
 | 
			
		||||
                Constraint::Length(maps_height),
 | 
			
		||||
                Constraint::Length(3),
 | 
			
		||||
            ])
 | 
			
		||||
            .split(centered_rect_size(max_width, total_height, &f.size()));
 | 
			
		||||
 | 
			
		||||
        // Render status
 | 
			
		||||
        let paragraph = Paragraph::new(status_text.as_str());
 | 
			
		||||
        f.render_widget(paragraph, centered_text(&status_text, &chunks[0]));
 | 
			
		||||
 | 
			
		||||
        // Render timeout
 | 
			
		||||
        let paragraph = Paragraph::new(timeout_str.as_str());
 | 
			
		||||
        f.render_widget(paragraph, centered_text(&timeout_str, &chunks[1]));
 | 
			
		||||
 | 
			
		||||
        // Render maps
 | 
			
		||||
        if show_both_maps {
 | 
			
		||||
            let maps_chunks = Layout::default()
 | 
			
		||||
                .direction(Direction::Horizontal)
 | 
			
		||||
                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
 | 
			
		||||
                .split(chunks[2]);
 | 
			
		||||
 | 
			
		||||
            f.render_widget(
 | 
			
		||||
                player_map,
 | 
			
		||||
                centered_rect_size_horizontally(player_map_size.0, &maps_chunks[0]),
 | 
			
		||||
            );
 | 
			
		||||
            f.render_widget(
 | 
			
		||||
                opponent_map,
 | 
			
		||||
                centered_rect_size_horizontally(opponent_map_size.0, &maps_chunks[1]),
 | 
			
		||||
            );
 | 
			
		||||
        } else {
 | 
			
		||||
            // Render a single map
 | 
			
		||||
            if self.can_fire() {
 | 
			
		||||
                f.render_widget(opponent_map, chunks[2]);
 | 
			
		||||
            } else {
 | 
			
		||||
                f.render_widget(player_map, chunks[2]);
 | 
			
		||||
                drop(opponent_map);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Render buttons
 | 
			
		||||
        if !buttons.is_empty() {
 | 
			
		||||
            let buttons_area = Layout::default()
 | 
			
		||||
                .direction(Direction::Horizontal)
 | 
			
		||||
                .constraints(
 | 
			
		||||
                    (0..buttons.len())
 | 
			
		||||
                        .map(|_| Constraint::Percentage(100 / buttons.len() as u16))
 | 
			
		||||
                        .collect::<Vec<_>>(),
 | 
			
		||||
                )
 | 
			
		||||
                .split(chunks[3]);
 | 
			
		||||
 | 
			
		||||
            for (idx, b) in buttons.into_iter().enumerate() {
 | 
			
		||||
                let target = centered_rect_size(
 | 
			
		||||
                    b.estimated_size().0,
 | 
			
		||||
                    b.estimated_size().1,
 | 
			
		||||
                    &buttons_area[idx],
 | 
			
		||||
                );
 | 
			
		||||
                f.render_widget(b, target);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        coordinates_mapper
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										187
									
								
								rust/sea_battle_cli_player/src/ui_screens/input_screen.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								rust/sea_battle_cli_player/src/ui_screens/input_screen.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,187 @@
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
use tui::style::*;
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::layout::*;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use crate::constants::*;
 | 
			
		||||
use crate::ui_screens::utils::*;
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crate::ui_widgets::button_widget::ButtonWidget;
 | 
			
		||||
use crate::ui_widgets::text_editor_widget::TextEditorWidget;
 | 
			
		||||
 | 
			
		||||
pub struct InputScreen<'a> {
 | 
			
		||||
    title: &'a str,
 | 
			
		||||
    msg: &'a str,
 | 
			
		||||
    input_label: &'a str,
 | 
			
		||||
    value: String,
 | 
			
		||||
    can_cancel: bool,
 | 
			
		||||
    is_cancel_hovered: bool,
 | 
			
		||||
    value_required: bool,
 | 
			
		||||
    min_len: usize,
 | 
			
		||||
    max_len: usize,
 | 
			
		||||
    has_already_been_edited: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> InputScreen<'a> {
 | 
			
		||||
    pub fn new(msg: &'a str) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            title: "Input",
 | 
			
		||||
            msg,
 | 
			
		||||
            input_label: "",
 | 
			
		||||
            value: "".to_string(),
 | 
			
		||||
            can_cancel: true,
 | 
			
		||||
            is_cancel_hovered: false,
 | 
			
		||||
            value_required: true,
 | 
			
		||||
            min_len: 1,
 | 
			
		||||
            max_len: 10,
 | 
			
		||||
            has_already_been_edited: false,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_title(mut self, title: &'a str) -> Self {
 | 
			
		||||
        self.title = title;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_value<D: Display>(mut self, value: D) -> Self {
 | 
			
		||||
        self.value = value.to_string();
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_min_length(mut self, v: usize) -> Self {
 | 
			
		||||
        self.min_len = v;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_max_length(mut self, v: usize) -> Self {
 | 
			
		||||
        self.max_len = v;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get error contained in input
 | 
			
		||||
    fn error(&self) -> Option<&'static str> {
 | 
			
		||||
        if self.value.len() > self.max_len {
 | 
			
		||||
            Some("Input is too large!")
 | 
			
		||||
        } else if self.value.is_empty() && !self.value_required {
 | 
			
		||||
            None
 | 
			
		||||
        } else if self.value.len() < self.min_len {
 | 
			
		||||
            Some("Input is too small!")
 | 
			
		||||
        } else {
 | 
			
		||||
            None
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn show<B: Backend>(
 | 
			
		||||
        mut self,
 | 
			
		||||
        terminal: &mut Terminal<B>,
 | 
			
		||||
    ) -> io::Result<ScreenResult<String>> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        loop {
 | 
			
		||||
            terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                if let Event::Key(key) = event::read()? {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        KeyCode::Esc => return Ok(ScreenResult::Canceled),
 | 
			
		||||
                        KeyCode::Tab if self.can_cancel => {
 | 
			
		||||
                            self.is_cancel_hovered = !self.is_cancel_hovered;
 | 
			
		||||
                        }
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            if self.is_cancel_hovered {
 | 
			
		||||
                                return Ok(ScreenResult::Canceled);
 | 
			
		||||
                            } else if self.error().is_none() {
 | 
			
		||||
                                return Ok(ScreenResult::Ok(self.value));
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        KeyCode::Backspace => {
 | 
			
		||||
                            self.value.pop();
 | 
			
		||||
                        }
 | 
			
		||||
                        KeyCode::Char(c) => {
 | 
			
		||||
                            if self.value.len() < self.max_len {
 | 
			
		||||
                                self.has_already_been_edited = true;
 | 
			
		||||
                                self.value.push(c);
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
 | 
			
		||||
        let area = centered_rect_size(
 | 
			
		||||
            (self.msg.len() + 4).max(self.max_len + 4).max(25) as u16,
 | 
			
		||||
            7,
 | 
			
		||||
            &f.size(),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        let error = self.error();
 | 
			
		||||
 | 
			
		||||
        let block = Block::default().borders(Borders::ALL).title(self.title);
 | 
			
		||||
        f.render_widget(block, area);
 | 
			
		||||
 | 
			
		||||
        // Create two chunks with equal horizontal screen space
 | 
			
		||||
        let chunks = Layout::default()
 | 
			
		||||
            .direction(Direction::Vertical)
 | 
			
		||||
            .constraints(
 | 
			
		||||
                [
 | 
			
		||||
                    Constraint::Length(1),
 | 
			
		||||
                    Constraint::Length(3),
 | 
			
		||||
                    Constraint::Length(3),
 | 
			
		||||
                ]
 | 
			
		||||
                .as_ref(),
 | 
			
		||||
            )
 | 
			
		||||
            .split(area.inner(&Margin {
 | 
			
		||||
                horizontal: 2,
 | 
			
		||||
                vertical: 1,
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        let paragraph = Paragraph::new(self.msg);
 | 
			
		||||
        f.render_widget(paragraph, chunks[0]);
 | 
			
		||||
 | 
			
		||||
        let input_widget =
 | 
			
		||||
            TextEditorWidget::new(self.input_label, &self.value, !self.is_cancel_hovered);
 | 
			
		||||
        f.render_widget(input_widget, chunks[1]);
 | 
			
		||||
 | 
			
		||||
        let buttons_area = Layout::default()
 | 
			
		||||
            .direction(Direction::Horizontal)
 | 
			
		||||
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
 | 
			
		||||
            .split(*chunks.last().unwrap());
 | 
			
		||||
 | 
			
		||||
        let cancel_button = ButtonWidget::new("Cancel", self.is_cancel_hovered)
 | 
			
		||||
            .set_disabled(!self.can_cancel)
 | 
			
		||||
            .set_min_width(8);
 | 
			
		||||
        f.render_widget(cancel_button, buttons_area[0]);
 | 
			
		||||
 | 
			
		||||
        let ok_button = ButtonWidget::new("OK", !self.is_cancel_hovered)
 | 
			
		||||
            .set_min_width(8)
 | 
			
		||||
            .set_disabled(error.is_some());
 | 
			
		||||
        f.render_widget(ok_button, buttons_area[1]);
 | 
			
		||||
 | 
			
		||||
        // Render error (if any)
 | 
			
		||||
        if let (Some(e), true) = (error, self.has_already_been_edited) {
 | 
			
		||||
            let target_area = centered_text(
 | 
			
		||||
                e,
 | 
			
		||||
                &Rect::new(f.size().x, area.bottom() + 2, f.size().width, 1),
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            let paragraph = Paragraph::new(e).style(Style::default().fg(Color::Red));
 | 
			
		||||
            f.render_widget(paragraph, target_area)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								rust/sea_battle_cli_player/src/ui_screens/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								rust/sea_battle_cli_player/src/ui_screens/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
use std::fmt::Debug;
 | 
			
		||||
 | 
			
		||||
pub mod configure_game_rules;
 | 
			
		||||
pub mod confirm_dialog_screen;
 | 
			
		||||
pub mod game_screen;
 | 
			
		||||
pub mod input_screen;
 | 
			
		||||
pub mod popup_screen;
 | 
			
		||||
pub mod select_bot_type_screen;
 | 
			
		||||
pub mod select_play_mode_screen;
 | 
			
		||||
pub mod set_boats_layout_screen;
 | 
			
		||||
pub mod utils;
 | 
			
		||||
 | 
			
		||||
#[derive(Debug)]
 | 
			
		||||
pub enum ScreenResult<E = ()> {
 | 
			
		||||
    Ok(E),
 | 
			
		||||
    Canceled,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<E: Debug> ScreenResult<E> {
 | 
			
		||||
    pub fn value(self) -> Option<E> {
 | 
			
		||||
        match self {
 | 
			
		||||
            ScreenResult::Ok(v) => Some(v),
 | 
			
		||||
            ScreenResult::Canceled => None,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn as_string(&self) -> String {
 | 
			
		||||
        format!("{:#?}", self)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										127
									
								
								rust/sea_battle_cli_player/src/ui_screens/popup_screen.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								rust/sea_battle_cli_player/src/ui_screens/popup_screen.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,127 @@
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::layout::*;
 | 
			
		||||
use tui::text::*;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use crate::constants::*;
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crate::ui_widgets::button_widget::ButtonWidget;
 | 
			
		||||
 | 
			
		||||
/// Convenience function to inform user that his terminal window is too small to display the current
 | 
			
		||||
/// screen
 | 
			
		||||
pub fn show_screen_too_small_popup<B: Backend>(f: &mut Frame<B>) {
 | 
			
		||||
    PopupScreen::new("🖵  Screen too small!").show_in_frame(f)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct PopupScreen<'a> {
 | 
			
		||||
    title: &'a str,
 | 
			
		||||
    msg: &'a str,
 | 
			
		||||
    can_close: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> PopupScreen<'a> {
 | 
			
		||||
    pub fn new(msg: &'a str) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            title: "Message",
 | 
			
		||||
            msg,
 | 
			
		||||
            can_close: true,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_can_close(mut self, can_close: bool) -> Self {
 | 
			
		||||
        self.can_close = can_close;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn show<B: Backend>(mut self, terminal: &mut Terminal<B>) -> io::Result<ScreenResult<()>> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        loop {
 | 
			
		||||
            terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                if let Event::Key(key) = event::read()? {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            return Ok(ScreenResult::Ok(()));
 | 
			
		||||
                        }
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Show message once message, without polling messages
 | 
			
		||||
    pub fn show_once<B: Backend>(mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
 | 
			
		||||
        self.can_close = false;
 | 
			
		||||
        terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Show message once message in a frame, without polling messages
 | 
			
		||||
    pub fn show_in_frame<B: Backend>(mut self, frame: &mut Frame<B>) {
 | 
			
		||||
        self.can_close = false;
 | 
			
		||||
        self.ui(frame)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
 | 
			
		||||
        // Preprocess message
 | 
			
		||||
        let lines = textwrap::wrap(self.msg, f.size().width as usize - 20);
 | 
			
		||||
        let line_max_len = lines.iter().map(|l| l.len()).max().unwrap();
 | 
			
		||||
 | 
			
		||||
        let area = centered_rect_size(
 | 
			
		||||
            line_max_len as u16 + 4,
 | 
			
		||||
            match self.can_close {
 | 
			
		||||
                // reserve space for button
 | 
			
		||||
                true => 5,
 | 
			
		||||
                false => 2,
 | 
			
		||||
            } + lines.len() as u16,
 | 
			
		||||
            &f.size(),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        let block = Block::default().borders(Borders::ALL).title(self.title);
 | 
			
		||||
        f.render_widget(block, area);
 | 
			
		||||
 | 
			
		||||
        // Create two chunks with equal horizontal screen space
 | 
			
		||||
        let chunks = Layout::default()
 | 
			
		||||
            .direction(Direction::Vertical)
 | 
			
		||||
            .constraints(
 | 
			
		||||
                [
 | 
			
		||||
                    Constraint::Length(lines.len() as u16),
 | 
			
		||||
                    Constraint::Length(3),
 | 
			
		||||
                ]
 | 
			
		||||
                .as_ref(),
 | 
			
		||||
            )
 | 
			
		||||
            .split(area.inner(&Margin {
 | 
			
		||||
                horizontal: 2,
 | 
			
		||||
                vertical: 1,
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        let text = lines
 | 
			
		||||
            .iter()
 | 
			
		||||
            .map(|s| Spans::from(s.as_ref()))
 | 
			
		||||
            .collect::<Vec<_>>();
 | 
			
		||||
        let paragraph = Paragraph::new(text);
 | 
			
		||||
        f.render_widget(paragraph, chunks[0]);
 | 
			
		||||
 | 
			
		||||
        if self.can_close {
 | 
			
		||||
            let ok_button = ButtonWidget::new("OK", true);
 | 
			
		||||
            f.render_widget(ok_button, chunks[1]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,102 @@
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::style::*;
 | 
			
		||||
use tui::text::*;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::data::{BotDescription, BotType, PlayConfiguration};
 | 
			
		||||
 | 
			
		||||
use crate::constants::{HIGHLIGHT_COLOR, TICK_RATE};
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
 | 
			
		||||
pub struct SelectBotTypeScreen {
 | 
			
		||||
    state: ListState,
 | 
			
		||||
    curr_selection: usize,
 | 
			
		||||
    types: Vec<BotDescription>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for SelectBotTypeScreen {
 | 
			
		||||
    fn default() -> Self {
 | 
			
		||||
        let types = PlayConfiguration::default().bot_types;
 | 
			
		||||
        Self {
 | 
			
		||||
            state: Default::default(),
 | 
			
		||||
            curr_selection: types.len() - 1,
 | 
			
		||||
            types: types.to_vec(),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl SelectBotTypeScreen {
 | 
			
		||||
    pub fn show<B: Backend>(
 | 
			
		||||
        mut self,
 | 
			
		||||
        terminal: &mut Terminal<B>,
 | 
			
		||||
    ) -> io::Result<ScreenResult<BotType>> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        loop {
 | 
			
		||||
            self.state.select(Some(self.curr_selection));
 | 
			
		||||
            terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                if let Event::Key(key) = event::read()? {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            return Ok(ScreenResult::Ok(self.types[self.curr_selection].r#type));
 | 
			
		||||
                        }
 | 
			
		||||
                        KeyCode::Down => self.curr_selection += 1,
 | 
			
		||||
                        KeyCode::Up => self.curr_selection += self.types.len() - 1,
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    self.curr_selection %= self.types.len();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
 | 
			
		||||
        let area = centered_rect_size(60, self.types.len() as u16 * 2 + 2, &f.size());
 | 
			
		||||
 | 
			
		||||
        // Create a List from all list items and highlight the currently selected one
 | 
			
		||||
        let items = self
 | 
			
		||||
            .types
 | 
			
		||||
            .iter()
 | 
			
		||||
            .map(|bot| {
 | 
			
		||||
                ListItem::new(vec![
 | 
			
		||||
                    Spans::from(bot.name),
 | 
			
		||||
                    Spans::from(Span::styled(
 | 
			
		||||
                        bot.description,
 | 
			
		||||
                        Style::default().add_modifier(Modifier::ITALIC),
 | 
			
		||||
                    )),
 | 
			
		||||
                ])
 | 
			
		||||
            })
 | 
			
		||||
            .collect::<Vec<_>>();
 | 
			
		||||
        let items = List::new(items)
 | 
			
		||||
            .block(
 | 
			
		||||
                Block::default()
 | 
			
		||||
                    .title("Select bot type")
 | 
			
		||||
                    .borders(Borders::ALL),
 | 
			
		||||
            )
 | 
			
		||||
            .highlight_style(
 | 
			
		||||
                Style::default()
 | 
			
		||||
                    .fg(HIGHLIGHT_COLOR)
 | 
			
		||||
                    .add_modifier(Modifier::BOLD),
 | 
			
		||||
            )
 | 
			
		||||
            .highlight_symbol(">> ");
 | 
			
		||||
 | 
			
		||||
        f.render_stateful_widget(items, area, &mut self.state);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,133 @@
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crate::constants::{HIGHLIGHT_COLOR, TICK_RATE};
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::style::*;
 | 
			
		||||
use tui::text::Text;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Eq, PartialEq, Copy, Clone, Default)]
 | 
			
		||||
pub enum SelectPlayModeResult {
 | 
			
		||||
    #[default]
 | 
			
		||||
    PlayAgainstBot,
 | 
			
		||||
    PlayRandom,
 | 
			
		||||
    CreateInvite,
 | 
			
		||||
    AcceptInvite,
 | 
			
		||||
    Exit,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl SelectPlayModeResult {
 | 
			
		||||
    /// Specify whether a selected play mode requires a user name or not
 | 
			
		||||
    pub fn need_player_name(&self) -> bool {
 | 
			
		||||
        self != &SelectPlayModeResult::PlayAgainstBot && self != &SelectPlayModeResult::Exit
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Specify whether a selected play mode requires a the user to specify its own game rules or
 | 
			
		||||
    /// not
 | 
			
		||||
    pub fn need_custom_rules(&self) -> bool {
 | 
			
		||||
        self == &SelectPlayModeResult::PlayAgainstBot || self == &SelectPlayModeResult::CreateInvite
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone)]
 | 
			
		||||
struct PlayModeDescription {
 | 
			
		||||
    name: &'static str,
 | 
			
		||||
    value: SelectPlayModeResult,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const AVAILABLE_PLAY_MODES: [PlayModeDescription; 5] = [
 | 
			
		||||
    PlayModeDescription {
 | 
			
		||||
        name: "🤖 Play against bot (offline)",
 | 
			
		||||
        value: SelectPlayModeResult::PlayAgainstBot,
 | 
			
		||||
    },
 | 
			
		||||
    PlayModeDescription {
 | 
			
		||||
        name: "🎲 Play against random player (online)",
 | 
			
		||||
        value: SelectPlayModeResult::PlayRandom,
 | 
			
		||||
    },
 | 
			
		||||
    PlayModeDescription {
 | 
			
		||||
        name: "➕ Create play invite (online)",
 | 
			
		||||
        value: SelectPlayModeResult::CreateInvite,
 | 
			
		||||
    },
 | 
			
		||||
    PlayModeDescription {
 | 
			
		||||
        name: "🎫 Accept play invite (online)",
 | 
			
		||||
        value: SelectPlayModeResult::AcceptInvite,
 | 
			
		||||
    },
 | 
			
		||||
    PlayModeDescription {
 | 
			
		||||
        name: "❌ Exit app",
 | 
			
		||||
        value: SelectPlayModeResult::Exit,
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
#[derive(Default)]
 | 
			
		||||
pub struct SelectPlayModeScreen {
 | 
			
		||||
    state: ListState,
 | 
			
		||||
    curr_selection: usize,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl SelectPlayModeScreen {
 | 
			
		||||
    pub fn show<B: Backend>(
 | 
			
		||||
        mut self,
 | 
			
		||||
        terminal: &mut Terminal<B>,
 | 
			
		||||
    ) -> io::Result<ScreenResult<SelectPlayModeResult>> {
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        loop {
 | 
			
		||||
            self.state.select(Some(self.curr_selection));
 | 
			
		||||
            terminal.draw(|f| self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if crossterm::event::poll(timeout)? {
 | 
			
		||||
                if let Event::Key(key) = event::read()? {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        KeyCode::Char('q') => return Ok(ScreenResult::Canceled),
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            return Ok(ScreenResult::Ok(
 | 
			
		||||
                                AVAILABLE_PLAY_MODES[self.curr_selection].value,
 | 
			
		||||
                            ));
 | 
			
		||||
                        }
 | 
			
		||||
                        KeyCode::Down => self.curr_selection += 1,
 | 
			
		||||
                        KeyCode::Up => self.curr_selection += AVAILABLE_PLAY_MODES.len() - 1,
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    self.curr_selection %= AVAILABLE_PLAY_MODES.len();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) {
 | 
			
		||||
        let area = centered_rect_size(50, 2 + AVAILABLE_PLAY_MODES.len() as u16, &f.size());
 | 
			
		||||
 | 
			
		||||
        // Create a List from all list items and highlight the currently selected one
 | 
			
		||||
        let items = AVAILABLE_PLAY_MODES
 | 
			
		||||
            .iter()
 | 
			
		||||
            .map(|mode| ListItem::new(Text::raw(mode.name)))
 | 
			
		||||
            .collect::<Vec<_>>();
 | 
			
		||||
        let items = List::new(items)
 | 
			
		||||
            .block(
 | 
			
		||||
                Block::default()
 | 
			
		||||
                    .title("Select play mode")
 | 
			
		||||
                    .borders(Borders::ALL),
 | 
			
		||||
            )
 | 
			
		||||
            .highlight_style(
 | 
			
		||||
                Style::default()
 | 
			
		||||
                    .fg(HIGHLIGHT_COLOR)
 | 
			
		||||
                    .add_modifier(Modifier::BOLD),
 | 
			
		||||
            )
 | 
			
		||||
            .highlight_symbol(">> ");
 | 
			
		||||
 | 
			
		||||
        f.render_stateful_widget(items, area, &mut self.state);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,258 @@
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::io;
 | 
			
		||||
use std::time::{Duration, Instant};
 | 
			
		||||
 | 
			
		||||
use crossterm::event;
 | 
			
		||||
use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind};
 | 
			
		||||
use tui::backend::Backend;
 | 
			
		||||
use tui::layout::Rect;
 | 
			
		||||
use tui::style::*;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
use tui::{Frame, Terminal};
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::data::*;
 | 
			
		||||
 | 
			
		||||
use crate::constants::*;
 | 
			
		||||
use crate::ui_screens::confirm_dialog_screen::confirm;
 | 
			
		||||
use crate::ui_screens::popup_screen::show_screen_too_small_popup;
 | 
			
		||||
use crate::ui_screens::utils::{centered_rect_size, centered_text};
 | 
			
		||||
use crate::ui_screens::ScreenResult;
 | 
			
		||||
use crate::ui_widgets::game_map_widget::{ColoredCells, GameMapWidget};
 | 
			
		||||
 | 
			
		||||
type CoordinatesMapper = HashMap<Coordinates, Coordinates>;
 | 
			
		||||
 | 
			
		||||
pub struct SetBoatsLayoutScreen<'a> {
 | 
			
		||||
    curr_boat: usize,
 | 
			
		||||
    layout: BoatsLayout,
 | 
			
		||||
    rules: &'a GameRules,
 | 
			
		||||
    confirm_on_cancel: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> SetBoatsLayoutScreen<'a> {
 | 
			
		||||
    pub fn new(rules: &'a GameRules) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            curr_boat: 0,
 | 
			
		||||
            layout: BoatsLayout::gen_random_for_rules(rules)
 | 
			
		||||
                .expect("Failed to generate initial boats layout"),
 | 
			
		||||
            rules,
 | 
			
		||||
            confirm_on_cancel: false,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Specify whether user should confirm his choice to cancel screen or not
 | 
			
		||||
    pub fn set_confirm_on_cancel(mut self, confirm: bool) -> Self {
 | 
			
		||||
        self.confirm_on_cancel = confirm;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn show<B: Backend>(
 | 
			
		||||
        mut self,
 | 
			
		||||
        terminal: &mut Terminal<B>,
 | 
			
		||||
    ) -> io::Result<ScreenResult<BoatsLayout>> {
 | 
			
		||||
        let mut coordinates_mapper = CoordinatesMapper::default();
 | 
			
		||||
 | 
			
		||||
        let mut last_tick = Instant::now();
 | 
			
		||||
        let mut is_moving_boat = false;
 | 
			
		||||
        loop {
 | 
			
		||||
            terminal.draw(|f| coordinates_mapper = self.ui(f))?;
 | 
			
		||||
 | 
			
		||||
            let timeout = TICK_RATE
 | 
			
		||||
                .checked_sub(last_tick.elapsed())
 | 
			
		||||
                .unwrap_or_else(|| Duration::from_secs(0));
 | 
			
		||||
 | 
			
		||||
            if event::poll(timeout)? {
 | 
			
		||||
                let mut move_boat = None;
 | 
			
		||||
 | 
			
		||||
                let event = event::read()?;
 | 
			
		||||
                if let Event::Key(key) = &event {
 | 
			
		||||
                    match key.code {
 | 
			
		||||
                        KeyCode::Char('q') => {
 | 
			
		||||
                            if !self.confirm_on_cancel
 | 
			
		||||
                                || confirm(terminal, "Do you really want to quit?")
 | 
			
		||||
                            {
 | 
			
		||||
                                return Ok(ScreenResult::Canceled);
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        // Select next boat
 | 
			
		||||
                        KeyCode::Char('n') => self.curr_boat += self.layout.number_of_boats() - 1,
 | 
			
		||||
 | 
			
		||||
                        // Rotate boat
 | 
			
		||||
                        KeyCode::Char('r') => {
 | 
			
		||||
                            self.layout.0[self.curr_boat].direction =
 | 
			
		||||
                                match self.layout.0[self.curr_boat].direction {
 | 
			
		||||
                                    BoatDirection::Right => BoatDirection::Down,
 | 
			
		||||
                                    _ => BoatDirection::Right,
 | 
			
		||||
                                }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        // Move boat
 | 
			
		||||
                        KeyCode::Left => move_boat = Some((-1, 0)),
 | 
			
		||||
                        KeyCode::Right => move_boat = Some((1, 0)),
 | 
			
		||||
                        KeyCode::Up => move_boat = Some((0, -1)),
 | 
			
		||||
                        KeyCode::Down => move_boat = Some((0, 1)),
 | 
			
		||||
 | 
			
		||||
                        // Submit configuration
 | 
			
		||||
                        KeyCode::Enter => {
 | 
			
		||||
                            if self.layout.is_valid(self.rules) {
 | 
			
		||||
                                return Ok(ScreenResult::Ok(self.layout));
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    self.curr_boat %= self.layout.number_of_boats();
 | 
			
		||||
 | 
			
		||||
                    // Apply boat move
 | 
			
		||||
                    if let Some((x, y)) = move_boat {
 | 
			
		||||
                        let new_pos = self.layout.0[self.curr_boat].start.add_x(x).add_y(y);
 | 
			
		||||
                        if new_pos.is_valid(self.rules) {
 | 
			
		||||
                            self.layout.0[self.curr_boat].start = new_pos;
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                // Mouse event
 | 
			
		||||
                else if let Event::Mouse(mouse) = event {
 | 
			
		||||
                    let src_pos = Coordinates::new(mouse.column, mouse.row);
 | 
			
		||||
 | 
			
		||||
                    // Start mouse action
 | 
			
		||||
                    if MouseEventKind::Down(MouseButton::Left) == mouse.kind {
 | 
			
		||||
                        is_moving_boat = if let Some(pos) = coordinates_mapper.get(&src_pos) {
 | 
			
		||||
                            if let Some(b) = self.layout.find_boat_at_position(*pos) {
 | 
			
		||||
                                self.curr_boat = self.layout.0.iter().position(|s| s == b).unwrap();
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            true
 | 
			
		||||
                        } else {
 | 
			
		||||
                            false
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    // Handle continue mouse action
 | 
			
		||||
                    else if is_moving_boat {
 | 
			
		||||
                        if let Some(pos) = coordinates_mapper.get(&src_pos) {
 | 
			
		||||
                            self.layout.0[self.curr_boat].start = *pos;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        if let MouseEventKind::Up(_) = mouse.kind {
 | 
			
		||||
                            is_moving_boat = false;
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if last_tick.elapsed() >= TICK_RATE {
 | 
			
		||||
                last_tick = Instant::now();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn ui<B: Backend>(&mut self, f: &mut Frame<B>) -> CoordinatesMapper {
 | 
			
		||||
        let errors = self.layout.errors(self.rules);
 | 
			
		||||
 | 
			
		||||
        // Color of current boat
 | 
			
		||||
        let current_boat = ColoredCells {
 | 
			
		||||
            color: Color::Green,
 | 
			
		||||
            cells: self.layout.0[self.curr_boat].all_coordinates(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Color of invalid boats
 | 
			
		||||
        let mut invalid_coordinates = vec![];
 | 
			
		||||
        for (idx, pos) in self.layout.boats().iter().enumerate() {
 | 
			
		||||
            if idx == self.curr_boat {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if !self
 | 
			
		||||
                .layout
 | 
			
		||||
                .check_present_boat_position(idx, self.rules)
 | 
			
		||||
                .is_empty()
 | 
			
		||||
            {
 | 
			
		||||
                invalid_coordinates.append(&mut pos.all_coordinates());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        let invalid_boats = ColoredCells {
 | 
			
		||||
            color: Color::Red,
 | 
			
		||||
            cells: invalid_coordinates,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Color of other boats
 | 
			
		||||
        let mut other_boats_cells = vec![];
 | 
			
		||||
        for boat in &self.layout.0 {
 | 
			
		||||
            other_boats_cells.append(&mut boat.all_coordinates());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let other_boats = ColoredCells {
 | 
			
		||||
            color: Color::Gray,
 | 
			
		||||
            cells: other_boats_cells,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        let mut coordinates_mapper = HashMap::new();
 | 
			
		||||
 | 
			
		||||
        let mut legend = "n        next boat     \n\
 | 
			
		||||
                     r        rotate boat    \n\n\
 | 
			
		||||
                     ← ↓↑ →   move boat      \n\n"
 | 
			
		||||
            .to_string();
 | 
			
		||||
        if errors.is_empty() {
 | 
			
		||||
            legend.push_str("Enter    confirm layout");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let mut game_map_widget = GameMapWidget::new(self.rules)
 | 
			
		||||
            .set_default_empty_char(' ')
 | 
			
		||||
            .add_colored_cells(current_boat)
 | 
			
		||||
            .add_colored_cells(invalid_boats)
 | 
			
		||||
            .add_colored_cells(other_boats)
 | 
			
		||||
            .set_title("Choose your boat layout")
 | 
			
		||||
            .set_yield_func(|c, r| {
 | 
			
		||||
                for i in 0..r.width {
 | 
			
		||||
                    for j in 0..r.height {
 | 
			
		||||
                        coordinates_mapper.insert(Coordinates::new(r.x + i, r.y + j), c);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
            .set_legend(legend);
 | 
			
		||||
 | 
			
		||||
        // Color of neighbors if boats can not touch
 | 
			
		||||
        if !self.rules.boats_can_touch {
 | 
			
		||||
            let mut boats_neighbors_cells = vec![];
 | 
			
		||||
            for boat in &self.layout.0 {
 | 
			
		||||
                for pos in boat.neighbor_coordinates(self.rules) {
 | 
			
		||||
                    boats_neighbors_cells.push(pos);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            game_map_widget = game_map_widget.add_colored_cells(ColoredCells {
 | 
			
		||||
                color: Color::Rgb(30, 30, 30),
 | 
			
		||||
                cells: boats_neighbors_cells,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let (w, h) = game_map_widget.estimated_size();
 | 
			
		||||
 | 
			
		||||
        if f.size().width < w || f.size().height < h + 3 {
 | 
			
		||||
            // +3 = for errors
 | 
			
		||||
            show_screen_too_small_popup(f);
 | 
			
		||||
            drop(game_map_widget);
 | 
			
		||||
            return coordinates_mapper;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let area = centered_rect_size(w, h, &f.size());
 | 
			
		||||
        f.render_widget(game_map_widget, area);
 | 
			
		||||
 | 
			
		||||
        if !errors.is_empty() {
 | 
			
		||||
            let messages = ["INVALID_LAYOUT", errors[0]];
 | 
			
		||||
            for (i, msg) in messages.iter().enumerate() {
 | 
			
		||||
                let paragraph = Paragraph::new(*msg).style(Style::default().fg(Color::Red));
 | 
			
		||||
                f.render_widget(
 | 
			
		||||
                    paragraph,
 | 
			
		||||
                    centered_text(
 | 
			
		||||
                        msg,
 | 
			
		||||
                        &Rect::new(f.size().x, area.bottom() + i as u16, f.size().width, 1),
 | 
			
		||||
                    ),
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        coordinates_mapper
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										80
									
								
								rust/sea_battle_cli_player/src/ui_screens/utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								rust/sea_battle_cli_player/src/ui_screens/utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
use tui::layout::{Constraint, Direction, Layout, Rect};
 | 
			
		||||
 | 
			
		||||
/// helper function to create a centered rect using up certain percentage of the available rect `r`
 | 
			
		||||
pub fn centered_rect_percentage(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
 | 
			
		||||
    let popup_layout = Layout::default()
 | 
			
		||||
        .direction(Direction::Vertical)
 | 
			
		||||
        .constraints(
 | 
			
		||||
            [
 | 
			
		||||
                Constraint::Percentage((100 - percent_y) / 2),
 | 
			
		||||
                Constraint::Percentage(percent_y),
 | 
			
		||||
                Constraint::Percentage((100 - percent_y) / 2),
 | 
			
		||||
            ]
 | 
			
		||||
            .as_ref(),
 | 
			
		||||
        )
 | 
			
		||||
        .split(r);
 | 
			
		||||
 | 
			
		||||
    Layout::default()
 | 
			
		||||
        .direction(Direction::Horizontal)
 | 
			
		||||
        .constraints(
 | 
			
		||||
            [
 | 
			
		||||
                Constraint::Percentage((100 - percent_x) / 2),
 | 
			
		||||
                Constraint::Percentage(percent_x),
 | 
			
		||||
                Constraint::Percentage((100 - percent_x) / 2),
 | 
			
		||||
            ]
 | 
			
		||||
            .as_ref(),
 | 
			
		||||
        )
 | 
			
		||||
        .split(popup_layout[1])[1]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// helper function to create a centered rect using up certain container size
 | 
			
		||||
pub fn centered_rect_size(width: u16, height: u16, parent: &Rect) -> Rect {
 | 
			
		||||
    if parent.width < width || parent.height < height {
 | 
			
		||||
        return Rect {
 | 
			
		||||
            x: parent.x,
 | 
			
		||||
            y: parent.y,
 | 
			
		||||
            width: parent.width,
 | 
			
		||||
            height: parent.height,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Rect {
 | 
			
		||||
        x: parent.x + (parent.width - width) / 2,
 | 
			
		||||
        y: parent.y + (parent.height - height) / 2,
 | 
			
		||||
        width,
 | 
			
		||||
        height,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// helper function to create a centered rect using up certain container size, only horizontally
 | 
			
		||||
pub fn centered_rect_size_horizontally(width: u16, parent: &Rect) -> Rect {
 | 
			
		||||
    if parent.width < width {
 | 
			
		||||
        return Rect {
 | 
			
		||||
            x: parent.x,
 | 
			
		||||
            y: parent.y,
 | 
			
		||||
            width: parent.width,
 | 
			
		||||
            height: parent.height,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Rect {
 | 
			
		||||
        x: parent.x + (parent.width - width) / 2,
 | 
			
		||||
        y: parent.y,
 | 
			
		||||
        width,
 | 
			
		||||
        height: parent.height,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Get coordinates to render centered text
 | 
			
		||||
pub fn centered_text(text: &str, container: &Rect) -> Rect {
 | 
			
		||||
    if text.len() > container.width as usize {
 | 
			
		||||
        return *container;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Rect {
 | 
			
		||||
        x: container.x + (container.width - text.len() as u16) / 2,
 | 
			
		||||
        y: container.y,
 | 
			
		||||
        height: 1,
 | 
			
		||||
        width: text.len() as u16,
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								rust/sea_battle_cli_player/src/ui_widgets/button_widget.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								rust/sea_battle_cli_player/src/ui_widgets/button_widget.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
 | 
			
		||||
use crate::constants::HIGHLIGHT_COLOR;
 | 
			
		||||
use tui::buffer::Buffer;
 | 
			
		||||
use tui::layout::Rect;
 | 
			
		||||
use tui::style::{Color, Style};
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
 | 
			
		||||
pub struct ButtonWidget {
 | 
			
		||||
    is_hovered: bool,
 | 
			
		||||
    label: String,
 | 
			
		||||
    disabled: bool,
 | 
			
		||||
    min_width: usize,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl ButtonWidget {
 | 
			
		||||
    pub fn new<D: Display>(label: D, is_hovered: bool) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            label: label.to_string(),
 | 
			
		||||
            is_hovered,
 | 
			
		||||
            disabled: false,
 | 
			
		||||
            min_width: 0,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_disabled(mut self, disabled: bool) -> Self {
 | 
			
		||||
        self.disabled = disabled;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_min_width(mut self, min_width: usize) -> Self {
 | 
			
		||||
        self.min_width = min_width;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn estimated_size(&self) -> (u16, u16) {
 | 
			
		||||
        ((self.label.len() + 2).max(self.min_width) as u16, 1)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Widget for ButtonWidget {
 | 
			
		||||
    fn render(self, area: Rect, buf: &mut Buffer) {
 | 
			
		||||
        let expected_len = self.estimated_size().0;
 | 
			
		||||
 | 
			
		||||
        let mut label = self.label.clone();
 | 
			
		||||
        while label.len() < expected_len as usize {
 | 
			
		||||
            label.insert(0, ' ');
 | 
			
		||||
            label.push(' ');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let area = centered_rect_size(label.len() as u16, 1, &area);
 | 
			
		||||
 | 
			
		||||
        let input = Paragraph::new(label.as_ref()).style(match (self.disabled, self.is_hovered) {
 | 
			
		||||
            (true, _) => Style::default(),
 | 
			
		||||
            (_, false) => Style::default().bg(Color::DarkGray),
 | 
			
		||||
            (_, true) => Style::default().fg(Color::White).bg(HIGHLIGHT_COLOR),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        input.render(area, buf);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										57
									
								
								rust/sea_battle_cli_player/src/ui_widgets/checkbox_widget.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								rust/sea_battle_cli_player/src/ui_widgets/checkbox_widget.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
use crate::constants::HIGHLIGHT_COLOR;
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
use tui::buffer::Buffer;
 | 
			
		||||
use tui::layout::Rect;
 | 
			
		||||
use tui::style::*;
 | 
			
		||||
use tui::widgets::*;
 | 
			
		||||
 | 
			
		||||
pub struct CheckboxWidget {
 | 
			
		||||
    is_editing: bool,
 | 
			
		||||
    checked: bool,
 | 
			
		||||
    label: String,
 | 
			
		||||
    is_radio: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl CheckboxWidget {
 | 
			
		||||
    pub fn new<D: Display>(label: D, checked: bool, is_editing: bool) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            is_editing,
 | 
			
		||||
            checked,
 | 
			
		||||
            label: label.to_string(),
 | 
			
		||||
            is_radio: false,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_radio(mut self) -> Self {
 | 
			
		||||
        self.is_radio = true;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Widget for CheckboxWidget {
 | 
			
		||||
    fn render(self, area: Rect, buf: &mut Buffer) {
 | 
			
		||||
        let paragraph = format!(
 | 
			
		||||
            "{}{}{} {}",
 | 
			
		||||
            match self.is_radio {
 | 
			
		||||
                true => "(",
 | 
			
		||||
                false => "[",
 | 
			
		||||
            },
 | 
			
		||||
            match self.checked {
 | 
			
		||||
                true => "X",
 | 
			
		||||
                false => " ",
 | 
			
		||||
            },
 | 
			
		||||
            match self.is_radio {
 | 
			
		||||
                true => ")",
 | 
			
		||||
                false => "]",
 | 
			
		||||
            },
 | 
			
		||||
            self.label
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        let input = Paragraph::new(paragraph.as_ref()).style(match &self.is_editing {
 | 
			
		||||
            false => Style::default(),
 | 
			
		||||
            true => Style::default().fg(HIGHLIGHT_COLOR),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        input.render(area, buf);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										204
									
								
								rust/sea_battle_cli_player/src/ui_widgets/game_map_widget.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								rust/sea_battle_cli_player/src/ui_widgets/game_map_widget.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,204 @@
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
 | 
			
		||||
use tui::buffer::Buffer;
 | 
			
		||||
use tui::layout::Rect;
 | 
			
		||||
use tui::style::{Color, Style};
 | 
			
		||||
use tui::widgets::{BorderType, Widget};
 | 
			
		||||
 | 
			
		||||
use sea_battle_backend::data::{Coordinates, GameRules, PlayConfiguration};
 | 
			
		||||
 | 
			
		||||
use crate::ui_screens::utils::centered_rect_size;
 | 
			
		||||
 | 
			
		||||
pub struct ColoredCells {
 | 
			
		||||
    pub color: Color,
 | 
			
		||||
    pub cells: Vec<Coordinates>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct GameMapWidget<'a> {
 | 
			
		||||
    rules: &'a GameRules,
 | 
			
		||||
    default_empty_character: char,
 | 
			
		||||
    colored_cells: Vec<ColoredCells>,
 | 
			
		||||
    title: Option<String>,
 | 
			
		||||
    legend: Option<String>,
 | 
			
		||||
    yield_coordinates: Option<Box<dyn 'a + FnMut(Coordinates, Rect)>>,
 | 
			
		||||
    chars: HashMap<Coordinates, char>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> GameMapWidget<'a> {
 | 
			
		||||
    pub fn new(rules: &'a GameRules) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            rules,
 | 
			
		||||
            default_empty_character: '.',
 | 
			
		||||
            colored_cells: vec![],
 | 
			
		||||
            title: None,
 | 
			
		||||
            legend: None,
 | 
			
		||||
            yield_coordinates: None,
 | 
			
		||||
            chars: Default::default(),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_default_empty_char(mut self, c: char) -> Self {
 | 
			
		||||
        self.default_empty_character = c;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn add_colored_cells(mut self, c: ColoredCells) -> Self {
 | 
			
		||||
        self.colored_cells.push(c);
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_title<D: Display>(mut self, title: D) -> Self {
 | 
			
		||||
        self.title = Some(title.to_string());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_legend<D: Display>(mut self, legend: D) -> Self {
 | 
			
		||||
        self.legend = Some(legend.to_string());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_yield_func<F>(mut self, func: F) -> Self
 | 
			
		||||
    where
 | 
			
		||||
        F: 'a + FnMut(Coordinates, Rect),
 | 
			
		||||
    {
 | 
			
		||||
        self.yield_coordinates = Some(Box::new(func));
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_char(mut self, coordinates: Coordinates, c: char) -> Self {
 | 
			
		||||
        self.chars.insert(coordinates, c);
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn set_char_no_overwrite(mut self, coordinates: Coordinates, c: char) -> Self {
 | 
			
		||||
        self.chars.entry(coordinates).or_insert(c);
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn grid_size(&self) -> (u16, u16) {
 | 
			
		||||
        let w = (self.rules.map_width as u16 * 2) + 2;
 | 
			
		||||
        let h = (self.rules.map_height as u16 * 2) + 2;
 | 
			
		||||
 | 
			
		||||
        (w, h)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn estimated_size(&self) -> (u16, u16) {
 | 
			
		||||
        let (w, mut h) = self.grid_size();
 | 
			
		||||
 | 
			
		||||
        if self.title.is_some() {
 | 
			
		||||
            h += 2;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if let Some(l) = &self.legend {
 | 
			
		||||
            h += 1 + l.split('\n').count() as u16;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        (w, h)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> Widget for GameMapWidget<'a> {
 | 
			
		||||
    fn render(mut self, area: Rect, buf: &mut Buffer) {
 | 
			
		||||
        let alphabet = PlayConfiguration::default().ordinate_alphabet;
 | 
			
		||||
 | 
			
		||||
        let symbols = BorderType::line_symbols(BorderType::Plain);
 | 
			
		||||
 | 
			
		||||
        let mut start_y = area.y;
 | 
			
		||||
 | 
			
		||||
        // Render title
 | 
			
		||||
        if let Some(title) = &self.title {
 | 
			
		||||
            let x = centered_rect_size(title.len() as u16, 1, &area).x;
 | 
			
		||||
            buf.set_string(x, start_y, title, Style::default());
 | 
			
		||||
            start_y += 2;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Paint game grid
 | 
			
		||||
        for y in 0..(self.rules.map_height + 1) {
 | 
			
		||||
            // Header (ordinate)
 | 
			
		||||
            if y < self.rules.map_height {
 | 
			
		||||
                buf.get_mut(area.x, start_y + 2 + (y as u16 * 2))
 | 
			
		||||
                    .set_char(alphabet.chars().nth(y).unwrap());
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for x in 0..(self.rules.map_width + 1) {
 | 
			
		||||
                let coordinates = Coordinates::new(x as i32, y as i32);
 | 
			
		||||
 | 
			
		||||
                // Header (abscissa)
 | 
			
		||||
                if x < self.rules.map_width {
 | 
			
		||||
                    buf.set_string(
 | 
			
		||||
                        area.x + 2 + (x as u16 * 2) - (x as u16) / 10,
 | 
			
		||||
                        start_y,
 | 
			
		||||
                        x.to_string(),
 | 
			
		||||
                        Style::default(),
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                let o_x = 1 + area.x + (x as u16 * 2);
 | 
			
		||||
                let o_y = 1 + start_y + (y as u16 * 2);
 | 
			
		||||
 | 
			
		||||
                let color = self
 | 
			
		||||
                    .colored_cells
 | 
			
		||||
                    .iter()
 | 
			
		||||
                    .find(|c| c.cells.contains(&coordinates));
 | 
			
		||||
 | 
			
		||||
                buf.get_mut(o_x, o_y).set_symbol(match (x, y) {
 | 
			
		||||
                    (0, 0) => symbols.top_left,
 | 
			
		||||
 | 
			
		||||
                    (x, 0) if x == self.rules.map_width => symbols.top_right,
 | 
			
		||||
                    (0, y) if y == self.rules.map_height => symbols.bottom_left,
 | 
			
		||||
 | 
			
		||||
                    (0, _) => symbols.vertical_right,
 | 
			
		||||
                    (_, 0) => symbols.horizontal_down,
 | 
			
		||||
 | 
			
		||||
                    (x, y) if x == self.rules.map_width && y == self.rules.map_height => {
 | 
			
		||||
                        symbols.bottom_right
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    (x, _) if x == self.rules.map_width => symbols.vertical_left,
 | 
			
		||||
                    (_, y) if y == self.rules.map_height => symbols.horizontal_up,
 | 
			
		||||
 | 
			
		||||
                    _ => symbols.cross,
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                if x < self.rules.map_width {
 | 
			
		||||
                    buf.get_mut(o_x + 1, o_y).set_symbol(symbols.horizontal);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if y < self.rules.map_height {
 | 
			
		||||
                    buf.get_mut(o_x, o_y + 1).set_symbol(symbols.vertical);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if x < self.rules.map_width && y < self.rules.map_height {
 | 
			
		||||
                    let cell = buf.get_mut(o_x + 1, o_y + 1).set_char(
 | 
			
		||||
                        *self
 | 
			
		||||
                            .chars
 | 
			
		||||
                            .get(&coordinates)
 | 
			
		||||
                            .unwrap_or(&self.default_empty_character),
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    if let Some(c) = color {
 | 
			
		||||
                        cell.set_bg(c.color);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if let Some(f) = self.yield_coordinates.as_mut() {
 | 
			
		||||
                        f(coordinates, Rect::new(o_x + 1, o_y + 1, 1, 1));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        start_y += self.grid_size().1;
 | 
			
		||||
 | 
			
		||||
        // Paint legend (if any)
 | 
			
		||||
        if let Some(legend) = &self.legend {
 | 
			
		||||
            start_y += 1;
 | 
			
		||||
            for line in legend.split('\n') {
 | 
			
		||||
                let center_rect = centered_rect_size(line.len() as u16, 1, &area);
 | 
			
		||||
                let x = center_rect.x;
 | 
			
		||||
                buf.set_string(x, start_y, line, Style::default());
 | 
			
		||||
                start_y += 1;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								rust/sea_battle_cli_player/src/ui_widgets/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								rust/sea_battle_cli_player/src/ui_widgets/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
pub mod button_widget;
 | 
			
		||||
pub mod checkbox_widget;
 | 
			
		||||
pub mod game_map_widget;
 | 
			
		||||
pub mod text_editor_widget;
 | 
			
		||||
@@ -0,0 +1,65 @@
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
 | 
			
		||||
use crate::constants::HIGHLIGHT_COLOR;
 | 
			
		||||
use tui::buffer::Buffer;
 | 
			
		||||
use tui::layout::Rect;
 | 
			
		||||
use tui::style::*;
 | 
			
		||||
use tui::text::*;
 | 
			
		||||
use tui::widgets::{Block, Borders, Paragraph, Widget};
 | 
			
		||||
 | 
			
		||||
#[derive(Eq, PartialEq)]
 | 
			
		||||
pub enum InputMode {
 | 
			
		||||
    Normal,
 | 
			
		||||
    Editing,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub struct TextEditorWidget {
 | 
			
		||||
    input_mode: InputMode,
 | 
			
		||||
    value: String,
 | 
			
		||||
    label: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl TextEditorWidget {
 | 
			
		||||
    pub fn new<D: Display>(label: D, value: D, is_editing: bool) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            input_mode: match is_editing {
 | 
			
		||||
                true => InputMode::Editing,
 | 
			
		||||
                false => InputMode::Normal,
 | 
			
		||||
            },
 | 
			
		||||
            value: value.to_string(),
 | 
			
		||||
            label: label.to_string(),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Widget for TextEditorWidget {
 | 
			
		||||
    fn render(self, area: Rect, buf: &mut Buffer) {
 | 
			
		||||
        let span = Span::styled(
 | 
			
		||||
            self.value.to_string(),
 | 
			
		||||
            match &self.input_mode {
 | 
			
		||||
                InputMode::Normal => Style::default(),
 | 
			
		||||
                InputMode::Editing => Style::default().fg(HIGHLIGHT_COLOR),
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        let mut spans = vec![span];
 | 
			
		||||
 | 
			
		||||
        // Add cursor if field is highlighted
 | 
			
		||||
        if self.input_mode == InputMode::Editing {
 | 
			
		||||
            spans.push(Span::styled(" ", Style::default().bg(HIGHLIGHT_COLOR)))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let text = Text::from(Spans::from(spans));
 | 
			
		||||
        let input = Paragraph::new(text).block(
 | 
			
		||||
            Block::default()
 | 
			
		||||
                .borders(Borders::ALL)
 | 
			
		||||
                .border_style(match self.input_mode {
 | 
			
		||||
                    InputMode::Normal => Style::default(),
 | 
			
		||||
                    InputMode::Editing => Style::default().fg(HIGHLIGHT_COLOR),
 | 
			
		||||
                })
 | 
			
		||||
                .title(self.label.as_ref()),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        input.render(area, buf);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user