diff --git a/src/app.rs b/src/app.rs index e5f8e5548..6b32efebd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -204,9 +204,17 @@ impl App { self.process_kill_dialog.on_esc(); self.is_force_redraw = true; } else if self.help_dialog_state.is_showing_help { - self.help_dialog_state.is_showing_help = false; - self.help_dialog_state.scroll_state.current_scroll_index = 0; - self.is_force_redraw = true; + if self.help_dialog_state.is_searching { + // Exit help search mode first; keep help dialog open + self.help_dialog_state.is_searching = false; + self.help_dialog_state.search_query.clear(); + self.help_dialog_state.search_cursor_index = 0; + self.is_force_redraw = true; + } else { + self.help_dialog_state.is_showing_help = false; + self.help_dialog_state.scroll_state.current_scroll_index = 0; + self.is_force_redraw = true; + } } else { match self.current_widget.widget_type { BottomWidgetType::Proc => { @@ -455,6 +463,9 @@ impl App { if self.process_kill_dialog.is_open() { // Not the best way of doing things for now but works as glue. self.process_kill_dialog.on_enter(); + } else if self.help_dialog_state.is_showing_help && self.help_dialog_state.is_searching { + // Do not close help when searching; just trigger a redraw + self.is_force_redraw = true; } else if !self.is_in_dialog() { match self.current_widget.widget_type { BottomWidgetType::ProcSearch => { @@ -490,6 +501,15 @@ impl App { } pub fn on_delete(&mut self) { + if self.help_dialog_state.is_showing_help && self.help_dialog_state.is_searching { + let len = self.help_dialog_state.search_query.len(); + let idx = self.help_dialog_state.search_cursor_index.min(len); + if idx < len { + self.help_dialog_state.search_query.remove(idx); + } + self.is_force_redraw = true; + return; + } match self.current_widget.widget_type { BottomWidgetType::ProcSearch => { let is_in_search_widget = self.is_in_search_widget(); @@ -540,7 +560,14 @@ impl App { } pub fn on_backspace(&mut self) { - if let BottomWidgetType::ProcSearch = self.current_widget.widget_type { + if self.help_dialog_state.is_showing_help && self.help_dialog_state.is_searching { + if self.help_dialog_state.search_cursor_index > 0 { + let idx = self.help_dialog_state.search_cursor_index - 1; + self.help_dialog_state.search_query.remove(idx); + self.help_dialog_state.search_cursor_index = idx; + } + self.is_force_redraw = true; + } else if let BottomWidgetType::ProcSearch = self.current_widget.widget_type { let is_in_search_widget = self.is_in_search_widget(); if let Some(proc_widget_state) = self .states @@ -607,6 +634,15 @@ impl App { } pub fn on_left_key(&mut self) { + // Move help search cursor left if searching in help dialog + if self.help_dialog_state.is_showing_help && self.help_dialog_state.is_searching { + if self.help_dialog_state.search_cursor_index > 0 { + self.help_dialog_state.search_cursor_index -= 1; + self.is_force_redraw = true; + } + return; + } + if !self.is_in_dialog() { match self.current_widget.widget_type { BottomWidgetType::Proc => { @@ -655,6 +691,16 @@ impl App { } pub fn on_right_key(&mut self) { + // Move help search cursor right if searching in help dialog + if self.help_dialog_state.is_showing_help && self.help_dialog_state.is_searching { + let len = self.help_dialog_state.search_query.len(); + if self.help_dialog_state.search_cursor_index < len { + self.help_dialog_state.search_cursor_index += 1; + self.is_force_redraw = true; + } + return; + } + if !self.is_in_dialog() { match self.current_widget.widget_type { BottomWidgetType::Proc => { @@ -720,9 +766,8 @@ impl App { proc_widget_state.toggle_current_tree_branch_entry(); } } - } else if self.process_kill_dialog.is_open() { - // Either select the current option, - // or scroll to the next one + } else if self.help_dialog_state.is_showing_help && self.help_dialog_state.is_searching { + self.on_char_key(' '); } } @@ -992,20 +1037,37 @@ impl App { } self.handle_char(caught_char); } else if self.help_dialog_state.is_showing_help { - match caught_char { - '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { - let potential_index = caught_char.to_digit(10); - if let Some(potential_index) = potential_index { - let potential_index = potential_index as usize; - if (potential_index) < self.help_dialog_state.index_shortcuts.len() { - self.help_scroll_to_or_max( - self.help_dialog_state.index_shortcuts[potential_index], - ); + if self.help_dialog_state.is_searching { + let idx = self + .help_dialog_state + .search_cursor_index + .min(self.help_dialog_state.search_query.len()); + self.help_dialog_state.search_query.insert(idx, caught_char); + self.help_dialog_state.search_cursor_index = idx + 1; + self.is_force_redraw = true; + } else { + match caught_char { + '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { + let potential_index = caught_char.to_digit(10); + if let Some(potential_index) = potential_index { + let potential_index = potential_index as usize; + if (potential_index) < self.help_dialog_state.index_shortcuts.len() { + self.help_scroll_to_or_max( + self.help_dialog_state.index_shortcuts[potential_index], + ); + } } } + 'j' | 'k' | 'g' | 'G' => self.handle_char(caught_char), + '/' => { + // Start help dialog search; place cursor at end of current query + self.help_dialog_state.is_searching = true; + self.help_dialog_state.search_cursor_index = + self.help_dialog_state.search_query.len(); + self.is_force_redraw = true; + } + _ => {} } - 'j' | 'k' | 'g' | 'G' => self.handle_char(caught_char), - _ => {} } } else if self.process_kill_dialog.is_open() { self.process_kill_dialog.on_char(caught_char); diff --git a/src/app/states.rs b/src/app/states.rs index b7c3c5897..0ab1d420b 100644 --- a/src/app/states.rs +++ b/src/app/states.rs @@ -36,6 +36,9 @@ pub struct AppHelpDialogState { pub height: u16, pub scroll_state: ParagraphScrollState, pub index_shortcuts: Vec, + pub is_searching: bool, + pub search_query: String, + pub search_cursor_index: usize, } impl Default for AppHelpDialogState { @@ -45,6 +48,9 @@ impl Default for AppHelpDialogState { height: 0, scroll_state: ParagraphScrollState::default(), index_shortcuts: vec![0; constants::HELP_TEXT.len()], + is_searching: false, + search_query: String::new(), + search_cursor_index: 0, } } } diff --git a/src/canvas/dialogs/help_dialog.rs b/src/canvas/dialogs/help_dialog.rs index a4e9aec91..187c7322a 100644 --- a/src/canvas/dialogs/help_dialog.rs +++ b/src/canvas/dialogs/help_dialog.rs @@ -16,30 +16,83 @@ use crate::{ // TODO: [REFACTOR] Make generic dialog boxes to build off of instead? impl Painter { - fn help_text_lines(&self) -> Vec> { - let mut styled_help_spans = Vec::new(); + fn help_text_lines(&self, app_state: &App) -> Vec> { + let mut lines: Vec> = Vec::new(); + + let query = app_state + .help_dialog_state + .search_query + .trim() + .to_lowercase(); + let is_filtering = !query.is_empty(); - // Init help text: HELP_TEXT.iter().enumerate().for_each(|(itx, section)| { - let mut section = section.iter(); + let mut iter = section.iter(); - if itx > 0 { - if let Some(header) = section.next() { - styled_help_spans.push(Span::default()); - styled_help_spans.push(Span::styled(*header, self.styles.table_header_style)); + // Section 0 (intro) - hide entirely when filtering; render fully otherwise + if itx == 0 { + if !is_filtering { + for &text in iter { + lines.push(Line::from(Span::styled(text, self.styles.text_style))); + } } + return; } - section.for_each(|&text| { - styled_help_spans.push(Span::styled(text, self.styles.text_style)) - }); + // Non-root sections: pull out header; render header only if section has matches in search + let header_opt = iter.next(); + let header_str = header_opt.copied(); + + if is_filtering { + // Collect matching body lines + let mut matched_body: Vec> = Vec::new(); + for &text in iter { + let lower = text.to_lowercase(); + if let Some(pos) = lower.find(&query) { + let pre = &text[..pos]; + let mat_end = pos + query.len(); + let mat_str = &text[pos..mat_end]; + let post = &text[mat_end..]; + matched_body.push(Line::from(vec![ + Span::styled(pre, self.styles.text_style), + Span::styled(mat_str, self.styles.table_header_style), + Span::styled(post, self.styles.text_style), + ])); + } + } + + if !matched_body.is_empty() { + // Spacer + header (same appearance as non-search) + if let Some(header) = header_str { + lines.push(Line::from(Span::default())); + lines.push(Line::from(Span::styled( + header, + self.styles.table_header_style, + ))); + } + // Push matching body lines + lines.extend(matched_body); + } + } else { + // Non-search: show header and all body lines + if let Some(header) = header_str { + lines.push(Line::from(Span::default())); + lines.push(Line::from(Span::styled( + header, + self.styles.table_header_style, + ))); + } + for &text in iter { + lines.push(Line::from(Span::styled(text, self.styles.text_style))); + } + } }); - styled_help_spans.into_iter().map(Line::from).collect() + lines } pub fn draw_help_dialog(&self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect) { - let styled_help_text = self.help_text_lines(); + let styled_help_text = self.help_text_lines(app_state); let block = dialog_block(self.styles.border_type, self.styles.border_style) .title_top(Line::styled(" Help ", self.styles.widget_title_style)) @@ -51,39 +104,55 @@ impl Painter { // We must also recalculate how many lines are wrapping to properly get // scrolling to work on small terminal sizes... oh joy. - app_state.help_dialog_state.height = block.inner(draw_loc).height; + // Split into content and input areas; use content area for height and scrolling math. + let content_area = if app_state.help_dialog_state.is_searching { + tui::layout::Layout::default() + .direction(tui::layout::Direction::Vertical) + .constraints([ + tui::layout::Constraint::Min(1), + tui::layout::Constraint::Length(1), + ]) + .areas::<2>(draw_loc)[0] + } else { + draw_loc + }; + + app_state.help_dialog_state.height = block.inner(content_area).height; let mut overflow_buffer = 0; let paragraph_width = max(draw_loc.width.saturating_sub(2), 1); let mut prev_section_len = 0; - constants::HELP_TEXT - .iter() - .enumerate() - .for_each(|(itx, section)| { - let mut buffer = 0; + if app_state.help_dialog_state.search_query.is_empty() { + constants::HELP_TEXT + .iter() + .enumerate() + .for_each(|(itx, section)| { + let mut buffer = 0; - if itx == 0 { section.iter().for_each(|text_line| { buffer += UnicodeWidthStr::width(*text_line).saturating_sub(1) as u16 / paragraph_width; }); - app_state.help_dialog_state.index_shortcuts[itx] = 0; - } else { - section.iter().for_each(|text_line| { - buffer += UnicodeWidthStr::width(*text_line).saturating_sub(1) as u16 - / paragraph_width; - }); - - app_state.help_dialog_state.index_shortcuts[itx] = - app_state.help_dialog_state.index_shortcuts[itx - 1] - + 1 - + prev_section_len; - } - prev_section_len = section.len() as u16 + buffer; - overflow_buffer += buffer; - }); + if itx == 0 { + app_state.help_dialog_state.index_shortcuts[itx] = 0; + } else { + app_state.help_dialog_state.index_shortcuts[itx] = + app_state.help_dialog_state.index_shortcuts[itx - 1] + + 1 + + prev_section_len; + } + prev_section_len = section.len() as u16 + buffer; + overflow_buffer += buffer; + }); + } else { + // When filtering in search mode, approximate wrapping overflow + for line in &styled_help_text { + let w = UnicodeWidthStr::width(line.to_string().as_str()) as u16; + overflow_buffer += w.saturating_sub(1) / paragraph_width; + } + } let max_scroll_index = &mut app_state.help_dialog_state.scroll_state.max_scroll_index; *max_scroll_index = (styled_help_text.len() as u16 + 3 + overflow_buffer) @@ -98,6 +167,20 @@ impl Painter { *index = min(*index, *max_scroll_index); } + // Split into content and input areas for rendering + let content_area = if app_state.help_dialog_state.is_searching { + tui::layout::Layout::default() + .direction(tui::layout::Direction::Vertical) + .constraints([ + tui::layout::Constraint::Min(1), + tui::layout::Constraint::Length(1), + ]) + .areas::<2>(draw_loc)[0] + } else { + draw_loc + }; + + // Render help content f.render_widget( Paragraph::new(styled_help_text.clone()) .block(block) @@ -111,7 +194,34 @@ impl Painter { .current_scroll_index, 0, )), - draw_loc, + content_area, ); + + // Render input pane only when searching; no visible cursor + if app_state.help_dialog_state.is_searching { + let query = app_state.help_dialog_state.search_query.as_str(); + let hint = "Type to search, Esc to clear"; + let input_line = Line::from(vec![ + Span::styled("Search: ", self.styles.widget_title_style), + Span::styled(query, self.styles.text_style), + Span::styled(" ", self.styles.text_style), + Span::styled(hint, self.styles.table_header_style), + ]); + + let input_area = tui::layout::Layout::default() + .direction(tui::layout::Direction::Vertical) + .constraints([ + tui::layout::Constraint::Min(1), + tui::layout::Constraint::Length(1), + ]) + .areas::<2>(draw_loc)[1]; + + f.render_widget( + Paragraph::new(input_line) + .style(self.styles.text_style) + .alignment(Alignment::Left), + input_area, + ); + } } }