Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 185 additions & 44 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crossterm::event::{Event, KeyCode, KeyModifiers, poll, read};
use crossterm::terminal::{self, Clear, ClearType};
use crossterm::{QueueableCommand, cursor::MoveTo};
use std::io::{self, ErrorKind, Read, Write, stdout};
use std::net::TcpStream;
use std::net::{Shutdown, TcpStream};
use std::str::from_utf8;
use std::thread::sleep;
use std::time::Duration;
Expand All @@ -14,6 +14,98 @@ struct Rect {
h: usize,
}

// C-style command representation used by the client dispatcher.
struct Command {
name: &'static str,
description: &'static str,
run: fn(&mut Ctx, &[&str]),
}

struct Ctx {
stream: Option<TcpStream>,
chat: Vec<String>,
stop: bool,
}

impl Ctx {
fn message(&mut self, message: impl Into<String>) {
self.chat.push(message.into());
}
}

fn cmd_help(ctx: &mut Ctx, _args: &[&str]) {
for command in COMMANDS {
ctx.message(format!("/{} - {}", command.name, command.description));
}
}

fn cmd_quit(ctx: &mut Ctx, _args: &[&str]) {
ctx.stop = true;
}

fn cmd_disconnect(ctx: &mut Ctx, _args: &[&str]) {
match ctx.stream.take() {
Some(stream) => {
if let Err(error) = stream.shutdown(Shutdown::Both) {
ctx.message(format!("failed to disconnect: {error}"));
} else {
ctx.message("disconnected");
}
}
None => ctx.message("not connected"),
}
}

const COMMANDS: &[Command] = &[
Command {
name: "help",
description: "show this help",
run: cmd_help,
},
Command {
name: "quit",
description: "exit the client",
run: cmd_quit,
},
Command {
name: "disconnect",
description: "disconnect from the server",
run: cmd_disconnect,
},
Command {
name: "connect",
description: "connect to server",
run: cmd_connect,
},
];

fn handle_prompt(ctx: &mut Ctx, prompt: &str) {
let input = prompt.trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve whitespace in regular chat messages

For any regular message with leading or trailing whitespace, trim() changes the bytes sent to the server even though the unmodified line has already been appended to the sender's chat history. For example, entering hello displays that exact text locally but sends hello to peers, so transcripts diverge and whitespace-sensitive messages cannot be sent. Use a trimmed view only for command parsing and write the original prompt for non-command input.

Useful? React with 👍 / 👎.

if input.is_empty() {
return;
}

if let Some(rest) = input.strip_prefix('/') {
let mut parts = rest.split_whitespace();
let name = parts.next().unwrap_or_default();
let args: Vec<&str> = parts.collect();
match COMMANDS.iter().find(|command| command.name == name) {
Some(command) => (command.run)(ctx, &args),
None => ctx.message(format!("unknown command: /{name}")),
}
return;
}

match ctx.stream.as_mut() {
Some(stream) => {
if let Err(error) = stream.write_all(input.as_bytes()) {
ctx.message(error.to_string());
}
}
None => ctx.message("not connected"),
}
}

fn chat_window(stdout: &mut impl Write, chat: &[String], boundary: Rect) -> io::Result<()> {
let n = chat.len();
let size = n.checked_sub(boundary.h).unwrap_or(0);
Expand All @@ -26,85 +118,108 @@ fn chat_window(stdout: &mut impl Write, chat: &[String], boundary: Rect) -> io::
Ok(())
}

fn cmd_connect(ctx: &mut Ctx, args: &[&str]) {
if let Some(_stream) = ctx.stream.as_mut() {
ctx.message("You already connected ");
return;
}
//args is ip port
if args.len() < 2 {
ctx.message("/connect ip port");
return;
}
let addr = format!("{}:{}", args[0], args[1]);
let stream = match TcpStream::connect(&addr) {
Ok(stream) => stream,
Err(err) => {
ctx.message(format!("failed to connect to {} : {}", addr, err));
return;
}
};
if let Err(err) = stream.set_nonblocking(true) {
ctx.message(format!("failed to set noblock to {} : {}", addr, err));
return;
}
ctx.stream = Some(stream);
}

