From e73ee1eb1e1b1942bc7b840e702ec3f04783a880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 1 Mar 2026 00:58:26 +0100 Subject: [PATCH] Fix: UI/UX --- .gitignore | 1 + assets/DMG-README.txt | 21 ++++--- crates/ratatui/src/sample_browser.rs | 57 +++++++++++++++--- docs/engine/samples.md | 8 ++- src/app/dispatch.rs | 2 +- src/app/persistence.rs | 1 + src/bin/desktop/main.rs | 3 +- src/commands.rs | 2 +- src/init.rs | 8 ++- src/input/engine_page.rs | 13 +++- src/input/mod.rs | 10 +++- src/input/modal.rs | 4 +- src/main.rs | 5 +- src/settings.rs | 5 ++ src/state/audio.rs | 34 +++++++++-- src/views/engine_view.rs | 90 ++++++++++++++++++---------- src/views/render.rs | 5 +- 17 files changed, 196 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 2fb16d8..5da52be 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.cache *.prof .DS_Store +releases/ # Local cargo overrides (doux path patch) .cargo/config.local.toml diff --git a/assets/DMG-README.txt b/assets/DMG-README.txt index bc94aac..240ccdf 100644 --- a/assets/DMG-README.txt +++ b/assets/DMG-README.txt @@ -1,19 +1,18 @@ -Cagire - A Forth-based music sequencer -Made by BuboBubo and his friends -====================================== +# Cagire - A Forth-based music sequencer + +## Installation -Installation ------------- Drag Cagire.app into the Applications folder. -Unquarantine ------------- +## Unquarantine + Since this app is not signed with an Apple Developer certificate, -macOS will block it from running. To fix this, open Terminal and run: +macOS will block it from running. Thanks Apple! To fix this, open +Terminal and run: xattr -cr /Applications/Cagire.app -Support -------- -If you enjoy Cagire, consider supporting development: +## Support + +If you enjoy this software, consider supporting development: https://ko-fi.com/raphaelbubo diff --git a/crates/ratatui/src/sample_browser.rs b/crates/ratatui/src/sample_browser.rs index faa8f55..5594e97 100644 --- a/crates/ratatui/src/sample_browser.rs +++ b/crates/ratatui/src/sample_browser.rs @@ -4,7 +4,7 @@ use crate::theme; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; /// Node type in the sample tree. @@ -116,13 +116,13 @@ impl<'a> SampleBrowser<'a> { fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) { let height = area.height as usize; if self.entries.is_empty() { - let msg = if self.search_query.is_empty() { - "No samples loaded" + if self.search_query.is_empty() { + self.render_empty_guide(frame, area, colors); } else { - "No matches" - }; - let line = Line::from(Span::styled(msg, Style::new().fg(colors.browser.empty_text))); - frame.render_widget(Paragraph::new(vec![line]), area); + let line = + Line::from(Span::styled("No matches", Style::new().fg(colors.browser.empty_text))); + frame.render_widget(Paragraph::new(vec![line]), area); + } return; } @@ -179,4 +179,47 @@ impl<'a> SampleBrowser<'a> { frame.render_widget(Paragraph::new(lines), area); } + + fn render_empty_guide(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) { + let muted = Style::new().fg(colors.browser.empty_text); + let heading = Style::new().fg(colors.ui.text_primary); + let key = Style::new().fg(colors.hint.key); + let desc = Style::new().fg(colors.hint.text); + let code = Style::new().fg(colors.ui.accent); + + let lines = vec![ + Line::from(Span::styled(" No samples loaded.", muted)), + Line::from(""), + Line::from(Span::styled(" Load from the Engine page:", heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" F6 ", key), + Span::styled("Go to Engine page", desc), + ]), + Line::from(vec![ + Span::styled(" A ", key), + Span::styled("Add a sample folder", desc), + ]), + Line::from(""), + Line::from(Span::styled(" Organize samples like this:", heading)), + Line::from(""), + Line::from(Span::styled(" samples/", code)), + Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} kick/", code)), + Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} kick.wav", code)), + Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} snare/", code)), + Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} snare.wav", code)), + Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} hats/", code)), + Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} closed.wav", code)), + Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} open.wav", code)), + Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} pedal.wav", code)), + Line::from(""), + Line::from(Span::styled(" Folders become Forth words:", heading)), + Line::from(""), + Line::from(Span::styled(" kick sound .", code)), + Line::from(Span::styled(" hats sound 2 n .", code)), + Line::from(Span::styled(" snare sound 0.5 speed .", code)), + ]; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); + } } diff --git a/docs/engine/samples.md b/docs/engine/samples.md index 6cef145..5e70b9c 100644 --- a/docs/engine/samples.md +++ b/docs/engine/samples.md @@ -20,15 +20,17 @@ The engine scans these directories and builds a registry of available samples. S ``` samples/ -├── kick.wav → "kick" -├── snare.wav → "snare" +├── kick/ → "kick" +│ └── kick.wav +├── snare/ → "snare" +│ └── snare.wav └── hats/ ├── closed.wav → "hats" n 0 ├── open.wav → "hats" n 1 └── pedal.wav → "hats" n 2 ``` -Folders at the root of your directory are used as the name of a sample bank. Folders create sample banks where each file gets an index. Files are sorted alphabetically and assigned indices starting from `0`. +Folders at the root of your sample directory become sample banks named after the folder. Each file within a folder gets an index. Files are sorted alphabetically and assigned indices starting from `0`. ## Playing Samples diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 5d9e468..762af3a 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -407,7 +407,7 @@ impl App { } } AppCommand::AudioTriggerRestart => self.audio.trigger_restart(), - AppCommand::RemoveLastSamplePath => self.audio.remove_last_sample_path(), + AppCommand::RemoveSamplePath(index) => self.audio.remove_sample_path(index), AppCommand::AudioRefreshDevices => self.audio.refresh_devices(), // Options page diff --git a/src/app/persistence.rs b/src/app/persistence.rs index 9c7f459..f053be9 100644 --- a/src/app/persistence.rs +++ b/src/app/persistence.rs @@ -21,6 +21,7 @@ impl App { channels: self.audio.config.channels, buffer_size: self.audio.config.buffer_size, max_voices: self.audio.config.max_voices, + sample_paths: self.audio.config.sample_paths.clone(), }, display: crate::settings::DisplaySettings { fps: self.audio.config.refresh_rate.to_fps(), diff --git a/src/bin/desktop/main.rs b/src/bin/desktop/main.rs index cf7e648..cf6850e 100644 --- a/src/bin/desktop/main.rs +++ b/src/bin/desktop/main.rs @@ -245,11 +245,12 @@ impl CagireDesktop { self.stream_error_rx = new_error_rx; let mut restart_samples = Vec::new(); + self.app.audio.config.sample_counts.clear(); for path in &self.app.audio.config.sample_paths { let index = doux::sampling::scan_samples_dir(path); + self.app.audio.config.sample_counts.push(index.len()); restart_samples.extend(index); } - self.app.audio.config.sample_count = restart_samples.len(); self.audio_sample_pos.store(0, Ordering::Release); diff --git a/src/commands.rs b/src/commands.rs index abd8cee..3647568 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -267,7 +267,7 @@ pub enum AppCommand { delta: i32, }, AudioTriggerRestart, - RemoveLastSamplePath, + RemoveSamplePath(usize), AudioRefreshDevices, // Options page diff --git a/src/init.rs b/src/init.rs index 0d044bc..9f3bf3f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -104,7 +104,11 @@ pub fn init(args: InitArgs) -> Init { app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels); app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size); app.audio.config.max_voices = settings.audio.max_voices; - app.audio.config.sample_paths = args.samples; + app.audio.config.sample_paths = if args.samples.is_empty() { + settings.audio.sample_paths.clone() + } else { + args.samples + }; app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps); app.ui.runtime_highlight = settings.display.runtime_highlight; app.audio.config.show_scope = settings.display.show_scope; @@ -154,7 +158,7 @@ pub fn init(args: InitArgs) -> Init { let mut initial_samples = Vec::new(); for path in &app.audio.config.sample_paths { let index = doux::sampling::scan_samples_dir(path); - app.audio.config.sample_count += index.len(); + app.audio.config.sample_counts.push(index.len()); initial_samples.extend(index); } let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index 2ec02bb..be16e42 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -49,6 +49,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input EngineSection::Settings => { ctx.dispatch(AppCommand::AudioSettingPrev); } + EngineSection::Samples => { + ctx.app.audio.sample_list.move_up(); + } _ => {} }, KeyCode::Down => match ctx.app.audio.section { @@ -65,6 +68,10 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input EngineSection::Settings => { ctx.dispatch(AppCommand::AudioSettingNext); } + EngineSection::Samples => { + let count = ctx.app.audio.config.sample_paths.len(); + ctx.app.audio.sample_list.move_down(count); + } _ => {} }, KeyCode::PageUp => { @@ -128,14 +135,16 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char('R') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::AudioTriggerRestart); } - KeyCode::Char('A') => { + KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => { use crate::state::file_browser::FileBrowserState; let state = FileBrowserState::new_load(String::new()); ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state)))); } KeyCode::Char('D') => { if ctx.app.audio.section == EngineSection::Samples { - ctx.dispatch(AppCommand::RemoveLastSamplePath); + let cursor = ctx.app.audio.sample_list.cursor; + ctx.dispatch(AppCommand::RemoveSamplePath(cursor)); + ctx.app.save_settings(ctx.link); } else if !ctx.app.plugin_mode { ctx.dispatch(AppCommand::AudioRefreshDevices); let out_count = ctx.app.audio.output_devices.len(); diff --git a/src/input/mod.rs b/src/input/mod.rs index 1e71422..bfa2179 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -132,7 +132,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } if ctrl { - let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); + let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(1000)); match key.code { KeyCode::Left => { ctx.app.ui.minimap = minimap_timed; @@ -168,7 +168,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::F(7) => Some(Page::Script), _ => None, } { - ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); + ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(1000)); ctx.dispatch(AppCommand::GoToPage(page)); return InputResult::Continue; } @@ -224,21 +224,25 @@ fn load_project_samples(ctx: &mut InputContext) { } let mut total_count = 0; + let mut counts = Vec::new(); let mut all_preload_entries = Vec::new(); for path in &paths { if path.is_dir() { let index = doux::sampling::scan_samples_dir(path); let count = index.len(); total_count += count; + counts.push(count); for e in &index { all_preload_entries.push((e.name.clone(), e.path.clone())); } let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); + } else { + counts.push(0); } } ctx.app.audio.config.sample_paths = paths; - ctx.app.audio.config.sample_count = total_count; + ctx.app.audio.config.sample_counts = counts; for path in &ctx.app.audio.config.sample_paths { if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) { diff --git a/src/input/modal.rs b/src/input/modal.rs index 3758b0a..6888d4e 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -245,8 +245,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) { let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path)); } - ctx.app.audio.config.sample_count += count; - ctx.app.audio.add_sample_path(path); + ctx.app.audio.add_sample_path(path, count); if let Some(registry) = ctx.app.audio.sample_registry.clone() { let sr = ctx.app.audio.config.sample_rate; std::thread::Builder::new() @@ -256,6 +255,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input }) .expect("failed to spawn preload thread"); } + ctx.app.save_settings(ctx.link); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); ctx.dispatch(AppCommand::CloseModal); } diff --git a/src/main.rs b/src/main.rs index 6013cce..46f31ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,11 +134,12 @@ fn main() -> io::Result<()> { stream_error_rx = new_error_rx; let mut restart_samples = Vec::new(); + app.audio.config.sample_counts.clear(); for path in &app.audio.config.sample_paths { let index = doux::sampling::scan_samples_dir(path); + app.audio.config.sample_counts.push(index.len()); restart_samples.extend(index); } - app.audio.config.sample_count = restart_samples.len(); audio_sample_pos.store(0, Ordering::Relaxed); @@ -340,7 +341,7 @@ fn main() -> io::Result<()> { || app.ui.modal_fx.borrow().is_some() || app.ui.title_fx.borrow().is_some() || app.ui.nav_fx.borrow().is_some(); - if app.playback.playing || had_event || app.ui.show_title || effects_active { + if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() { if app.ui.show_title { app.ui.sparkles.tick(terminal.get_frame().area()); } diff --git a/src/settings.rs b/src/settings.rs index 50286bd..d318b32 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; use crate::state::{ColorScheme, MainLayout}; @@ -30,6 +32,8 @@ pub struct AudioSettings { pub buffer_size: u32, #[serde(default = "default_max_voices")] pub max_voices: usize, + #[serde(default)] + pub sample_paths: Vec, } fn default_max_voices() -> usize { 32 } @@ -97,6 +101,7 @@ impl Default for AudioSettings { channels: 2, buffer_size: 512, max_voices: 32, + sample_paths: Vec::new(), } } } diff --git a/src/state/audio.rs b/src/state/audio.rs index 20a4dd5..aba0317 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -113,7 +113,7 @@ pub struct AudioConfig { pub sample_rate: f32, pub host_name: String, pub sample_paths: Vec, - pub sample_count: usize, + pub sample_counts: Vec, pub refresh_rate: RefreshRate, pub show_scope: bool, pub show_spectrum: bool, @@ -140,7 +140,7 @@ impl Default for AudioConfig { sample_rate: 44100.0, host_name: String::new(), sample_paths: Vec::new(), - sample_count: 0, + sample_counts: Vec::new(), refresh_rate: RefreshRate::default(), show_scope: true, show_spectrum: true, @@ -275,6 +275,7 @@ pub struct AudioSettings { pub input_devices: Vec, pub output_list: ListSelectState, pub input_list: ListSelectState, + pub sample_list: ListSelectState, pub restart_pending: bool, pub error: Option, pub sample_registry: Option>, @@ -297,6 +298,10 @@ impl Default for AudioSettings { cursor: 0, scroll_offset: 0, }, + sample_list: ListSelectState { + cursor: 0, + scroll_offset: 0, + }, restart_pending: false, error: None, sample_registry: None, @@ -321,6 +326,10 @@ impl AudioSettings { cursor: 0, scroll_offset: 0, }, + sample_list: ListSelectState { + cursor: 0, + scroll_offset: 0, + }, restart_pending: false, error: None, sample_registry: None, @@ -429,14 +438,29 @@ impl AudioSettings { self.config.refresh_rate = self.config.refresh_rate.toggle(); } - pub fn add_sample_path(&mut self, path: PathBuf) { + pub fn total_sample_count(&self) -> usize { + self.config.sample_counts.iter().sum() + } + + pub fn add_sample_path(&mut self, path: PathBuf, count: usize) { if !self.config.sample_paths.contains(&path) { self.config.sample_paths.push(path); + self.config.sample_counts.push(count); } } - pub fn remove_last_sample_path(&mut self) { - self.config.sample_paths.pop(); + pub fn remove_sample_path(&mut self, index: usize) { + if index < self.config.sample_paths.len() { + self.config.sample_paths.remove(index); + self.config.sample_counts.remove(index); + let len = self.config.sample_paths.len(); + if len == 0 { + self.sample_list.cursor = 0; + self.sample_list.scroll_offset = 0; + } else if self.sample_list.cursor >= len { + self.sample_list.cursor = len - 1; + } + } } pub fn trigger_restart(&mut self) { diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 570316d..6eb599a 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -48,6 +48,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { }; // Calculate section heights + let intro_lines: usize = 3; let plugin_mode = app.plugin_mode; let devices_lines = if plugin_mode { 0 @@ -55,17 +56,19 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { devices_section_height(app) as usize }; let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows - let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1) + let sample_content = app.audio.config.sample_paths.len().max(2); // at least 2 for empty message + let samples_lines: usize = 2 + sample_content; // header(2) + content let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with - let total_lines = devices_lines + settings_lines + samples_lines + sections_gap; + let total_lines = intro_lines + 1 + devices_lines + settings_lines + samples_lines + sections_gap; let max_visible = padded.height as usize; // Calculate scroll offset based on focused section - let settings_start = if plugin_mode { 0 } else { devices_lines + 1 }; + let intro_offset = intro_lines + 1; + let settings_start = if plugin_mode { intro_offset } else { intro_offset + devices_lines + 1 }; let (focus_start, focus_height) = match app.audio.section { - EngineSection::Devices => (0, devices_lines), + EngineSection::Devices => (intro_offset, devices_lines), EngineSection::Settings => (settings_start, settings_lines), EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines), }; @@ -86,6 +89,29 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { let mut y = viewport_top - scroll_offset as i32; + // Intro text + let intro_top = y; + let intro_bottom = y + intro_lines as i32; + if intro_bottom > viewport_top && intro_top < viewport_bottom { + let clipped_y = intro_top.max(viewport_top) as u16; + let clipped_height = + (intro_bottom.min(viewport_bottom) - intro_top.max(viewport_top)) as u16; + let intro_area = Rect { + x: padded.x, + y: clipped_y, + width: padded.width, + height: clipped_height, + }; + let dim = Style::new().fg(theme.engine.dim); + let intro = Paragraph::new(vec![ + Line::from(Span::styled(" Audio devices, settings, and sample paths.", dim)), + Line::from(Span::styled(" Supports .wav, .ogg, .mp3 samples and .sf2 soundfonts.", dim)), + Line::from(Span::styled(" Press R to restart the audio engine after changes.", dim)), + ]); + frame.render_widget(intro, intro_area); + } + y += intro_lines as i32 + 1; + // Devices section (skip in plugin mode) if !plugin_mode { let devices_top = y; @@ -495,21 +521,26 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let section_focused = app.audio.section == EngineSection::Samples; - let [header_area, content_area, _, hint_area] = Layout::vertical([ + let [header_area, content_area] = Layout::vertical([ Constraint::Length(2), Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(1), ]) .areas(area); let path_count = app.audio.config.sample_paths.len(); - let sample_count = app.audio.config.sample_count; + let sample_count: usize = app.audio.total_sample_count(); let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed"); render_section_header(frame, &header_text, section_focused, header_area); let dim = Style::new().fg(theme.engine.dim); let path_style = Style::new().fg(theme.engine.path); + let cursor_style = Style::new() + .fg(theme.engine.focused) + .add_modifier(Modifier::BOLD); + + let cursor = app.audio.sample_list.cursor; + let scroll_offset = app.audio.sample_list.scroll_offset; + let visible_rows = content_area.height as usize; let mut lines: Vec = Vec::new(); if app.audio.config.sample_paths.is_empty() { @@ -522,35 +553,32 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) { dim, ))); } else { - for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { + for (i, path) in app + .audio + .config + .sample_paths + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_rows) + { + let is_cursor = section_focused && i == cursor; + let prefix = if is_cursor { "> " } else { " " }; + let count = app.audio.config.sample_counts.get(i).copied().unwrap_or(0); let path_str = path.to_string_lossy(); - let display = truncate_name(&path_str, 40); + let count_str = format!(" ({count})"); + let max_path = (content_area.width as usize) + .saturating_sub(prefix.len() + count_str.len()); + let display = truncate_name(&path_str, max_path); + let style = if is_cursor { cursor_style } else { path_style }; lines.push(Line::from(vec![ - Span::styled(format!(" {} ", i + 1), dim), - Span::styled(display, path_style), + Span::styled(prefix.to_string(), if is_cursor { cursor_style } else { dim }), + Span::styled(display, style), + Span::styled(count_str, dim), ])); } - if path_count > 4 { - lines.push(Line::from(Span::styled( - format!(" ... and {} more", path_count - 4), - dim, - ))); - } } frame.render_widget(Paragraph::new(lines), content_area); - - let hint_style = if section_focused { - Style::new().fg(theme.engine.hint_active) - } else { - Style::new().fg(theme.engine.hint_inactive) - }; - let hint = Line::from(vec![ - Span::styled("A", hint_style), - Span::styled(":add ", Style::new().fg(theme.engine.dim)), - Span::styled("D", hint_style), - Span::styled(":remove", Style::new().fg(theme.engine.dim)), - ]); - frame.render_widget(Paragraph::new(hint), hint_area); } fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { diff --git a/src/views/render.rs b/src/views/render.rs index d40af42..2e21250 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -551,15 +551,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { Page::Patterns => vec![ ("Enter", "Select"), ("Space", "Play"), + ("c", "Commit"), ("r", "Rename"), ("?", "Keys"), ], Page::Engine => vec![ ("Tab", "Section"), ("←→", "Switch/Adjust"), - ("Enter", "Select"), + ("A", "Add Samples"), + ("D", "Remove"), ("R", "Restart"), - ("h", "Hush"), ("?", "Keys"), ], Page::Options => vec![