Feat: collapsible help
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user