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

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