diff --git a/src/tui/app.rs b/src/tui/app.rs index b5559cb..6f32e92 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -15,6 +15,7 @@ use crate::{cache, cost, dedup, rollup}; use super::diff::{self, RowKey}; use super::event::Event; use super::views::dashboard::{self, MouseAction}; +use super::views::settings::{self, MouseAction as SettingsMouseAction}; use super::widgets::heatmap::{self, HeatmapDay}; use super::widgets::spike_chart::{self, SpikeSeries}; @@ -472,7 +473,10 @@ impl App { } fn handle_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { - if self.show_settings || self.show_help || self.filter_active { + if self.show_settings { + return self.handle_settings_mouse(mouse, terminal_area); + } + if self.show_help || self.filter_active { return self.set_hover(None); } @@ -487,6 +491,65 @@ impl App { hover_changed || action_changed } + fn handle_settings_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { + // Keep an in-progress text edit isolated until Enter applies it or + // Escape cancels it, matching the keyboard editing behavior. + if self.settings_state.editing { + return false; + } + + match settings::mouse_action(terminal_area, self, mouse) { + Some(SettingsMouseAction::SelectField(field)) => { + if self.settings_state.selected == field { + false + } else { + self.settings_state.selected = field; + true + } + } + Some(SettingsMouseAction::ActivateField(field)) => { + self.settings_state.selected = field; + self.activate_current_setting() + } + Some(SettingsMouseAction::ScrollUp) => { + let next = self.settings_state.selected.saturating_sub(1); + if next == self.settings_state.selected { + false + } else { + self.settings_state.selected = next; + true + } + } + Some(SettingsMouseAction::ScrollDown) => { + let next = (self.settings_state.selected + 1) + .min(crate::tui::settings_state::SettingField::COUNT - 1); + if next == self.settings_state.selected { + false + } else { + self.settings_state.selected = next; + true + } + } + Some(SettingsMouseAction::ReviewSave) => { + self.settings_state.confirming_save = self.settings_state.unsaved; + true + } + Some(SettingsMouseAction::ConfirmSave) => self.save_settings(), + Some(SettingsMouseAction::CancelSave) => { + self.settings_state.confirming_save = false; + true + } + Some(SettingsMouseAction::Discard | SettingsMouseAction::Close) => { + self.show_settings = false; + if self.config_only { + self.should_quit = true; + } + true + } + None => false, + } + } + fn set_hover(&mut self, target: Option) -> bool { if self.hovered == target { return false; @@ -786,23 +849,7 @@ impl App { state.selected = state.selected.saturating_sub(1); true } - KeyCode::Enter | KeyCode::Char(' ') => { - let field = state.current_field(); - if field.is_bool() { - field.toggle_bool(&mut state.draft); - state.unsaved = true; - true - } else if field.is_enum() { - field.cycle_enum(&mut state.draft); - state.unsaved = true; - true - } else { - // Enter edit mode for numeric fields - state.editing = true; - state.edit_buffer = field.edit_value(&state.draft); - true - } - } + KeyCode::Enter | KeyCode::Char(' ') => self.activate_current_setting(), KeyCode::Left => { let field = state.current_field(); if field.is_enum() { @@ -832,6 +879,21 @@ impl App { } } + fn activate_current_setting(&mut self) -> bool { + let field = self.settings_state.current_field(); + if field.is_bool() { + field.toggle_bool(&mut self.settings_state.draft); + self.settings_state.unsaved = true; + } else if field.is_enum() { + field.cycle_enum(&mut self.settings_state.draft); + self.settings_state.unsaved = true; + } else { + self.settings_state.editing = true; + self.settings_state.edit_buffer = field.edit_value(&self.settings_state.draft); + } + true + } + fn save_settings(&mut self) -> bool { let old_metric = self.config.sparkline_metric; match self.settings_state.draft.save() { diff --git a/src/tui/views/settings.rs b/src/tui/views/settings.rs index c1b040c..961f8ea 100644 --- a/src/tui/views/settings.rs +++ b/src/tui/views/settings.rs @@ -1,3 +1,4 @@ +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; @@ -8,17 +9,37 @@ use crate::tui::app::App; use crate::tui::settings_state::SettingField; use crate::tui::theme; +/// Actions that a pointer can perform in the settings overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MouseAction { + SelectField(usize), + ActivateField(usize), + ScrollUp, + ScrollDown, + ReviewSave, + ConfirmSave, + CancelSave, + Discard, + Close, +} + +#[derive(Debug, Clone, Copy)] +struct SettingsLayout { + popup: Rect, + inner: Rect, + content: Rect, + footer: Rect, + scroll_offset: usize, +} + /// Render the settings overlay as a centered popup. #[allow(clippy::too_many_lines)] pub fn render(frame: &mut Frame, app: &App) { let area = frame.area(); let state = &app.settings_state; - // Size: wide enough for labels + values, tall enough for all fields + sections + footer - let popup_width = area.width.min(80); - let popup_height = area.height.min(32); - - let popup_area = centered_rect(popup_width, popup_height, area); + let layout = settings_layout(area, app); + let popup_area = layout.popup; // Clear the area behind the popup frame.render_widget(Clear, popup_area); @@ -42,12 +63,11 @@ pub fn render(frame: &mut Frame, app: &App) { })) .style(theme::card()); - let inner = block.inner(popup_area); + let inner = layout.inner; frame.render_widget(block, popup_area); // Build the content lines, tracking which line index each field maps to. let mut lines: Vec = Vec::with_capacity(SettingField::COUNT + 10); - let mut field_line_indices: Vec = Vec::with_capacity(SettingField::COUNT); let config_path = Config::config_path(); let source_label = if config_path.exists() { @@ -77,8 +97,6 @@ pub fn render(frame: &mut Frame, app: &App) { ))); } - field_line_indices.push(lines.len()); // record the line index for this field - let is_selected = idx == state.selected; let value_str = if state.editing && is_selected { // Show edit buffer with cursor @@ -155,30 +173,9 @@ pub fn render(frame: &mut Frame, app: &App) { ))); } - // Split inner into scrollable content area and fixed footer - let footer_height: u16 = if state.unsaved { 3 } else { 2 }; - let content_height = inner.height.saturating_sub(footer_height); - let content_area = Rect::new(inner.x, inner.y, inner.width, content_height); - let footer_area = Rect::new( - inner.x, - inner.y + content_height, - inner.width, - footer_height, - ); - - // Determine scroll for content area - let visible_height = content_height as usize; - let selected_line_idx = field_line_indices.get(state.selected).copied().unwrap_or(0); - - let scroll_offset = if selected_line_idx >= visible_height { - selected_line_idx.saturating_sub(visible_height / 2) - } else { - 0 - }; - #[allow(clippy::cast_possible_truncation)] - let paragraph = Paragraph::new(lines).scroll((scroll_offset as u16, 0)); - frame.render_widget(paragraph, content_area); + let paragraph = Paragraph::new(lines).scroll((layout.scroll_offset as u16, 0)); + frame.render_widget(paragraph, layout.content); // Fixed footer — always visible let mut footer_lines: Vec = Vec::new(); @@ -233,7 +230,156 @@ pub fn render(frame: &mut Frame, app: &App) { } let footer_paragraph = Paragraph::new(footer_lines); - frame.render_widget(footer_paragraph, footer_area); + frame.render_widget(footer_paragraph, layout.footer); +} + +/// Translate a pointer event into a settings action using the same geometry as +/// the renderer. Pointer movement selects a field for visual feedback, while a +/// left click activates it just like Enter/Space. +#[must_use] +pub(crate) fn mouse_action(area: Rect, app: &App, event: MouseEvent) -> Option { + let layout = settings_layout(area, app); + + if let Some(field) = field_at(layout, event.column, event.row) { + return match event.kind { + MouseEventKind::Moved => Some(MouseAction::SelectField(field)), + MouseEventKind::Down(MouseButton::Left) => Some(MouseAction::ActivateField(field)), + MouseEventKind::ScrollUp => Some(MouseAction::ScrollUp), + MouseEventKind::ScrollDown => Some(MouseAction::ScrollDown), + _ => None, + }; + } + + if let Some(action) = footer_action( + layout, + app.settings_state.unsaved, + app.settings_state.confirming_save, + event, + ) { + return Some(action); + } + + if contains(layout.content, event.column, event.row) { + return match event.kind { + MouseEventKind::ScrollUp => Some(MouseAction::ScrollUp), + MouseEventKind::ScrollDown => Some(MouseAction::ScrollDown), + _ => None, + }; + } + + None +} + +fn settings_layout(area: Rect, app: &App) -> SettingsLayout { + let popup_width = area.width.min(80); + let popup_height = area.height.min(32); + let popup_area = centered_rect(popup_width, popup_height, area); + let inner = Rect::new( + popup_area.x.saturating_add(1), + popup_area.y.saturating_add(1), + popup_area.width.saturating_sub(2), + popup_area.height.saturating_sub(2), + ); + let footer_height: u16 = if app.settings_state.unsaved || app.settings_state.confirming_save { + 3 + } else { + 2 + }; + let content_height = inner.height.saturating_sub(footer_height); + let content = Rect::new(inner.x, inner.y, inner.width, content_height); + let footer = Rect::new( + inner.x, + inner.y.saturating_add(content_height), + inner.width, + footer_height, + ); + let selected_line = field_line_indices() + .get(app.settings_state.selected) + .copied() + .unwrap_or_default(); + let scroll_offset = if selected_line >= content_height as usize { + selected_line.saturating_sub(content_height as usize / 2) + } else { + 0 + }; + + SettingsLayout { + popup: popup_area, + inner, + content, + footer, + scroll_offset, + } +} + +fn field_line_indices() -> Vec { + let mut lines = 3; // source, path, blank + let mut indices = Vec::with_capacity(SettingField::COUNT); + for (idx, field) in SettingField::ALL.iter().enumerate() { + if field.section_header().is_some() { + if idx > 0 { + lines += 1; + } + lines += 1; + } + indices.push(lines); + lines += 1; + } + indices +} + +fn field_at(layout: SettingsLayout, column: u16, row: u16) -> Option { + if !contains(layout.content, column, row) { + return None; + } + let line = layout.scroll_offset + row.saturating_sub(layout.content.y) as usize; + field_line_indices().iter().position(|index| *index == line) +} + +fn footer_action( + layout: SettingsLayout, + unsaved: bool, + confirming_save: bool, + event: MouseEvent, +) -> Option { + if contains(layout.content, event.column, event.row) + || !contains(layout.footer, event.column, event.row) + { + return None; + } + let row = event.row.saturating_sub(layout.footer.y); + let midpoint = layout.footer.x + layout.footer.width / 2; + if row == 0 && matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + if confirming_save { + return Some(if event.column < midpoint { + MouseAction::ConfirmSave + } else { + MouseAction::CancelSave + }); + } + if unsaved { + return Some(if event.column < midpoint { + MouseAction::ReviewSave + } else { + MouseAction::Discard + }); + } + } + if !unsaved + && !confirming_save + && row == layout.footer.height.saturating_sub(1) + && matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) + { + return Some(MouseAction::Close); + } + None +} + +fn contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) } fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { @@ -246,3 +392,93 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { ); popup_area } + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ratatui::layout::Rect; + + use super::{field_at, field_line_indices, footer_action, MouseAction, SettingsLayout}; + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn visible_field_rows_resolve_and_headers_do_not() { + let layout = SettingsLayout { + popup: Rect::default(), + inner: Rect::default(), + content: Rect::new(5, 4, 30, 6), + footer: Rect::new(5, 10, 30, 2), + scroll_offset: field_line_indices()[0], + }; + + assert_eq!(field_at(layout, 6, 4), Some(0)); + assert_eq!(field_at(layout, 6, 5), None); + assert_eq!(field_at(layout, 4, 4), None); + } + + #[test] + fn scroll_offset_resolves_later_fields() { + let indices = field_line_indices(); + let layout = SettingsLayout { + popup: Rect::default(), + inner: Rect::default(), + content: Rect::new(0, 0, 20, 4), + footer: Rect::new(0, 4, 20, 2), + scroll_offset: indices[indices.len() - 1], + }; + + assert_eq!(field_at(layout, 10, 0), Some(indices.len() - 1)); + assert_eq!(field_at(layout, 21, 0), None); + } + + #[test] + fn footer_clicks_map_to_save_and_close_actions() { + let layout = SettingsLayout { + popup: Rect::default(), + inner: Rect::default(), + content: Rect::new(0, 0, 20, 4), + footer: Rect::new(0, 4, 20, 3), + scroll_offset: 0, + }; + assert_eq!( + footer_action( + layout, + true, + false, + mouse(MouseEventKind::Down(MouseButton::Left), 2, 4) + ), + Some(MouseAction::ReviewSave) + ); + assert_eq!( + footer_action( + layout, + true, + false, + mouse(MouseEventKind::Down(MouseButton::Left), 18, 4) + ), + Some(MouseAction::Discard) + ); + + let clean = SettingsLayout { + footer: Rect::new(0, 4, 20, 2), + ..layout + }; + assert_eq!( + footer_action( + clean, + false, + false, + mouse(MouseEventKind::Down(MouseButton::Left), 10, 5) + ), + Some(MouseAction::Close) + ); + } +}