diff --git a/crates/ratatui/src/category_list.rs b/crates/ratatui/src/category_list.rs index 00187bc..7dda8aa 100644 --- a/crates/ratatui/src/category_list.rs +++ b/crates/ratatui/src/category_list.rs @@ -8,11 +8,17 @@ use crate::theme; pub struct CategoryItem<'a> { pub label: &'a str, pub is_section: bool, + pub collapsed: bool, +} + +pub enum Selection { + Item(usize), + Section(usize), } pub struct CategoryList<'a> { items: &'a [CategoryItem<'a>], - selected: usize, + selection: Selection, focused: bool, title: &'a str, section_color: Color, @@ -23,11 +29,11 @@ pub struct CategoryList<'a> { } impl<'a> CategoryList<'a> { - pub fn new(items: &'a [CategoryItem<'a>], selected: usize) -> Self { + pub fn new(items: &'a [CategoryItem<'a>], selection: Selection) -> Self { let theme = theme::get(); Self { items, - selected, + selection, focused: false, title: "", section_color: theme.ui.text_dim, @@ -63,25 +69,44 @@ impl<'a> CategoryList<'a> { self } + /// Build the visible items list, filtering out children of collapsed sections. + /// Returns (item, section_index_if_section, item_index_if_item). + fn visible_items(&self) -> Vec<(&CategoryItem<'a>, Option, Option)> { + let mut result = Vec::new(); + let mut skipping = false; + let mut section_idx = 0usize; + let mut item_idx = 0usize; + for item in self.items.iter() { + if item.is_section { + skipping = item.collapsed; + result.push((item, Some(section_idx), None)); + section_idx += 1; + } else if !skipping { + result.push((item, None, Some(item_idx))); + item_idx += 1; + } else { + item_idx += 1; + } + } + result + } + pub fn render(self, frame: &mut Frame, area: Rect) { let theme = theme::get(); + let visible = self.visible_items(); let visible_height = area.height.saturating_sub(2) as usize; - let total_items = self.items.len(); + let total_items = visible.len(); - let selected_visual_idx = { - let mut visual = 0; - let mut selectable_count = 0; - for item in self.items.iter() { - if !item.is_section { - if selectable_count == self.selected { - break; - } - selectable_count += 1; - } - visual += 1; - } - visual + let selected_visual_idx = match &self.selection { + Selection::Item(sel) => visible + .iter() + .position(|(_, _, item_idx)| *item_idx == Some(*sel)) + .unwrap_or(0), + Selection::Section(sel) => visible + .iter() + .position(|(_, sec_idx, _)| *sec_idx == Some(*sel)) + .unwrap_or(0), }; let scroll = if selected_visual_idx < visible_height / 2 { @@ -92,24 +117,35 @@ impl<'a> CategoryList<'a> { selected_visual_idx.saturating_sub(visible_height / 2) }; - let mut selectable_idx = self.items - .iter() - .take(scroll) - .filter(|e| !e.is_section) - .count(); - let is_dimmed = self.dimmed_color.is_some(); - let items: Vec = self.items + let items: Vec = visible .iter() .skip(scroll) .take(visible_height) - .map(|item| { + .enumerate() + .map(|(vis_offset, (item, sec_idx, _itm_idx))| { + let visual_pos = scroll + vis_offset; if item.is_section { - let style = Style::new().fg(self.section_color); - ListItem::new(format!("─ {} ─", item.label)).style(style) + let is_selected = + matches!(&self.selection, Selection::Section(s) if Some(*s) == *sec_idx); + let arrow = if item.collapsed { "▸" } else { "▾" }; + let style = if is_selected && self.focused { + Style::new() + .fg(self.focused_color) + .add_modifier(Modifier::BOLD) + } else if is_selected { + Style::new() + .fg(self.selected_color) + .add_modifier(Modifier::BOLD) + } else { + Style::new().fg(self.section_color) + }; + let prefix = if is_selected && !is_dimmed { "> " } else { "" }; + ListItem::new(format!("{prefix}{arrow} {}", item.label)).style(style) } else { - let is_selected = selectable_idx == self.selected; + let is_selected = visual_pos == selected_visual_idx + && matches!(&self.selection, Selection::Item(_)); let style = if let Some(dim_color) = self.dimmed_color { Style::new().fg(dim_color) } else if is_selected && self.focused { @@ -123,8 +159,11 @@ impl<'a> CategoryList<'a> { } else { Style::new().fg(self.normal_color) }; - let prefix = if is_selected && !is_dimmed { "> " } else { " " }; - selectable_idx += 1; + let prefix = if is_selected && !is_dimmed { + "> " + } else { + " " + }; ListItem::new(format!("{prefix}{}", item.label)).style(style) } }) diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index ac3c318..6ab2898 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -21,7 +21,7 @@ mod vu_meter; mod waveform; pub use active_patterns::{ActivePatterns, MuteStatus}; -pub use category_list::{CategoryItem, CategoryList}; +pub use category_list::{CategoryItem, CategoryList, Selection}; pub use confirm::ConfirmModal; pub use editor::{fuzzy_match, CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; diff --git a/src/input/help_page.rs b/src/input/help_page.rs index 2364698..675abaa 100644 --- a/src/input/help_page.rs +++ b/src/input/help_page.rs @@ -3,6 +3,8 @@ use std::sync::atomic::Ordering; use super::{InputContext, InputResult}; use crate::commands::AppCommand; +use crate::model::categories; +use crate::model::docs; use crate::state::{ConfirmAction, DictFocus, FlashKind, HelpFocus, Modal}; pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { @@ -30,6 +32,12 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe ctx.app.ui.help_focused_block = None; } KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus), + KeyCode::Left if ctx.app.ui.help_focus == HelpFocus::Topics => { + collapse_help_section(ctx); + } + KeyCode::Right if ctx.app.ui.help_focus == HelpFocus::Topics => { + expand_help_section(ctx); + } KeyCode::Char('n') if ctx.app.ui.help_focus == HelpFocus::Content => { navigate_code_block(ctx, true); } @@ -130,6 +138,66 @@ fn execute_focused_block(ctx: &mut InputContext) { } } +fn collapse_help_section(ctx: &mut InputContext) { + if let Some(s) = ctx.app.ui.help_on_section { + if ctx.app.ui.help_collapsed.get(s).copied().unwrap_or(false) { + return; + } + } + let section = match ctx.app.ui.help_on_section { + Some(s) => s, + None => docs::section_index_for_topic(ctx.app.ui.help_topic), + }; + if let Some(v) = ctx.app.ui.help_collapsed.get_mut(section) { + *v = true; + } + ctx.app.ui.help_on_section = Some(section); + ctx.app.ui.help_focused_block = None; +} + +fn expand_help_section(ctx: &mut InputContext) { + let Some(section) = ctx.app.ui.help_on_section else { + return; + }; + if let Some(v) = ctx.app.ui.help_collapsed.get_mut(section) { + *v = false; + } + ctx.app.ui.help_on_section = None; + if let Some(first) = docs::first_topic_in_section(section) { + ctx.app.ui.help_topic = first; + } + ctx.app.ui.help_focused_block = None; +} + +fn collapse_dict_section(ctx: &mut InputContext) { + if let Some(s) = ctx.app.ui.dict_on_section { + if ctx.app.ui.dict_collapsed.get(s).copied().unwrap_or(false) { + return; + } + } + let section = match ctx.app.ui.dict_on_section { + Some(s) => s, + None => categories::section_index_for_category(ctx.app.ui.dict_category), + }; + if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(section) { + *v = true; + } + ctx.app.ui.dict_on_section = Some(section); +} + +fn expand_dict_section(ctx: &mut InputContext) { + let Some(section) = ctx.app.ui.dict_on_section else { + return; + }; + if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(section) { + *v = false; + } + ctx.app.ui.dict_on_section = None; + if let Some(first) = categories::first_category_in_section(section) { + ctx.app.ui.dict_category = first; + } +} + pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); @@ -152,6 +220,12 @@ pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe ctx.dispatch(AppCommand::DictClearSearch); } KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus), + KeyCode::Left if ctx.app.ui.dict_focus == DictFocus::Categories => { + collapse_dict_section(ctx); + } + KeyCode::Right if ctx.app.ui.dict_focus == DictFocus::Categories => { + expand_dict_section(ctx); + } KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.dict_focus { DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory), DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)), diff --git a/src/input/mouse.rs b/src/input/mouse.rs index a1318ed..a4a6cb4 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -4,8 +4,8 @@ use ratatui::layout::{Constraint, Layout, Rect}; use crate::commands::AppCommand; use crate::page::Page; use crate::state::{ - DictFocus, DeviceKind, EngineSection, HelpFocus, MainLayout, MinimapMode, Modal, - OptionsFocus, PatternsColumn, SettingKind, + DeviceKind, DictFocus, EngineSection, HelpFocus, MainLayout, MinimapMode, Modal, OptionsFocus, + PatternsColumn, SettingKind, }; use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view}; @@ -57,10 +57,7 @@ fn top_level_layout(padded: Rect) -> (Rect, Rect, Rect) { } fn contains(area: Rect, col: u16, row: u16) -> bool { - col >= area.x - && col < area.x + area.width - && row >= area.y - && row < area.y + area.height + col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height } fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { @@ -319,20 +316,16 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { let sequencer_area = match layout { MainLayout::Top => { let viz_height = if has_viz { 16 } else { 0 }; - let [_viz, seq] = Layout::vertical([ - Constraint::Length(viz_height), - Constraint::Fill(1), - ]) - .areas(main_area); + let [_viz, seq] = + Layout::vertical([Constraint::Length(viz_height), Constraint::Fill(1)]) + .areas(main_area); seq } MainLayout::Bottom => { let viz_height = if has_viz { 16 } else { 0 }; - let [seq, _viz] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(viz_height), - ]) - .areas(main_area); + let [seq, _viz] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(viz_height)]) + .areas(main_area); seq } MainLayout::Left => { @@ -497,10 +490,39 @@ fn handle_help_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { let [topics_area, content_area] = help_view::layout(area); if contains(topics_area, col, row) { - use crate::model::docs::{DocEntry, DOCS}; - let is_section: Vec = DOCS.iter().map(|e| matches!(e, DocEntry::Section(_))).collect(); - if let Some(i) = hit_test_category_list(&is_section, ctx.app.ui.help_topic, topics_area, row) { - ctx.dispatch(AppCommand::HelpSelectTopic(i)); + use crate::model::docs::{self, DocEntry, DOCS}; + let items = build_visible_list( + &DOCS + .iter() + .map(|e| matches!(e, DocEntry::Section(_))) + .collect::>(), + &ctx.app.ui.help_collapsed, + ); + let sel = match ctx.app.ui.help_on_section { + Some(s) => VisibleEntry::Section(s), + None => VisibleEntry::Item(ctx.app.ui.help_topic), + }; + if let Some(entry) = hit_test_visible_list(&items, &sel, topics_area, row) { + match entry { + VisibleEntry::Item(i) => ctx.dispatch(AppCommand::HelpSelectTopic(i)), + VisibleEntry::Section(s) => { + let collapsed = ctx.app.ui.help_collapsed.get(s).copied().unwrap_or(false); + if collapsed { + if let Some(v) = ctx.app.ui.help_collapsed.get_mut(s) { + *v = false; + } + ctx.app.ui.help_on_section = None; + if let Some(first) = docs::first_topic_in_section(s) { + ctx.app.ui.help_topic = first; + } + } else { + if let Some(v) = ctx.app.ui.help_collapsed.get_mut(s) { + *v = true; + } + ctx.app.ui.help_on_section = Some(s); + } + } + } } ctx.app.ui.help_focus = HelpFocus::Topics; } else if contains(content_area, col, row) { @@ -514,10 +536,39 @@ fn handle_dict_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { let (_header_area, [cat_area, words_area]) = dict_view::layout(area); if contains(cat_area, col, row) { - use crate::model::categories::{CatEntry, CATEGORIES}; - let is_section: Vec = CATEGORIES.iter().map(|e| matches!(e, CatEntry::Section(_))).collect(); - if let Some(i) = hit_test_category_list(&is_section, ctx.app.ui.dict_category, cat_area, row) { - ctx.dispatch(AppCommand::DictSelectCategory(i)); + use crate::model::categories::{self, CatEntry, CATEGORIES}; + let items = build_visible_list( + &CATEGORIES + .iter() + .map(|e| matches!(e, CatEntry::Section(_))) + .collect::>(), + &ctx.app.ui.dict_collapsed, + ); + let sel = match ctx.app.ui.dict_on_section { + Some(s) => VisibleEntry::Section(s), + None => VisibleEntry::Item(ctx.app.ui.dict_category), + }; + if let Some(entry) = hit_test_visible_list(&items, &sel, cat_area, row) { + match entry { + VisibleEntry::Item(i) => ctx.dispatch(AppCommand::DictSelectCategory(i)), + VisibleEntry::Section(s) => { + let collapsed = ctx.app.ui.dict_collapsed.get(s).copied().unwrap_or(false); + if collapsed { + if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(s) { + *v = false; + } + ctx.app.ui.dict_on_section = None; + if let Some(first) = categories::first_category_in_section(s) { + ctx.app.ui.dict_category = first; + } + } else { + if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(s) { + *v = true; + } + ctx.app.ui.dict_on_section = Some(s); + } + } + } } ctx.app.ui.dict_focus = DictFocus::Categories; } else if contains(words_area, col, row) { @@ -527,32 +578,54 @@ fn handle_dict_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { // --- CategoryList hit test --- -fn hit_test_category_list( - is_section: &[bool], - selected: usize, +#[derive(Clone, Copy)] +enum VisibleEntry { + Item(usize), + Section(usize), +} + +fn build_visible_list(is_section: &[bool], collapsed: &[bool]) -> Vec { + let mut result = Vec::new(); + let mut section_idx = 0usize; + let mut item_idx = 0usize; + let mut skipping = false; + for &is_sec in is_section { + if is_sec { + let is_collapsed = collapsed.get(section_idx).copied().unwrap_or(false); + result.push(VisibleEntry::Section(section_idx)); + skipping = is_collapsed; + section_idx += 1; + } else if !skipping { + result.push(VisibleEntry::Item(item_idx)); + item_idx += 1; + } else { + item_idx += 1; + } + } + result +} + +fn hit_test_visible_list( + items: &[VisibleEntry], + selected: &VisibleEntry, area: Rect, click_row: u16, -) -> Option { +) -> Option { let visible_height = area.height.saturating_sub(2) as usize; if visible_height == 0 { return None; } - let total_items = is_section.len(); + let total_items = items.len(); - // Compute visual index of the selected item (same as CategoryList::render) - let selected_visual_idx = { - let mut visual = 0; - let mut selectable_count = 0; - for &is_sec in is_section { - if !is_sec { - if selectable_count == selected { - break; - } - selectable_count += 1; - } - visual += 1; - } - visual + let selected_visual_idx = match selected { + VisibleEntry::Item(sel) => items + .iter() + .position(|v| matches!(v, VisibleEntry::Item(i) if *i == *sel)) + .unwrap_or(0), + VisibleEntry::Section(sel) => items + .iter() + .position(|v| matches!(v, VisibleEntry::Section(s) if *s == *sel)) + .unwrap_or(0), }; let scroll = if selected_visual_idx < visible_height / 2 { @@ -563,7 +636,6 @@ fn hit_test_category_list( selected_visual_idx.saturating_sub(visible_height / 2) }; - // Inner area starts at area.y + 1 (border top), height is area.height - 2 let inner_y = area.y + 1; if click_row < inner_y { return None; @@ -578,14 +650,7 @@ fn hit_test_category_list( return None; } - // If it's a section header, not clickable - if is_section[visual_idx] { - return None; - } - - // Count selectable items before this visual index to get the selectable index - let selectable_idx = is_section[..visual_idx].iter().filter(|&&s| !s).count(); - Some(selectable_idx) + Some(items[visual_idx]) } // --- Options page --- diff --git a/src/model/categories.rs b/src/model/categories.rs index 0adfc14..5ce52dc 100644 --- a/src/model/categories.rs +++ b/src/model/categories.rs @@ -63,3 +63,53 @@ pub fn get_category_name(index: usize) -> &'static str { .nth(index) .unwrap_or("Unknown") } + +pub fn section_count() -> usize { + CATEGORIES + .iter() + .filter(|e| matches!(e, Section(_))) + .count() +} + +pub fn section_index_for_category(cat_idx: usize) -> usize { + let mut section: usize = 0; + let mut cat: usize = 0; + for entry in CATEGORIES.iter() { + match entry { + Section(_) => section += 1, + Category(_) => { + if cat == cat_idx { + return section.saturating_sub(1); + } + cat += 1; + } + } + } + section.saturating_sub(1) +} + +pub fn first_category_in_section(section_idx: usize) -> Option { + let mut section: usize = 0; + let mut cat: usize = 0; + let mut in_target = false; + for entry in CATEGORIES.iter() { + match entry { + Section(_) => { + if in_target { + return None; + } + if section == section_idx { + in_target = true; + } + section += 1; + } + Category(_) => { + if in_target { + return Some(cat); + } + cat += 1; + } + } + } + None +} diff --git a/src/model/docs.rs b/src/model/docs.rs index 4cf1b70..bd43226 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -122,6 +122,53 @@ pub fn topic_count() -> usize { DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count() } +pub fn section_count() -> usize { + DOCS.iter().filter(|e| matches!(e, Section(_))).count() +} + +pub fn section_index_for_topic(topic_idx: usize) -> usize { + let mut section: usize = 0; + let mut topic: usize = 0; + for entry in DOCS.iter() { + match entry { + Section(_) => section += 1, + Topic(_, _) => { + if topic == topic_idx { + return section.saturating_sub(1); + } + topic += 1; + } + } + } + section.saturating_sub(1) +} + +pub fn first_topic_in_section(section_idx: usize) -> Option { + let mut section: usize = 0; + let mut topic: usize = 0; + let mut in_target = false; + for entry in DOCS.iter() { + match entry { + Section(_) => { + if in_target { + return None; + } + if section == section_idx { + in_target = true; + } + section += 1; + } + Topic(_, _) => { + if in_target { + return Some(topic); + } + topic += 1; + } + } + } + None +} + pub fn get_topic(index: usize) -> Option<(&'static str, &'static str)> { DOCS.iter() .filter_map(|e| match e { diff --git a/src/services/dict_nav.rs b/src/services/dict_nav.rs index 7cd78c4..c8be53b 100644 --- a/src/services/dict_nav.rs +++ b/src/services/dict_nav.rs @@ -1,6 +1,8 @@ -use crate::model::categories; +use crate::model::categories::{self, CatEntry, CATEGORIES}; use crate::state::{DictFocus, UiState}; +use CatEntry::{Category, Section}; + pub fn toggle_focus(ui: &mut UiState) { ui.dict_focus = match ui.dict_focus { DictFocus::Categories => DictFocus::Words, @@ -11,17 +13,86 @@ pub fn toggle_focus(ui: &mut UiState) { pub fn select_category(ui: &mut UiState, index: usize) { if index < categories::category_count() { ui.dict_category = index; + ui.dict_on_section = None; + } +} + +#[derive(Clone, Copy)] +enum VisibleItem { + Category(usize), + Section(usize), +} + +fn visible_items(collapsed: &[bool]) -> Vec { + let mut result = Vec::new(); + let mut section_idx = 0usize; + let mut cat_idx = 0usize; + let mut skipping = false; + for entry in CATEGORIES.iter() { + match entry { + Section(_) => { + let is_collapsed = collapsed.get(section_idx).copied().unwrap_or(false); + if is_collapsed { + result.push(VisibleItem::Section(section_idx)); + } + skipping = is_collapsed; + section_idx += 1; + } + Category(_) => { + if !skipping { + result.push(VisibleItem::Category(cat_idx)); + } + cat_idx += 1; + } + } + } + result +} + +fn find_current(items: &[VisibleItem], ui: &UiState) -> usize { + if let Some(s) = ui.dict_on_section { + items + .iter() + .position(|v| matches!(v, VisibleItem::Section(idx) if *idx == s)) + .unwrap_or(0) + } else { + items + .iter() + .position(|v| matches!(v, VisibleItem::Category(idx) if *idx == ui.dict_category)) + .unwrap_or(0) + } +} + +fn apply_selection(ui: &mut UiState, item: VisibleItem) { + match item { + VisibleItem::Category(idx) => { + ui.dict_category = idx; + ui.dict_on_section = None; + } + VisibleItem::Section(idx) => { + ui.dict_on_section = Some(idx); + } } } pub fn next_category(ui: &mut UiState) { - let count = categories::category_count(); - ui.dict_category = (ui.dict_category + 1) % count; + let items = visible_items(&ui.dict_collapsed); + if items.is_empty() { + return; + } + let cur = find_current(&items, ui); + let next = (cur + 1) % items.len(); + apply_selection(ui, items[next]); } pub fn prev_category(ui: &mut UiState) { - let count = categories::category_count(); - ui.dict_category = (ui.dict_category + count - 1) % count; + let items = visible_items(&ui.dict_collapsed); + if items.is_empty() { + return; + } + let cur = find_current(&items, ui); + let next = (cur + items.len() - 1) % items.len(); + apply_selection(ui, items[next]); } pub fn scroll_down(ui: &mut UiState, n: usize) { diff --git a/src/services/help_nav.rs b/src/services/help_nav.rs index 2ad8379..1958be8 100644 --- a/src/services/help_nav.rs +++ b/src/services/help_nav.rs @@ -1,6 +1,8 @@ -use crate::model::docs; +use crate::model::docs::{self, DocEntry, DOCS}; use crate::state::{HelpFocus, UiState}; +use DocEntry::{Section, Topic}; + pub fn toggle_focus(ui: &mut UiState) { ui.help_focus = match ui.help_focus { HelpFocus::Topics => HelpFocus::Content, @@ -11,18 +13,87 @@ pub fn toggle_focus(ui: &mut UiState) { pub fn select_topic(ui: &mut UiState, index: usize) { if index < docs::topic_count() { ui.help_topic = index; + ui.help_on_section = None; + } +} + +#[derive(Clone, Copy)] +enum VisibleItem { + Topic(usize), + Section(usize), +} + +fn visible_items(collapsed: &[bool]) -> Vec { + let mut result = Vec::new(); + let mut section_idx = 0usize; + let mut topic_idx = 0usize; + let mut skipping = false; + for entry in DOCS.iter() { + match entry { + Section(_) => { + let is_collapsed = collapsed.get(section_idx).copied().unwrap_or(false); + if is_collapsed { + result.push(VisibleItem::Section(section_idx)); + } + skipping = is_collapsed; + section_idx += 1; + } + Topic(_, _) => { + if !skipping { + result.push(VisibleItem::Topic(topic_idx)); + } + topic_idx += 1; + } + } + } + result +} + +fn find_current(items: &[VisibleItem], ui: &UiState) -> usize { + if let Some(s) = ui.help_on_section { + items + .iter() + .position(|v| matches!(v, VisibleItem::Section(idx) if *idx == s)) + .unwrap_or(0) + } else { + items + .iter() + .position(|v| matches!(v, VisibleItem::Topic(idx) if *idx == ui.help_topic)) + .unwrap_or(0) + } +} + +fn apply_selection(ui: &mut UiState, item: VisibleItem) { + match item { + VisibleItem::Topic(idx) => { + ui.help_topic = idx; + ui.help_on_section = None; + } + VisibleItem::Section(idx) => { + ui.help_on_section = Some(idx); + } } } pub fn next_topic(ui: &mut UiState, n: usize) { - let count = docs::topic_count(); - ui.help_topic = (ui.help_topic + n) % count; + let items = visible_items(&ui.help_collapsed); + if items.is_empty() { + return; + } + let cur = find_current(&items, ui); + let next = (cur + n) % items.len(); + apply_selection(ui, items[next]); ui.help_focused_block = None; } pub fn prev_topic(ui: &mut UiState, n: usize) { - let count = docs::topic_count(); - ui.help_topic = (ui.help_topic + count - (n % count)) % count; + let items = visible_items(&ui.help_collapsed); + if items.is_empty() { + return; + } + let cur = find_current(&items, ui); + let next = (cur + items.len() - (n % items.len())) % items.len(); + apply_selection(ui, items[next]); ui.help_focused_block = None; } @@ -49,6 +120,7 @@ pub fn search_input(ui: &mut UiState, c: char) { ui.help_search_query.push(c); if let Some((topic, line)) = docs::find_match(&ui.help_search_query) { ui.help_topic = topic; + ui.help_on_section = None; ui.help_scrolls[topic] = line; } } @@ -60,6 +132,7 @@ pub fn search_backspace(ui: &mut UiState) { } if let Some((topic, line)) = docs::find_match(&ui.help_search_query) { ui.help_topic = topic; + ui.help_on_section = None; ui.help_scrolls[topic] = line; } } diff --git a/src/state/ui.rs b/src/state/ui.rs index 13f54b6..1053e4d 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -57,6 +57,10 @@ pub struct UiState { pub dict_scrolls: Vec, pub dict_search_query: String, pub dict_search_active: bool, + pub help_collapsed: Vec, + pub help_on_section: Option, + pub dict_collapsed: Vec, + pub dict_on_section: Option, pub show_title: bool, pub runtime_highlight: bool, pub show_completion: bool, @@ -86,12 +90,20 @@ impl Default for UiState { help_search_active: false, help_search_query: String::new(), help_focused_block: None, - help_parsed: RefCell::new((0..crate::model::docs::topic_count()).map(|_| None).collect()), + help_parsed: RefCell::new( + (0..crate::model::docs::topic_count()) + .map(|_| None) + .collect(), + ), dict_focus: DictFocus::default(), dict_category: 0, dict_scrolls: vec![0; crate::model::categories::category_count()], dict_search_query: String::new(), dict_search_active: false, + help_collapsed: vec![false; crate::model::docs::section_count()], + help_on_section: None, + dict_collapsed: vec![false; crate::model::categories::section_count()], + dict_on_section: None, show_title: true, runtime_highlight: false, show_completion: true, @@ -167,6 +179,9 @@ impl UiState { } pub fn invalidate_help_cache(&self) { - self.help_parsed.borrow_mut().iter_mut().for_each(|slot| *slot = None); + self.help_parsed + .borrow_mut() + .iter_mut() + .for_each(|slot| *slot = None); } } diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs index a30055d..3b26dc3 100644 --- a/src/views/dict_view.rs +++ b/src/views/dict_view.rs @@ -9,7 +9,7 @@ use crate::model::categories::{get_category_name, CatEntry, CATEGORIES}; use crate::model::{Word, WORDS}; use crate::state::DictFocus; use crate::theme; -use crate::widgets::{render_search_bar, CategoryItem, CategoryList}; +use crate::widgets::{render_search_bar, CategoryItem, CategoryList, Selection}; use CatEntry::{Category, Section}; @@ -51,21 +51,38 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) { let theme = theme::get(); let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed; + let mut section_idx = 0usize; let items: Vec = CATEGORIES .iter() .map(|entry| match entry { - Section(name) => CategoryItem { - label: name, - is_section: true, - }, + Section(name) => { + let collapsed = app + .ui + .dict_collapsed + .get(section_idx) + .copied() + .unwrap_or(false); + section_idx += 1; + CategoryItem { + label: name, + is_section: true, + collapsed, + } + } Category(name) => CategoryItem { label: name, is_section: false, + collapsed: false, }, }) .collect(); - let mut list = CategoryList::new(&items, app.ui.dict_category) + let selection = match app.ui.dict_on_section { + Some(s) => Selection::Section(s), + None => Selection::Item(app.ui.dict_category), + }; + + let mut list = CategoryList::new(&items, selection) .focused(focused) .title("Categories"); @@ -91,23 +108,24 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { .collect() } else { let category = get_category_name(app.ui.dict_category); - WORDS - .iter() - .filter(|w| w.category == category) - .collect() + WORDS.iter().filter(|w| w.category == category).collect() }; let show_search = app.ui.dict_search_active || is_searching; let (search_area, content_area) = if show_search { - let [s, c] = - Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); + let [s, c] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); (Some(s), c) } else { (None, area) }; if let Some(sa) = search_area { - render_search_bar(frame, sa, &app.ui.dict_search_query, app.ui.dict_search_active); + render_search_bar( + frame, + sa, + &app.ui.dict_search_query, + app.ui.dict_search_active, + ); } let content_width = content_area.width.saturating_sub(2) as usize; @@ -172,7 +190,11 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { let category = get_category_name(app.ui.dict_category); format!("{category} ({} words)", words.len()) }; - let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; + let border_color = if focused { + theme.dict.border_focused + } else { + theme.dict.border_normal + }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 0b7d207..45137a7 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -11,7 +11,7 @@ use crate::model::docs::{get_topic, DocEntry, DOCS}; use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; -use crate::widgets::{render_search_bar, CategoryItem, CategoryList}; +use crate::widgets::{render_search_bar, CategoryItem, CategoryList, Selection}; use DocEntry::{Section, Topic}; @@ -105,21 +105,38 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let focused = app.ui.help_focus == HelpFocus::Topics; + let mut section_idx = 0usize; let items: Vec = DOCS .iter() .map(|entry| match entry { - Section(name) => CategoryItem { - label: name, - is_section: true, - }, + Section(name) => { + let collapsed = app + .ui + .help_collapsed + .get(section_idx) + .copied() + .unwrap_or(false); + section_idx += 1; + CategoryItem { + label: name, + is_section: true, + collapsed, + } + } Topic(name, _) => CategoryItem { label: name, is_section: false, + collapsed: false, }, }) .collect(); - CategoryList::new(&items, app.ui.help_topic) + let selection = match app.ui.help_on_section { + Some(s) => Selection::Section(s), + None => Selection::Item(app.ui.help_topic), + }; + + CategoryList::new(&items, selection) .focused(focused) .title("Topics") .selected_color(theme.dict.category_selected) @@ -172,7 +189,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { { let mut cache = app.ui.help_parsed.borrow_mut(); if cache[app.ui.help_topic].is_none() { - cache[app.ui.help_topic] = Some(cagire_markdown::parse(md, &AppTheme, &ForthHighlighter)); + cache[app.ui.help_topic] = + Some(cagire_markdown::parse(md, &AppTheme, &ForthHighlighter)); } } let cache = app.ui.help_parsed.borrow(); @@ -182,7 +200,12 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { let content_area = if has_search_bar { let [content, search] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area); - render_search_bar(frame, search, &app.ui.help_search_query, app.ui.help_search_active); + render_search_bar( + frame, + search, + &app.ui.help_search_query, + app.ui.help_search_active, + ); content } else { md_area @@ -295,4 +318,3 @@ fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> { fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } - diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 5fa4575..f861f2b 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -2,5 +2,5 @@ pub use cagire_ratatui::{ hint_line, render_props_form, render_scroll_indicators, render_search_bar, render_section_header, ActivePatterns, CategoryItem, CategoryList, ConfirmModal, FileBrowserModal, IndicatorAlign, ModalFrame, MuteStatus, NavMinimap, NavTile, Orientation, - SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform, + SampleBrowser, Scope, Selection, Spectrum, TextInputModal, VuMeter, Waveform, };