Feat: UI / UX

This commit is contained in:
2026-02-16 01:22:40 +01:00
parent b23dd85d0f
commit af6732db1c
37 changed files with 1045 additions and 64 deletions

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -5,6 +5,8 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
("F1F6", "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"));

View File

@@ -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();

View File

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

View File

@@ -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),
)),
];