From d155f388519d8cb99b99925da0be6fa4c93834a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rickard=20Hallerb=C3=A4ck?= Date: Mon, 23 Sep 2024 21:24:08 +0200 Subject: [PATCH] scrollable chat --- src/client.rs | 4 +- src/ratatui_scroll.rs | 211 ++++++++++++++++++++++++++++++++++++++++++ src/terminal.rs | 107 ++++++++++++--------- 3 files changed, 278 insertions(+), 44 deletions(-) create mode 100644 src/ratatui_scroll.rs diff --git a/src/client.rs b/src/client.rs index efffd97..bbac3f2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -180,7 +180,7 @@ impl InputCommand { println_message(1, InputCommand::get_small_help()).await; } fn get_small_help() -> String { - "Type !exit to exit and !help for more commands.".to_string() + "Welcome to Chat-PGP. Type !help for help.".to_string() } async fn read_yes_or_no( window: usize, @@ -198,7 +198,7 @@ impl InputCommand { } } } - Ok(true) + Ok(false) } } diff --git a/src/ratatui_scroll.rs b/src/ratatui_scroll.rs new file mode 100644 index 0000000..d6e7bba --- /dev/null +++ b/src/ratatui_scroll.rs @@ -0,0 +1,211 @@ +//! # [Ratatui] Scrollbar example +//! +//! The latest version of this example is available in the [examples] folder in the repository. +//! +//! Please note that the examples are designed to be run against the `main` branch of the Github +//! repository. This means that you may not be able to compile with the latest release version on +//! crates.io, or the one that you have installed locally. +//! +//! See the [examples readme] for more information on finding examples that match the version of the +//! library you are using. +//! +//! [Ratatui]: https://github.com/ratatui/ratatui +//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples +//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md + +#![warn(clippy::pedantic)] + +use std::time::{Duration, Instant}; + +use color_eyre::Result; +use ratatui::{ + crossterm::event::{self, Event, KeyCode}, + layout::{Alignment, Constraint, Layout, Margin}, + style::{Color, Style, Stylize}, + symbols::scrollbar, + text::{Line, Masked, Span}, + widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + DefaultTerminal, Frame, +}; + +#[derive(Default)] +struct App { + pub vertical_scroll_state: ScrollbarState, + pub horizontal_scroll_state: ScrollbarState, + pub vertical_scroll: usize, + pub horizontal_scroll: usize, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let terminal = ratatui::init(); + let app_result = App::default().run(terminal); + ratatui::restore(); + app_result +} + +impl App { + fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + let tick_rate = Duration::from_millis(250); + let mut last_tick = Instant::now(); + loop { + terminal.draw(|frame| self.draw(frame))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Char('j') | KeyCode::Down => { + self.vertical_scroll = self.vertical_scroll.saturating_add(1); + self.vertical_scroll_state = + self.vertical_scroll_state.position(self.vertical_scroll); + } + KeyCode::Char('k') | KeyCode::Up => { + self.vertical_scroll = self.vertical_scroll.saturating_sub(1); + self.vertical_scroll_state = + self.vertical_scroll_state.position(self.vertical_scroll); + } + KeyCode::Char('h') | KeyCode::Left => { + self.horizontal_scroll = self.horizontal_scroll.saturating_sub(1); + self.horizontal_scroll_state = self + .horizontal_scroll_state + .position(self.horizontal_scroll); + } + KeyCode::Char('l') | KeyCode::Right => { + self.horizontal_scroll = self.horizontal_scroll.saturating_add(1); + self.horizontal_scroll_state = self + .horizontal_scroll_state + .position(self.horizontal_scroll); + } + _ => {} + } + } + } + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + } + } + + #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)] + fn draw(&mut self, frame: &mut Frame) { + let area = frame.area(); + + // Words made "loooong" to demonstrate line breaking. + let s = + "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. "; + let mut long_line = s.repeat(usize::from(area.width) / s.len() + 4); + long_line.push('\n'); + + let chunks = Layout::vertical([ + Constraint::Min(1), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(area); + + let text = vec![ + Line::from("This is a line "), + Line::from("This is a line ".red()), + Line::from("This is a line".on_dark_gray()), + Line::from("This is a longer line".crossed_out()), + Line::from(long_line.clone()), + Line::from("This is a line".reset()), + Line::from(vec![ + Span::raw("Masked text: "), + Span::styled(Masked::new("password", '*'), Style::new().fg(Color::Red)), + ]), + Line::from("This is a line "), + Line::from("This is a line ".red()), + Line::from("This is a line".on_dark_gray()), + Line::from("This is a longer line".crossed_out()), + Line::from(long_line.clone()), + Line::from("This is a line".reset()), + Line::from(vec![ + Span::raw("Masked text: "), + Span::styled(Masked::new("password", '*'), Style::new().fg(Color::Red)), + ]), + ]; + self.vertical_scroll_state = self.vertical_scroll_state.content_length(text.len()); + self.horizontal_scroll_state = self.horizontal_scroll_state.content_length(long_line.len()); + + let create_block = |title: &'static str| Block::bordered().gray().title(title.bold()); + + let title = Block::new() + .title_alignment(Alignment::Center) + .title("Use h j k l or ◄ ▲ ▼ ► to scroll ".bold()); + frame.render_widget(title, chunks[0]); + + let paragraph = Paragraph::new(text.clone()) + .gray() + .block(create_block("Vertical scrollbar with arrows")) + .scroll((self.vertical_scroll as u16, 0)); + frame.render_widget(paragraph, chunks[1]); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")), + chunks[1], + &mut self.vertical_scroll_state, + ); + + let paragraph = Paragraph::new(text.clone()) + .gray() + .block(create_block( + "Vertical scrollbar without arrows, without track symbol and mirrored", + )) + .scroll((self.vertical_scroll as u16, 0)); + frame.render_widget(paragraph, chunks[2]); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalLeft) + .symbols(scrollbar::VERTICAL) + .begin_symbol(None) + .track_symbol(None) + .end_symbol(None), + chunks[2].inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut self.vertical_scroll_state, + ); + + let paragraph = Paragraph::new(text.clone()) + .gray() + .block(create_block( + "Horizontal scrollbar with only begin arrow & custom thumb symbol", + )) + .scroll((0, self.horizontal_scroll as u16)); + frame.render_widget(paragraph, chunks[3]); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::HorizontalBottom) + .thumb_symbol("🬋") + .end_symbol(None), + chunks[3].inner(Margin { + vertical: 0, + horizontal: 1, + }), + &mut self.horizontal_scroll_state, + ); + + let paragraph = Paragraph::new(text.clone()) + .gray() + .block(create_block( + "Horizontal scrollbar without arrows & custom thumb and track symbol", + )) + .scroll((0, self.horizontal_scroll as u16)); + frame.render_widget(paragraph, chunks[4]); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::HorizontalBottom) + .thumb_symbol("░") + .track_symbol(Some("─")), + chunks[4].inner(Margin { + vertical: 0, + horizontal: 1, + }), + &mut self.horizontal_scroll_state, + ); + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 66e5954..469ca44 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -12,9 +12,10 @@ use color_eyre::Result; use ratatui::{ crossterm::event::{self, Event, KeyCode, KeyEventKind}, layout::{Constraint, Layout, Position}, + prelude::Margin, style::{Color, Modifier, Style, Stylize}, text::{Line, Span, Text}, - widgets::{Block, List, ListItem, Paragraph}, + widgets::{Block, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, DefaultTerminal, Frame, }; @@ -177,6 +178,8 @@ struct AppState { pub messages: Vec<(String, TextStyle)>, pub chat_messages: Vec, pub chatid: String, + pub vertical_position: usize, + pub scrollstate: ScrollbarState, } /// App holds the state of the application @@ -202,6 +205,8 @@ impl App { messages: Vec::new(), chat_messages: Vec::new(), chatid: "Nobody".to_string(), + vertical_position: 0, + scrollstate: ScrollbarState::default(), })), should_run: Arc::new(Mutex::new(true)), tx: tx, @@ -339,6 +344,16 @@ impl App { let state = self.state.lock().await; state.input.chars().count() } + async fn move_vertical_scroll_down(&mut self) { + let mut state = self.state.lock().await; + state.vertical_position = state.vertical_position.saturating_add(1); + state.scrollstate = state.scrollstate.position(state.vertical_position); + } + async fn move_vertical_scroll_up(&mut self) { + let mut state = self.state.lock().await; + state.vertical_position = state.vertical_position.saturating_sub(1); + state.scrollstate = state.scrollstate.position(state.vertical_position); + } async fn write_new_message(&mut self, message: String, style: TextStyle) { let mut state = self.state.lock().await; state.messages.push((message, style)); @@ -347,6 +362,7 @@ impl App { let mut state = self.state.lock().await; state.chatid = chatid; state.chat_messages.push(message); + state.scrollstate = state.scrollstate.content_length(state.chat_messages.len()); } async fn set_last_message(&mut self, window: usize, message: String, style: TextStyle) { let mut state = self.state.lock().await; @@ -381,35 +397,23 @@ impl App { while should_run { let timeout_duration = Duration::from_millis(100); match timeout(timeout_duration, pipe.read()).await { - Ok(Ok(command)) => { - match command { - WindowCommand::Print(cmd) => { - app.set_last_message(cmd.window, cmd.message, TextStyle::Normal) - .await; - } - WindowCommand::Println(cmd) => { - app.write_new_message(cmd.message, TextStyle::Normal).await; - } - WindowCommand::ChatClosed(cmd) => { - app.write_new_message(cmd.message, TextStyle::Bold).await; - app.clear_chat().await; - } - WindowCommand::PrintChat(cmd) => { - app.write_chat_new_message(cmd.chatid, cmd.message).await; - } - /*WindowCommand::Read(cmd) => { - app.write_new_message(cmd.window, cmd.prompt).await; - app.await_submit().await; - let s = app.get_submitted().await; - //send_terminal_input(s.clone()).await; - //pipe.tx_input(s.clone()).await; - app.set_last_message(cmd.window, s).await; - }*/ - _ => {} - }; - // let state = app.get_state().await; - // send_app_state(state).await; - } + Ok(Ok(command)) => match command { + WindowCommand::Print(cmd) => { + app.set_last_message(cmd.window, cmd.message, TextStyle::Normal) + .await; + } + WindowCommand::Println(cmd) => { + app.write_new_message(cmd.message, TextStyle::Normal).await; + } + WindowCommand::ChatClosed(cmd) => { + app.write_new_message(cmd.message, TextStyle::Bold).await; + app.clear_chat().await; + } + WindowCommand::PrintChat(cmd) => { + app.write_chat_new_message(cmd.chatid, cmd.message).await; + } + _ => {} + }, Ok(Err(_)) => { println!("got an error"); } @@ -429,9 +433,9 @@ impl App { should_run = app.should_run().await; } while should_run { - let state = read_app_state().await.unwrap(); + let mut state = read_app_state().await.unwrap(); - let _ = terminal.draw(|frame| App::draw(frame, state)); + let _ = terminal.draw(|frame| App::draw(frame, &mut state)); { should_run = app.should_run().await; } @@ -476,6 +480,8 @@ impl App { app.set_terminate().await; let _ = tx.send(None).await; } + KeyCode::Up => app.move_vertical_scroll_up().await, + KeyCode::Down => app.move_vertical_scroll_down().await, _ => {} }, InputMode::Editing if key.kind == KeyEventKind::Press => { @@ -485,6 +491,8 @@ impl App { KeyCode::Char(to_insert) => app.enter_char(to_insert).await, KeyCode::Backspace => app.delete_char().await, KeyCode::Left => app.move_cursor_left(len).await, + KeyCode::Up => app.move_vertical_scroll_up().await, + KeyCode::Down => app.move_vertical_scroll_down().await, KeyCode::Right => app.move_cursor_right(len).await, KeyCode::Esc => app.set_input_mode(InputMode::Normal).await, _ => {} @@ -507,13 +515,14 @@ impl App { h3.await.unwrap(); } - fn draw(frame: &mut Frame, state: AppState) { - let messages = state.messages; - let input = state.input; - let input_mode = state.input_mode; + fn draw(frame: &mut Frame, state: &mut AppState) { + let messages = &state.messages; + let input = &state.input; + let input_mode = &state.input_mode; let character_index = state.character_index; - let chat_messages = state.chat_messages; - let chatid = state.chatid; + let chat_messages = &state.chat_messages; + let chatid = &state.chatid; + if chat_messages.len() == 0 { let vertical = Layout::vertical([ Constraint::Length(1), @@ -650,16 +659,30 @@ impl App { )), } - let messages: Vec = chat_messages + let messages: Vec = chat_messages .iter() .map(|m| { - let content = Line::from(Span::raw(format!("{m}"))); - ListItem::new(content) + Line::from(Span::raw(format!("{m}"))) }) .collect(); let messages = - List::new(messages).block(Block::bordered().title(format!("Chat with {}", chatid))); + Paragraph::new(messages).block(Block::bordered().title(format!("Chat with {}", chatid))).scroll((state.vertical_position as u16, 0)); + + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + frame.render_widget(messages, chat_area); + + frame.render_stateful_widget( + scrollbar, + chat_area.inner(Margin { + // using an inner vertical margin of 1 unit makes the scrollbar inside the block + vertical: 1, + horizontal: 0, + }), + &mut state.scrollstate, + ); } } }