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

@@ -134,6 +134,7 @@ impl App {
color_scheme: self.ui.color_scheme,
layout: self.audio.config.layout,
hue_rotation: self.ui.hue_rotation,
onboarding_dismissed: self.ui.onboarding_dismissed.clone(),
..Default::default()
},
link: crate::settings::LinkSettings {
@@ -1117,6 +1118,17 @@ impl App {
UndoEntry { scope: reverse_scope, cursor }
}
pub fn maybe_show_onboarding(&mut self) {
if self.ui.modal != Modal::None {
return;
}
let name = self.page.name();
if self.ui.onboarding_dismissed.iter().any(|d| d == name) {
return;
}
self.ui.modal = Modal::Onboarding;
}
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
// Handle undo/redo before the undoable snapshot
match cmd {
@@ -1342,11 +1354,26 @@ impl App {
}
// Page navigation
AppCommand::PageLeft => self.page.left(),
AppCommand::PageRight => self.page.right(),
AppCommand::PageUp => self.page.up(),
AppCommand::PageDown => self.page.down(),
AppCommand::GoToPage(page) => self.page = page,
AppCommand::PageLeft => {
self.page.left();
self.maybe_show_onboarding();
}
AppCommand::PageRight => {
self.page.right();
self.maybe_show_onboarding();
}
AppCommand::PageUp => {
self.page.up();
self.maybe_show_onboarding();
}
AppCommand::PageDown => {
self.page.down();
self.maybe_show_onboarding();
}
AppCommand::GoToPage(page) => {
self.page = page;
self.maybe_show_onboarding();
}
// Help navigation
AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui),
@@ -1391,9 +1418,11 @@ impl App {
self.select_edit_bank(bank);
self.select_edit_pattern(pattern);
self.page.down();
self.maybe_show_onboarding();
}
AppCommand::PatternsBack => {
self.page.down();
self.maybe_show_onboarding();
}
// Mute/Solo (staged)
@@ -1418,6 +1447,7 @@ impl App {
}
AppCommand::HideTitle => {
self.ui.show_title = false;
self.maybe_show_onboarding();
}
AppCommand::ToggleEditorStack => {
self.editor_ctx.show_stack = !self.editor_ctx.show_stack;
@@ -1612,6 +1642,17 @@ impl App {
);
}
// Onboarding
AppCommand::DismissOnboarding => {
let name = self.page.name().to_string();
if !self.ui.onboarding_dismissed.contains(&name) {
self.ui.onboarding_dismissed.push(name);
}
}
AppCommand::ResetOnboarding => {
self.ui.onboarding_dismissed.clear();
}
// Prelude
AppCommand::OpenPreludeEditor => self.open_prelude_editor(),
AppCommand::SavePrelude => self.save_prelude(),

View File

@@ -143,6 +143,10 @@ struct CagireDesktop {
_analysis_handle: Option<AnalysisHandle>,
midi_rx: Receiver<MidiCommand>,
current_font: FontChoice,
zoom_factor: f32,
fullscreen: bool,
decorations: bool,
always_on_top: bool,
mouse_x: Arc<AtomicU32>,
mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>,
@@ -161,8 +165,10 @@ impl CagireDesktop {
let current_font = FontChoice::from_setting(&b.settings.display.font);
let terminal = create_terminal(current_font);
let zoom_factor = b.settings.display.zoom_factor;
cc.egui_ctx.set_visuals(egui::Visuals::dark());
cc.egui_ctx.set_zoom_factor(zoom_factor);
Self {
app: b.app,
@@ -180,6 +186,10 @@ impl CagireDesktop {
_analysis_handle: b.analysis_handle,
midi_rx: b.midi_rx,
current_font,
zoom_factor,
fullscreen: false,
decorations: true,
always_on_top: false,
mouse_x: b.mouse_x,
mouse_y: b.mouse_y,
mouse_down: b.mouse_down,
@@ -412,7 +422,15 @@ impl eframe::App for CagireDesktop {
}
let current_font = self.current_font;
let current_zoom = self.zoom_factor;
let current_fullscreen = self.fullscreen;
let current_decorations = self.decorations;
let current_always_on_top = self.always_on_top;
let mut new_font = None;
let mut new_zoom = None;
let mut toggle_fullscreen = false;
let mut toggle_decorations = false;
let mut toggle_always_on_top = false;
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
@@ -449,6 +467,38 @@ impl eframe::App for CagireDesktop {
}
}
});
ui.menu_button("Zoom", |ui| {
for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
let selected = (current_zoom - level).abs() < 0.01;
let label = format!("{:.0}%", level * 100.0);
if ui.selectable_label(selected, label).clicked() {
new_zoom = Some(level);
ui.close();
}
}
});
ui.separator();
if ui
.selectable_label(current_fullscreen, "Fullscreen")
.clicked()
{
toggle_fullscreen = true;
ui.close();
}
if ui
.selectable_label(current_always_on_top, "Always On Top")
.clicked()
{
toggle_always_on_top = true;
ui.close();
}
if ui
.selectable_label(!current_decorations, "Borderless")
.clicked()
{
toggle_decorations = true;
ui.close();
}
});
});
@@ -459,6 +509,30 @@ impl eframe::App for CagireDesktop {
settings.display.font = font.to_setting().to_string();
settings.save();
}
if let Some(zoom) = new_zoom {
self.zoom_factor = zoom;
ctx.set_zoom_factor(zoom);
let mut settings = Settings::load();
settings.display.zoom_factor = zoom;
settings.save();
}
if toggle_fullscreen {
self.fullscreen = !self.fullscreen;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
}
if toggle_always_on_top {
self.always_on_top = !self.always_on_top;
let level = if self.always_on_top {
egui::WindowLevel::AlwaysOnTop
} else {
egui::WindowLevel::Normal
};
ctx.send_viewport_cmd(egui::ViewportCommand::WindowLevel(level));
}
if toggle_decorations {
self.decorations = !self.decorations;
ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(self.decorations));
}
ctx.request_repaint_after(Duration::from_millis(
self.app.audio.config.refresh_rate.millis(),

View File

@@ -267,4 +267,8 @@ pub enum AppCommand {
SavePrelude,
EvaluatePrelude,
ClosePreludeEditor,
// Onboarding
DismissOnboarding,
ResetOnboarding,
}

View File

@@ -86,6 +86,7 @@ pub fn init(args: InitArgs) -> Init {
app.ui.color_scheme = settings.display.color_scheme;
app.ui.hue_rotation = settings.display.hue_rotation;
app.audio.config.layout = settings.display.layout;
app.ui.onboarding_dismissed = settings.display.onboarding_dismissed.clone();
let base_theme = settings.display.color_scheme.to_theme();
let rotated =

View File

@@ -158,6 +158,8 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
time: None,
});
}
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}

