diff --git a/src/lib.rs b/src/lib.rs index ceabe65..b5f73d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,3 +29,4 @@ pub fn get_save_backup_file_path() -> PathBuf { pub const ORANGE: ratatui::style::Color = ratatui::style::Color::Rgb(255, 165, 0); pub const DAEMONIZE_ARG: &str = "__thoth_copy_daemonize"; pub const MIN_TEXTAREA_HEIGHT: usize = 3; +pub const BORDER_PADDING_SIZE: usize = 2; diff --git a/src/scrollable_textarea.rs b/src/scrollable_textarea.rs index d2e79af..6bd26f8 100644 --- a/src/scrollable_textarea.rs +++ b/src/scrollable_textarea.rs @@ -5,7 +5,7 @@ use std::{ rc::Rc, }; -use crate::{EditorClipboard, MIN_TEXTAREA_HEIGHT}; +use crate::{EditorClipboard, BORDER_PADDING_SIZE, MIN_TEXTAREA_HEIGHT}; use crate::{MarkdownRenderer, ORANGE}; use anyhow; use anyhow::Result; @@ -173,7 +173,7 @@ impl ScrollableTextArea { pub fn move_focus(&mut self, direction: isize) { let new_index = self.focused_index as isize + direction; - if new_index > (self.textareas.len()) as isize { + if new_index >= (self.textareas.len()) as isize { self.focused_index = 0; } else if new_index < 0 { self.focused_index = self.textareas.len() - 1; @@ -190,10 +190,10 @@ impl ScrollableTextArea { let mut height_sum = 0; for i in self.scroll..=self.focused_index { let textarea_height = - self.textareas[i].lines().len().max(MIN_TEXTAREA_HEIGHT) as u16 + 2; + self.textareas[i].lines().len().max(MIN_TEXTAREA_HEIGHT) + BORDER_PADDING_SIZE; height_sum += textarea_height; - if height_sum > self.viewport_height { + if height_sum > self.viewport_height as usize { self.scroll = i; break; } @@ -210,7 +210,7 @@ impl ScrollableTextArea { pub fn calculate_height_to_focused(&self) -> u16 { self.textareas[self.scroll..=self.focused_index] .iter() - .map(|ta| ta.lines().len().max(MIN_TEXTAREA_HEIGHT) as u16 + 2) + .map(|ta| (ta.lines().len().max(MIN_TEXTAREA_HEIGHT) + BORDER_PADDING_SIZE) as u16) .sum() } @@ -283,7 +283,7 @@ impl ScrollableTextArea { break; } - let content_height = textarea.lines().len() as u16 + 2; + let content_height = (textarea.lines().len() + BORDER_PADDING_SIZE) as u16; let is_focused = i == self.focused_index; let is_editing = is_focused && self.edit_mode; @@ -344,7 +344,7 @@ impl ScrollableTextArea { let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render( &content, title, - f.size().width as usize - 2, + f.size().width as usize - BORDER_PADDING_SIZE, )?; let paragraph = Paragraph::new(rendered_markdown) .block(block) @@ -408,7 +408,7 @@ impl ScrollableTextArea { let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render( &content, title, - f.size().width as usize - 2, + f.size().width as usize - BORDER_PADDING_SIZE, )?; let paragraph = Paragraph::new(rendered_markdown) @@ -452,11 +452,14 @@ mod tests { fn test_move_focus() { let mut sta = create_test_textarea(); sta.add_textarea(TextArea::default(), "Test1".to_string()); + assert_eq!(sta.focused_index, 0); sta.add_textarea(TextArea::default(), "Test2".to_string()); - sta.move_focus(1); + assert_eq!(sta.focused_index, 1); - sta.move_focus(-1); + sta.move_focus(1); assert_eq!(sta.focused_index, 0); + sta.move_focus(-1); + assert_eq!(sta.focused_index, 1); } #[test] diff --git a/src/title_select_popup.rs b/src/title_select_popup.rs index c8ee55e..da3a9f0 100644 --- a/src/title_select_popup.rs +++ b/src/title_select_popup.rs @@ -2,6 +2,7 @@ pub struct TitleSelectPopup { pub titles: Vec, pub selected_index: usize, pub visible: bool, + pub scroll_offset: usize, } impl TitleSelectPopup { @@ -10,6 +11,47 @@ impl TitleSelectPopup { titles: Vec::new(), selected_index: 0, visible: false, + scroll_offset: 0, + } + } + + pub fn move_selection_up(&mut self, visible_items: usize) { + if self.titles.is_empty() { + return; + } + + if self.selected_index > 0 { + self.selected_index -= 1; + } else { + self.selected_index = self.titles.len() - 1; + } + + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } + if self.selected_index == self.titles.len() - 1 { + self.scroll_offset = self.titles.len().saturating_sub(visible_items); + } + } + + pub fn move_selection_down(&mut self, visible_items: usize) { + if self.titles.is_empty() { + return; + } + + if self.selected_index < self.titles.len() - 1 { + self.selected_index += 1; + } else { + self.selected_index = 0; + self.scroll_offset = 0; + } + + let max_scroll = self.titles.len().saturating_sub(visible_items); + if self.selected_index >= self.scroll_offset + visible_items { + self.scroll_offset = (self.selected_index + 1).saturating_sub(visible_items); + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } } } } @@ -40,4 +82,20 @@ mod tests { assert_eq!(popup.titles[0], "Title1"); assert_eq!(popup.titles[1], "Title2"); } + + #[test] + fn test_wrap_around_selection() { + let mut popup = TitleSelectPopup::new(); + popup.titles = vec!["1".to_string(), "2".to_string(), "3".to_string()]; + + popup.selected_index = 0; + popup.move_selection_up(2); + assert_eq!(popup.selected_index, 2); + assert_eq!(popup.scroll_offset, 1); + + popup.selected_index = 2; + popup.move_selection_down(2); + assert_eq!(popup.selected_index, 0); + assert_eq!(popup.scroll_offset, 0); + } } diff --git a/src/ui.rs b/src/ui.rs index f98726f..ab06bb7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,4 @@ -use crate::{TitlePopup, TitleSelectPopup, ORANGE}; +use crate::{TitlePopup, TitleSelectPopup, BORDER_PADDING_SIZE, ORANGE}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -67,7 +67,7 @@ pub fn render_edit_commands_popup(f: &mut Frame) { Cell::from("MAPPINGS").style(Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)), Cell::from("DESCRIPTIONS").style(Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)), ]) - .height(2); + .height(BORDER_PADDING_SIZE as u16); let commands: Vec = vec![ Row::new(vec![ @@ -105,7 +105,7 @@ pub fn render_edit_commands_popup(f: &mut Frame) { .header(header) .block(block) .widths([Constraint::Percentage(30), Constraint::Percentage(70)]) - .column_spacing(2) + .column_spacing(BORDER_PADDING_SIZE as u16) .highlight_style(Style::default().fg(Color::Yellow)) .highlight_symbol(">> "); @@ -149,7 +149,7 @@ pub fn render_header(f: &mut Frame, area: Rect, is_edit_mode: bool) { let thoth_width = thoth.width(); let separator_width = separator.width(); - let reserved_width = thoth_width + 2; // 2 extra spaces for padding + let reserved_width = thoth_width + BORDER_PADDING_SIZE; // 2 extra spaces for padding let mut display_commands = Vec::new(); let mut current_width = 0; @@ -166,7 +166,7 @@ pub fn render_header(f: &mut Frame, area: Rect, is_edit_mode: bool) { let command_string = display_commands.join(separator); let command_width = command_string.width(); - let padding = " ".repeat(available_width - command_width - thoth_width - 2); + let padding = " ".repeat(available_width - command_width - thoth_width - BORDER_PADDING_SIZE); let header = Line::from(vec![ Span::styled(command_string, Style::default().fg(ORANGE)), @@ -200,12 +200,18 @@ pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup) { let area = centered_rect(80, 80, f.size()); f.render_widget(ratatui::widgets::Clear, area); - let items: Vec = popup - .titles + let visible_height = area.height.saturating_sub(BORDER_PADDING_SIZE as u16) as usize; + + let start_idx = popup.scroll_offset; + let end_idx = (popup.scroll_offset + visible_height).min(popup.titles.len()); + let visible_titles = &popup.titles[start_idx..end_idx]; + + let items: Vec = visible_titles .iter() .enumerate() .map(|(i, title)| { - if i == popup.selected_index { + let absolute_idx = i + popup.scroll_offset; + if absolute_idx == popup.selected_index { Line::from(vec![Span::styled( format!("> {}", title), Style::default().fg(Color::Yellow), @@ -219,7 +225,7 @@ pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(ORANGE)) - .title("Select Title"); + .title(format!("Select Title")); let paragraph = Paragraph::new(items) .block(block) @@ -374,6 +380,7 @@ mod tests { titles: vec!["Title1".to_string(), "Title2".to_string()], selected_index: 0, visible: true, + scroll_offset: 0, }; terminal diff --git a/src/ui_handler.rs b/src/ui_handler.rs index 57898b9..90cc7b9 100644 --- a/src/ui_handler.rs +++ b/src/ui_handler.rs @@ -1,4 +1,4 @@ -use crate::{get_save_backup_file_path, EditorClipboard}; +use crate::{get_save_backup_file_path, EditorClipboard, BORDER_PADDING_SIZE}; use anyhow::{bail, Result}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyModifiers}, @@ -238,6 +238,15 @@ fn handle_title_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result } fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result { + // Subtract 2 from viewport height to account for the top and bottom borders + // drawn by Block::default().borders(Borders::ALL) in ui.rs render_title_select_popup. + // The borders are rendered using unicode box-drawing characters: + // top border : ┌───┐ + // bottom border : └───┘ + // let visible_items = state.scrollable_textarea.viewport_height.saturating_sub(2) as usize; + let visible_items = (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize + - BORDER_PADDING_SIZE; + match key.code { KeyCode::Enter => { state @@ -250,18 +259,10 @@ fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> state.edit_commands_popup.visible = false; } KeyCode::Up => { - if state.title_select_popup.selected_index > 0 { - state.title_select_popup.selected_index -= 1; - } else { - state.title_select_popup.selected_index = state.title_select_popup.titles.len() - 1 - } + state.title_select_popup.move_selection_up(visible_items); } KeyCode::Down => { - if state.title_select_popup.selected_index < state.title_select_popup.titles.len() - 1 { - state.title_select_popup.selected_index += 1; - } else { - state.title_select_popup.selected_index = 0; - } + state.title_select_popup.move_selection_down(visible_items); } _ => {} } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 06ff5a8..e9f4ff9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -21,8 +21,10 @@ fn test_full_application_flow() { // Test focus movement sta.move_focus(1); - assert_eq!(sta.focused_index, 1); + assert_eq!(sta.focused_index, 0); sta.move_focus(-1); + assert_eq!(sta.focused_index, 1); + sta.move_focus(1); assert_eq!(sta.focused_index, 0); // Test title change