sharenet/backend/crates/tui/src/lib.rs

673 lines
No EOL
24 KiB
Rust

/*
* This file is part of Sharenet.
*
* Sharenet is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
*
* You may obtain a copy of the license at:
* https://creativecommons.org/licenses/by-nc-sa/4.0/
*
* Copyright (c) 2024 Continuist <continuist02@gmail.com>
*/
use std::io;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::collections::VecDeque;
use application::UseCase;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use domain::{CreateProduct, CreateUser, Product, User};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
Frame, Terminal,
};
use textwrap;
const MAX_HISTORY: usize = 100;
pub struct App {
input: String,
messages: Vec<String>,
should_quit: bool,
command_history: VecDeque<String>,
history_index: Option<usize>,
cursor_position: usize,
}
impl App {
pub fn new() -> Self {
Self {
input: String::new(),
messages: vec!["Welcome to Sharenet CLI!".to_string()],
should_quit: false,
command_history: VecDeque::with_capacity(MAX_HISTORY),
history_index: None,
cursor_position: 0,
}
}
pub fn add_message(&mut self, message: String) {
self.messages.push(message);
}
pub fn clear_input(&mut self) {
self.input.clear();
self.cursor_position = 0;
}
pub fn add_to_history(&mut self, command: String) {
if !command.trim().is_empty() {
self.command_history.push_front(command);
if self.command_history.len() > MAX_HISTORY {
self.command_history.pop_back();
}
}
}
pub fn move_cursor(&mut self, delta: isize) {
let new_pos = self.cursor_position as isize + delta;
if new_pos >= 0 && new_pos <= self.input.len() as isize {
self.cursor_position = new_pos as usize;
}
}
pub fn insert_char(&mut self, c: char) {
self.input.insert(self.cursor_position, c);
self.cursor_position += 1;
}
pub fn delete_char(&mut self) {
if self.cursor_position > 0 {
self.input.remove(self.cursor_position - 1);
self.cursor_position -= 1;
}
}
}
pub async fn run_tui<U, P>(user_service: U, product_service: P) -> anyhow::Result<()>
where
U: UseCase<User> + Clone + Send + 'static,
P: UseCase<Product> + Clone + Send + 'static,
{
// 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 app = App::new();
let res = run_app(&mut terminal, app, user_service, product_service).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}
async fn run_app<B: Backend, U, P>(
terminal: &mut Terminal<B>,
mut app: App,
user_service: U,
product_service: P,
) -> anyhow::Result<()>
where
U: UseCase<User> + Clone + Send + 'static,
P: UseCase<Product> + Clone + Send + 'static,
{
let (tx, rx) = mpsc::channel();
let user_service_clone = user_service.clone();
let product_service_clone = product_service.clone();
// Spawn a thread to handle user input
thread::spawn(move || {
loop {
if event::poll(Duration::from_millis(50)).unwrap() {
if let Event::Key(key) = event::read().unwrap() {
tx.send(key).unwrap();
}
}
}
});
loop {
terminal.draw(|f| ui(f, &app))?;
if let Ok(key) = rx.try_recv() {
match key.code {
KeyCode::Char(c) => {
app.insert_char(c);
}
KeyCode::Backspace => {
app.delete_char();
}
KeyCode::Left => {
app.move_cursor(-1);
}
KeyCode::Right => {
app.move_cursor(1);
}
KeyCode::Up => {
if let Some(index) = app.history_index {
if index + 1 < app.command_history.len() {
app.history_index = Some(index + 1);
app.input = app.command_history[index + 1].clone();
app.cursor_position = app.input.len();
}
} else if !app.command_history.is_empty() {
app.history_index = Some(0);
app.input = app.command_history[0].clone();
app.cursor_position = app.input.len();
}
}
KeyCode::Down => {
if let Some(index) = app.history_index {
if index > 0 {
app.history_index = Some(index - 1);
app.input = app.command_history[index - 1].clone();
app.cursor_position = app.input.len();
} else {
app.history_index = None;
app.clear_input();
}
}
}
KeyCode::Enter => {
let input = app.input.clone();
app.add_to_history(input.clone());
app.clear_input();
app.history_index = None;
// Display the command in a distinct color
app.add_message(format!("> {}", input));
// Handle commands
match input.trim() {
"exit" => {
app.add_message("Goodbye!".to_string());
app.should_quit = true;
}
"help" => {
print_help(&mut app);
}
cmd if cmd.starts_with("user create") => {
match parse_user_create(cmd) {
Ok((username, email)) => {
match CreateUser::new(username, email) {
Ok(create_user) => {
match user_service_clone.create(create_user).await {
Ok(user) => app.add_message(format!("Created user: {:?}", user)),
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
"user list" => {
match user_service_clone.list().await {
Ok(users) => app.add_message(format!("Users: {:?}", users)),
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
cmd if cmd.starts_with("product create") => {
match parse_product_create(cmd) {
Ok((name, description)) => {
match product_service_clone
.create(match CreateProduct::new(name, description) {
Ok(create_product) => create_product,
Err(e) => {
app.add_message(format!("Error: {}", e));
continue;
}
})
.await
{
Ok(product) => app.add_message(format!("Created product: {:?}", product)),
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
"product list" => {
match product_service_clone.list().await {
Ok(products) => app.add_message(format!("Products: {:?}", products)),
Err(e) => app.add_message(format!("Error: {}", e)),
}
}
"" => {}
_ => {
app.add_message("Unknown command. Type 'help' for available commands.".to_string());
}
}
}
KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
}
}
if app.should_quit {
return Ok(());
}
}
}
fn print_help(app: &mut App) {
app.add_message("\nAvailable commands:".to_string());
app.add_message(" user create -u <username> -e <email>".to_string());
app.add_message(" Example: user create -u \"john doe\" -e \"john@example.com\"".to_string());
app.add_message(" user list".to_string());
app.add_message(" product create -n <name> -d <description>".to_string());
app.add_message(" Example: product create -n \"My Product\" -d \"A great product description\"".to_string());
app.add_message(" product list".to_string());
app.add_message("\nTips:".to_string());
app.add_message(" - Use quotes for values with spaces".to_string());
app.add_message(" - Use Up/Down arrows to navigate command history".to_string());
app.add_message(" - Press Esc to exit".to_string());
app.add_message(" - Type 'help' to show this message".to_string());
}
fn ui(f: &mut Frame, app: &App) {
// Create the layout
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Min(1),
Constraint::Length(3),
])
.split(f.size());
// Create the messages list with styling and wrapping
let messages: Vec<ListItem> = app
.messages
.iter()
.flat_map(|msg| {
let style = if msg.starts_with("Error:") {
Style::default().fg(Color::Red)
} else if msg.starts_with("Created") {
Style::default().fg(Color::Green)
} else if msg.starts_with(">") {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
// Calculate available width for text (accounting for borders and margins)
let available_width = chunks[0].width.saturating_sub(2) as usize;
// Split message into wrapped lines
let wrapped_lines = textwrap::wrap(msg, available_width);
// Convert each wrapped line into a ListItem
wrapped_lines.into_iter().map(move |line| {
ListItem::new(line.to_string()).style(style)
})
})
.collect();
let messages = List::new(messages)
.block(Block::default().title("Messages").borders(Borders::ALL));
f.render_widget(messages, chunks[0]);
// Create the input box with cursor and wrapping
let input = Paragraph::new(app.input.as_str())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().title("Input (Press 'Esc' to exit)").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(input, chunks[1]);
// Show cursor
f.set_cursor(
chunks[1].x + app.cursor_position as u16 + 1,
chunks[1].y + 1,
);
}
fn parse_user_create(cmd: &str) -> anyhow::Result<(String, String)> {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.len() < 6 {
return Err(anyhow::anyhow!(
"Invalid command format. Use: user create -u <username> -e <email>\nExample: user create -u \"john doe\" -e \"john@example.com\""
));
}
let mut username = None;
let mut email = None;
let mut current_arg = None;
let mut current_value = Vec::new();
// Skip "user create" command
let mut i = 2;
while i < parts.len() {
match parts[i] {
"-u" => {
if let Some(arg_type) = current_arg {
match arg_type {
"username" => username = Some(current_value.join(" ")),
"email" => email = Some(current_value.join(" ")),
_ => {}
}
}
current_arg = Some("username");
current_value.clear();
i += 1;
}
"-e" => {
if let Some(arg_type) = current_arg {
match arg_type {
"username" => username = Some(current_value.join(" ")),
"email" => email = Some(current_value.join(" ")),
_ => {}
}
}
current_arg = Some("email");
current_value.clear();
i += 1;
}
_ => {
if current_arg.is_some() {
current_value.push(parts[i].trim_matches('"'));
}
i += 1;
}
}
}
// Handle the last argument
if let Some(arg_type) = current_arg {
match arg_type {
"username" => username = Some(current_value.join(" ")),
"email" => email = Some(current_value.join(" ")),
_ => {}
}
}
match (username, email) {
(Some(u), Some(e)) if !u.is_empty() && !e.is_empty() => Ok((u, e)),
_ => Err(anyhow::anyhow!(
"Invalid command format. Use: user create -u <username> -e <email>\nExample: user create -u \"john doe\" -e \"john@example.com\""
)),
}
}
fn parse_product_create(cmd: &str) -> anyhow::Result<(String, String)> {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.len() < 6 {
return Err(anyhow::anyhow!(
"Invalid command format. Use: product create -n <name> -d <description>\nExample: product create -n \"My Product\" -d \"A great product description\""
));
}
let mut name = None;
let mut description = None;
let mut current_arg = None;
let mut current_value = Vec::new();
// Skip "product create" command
let mut i = 2;
while i < parts.len() {
match parts[i] {
"-n" => {
if let Some(arg_type) = current_arg {
match arg_type {
"name" => name = Some(current_value.join(" ")),
"description" => description = Some(current_value.join(" ")),
_ => {}
}
}
current_arg = Some("name");
current_value.clear();
i += 1;
}
"-d" => {
if let Some(arg_type) = current_arg {
match arg_type {
"name" => name = Some(current_value.join(" ")),
"description" => description = Some(current_value.join(" ")),
_ => {}
}
}
current_arg = Some("description");
current_value.clear();
i += 1;
}
_ => {
if current_arg.is_some() {
current_value.push(parts[i].trim_matches('"'));
}
i += 1;
}
}
}
// Handle the last argument
if let Some(arg_type) = current_arg {
match arg_type {
"name" => name = Some(current_value.join(" ")),
"description" => description = Some(current_value.join(" ")),
_ => {}
}
}
match (name, description) {
(Some(n), Some(d)) if !n.is_empty() && !d.is_empty() => Ok((n, d)),
_ => Err(anyhow::anyhow!(
"Invalid command format. Use: product create -n <name> -d <description>\nExample: product create -n \"My Product\" -d \"A great product description\""
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Cell;
use ratatui::layout::{Size, Rect};
use ratatui::backend::WindowSize;
use memory::{InMemoryUserRepository, InMemoryProductRepository};
use application::Repository;
#[test]
fn test_app_new_initializes_fields() {
let app = App::new();
assert_eq!(app.input, "");
assert_eq!(app.messages, vec!["Welcome to Sharenet CLI!".to_string()]);
assert!(!app.should_quit);
assert_eq!(app.command_history.len(), 0);
assert_eq!(app.command_history.capacity(), MAX_HISTORY);
assert_eq!(app.history_index, None);
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_add_message_appends() {
let mut app = App::new();
app.add_message("Hello".to_string());
assert!(app.messages.contains(&"Hello".to_string()));
}
#[test]
fn test_clear_input_resets_input_and_cursor() {
let mut app = App::new();
app.input = "abc".to_string();
app.cursor_position = 2;
app.clear_input();
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_add_to_history_adds_and_limits() {
let mut app = App::new();
for i in 0..(MAX_HISTORY + 5) {
app.add_to_history(format!("cmd{}", i));
}
assert_eq!(app.command_history.len(), MAX_HISTORY);
assert_eq!(app.command_history[0], format!("cmd{}", MAX_HISTORY + 4));
assert_eq!(app.command_history[MAX_HISTORY - 1], "cmd5");
}
#[test]
fn test_add_to_history_ignores_empty() {
let mut app = App::new();
app.add_to_history(" ".to_string());
assert!(app.command_history.is_empty());
}
#[test]
fn test_move_cursor_within_bounds() {
let mut app = App::new();
app.input = "abc".to_string();
app.cursor_position = 1;
app.move_cursor(1);
assert_eq!(app.cursor_position, 2);
app.move_cursor(-1);
assert_eq!(app.cursor_position, 1);
app.move_cursor(-2); // Should not go below 0
assert_eq!(app.cursor_position, 1);
app.move_cursor(100); // Should not go past input length
assert_eq!(app.cursor_position, 1);
}
#[test]
fn test_insert_char_and_delete_char() {
let mut app = App::new();
app.input = "ac".to_string();
app.cursor_position = 1;
app.insert_char('b');
assert_eq!(app.input, "abc");
assert_eq!(app.cursor_position, 2);
app.delete_char();
assert_eq!(app.input, "ac");
assert_eq!(app.cursor_position, 1);
app.delete_char();
assert_eq!(app.input, "c");
assert_eq!(app.cursor_position, 0);
app.delete_char(); // Should do nothing
assert_eq!(app.input, "c");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_parse_user_create_valid() {
let cmd = "user create -u alice -e alice@example.com";
let parsed = parse_user_create(cmd).unwrap();
assert_eq!(parsed, ("alice".to_string(), "alice@example.com".to_string()));
}
#[test]
fn test_parse_user_create_invalid() {
let cmd = "user create -u alice";
assert!(parse_user_create(cmd).is_err());
let cmd = "user create";
assert!(parse_user_create(cmd).is_err());
}
#[test]
fn test_parse_product_create_valid() {
let cmd = "product create -n widget -d description";
let parsed = parse_product_create(cmd).unwrap();
assert_eq!(parsed, ("widget".to_string(), "description".to_string()));
}
#[test]
fn test_parse_product_create_invalid() {
let cmd = "product create -n widget";
assert!(parse_product_create(cmd).is_err());
let cmd = "product create";
assert!(parse_product_create(cmd).is_err());
}
#[test]
fn test_print_help_adds_message() {
let mut app = App::new();
print_help(&mut app);
assert!(app.messages.iter().any(|m| m.contains("Available commands")));
}
// UI rendering and event handling tests are limited in unit tests, but we can check that ui() doesn't panic.
#[allow(dead_code)]
struct DummyBackend;
impl Backend for DummyBackend {
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> where I: Iterator<Item = (u16, u16, &'a Cell)> { Ok(()) }
fn hide_cursor(&mut self) -> io::Result<()> { Ok(()) }
fn show_cursor(&mut self) -> io::Result<()> { Ok(()) }
fn get_cursor(&mut self) -> io::Result<(u16, u16)> { Ok((0, 0)) }
fn set_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> { Ok(()) }
fn clear(&mut self) -> io::Result<()> { Ok(()) }
fn size(&self) -> io::Result<Rect> { Ok(Rect::new(0, 0, 1, 1)) }
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: Size { width: 1, height: 1 },
pixels: Size { width: 0, height: 0 },
})
}
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
#[test]
fn test_ui_does_not_panic() {
use ratatui::prelude::*;
let backend = CrosstermBackend::new(std::io::sink());
let mut terminal = Terminal::new(backend).unwrap();
let app = App::new();
// Just check that calling ui does not panic
terminal.draw(|f| ui(f, &app)).unwrap();
}
#[tokio::test]
async fn test_find_all_products_empty() {
let repo = InMemoryProductRepository::new();
let products = repo.find_all().await.unwrap();
assert_eq!(products.len(), 0);
}
#[tokio::test]
async fn test_concurrent_access() {
let repo = InMemoryUserRepository::new();
let create_data = CreateUser {
username: "concurrent_user".to_string(),
email: "concurrent@example.com".to_string(),
};
let user = repo.create(create_data).await.unwrap();
let user_id = user.id;
let repo_clone = repo.clone();
let handle = tokio::spawn(async move {
repo_clone.find_by_id(user_id).await
});
let direct_result = repo.find_by_id(user_id).await;
let spawned_result = handle.await.unwrap();
assert!(direct_result.is_ok());
assert!(spawned_result.is_ok());
assert_eq!(direct_result.unwrap().id, user_id);
assert_eq!(spawned_result.unwrap().id, user_id);
}
}