aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOxbian <oxbian@mailbox.org>2025-02-20 13:55:52 -0500
committerOxbian <oxbian@mailbox.org>2025-02-20 13:55:52 -0500
commit3dfa9363d2ef0eb217fb534c30317930a72b519e (patch)
tree0bef672952a40b24a5dd3a1cf08913b5b34ba498
parent0d4bbf7a60012b459be5dfe3077055c8e25bba02 (diff)
downloadNAI-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.rs1
-rw-r--r--src/lib.rs2
-rw-r--r--src/main.rs11
-rw-r--r--src/ui.rs255
-rw-r--r--src/ui/init.rs171
-rw-r--r--src/ui/inputfield.rs172
-rw-r--r--src/ui/mod.rs2
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;
ArKa projects. All rights to me, and your next child right arm.