diff --git a/src/app.rs b/src/app.rs index a89636d..7883c03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use ratatui::{ crossterm::event::{KeyCode, KeyEvent}, layout::{Constraint, Layout, Rect}, style::{Modifier, Style, Stylize}, - text::Line, + text::{Line, Span}, widgets::{Block, Borders, Padding, Paragraph}, DefaultTerminal, Frame, }; @@ -32,8 +32,17 @@ use crate::{ enum StatusLine { #[default] None, - Input(String, Option, Option), + Input { + message: String, + cursor_position: u16, + metadata: String, + }, Transient(String), + SearchResult { + message: String, + options: String, + matched: bool, + }, NotificationInfo(String), NotificationSuccess(String), NotificationWarn(String), @@ -159,10 +168,11 @@ impl App<'_> { match self.ec.recv() { AppEvent::Key(key) => { match self.app_status.status_line { - StatusLine::None | StatusLine::Input(_, _, _) => { + StatusLine::None | StatusLine::Input { .. } => { // do nothing } StatusLine::Transient(_) + | StatusLine::SearchResult { .. } | StatusLine::NotificationInfo(_) | StatusLine::NotificationSuccess(_) | StatusLine::NotificationWarn(_) => { @@ -197,7 +207,7 @@ impl App<'_> { self.app_status.numeric_prefix.clear(); } None => { - if let StatusLine::Input(_, _, _) = self.app_status.status_line { + if let StatusLine::Input { .. } = self.app_status.status_line { // In input mode, pass all key events to the view // fixme: currently, the only thing that processes key_event is searching the list, // so this probably works, but it's not the right process... @@ -274,12 +284,23 @@ impl App<'_> { AppEvent::ClearStatusLine => { self.clear_status_line(); } - AppEvent::UpdateStatusInput(msg, cursor_pos, msg_r) => { - self.update_status_input(msg, cursor_pos, msg_r); + AppEvent::UpdateStatusInput { + message, + cursor_position, + metadata, + } => { + self.update_status_input(message, cursor_position, metadata); } AppEvent::UpdateStatusTransient(msg) => { self.update_status_transient(msg); } + AppEvent::UpdateSearchResult { + message, + options, + matched, + } => { + self.update_search_result(message, options, matched); + } AppEvent::NotifyInfo(msg) => { self.info_notification(msg); } @@ -349,23 +370,15 @@ impl App<'_> { .fg(self.ctx.color_theme.status_input_transient_fg) } } - StatusLine::Input(msg, _, transient_msg) => { - let msg_w = console::measure_text_width(msg.as_str()); - if let Some(t_msg) = transient_msg { - let t_msg_w = console::measure_text_width(t_msg.as_str()); - let pad_w = - (area.width as usize).saturating_sub(msg_w + t_msg_w + 2 /* pad */); - Line::from(vec![ - msg.as_str().fg(self.ctx.color_theme.status_input_fg), - " ".repeat(pad_w).into(), - t_msg - .as_str() - .fg(self.ctx.color_theme.status_input_transient_fg), - ]) - } else { - Line::raw(msg).fg(self.ctx.color_theme.status_input_fg) - } - } + StatusLine::Input { + message, metadata, .. + } => status_line_with_metadata( + message, + metadata, + Style::default().fg(self.ctx.color_theme.status_input_fg), + Style::default().fg(self.ctx.color_theme.status_input_transient_fg), + area.width, + ), StatusLine::Transient(msg) => { let msg_w = console::measure_text_width(msg.as_str()); let pad_w = (area.width as usize).saturating_sub(msg_w + 2 /* pad */); @@ -375,6 +388,26 @@ impl App<'_> { .fg(self.ctx.color_theme.status_input_transient_fg), ]) } + StatusLine::SearchResult { + message, + options, + matched, + } => { + let message_style = if *matched { + Style::default().fg(self.ctx.color_theme.status_info_fg) + } else { + Style::default() + .fg(self.ctx.color_theme.status_warn_fg) + .add_modifier(Modifier::BOLD) + }; + status_line_with_metadata( + message, + options, + message_style, + Style::default().fg(self.ctx.color_theme.status_input_transient_fg), + area.width, + ) + } StatusLine::NotificationInfo(msg) => { Line::raw(msg).fg(self.ctx.color_theme.status_info_fg) } @@ -396,8 +429,11 @@ impl App<'_> { ); f.render_widget(paragraph, area); - if let StatusLine::Input(_, Some(cursor_pos), _) = &self.app_status.status_line { - let (x, y) = (area.x + cursor_pos + 1, area.y + 1); + if let StatusLine::Input { + cursor_position, .. + } = &self.app_status.status_line + { + let (x, y) = (area.x + cursor_position + 1, area.y + 1); match &self.ctx.ui_config.common.cursor_type { CursorType::Native => { f.set_cursor_position((x, y)); @@ -689,19 +725,26 @@ impl App<'_> { self.app_status.status_line = StatusLine::None; } - fn update_status_input( - &mut self, - msg: String, - cursor_pos: Option, - transient_msg: Option, - ) { - self.app_status.status_line = StatusLine::Input(msg, cursor_pos, transient_msg); + fn update_status_input(&mut self, message: String, cursor_position: u16, metadata: String) { + self.app_status.status_line = StatusLine::Input { + message, + cursor_position, + metadata, + }; } fn update_status_transient(&mut self, msg: String) { self.app_status.status_line = StatusLine::Transient(msg); } + fn update_search_result(&mut self, message: String, options: String, matched: bool) { + self.app_status.status_line = StatusLine::SearchResult { + message, + options, + matched, + }; + } + fn info_notification(&mut self, msg: String) { self.app_status.status_line = StatusLine::NotificationInfo(msg); } @@ -731,6 +774,30 @@ impl App<'_> { } } +fn status_line_with_metadata( + message: &str, + metadata: &str, + message_style: Style, + metadata_style: Style, + area_width: u16, +) -> Line<'static> { + let content_width = area_width.saturating_sub(2) as usize; + let message_width = console::measure_text_width(message); + let metadata_width = console::measure_text_width(metadata); + let min_gap_width = 2; + + if message_width + min_gap_width + metadata_width > content_width { + return Line::from(Span::styled(message.to_owned(), message_style)); + } + + let pad_width = content_width - message_width - metadata_width; + Line::from(vec![ + Span::styled(message.to_owned(), message_style), + Span::raw(" ".repeat(pad_width)), + Span::styled(metadata.to_owned(), metadata_style), + ]) +} + fn selected_commit_details( repository: &Repository, commit_list_state: &CommitListState, @@ -862,4 +929,24 @@ mod tests { let actual = process_numeric_prefix(numeric_prefix, user_event, dummy_key_event); assert_eq!(actual, expected); } + + #[test] + fn test_status_line_with_metadata_right_aligns_metadata() { + let line = + status_line_with_metadata("left", "[meta]", Style::default(), Style::default(), 14); + + assert_eq!(line.spans.len(), 3); + assert_eq!(line.spans[0].content.as_ref(), "left"); + assert_eq!(line.spans[1].content.as_ref(), " "); + assert_eq!(line.spans[2].content.as_ref(), "[meta]"); + } + + #[test] + fn test_status_line_with_metadata_hides_metadata_when_area_is_too_narrow() { + let line = + status_line_with_metadata("left", "[meta]", Style::default(), Style::default(), 13); + + assert_eq!(line.spans.len(), 1); + assert_eq!(line.spans[0].content.as_ref(), "left"); + } } diff --git a/src/event.rs b/src/event.rs index 89adb01..6421699 100644 --- a/src/event.rs +++ b/src/event.rs @@ -31,13 +31,27 @@ pub enum AppEvent { SelectNewerCommit, SelectOlderCommit, SelectParentCommit, - CopyToClipboard { name: String, value: String }, + CopyToClipboard { + name: String, + value: String, + }, Refresh(RefreshViewContext), ClearStatusLine, - UpdateStatusInput(String, Option, Option), + UpdateStatusInput { + message: String, + cursor_position: u16, + metadata: String, + }, UpdateStatusTransient(String), + UpdateSearchResult { + message: String, + options: String, + matched: bool, + }, + #[expect(dead_code)] NotifyInfo(String), NotifySuccess(String), + #[expect(dead_code)] NotifyWarn(String), NotifyError(String), } diff --git a/src/view/list.rs b/src/view/list.rs index d94282d..f594ac6 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -45,16 +45,16 @@ impl<'a> ListView<'a> { self.clear_search_query(); } UserEvent::IgnoreCaseToggle => { - let message = self.as_mut_list_state().toggle_ignore_case(); - self.update_search_status(Some(message)); + self.as_mut_list_state().toggle_ignore_case(); + self.update_search_status(); } UserEvent::FuzzyToggle => { - let message = self.as_mut_list_state().toggle_fuzzy(); - self.update_search_status(Some(message)); + self.as_mut_list_state().toggle_fuzzy(); + self.update_search_status(); } _ => { self.as_mut_list_state().handle_search_input(key); - self.update_search_status(None); + self.update_search_status(); } } return; @@ -131,15 +131,15 @@ impl<'a> ListView<'a> { } UserEvent::Search => { self.as_mut_list_state().start_search(); - self.update_search_status(None); + self.update_search_status(); } UserEvent::IgnoreCaseToggle => { - let message = self.as_mut_list_state().toggle_ignore_case(); - self.tx.send(AppEvent::UpdateStatusTransient(message)); + self.as_mut_list_state().toggle_ignore_case(); + self.update_search_options_message(); } UserEvent::FuzzyToggle => { - let message = self.as_mut_list_state().toggle_fuzzy(); - self.tx.send(AppEvent::UpdateStatusTransient(message)); + self.as_mut_list_state().toggle_fuzzy(); + self.update_search_options_message(); } UserEvent::UserCommand(n) => { self.tx.send(AppEvent::OpenUserCommand(n)); @@ -215,31 +215,44 @@ impl<'a> ListView<'a> { self.as_list_state().graph_image_ids_sorted() } - fn update_search_status(&self, transient_message: Option) { + fn update_search_status(&self) { if let SearchState::Searching { .. } = self.as_list_state().search_state() { let list_state = self.as_list_state(); if let Some(query) = list_state.search_query_string() { - let cursor_pos = list_state.search_query_cursor_position(); - self.tx.send(AppEvent::UpdateStatusInput( - query, - Some(cursor_pos), - transient_message, - )); + let cursor_position = list_state.search_query_cursor_position(); + let options = list_state.search_options().status_string(); + self.tx.send(AppEvent::UpdateStatusInput { + message: query, + cursor_position, + metadata: options, + }); } } } + fn update_search_options_message(&self) { + if let SearchState::Applied { .. } = self.as_list_state().search_state() { + self.update_matched_message(); + } else { + let options = self.as_list_state().search_options().status_string(); + self.tx.send(AppEvent::UpdateStatusTransient(format!( + "Search: {options}" + ))); + } + } + fn clear_search_query(&self) { self.tx.send(AppEvent::ClearStatusLine); } fn update_matched_message(&self) { if let Some((msg, matched)) = self.as_list_state().matched_query_string() { - if matched { - self.tx.send(AppEvent::NotifyInfo(msg)); - } else { - self.tx.send(AppEvent::NotifyWarn(msg)); - } + let options = self.as_list_state().search_options().status_string(); + self.tx.send(AppEvent::UpdateSearchResult { + message: msg, + options, + matched, + }); } else { self.tx.send(AppEvent::ClearStatusLine); } diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 96bd2a1..64787d4 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -63,6 +63,18 @@ pub struct SearchOptions { pub fuzzy: bool, } +impl SearchOptions { + pub fn status_string(&self) -> String { + let case = if self.ignore_case { + "ignore-case" + } else { + "case-sensitive" + }; + let matcher = if self.fuzzy { "fuzzy" } else { "substring" }; + format!("[{case}] [{matcher}]") + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchRefreshContext { query: String, @@ -545,28 +557,14 @@ impl<'a> CommitListState<'a> { } } - pub fn toggle_ignore_case(&mut self) -> String { + pub fn toggle_ignore_case(&mut self) { self.search_options.ignore_case = !self.search_options.ignore_case; - let message = if self.search_options.ignore_case { - "Ignore case: ON " - } else { - "Ignore case: OFF" - }; - self.update_search_after_options_change(); - message.into() } - pub fn toggle_fuzzy(&mut self) -> String { + pub fn toggle_fuzzy(&mut self) { self.search_options.fuzzy = !self.search_options.fuzzy; - let message = if self.search_options.fuzzy { - "Fuzzy match: ON " - } else { - "Fuzzy match: OFF" - }; - self.update_search_after_options_change(); - message.into() } pub fn search_query_string(&self) -> Option { @@ -1364,12 +1362,28 @@ mod tests { } #[test] - fn test_search_option_toggle_messages() { + fn test_search_option_string_after_toggles() { with_commit_list_state(&["fix"], |state| { - assert_eq!(state.toggle_ignore_case(), "Ignore case: ON "); - assert_eq!(state.toggle_ignore_case(), "Ignore case: OFF"); - assert_eq!(state.toggle_fuzzy(), "Fuzzy match: ON "); - assert_eq!(state.toggle_fuzzy(), "Fuzzy match: OFF"); + state.toggle_ignore_case(); + assert_eq!( + state.search_options().status_string(), + "[ignore-case] [substring]" + ); + state.toggle_ignore_case(); + assert_eq!( + state.search_options().status_string(), + "[case-sensitive] [substring]" + ); + state.toggle_fuzzy(); + assert_eq!( + state.search_options().status_string(), + "[case-sensitive] [fuzzy]" + ); + state.toggle_fuzzy(); + assert_eq!( + state.search_options().status_string(), + "[case-sensitive] [substring]" + ); }); }