View File

@@ -3,7 +3,7 @@ use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::state::{ConfirmAction, DictFocus, HelpFocus, Modal};
use crate::state::{ConfirmAction, DictFocus, FlashKind, HelpFocus, Modal};
pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -26,7 +26,22 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
ctx.dispatch(AppCommand::HelpClearSearch);
}
KeyCode::Esc if ctx.app.ui.help_focused_block.is_some() => {
ctx.app.ui.help_focused_block = None;
}
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
KeyCode::Char('n') if ctx.app.ui.help_focus == HelpFocus::Content => {
navigate_code_block(ctx, true);
}
KeyCode::Char('p') if ctx.app.ui.help_focus == HelpFocus::Content => {
navigate_code_block(ctx, false);
}
KeyCode::Enter
if ctx.app.ui.help_focus == HelpFocus::Content
&& ctx.app.ui.help_focused_block.is_some() =>
{
execute_focused_block(ctx);
}
KeyCode::Char('j') | KeyCode::Down if ctrl => {
ctx.dispatch(AppCommand::HelpNextTopic(5));
}
@@ -49,6 +64,8 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
selected: false,
}));
}
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
@@ -62,6 +79,57 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
InputResult::Continue
}
fn navigate_code_block(ctx: &mut InputContext, forward: bool) {
let cache = ctx.app.ui.help_parsed.borrow();
let Some(parsed) = cache[ctx.app.ui.help_topic].as_ref() else {
return;
};
let count = parsed.code_blocks.len();
if count == 0 {
return;
}
let next = match ctx.app.ui.help_focused_block {
Some(cur) if forward => (cur + 1) % count,
Some(0) if !forward => count - 1,
Some(cur) if !forward => cur - 1,
_ if forward => 0,
_ => count - 1,
};
let scroll_to = parsed.code_blocks[next].start_line.saturating_sub(2);
drop(cache);
ctx.app.ui.help_focused_block = Some(next);
*ctx.app.ui.help_scroll_mut() = scroll_to;
}
fn execute_focused_block(ctx: &mut InputContext) {
let source = {
let cache = ctx.app.ui.help_parsed.borrow();
let Some(parsed) = cache[ctx.app.ui.help_topic].as_ref() else {
return;
};
let idx = ctx.app.ui.help_focused_block.unwrap();
let Some(block) = parsed.code_blocks.get(idx) else {
return;
};
block.source.clone()
};
let cleaned: String = source
.lines()
.map(|l| l.split(" => ").next().unwrap_or(l))
.collect::<Vec<_>>()
.join("\n");
match ctx
.app
.execute_script_oneshot(&cleaned, ctx.link, ctx.audio_tx)
{
Ok(()) => ctx.app.ui.flash("Executed", 100, FlashKind::Info),
Err(e) => ctx
.app
.ui
.flash(&format!("Error: {e}"), 200, FlashKind::Error),
}
}
pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -100,6 +168,8 @@ pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
selected: false,
}));
}
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}

