diff --git a/Cargo.toml b/Cargo.toml index 8dd311d..f540704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ path = "src/main.rs" [[bin]] name = "cagire-desktop" -path = "src/bin/desktop.rs" +path = "src/bin/desktop/main.rs" required-features = ["desktop"] [features] @@ -41,6 +41,7 @@ desktop = [ "dep:eframe", "dep:egui_ratatui", "dep:soft_ratatui", + "dep:rustc-hash", "dep:image", ] @@ -77,6 +78,7 @@ egui = { version = "0.33", optional = true } eframe = { version = "0.33", optional = true } egui_ratatui = { version = "2.1", optional = true } soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true } +rustc-hash = { version = "2", optional = true } image = { version = "0.25", default-features = false, features = ["png"], optional = true } diff --git a/src/app.rs b/src/app.rs index 798012c..91a7eea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1126,7 +1126,7 @@ impl App { if self.ui.onboarding_dismissed.iter().any(|d| d == name) { return; } - self.ui.modal = Modal::Onboarding; + self.ui.modal = Modal::Onboarding { page: 0 }; } pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { @@ -1652,6 +1652,11 @@ impl App { AppCommand::ResetOnboarding => { self.ui.onboarding_dismissed.clear(); } + AppCommand::GoToHelpTopic(topic) => { + self.ui.modal = Modal::None; + self.page = Page::Help; + self.ui.help_topic = topic; + } // Prelude AppCommand::OpenPreludeEditor => self.open_prelude_editor(), diff --git a/src/bin/desktop/block_renderer.rs b/src/bin/desktop/block_renderer.rs new file mode 100644 index 0000000..cc19331 --- /dev/null +++ b/src/bin/desktop/block_renderer.rs @@ -0,0 +1,267 @@ +//! Programmatic rendering of Unicode block elements for the desktop backend. +//! +//! Real terminals render block characters (█, ▀, ▄, quadrants, sextants) as +//! pixel-perfect filled rectangles. The bitmap font backend can't guarantee +//! gap-free fills and lacks sextant glyphs entirely. This wrapper intercepts +//! block element code points and draws them directly on the pixmap, delegating +//! everything else to EmbeddedGraphics. + +use ratatui::buffer::Cell; +use ratatui::style::{Color, Modifier}; +use rustc_hash::FxHashSet; +use soft_ratatui::{EmbeddedGraphics, RasterBackend, RgbPixmap}; + +pub struct BlockCharBackend { + pub inner: EmbeddedGraphics, +} + +impl RasterBackend for BlockCharBackend { + fn draw_cell( + &mut self, + x: u16, + y: u16, + cell: &Cell, + always_redraw_list: &mut FxHashSet<(u16, u16)>, + blinking_fast: bool, + blinking_slow: bool, + char_width: usize, + char_height: usize, + rgb_pixmap: &mut RgbPixmap, + ) { + let cp = cell.symbol().chars().next().unwrap_or(' ') as u32; + + if !is_block_element(cp) { + self.inner.draw_cell( + x, + y, + cell, + always_redraw_list, + blinking_fast, + blinking_slow, + char_width, + char_height, + rgb_pixmap, + ); + return; + } + + let (fg, bg) = resolve_colors(cell, always_redraw_list, x, y, blinking_fast, blinking_slow); + let px = x as usize * char_width; + let py = y as usize * char_height; + + fill_rect(rgb_pixmap, px, py, char_width, char_height, bg); + draw_block_element(rgb_pixmap, cp, px, py, char_width, char_height, fg); + } +} + +// --------------------------------------------------------------------------- +// Block element classification and drawing +// --------------------------------------------------------------------------- + +fn is_block_element(cp: u32) -> bool { + matches!(cp, 0x2580..=0x2590 | 0x2594..=0x259F | 0x1FB00..=0x1FB3B) +} + +fn draw_block_element( + pixmap: &mut RgbPixmap, + cp: u32, + px: usize, + py: usize, + cw: usize, + ch: usize, + fg: [u8; 3], +) { + match cp { + 0x2580 => fill_rect(pixmap, px, py, cw, ch / 2, fg), + 0x2581..=0x2587 => { + let n = (cp - 0x2580) as usize; + let h = ch * n / 8; + fill_rect(pixmap, px, py + ch - h, cw, h, fg); + } + 0x2588 => fill_rect(pixmap, px, py, cw, ch, fg), + 0x2589..=0x258F => { + let n = (0x2590 - cp) as usize; + fill_rect(pixmap, px, py, cw * n / 8, ch, fg); + } + 0x2590 => { + let hw = cw / 2; + fill_rect(pixmap, px + hw, py, cw - hw, ch, fg); + } + 0x2594 => fill_rect(pixmap, px, py, cw, (ch / 8).max(1), fg), + 0x2595 => { + let w = (cw / 8).max(1); + fill_rect(pixmap, px + cw - w, py, w, ch, fg); + } + 0x2596..=0x259F => draw_quadrants(pixmap, px, py, cw, ch, fg, cp), + 0x1FB00..=0x1FB3B => draw_sextants(pixmap, px, py, cw, ch, fg, cp), + _ => unreachable!(), + } +} + +// --------------------------------------------------------------------------- +// Quadrants (U+2596-U+259F): 2x2 grid +// --------------------------------------------------------------------------- + +// Bits: 3=UL, 2=UR, 1=LL, 0=LR +const QUADRANT: [u8; 10] = [ + 0b0010, // ▖ LL + 0b0001, // ▗ LR + 0b1000, // ▘ UL + 0b1011, // ▙ UL+LL+LR + 0b1001, // ▚ UL+LR + 0b1110, // ▛ UL+UR+LL + 0b1101, // ▜ UL+UR+LR + 0b0100, // ▝ UR + 0b0110, // ▞ UR+LL + 0b0111, // ▟ UR+LL+LR +]; + +fn draw_quadrants( + pixmap: &mut RgbPixmap, + px: usize, + py: usize, + cw: usize, + ch: usize, + fg: [u8; 3], + cp: u32, +) { + let pattern = QUADRANT[(cp - 0x2596) as usize]; + let hw = cw / 2; + let hh = ch / 2; + let rw = cw - hw; + let rh = ch - hh; + if pattern & 0b1000 != 0 { fill_rect(pixmap, px, py, hw, hh, fg); } + if pattern & 0b0100 != 0 { fill_rect(pixmap, px + hw, py, rw, hh, fg); } + if pattern & 0b0010 != 0 { fill_rect(pixmap, px, py + hh, hw, rh, fg); } + if pattern & 0b0001 != 0 { fill_rect(pixmap, px + hw, py + hh, rw, rh, fg); } +} + +// --------------------------------------------------------------------------- +// Sextants (U+1FB00-U+1FB3B): 2x3 grid +// --------------------------------------------------------------------------- + +// Bit layout: 0=TL, 1=TR, 2=ML, 3=MR, 4=BL, 5=BR +// The 60 characters encode patterns 1-62, skipping 0 (space), 21 (left half), +// 42 (right half), and 63 (full block) which exist as standard block elements. +fn sextant_pattern(cp: u32) -> u8 { + let mut p = (cp - 0x1FB00) as u8 + 1; + if p >= 21 { p += 1; } + if p >= 42 { p += 1; } + p +} + +fn draw_sextants( + pixmap: &mut RgbPixmap, + px: usize, + py: usize, + cw: usize, + ch: usize, + fg: [u8; 3], + cp: u32, +) { + let pattern = sextant_pattern(cp); + let hw = cw / 2; + let rw = cw - hw; + let h0 = ch / 3; + let h1 = (ch - h0) / 2; + let h2 = ch - h0 - h1; + let y1 = py + h0; + let y2 = y1 + h1; + + if pattern & 0b000001 != 0 { fill_rect(pixmap, px, py, hw, h0, fg); } + if pattern & 0b000010 != 0 { fill_rect(pixmap, px + hw, py, rw, h0, fg); } + if pattern & 0b000100 != 0 { fill_rect(pixmap, px, y1, hw, h1, fg); } + if pattern & 0b001000 != 0 { fill_rect(pixmap, px + hw, y1, rw, h1, fg); } + if pattern & 0b010000 != 0 { fill_rect(pixmap, px, y2, hw, h2, fg); } + if pattern & 0b100000 != 0 { fill_rect(pixmap, px + hw, y2, rw, h2, fg); } +} + +// --------------------------------------------------------------------------- +// Pixel operations +// --------------------------------------------------------------------------- + +fn fill_rect(pixmap: &mut RgbPixmap, x0: usize, y0: usize, w: usize, h: usize, color: [u8; 3]) { + let pw = pixmap.width; + let x_end = (x0 + w).min(pw); + let y_end = (y0 + h).min(pixmap.height); + let data = &mut pixmap.data; + for y in y0..y_end { + let start = 3 * (y * pw + x0); + let end = 3 * (y * pw + x_end); + for chunk in data[start..end].chunks_exact_mut(3) { + chunk.copy_from_slice(&color); + } + } +} + +// --------------------------------------------------------------------------- +// Color resolution (mirrors soft_ratatui::colors which is private) +// --------------------------------------------------------------------------- + +fn resolve_colors( + cell: &Cell, + always_redraw_list: &mut FxHashSet<(u16, u16)>, + x: u16, + y: u16, + blinking_fast: bool, + blinking_slow: bool, +) -> ([u8; 3], [u8; 3]) { + let mut fg = color_to_rgb(&cell.fg, true); + let mut bg = color_to_rgb(&cell.bg, false); + + for modifier in cell.modifier.iter() { + match modifier { + Modifier::DIM => { + fg = dim_rgb(fg); + bg = dim_rgb(bg); + } + Modifier::REVERSED => std::mem::swap(&mut fg, &mut bg), + Modifier::HIDDEN => fg = bg, + Modifier::SLOW_BLINK => { + always_redraw_list.insert((x, y)); + if blinking_slow { fg = bg; } + } + Modifier::RAPID_BLINK => { + always_redraw_list.insert((x, y)); + if blinking_fast { fg = bg; } + } + _ => {} + } + } + + (fg, bg) +} + +fn color_to_rgb(color: &Color, is_fg: bool) -> [u8; 3] { + match color { + Color::Reset if is_fg => [204, 204, 255], + Color::Reset => [5, 1, 121], + Color::Black => [0, 0, 0], + Color::Red => [139, 0, 0], + Color::Green => [0, 100, 0], + Color::Yellow => [255, 215, 0], + Color::Blue => [0, 0, 139], + Color::Magenta => [255, 0, 255], + Color::Cyan => [0, 0, 255], + Color::Gray => [128, 128, 128], + Color::DarkGray => [64, 64, 64], + Color::LightRed => [255, 0, 0], + Color::LightGreen => [0, 255, 0], + Color::LightBlue => [173, 216, 230], + Color::LightYellow => [255, 255, 224], + Color::LightMagenta => [139, 0, 139], + Color::LightCyan => [224, 255, 255], + Color::White => [255, 255, 255], + Color::Indexed(i) => [i.wrapping_mul(*i), i.wrapping_add(*i), *i], + Color::Rgb(r, g, b) => [*r, *g, *b], + } +} + +fn dim_rgb(c: [u8; 3]) -> [u8; 3] { + const F: u32 = 77; // ~30% brightness + [ + ((c[0] as u32 * F + 127) / 255) as u8, + ((c[1] as u32 * F + 127) / 255) as u8, + ((c[2] as u32 * F + 127) / 255) as u8, + ] +} diff --git a/src/bin/desktop.rs b/src/bin/desktop/main.rs similarity index 96% rename from src/bin/desktop.rs rename to src/bin/desktop/main.rs index 0b7393f..2bcff71 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop/main.rs @@ -1,7 +1,10 @@ +mod block_renderer; + use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; +use block_renderer::BlockCharBackend; use clap::Parser; use doux::EngineMetrics; use eframe::NativeOptions; @@ -99,7 +102,7 @@ impl FontChoice { ]; } -type TerminalType = Terminal>; +type TerminalType = Terminal>; fn create_terminal(font: FontChoice) -> TerminalType { let (regular, bold, italic) = match font { @@ -123,7 +126,22 @@ fn create_terminal(font: FontChoice) -> TerminalType { FontChoice::Size10x20 => (mono_10x20_atlas(), None, None), }; - let soft = SoftBackend::::new(80, 24, regular, bold, italic); + let eg = SoftBackend::::new(80, 24, regular, bold, italic); + let soft = SoftBackend { + buffer: eg.buffer, + cursor: eg.cursor, + cursor_pos: eg.cursor_pos, + char_width: eg.char_width, + char_height: eg.char_height, + blink_counter: eg.blink_counter, + blinking_fast: eg.blinking_fast, + blinking_slow: eg.blinking_slow, + rgb_pixmap: eg.rgb_pixmap, + always_redraw_list: eg.always_redraw_list, + raster_backend: BlockCharBackend { + inner: eg.raster_backend, + }, + }; Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") } @@ -547,7 +565,7 @@ impl eframe::App for CagireDesktop { } fn load_icon() -> egui::IconData { - const ICON_BYTES: &[u8] = include_bytes!("../../assets/Cagire.png"); + const ICON_BYTES: &[u8] = include_bytes!("../../../assets/Cagire.png"); let img = image::load_from_memory(ICON_BYTES) .expect("Failed to load embedded icon") diff --git a/src/commands.rs b/src/commands.rs index ecd2abf..bab13b9 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -271,4 +271,5 @@ pub enum AppCommand { // Onboarding DismissOnboarding, ResetOnboarding, + GoToHelpTopic(usize), } diff --git a/src/input/modal.rs b/src/input/modal.rs index eae94b0..27e1453 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -525,14 +525,37 @@ 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); + Modal::Onboarding { .. } => { + let pages = ctx.app.page.onboarding(); + let page_count = pages.len(); + match key.code { + KeyCode::Right | KeyCode::Char('l') if page_count > 1 => { + if let Modal::Onboarding { page } = &mut ctx.app.ui.modal { + if *page + 1 < page_count { + *page += 1; + } + } + } + KeyCode::Left | KeyCode::Char('h') if page_count > 1 => { + if let Modal::Onboarding { page } = &mut ctx.app.ui.modal { + *page = page.saturating_sub(1); + } + } + KeyCode::Char('?') | KeyCode::F(1) => { + if let Some(topic) = ctx.app.page.help_topic_index() { + ctx.dispatch(AppCommand::GoToHelpTopic(topic)); + } else { + ctx.dispatch(AppCommand::CloseModal); + } + } + KeyCode::Enter => { + ctx.dispatch(AppCommand::DismissOnboarding); + ctx.dispatch(AppCommand::CloseModal); + ctx.app.save_settings(ctx.link); + } + _ => ctx.dispatch(AppCommand::CloseModal), } - _ => ctx.dispatch(AppCommand::CloseModal), - }, + } Modal::None => unreachable!(), } InputResult::Continue diff --git a/src/page.rs b/src/page.rs index 344bf8c..142dfe5 100644 --- a/src/page.rs +++ b/src/page.rs @@ -92,33 +92,60 @@ impl Page { } } - pub const fn onboarding(self) -> (&'static str, &'static [(&'static str, &'static str)]) { + pub const fn onboarding(self) -> &'static [(&'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 => ( + Page::Main => &[ + ( + "The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern. The left panel shows playing patterns, the right side shows VU meters.", + &[ + ("Arrows", "navigate grid"), + ("Space", "play / stop"), + ("Enter", "edit step script"), + ("t", "toggle step on/off"), + ("p", "preview script"), + ("Tab", "sample browser"), + ("?", "all keybindings"), + ], + ), + ( + "Enter opens the script editor (Esc saves and closes). Select ranges with Shift+arrows for bulk operations. Linked steps share one script: edit the source and all links update. Adjust pattern length/speed directly, or use euclidean distribution to spread a step rhythmically.", + &[ + ("Shift+Arrows", "select range"), + ("Ctrl+C / V", "copy / paste steps"), + ("Ctrl+D", "duplicate steps"), + ("Ctrl+B", "paste as linked copies"), + ("< > / [ ]", "length / speed"), + ("e", "euclidean distribution"), + ("+ - / T", "tempo adjust / set"), + ], + ), + ], + 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, then commit 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"), + ], + ), + ( + "Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.", + &[ + ("m", "stage mute"), + ("s", "stage solo"), + ("E", "euclidean"), + ("Shift+↑↓", "select range"), + ("y", "copy"), + ("P", "paste"), + ], + ), + ], + 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"), @@ -128,16 +155,16 @@ impl Page { ("A", "add samples"), ("?", "all keys"), ], - ), - Page::Options => ( + )], + 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 => ( + )], + 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"), @@ -147,8 +174,8 @@ impl Page { ("/", "search"), ("?", "all keys"), ], - ), - Page::Dict => ( + )], + 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"), @@ -156,7 +183,18 @@ impl Page { ("/", "search"), ("?", "all keys"), ], - ), + )], + } + } + + pub const fn help_topic_index(self) -> Option { + match self { + Page::Main => Some(5), // "Using the Sequencer" + Page::Patterns => Some(3), // "Banks & Patterns" + Page::Engine => Some(14), // "Introduction" (Audio Engine) + Page::Help => Some(0), // "Welcome" + Page::Dict => Some(7), // "About Forth" + Page::Options => None, } } } diff --git a/src/state/modal.rs b/src/state/modal.rs index a96a3ac..8f30ebf 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -90,5 +90,5 @@ pub enum Modal { steps: String, rotation: String, }, - Onboarding, + Onboarding { page: usize }, } diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 8a329ce..0b7d207 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -4,7 +4,6 @@ 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; @@ -129,10 +128,7 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) { } 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(); @@ -146,19 +142,12 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { 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", @@ -166,12 +155,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { ))) .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); diff --git a/src/views/render.rs b/src/views/render.rs index 7323a85..8bc2d45 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -601,20 +601,38 @@ 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 + Modal::Onboarding { page } => { + let pages = app.page.onboarding(); + let page_idx = (*page).min(pages.len().saturating_sub(1)); + let (desc, keys) = pages[page_idx]; + let page_count = pages.len(); + let text_width = 51usize; 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); + let mut col = 0usize; + for word in line.split_whitespace() { + let wlen = word.len(); + if col > 0 && col + 1 + wlen > text_width { + lines += 1; + col = wlen; + } else { + col += if col > 0 { 1 + wlen } else { wlen }; + } + } + lines += 1; } 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 modal_height = (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4)); - let inner = ModalFrame::new(&format!(" {} ", app.page.name())) + let title = if page_count > 1 { + format!(" {} ({}/{}) ", app.page.name(), page_idx + 1, page_count) + } else { + format!(" {} ", app.page.name()) + }; + let inner = ModalFrame::new(&title) .width(57) .height(modal_height) .border_color(theme.modal.confirm) @@ -650,7 +668,15 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term } 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")]); + let mut hints_vec: Vec<(&str, &str)> = Vec::new(); + if page_count > 1 { + hints_vec.push(("\u{2190}\u{2192}", "page")); + } + if app.page.help_topic_index().is_some() { + hints_vec.push(("?", "help")); + } + hints_vec.push(("Enter", "don't show again")); + let hints = hint_line(&hints_vec); frame.render_widget(Paragraph::new(hints).alignment(Alignment::Center), hint_area); inner diff --git a/src/views/title_view.rs b/src/views/title_view.rs index 1a46772..7a2786c 100644 --- a/src/views/title_view.rs +++ b/src/views/title_view.rs @@ -1,9 +1,8 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Cell, Paragraph, Row, Table}; use ratatui::Frame; -#[cfg(not(feature = "desktop"))] use tui_big_text::{BigText, PixelSize}; use crate::state::ui::UiState; @@ -17,7 +16,6 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { let link_style = Style::new().fg(theme.title.link); let license_style = Style::new().fg(theme.title.license); - #[cfg(not(feature = "desktop"))] let big_title = BigText::builder() .pixel_size(PixelSize::Full) .style(Style::new().fg(theme.title.big_title).bold()) @@ -25,99 +23,111 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { .centered() .build(); - #[cfg(feature = "desktop")] - let big_title = Paragraph::new(Line::from(Span::styled( - "CAGIRE", - Style::new().fg(theme.title.big_title).bold(), - ))) - .alignment(Alignment::Center); - - let version_style = Style::new().fg(theme.title.subtitle); - - let subtitle_lines = vec![ + let info_lines = vec![ Line::from(""), - Line::from(Span::styled( - "A Forth Music Sequencer", - Style::new().fg(theme.title.subtitle), - )), + Line::from(vec![ + Span::styled("A Forth Music Sequencer by ", Style::new().fg(theme.title.subtitle)), + Span::styled("BuboBubo", author_style), + ]), Line::from(Span::styled( format!("v{}", env!("CARGO_PKG_VERSION")), - version_style, + Style::new().fg(theme.title.subtitle), )), Line::from(""), - Line::from(Span::styled("by BuboBubo", author_style)), - Line::from(""), Line::from(Span::styled("https://raphaelforment.fr", link_style)), Line::from(""), 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.subtitle), - )), ]; - #[cfg(not(feature = "desktop"))] - let big_text_height = 8; - #[cfg(feature = "desktop")] - let big_text_height = 1; - let min_title_width = 30; - let subtitle_height = subtitle_lines.len() as u16; + let keybindings = [ + ("Ctrl+Arrows", "Navigate Views"), + ("Enter", "Edit Step"), + ("Space", "Play/Stop"), + ("s", "Save"), + ("l", "Load"), + ("q", "Quit"), + ("?", "Keybindings"), + ]; + let key_style = Style::new().fg(theme.modal.confirm); + let desc_style = Style::new().fg(theme.ui.text_primary); + let rows: Vec = keybindings + .iter() + .enumerate() + .map(|(i, (key, desc))| { + let bg = if i % 2 == 0 { + theme.table.row_even + } else { + theme.table.row_odd + }; + Row::new(vec![ + Cell::from(*key).style(key_style), + Cell::from(*desc).style(desc_style), + ]) + .style(Style::new().bg(bg)) + }) + .collect(); + + let table = Table::new( + rows, + [Constraint::Length(14), Constraint::Fill(1)], + ) + .column_spacing(2); + + let press_line = Line::from(Span::styled( + "Press any key to continue", + Style::new().fg(theme.title.subtitle), + )); + + let info_height = info_lines.len() as u16; + let table_height = keybindings.len() as u16; + let table_width: u16 = 42; + + let big_text_height: u16 = 8; + + let content_height = info_height + table_height + 3; // +3 for gap + empty line + press line let show_big_title = - area.height >= (big_text_height + subtitle_height) && area.width >= min_title_width; + area.height >= (big_text_height + content_height) && area.width >= 30; + + let total_height = if show_big_title { + big_text_height + content_height + } else { + content_height + }; + let v_pad = area.height.saturating_sub(total_height) / 2; + + let mut constraints = Vec::new(); + constraints.push(Constraint::Length(v_pad)); + if show_big_title { + constraints.push(Constraint::Length(big_text_height)); + } + constraints.push(Constraint::Length(info_height)); + constraints.push(Constraint::Length(1)); // gap + constraints.push(Constraint::Length(table_height)); + constraints.push(Constraint::Length(1)); // empty line + constraints.push(Constraint::Length(1)); // press any key + constraints.push(Constraint::Fill(1)); + + let areas = Layout::vertical(constraints).split(area); + let mut idx = 1; // skip padding if show_big_title { - let total_height = big_text_height + subtitle_height; - let vertical_padding = area.height.saturating_sub(total_height) / 2; - - let [_, title_area, subtitle_area, _] = Layout::vertical([ - Constraint::Length(vertical_padding), - Constraint::Length(big_text_height), - Constraint::Length(subtitle_height), - Constraint::Fill(1), - ]) - .areas(area); - - frame.render_widget(big_title, title_area); - - let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center); - frame.render_widget(subtitle, subtitle_area); - } else { - let vertical_padding = area.height.saturating_sub(subtitle_height) / 2; - - let [_, subtitle_area, _] = Layout::vertical([ - Constraint::Length(vertical_padding), - Constraint::Length(subtitle_height), - Constraint::Fill(1), - ]) - .areas(area); - - let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center); - frame.render_widget(subtitle, subtitle_area); + frame.render_widget(big_title, areas[idx]); + idx += 1; } + + let info = Paragraph::new(info_lines).alignment(Alignment::Center); + frame.render_widget(info, areas[idx]); + idx += 1; + + idx += 1; // skip gap + + let tw = table_width.min(areas[idx].width); + let tx = areas[idx].x + (areas[idx].width.saturating_sub(tw)) / 2; + let table_area = Rect::new(tx, areas[idx].y, tw, areas[idx].height); + frame.render_widget(table, table_area); + idx += 2; // skip empty line + + let press = Paragraph::new(press_line).alignment(Alignment::Center); + frame.render_widget(press, areas[idx]); }