Files
Cagire/src/views/help_view.rs

286 lines
8.5 KiB
Rust

use cagire_markdown::{CodeHighlighter, MarkdownTheme};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame;
#[cfg(not(feature = "desktop"))]
use tui_big_text::{BigText, PixelSize};
use crate::app::App;
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 DocEntry::{Section, Topic};
struct AppTheme;
impl MarkdownTheme for AppTheme {
fn h1(&self) -> Style {
Style::new()
.fg(theme::get().markdown.h1)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
fn h2(&self) -> Style {
Style::new()
.fg(theme::get().markdown.h2)
.add_modifier(Modifier::BOLD)
}
fn h3(&self) -> Style {
Style::new()
.fg(theme::get().markdown.h3)
.add_modifier(Modifier::BOLD)
}
fn text(&self) -> Style {
Style::new().fg(theme::get().markdown.text)
}
fn code(&self) -> Style {
Style::new().fg(theme::get().markdown.code)
}
fn code_border(&self) -> Style {
Style::new().fg(theme::get().markdown.code_border)
}
fn link(&self) -> Style {
Style::new()
.fg(theme::get().markdown.link)
.add_modifier(Modifier::UNDERLINED)
}
fn link_url(&self) -> Style {
Style::new().fg(theme::get().markdown.link_url)
}
fn quote(&self) -> Style {
Style::new().fg(theme::get().markdown.quote)
}
fn list(&self) -> Style {
Style::new().fg(theme::get().markdown.list)
}
fn table_header_bg(&self) -> Color {
theme::get().ui.surface
}
fn table_row_even(&self) -> Color {
theme::get().table.row_even
}
fn table_row_odd(&self) -> Color {
theme::get().table.row_odd
}
}
struct ForthHighlighter;
impl CodeHighlighter for ForthHighlighter {
fn highlight(&self, line: &str) -> Vec<(Style, String)> {
highlight::highlight_line(line)
.into_iter()
.map(|(s, t, _)| (s, t))
.collect()
}
}
pub fn layout(area: Rect) -> [Rect; 2] {
Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area)
}
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [topics_area, content_area] = layout(area);
render_topics(frame, app, topics_area);
render_content(frame, app, content_area);
}
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let focused = app.ui.help_focus == HelpFocus::Topics;
let items: Vec<CategoryItem> = DOCS
.iter()
.map(|entry| match entry {
Section(name) => CategoryItem {
label: name,
is_section: true,
},
Topic(name, _) => CategoryItem {
label: name,
is_section: false,
},
})
.collect();
CategoryList::new(&items, app.ui.help_topic)
.focused(focused)
.title("Topics")
.selected_color(theme.dict.category_selected)
.normal_color(theme.ui.text_primary)
.render(frame, area);
}
const WELCOME_TOPIC: usize = 0;
#[cfg(not(feature = "desktop"))]
const BIG_TITLE_HEIGHT: u16 = 6;
#[cfg(feature = "desktop")]
const BIG_TITLE_HEIGHT: u16 = 3;
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let Some((_, md)) = get_topic(app.ui.help_topic) else {
return;
};
let is_welcome = app.ui.help_topic == WELCOME_TOPIC;
let md_area = if is_welcome {
let [title_area, rest] =
Layout::vertical([Constraint::Length(BIG_TITLE_HEIGHT), Constraint::Fill(1)])
.areas(area);
#[cfg(not(feature = "desktop"))]
let big_title = BigText::builder()
.pixel_size(PixelSize::Quadrant)
.style(Style::new().fg(theme.markdown.h1).bold())
.lines(vec!["CAGIRE".into()])
.centered()
.build();
#[cfg(feature = "desktop")]
let big_title = Paragraph::new(RLine::from(Span::styled(
"CAGIRE",
Style::new().fg(theme.markdown.h1).bold(),
)))
.alignment(ratatui::layout::Alignment::Center);
let subtitle = Paragraph::new(RLine::from(Span::styled(
"A Forth Sequencer",
Style::new().fg(theme.ui.text_primary),
)))
.alignment(ratatui::layout::Alignment::Center);
#[cfg(not(feature = "desktop"))]
let [big_area, subtitle_area] =
Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area);
#[cfg(feature = "desktop")]
let [big_area, subtitle_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(2)]).areas(title_area);
frame.render_widget(big_title, big_area);
frame.render_widget(subtitle, subtitle_area);
rest
} else {
area
};
let query = &app.ui.help_search_query;
let has_query = !query.is_empty();
let query_lower = query.to_lowercase();
let lines = cagire_markdown::parse(md, &AppTheme, &ForthHighlighter);
let has_search_bar = app.ui.help_search_active || has_query;
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);
content
} else {
md_area
};
// Calculate dimensions: 2 borders + 4 padding (2 left + 2 right)
let content_width = content_area.width.saturating_sub(6) as usize;
// 2 borders + 4 padding (2 top + 2 bottom)
let visible_height = content_area.height.saturating_sub(6) as usize;
// Calculate total wrapped line count for accurate max_scroll
let total_wrapped: usize = lines
.iter()
.map(|l| wrapped_line_count(l, content_width))
.sum();
let max_scroll = total_wrapped.saturating_sub(visible_height);
let scroll = app.ui.help_scroll().min(max_scroll);
let lines: Vec<RLine> = if has_query {
lines
.into_iter()
.map(|line| highlight_line(line, &query_lower))
.collect()
} else {
lines
};
let focused = app.ui.help_focus == HelpFocus::Content;
let border_color = if focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let para = Paragraph::new(lines)
.scroll((scroll as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.padding(Padding::new(2, 2, 2, 2)),
)
.wrap(Wrap { trim: false });
frame.render_widget(para, content_area);
}
fn wrapped_line_count(line: &RLine, width: usize) -> usize {
let char_count: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if char_count == 0 || width == 0 {
1
} else {
char_count.div_ceil(width)
}
}
fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
let theme = theme::get();
let mut result: Vec<Span<'a>> = Vec::new();
for span in line.spans {
let lower = span.content.to_lowercase();
if !lower.contains(query) {
result.push(span);
continue;
}
let content = span.content.to_string();
let base_style = span.style;
let hl_style = base_style
.bg(theme.search.match_bg)
.fg(theme.search.match_fg);
let mut start = 0;
let lower_bytes = lower.as_bytes();
let query_bytes = query.as_bytes();
while let Some(pos) = find_bytes(&lower_bytes[start..], query_bytes) {
let abs = start + pos;
if abs > start {
result.push(Span::styled(content[start..abs].to_string(), base_style));
}
result.push(Span::styled(
content[abs..abs + query.len()].to_string(),
hl_style,
));
start = abs + query.len();
}
if start < content.len() {
result.push(Span::styled(content[start..].to_string(), base_style));
}
}
RLine::from(result)
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}