View File

@@ -84,18 +84,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
ctx.dispatch(AppCommand::OpenModal(Modal::Editor));
}
KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleSteps),
KeyCode::Char('s') => {
use crate::state::file_browser::FileBrowserState;
let initial = ctx
.app
.project_state
.file_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('z') if ctrl && !shift => {
ctx.dispatch(AppCommand::Undo);
}
@@ -115,25 +104,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
ctx.dispatch(AppCommand::DuplicateSteps);
}
KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps),
KeyCode::Char('l') => {
use crate::state::file_browser::FileBrowserState;
let default_dir = ctx
.app
.project_state
.file_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| {
let mut s = p.display().to_string();
if !s.ends_with('/') {
s.push('/');
}
s
})
.unwrap_or_default();
let state = FileBrowserState::new_load(default_dir);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
KeyCode::Char('T') => {

View File

@@ -147,6 +147,39 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
}
fn open_save(ctx: &mut InputContext) {
use crate::state::file_browser::FileBrowserState;
let initial = ctx
.app
.project_state
.file_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
fn open_load(ctx: &mut InputContext) {
use crate::state::file_browser::FileBrowserState;
let default_dir = ctx
.app
.project_state
.file_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| {
let mut s = p.display().to_string();
if !s.ends_with('/') {
s.push('/');
}
s
})
.unwrap_or_default();
let state = FileBrowserState::new_load(default_dir);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
fn load_project_samples(ctx: &mut InputContext) {
let paths = ctx.app.project_state.project.sample_paths.clone();
if paths.is_empty() {

View File

@@ -525,6 +525,14 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => {}
}
}
Modal::Onboarding => match key.code {
KeyCode::Enter => {
ctx.dispatch(AppCommand::DismissOnboarding);
ctx.dispatch(AppCommand::CloseModal);
ctx.app.save_settings(ctx.link);
}
_ => ctx.dispatch(AppCommand::CloseModal),
},
Modal::None => unreachable!(),
}
InputResult::Continue

View File

@@ -87,6 +87,9 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
}
}
}
OptionsFocus::ResetOnboarding => {
ctx.dispatch(AppCommand::ResetOnboarding);
}
OptionsFocus::MidiInput0
| OptionsFocus::MidiInput1
| OptionsFocus::MidiInput2
@@ -162,6 +165,8 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}

View File

@@ -249,6 +249,8 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}

View File

@@ -27,6 +27,7 @@ pub const DOCS: &[DocEntry] = &[
Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
Topic("The Stack", include_str!("../../docs/stack.md")),
Topic("Creating Words", include_str!("../../docs/definitions.md")),
Topic("Control Flow", include_str!("../../docs/control_flow.md")),
Topic("The Prelude", include_str!("../../docs/prelude.md")),
Topic("Oddities", include_str!("../../docs/oddities.md")),
// Audio Engine
@@ -68,6 +69,7 @@ pub const DOCS: &[DocEntry] = &[
include_str!("../../docs/tutorial_generators.md"),
),
Topic("Timing with at", include_str!("../../docs/tutorial_at.md")),
Topic("Using Variables", include_str!("../../docs/tutorial_variables.md")),
];
pub fn topic_count() -> usize {

View File

@@ -91,4 +91,72 @@ impl Page {
*self = page;
}
}
pub const fn onboarding(self) -> (&'static str, &'static [(&'static str, &'static str)]) {
match self {
Page::Main => (
"The step sequencer. Each cell holds a Forth script. When playing, active steps are evaluated in order to produce sound. The grid shows step numbers, names, link indicators, and highlights the currently playing step.",
&[
("Arrows", "navigate"),
("Enter", "edit script"),
("Space", "play/stop"),
("t", "toggle step"),
("p", "preview"),
("Tab", "samples"),
("?", "all keys"),
],
),
Page::Patterns => (
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. Stage patterns to play or stop, mute them, solo them, change their settings. Stage / commit system to apply all changes at once.",
&[
("Arrows", "navigate"),
("Enter", "open in sequencer"),
("Space", "stage play/stop"),
("c", "commit changes"),
("r", "rename"),
("e", "properties"),
("?", "all keys"),
],
),
Page::Engine => (
"Audio engine configuration. Select output and input devices, adjust buffer size and polyphony, and manage sample directories. The right side shows a live scope and spectrum analyzer.",
&[
("Tab", "switch section"),
("↑↓", "navigate"),
("←→", "adjust"),
("R", "restart engine"),
("A", "add samples"),
("?", "all keys"),
],
),
Page::Options => (
"Global settings for display, UI, Link sync, MIDI, etc. All changes save automatically. Tutorial can be reset from here!",
&[
("↑↓", "navigate"),
("←→", "change value"),
("?", "all keys"),
],
),
Page::Help => (
"Interactive documentation with executable Forth examples. Browse topics on the left, read content on the right. Code blocks can be run directly and evaluated by the sequencer engine.",
&[
("Tab", "switch panels"),
("↑↓", "navigate"),
("Enter", "run code block"),
("n/p", "next/prev example"),
("/", "search"),
("?", "all keys"),
],
),
Page::Dict => (
"Complete reference of all Forth words by category. Each entry shows the word name, stack effect signature, description, and a usage example. Search filters across all categories.",
&[
("Tab", "switch panels"),
("↑↓", "navigate"),
("/", "search"),
("?", "all keys"),
],
),
}
}
}

