diff options
author | Oxbian <oxbian@mailbox.org> | 2025-02-20 13:55:52 -0500 |
---|---|---|
committer | Oxbian <oxbian@mailbox.org> | 2025-02-20 13:55:52 -0500 |
commit | 3dfa9363d2ef0eb217fb534c30317930a72b519e (patch) | |
tree | 0bef672952a40b24a5dd3a1cf08913b5b34ba498 | |
parent | 0d4bbf7a60012b459be5dfe3077055c8e25bba02 (diff) | |
download | NAI-3dfa9363d2ef0eb217fb534c30317930a72b519e.tar.gz NAI-3dfa9363d2ef0eb217fb534c30317930a72b519e.zip |
feat: input scrollbar + structuring the project
-rw-r--r-- | src/app/init.rs (renamed from src/app.rs) | 0 | ||||
-rw-r--r-- | src/app/mod.rs | 1 | ||||
-rw-r--r-- | src/lib.rs | 2 | ||||
-rw-r--r-- | src/main.rs | 11 | ||||
-rw-r--r-- | src/ui.rs | 255 | ||||
-rw-r--r-- | src/ui/init.rs | 171 | ||||
-rw-r--r-- | src/ui/inputfield.rs | 172 | ||||
-rw-r--r-- | src/ui/mod.rs | 2 |
8 files changed, 353 insertions, 261 deletions
diff --git a/src/app.rs b/src/app/init.rs index ff9f130..ff9f130 100644 --- a/src/app.rs +++ b/src/app/init.rs diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..43763f1 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1 @@ +pub mod init; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3e2facd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod ui; diff --git a/src/main.rs b/src/main.rs index 59ab2b2..a830969 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,15 @@ -use crate::{app::App, ui::Ui}; -use color_eyre::Result; -use ratatui; -//use reqwest; mod app; mod ui; +use crate::{app::init::App, ui::init::Ui}; +use color_eyre::Result; +use ratatui; fn main() -> Result<()> { // Setup terminal - let mut terminal = ratatui::init(); + let terminal = ratatui::init(); // Run the app - let mut app = App::new(); + let app = App::new(); let res = Ui::new(app).run(terminal); // Clean diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 13b7b82..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,255 +0,0 @@ -use crate::app::App; -use color_eyre::Result; -use ratatui::{ - crossterm::event::{self, Event, KeyCode, KeyEventKind}, - layout::{Constraint, Layout, Position}, - style::{Color, Style, Stylize}, - text::{Line, Span, Text}, - widgets::{ - Block, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, - }, - DefaultTerminal, Frame, -}; - -pub struct Ui { - input: String, - input_mode: InputMode, - character_index: usize, // Cursor index in 1D input - app: App, - scroll_offset_input: usize, - scroll_offset_messages: usize, -} - -pub enum InputMode { - Normal, - Editing, -} - -impl Ui { - pub fn new(app: App) -> Self { - Self { - input: String::new(), - input_mode: InputMode::Normal, - character_index: 0, - app, - scroll_offset_input: 0, - scroll_offset_messages: 0, - } - } - - fn move_cursor_left(&mut self) { - let cursor_moved_left = self.character_index.saturating_sub(1); - self.character_index = self.clamp_cursor(cursor_moved_left); - } - - fn move_cursor_right(&mut self) { - let cursor_moved_right = self.character_index.saturating_add(1); - self.character_index = self.clamp_cursor(cursor_moved_right); - } - - fn enter_char(&mut self, new_char: char) { - let index = self.byte_index(); - self.input.insert(index, new_char); - self.move_cursor_right(); - } - - fn byte_index(&self) -> usize { - self.input - .char_indices() - .map(|(i, _)| i) - .nth(self.character_index) - .unwrap_or(self.input.len()) - } - - fn delete_char(&mut self) { - if self.character_index != 0 { - // Method "remove" is not used on the saved text for deleting the selected char. - // Reason: Using remove on String works on bytes instead of the chars. - // Using remove would require special care because of char boundaries. - - let current_index = self.character_index; - let from_left_to_current_index = current_index - 1; - - // Getting all characters before the selected character. - let before_char_to_delete = self.input.chars().take(from_left_to_current_index); - // Getting all characters after selected character. - let after_char_to_delete = self.input.chars().skip(current_index); - - // Put all characters together except the selected one. - // By leaving the selected one out, it is forgotten and therefore deleted. - self.input = before_char_to_delete.chain(after_char_to_delete).collect(); - self.move_cursor_left(); - } - } - - // Limit the character_index between 0 and inputfield characters number - fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { - new_cursor_pos.clamp(0, self.input.chars().count()) - } - - fn reset_char_index(&mut self) { - self.character_index = 0; - } - - // Send the message to the LLM API when "enter" pressed - fn submit_message(&mut self) { - if self.input.len() > 0 { - self.input_mode = InputMode::Normal; - self.app.send_message(self.input.clone()); - self.input.clear(); - self.reset_char_index(); - } - } - - // Get the max chars allowed per line (not trustable while a line is not completed) - fn get_max_chars_per_line(&self, area_width: u16) -> usize { - let available_width = area_width.saturating_sub(2); // Retirer les bordures - self.input.chars().take(available_width as usize).count() - } - - // Get the number of line needed for the inputfield text - fn get_nb_line(&self, area_width: u16) -> usize { - let available_width = area_width.saturating_sub(2); // Retirer les bordures - (self.input.chars().count() as f64 / available_width as f64).ceil() as usize - } - - pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { - loop { - terminal.draw(|frame| self.draw(frame))?; - - if let Event::Key(key) = event::read()? { - match self.input_mode { - InputMode::Normal => match key.code { - KeyCode::Char('e') => { - self.input_mode = InputMode::Editing; - } - KeyCode::Char('q') => return Ok(()), - _ => {} - }, - InputMode::Editing if key.kind == KeyEventKind::Press => match key.code { - KeyCode::Enter => self.submit_message(), - KeyCode::Char(to_insert) => self.enter_char(to_insert), - KeyCode::Backspace => self.delete_char(), - KeyCode::Left => self.move_cursor_left(), - KeyCode::Right => self.move_cursor_right(), - KeyCode::Esc => self.input_mode = InputMode::Normal, - KeyCode::Up => { - if self.scroll_offset_messages > 0 { - self.scroll_offset_messages -= 1; - } - } - KeyCode::Down => { - // TODO: WTF - let message_count = self.app.messages.len(); - if self.scroll_offset_messages < message_count.saturating_sub(1) { - self.scroll_offset_messages += 1; - } - } - - _ => {} - }, - InputMode::Editing => {} - } - } - } - } - - fn draw(&self, frame: &mut Frame) { - let vertical = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(10), - Constraint::Length(5), - ]); - let [help_area, messages_area, input_area] = vertical.areas(frame.area()); - - let (msg, style) = match self.input_mode { - InputMode::Normal => ( - vec![ - "Press ".into(), - "q".bold(), - " to exit, ".into(), - "e".bold(), - " to start editing.".bold(), - ], - Style::default(), - ), - InputMode::Editing => ( - vec![ - "Press ".into(), - "Esc".bold(), - " to stop editing, ".into(), - "Enter".bold(), - " to send the message to Néo AI".into(), - ], - Style::default(), - ), - }; - let text = Text::from(Line::from(msg)).patch_style(style); - let help_message = Paragraph::new(text); - frame.render_widget(help_message, help_area); - - let input = Paragraph::new(self.input.as_str()) - .style(match self.input_mode { - InputMode::Normal => Style::default(), - InputMode::Editing => Style::default().fg(Color::Yellow), - }) - .block(Block::bordered().title("Input")) - .wrap(Wrap { trim: false }); - frame.render_widget(input, input_area); - - let nb_line = self.get_nb_line(input_area.width); - let max_char = self.get_max_chars_per_line(input_area.width); - - let cursor_y = if nb_line > 1 { - (self.character_index / max_char) + 1 - } else { - 1 - }; - let cursor_x = if nb_line > 1 { - self.character_index % max_char - } else { - self.character_index - }; - - match self.input_mode { - // Hide the cursor. `Frame` does this by default, so we don't need to do anything here - InputMode::Normal => {} - - // Make the cursor visible and ask ratatui to put it at the specified coordinates after - // rendering - #[allow(clippy::cast_possible_truncation)] - InputMode::Editing => frame.set_cursor_position(Position::new( - // Draw the cursor at the current position in the input field. - // This position is can be controlled via the left and right arrow key - input_area.x + cursor_x as u16 + 1, - input_area.y + cursor_y as u16, - )), - } - let mut scrollbar_state_input = ScrollbarState::new(self.get_nb_line(input_area.width)) - .position(self.scroll_offset_input); - let scrollbar_input = Scrollbar::new(ScrollbarOrientation::VerticalRight); - frame.render_stateful_widget(scrollbar_input, input_area, &mut scrollbar_state_input); - - let messages: Vec<ListItem> = self - .app - .messages - .iter() - .map(|m| { - let content = Line::from(Span::raw(format!("{m}"))); - ListItem::new(content) - }) - .collect(); - let messages = List::new(messages).block(Block::bordered().title("Chat with Néo AI")); - frame.render_widget(messages, messages_area); - - let message_count = self.app.messages.len(); - let mut scrollbar_state_messages = - ScrollbarState::new(message_count).position(self.scroll_offset_messages); - let scrollbar_messages = Scrollbar::new(ScrollbarOrientation::VerticalRight); - frame.render_stateful_widget( - scrollbar_messages, - messages_area, - &mut scrollbar_state_messages, - ); - } -} diff --git a/src/ui/init.rs b/src/ui/init.rs new file mode 100644 index 0000000..6d27236 --- /dev/null +++ b/src/ui/init.rs @@ -0,0 +1,171 @@ +use crate::app::init::App; +use crate::ui::inputfield::{BoxData, InputField, InputMode}; +use color_eyre::Result; +use ratatui::{ + crossterm::event::{self, Event, KeyCode, KeyEventKind}, + layout::{Constraint, Layout, Position}, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{ + Block, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, + }, + DefaultTerminal, Frame, +}; + +pub struct Ui { + app: App, + input_field: InputField, + message_box_data: BoxData, +} + +impl Ui { + pub fn new(app: App) -> Self { + Self { + app, + input_field: InputField::new(), + message_box_data: BoxData::new(), + } + } + + // Send the message to the LLM API when "enter" pressed + pub fn submit_message(&mut self) { + if self.input_field.input_len() > 0 { + self.input_field.input_mode = InputMode::Normal; + let _ = self.app.send_message(self.input_field.input.clone()); + self.input_field.input.clear(); + self.input_field.reset_char_index(); + } + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + loop { + terminal.draw(|frame| self.draw(frame))?; + + if let Event::Key(key) = event::read()? { + match self.input_field.input_mode { + InputMode::Normal => match key.code { + KeyCode::Char('e') => { + self.input_field.input_mode = InputMode::Editing; + } + KeyCode::Char('q') => return Ok(()), + _ => {} + }, + InputMode::Editing if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Enter => self.submit_message(), + KeyCode::Char(to_insert) => self.input_field.enter_char(to_insert), + KeyCode::Backspace => self.input_field.delete_char(), + KeyCode::Left => self.input_field.move_cursor_left(), + KeyCode::Right => self.input_field.move_cursor_right(), + KeyCode::Up => self.input_field.move_cursor_up(), + KeyCode::Down => self.input_field.move_cursor_down(), + KeyCode::Esc => self.input_field.input_mode = InputMode::Normal, + _ => {} + }, + InputMode::Editing => {} + } + } + } + } + + fn wrap_text(&self, text: String, max_width: usize) -> Vec<Line<'_>> { + text.chars() + .collect::<Vec<_>>() + .chunks(max_width) + .map(|chunk| Line::from(Span::raw(chunk.iter().collect::<String>()))) + .collect() + } + + fn draw(&mut self, frame: &mut Frame) { + let vertical = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(10), + Constraint::Length(5), + ]); + let [help_area, messages_area, input_area] = vertical.areas(frame.area()); + + let (msg, style) = match self.input_field.input_mode { + InputMode::Normal => ( + vec![ + "Press ".into(), + "q".bold(), + " to exit, ".into(), + "e".bold(), + " to start editing.".bold(), + ], + Style::default(), + ), + InputMode::Editing => ( + vec![ + "Press ".into(), + "Esc".bold(), + " to stop editing, ".into(), + "Enter".bold(), + " to send the message to Néo AI".into(), + ], + Style::default(), + ), + }; + let text = Text::from(Line::from(msg)).patch_style(style); + let help_message = Paragraph::new(text); + frame.render_widget(help_message, help_area); + + let input = Paragraph::new(self.input_field.input.as_str()) + .style(match self.input_field.input_mode { + InputMode::Normal => Style::default(), + InputMode::Editing => Style::default().fg(Color::Yellow), + }) + .block(Block::bordered().title("Input")) + .wrap(Wrap { trim: true }) + .scroll((self.input_field.input_data.scroll_offset as u16, 0)); + frame.render_widget(input, input_area); + + self.input_field.update_nb_line(input_area.width); + self.input_field + .update_max(input_area.width, input_area.height); + + let cursor_y = self.input_field.cursor_y(); + let cursor_x = self.input_field.cursor_x(); + + match self.input_field.input_mode { + // Hide the cursor. `Frame` does this by default, so we don't need to do anything here + InputMode::Normal => {} + + // Make the cursor visible and ask ratatui to put it at the specified coordinates after + // rendering + #[allow(clippy::cast_possible_truncation)] + InputMode::Editing => frame.set_cursor_position(Position::new( + // Draw the cursor at the current position in the input field. + // This position is can be controlled via the left and right arrow key + input_area.x + cursor_x as u16 + 1, + input_area.y + cursor_y as u16, + )), + } + let mut scrollbar_state_input = ScrollbarState::new(self.input_field.input_data.nb_line) + .position(self.input_field.input_data.scroll_offset); + let scrollbar_input = Scrollbar::new(ScrollbarOrientation::VerticalRight); + frame.render_stateful_widget(scrollbar_input, input_area, &mut scrollbar_state_input); + + let available_width_message = messages_area.width.saturating_sub(2); + for m in &self.app.messages { + let msg = format!("{}", m); + let size = msg.chars().take(available_width_message as usize).count(); + + if size > self.message_box_data.max_char_per_line { + self.message_box_data.max_char_per_line = size; + } + } + + let messages: Vec<ListItem> = self + .app + .messages + .iter() + .map(|m| { + let content = + self.wrap_text(format!("{}", m), self.message_box_data.max_char_per_line); + ListItem::new(content) + }) + .collect(); + let messages = List::new(messages).block(Block::bordered().title("Chat with Néo AI")); + frame.render_widget(messages, messages_area); + } +} diff --git a/src/ui/inputfield.rs b/src/ui/inputfield.rs new file mode 100644 index 0000000..8717cfe --- /dev/null +++ b/src/ui/inputfield.rs @@ -0,0 +1,172 @@ +pub struct BoxData { + pub max_char_per_line: usize, + pub max_line: usize, + pub nb_line: usize, + pub scroll_offset: usize, +} + +pub struct InputField { + pub input: String, + pub input_mode: InputMode, + character_index: usize, // Cursor index in 1D input + pub input_data: BoxData, // InputField data +} + +pub enum InputMode { + Normal, + Editing, +} + +impl BoxData { + pub fn new() -> Self { + Self { + max_char_per_line: 1, + max_line: 1, + nb_line: 0, + scroll_offset: 0, + } + } +} + +impl InputField { + pub fn new() -> Self { + Self { + input: String::new(), + input_mode: InputMode::Normal, + character_index: 0, + input_data: BoxData::new(), + } + } + + // Move cursor left in 1D + pub fn move_cursor_left(&mut self) { + let cursor_moved_left = self.character_index.saturating_sub(1); + self.character_index = self.clamp_cursor(cursor_moved_left); + } + + // Move cursor right in 1D + pub fn move_cursor_right(&mut self) { + let cursor_moved_right = self.character_index.saturating_add(1); + self.character_index = self.clamp_cursor(cursor_moved_right); + } + + // Move cursor in 2D, y-1 + pub fn move_cursor_up(&mut self) { + if self.input_data.nb_line > 1 { + let cursor_moved_up = self + .character_index + .saturating_sub(self.input_data.max_char_per_line); + self.character_index = self.clamp_cursor(cursor_moved_up); + } + } + + // Move cursor in 2D, y+1 + pub fn move_cursor_down(&mut self) { + if self.input_data.nb_line > 1 + && self + .character_index + .saturating_add(self.input_data.max_char_per_line) + < self.input_len() + { + let cursor_moved_down = self + .character_index + .saturating_add(self.input_data.max_char_per_line); + self.character_index = self.clamp_cursor(cursor_moved_down); + } + } + + pub fn enter_char(&mut self, new_char: char) { + let index = self.byte_index(); + self.input.insert(index, new_char); + self.move_cursor_right(); + } + + // Limit the character_index between 0 and inputfield characters number + fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { + new_cursor_pos.clamp(0, self.input.chars().count()) + } + + pub fn reset_char_index(&mut self) { + self.character_index = 0; + self.input_data.scroll_offset = 0; + } + + fn byte_index(&self) -> usize { + self.input + .char_indices() + .map(|(i, _)| i) + .nth(self.character_index) + .unwrap_or(self.input.len()) + } + + pub fn delete_char(&mut self) { + if self.character_index != 0 { + // Method "remove" is not used on the saved text for deleting the selected char. + // Reason: Using remove on String works on bytes instead of the chars. + // Using remove would require special care because of char boundaries. + + let current_index = self.character_index; + let from_left_to_current_index = current_index - 1; + + // Getting all characters before the selected character. + let before_char_to_delete = self.input.chars().take(from_left_to_current_index); + // Getting all characters after selected character. + let after_char_to_delete = self.input.chars().skip(current_index); + + // Put all characters together except the selected one. + // By leaving the selected one out, it is forgotten and therefore deleted. + self.input = before_char_to_delete.chain(after_char_to_delete).collect(); + self.move_cursor_left(); + } + } + + // Get the max chars allowed per line (not trustable while a line is not completed) and the max + // line allowed + pub fn update_max(&mut self, area_width: u16, area_height: u16) { + let available_width = area_width.saturating_sub(2); // Retirer les bordures + self.input_data.max_char_per_line = + self.input.chars().take(available_width as usize).count(); + + self.input_data.max_line = area_height.saturating_sub(2) as usize; // retirer les bordures + } + + // Get the number of line needed for the inputfield text + pub fn update_nb_line(&mut self, area_width: u16) { + let available_width = area_width.saturating_sub(2); // Retirer les bordures + self.input_data.nb_line = + (self.input.chars().count() as f64 / available_width as f64).ceil() as usize; + } + + pub fn input_len(&mut self) -> usize { + self.input.chars().count() + } + + // Calculate cursor_y position + pub fn cursor_y(&mut self) -> usize { + if self.input_data.nb_line > 1 { + let mut y = (self.character_index / self.input_data.max_char_per_line) + 1; + + // Offsetting the inputfield for y be inside + if y > self.input_data.max_line { + self.input_data.scroll_offset = y - self.input_data.max_line; + y -= self.input_data.scroll_offset; + } + + if y < self.input_data.scroll_offset { + self.input_data.scroll_offset = y; + } + return y.max(1); + } else { + return 1; + } + } + + // Calculate cursor_x position + pub fn cursor_x(&mut self) -> usize { + if self.input_data.nb_line > 1 { + return self.character_index % self.input_data.max_char_per_line; + } else { + return self.character_index; + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..424376c --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,2 @@ +pub mod init; +pub mod inputfield; |