diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a3318..1c9d40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. - Double-stack words: `2dup`, `2drop`, `2swap`, `2over`. - `forget` word to remove user-defined words from the dictionary. +### Fixed +- Scope/spectrum ratio asymmetry in Left/Right layout modes. + ## [0.0.3] - 2026-02-02 ### Added diff --git a/crates/ratatui/src/active_patterns.rs b/crates/ratatui/src/active_patterns.rs new file mode 100644 index 0000000..33f5b6a --- /dev/null +++ b/crates/ratatui/src/active_patterns.rs @@ -0,0 +1,73 @@ +use crate::theme; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; + +pub struct ActivePatterns<'a> { + patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter) + current_step: Option<(usize, usize)>, // (current_step, total_steps) +} + +impl<'a> ActivePatterns<'a> { + pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self { + Self { + patterns, + current_step: None, + } + } + + pub fn with_step(mut self, current: usize, total: usize) -> Self { + self.current_step = Some((current, total)); + self + } +} + +impl Widget for ActivePatterns<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width < 10 || area.height == 0 { + return; + } + let theme = theme::get(); + + let max_pattern_rows = if self.current_step.is_some() { + area.height.saturating_sub(1) as usize + } else { + area.height as usize + }; + + for (row, &(bank, pattern, iter)) in self.patterns.iter().enumerate() { + if row >= max_pattern_rows { + break; + } + let text = format!("B{:02}:{:02} ({:02})", bank + 1, pattern + 1, iter.min(99)); + let y = area.y + row as u16; + let bg = if row % 2 == 0 { + theme.table.row_even + } else { + theme.table.row_odd + }; + + let mut chars = text.chars(); + for col in 0..area.width as usize { + let ch = chars.next().unwrap_or(' '); + buf[(area.x + col as u16, y)] + .set_char(ch) + .set_fg(theme.ui.text_primary) + .set_bg(bg); + } + } + + if let Some((current, total)) = self.current_step { + let text = format!("{:02}/{:02}", current + 1, total); + let y = area.y + area.height.saturating_sub(1); + let mut chars = text.chars(); + for col in 0..area.width as usize { + let ch = chars.next().unwrap_or(' '); + buf[(area.x + col as u16, y)] + .set_char(ch) + .set_fg(theme.ui.text_primary) + .set_bg(theme.table.row_even); + } + } + } +} diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 7df8371..b72a00a 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -1,3 +1,4 @@ +mod active_patterns; mod confirm; mod editor; mod file_browser; @@ -12,6 +13,7 @@ mod text_input; pub mod theme; mod vu_meter; +pub use active_patterns::ActivePatterns; pub use confirm::ConfirmModal; pub use editor::{CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; diff --git a/src/app.rs b/src/app.rs index 71b245a..683de7a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -110,6 +110,7 @@ impl App { show_completion: self.ui.show_completion, flash_brightness: self.ui.flash_brightness, color_scheme: self.ui.color_scheme, + layout: self.audio.config.layout, ..Default::default() }, link: crate::settings::LinkSettings { diff --git a/src/input.rs b/src/input.rs index 3d3fac6..8effb6c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{ - DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField, - SampleBrowserState, SettingKind, SidePanel, + CyclicEnum, DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, + PatternPropsField, SampleBrowserState, SettingKind, SidePanel, }; pub enum InputResult { @@ -956,6 +956,9 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR name: current_name, })); } + KeyCode::Char('o') => { + ctx.app.audio.config.layout = ctx.app.audio.config.layout.next(); + } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } diff --git a/src/main.rs b/src/main.rs index 90d6484..b8e6c40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,7 @@ fn main() -> io::Result<()> { app.ui.show_completion = settings.display.show_completion; app.ui.flash_brightness = settings.display.flash_brightness; app.ui.color_scheme = settings.display.color_scheme; + app.audio.config.layout = settings.display.layout; theme::set(settings.display.color_scheme.to_theme()); // Load MIDI settings diff --git a/src/settings.rs b/src/settings.rs index f7c84c8..7bd6934 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::state::ColorScheme; +use crate::state::{ColorScheme, MainLayout}; const APP_NAME: &str = "cagire"; @@ -50,6 +50,8 @@ pub struct DisplaySettings { pub font: String, #[serde(default)] pub color_scheme: ColorScheme, + #[serde(default)] + pub layout: MainLayout, } fn default_font() -> String { @@ -91,6 +93,7 @@ impl Default for DisplaySettings { flash_brightness: 1.0, font: default_font(), color_scheme: ColorScheme::default(), + layout: MainLayout::default(), } } } diff --git a/src/state/audio.rs b/src/state/audio.rs index d0da46f..6cf527d 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -1,8 +1,22 @@ use doux::audio::AudioDeviceInfo; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; use super::CyclicEnum; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum MainLayout { + #[default] + Top, + Bottom, + Left, + Right, +} + +impl CyclicEnum for MainLayout { + const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right]; +} + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum RefreshRate { #[default] @@ -62,6 +76,7 @@ pub struct AudioConfig { pub show_scope: bool, pub show_spectrum: bool, pub lookahead_ms: u32, + pub layout: MainLayout, } impl Default for AudioConfig { @@ -79,6 +94,7 @@ impl Default for AudioConfig { show_scope: true, show_spectrum: true, lookahead_ms: 15, + layout: MainLayout::default(), } } } diff --git a/src/state/mod.rs b/src/state/mod.rs index d6bb6c4..5e6d384 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -27,7 +27,7 @@ pub mod project; pub mod sample_browser; pub mod ui; -pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind}; +pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind}; pub use color_scheme::ColorScheme; pub use editor::{ CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache, diff --git a/src/views/main_view.rs b/src/views/main_view.rs index c8fef1b..18b2492 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -1,65 +1,117 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; +use crate::state::MainLayout; use crate::theme; -use crate::widgets::{Orientation, Scope, Spectrum, VuMeter}; +use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter}; pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { - let [left_area, _spacer, vu_area] = Layout::horizontal([ + let [patterns_area, _, main_area, _, vu_area] = Layout::horizontal([ + Constraint::Length(13), + Constraint::Length(2), Constraint::Fill(1), Constraint::Length(2), - Constraint::Length(8), + Constraint::Length(10), ]) .areas(area); let show_scope = app.audio.config.show_scope; let show_spectrum = app.audio.config.show_spectrum; - let viz_height = if show_scope || show_spectrum { 14 } else { 0 }; + let has_viz = show_scope || show_spectrum; + let layout = app.audio.config.layout; - let [viz_area, sequencer_area] = Layout::vertical([ - Constraint::Length(viz_height), - Constraint::Fill(1), - ]) - .areas(left_area); + let (viz_area, sequencer_area) = match layout { + MainLayout::Top => { + let viz_height = if has_viz { 16 } else { 0 }; + let [viz, seq] = Layout::vertical([ + Constraint::Length(viz_height), + Constraint::Fill(1), + ]) + .areas(main_area); + (viz, seq) + } + MainLayout::Bottom => { + let viz_height = if has_viz { 16 } else { 0 }; + let [seq, viz] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(viz_height), + ]) + .areas(main_area); + (viz, seq) + } + MainLayout::Left => { + let viz_width = if has_viz { 33 } else { 0 }; + let [viz, _spacer, seq] = Layout::horizontal([ + Constraint::Percentage(viz_width), + Constraint::Length(2), + Constraint::Fill(1), + ]) + .areas(main_area); + (viz, seq) + } + MainLayout::Right => { + let viz_width = if has_viz { 33 } else { 0 }; + let [seq, _spacer, viz] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Percentage(viz_width), + ]) + .areas(main_area); + (viz, seq) + } + }; - if show_scope && show_spectrum { - let [scope_area, _, spectrum_area] = Layout::horizontal([ - Constraint::Percentage(50), - Constraint::Length(2), - Constraint::Percentage(50), - ]) - .areas(viz_area); - render_scope(frame, app, scope_area); - render_spectrum(frame, app, spectrum_area); - } else if show_scope { - render_scope(frame, app, viz_area); - } else if show_spectrum { - render_spectrum(frame, app, viz_area); + if has_viz { + render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum); } render_sequencer(frame, app, snapshot, sequencer_area); + render_vu_meter(frame, app, vu_area); + render_active_patterns(frame, app, snapshot, patterns_area); +} - // Calculate actual grid height to align VU meter - let pattern = app.current_edit_pattern(); - let page = app.editor_ctx.step / STEPS_PER_PAGE; - let page_start = page * STEPS_PER_PAGE; - let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern.length) - page_start; - let num_rows = steps_on_page.div_ceil(8); - let spacing = num_rows.saturating_sub(1) as u16; - let row_height = sequencer_area.height.saturating_sub(spacing) / num_rows as u16; - let actual_grid_height = row_height * num_rows as u16 + spacing; +fn render_viz_area( + frame: &mut Frame, + app: &App, + area: Rect, + layout: MainLayout, + show_scope: bool, + show_spectrum: bool, +) { + let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right); - let aligned_vu_area = Rect { - y: sequencer_area.y, - height: actual_grid_height, - ..vu_area - }; - - render_vu_meter(frame, app, aligned_vu_area); + if show_scope && show_spectrum { + if is_vertical_layout { + let [scope_area, spectrum_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Fill(1), + ]) + .areas(area); + render_scope(frame, app, scope_area, Orientation::Vertical); + render_spectrum(frame, app, spectrum_area); + } else { + let [scope_area, spectrum_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Fill(1), + ]) + .areas(area); + render_scope(frame, app, scope_area, Orientation::Horizontal); + render_spectrum(frame, app, spectrum_area); + } + } else if show_scope { + let orientation = if is_vertical_layout { + Orientation::Vertical + } else { + Orientation::Horizontal + }; + render_scope(frame, app, area, orientation); + } else if show_spectrum { + render_spectrum(frame, app, area); + } } const STEPS_PER_PAGE: usize = 32; @@ -233,21 +285,65 @@ fn render_tile( } } -fn render_scope(frame: &mut Frame, app: &App, area: Rect) { +fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) { let theme = theme::get(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + let scope = Scope::new(&app.metrics.scope) - .orientation(Orientation::Horizontal) + .orientation(orientation) .color(theme.meter.low); - frame.render_widget(scope, area); + frame.render_widget(scope, inner); } fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { - let area = Rect { height: area.height.saturating_sub(1), ..area }; + let theme = theme::get(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + let spectrum = Spectrum::new(&app.metrics.spectrum); - frame.render_widget(spectrum, area); + frame.render_widget(spectrum, inner); } fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { + let theme = theme::get(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); - frame.render_widget(vu, area); + frame.render_widget(vu, inner); +} + +fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + let theme = theme::get(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let patterns: Vec<(usize, usize, usize)> = snapshot + .active_patterns + .iter() + .map(|p| (p.bank, p.pattern, p.iter)) + .collect(); + + let step_info = snapshot + .get_step(app.editor_ctx.bank, app.editor_ctx.pattern) + .map(|step| (step, app.current_edit_pattern().length)); + + let mut widget = ActivePatterns::new(&patterns); + if let Some((step, total)) = step_info { + widget = widget.with_step(step, total); + } + frame.render_widget(widget, inner); } diff --git a/src/views/title_view.rs b/src/views/title_view.rs index 75f375d..a1f11d3 100644 --- a/src/views/title_view.rs +++ b/src/views/title_view.rs @@ -3,6 +3,7 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +#[cfg(not(feature = "desktop"))] use tui_big_text::{BigText, PixelSize}; use crate::state::ui::UiState; @@ -16,13 +17,21 @@ 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::Quadrant) + .pixel_size(PixelSize::Full) .style(Style::new().fg(theme.title.big_title).bold()) .lines(vec!["CAGIRE".into()]) .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![ @@ -49,21 +58,43 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { )), ]; - let big_text_height = 4; + #[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 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); + let show_big_title = + area.height >= (big_text_height + subtitle_height) && area.width >= min_title_width; - frame.render_widget(big_title, title_area); + if show_big_title { + let total_height = big_text_height + subtitle_height; + let vertical_padding = area.height.saturating_sub(total_height) / 2; - let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center); - frame.render_widget(subtitle, subtitle_area); + 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); + } } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 1727cad..35e5668 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,4 +1,4 @@ pub use cagire_ratatui::{ - ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, - Scope, Spectrum, TextInputModal, VuMeter, + ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, + SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, };