View File

@@ -17,11 +17,13 @@ pub fn select_topic(ui: &mut UiState, index: usize) {
pub fn next_topic(ui: &mut UiState, n: usize) {
let count = docs::topic_count();
ui.help_topic = (ui.help_topic + n) % count;
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;
ui.help_focused_block = None;
}
pub fn scroll_down(ui: &mut UiState, n: usize) {

View File

@@ -45,18 +45,26 @@ pub struct DisplaySettings {
pub show_completion: bool,
#[serde(default = "default_font")]
pub font: String,
#[serde(default = "default_zoom")]
pub zoom_factor: f32,
#[serde(default)]
pub color_scheme: ColorScheme,
#[serde(default)]
pub layout: MainLayout,
#[serde(default)]
pub hue_rotation: f32,
#[serde(default)]
pub onboarding_dismissed: Vec<String>,
}
fn default_font() -> String {
"8x13".to_string()
}
fn default_zoom() -> f32 {
1.5
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LinkSettings {
pub enabled: bool,
@@ -88,9 +96,11 @@ impl Default for DisplaySettings {
show_preview: true,
show_completion: true,
font: default_font(),
zoom_factor: default_zoom(),
color_scheme: ColorScheme::default(),
layout: MainLayout::default(),
hue_rotation: 0.0,
onboarding_dismissed: Vec::new(),
}
}
}

View File

@@ -90,4 +90,5 @@ pub enum Modal {
steps: String,
rotation: String,
},
Onboarding,
}

View File

@@ -22,6 +22,7 @@ pub enum OptionsFocus {
MidiInput1,
MidiInput2,
MidiInput3,
ResetOnboarding,
}
impl CyclicEnum for OptionsFocus {
@@ -45,6 +46,7 @@ impl CyclicEnum for OptionsFocus {
Self::MidiInput1,
Self::MidiInput2,
Self::MidiInput3,
Self::ResetOnboarding,
];
}
@@ -68,6 +70,7 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[
(OptionsFocus::MidiInput1, 33),
(OptionsFocus::MidiInput2, 34),
(OptionsFocus::MidiInput3, 35),
(OptionsFocus::ResetOnboarding, 39),
];
impl OptionsFocus {

View File

@@ -1,6 +1,7 @@
use std::cell::RefCell;
use std::time::{Duration, Instant};
use cagire_markdown::ParsedMarkdown;
use cagire_ratatui::Sparkles;
use tachyonfx::{fx, Effect, EffectManager, Interpolation};
@@ -49,6 +50,8 @@ pub struct UiState {
pub help_scrolls: Vec<usize>,
pub help_search_active: bool,
pub help_search_query: String,
pub help_focused_block: Option<usize>,
pub help_parsed: RefCell<Vec<Option<ParsedMarkdown>>>,
pub dict_focus: DictFocus,
pub dict_category: usize,
pub dict_scrolls: Vec<usize>,
@@ -66,6 +69,7 @@ pub struct UiState {
pub prev_modal_open: bool,
pub prev_page: Page,
pub prev_show_title: bool,
pub onboarding_dismissed: Vec<String>,
}
impl Default for UiState {
@@ -81,6 +85,8 @@ impl Default for UiState {
help_scrolls: vec![0; crate::model::docs::topic_count()],
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()),
dict_focus: DictFocus::default(),
dict_category: 0,
dict_scrolls: vec![0; crate::model::categories::category_count()],
@@ -98,6 +104,7 @@ impl Default for UiState {
prev_modal_open: false,
prev_page: Page::default(),
prev_show_title: true,
onboarding_dismissed: Vec::new(),
}
}
}

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