use std::{io::IsTerminal, mem, path::PathBuf};
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
use futures::StreamExt;
use kdl::KdlDocument;
use mcserver_core::{
ClientMessage, Config, LineBuffer, ServerMessage, config, display_bytes, read_message,
write_message,
};
use miette::{Context, IntoDiagnostic, bail};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout},
style::{Color, Style, Stylize},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use crate::servers;
#[derive(Debug)]
enum ServerState {
Normal,
Escaping,
Dead,
}
#[derive(Debug)]
enum Mode {
Selecting,
Console {
attached_server: usize,
name: String,
input: String,
output: LineBuffer,
state: ServerState,
},
}
impl Mode {
fn output(index: usize, name: String) -> Self {
Self::Console {
attached_server: index,
name,
input: String::new(),
output: LineBuffer::new(),
state: ServerState::Normal,
}
}
}
pub struct App {
config: Config,
mode: Mode,
document: Option<KdlDocument>,
servers: Vec<String>,
selected_server: usize,
exiting: bool,
}
#[macro_export]
macro_rules! stderr_log {
($($arg:tt)*) => {{
#[cfg(debug_assertions)]
if !std::io::stderr().is_terminal() {
eprintln!($($arg)*);
}
}};
}
macro_rules! try_attach {
($app:ident, $writer:expr) => {{
let name = $app
.servers
.get($app.selected_server)
.wrap_err("Invalid server index")?
.clone();
$app.request_attach($writer, name.clone())
.await
.into_diagnostic()
.wrap_err("Failed to attach to server")?;
$app.mode = Mode::output($app.selected_server, name);
}};
}
impl App {
pub async fn try_new() -> miette::Result<Self> {
let (config, document) = config::read()
.await
.into_diagnostic()
.wrap_err("Failed to create/read config")?;
stderr_log!("{config:?}");
let servers = servers::read(config.servers_directory.as_ref())
.await?
.into_iter()
.map(PathBuf::into_string)
.collect::<Result<Vec<String>, PathBuf>>()
.map_err(mcserver_core::Error::NonUtf8Path)
.into_diagnostic()?;
Ok(Self {
config,
document,
servers,
selected_server: 0usize,
mode: Mode::Selecting,
exiting: false,
})
}
pub async fn request_attach(
&self,
mut writer: impl AsyncWriteExt + Unpin,
server_name: impl Into<String>,
) -> io::Result<()> {
let name = server_name.into();
write_message!(writer, ClientMessage::Attach { name: name.clone() }).await
}
pub async fn handle_keycode(
&mut self,
key: KeyEvent,
mut writer: impl AsyncWriteExt + Unpin,
) -> miette::Result<()> {
match &mut self.mode {
Mode::Selecting => match key.code {
KeyCode::Enter => {
try_attach!(self, writer);
}
KeyCode::Up | KeyCode::Char('k') => self.selection_up(),
KeyCode::Down | KeyCode::Char('j') => self.selection_down(),
KeyCode::Tab => todo!("Implement tab"),
KeyCode::BackTab => todo!("Implement back tab"),
KeyCode::Char(ch) => match ch {
'q' => self.exit(),
'd' => todo!("Implement deletion"),
'u' => todo!("Implement manual server list updation"),
_ => {}
},
_ => {}
},
Mode::Console {
state: ServerState::Dead,
..
} => match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Tab | KeyCode::BackTab => {
self.selection_mode()
}
_ => {}
},
Mode::Console { name, state, .. } if let ServerState::Escaping = state => {
match key.code {
KeyCode::Backspace => todo!(),
KeyCode::Enter => {
let Some(selected_server) = self.servers.get(self.selected_server) else {
return Ok(());
};
*state = ServerState::Normal;
if name != selected_server {
try_attach!(self, writer);
}
}
KeyCode::Up | KeyCode::Char('k') => self.selection_up(),
KeyCode::Down | KeyCode::Char('j') => self.selection_down(),
KeyCode::Tab => {
*state = ServerState::Normal;
}
KeyCode::BackTab => self.selection_mode(),
KeyCode::Char('q') => self.exit(),
KeyCode::Delete => todo!(),
KeyCode::Insert => todo!(),
KeyCode::F(_) => todo!(),
KeyCode::Char(_) => todo!(),
KeyCode::Null => todo!(),
KeyCode::Esc => self.selection_mode(),
_ => {}
}
}
Mode::Console { input, state, .. } => match key.code {
KeyCode::Backspace => {
input.pop();
}
KeyCode::Enter => {
input.push('\n');
write_message!(
writer,
ClientMessage::ProgramInput(mem::take(input).into_bytes())
)
.await
.into_diagnostic()
.wrap_err("Failed to send input to the server")?
}
KeyCode::Left => todo!("Implement cursor left"),
KeyCode::Right => todo!("Implement cursor right"),
KeyCode::Tab => todo!("Implement autocompletions"),
KeyCode::BackTab => {
*state = ServerState::Escaping;
}
KeyCode::Delete => todo!("Implement deletion"),
KeyCode::Char(ch) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
write_message!(
writer,
ClientMessage::ProgramInput(vec![(ch as u8) - b'a' + 1])
)
.await
.into_diagnostic()?;
} else {
input.push(ch);
}
}
KeyCode::Esc => {
*state = ServerState::Escaping;
}
_ => {}
},
};
Ok(())
}
pub async fn run(
&mut self,
terminal: &mut DefaultTerminal,
(mut reader, mut writer): (impl AsyncReadExt + Unpin, impl AsyncWriteExt + Unpin),
) -> miette::Result<()> {
let mut event_reader = EventStream::new();
loop {
if self.exiting {
writer
.shutdown()
.await
.into_diagnostic()
.wrap_err("Failed to send shutdown packet")?;
break Ok(());
}
terminal
.draw(|frame| self.render(frame))
.into_diagnostic()
.wrap_err("Failed to render tui")?;
tokio::select! {
event = &mut event_reader.next() => {
let Some(event) = event else {
bail!("Failed to read from terminal");
};
let Event::Key(key) = event.into_diagnostic().wrap_err("Failed to read key input")? else {
continue;
};
self.handle_keycode(key, &mut writer).await?;
}
length = reader.read_u32() => {
let length = match length {
Ok(length) => length,
Err(err) => match err.kind() {
io::ErrorKind::UnexpectedEof => {
stderr_log!("Server closed");
return Err(err).into_diagnostic().wrap_err("Server connection severed");
},
_ => return Err(err).into_diagnostic(),
},
};
match read_message!(reader, length).await.into_diagnostic().wrap_err("Failed to receive message from server")? {
ServerMessage::KillFailure(err) => stderr_log!("Failed to kill child: {err}"),
ServerMessage::KillSuccess => stderr_log!("Successfully killed the child process"),
ServerMessage::Closed => if let Mode::Console { input, state, .. } = &mut self.mode {
*input = "Press enter to return...".to_string();
*state = ServerState::Dead;
},
ServerMessage::Message(message) => stderr_log!("Received a message from the server: {message}"),
ServerMessage::AlreadyStarted => stderr_log!("The server has already started apparently"),
ServerMessage::NotAttached => stderr_log!("The server has not yet started apparently"),
ServerMessage::Output(bytes) => if let Mode::Console { output, .. } = &mut self.mode {
output.push(display_bytes(&bytes).to_string());
},
ServerMessage::History(history) => if let Mode::Console { output, .. } = &mut self.mode {
history.into_iter().for_each(|line|{
output.push(display_bytes(&line).to_string())
});
}
};
}
}
}
}
pub async fn run_attached(
&mut self,
terminal: &mut DefaultTerminal,
(reader, mut writer): (impl AsyncReadExt + Unpin, impl AsyncWriteExt + Unpin),
name: String,
) -> miette::Result<()> {
self.selected_server = self
.servers
.iter()
.position(|server| *server == name)
.wrap_err("Server not found")?;
try_attach!(self, &mut writer);
self.run(terminal, (reader, writer)).await
}
fn draw_servers_list_widget(&self) -> List<'_> {
let servers_iter = self.servers.iter().enumerate();
if let Mode::Console {
attached_server, ..
} = self.mode
{
List::new(servers_iter.map(|(idx, name)| {
let item = if idx == self.selected_server {
ListItem::from(format!("> {name}")).bold()
} else {
ListItem::from(format!(" {name}"))
};
if idx == attached_server {
item.fg(Color::Black).bg(Color::White)
} else {
item
}
}))
} else {
List::new(servers_iter.map(|(idx, name)| {
if idx == self.selected_server {
ListItem::from(format!("> {name}")).bold()
} else {
ListItem::from(format!(" {name}"))
}
}))
}
.block(Block::new().title("Servers").borders(Borders::ALL))
}
fn render(&self, frame: &mut Frame) {
let background_block = Block::new().style(Style::new().bg(Color::Rgb(46, 52, 64)));
frame.render_widget(background_block, frame.area());
let Mode::Console {
input,
output,
state,
..
} = &self.mode
else {
frame.render_widget(self.draw_servers_list_widget(), frame.area());
return;
};
let output_area = if matches!(state, ServerState::Escaping) {
let main_sections = Layout::horizontal([Constraint::Length(30), Constraint::Fill(1)])
.split(frame.area());
frame.render_widget(self.draw_servers_list_widget(), main_sections[0]);
main_sections[1]
} else {
frame.area()
};
let output_text_width = output_area.width.saturating_sub(2) as usize;
let output_lines_available = output_area.height.saturating_sub(2) as usize;
let mut output_text = output
.iter()
.skip((output.len()).saturating_sub(output_lines_available.saturating_sub(1)))
.fold(String::new(), |mut acc, item| {
acc.push_str(item);
acc
});
output_text.push_str(input);
let lines = output_text
.split('\n')
.flat_map(|line| textwrap::wrap(line, output_text_width))
.count();
let output_widget = Paragraph::new(output_text)
.block(Block::new().title("Console").borders(Borders::ALL))
.wrap(Wrap { trim: false })
.scroll((lines.saturating_sub(output_lines_available) as u16, 0));
frame.render_widget(output_widget, output_area);
}
fn selection_mode(&mut self) {
self.mode = Mode::Selecting;
}
fn selection_up(&mut self) {
self.selected_server = self.selected_server.saturating_sub(1);
}
fn selection_down(&mut self) {
self.selected_server = usize::min(self.selected_server + 1, self.servers.len() - 1);
}
pub fn exit(&mut self) {
self.exiting = true;
}
}