Feat: UI / UX
This commit is contained in:
@@ -183,7 +183,15 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let has_query = !query.is_empty();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let lines = cagire_markdown::parse(md, &AppTheme, &ForthHighlighter);
|
||||
// Populate parse cache for this topic
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
let cache = app.ui.help_parsed.borrow();
|
||||
let parsed = cache[app.ui.help_topic].as_ref().unwrap();
|
||||
|
||||
let has_search_bar = app.ui.help_search_active || has_query;
|
||||
let content_area = if has_search_bar {
|
||||
@@ -201,13 +209,33 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
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
|
||||
let total_wrapped: usize = parsed
|
||||
.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 mut lines = parsed.lines.clone();
|
||||
|
||||
// Highlight focused code block with background tint
|
||||
if let Some(block_idx) = app.ui.help_focused_block {
|
||||
if let Some(block) = parsed.code_blocks.get(block_idx) {
|
||||
let tint_bg = theme.ui.surface;
|
||||
for line in lines.iter_mut().take(block.end_line).skip(block.start_line) {
|
||||
for (i, span) in line.spans.iter_mut().enumerate() {
|
||||
let style = if i < 2 {
|
||||
span.style.fg(theme.ui.accent).bg(tint_bg)
|
||||
} else {
|
||||
span.style.bg(tint_bg)
|
||||
};
|
||||
*span = Span::styled(span.content.clone(), style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lines: Vec<RLine> = if has_query {
|
||||
lines
|
||||
.into_iter()
|
||||
|
||||
@@ -191,7 +191,7 @@ fn classify_word(word: &str, user_words: &HashSet<String>) -> (TokenKind, bool)
|
||||
return (TokenKind::Note, false);
|
||||
}
|
||||
|
||||
if word.len() > 1 && (word.starts_with('@') || word.starts_with('!')) {
|
||||
if word.len() > 1 && (word.starts_with('@') || word.starts_with('!') || word.starts_with(',')) {
|
||||
return (TokenKind::Variable, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
||||
("F1–F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
|
||||
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
|
||||
("q", "Quit", "Quit application"),
|
||||
("s", "Save", "Save project"),
|
||||
("l", "Load", "Load project"),
|
||||
("?", "Keybindings", "Show this help"),
|
||||
];
|
||||
|
||||
@@ -31,8 +33,6 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
||||
bindings.push(("T", "Set tempo", "Open tempo input"));
|
||||
bindings.push(("L", "Set length", "Open length input"));
|
||||
bindings.push(("S", "Set speed", "Open speed input"));
|
||||
bindings.push(("s", "Save", "Save project"));
|
||||
bindings.push(("l", "Load", "Load project"));
|
||||
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
||||
bindings.push(("r", "Rename", "Rename current step"));
|
||||
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
||||
@@ -88,8 +88,11 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
||||
bindings.push(("Tab", "Topic", "Next topic"));
|
||||
bindings.push(("Shift+Tab", "Topic", "Previous topic"));
|
||||
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
|
||||
bindings.push(("n", "Next code", "Jump to next code block"));
|
||||
bindings.push(("p", "Prev code", "Jump to previous code block"));
|
||||
bindings.push(("Enter", "Run code", "Execute focused code block"));
|
||||
bindings.push(("/", "Search", "Activate search"));
|
||||
bindings.push(("Esc", "Clear", "Clear search"));
|
||||
bindings.push(("Esc", "Clear", "Clear search / deselect block"));
|
||||
}
|
||||
Page::Dict => {
|
||||
bindings.push(("Tab", "Focus", "Toggle category/words focus"));
|
||||
|
||||
@@ -110,6 +110,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let midi_in_2 = midi_in_display(2);
|
||||
let midi_in_3 = midi_in_display(3);
|
||||
|
||||
let onboarding_str = format!("{}/6 dismissed", app.ui.onboarding_dismissed.len());
|
||||
let hue_str = format!("{}°", app.ui.hue_rotation as i32);
|
||||
|
||||
let lines: Vec<Line> = vec![
|
||||
@@ -207,6 +208,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme),
|
||||
render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme),
|
||||
render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme),
|
||||
Line::from(""),
|
||||
render_section_header("ONBOARDING", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
render_option_line(
|
||||
"Reset guides",
|
||||
&onboarding_str,
|
||||
focus == OptionsFocus::ResetOnboarding,
|
||||
&theme,
|
||||
),
|
||||
];
|
||||
|
||||
let total_lines = lines.len();
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
@@ -418,13 +418,20 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("Space", "Play"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Help => vec![
|
||||
("↑↓", "Scroll"),
|
||||
("Tab", "Topic"),
|
||||
("PgUp/Dn", "Page"),
|
||||
("/", "Search"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Help => match app.ui.help_focus {
|
||||
crate::state::HelpFocus::Content => vec![
|
||||
("n", "Next Example"),
|
||||
("p", "Previous Example"),
|
||||
("Enter", "Evaluate"),
|
||||
("Tab", "Topics"),
|
||||
],
|
||||
crate::state::HelpFocus::Topics => vec![
|
||||
("↑↓", "Navigate"),
|
||||
("Tab", "Content"),
|
||||
("/", "Search"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
},
|
||||
Page::Dict => vec![
|
||||
("Tab", "Focus"),
|
||||
("↑↓", "Navigate"),
|
||||
@@ -594,6 +601,60 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
inner
|
||||
}
|
||||
Modal::Onboarding => {
|
||||
let (desc, keys) = app.page.onboarding();
|
||||
let text_width = 51usize; // inner width minus 2 for padding
|
||||
let desc_lines = {
|
||||
let mut lines = 0u16;
|
||||
for line in desc.split('\n') {
|
||||
lines += (line.len() as u16).max(1).div_ceil(text_width as u16);
|
||||
}
|
||||
lines
|
||||
};
|
||||
let key_lines = keys.len() as u16;
|
||||
let modal_height = (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4)); // border + pad + desc + gap + keys + pad + hint
|
||||
|
||||
let inner = ModalFrame::new(&format!(" {} ", app.page.name()))
|
||||
.width(57)
|
||||
.height(modal_height)
|
||||
.border_color(theme.modal.confirm)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let content_width = inner.width.saturating_sub(4);
|
||||
let mut y = inner.y + 1;
|
||||
|
||||
let desc_area = Rect::new(inner.x + 2, y, content_width, desc_lines);
|
||||
let body = Paragraph::new(desc)
|
||||
.style(Style::new().fg(theme.ui.text_primary))
|
||||
.wrap(Wrap { trim: true });
|
||||
frame.render_widget(body, desc_area);
|
||||
y += desc_lines + 1;
|
||||
|
||||
for &(key, action) in keys {
|
||||
if y >= inner.y + inner.height - 1 {
|
||||
break;
|
||||
}
|
||||
let line = Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{:>8}", key),
|
||||
Style::new().fg(theme.hint.key),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {action}"),
|
||||
Style::new().fg(theme.hint.text),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1));
|
||||
y += 1;
|
||||
}
|
||||
|
||||
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||
let hints = hint_line(&[("Enter", "don't show again"), ("any key", "dismiss")]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Center), hint_area);
|
||||
|
||||
inner
|
||||
}
|
||||
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
|
||||
Modal::EuclideanDistribution {
|
||||
source_step,
|
||||
|
||||
@@ -52,9 +52,32 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
||||
Line::from(Span::styled("AGPL-3.0", license_style)),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Ctrl+Arrows", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": navigate views", Style::new().fg(theme.title.prompt)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Enter", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": edit step ", Style::new().fg(theme.title.prompt)),
|
||||
Span::styled("Space", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": play/stop", Style::new().fg(theme.title.prompt)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("s", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": save ", Style::new().fg(theme.title.prompt)),
|
||||
Span::styled("l", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": load ", Style::new().fg(theme.title.prompt)),
|
||||
Span::styled("q", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": quit", Style::new().fg(theme.title.prompt)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("?", Style::new().fg(theme.title.link)),
|
||||
Span::styled(": keybindings", Style::new().fg(theme.title.prompt)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Press any key to continue",
|
||||
Style::new().fg(theme.title.prompt),
|
||||
Style::new().fg(theme.title.subtitle),
|
||||
)),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user