fn main() -> io::Result<()> {
// 在进入 raw mode 之前,从 stdin 读取服务器打印出来的 token。
let mut token = String::new();
io::stdin().read_line(&mut token)?;
let token = token.trim().to_string();

let mut stream = TcpStream::connect("127.0.0.1:6969").expect("Can not connect to host");
let _ = stream.set_nonblocking(true).expect("set block failed ");
// 服务器 authorize() 会先发 "token: ",然后阻塞等 32 字节 token,
// 所以这里连上后立刻把 token 发过去。
stream.write_all(token.as_bytes())?;
let _ = terminal::enable_raw_mode()?;
// Read the token printed by the server before entering raw mode.

terminal::enable_raw_mode()?;
let result = run_client();
let _ = terminal::disable_raw_mode();
result
}

fn run_client() -> io::Result<()> {
let mut stdout = stdout();
let (mut w, mut h) = terminal::size()?;
let barchar = "─";
let mut bar = barchar.repeat(w as usize);

let mut chat = Vec::new();
let mut ctx = Ctx {
stream: None,
chat: Vec::new(),
stop: false,
};
let mut prompt = String::new();
let mut stop = false;

let mut buffer = [0; 64];

while !stop {
while poll(Duration::ZERO).unwrap() {
while !ctx.stop {
while poll(Duration::ZERO).unwrap_or(false) {
match read()? {
Event::Resize(width, height) => {
w = width;
h = height;
bar = barchar.repeat(w as usize);
}
Event::Paste(data) => {
prompt.push_str(&data);
}
Event::Paste(data) => prompt.push_str(&data),
Event::Key(event) => match event.code {
KeyCode::Char(code) => {
if event.modifiers.contains(KeyModifiers::CONTROL) && code == 'c' {
stop = true;
ctx.stop = true;
} else {
prompt.push(code);
}
}
KeyCode::Esc => {
prompt.clear();
KeyCode::Esc => prompt.clear(),
KeyCode::Backspace => {
prompt.pop();
}
KeyCode::Enter => {
stream.write(prompt.as_bytes())?;
chat.push(prompt.clone());
let line = prompt.clone();
ctx.chat.push(line.clone());
handle_prompt(&mut ctx, &line);
prompt.clear();
}
_ => {}
},

_ => {}
}
}
match stream.read(&mut buffer) {
Ok(0) => {
stop = true;
}

Ok(n) => {
chat.push(from_utf8(&buffer[0..n]).unwrap().to_string());
}

Err(e) => {
// 非阻塞读在没有数据时会返回 WouldBlock,
// 这只是“暂时没数据”,应该继续循环而不是退出程序。
if e.kind() != ErrorKind::WouldBlock {
chat.push(e.to_string());
if let Some(stream) = ctx.stream.as_mut() {
match stream.read(&mut buffer) {
Ok(0) => {
ctx.message("disconnected");
ctx.stream = None;
}
Ok(n) => match from_utf8(&buffer[..n]) {
Ok(message) => ctx.message(message),
Err(error) => ctx.message(format!("invalid server response: {error}")),
},
Err(error) if error.kind() == ErrorKind::WouldBlock => {}
Err(error) => {
ctx.message(error.to_string());
ctx.stream = None;
}
}
}

stdout.queue(Clear(ClearType::All))?;

let _ = chat_window(
chat_window(
&mut stdout,
&chat,
&ctx.chat,
Rect {
x: 0,
y: 0,
Expand All @@ -119,6 +234,32 @@ fn main() -> io::Result<()> {
stdout.flush()?;
sleep(Duration::from_millis(33));
}
let _ = terminal::disable_raw_mode()?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn command_table_contains_only_minimal_commands() {
assert_eq!(
COMMANDS
.iter()
.map(|command| command.name)
.collect::<Vec<_>>(),
vec!["help", "quit", "disconnect", "connect"]
);
}

#[test]
fn unknown_command_is_reported() {
let mut ctx = Ctx {
stream: None,
chat: Vec::new(),
stop: false,
};
handle_prompt(&mut ctx, "/missing");
assert_eq!(ctx.chat, vec!["unknown command: /missing"]);
}
}
Loading