sharenet/backend/crates/sharenet-tui-memory/src/tui.rs

464 lines
No EOL
16 KiB
Rust

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 user_service_clone
.create(CreateUser { username, email })
.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)),
}
}
"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(CreateProduct { name, description })
.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\""
)),
}
}