Feat: collapsible help

This commit is contained in:
2026-02-16 23:43:25 +01:00
parent 540f59dcf5
commit 524e686b3a
12 changed files with 597 additions and 119 deletions

View File

@@ -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<usize>, Option<usize>)> {
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<ListItem> = self.items
let items: Vec<ListItem> = 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 {
let is_selected = selectable_idx == self.selected;
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 = 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)
}
})

View File

@@ -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;

View File

@@ -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)),

View File

@@ -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,19 +316,15 @@ 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),
])
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),
])
let [seq, _viz] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(viz_height)])
.areas(main_area);
seq
}
@@ -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<bool> = 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::<Vec<_>>(),
&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<bool> = 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::<Vec<_>>(),
&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<VisibleEntry> {
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<usize> {
) -> Option<VisibleEntry> {
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 ---

View File

@@ -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<usize> {
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
}

View File

@@ -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<usize> {
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 {

View File

@@ -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<VisibleItem> {
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) {

View File

@@ -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<VisibleItem> {
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;
}
}

View File

@@ -57,6 +57,10 @@ pub struct UiState {
pub dict_scrolls: Vec<usize>,
pub dict_search_query: String,
pub dict_search_active: bool,
pub help_collapsed: Vec<bool>,
pub help_on_section: Option<usize>,
pub dict_collapsed: Vec<bool>,
pub dict_on_section: Option<usize>,
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);
}
}

View File

@@ -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<CategoryItem> = CATEGORIES
.iter()
.map(|entry| match entry {
Section(name) => CategoryItem {
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))

View File

@@ -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<CategoryItem> = DOCS
.iter()
.map(|entry| match entry {
Section(name) => CategoryItem {
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<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}

View File

@@ -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,
};