From 054a72e4a6ab51a26560b3da57334df54b71977e Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 25 Nov 2025 18:01:00 -0500 Subject: [PATCH] Multi-column support --- src/app.rs | 132 +++++++++++++++++++++++++++++++++++++---- src/config.rs | 7 +++ src/state.rs | 3 + src/ui.rs | 159 ++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 255 insertions(+), 46 deletions(-) diff --git a/src/app.rs b/src/app.rs index 798a311..deaa35f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -146,8 +146,8 @@ impl App { (KeyCode::Char('q'), _) => return Ok(true), (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1), (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1), - (KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.change_pane(-1), - (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1), + (KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.move_column(-1), + (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.move_column(1), (KeyCode::Char('n'), KeyModifiers::CONTROL) => self.change_pane(1), (KeyCode::Char('p'), KeyModifiers::CONTROL) => self.change_pane(-1), (KeyCode::Enter, _) => self.toggle_current_item()?, @@ -1085,24 +1085,136 @@ impl App { } fn move_selection(&mut self, delta: i32) { - let items_len = match self.state.current_pane { - 0 => self.state.permanent_items.len(), - 1 => self.state.recurring_items.len(), - 2 => self.state.recent_items.len(), + let items = match self.state.current_pane { + 0 => &self.state.permanent_items, + 1 => &self.state.recurring_items, + 2 => &self.state.recent_items, _ => return, }; - if items_len == 0 { + if items.is_empty() { return; } let current = self.state.selected_indices[self.state.current_pane] as i32; - let new_index = (current + delta).rem_euclid(items_len as i32) as usize; - self.state.selected_indices[self.state.current_pane] = new_index; + let items_len = items.len() as i32; + + // Check if we're using two-column mode + let use_two_columns = self.config.multi_column && items.len() > 6; + + if use_two_columns { + let mid_point = (items.len() + 1) / 2; + let (col_start, col_end) = if self.state.current_column == 0 { + (0, mid_point as i32 - 1) + } else { + (mid_point as i32, items_len - 1) + }; + + let new_index = current + delta; + + if new_index < col_start && delta < 0 { + // Moving up beyond column - go to previous pane + self.change_pane(-1); + let new_items = match self.state.current_pane { + 0 => &self.state.permanent_items, + 1 => &self.state.recurring_items, + 2 => &self.state.recent_items, + _ => return, + }; + if !new_items.is_empty() { + self.state.selected_indices[self.state.current_pane] = new_items.len() - 1; + // Stay in right column if new pane has enough items + if self.config.multi_column && new_items.len() > 6 { + self.state.current_column = 1; + } else { + self.state.current_column = 0; + } + } + } else if new_index > col_end && delta > 0 { + // Moving down beyond column - go to next pane + self.change_pane(1); + self.state.selected_indices[self.state.current_pane] = 0; + self.state.current_column = 0; + } else { + // Normal movement within column + self.state.selected_indices[self.state.current_pane] = + new_index.clamp(col_start, col_end) as usize; + } + } else { + // Single column mode + let new_index = current + delta; + + if new_index < 0 && delta < 0 { + // At top, move to previous pane + self.change_pane(-1); + let new_items = match self.state.current_pane { + 0 => &self.state.permanent_items, + 1 => &self.state.recurring_items, + 2 => &self.state.recent_items, + _ => return, + }; + if !new_items.is_empty() { + self.state.selected_indices[self.state.current_pane] = new_items.len() - 1; + } + } else if new_index >= items_len && delta > 0 { + // At bottom, move to next pane + self.change_pane(1); + self.state.selected_indices[self.state.current_pane] = 0; + } else { + // Normal movement within pane + self.state.selected_indices[self.state.current_pane] = + new_index.clamp(0, items_len - 1) as usize; + } + } } - + + fn move_column(&mut self, delta: i32) { + if !self.config.multi_column { + return; + } + + let items = match self.state.current_pane { + 0 => &self.state.permanent_items, + 1 => &self.state.recurring_items, + 2 => &self.state.recent_items, + _ => return, + }; + + // Only switch columns if we have enough items for two columns + if items.len() <= 6 { + return; + } + + let mid_point = (items.len() + 1) / 2; + let new_column = if delta > 0 { 1 } else { 0 }; + + if new_column != self.state.current_column { + let current_selected = self.state.selected_indices[self.state.current_pane]; + + // Calculate which row we're on in the current column + let current_row = if self.state.current_column == 0 { + current_selected // In left column + } else { + current_selected.saturating_sub(mid_point) // In right column + }; + + // Switch column and jump to the same row in the new column + self.state.current_column = new_column; + + if new_column == 0 { + // Moving to left column - jump to same row + self.state.selected_indices[self.state.current_pane] = current_row.min(mid_point - 1); + } else { + // Moving to right column - jump to same row + let right_items_len = items.len() - mid_point; + self.state.selected_indices[self.state.current_pane] = (mid_point + current_row).min(items.len() - 1).min(mid_point + right_items_len - 1); + } + } + } + fn change_pane(&mut self, delta: i32) { self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize; + self.state.current_column = 0; } fn get_current_item(&self) -> Option { diff --git a/src/config.rs b/src/config.rs index f9aa9e0..1ddb538 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub struct Config { pub projects: Vec, #[serde(default = "default_strict_projects")] pub strict_projects: bool, + #[serde(default = "default_multi_column")] + pub multi_column: bool, } fn default_show_help_hint() -> bool { @@ -19,12 +21,17 @@ fn default_strict_projects() -> bool { false } +fn default_multi_column() -> bool { + true +} + impl Default for Config { fn default() -> Self { Self { show_help_hint: default_show_help_hint(), projects: Vec::new(), strict_projects: default_strict_projects(), + multi_column: default_multi_column(), } } } diff --git a/src/state.rs b/src/state.rs index 9d4e68d..1f7860a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -16,6 +16,8 @@ pub struct AppState { pub recent_items: Vec, pub current_pane: usize, pub selected_indices: [usize; 3], + #[serde(default)] + pub current_column: usize, // 0 = left, 1 = right pub active_timer: Option<(TimeItem, DateTime)>, } @@ -27,6 +29,7 @@ impl Default for AppState { recent_items: Vec::new(), current_pane: 0, selected_indices: [0; 3], + current_column: 0, active_timer: None, } } diff --git a/src/ui.rs b/src/ui.rs index 36c6ad4..d7ab0f5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,17 +2,16 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Row, Table, TableState}, Frame, }; use crate::{ app::{App, LogViewDayOrder, LogViewGrouping, LogViewPeriod, LogViewSelection, NewEntryMode, Screen}, - state::{AppState, TimeItem}, + state::TimeItem, }; const ACTIVE_COLOR: Color = Color::Green; -const INACTIVE_COLOR: Color = Color::Yellow; pub fn render(frame: &mut Frame, app: &App) { match app.current_screen { @@ -83,7 +82,8 @@ fn render_main(frame: &mut Frame, app: &App) { &app.state.permanent_items, app.state.current_pane == 0, app.state.selected_indices[0], - &app.state, + app.state.current_column, + app.config.multi_column, ); render_section( @@ -93,7 +93,8 @@ fn render_main(frame: &mut Frame, app: &App) { &app.state.recurring_items, app.state.current_pane == 1, app.state.selected_indices[1], - &app.state, + app.state.current_column, + app.config.multi_column, ); render_section( @@ -103,7 +104,8 @@ fn render_main(frame: &mut Frame, app: &App) { &app.state.recent_items, app.state.current_pane == 2, app.state.selected_indices[2], - &app.state, + app.state.current_column, + app.config.multi_column, ); // Render bottom bar if needed @@ -252,9 +254,8 @@ fn render_help(frame: &mut Frame, app: &App) { "3. Ad-Hoc Items: One-off tasks and quick entries", "", "Navigation:", - "- j/k or ↑/↓: Move selection up/down", - "- h/l or ←/→: Switch between panes", - "- Ctrl+n/Ctrl+p: Switch between panes", + "- j/k or ↑/↓: Move selection up/down (wraps between sections)", + "- Ctrl+n/Ctrl+p: Switch between sections (Permanent/Recurring/Ad-Hoc)", "- Enter: Start/stop time tracking for selected item", "- n: Create a new task in the current section", "", @@ -364,45 +365,131 @@ fn render_section( items: &[TimeItem], is_active: bool, selected: usize, - _state: &AppState, + current_column: usize, + multi_column: bool, ) { - let border_color = if is_active { - ACTIVE_COLOR - } else { - INACTIVE_COLOR - }; - let block = Block::default() .borders(Borders::ALL) .title(title) - .style(Style::default().fg(border_color)); + .style(Style::default().fg(if is_active { ACTIVE_COLOR } else { Color::White })); - let items: Vec = items - .iter() - .enumerate() - .map(|(i, item)| { - let style = if i == selected && is_active { + // Check if we should use two-column layout + let use_two_columns = multi_column && items.len() > 6; + + if use_two_columns { + // Split items into two halves + let mid_point = (items.len() + 1) / 2; + let left_items = &items[..mid_point]; + let right_items = &items[mid_point..]; + + // Create inner area for columns + let inner_area = block.inner(area); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner_area); + + // Render the block first + frame.render_widget(block, area); + + // Calculate which row to show (same for both columns) + let row_in_column = if current_column == 0 { + selected // Left column: row = selected index + } else { + selected.saturating_sub(mid_point) // Right column: row = index - mid_point + }; + + // Left column + let left_rows: Vec = left_items + .iter() + .map(|item| { + let display = if !item.tags.is_empty() { + format!("{} [{}]", item.tags.join(", "), item.name) + } else { + item.name.clone() + }; + Row::new(vec![display]) + }) + .collect(); + + let left_table = Table::new(left_rows) + .widths(&[Constraint::Percentage(100)]) + .highlight_style(if is_active && current_column == 0 { Style::default() - .fg(border_color) + .fg(ACTIVE_COLOR) .add_modifier(Modifier::REVERSED) } else { + Style::default() // No highlight for inactive column + }); + + // Both columns use the same row offset for synchronized scrolling + let mut left_state = TableState::default(); + if !left_items.is_empty() { + // Always select the same row to force scrolling, but only highlight if active + left_state.select(Some(row_in_column.min(left_items.len() - 1))); + } + + // Right column + let right_rows: Vec = right_items + .iter() + .map(|item| { + let display = if !item.tags.is_empty() { + format!("{} [{}]", item.tags.join(", "), item.name) + } else { + item.name.clone() + }; + Row::new(vec![display]) + }) + .collect(); + + let right_table = Table::new(right_rows) + .widths(&[Constraint::Percentage(100)]) + .highlight_style(if is_active && current_column == 1 { Style::default() - }; - - let line = if !item.tags.is_empty() { - // Display as: tag [project] - format!("{} [{}]", item.tags.join(", "), item.name) + .fg(ACTIVE_COLOR) + .add_modifier(Modifier::REVERSED) } else { - // No tags, just show project - item.name.clone() - }; + Style::default() // No highlight for inactive column + }); - ListItem::new(Line::from(vec![Span::styled(line, style)])) - }) - .collect(); + let mut right_state = TableState::default(); + if !right_items.is_empty() { + // Always select the same row to force scrolling, but only highlight if active + right_state.select(Some(row_in_column.min(right_items.len() - 1))); + } - let list = List::new(items).block(block); - frame.render_widget(list, area); + frame.render_stateful_widget(left_table, columns[0], &mut left_state); + frame.render_stateful_widget(right_table, columns[1], &mut right_state); + } else { + // Single column mode + let rows: Vec = items + .iter() + .map(|item| { + let display = if !item.tags.is_empty() { + format!("{} [{}]", item.tags.join(", "), item.name) + } else { + item.name.clone() + }; + Row::new(vec![display]) + }) + .collect(); + + let table = Table::new(rows) + .block(block) + .widths(&[Constraint::Percentage(100)]) + .highlight_style( + Style::default() + .fg(ACTIVE_COLOR) + .add_modifier(Modifier::REVERSED), + ); + + let mut table_state = TableState::default(); + if !items.is_empty() && is_active { + table_state.select(Some(selected.min(items.len() - 1))); + } + + frame.render_stateful_widget(table, area, &mut table_state); + } } fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {