286 lines
8.5 KiB
Rust
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)
|
|
}
|
|
|