diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 114144b..8e8246d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ name: CI on: + workflow_dispatch: push: - branches: [main] tags: ['v*'] pull_request: branches: [main] diff --git a/Cargo.toml b/Cargo.toml index df220f6..7cb086a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,20 +14,34 @@ path = "src/lib.rs" name = "cagire" path = "src/main.rs" +[[bin]] +name = "cagire-desktop" +path = "src/bin/desktop.rs" +required-features = ["desktop"] + +[features] +default = [] +desktop = [ + "egui", + "eframe", + "egui_ratatui", + "soft_ratatui", +] + [dependencies] cagire-forth = { path = "crates/forth" } cagire-project = { path = "crates/project" } cagire-ratatui = { path = "crates/ratatui" } doux = { git = "https://github.com/sova-org/doux", features = ["native"] } rusty_link = "0.4" -ratatui = "0.29" -crossterm = "0.28" +ratatui = "0.30" +crossterm = "0.29" cpal = "0.15" clap = { version = "4", features = ["derive"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" -tui-big-text = "0.7" +tui-big-text = "0.8" arboard = "3" minimad = "0.13" crossbeam-channel = "0.5" @@ -37,6 +51,12 @@ thread-priority = "1" ringbuf = "0.4" arc-swap = "1" +# Desktop-only dependencies (behind feature flag) +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 } + [profile.release] opt-level = 3 lto = "fat" diff --git a/README.md b/README.md index 341bac2..a7d9485 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,28 @@ A Forth Music Sequencer. ## Build +Terminal version: ``` cargo build --release ``` +Desktop version (with egui window): +``` +cargo build --release --features desktop --bin cagire-desktop +``` + ## Run +Terminal version: ``` cargo run --release ``` +Desktop version: +``` +cargo run --release --features desktop --bin cagire-desktop +``` + ## License AGPL-3.0 diff --git a/crates/ratatui/Cargo.toml b/crates/ratatui/Cargo.toml index 2d02998..2a2f231 100644 --- a/crates/ratatui/Cargo.toml +++ b/crates/ratatui/Cargo.toml @@ -5,6 +5,6 @@ edition = "2021" [dependencies] rand = "0.8" -ratatui = "0.29" +ratatui = "0.30" regex = "1" -tui-textarea = { version = "0.7", features = ["search"] } +tui-textarea = { git = "https://github.com/phsym/tui-textarea", branch = "main", features = ["search"] } diff --git a/crates/ratatui/src/confirm.rs b/crates/ratatui/src/confirm.rs index 1332426..2843685 100644 --- a/crates/ratatui/src/confirm.rs +++ b/crates/ratatui/src/confirm.rs @@ -1,5 +1,6 @@ +use crate::theme::confirm; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; @@ -25,7 +26,7 @@ impl<'a> ConfirmModal<'a> { let inner = ModalFrame::new(self.title) .width(30) .height(5) - .border_color(Color::Yellow) + .border_color(confirm::BORDER) .render_centered(frame, term); let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); @@ -36,12 +37,12 @@ impl<'a> ConfirmModal<'a> { ); let yes_style = if self.selected { - Style::new().fg(Color::Black).bg(Color::Yellow) + Style::new().fg(confirm::BUTTON_SELECTED_FG).bg(confirm::BUTTON_SELECTED_BG) } else { Style::default() }; let no_style = if !self.selected { - Style::new().fg(Color::Black).bg(Color::Yellow) + Style::new().fg(confirm::BUTTON_SELECTED_FG).bg(confirm::BUTTON_SELECTED_BG) } else { Style::default() }; diff --git a/crates/ratatui/src/editor.rs b/crates/ratatui/src/editor.rs index e50a883..eeaa5d8 100644 --- a/crates/ratatui/src/editor.rs +++ b/crates/ratatui/src/editor.rs @@ -1,6 +1,7 @@ +use crate::theme::editor_widget; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph}, Frame, @@ -333,8 +334,8 @@ impl Editor { pub fn render(&self, frame: &mut Frame, area: Rect, highlighter: Highlighter) { let (cursor_row, cursor_col) = self.text.cursor(); - let cursor_style = Style::default().bg(Color::White).fg(Color::Black); - let selection_style = Style::default().bg(Color::Rgb(60, 80, 120)); + let cursor_style = Style::default().bg(editor_widget::CURSOR_BG).fg(editor_widget::CURSOR_FG); + let selection_style = Style::default().bg(editor_widget::SELECTION_BG); let selection = self.text.selection_range(); @@ -412,9 +413,9 @@ impl Editor { let list_area = Rect::new(popup_x, popup_y, list_width, total_height); frame.render_widget(Clear, list_area); - let highlight_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let normal_style = Style::default().fg(Color::White); - let bg_style = Style::default().bg(Color::Rgb(30, 30, 40)); + let highlight_style = Style::default().fg(editor_widget::COMPLETION_SELECTED).add_modifier(Modifier::BOLD); + let normal_style = Style::default().fg(editor_widget::COMPLETION_FG); + let bg_style = Style::default().bg(editor_widget::COMPLETION_BG); let list_lines: Vec = (scroll_offset..scroll_offset + visible_count) .map(|i| { @@ -427,7 +428,7 @@ impl Editor { }; let prefix = if i == self.completion.cursor { "> " } else { " " }; let display = format!("{prefix}{name: = Vec::new(); diff --git a/crates/ratatui/src/file_browser.rs b/crates/ratatui/src/file_browser.rs index 0f82d7e..c756733 100644 --- a/crates/ratatui/src/file_browser.rs +++ b/crates/ratatui/src/file_browser.rs @@ -1,5 +1,7 @@ +use crate::theme::{browser, input, ui}; +use ratatui::style::Color; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; @@ -25,7 +27,7 @@ impl<'a> FileBrowserModal<'a> { entries, selected: 0, scroll_offset: 0, - border_color: Color::White, + border_color: ui::TEXT_PRIMARY, width: 60, height: 16, } @@ -69,8 +71,8 @@ impl<'a> FileBrowserModal<'a> { frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw("> "), - Span::styled(self.input, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), + Span::styled(self.input, Style::new().fg(input::TEXT)), + Span::styled("█", Style::new().fg(input::CURSOR)), ])), rows[0], ); @@ -95,13 +97,13 @@ impl<'a> FileBrowserModal<'a> { format!("{prefix}{name}") }; let color = if is_selected { - Color::Yellow + browser::SELECTED } else if *is_dir { - Color::Blue + browser::DIRECTORY } else if *is_cagire { - Color::Magenta + browser::PROJECT_FILE } else { - Color::White + browser::FILE }; Line::from(Span::styled(display, Style::new().fg(color))) }) diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 6e648e4..7df8371 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -9,6 +9,7 @@ mod scope; mod sparkles; mod spectrum; mod text_input; +pub mod theme; mod vu_meter; pub use confirm::ConfirmModal; diff --git a/crates/ratatui/src/list_select.rs b/crates/ratatui/src/list_select.rs index 2d4a527..e728db9 100644 --- a/crates/ratatui/src/list_select.rs +++ b/crates/ratatui/src/list_select.rs @@ -1,5 +1,6 @@ +use crate::theme::{hint, ui}; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; @@ -50,10 +51,10 @@ impl<'a> ListSelect<'a> { } pub fn render(self, frame: &mut Frame, area: Rect) { - let cursor_style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let selected_style = Style::new().fg(Color::Cyan); + let cursor_style = Style::new().fg(hint::KEY).add_modifier(Modifier::BOLD); + let selected_style = Style::new().fg(ui::ACCENT); let normal_style = Style::default(); - let indicator_style = Style::new().fg(Color::DarkGray); + let indicator_style = Style::new().fg(ui::TEXT_DIM); let visible_end = (self.scroll_offset + self.visible_count).min(self.items.len()); let has_above = self.scroll_offset > 0; diff --git a/crates/ratatui/src/modal.rs b/crates/ratatui/src/modal.rs index f3443fa..f09b565 100644 --- a/crates/ratatui/src/modal.rs +++ b/crates/ratatui/src/modal.rs @@ -1,6 +1,7 @@ +use crate::theme::ui; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Clear}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; pub struct ModalFrame<'a> { @@ -16,7 +17,7 @@ impl<'a> ModalFrame<'a> { title, width: 40, height: 5, - border_color: Color::White, + border_color: ui::TEXT_PRIMARY, } } @@ -45,6 +46,16 @@ impl<'a> ModalFrame<'a> { frame.render_widget(Clear, area); + // Fill background with theme color + let bg_fill = " ".repeat(area.width as usize); + for row in 0..area.height { + let line_area = Rect::new(area.x, area.y + row, area.width, 1); + frame.render_widget( + Paragraph::new(bg_fill.clone()).style(Style::new().bg(ui::BG)), + line_area, + ); + } + let block = Block::default() .borders(Borders::ALL) .title(self.title) diff --git a/crates/ratatui/src/nav_minimap.rs b/crates/ratatui/src/nav_minimap.rs index 76a6dcb..01634d6 100644 --- a/crates/ratatui/src/nav_minimap.rs +++ b/crates/ratatui/src/nav_minimap.rs @@ -1,5 +1,6 @@ +use crate::theme::{nav, ui}; use ratatui::layout::{Alignment, Rect}; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::widgets::{Clear, Paragraph}; use ratatui::Frame; @@ -48,6 +49,16 @@ impl<'a> NavMinimap<'a> { frame.render_widget(Clear, area); + // Fill background with theme color + let bg_fill = " ".repeat(area.width as usize); + for row in 0..area.height { + let line_area = Rect::new(area.x, area.y + row, area.width, 1); + frame.render_widget( + Paragraph::new(bg_fill.clone()).style(Style::new().bg(ui::BG)), + line_area, + ); + } + let inner_x = area.x + pad; let inner_y = area.y + pad; @@ -62,9 +73,9 @@ impl<'a> NavMinimap<'a> { fn render_tile(&self, frame: &mut Frame, area: Rect, label: &str, is_selected: bool) { let (bg, fg) = if is_selected { - (Color::Rgb(50, 90, 110), Color::White) + (nav::SELECTED_BG, nav::SELECTED_FG) } else { - (Color::Rgb(30, 35, 45), Color::Rgb(100, 105, 115)) + (nav::UNSELECTED_BG, nav::UNSELECTED_FG) }; // Fill background diff --git a/crates/ratatui/src/sample_browser.rs b/crates/ratatui/src/sample_browser.rs index fcde87d..fc4c73f 100644 --- a/crates/ratatui/src/sample_browser.rs +++ b/crates/ratatui/src/sample_browser.rs @@ -1,5 +1,6 @@ +use crate::theme::{browser, search}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; @@ -59,9 +60,9 @@ impl<'a> SampleBrowser<'a> { pub fn render(self, frame: &mut Frame, area: Rect) { let border_style = if self.focused { - Style::new().fg(Color::Yellow) + Style::new().fg(browser::FOCUSED_BORDER) } else { - Style::new().fg(Color::DarkGray) + Style::new().fg(browser::UNFOCUSED_BORDER) }; let block = Block::default() @@ -96,9 +97,9 @@ impl<'a> SampleBrowser<'a> { fn render_search(&self, frame: &mut Frame, area: Rect) { let style = if self.search_active { - Style::new().fg(Color::Yellow) + Style::new().fg(search::ACTIVE) } else { - Style::new().fg(Color::DarkGray) + Style::new().fg(search::INACTIVE) }; let cursor = if self.search_active { "_" } else { "" }; let text = format!("/{}{}", self.search_query, cursor); @@ -114,7 +115,7 @@ impl<'a> SampleBrowser<'a> { } else { "No matches" }; - let line = Line::from(Span::styled(msg, Style::new().fg(Color::DarkGray))); + let line = Line::from(Span::styled(msg, Style::new().fg(browser::EMPTY_TEXT))); frame.render_widget(Paragraph::new(vec![line]), area); return; } @@ -129,23 +130,23 @@ impl<'a> SampleBrowser<'a> { let (icon, icon_color) = match entry.kind { TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => { - ("\u{25BC} ", Color::Cyan) + ("\u{25BC} ", browser::FOLDER_ICON) } TreeLineKind::Root { expanded: false } - | TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", Color::Cyan), - TreeLineKind::File => ("\u{266A} ", Color::DarkGray), + | TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", browser::FOLDER_ICON), + TreeLineKind::File => ("\u{266A} ", browser::FILE_ICON), }; let label_style = if is_cursor && self.focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::new().fg(browser::SELECTED).add_modifier(Modifier::BOLD) } else if is_cursor { - Style::new().fg(Color::White) + Style::new().fg(browser::FILE) } else { match entry.kind { TreeLineKind::Root { .. } => { - Style::new().fg(Color::White).add_modifier(Modifier::BOLD) + Style::new().fg(browser::ROOT).add_modifier(Modifier::BOLD) } - TreeLineKind::Folder { .. } => Style::new().fg(Color::Cyan), + TreeLineKind::Folder { .. } => Style::new().fg(browser::DIRECTORY), TreeLineKind::File => Style::default(), } }; diff --git a/crates/ratatui/src/scope.rs b/crates/ratatui/src/scope.rs index 712d44f..299beb0 100644 --- a/crates/ratatui/src/scope.rs +++ b/crates/ratatui/src/scope.rs @@ -1,3 +1,4 @@ +use crate::theme::meter; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -26,7 +27,7 @@ impl<'a> Scope<'a> { Self { data, orientation: Orientation::Horizontal, - color: Color::Green, + color: meter::LOW, gain: 1.0, } } diff --git a/crates/ratatui/src/sparkles.rs b/crates/ratatui/src/sparkles.rs index 731a3c3..7db6610 100644 --- a/crates/ratatui/src/sparkles.rs +++ b/crates/ratatui/src/sparkles.rs @@ -1,3 +1,4 @@ +use crate::theme::sparkle; use rand::Rng; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -5,13 +6,6 @@ use ratatui::style::{Color, Style}; use ratatui::widgets::Widget; const CHARS: &[char] = &['·', '✦', '✧', '°', '•', '+', '⋆', '*']; -const COLORS: &[(u8, u8, u8)] = &[ - (200, 220, 255), - (255, 200, 150), - (150, 255, 200), - (255, 150, 200), - (200, 150, 255), -]; struct Sparkle { x: u16, @@ -47,17 +41,17 @@ impl Sparkles { impl Widget for &Sparkles { fn render(self, area: Rect, buf: &mut Buffer) { - for sparkle in &self.sparkles { - let color = COLORS[sparkle.char_idx % COLORS.len()]; - let intensity = (sparkle.life as f32 / 30.0).min(1.0); + for sp in &self.sparkles { + let color = sparkle::COLORS[sp.char_idx % sparkle::COLORS.len()]; + let intensity = (sp.life as f32 / 30.0).min(1.0); let r = (color.0 as f32 * intensity) as u8; let g = (color.1 as f32 * intensity) as u8; let b = (color.2 as f32 * intensity) as u8; - if sparkle.x < area.width && sparkle.y < area.height { - let x = area.x + sparkle.x; - let y = area.y + sparkle.y; - let ch = CHARS[sparkle.char_idx]; + if sp.x < area.width && sp.y < area.height { + let x = area.x + sp.x; + let y = area.y + sp.y; + let ch = CHARS[sp.char_idx]; buf[(x, y)].set_char(ch).set_style(Style::new().fg(Color::Rgb(r, g, b))); } } diff --git a/crates/ratatui/src/spectrum.rs b/crates/ratatui/src/spectrum.rs index 39bf81f..ee8d7f9 100644 --- a/crates/ratatui/src/spectrum.rs +++ b/crates/ratatui/src/spectrum.rs @@ -1,3 +1,4 @@ +use crate::theme::meter; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -39,11 +40,11 @@ impl Widget for Spectrum<'_> { let y = area.y + area.height - 1 - row as u16; let ratio = row as f32 / area.height as f32; let color = if ratio < 0.33 { - Color::Rgb(40, 180, 80) + Color::Rgb(meter::LOW_RGB.0, meter::LOW_RGB.1, meter::LOW_RGB.2) } else if ratio < 0.66 { - Color::Rgb(220, 180, 40) + Color::Rgb(meter::MID_RGB.0, meter::MID_RGB.1, meter::MID_RGB.2) } else { - Color::Rgb(220, 60, 40) + Color::Rgb(meter::HIGH_RGB.0, meter::HIGH_RGB.1, meter::HIGH_RGB.2) }; for dx in 0..band_width as u16 { let x = x_start + dx; diff --git a/crates/ratatui/src/text_input.rs b/crates/ratatui/src/text_input.rs index e39e6f3..28e745c 100644 --- a/crates/ratatui/src/text_input.rs +++ b/crates/ratatui/src/text_input.rs @@ -1,3 +1,4 @@ +use crate::theme::{input, ui}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; @@ -20,7 +21,7 @@ impl<'a> TextInputModal<'a> { title, input, hint: None, - border_color: Color::White, + border_color: ui::TEXT_PRIMARY, width: 50, } } @@ -56,15 +57,15 @@ impl<'a> TextInputModal<'a> { frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw("> "), - Span::styled(self.input, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), + Span::styled(self.input, Style::new().fg(input::TEXT)), + Span::styled("█", Style::new().fg(input::CURSOR)), ])), rows[0], ); if let Some(hint) = self.hint { frame.render_widget( - Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))), + Paragraph::new(Span::styled(hint, Style::new().fg(input::HINT))), rows[1], ); } @@ -72,8 +73,8 @@ impl<'a> TextInputModal<'a> { frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw("> "), - Span::styled(self.input, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), + Span::styled(self.input, Style::new().fg(input::TEXT)), + Span::styled("█", Style::new().fg(input::CURSOR)), ])), inner, ); diff --git a/crates/ratatui/src/theme.rs b/crates/ratatui/src/theme.rs new file mode 100644 index 0000000..2462126 --- /dev/null +++ b/crates/ratatui/src/theme.rs @@ -0,0 +1,377 @@ +//! Centralized color definitions for Cagire TUI. +//! Based on Catppuccin Mocha palette. + +use ratatui::style::Color; + +// Catppuccin Mocha base palette +mod palette { + use super::*; + + // Backgrounds (dark to light) + pub const CRUST: Color = Color::Rgb(17, 17, 27); + pub const MANTLE: Color = Color::Rgb(24, 24, 37); + pub const BASE: Color = Color::Rgb(30, 30, 46); + pub const SURFACE0: Color = Color::Rgb(49, 50, 68); + pub const SURFACE1: Color = Color::Rgb(69, 71, 90); + pub const SURFACE2: Color = Color::Rgb(88, 91, 112); + + // Overlays + pub const OVERLAY0: Color = Color::Rgb(108, 112, 134); + pub const OVERLAY1: Color = Color::Rgb(127, 132, 156); + pub const OVERLAY2: Color = Color::Rgb(147, 153, 178); + + // Text (dim to bright) + pub const SUBTEXT0: Color = Color::Rgb(166, 173, 200); + pub const SUBTEXT1: Color = Color::Rgb(186, 194, 222); + pub const TEXT: Color = Color::Rgb(205, 214, 244); + + // Accent colors + pub const ROSEWATER: Color = Color::Rgb(245, 224, 220); + pub const FLAMINGO: Color = Color::Rgb(242, 205, 205); + pub const PINK: Color = Color::Rgb(245, 194, 231); + pub const MAUVE: Color = Color::Rgb(203, 166, 247); + pub const RED: Color = Color::Rgb(243, 139, 168); + pub const MAROON: Color = Color::Rgb(235, 160, 172); + pub const PEACH: Color = Color::Rgb(250, 179, 135); + pub const YELLOW: Color = Color::Rgb(249, 226, 175); + pub const GREEN: Color = Color::Rgb(166, 227, 161); + pub const TEAL: Color = Color::Rgb(148, 226, 213); + pub const SKY: Color = Color::Rgb(137, 220, 235); + pub const SAPPHIRE: Color = Color::Rgb(116, 199, 236); + pub const BLUE: Color = Color::Rgb(137, 180, 250); + pub const LAVENDER: Color = Color::Rgb(180, 190, 254); +} + +pub mod ui { + use super::*; + use palette::*; + pub const BG: Color = BASE; + pub const BG_RGB: (u8, u8, u8) = (30, 30, 46); + pub const TEXT_PRIMARY: Color = TEXT; + pub const TEXT_MUTED: Color = SUBTEXT0; + pub const TEXT_DIM: Color = OVERLAY1; + pub const BORDER: Color = SURFACE1; + pub const HEADER: Color = LAVENDER; + pub const UNFOCUSED: Color = OVERLAY0; + pub const ACCENT: Color = MAUVE; + pub const SURFACE: Color = SURFACE0; +} + +pub mod status { + use super::*; + use palette::*; + pub const PLAYING_BG: Color = Color::Rgb(30, 50, 40); + pub const PLAYING_FG: Color = GREEN; + pub const STOPPED_BG: Color = Color::Rgb(50, 30, 40); + pub const STOPPED_FG: Color = RED; + pub const FILL_ON: Color = GREEN; + pub const FILL_OFF: Color = OVERLAY0; + pub const FILL_BG: Color = SURFACE0; +} + +pub mod selection { + use super::*; + use palette::*; + pub const CURSOR_BG: Color = MAUVE; + pub const CURSOR_FG: Color = CRUST; + pub const SELECTED_BG: Color = Color::Rgb(60, 60, 90); + pub const SELECTED_FG: Color = LAVENDER; + pub const IN_RANGE_BG: Color = Color::Rgb(50, 50, 75); + pub const IN_RANGE_FG: Color = SUBTEXT1; + // Aliases for simpler API + pub const CURSOR: Color = CURSOR_BG; + pub const SELECTED: Color = SELECTED_BG; + pub const IN_RANGE: Color = IN_RANGE_BG; +} + +pub mod tile { + use super::*; + use palette::*; + pub const PLAYING_ACTIVE_BG: Color = Color::Rgb(80, 50, 60); + pub const PLAYING_ACTIVE_FG: Color = PEACH; + pub const PLAYING_INACTIVE_BG: Color = Color::Rgb(70, 55, 45); + pub const PLAYING_INACTIVE_FG: Color = YELLOW; + pub const ACTIVE_BG: Color = Color::Rgb(40, 55, 55); + pub const ACTIVE_FG: Color = TEAL; + pub const INACTIVE_BG: Color = SURFACE0; + pub const INACTIVE_FG: Color = SUBTEXT0; + // Combined states for selected/in-range + active + pub const ACTIVE_SELECTED_BG: Color = Color::Rgb(70, 60, 80); + pub const ACTIVE_IN_RANGE_BG: Color = Color::Rgb(55, 55, 70); + + // Link colors (derived from palette accents, dimmed for backgrounds) + pub const LINK_BRIGHT: [(u8, u8, u8); 5] = [ + (203, 166, 247), // Mauve + (245, 194, 231), // Pink + (250, 179, 135), // Peach + (137, 220, 235), // Sky + (166, 227, 161), // Green + ]; + pub const LINK_DIM: [(u8, u8, u8); 5] = [ + (70, 55, 85), // Mauve dimmed + (85, 65, 80), // Pink dimmed + (85, 60, 45), // Peach dimmed + (45, 75, 80), // Sky dimmed + (55, 80, 55), // Green dimmed + ]; +} + +pub mod header { + use super::*; + use palette::*; + pub const TEMPO_BG: Color = Color::Rgb(50, 40, 60); + pub const TEMPO_FG: Color = MAUVE; + pub const BANK_BG: Color = Color::Rgb(35, 50, 55); + pub const BANK_FG: Color = SAPPHIRE; + pub const PATTERN_BG: Color = Color::Rgb(40, 50, 50); + pub const PATTERN_FG: Color = TEAL; + pub const STATS_BG: Color = SURFACE0; + pub const STATS_FG: Color = SUBTEXT0; +} + +pub mod modal { + use super::*; + use palette::*; + pub const BORDER: Color = LAVENDER; + pub const BORDER_ACCENT: Color = MAUVE; + pub const BORDER_WARN: Color = PEACH; + pub const BORDER_DIM: Color = OVERLAY1; + // Semantic modal colors + pub const CONFIRM: Color = PEACH; + pub const RENAME: Color = MAUVE; + pub const INPUT: Color = SAPPHIRE; + pub const EDITOR: Color = LAVENDER; + pub const PREVIEW: Color = OVERLAY1; +} + +pub mod flash { + use super::*; + use palette::*; + pub const ERROR_BG: Color = Color::Rgb(50, 30, 40); + pub const ERROR_FG: Color = RED; + pub const SUCCESS_BG: Color = Color::Rgb(30, 50, 40); + pub const SUCCESS_FG: Color = GREEN; + pub const INFO_BG: Color = SURFACE0; + pub const INFO_FG: Color = TEXT; + // Event flash tint (dimmed mauve) + pub const EVENT_RGB: (u8, u8, u8) = (55, 45, 70); +} + +pub mod list { + use super::*; + use palette::*; + pub const PLAYING_BG: Color = Color::Rgb(35, 55, 45); + pub const PLAYING_FG: Color = GREEN; + pub const STAGED_PLAY_BG: Color = Color::Rgb(55, 45, 65); + pub const STAGED_PLAY_FG: Color = MAUVE; + pub const STAGED_STOP_BG: Color = Color::Rgb(60, 40, 50); + pub const STAGED_STOP_FG: Color = MAROON; + pub const EDIT_BG: Color = Color::Rgb(40, 55, 55); + pub const EDIT_FG: Color = TEAL; + pub const HOVER_BG: Color = SURFACE1; + pub const HOVER_FG: Color = TEXT; +} + +pub mod link_status { + use super::*; + use palette::*; + pub const DISABLED: Color = RED; + pub const CONNECTED: Color = GREEN; + pub const LISTENING: Color = YELLOW; +} + +pub mod syntax { + use super::*; + use palette::*; + pub const GAP_BG: Color = MANTLE; + pub const EXECUTED_BG: Color = Color::Rgb(45, 40, 55); + pub const SELECTED_BG: Color = Color::Rgb(70, 55, 40); + + // (fg, bg) tuples - fg is bright accent, bg is subtle tint + pub const EMIT: (Color, Color) = (TEXT, Color::Rgb(80, 50, 60)); + pub const NUMBER: (Color, Color) = (PEACH, Color::Rgb(55, 45, 35)); + pub const STRING: (Color, Color) = (GREEN, Color::Rgb(35, 50, 40)); + pub const COMMENT: (Color, Color) = (OVERLAY1, CRUST); + pub const KEYWORD: (Color, Color) = (MAUVE, Color::Rgb(50, 40, 60)); + pub const STACK_OP: (Color, Color) = (SAPPHIRE, Color::Rgb(35, 45, 55)); + pub const OPERATOR: (Color, Color) = (YELLOW, Color::Rgb(55, 50, 35)); + pub const SOUND: (Color, Color) = (TEAL, Color::Rgb(35, 55, 55)); + pub const PARAM: (Color, Color) = (LAVENDER, Color::Rgb(45, 45, 60)); + pub const CONTEXT: (Color, Color) = (PEACH, Color::Rgb(55, 45, 35)); + pub const NOTE: (Color, Color) = (GREEN, Color::Rgb(35, 50, 40)); + pub const INTERVAL: (Color, Color) = (Color::Rgb(180, 230, 150), Color::Rgb(40, 55, 35)); + pub const VARIABLE: (Color, Color) = (PINK, Color::Rgb(55, 40, 55)); + pub const VARY: (Color, Color) = (YELLOW, Color::Rgb(55, 50, 35)); + pub const GENERATOR: (Color, Color) = (TEAL, Color::Rgb(35, 55, 50)); + pub const DEFAULT: (Color, Color) = (SUBTEXT0, MANTLE); +} + +pub mod table { + use super::*; + use palette::*; + pub const ROW_EVEN: Color = MANTLE; + pub const ROW_ODD: Color = BASE; +} + +pub mod values { + use super::*; + use palette::*; + pub const TEMPO: Color = PEACH; + pub const VALUE: Color = SUBTEXT0; +} + +pub mod hint { + use super::*; + use palette::*; + pub const KEY: Color = PEACH; + pub const TEXT: Color = OVERLAY1; +} + +pub mod nav { + use super::*; + use palette::*; + pub const SELECTED_BG: Color = Color::Rgb(60, 50, 75); + pub const SELECTED_FG: Color = TEXT; + pub const UNSELECTED_BG: Color = SURFACE0; + pub const UNSELECTED_FG: Color = OVERLAY1; +} + +pub mod editor_widget { + use super::*; + use palette::*; + pub const CURSOR_BG: Color = TEXT; + pub const CURSOR_FG: Color = CRUST; + pub const SELECTION_BG: Color = Color::Rgb(50, 60, 90); + pub const COMPLETION_BG: Color = SURFACE0; + pub const COMPLETION_FG: Color = TEXT; + pub const COMPLETION_SELECTED: Color = PEACH; + pub const COMPLETION_EXAMPLE: Color = TEAL; +} + +pub mod browser { + use super::*; + use palette::*; + pub const DIRECTORY: Color = SAPPHIRE; + pub const PROJECT_FILE: Color = MAUVE; + pub const SELECTED: Color = PEACH; + pub const FILE: Color = TEXT; + pub const FOCUSED_BORDER: Color = PEACH; + pub const UNFOCUSED_BORDER: Color = OVERLAY0; + pub const ROOT: Color = TEXT; + pub const FILE_ICON: Color = OVERLAY1; + pub const FOLDER_ICON: Color = SAPPHIRE; + pub const EMPTY_TEXT: Color = OVERLAY1; +} + +pub mod input { + use super::*; + use palette::*; + pub const TEXT: Color = SAPPHIRE; + pub const CURSOR: Color = super::palette::TEXT; + pub const HINT: Color = OVERLAY1; +} + +pub mod search { + use super::*; + use palette::*; + pub const ACTIVE: Color = PEACH; + pub const INACTIVE: Color = OVERLAY0; + pub const MATCH_BG: Color = YELLOW; + pub const MATCH_FG: Color = CRUST; +} + +pub mod markdown { + use super::*; + use palette::*; + pub const H1: Color = SAPPHIRE; + pub const H2: Color = PEACH; + pub const H3: Color = MAUVE; + pub const CODE: Color = GREEN; + pub const CODE_BORDER: Color = Color::Rgb(60, 60, 70); + pub const LINK: Color = TEAL; + pub const LINK_URL: Color = Color::Rgb(100, 100, 100); + pub const QUOTE: Color = OVERLAY1; + pub const TEXT: Color = super::palette::TEXT; + pub const LIST: Color = super::palette::TEXT; +} + +pub mod engine { + use super::*; + use palette::*; + pub const HEADER: Color = Color::Rgb(100, 160, 180); + pub const HEADER_FOCUSED: Color = YELLOW; + pub const DIVIDER: Color = Color::Rgb(60, 65, 70); + pub const SCROLL_INDICATOR: Color = Color::Rgb(80, 85, 95); + pub const LABEL: Color = Color::Rgb(120, 125, 135); + pub const LABEL_FOCUSED: Color = Color::Rgb(150, 155, 165); + pub const LABEL_DIM: Color = Color::Rgb(100, 105, 115); + pub const VALUE: Color = Color::Rgb(180, 180, 190); + pub const FOCUSED: Color = YELLOW; + pub const NORMAL: Color = TEXT; + pub const DIM: Color = Color::Rgb(80, 85, 95); + pub const PATH: Color = Color::Rgb(120, 125, 135); + pub const BORDER_MAGENTA: Color = MAUVE; + pub const BORDER_GREEN: Color = GREEN; + pub const BORDER_CYAN: Color = SAPPHIRE; + pub const SEPARATOR: Color = Color::Rgb(60, 65, 75); + pub const HINT_ACTIVE: Color = Color::Rgb(180, 180, 100); + pub const HINT_INACTIVE: Color = Color::Rgb(60, 60, 70); +} + +pub mod dict { + use super::*; + use palette::*; + pub const WORD_NAME: Color = GREEN; + pub const WORD_BG: Color = Color::Rgb(40, 50, 60); + pub const ALIAS: Color = OVERLAY1; + pub const STACK_SIG: Color = MAUVE; + pub const DESCRIPTION: Color = TEXT; + pub const EXAMPLE: Color = Color::Rgb(120, 130, 140); + pub const CATEGORY_FOCUSED: Color = YELLOW; + pub const CATEGORY_SELECTED: Color = SAPPHIRE; + pub const CATEGORY_NORMAL: Color = TEXT; + pub const CATEGORY_DIMMED: Color = Color::Rgb(80, 80, 90); + pub const BORDER_FOCUSED: Color = YELLOW; + pub const BORDER_NORMAL: Color = Color::Rgb(60, 60, 70); + pub const HEADER_DESC: Color = Color::Rgb(140, 145, 155); +} + +pub mod title { + use super::*; + use palette::*; + pub const BIG_TITLE: Color = MAUVE; + pub const AUTHOR: Color = LAVENDER; + pub const LINK: Color = TEAL; + pub const LICENSE: Color = PEACH; + pub const PROMPT: Color = Color::Rgb(140, 160, 170); + pub const SUBTITLE: Color = TEXT; +} + +pub mod meter { + use super::*; + use palette::*; + pub const LOW: Color = GREEN; + pub const MID: Color = YELLOW; + pub const HIGH: Color = RED; + pub const LOW_RGB: (u8, u8, u8) = (40, 180, 80); + pub const MID_RGB: (u8, u8, u8) = (220, 180, 40); + pub const HIGH_RGB: (u8, u8, u8) = (220, 60, 40); +} + +pub mod sparkle { + pub const COLORS: &[(u8, u8, u8)] = &[ + (200, 220, 255), // Lavender-ish + (250, 179, 135), // Peach + (166, 227, 161), // Green + (245, 194, 231), // Pink + (203, 166, 247), // Mauve + ]; +} + +pub mod confirm { + use super::*; + use palette::*; + pub const BORDER: Color = PEACH; + pub const BUTTON_SELECTED_BG: Color = PEACH; + pub const BUTTON_SELECTED_FG: Color = CRUST; +} diff --git a/crates/ratatui/src/vu_meter.rs b/crates/ratatui/src/vu_meter.rs index c5deb64..b3d1891 100644 --- a/crates/ratatui/src/vu_meter.rs +++ b/crates/ratatui/src/vu_meter.rs @@ -1,3 +1,4 @@ +use crate::theme::meter; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -31,11 +32,11 @@ impl VuMeter { fn row_to_color(row_position: f32) -> Color { if row_position > 0.9 { - Color::Red + meter::HIGH } else if row_position > 0.75 { - Color::Yellow + meter::MID } else { - Color::Green + meter::LOW } } } diff --git a/src/app.rs b/src/app.rs index 45de380..fa3266f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -100,6 +100,7 @@ impl App { show_spectrum: self.audio.config.show_spectrum, show_completion: self.ui.show_completion, flash_brightness: self.ui.flash_brightness, + ..Default::default() }, link: crate::settings::LinkSettings { enabled: link.is_enabled(), diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs new file mode 100644 index 0000000..dd1a32f --- /dev/null +++ b/src/bin/desktop.rs @@ -0,0 +1,531 @@ +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; +use doux::EngineMetrics; +use eframe::NativeOptions; +use egui_ratatui::RataguiBackend; +use ratatui::Terminal; +use soft_ratatui::embedded_graphics_unicodefonts::{ + mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas, + mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, + mono_8x13_atlas, mono_8x13_bold_atlas, mono_8x13_italic_atlas, + mono_9x15_atlas, mono_9x15_bold_atlas, + mono_9x18_atlas, mono_9x18_bold_atlas, + mono_10x20_atlas, +}; +use soft_ratatui::{EmbeddedGraphics, SoftBackend}; + +use cagire::app::App; +use cagire::engine::{ + build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer, + SequencerConfig, SequencerHandle, SpectrumBuffer, +}; +use cagire::input::{handle_key, InputContext, InputResult}; +use cagire::input_egui::convert_egui_events; +use cagire::settings::Settings; +use cagire::state::audio::RefreshRate; +use cagire::views; + +#[derive(Parser)] +#[command(name = "cagire-desktop", about = "Cagire desktop application")] +struct Args { + #[arg(short, long)] + samples: Vec, + + #[arg(short, long)] + output: Option, + + #[arg(short, long)] + input: Option, + + #[arg(short, long)] + channels: Option, + + #[arg(short, long)] + buffer: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum FontChoice { + Size6x13, + Size7x13, + Size8x13, + Size9x15, + Size9x18, + Size10x20, +} + +impl FontChoice { + fn from_setting(s: &str) -> Self { + match s { + "6x13" => Self::Size6x13, + "7x13" => Self::Size7x13, + "9x15" => Self::Size9x15, + "9x18" => Self::Size9x18, + "10x20" => Self::Size10x20, + _ => Self::Size8x13, + } + } + + fn to_setting(self) -> &'static str { + match self { + Self::Size6x13 => "6x13", + Self::Size7x13 => "7x13", + Self::Size8x13 => "8x13", + Self::Size9x15 => "9x15", + Self::Size9x18 => "9x18", + Self::Size10x20 => "10x20", + } + } + + fn label(self) -> &'static str { + match self { + Self::Size6x13 => "6x13 (Compact)", + Self::Size7x13 => "7x13", + Self::Size8x13 => "8x13 (Default)", + Self::Size9x15 => "9x15", + Self::Size9x18 => "9x18", + Self::Size10x20 => "10x20 (Large)", + } + } + + const ALL: [Self; 6] = [ + Self::Size6x13, + Self::Size7x13, + Self::Size8x13, + Self::Size9x15, + Self::Size9x18, + Self::Size10x20, + ]; +} + +type TerminalType = Terminal>; + +fn create_terminal(font: FontChoice) -> TerminalType { + let (regular, bold, italic) = match font { + FontChoice::Size6x13 => ( + mono_6x13_atlas(), + Some(mono_6x13_bold_atlas()), + Some(mono_6x13_italic_atlas()), + ), + FontChoice::Size7x13 => ( + mono_7x13_atlas(), + Some(mono_7x13_bold_atlas()), + Some(mono_7x13_italic_atlas()), + ), + FontChoice::Size8x13 => ( + mono_8x13_atlas(), + Some(mono_8x13_bold_atlas()), + Some(mono_8x13_italic_atlas()), + ), + FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None), + FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None), + FontChoice::Size10x20 => (mono_10x20_atlas(), None, None), + }; + + let soft = SoftBackend::::new(80, 24, regular, bold, italic); + Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") +} + +struct CagireDesktop { + app: App, + terminal: TerminalType, + link: Arc, + sequencer: Option, + playing: Arc, + nudge_us: Arc, + lookahead_ms: Arc, + metrics: Arc, + scope_buffer: Arc, + spectrum_buffer: Arc, + audio_sample_pos: Arc, + sample_rate_shared: Arc, + _stream: Option, + _analysis_handle: Option, + current_font: FontChoice, + pending_font: Option, +} + +impl CagireDesktop { + fn new(cc: &eframe::CreationContext<'_>, args: Args) -> Self { + let settings = Settings::load(); + + let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum)); + if settings.link.enabled { + link.enable(); + } + + let playing = Arc::new(AtomicBool::new(true)); + let nudge_us = Arc::new(AtomicI64::new(0)); + + let mut app = App::new(); + + app.playback + .queued_changes + .push(cagire::state::StagedChange { + change: cagire::engine::PatternChange::Start { + bank: 0, + pattern: 0, + }, + quantization: cagire::model::LaunchQuantization::Immediate, + sync_mode: cagire::model::SyncMode::Reset, + }); + + app.audio.config.output_device = args.output.or(settings.audio.output_device); + app.audio.config.input_device = args.input.or(settings.audio.input_device); + 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.lookahead_ms = settings.audio.lookahead_ms; + app.audio.config.sample_paths = 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; + app.audio.config.show_spectrum = settings.display.show_spectrum; + app.ui.show_completion = settings.display.show_completion; + app.ui.flash_brightness = settings.display.flash_brightness; + + let metrics = Arc::new(EngineMetrics::default()); + let scope_buffer = Arc::new(ScopeBuffer::new()); + let spectrum_buffer = Arc::new(SpectrumBuffer::new()); + + let audio_sample_pos = Arc::new(AtomicU64::new(0)); + let sample_rate_shared = Arc::new(AtomicU32::new(44100)); + let lookahead_ms = Arc::new(AtomicU32::new(settings.audio.lookahead_ms)); + + 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(); + initial_samples.extend(index); + } + + let seq_config = SequencerConfig { + audio_sample_pos: Arc::clone(&audio_sample_pos), + sample_rate: Arc::clone(&sample_rate_shared), + lookahead_ms: Arc::clone(&lookahead_ms), + }; + + let (sequencer, initial_audio_rx) = spawn_sequencer( + Arc::clone(&link), + Arc::clone(&playing), + Arc::clone(&app.variables), + Arc::clone(&app.dict), + Arc::clone(&app.rng), + settings.link.quantum, + Arc::clone(&app.live_keys), + Arc::clone(&nudge_us), + seq_config, + ); + + let stream_config = AudioStreamConfig { + output_device: app.audio.config.output_device.clone(), + channels: app.audio.config.channels, + buffer_size: app.audio.config.buffer_size, + max_voices: app.audio.config.max_voices, + }; + + let (stream, analysis_handle) = match build_stream( + &stream_config, + initial_audio_rx, + Arc::clone(&scope_buffer), + Arc::clone(&spectrum_buffer), + Arc::clone(&metrics), + initial_samples, + Arc::clone(&audio_sample_pos), + ) { + Ok((s, sample_rate, analysis)) => { + app.audio.config.sample_rate = sample_rate; + sample_rate_shared.store(sample_rate as u32, Ordering::Relaxed); + (Some(s), Some(analysis)) + } + Err(e) => { + app.ui.set_status(format!("Audio failed: {e}")); + app.audio.error = Some(e); + (None, None) + } + }; + app.mark_all_patterns_dirty(); + + let current_font = FontChoice::from_setting(&settings.display.font); + let terminal = create_terminal(current_font); + + cc.egui_ctx.set_visuals(egui::Visuals::dark()); + + Self { + app, + terminal, + link, + sequencer: Some(sequencer), + playing, + nudge_us, + lookahead_ms, + metrics, + scope_buffer, + spectrum_buffer, + audio_sample_pos, + sample_rate_shared, + _stream: stream, + _analysis_handle: analysis_handle, + current_font, + pending_font: None, + } + } + + fn handle_audio_restart(&mut self) { + if !self.app.audio.restart_pending { + return; + } + + self.app.audio.restart_pending = false; + self._stream = None; + self._analysis_handle = None; + + let Some(ref sequencer) = self.sequencer else { + return; + }; + let new_audio_rx = sequencer.swap_audio_channel(); + + let new_config = AudioStreamConfig { + output_device: self.app.audio.config.output_device.clone(), + channels: self.app.audio.config.channels, + buffer_size: self.app.audio.config.buffer_size, + max_voices: self.app.audio.config.max_voices, + }; + + let mut restart_samples = Vec::new(); + for path in &self.app.audio.config.sample_paths { + let index = doux::sampling::scan_samples_dir(path); + restart_samples.extend(index); + } + self.app.audio.config.sample_count = restart_samples.len(); + + self.audio_sample_pos.store(0, Ordering::Relaxed); + + match build_stream( + &new_config, + new_audio_rx, + Arc::clone(&self.scope_buffer), + Arc::clone(&self.spectrum_buffer), + Arc::clone(&self.metrics), + restart_samples, + Arc::clone(&self.audio_sample_pos), + ) { + Ok((new_stream, sr, new_analysis)) => { + self._stream = Some(new_stream); + self._analysis_handle = Some(new_analysis); + self.app.audio.config.sample_rate = sr; + self.sample_rate_shared.store(sr as u32, Ordering::Relaxed); + self.app.audio.error = None; + self.app.ui.set_status("Audio restarted".to_string()); + } + Err(e) => { + self.app.audio.error = Some(e.clone()); + self.app.ui.set_status(format!("Audio failed: {e}")); + } + } + } + + fn update_metrics(&mut self) { + self.app.playback.playing = self.playing.load(Ordering::Relaxed); + + self.app.metrics.active_voices = + self.metrics.active_voices.load(Ordering::Relaxed) as usize; + self.app.metrics.peak_voices = self.app.metrics.peak_voices.max(self.app.metrics.active_voices); + self.app.metrics.cpu_load = self.metrics.load.get_load(); + self.app.metrics.schedule_depth = + self.metrics.schedule_depth.load(Ordering::Relaxed) as usize; + self.app.metrics.scope = self.scope_buffer.read(); + (self.app.metrics.peak_left, self.app.metrics.peak_right) = self.scope_buffer.peaks(); + self.app.metrics.spectrum = self.spectrum_buffer.read(); + self.app.metrics.nudge_ms = self.nudge_us.load(Ordering::Relaxed) as f64 / 1000.0; + } + + fn handle_input(&mut self, ctx: &egui::Context) -> bool { + let Some(ref sequencer) = self.sequencer else { + return false; + }; + let seq_snapshot = sequencer.snapshot(); + + for key in convert_egui_events(ctx) { + let mut input_ctx = InputContext { + app: &mut self.app, + link: &self.link, + snapshot: &seq_snapshot, + playing: &self.playing, + audio_tx: &sequencer.audio_tx, + seq_cmd_tx: &sequencer.cmd_tx, + nudge_us: &self.nudge_us, + lookahead_ms: &self.lookahead_ms, + }; + + if let InputResult::Quit = handle_key(&mut input_ctx, key) { + return true; + } + } + + false + } +} + +impl eframe::App for CagireDesktop { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if let Some(font) = self.pending_font.take() { + self.terminal = create_terminal(font); + self.current_font = font; + let mut settings = Settings::load(); + settings.display.font = font.to_setting().to_string(); + settings.save(); + } + + self.handle_audio_restart(); + self.update_metrics(); + + let Some(ref sequencer) = self.sequencer else { + return; + }; + let seq_snapshot = sequencer.snapshot(); + + self.app.metrics.event_count = seq_snapshot.event_count; + self.app.metrics.dropped_events = seq_snapshot.dropped_events; + + self.app.ui.event_flash = (self.app.ui.event_flash - 0.1).max(0.0); + let new_events = self + .app + .metrics + .event_count + .saturating_sub(self.app.ui.last_event_count); + if new_events > 0 { + self.app.ui.event_flash = (new_events as f32 * 0.4).min(1.0); + } + self.app.ui.last_event_count = self.app.metrics.event_count; + + self.app.flush_queued_changes(&sequencer.cmd_tx); + self.app.flush_dirty_patterns(&sequencer.cmd_tx); + + let should_quit = self.handle_input(ctx); + if should_quit { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + + let current_font = self.current_font; + let mut pending_font = None; + + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ctx, |ui| { + if self.app.ui.show_title { + self.app.ui.sparkles.tick(self.terminal.get_frame().area()); + } + + let link = &self.link; + let app = &self.app; + self.terminal + .draw(|frame| views::render(frame, app, link, &seq_snapshot)) + .expect("Failed to draw"); + + ui.add(self.terminal.backend_mut()); + + // Create a click-sensing overlay for context menu + let response = ui.interact( + ui.max_rect(), + egui::Id::new("terminal_context"), + egui::Sense::click(), + ); + response.context_menu(|ui| { + ui.menu_button("Font", |ui| { + for choice in FontChoice::ALL { + let selected = current_font == choice; + if ui.selectable_label(selected, choice.label()).clicked() { + pending_font = Some(choice); + ui.close(); + } + } + }); + }); + }); + + if pending_font.is_some() { + self.pending_font = pending_font; + } + + ctx.request_repaint_after(Duration::from_millis( + self.app.audio.config.refresh_rate.millis(), + )); + } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + if let Some(sequencer) = self.sequencer.take() { + sequencer.shutdown(); + } + } +} + +fn load_icon() -> egui::IconData { + let size = 64u32; + let mut rgba = vec![0u8; (size * size * 4) as usize]; + + for y in 0..size { + for x in 0..size { + let idx = ((y * size + x) * 4) as usize; + let cx = x as f32 - size as f32 / 2.0; + let cy = y as f32 - size as f32 / 2.0; + let dist = (cx * cx + cy * cy).sqrt(); + let radius = size as f32 / 2.0 - 2.0; + + if dist < radius { + let angle = cy.atan2(cx); + let normalized = (angle + std::f32::consts::PI) / (2.0 * std::f32::consts::PI); + + if normalized > 0.1 && normalized < 0.9 { + let inner_radius = radius * 0.5; + if dist > inner_radius { + rgba[idx] = 80; + rgba[idx + 1] = 160; + rgba[idx + 2] = 200; + rgba[idx + 3] = 255; + } else { + rgba[idx] = 30; + rgba[idx + 1] = 60; + rgba[idx + 2] = 80; + rgba[idx + 3] = 255; + } + } else { + rgba[idx] = 30; + rgba[idx + 1] = 30; + rgba[idx + 2] = 40; + rgba[idx + 3] = 255; + } + } else { + rgba[idx + 3] = 0; + } + } + } + + egui::IconData { + rgba, + width: size, + height: size, + } +} + +fn main() -> eframe::Result<()> { + let args = Args::parse(); + + let options = NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("Cagire") + .with_inner_size([1200.0, 800.0]) + .with_icon(std::sync::Arc::new(load_icon())), + ..Default::default() + }; + + eframe::run_native( + "Cagire", + options, + Box::new(move |cc| Ok(Box::new(CagireDesktop::new(cc, args)))), + ) +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 9851a3f..870ed5d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,10 +1,10 @@ mod audio; mod link; -mod sequencer; +pub mod sequencer; -pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer}; +pub use audio::{build_stream, AnalysisHandle, AudioStreamConfig, ScopeBuffer, SpectrumBuffer}; pub use link::LinkState; pub use sequencer::{ spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig, - SequencerSnapshot, StepSnapshot, + SequencerHandle, SequencerSnapshot, StepSnapshot, }; diff --git a/src/input_egui.rs b/src/input_egui.rs new file mode 100644 index 0000000..80ea8b1 --- /dev/null +++ b/src/input_egui.rs @@ -0,0 +1,193 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub fn convert_egui_events(ctx: &egui::Context) -> Vec { + let mut events = Vec::new(); + + for event in &ctx.input(|i| i.events.clone()) { + if let Some(key_event) = convert_event(event) { + events.push(key_event); + } + } + + events +} + +fn convert_event(event: &egui::Event) -> Option { + match event { + egui::Event::Key { + key, + pressed, + modifiers, + .. + } => { + if !*pressed { + return None; + } + let mods = convert_modifiers(*modifiers); + // For character keys without ctrl/alt, let Event::Text handle it + if is_character_key(*key) && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { + return None; + } + let code = convert_key(*key)?; + Some(KeyEvent::new(code, mods)) + } + egui::Event::Text(text) => { + if text.len() == 1 { + let c = text.chars().next()?; + if !c.is_control() { + return Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())); + } + } + None + } + _ => None, + } +} + +fn convert_key(key: egui::Key) -> Option { + Some(match key { + egui::Key::ArrowDown => KeyCode::Down, + egui::Key::ArrowLeft => KeyCode::Left, + egui::Key::ArrowRight => KeyCode::Right, + egui::Key::ArrowUp => KeyCode::Up, + egui::Key::Escape => KeyCode::Esc, + egui::Key::Tab => KeyCode::Tab, + egui::Key::Backspace => KeyCode::Backspace, + egui::Key::Enter => KeyCode::Enter, + egui::Key::Space => KeyCode::Char(' '), + egui::Key::Insert => KeyCode::Insert, + egui::Key::Delete => KeyCode::Delete, + egui::Key::Home => KeyCode::Home, + egui::Key::End => KeyCode::End, + egui::Key::PageUp => KeyCode::PageUp, + egui::Key::PageDown => KeyCode::PageDown, + egui::Key::F1 => KeyCode::F(1), + egui::Key::F2 => KeyCode::F(2), + egui::Key::F3 => KeyCode::F(3), + egui::Key::F4 => KeyCode::F(4), + egui::Key::F5 => KeyCode::F(5), + egui::Key::F6 => KeyCode::F(6), + egui::Key::F7 => KeyCode::F(7), + egui::Key::F8 => KeyCode::F(8), + egui::Key::F9 => KeyCode::F(9), + egui::Key::F10 => KeyCode::F(10), + egui::Key::F11 => KeyCode::F(11), + egui::Key::F12 => KeyCode::F(12), + egui::Key::A => KeyCode::Char('a'), + egui::Key::B => KeyCode::Char('b'), + egui::Key::C => KeyCode::Char('c'), + egui::Key::D => KeyCode::Char('d'), + egui::Key::E => KeyCode::Char('e'), + egui::Key::F => KeyCode::Char('f'), + egui::Key::G => KeyCode::Char('g'), + egui::Key::H => KeyCode::Char('h'), + egui::Key::I => KeyCode::Char('i'), + egui::Key::J => KeyCode::Char('j'), + egui::Key::K => KeyCode::Char('k'), + egui::Key::L => KeyCode::Char('l'), + egui::Key::M => KeyCode::Char('m'), + egui::Key::N => KeyCode::Char('n'), + egui::Key::O => KeyCode::Char('o'), + egui::Key::P => KeyCode::Char('p'), + egui::Key::Q => KeyCode::Char('q'), + egui::Key::R => KeyCode::Char('r'), + egui::Key::S => KeyCode::Char('s'), + egui::Key::T => KeyCode::Char('t'), + egui::Key::U => KeyCode::Char('u'), + egui::Key::V => KeyCode::Char('v'), + egui::Key::W => KeyCode::Char('w'), + egui::Key::X => KeyCode::Char('x'), + egui::Key::Y => KeyCode::Char('y'), + egui::Key::Z => KeyCode::Char('z'), + egui::Key::Num0 => KeyCode::Char('0'), + egui::Key::Num1 => KeyCode::Char('1'), + egui::Key::Num2 => KeyCode::Char('2'), + egui::Key::Num3 => KeyCode::Char('3'), + egui::Key::Num4 => KeyCode::Char('4'), + egui::Key::Num5 => KeyCode::Char('5'), + egui::Key::Num6 => KeyCode::Char('6'), + egui::Key::Num7 => KeyCode::Char('7'), + egui::Key::Num8 => KeyCode::Char('8'), + egui::Key::Num9 => KeyCode::Char('9'), + egui::Key::Minus => KeyCode::Char('-'), + egui::Key::Equals => KeyCode::Char('='), + egui::Key::OpenBracket => KeyCode::Char('['), + egui::Key::CloseBracket => KeyCode::Char(']'), + egui::Key::Semicolon => KeyCode::Char(';'), + egui::Key::Comma => KeyCode::Char(','), + egui::Key::Period => KeyCode::Char('.'), + egui::Key::Slash => KeyCode::Char('/'), + egui::Key::Backslash => KeyCode::Char('\\'), + egui::Key::Backtick => KeyCode::Char('`'), + egui::Key::Quote => KeyCode::Char('\''), + _ => return None, + }) +} + +fn convert_modifiers(mods: egui::Modifiers) -> KeyModifiers { + let mut result = KeyModifiers::empty(); + if mods.shift { + result |= KeyModifiers::SHIFT; + } + if mods.ctrl || mods.command { + result |= KeyModifiers::CONTROL; + } + if mods.alt { + result |= KeyModifiers::ALT; + } + result +} + +fn is_character_key(key: egui::Key) -> bool { + matches!( + key, + egui::Key::A + | egui::Key::B + | egui::Key::C + | egui::Key::D + | egui::Key::E + | egui::Key::F + | egui::Key::G + | egui::Key::H + | egui::Key::I + | egui::Key::J + | egui::Key::K + | egui::Key::L + | egui::Key::M + | egui::Key::N + | egui::Key::O + | egui::Key::P + | egui::Key::Q + | egui::Key::R + | egui::Key::S + | egui::Key::T + | egui::Key::U + | egui::Key::V + | egui::Key::W + | egui::Key::X + | egui::Key::Y + | egui::Key::Z + | egui::Key::Num0 + | egui::Key::Num1 + | egui::Key::Num2 + | egui::Key::Num3 + | egui::Key::Num4 + | egui::Key::Num5 + | egui::Key::Num6 + | egui::Key::Num7 + | egui::Key::Num8 + | egui::Key::Num9 + | egui::Key::Space + | egui::Key::Minus + | egui::Key::Equals + | egui::Key::OpenBracket + | egui::Key::CloseBracket + | egui::Key::Semicolon + | egui::Key::Comma + | egui::Key::Period + | egui::Key::Slash + | egui::Key::Backslash + | egui::Key::Backtick + | egui::Key::Quote + ) +} diff --git a/src/lib.rs b/src/lib.rs index d850992..fe1f4b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,17 @@ pub use cagire_forth as forth; + +pub mod app; +pub mod commands; +pub mod engine; +pub mod input; pub mod model; +pub mod page; +pub mod services; +pub mod settings; +pub mod state; +pub mod theme; +pub mod views; +pub mod widgets; + +#[cfg(feature = "desktop")] +pub mod input_egui; diff --git a/src/main.rs b/src/main.rs index b18499f..9dabb1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod page; mod services; mod settings; mod state; +mod theme; mod views; mod widgets; diff --git a/src/settings.rs b/src/settings.rs index 73f26a9..34242c0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -34,6 +34,12 @@ pub struct DisplaySettings { pub show_completion: bool, #[serde(default = "default_flash_brightness")] pub flash_brightness: f32, + #[serde(default = "default_font")] + pub font: String, +} + +fn default_font() -> String { + "8x13".to_string() } fn default_flash_brightness() -> f32 { 1.0 } @@ -69,6 +75,7 @@ impl Default for DisplaySettings { show_spectrum: true, show_completion: true, flash_brightness: 1.0, + font: default_font(), } } } diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..48dc404 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,3 @@ +//! Re-export theme from cagire-ratatui crate. + +pub use cagire_ratatui::theme::*; diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs index 8d7ec33..1c30733 100644 --- a/src/views/dict_view.rs +++ b/src/views/dict_view.rs @@ -1,5 +1,5 @@ use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use ratatui::Frame; @@ -7,6 +7,7 @@ use ratatui::Frame; use crate::app::App; use crate::model::{Word, WORDS}; use crate::state::DictFocus; +use crate::theme::{dict, search}; const CATEGORIES: &[&str] = &[ // Forth core @@ -61,10 +62,10 @@ fn render_header(frame: &mut Frame, area: Rect) { pushes 7. This page lists all words with their signature ( inputs -- outputs )."; let block = Block::default() .borders(Borders::ALL) - .border_style(Style::new().fg(Color::Rgb(60, 60, 70))) + .border_style(Style::new().fg(dict::BORDER_NORMAL)) .title("Dictionary"); let para = Paragraph::new(desc) - .style(Style::new().fg(Color::Rgb(140, 145, 155))) + .style(Style::new().fg(dict::HEADER_DESC)) .wrap(Wrap { trim: false }) .block(block); frame.render_widget(para, area); @@ -79,20 +80,20 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) { .map(|(i, name)| { let is_selected = i == app.ui.dict_category; let style = if dimmed { - Style::new().fg(Color::Rgb(80, 80, 90)) + Style::new().fg(dict::CATEGORY_DIMMED) } else if is_selected && focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::new().fg(dict::CATEGORY_FOCUSED).add_modifier(Modifier::BOLD) } else if is_selected { - Style::new().fg(Color::Cyan) + Style::new().fg(dict::CATEGORY_SELECTED) } else { - Style::new().fg(Color::White) + Style::new().fg(dict::CATEGORY_NORMAL) }; let prefix = if is_selected && !dimmed { "> " } else { " " }; ListItem::new(format!("{prefix}{name}")).style(style) }) .collect(); - let border_color = if focused { Color::Yellow } else { Color::Rgb(60, 60, 70) }; + let border_color = if focused { dict::BORDER_FOCUSED } else { dict::BORDER_NORMAL }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) @@ -142,12 +143,12 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { let mut lines: Vec = Vec::new(); for word in &words { - let name_bg = Color::Rgb(40, 50, 60); + let name_bg = dict::WORD_BG; let name_style = Style::new() - .fg(Color::Green) + .fg(dict::WORD_NAME) .bg(name_bg) .add_modifier(Modifier::BOLD); - let alias_style = Style::new().fg(Color::DarkGray).bg(name_bg); + let alias_style = Style::new().fg(dict::ALIAS).bg(name_bg); let name_text = if word.aliases.is_empty() { format!(" {}", word.name) } else { @@ -167,19 +168,19 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { ])); } - let stack_style = Style::new().fg(Color::Magenta); + let stack_style = Style::new().fg(dict::STACK_SIG); lines.push(RLine::from(vec![ Span::raw(" "), Span::styled(word.stack.to_string(), stack_style), ])); - let desc_style = Style::new().fg(Color::White); + let desc_style = Style::new().fg(dict::DESCRIPTION); lines.push(RLine::from(vec![ Span::raw(" "), Span::styled(word.desc.to_string(), desc_style), ])); - let example_style = Style::new().fg(Color::Rgb(120, 130, 140)); + let example_style = Style::new().fg(dict::EXAMPLE); lines.push(RLine::from(vec![ Span::raw(" "), Span::styled(format!("e.g. {}", word.example), example_style), @@ -205,7 +206,7 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { let category = CATEGORIES[app.ui.dict_category]; format!("{category} ({} words)", words.len()) }; - let border_color = if focused { Color::Yellow } else { Color::Rgb(60, 60, 70) }; + let border_color = if focused { dict::BORDER_FOCUSED } else { dict::BORDER_NORMAL }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) @@ -216,9 +217,9 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { let style = if app.ui.dict_search_active { - Style::new().fg(Color::Yellow) + Style::new().fg(search::ACTIVE) } else { - Style::new().fg(Color::DarkGray) + Style::new().fg(search::INACTIVE) }; let cursor = if app.ui.dict_search_active { "_" } else { "" }; let text = format!(" /{}{}", app.ui.dict_search_query, cursor); diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index bee85d2..08a18b1 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -1,18 +1,15 @@ use cagire_ratatui::ListSelect; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; use ratatui::Frame; use crate::app::App; use crate::state::{DeviceKind, EngineSection, SettingKind}; +use crate::theme::{engine, meter}; use crate::widgets::{Orientation, Scope, Spectrum}; -const HEADER_COLOR: Color = Color::Rgb(100, 160, 180); -const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70); -const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95); - pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [left_col, _, right_col] = Layout::horizontal([ Constraint::Percentage(55), @@ -29,7 +26,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Engine ") - .border_style(Style::new().fg(Color::Magenta)); + .border_style(Style::new().fg(engine::BORDER_MAGENTA)); let inner = block.inner(area); frame.render_widget(block, area); @@ -125,7 +122,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { } // Scroll indicators - let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR); + let indicator_style = Style::new().fg(engine::SCROLL_INDICATOR); let indicator_x = padded.x + padded.width.saturating_sub(1); if scroll_offset > 0 { @@ -158,14 +155,14 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Scope ") - .border_style(Style::new().fg(Color::Green)); + .border_style(Style::new().fg(engine::BORDER_GREEN)); let inner = block.inner(area); frame.render_widget(block, area); let scope = Scope::new(&app.metrics.scope) .orientation(Orientation::Horizontal) - .color(Color::Green); + .color(meter::LOW); frame.render_widget(scope, inner); } @@ -173,7 +170,7 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Spectrum ") - .border_style(Style::new().fg(Color::Cyan)); + .border_style(Style::new().fg(engine::BORDER_CYAN)); let inner = block.inner(area); frame.render_widget(block, area); @@ -210,16 +207,16 @@ fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Re Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); let header_style = if focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::new().fg(engine::HEADER_FOCUSED).add_modifier(Modifier::BOLD) } else { - Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD) + Style::new().fg(engine::HEADER).add_modifier(Modifier::BOLD) }; frame.render_widget(Paragraph::new(title).style(header_style), header_area); let divider = "─".repeat(area.width as usize); frame.render_widget( - Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)), + Paragraph::new(divider).style(Style::new().fg(engine::DIVIDER)), divider_area, ); } @@ -254,7 +251,7 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) { section_focused, ); - let sep_style = Style::new().fg(Color::Rgb(60, 65, 75)); + let sep_style = Style::new().fg(engine::SEPARATOR); let sep_lines: Vec = (0..separator.height) .map(|_| Line::from(Span::styled("│", sep_style))) .collect(); @@ -289,11 +286,11 @@ fn render_device_column( Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); let label_style = if focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + Style::new().fg(engine::FOCUSED).add_modifier(Modifier::BOLD) } else if section_focused { - Style::new().fg(Color::Rgb(150, 155, 165)) + Style::new().fg(engine::LABEL_FOCUSED) } else { - Style::new().fg(Color::Rgb(100, 105, 115)) + Style::new().fg(engine::LABEL_DIM) }; let arrow = if focused { "> " } else { " " }; @@ -318,10 +315,10 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { render_section_header(frame, "SETTINGS", section_focused, header_area); - let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let normal = Style::new().fg(Color::White); - let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); + let highlight = Style::new().fg(engine::FOCUSED).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(engine::NORMAL); + let label_style = Style::new().fg(engine::LABEL); + let value_style = Style::new().fg(engine::VALUE); let channels_focused = section_focused && app.audio.setting_kind == SettingKind::Channels; let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize; @@ -438,8 +435,8 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) { 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(Color::Rgb(80, 85, 95)); - let path_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let dim = Style::new().fg(engine::DIM); + let path_style = Style::new().fg(engine::PATH); let mut lines: Vec = Vec::new(); if app.audio.config.sample_paths.is_empty() { @@ -470,15 +467,15 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(lines), content_area); let hint_style = if section_focused { - Style::new().fg(Color::Rgb(180, 180, 100)) + Style::new().fg(engine::HINT_ACTIVE) } else { - Style::new().fg(Color::Rgb(60, 60, 70)) + Style::new().fg(engine::HINT_INACTIVE) }; let hint = Line::from(vec![ Span::styled("A", hint_style), - Span::styled(":add ", Style::new().fg(Color::Rgb(80, 85, 95))), + Span::styled(":add ", Style::new().fg(engine::DIM)), Span::styled("D", hint_style), - Span::styled(":remove", Style::new().fg(Color::Rgb(80, 85, 95))), + Span::styled(":remove", Style::new().fg(engine::DIM)), ]); frame.render_widget(Paragraph::new(hint), hint_area); } diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 9f0b1db..3d81f42 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -1,12 +1,13 @@ use minimad::{Composite, CompositeStyle, Compound, Line}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap}; use ratatui::Frame; use tui_big_text::{BigText, PixelSize}; use crate::app::App; +use crate::theme::{dict, markdown, search, ui}; use crate::views::highlight; // To add a new help topic: drop a .md file in docs/ and add one line here. @@ -37,9 +38,9 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) { .map(|(i, (name, _))| { let selected = i == app.ui.help_topic; let style = if selected { - Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::new().fg(dict::CATEGORY_SELECTED).add_modifier(Modifier::BOLD) } else { - Style::new().fg(Color::White) + Style::new().fg(ui::TEXT_PRIMARY) }; let prefix = if selected { "> " } else { " " }; ListItem::new(format!("{prefix}{name}")).style(style) @@ -63,13 +64,13 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { .areas(area); let big_title = BigText::builder() .pixel_size(PixelSize::Quadrant) - .style(Style::new().cyan().bold()) + .style(Style::new().fg(markdown::H1).bold()) .lines(vec!["CAGIRE".into()]) .centered() .build(); let subtitle = Paragraph::new(RLine::from(Span::styled( "A Forth Sequencer", - Style::new().fg(Color::White), + Style::new().fg(ui::TEXT_PRIMARY), ))) .alignment(ratatui::layout::Alignment::Center); let [big_area, subtitle_area] = @@ -126,9 +127,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { let style = if app.ui.help_search_active { - Style::new().fg(Color::Yellow) + Style::new().fg(search::ACTIVE) } else { - Style::new().fg(Color::DarkGray) + Style::new().fg(search::INACTIVE) }; let cursor = if app.ui.help_search_active { "█" } else { "" }; let text = format!(" /{}{cursor}", app.ui.help_search_query); @@ -145,7 +146,7 @@ fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> { } let content = span.content.to_string(); let base_style = span.style; - let hl_style = base_style.bg(Color::Yellow).fg(Color::Black); + let hl_style = base_style.bg(search::MATCH_BG).fg(search::MATCH_FG); let mut start = 0; let lower_bytes = lower.as_bytes(); let query_bytes = query.as_bytes(); @@ -185,7 +186,7 @@ pub fn find_match(query: &str) -> Option<(usize, usize)> { } fn code_border_style() -> Style { - Style::new().fg(Color::Rgb(60, 60, 70)) + Style::new().fg(markdown::CODE_BORDER) } fn preprocess_underscores(md: &str) -> String { @@ -270,14 +271,14 @@ fn parse_markdown(md: &str) -> Vec> { fn composite_to_line(composite: Composite) -> RLine<'static> { let base_style = match composite.style { CompositeStyle::Header(1) => Style::new() - .fg(Color::Cyan) + .fg(markdown::H1) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), - CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD), - CompositeStyle::ListItem(_) => Style::new().fg(Color::White), - CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)), - CompositeStyle::Code => Style::new().fg(Color::Green), - CompositeStyle::Paragraph => Style::new().fg(Color::White), + CompositeStyle::Header(2) => Style::new().fg(markdown::H2).add_modifier(Modifier::BOLD), + CompositeStyle::Header(_) => Style::new().fg(markdown::H3).add_modifier(Modifier::BOLD), + CompositeStyle::ListItem(_) => Style::new().fg(markdown::LIST), + CompositeStyle::Quote => Style::new().fg(markdown::QUOTE), + CompositeStyle::Code => Style::new().fg(markdown::CODE), + CompositeStyle::Paragraph => Style::new().fg(markdown::TEXT), }; let prefix = match composite.style { @@ -308,7 +309,7 @@ fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec Style { - match self { - TokenKind::Emit => Style::default() - .fg(Color::Rgb(255, 255, 255)) - .bg(Color::Rgb(140, 50, 50)) - .add_modifier(Modifier::BOLD), - TokenKind::Number => Style::default() - .fg(Color::Rgb(255, 200, 120)) - .bg(Color::Rgb(60, 40, 15)), - TokenKind::String => Style::default() - .fg(Color::Rgb(150, 230, 150)) - .bg(Color::Rgb(20, 55, 20)), - TokenKind::Comment => Style::default() - .fg(Color::Rgb(100, 100, 100)) - .bg(Color::Rgb(18, 18, 18)), - TokenKind::Keyword => Style::default() - .fg(Color::Rgb(230, 130, 230)) - .bg(Color::Rgb(55, 25, 55)), - TokenKind::StackOp => Style::default() - .fg(Color::Rgb(130, 190, 240)) - .bg(Color::Rgb(20, 40, 70)), - TokenKind::Operator => Style::default() - .fg(Color::Rgb(220, 220, 140)) - .bg(Color::Rgb(45, 45, 20)), - TokenKind::Sound => Style::default() - .fg(Color::Rgb(100, 240, 220)) - .bg(Color::Rgb(15, 60, 55)), - TokenKind::Param => Style::default() - .fg(Color::Rgb(190, 160, 240)) - .bg(Color::Rgb(45, 30, 70)), - TokenKind::Context => Style::default() - .fg(Color::Rgb(240, 190, 120)) - .bg(Color::Rgb(60, 45, 20)), - TokenKind::Note => Style::default() - .fg(Color::Rgb(120, 220, 170)) - .bg(Color::Rgb(20, 55, 40)), - TokenKind::Interval => Style::default() - .fg(Color::Rgb(170, 220, 120)) - .bg(Color::Rgb(35, 55, 20)), - TokenKind::Variable => Style::default() - .fg(Color::Rgb(220, 150, 190)) - .bg(Color::Rgb(60, 30, 50)), - TokenKind::Vary => Style::default() - .fg(Color::Rgb(230, 230, 100)) - .bg(Color::Rgb(55, 55, 15)), - TokenKind::Generator => Style::default() - .fg(Color::Rgb(100, 220, 180)) - .bg(Color::Rgb(15, 55, 45)), - TokenKind::Default => Style::default() - .fg(Color::Rgb(160, 160, 160)) - .bg(Color::Rgb(25, 25, 25)), + let (fg, bg) = match self { + TokenKind::Emit => syntax::EMIT, + TokenKind::Number => syntax::NUMBER, + TokenKind::String => syntax::STRING, + TokenKind::Comment => syntax::COMMENT, + TokenKind::Keyword => syntax::KEYWORD, + TokenKind::StackOp => syntax::STACK_OP, + TokenKind::Operator => syntax::OPERATOR, + TokenKind::Sound => syntax::SOUND, + TokenKind::Param => syntax::PARAM, + TokenKind::Context => syntax::CONTEXT, + TokenKind::Note => syntax::NOTE, + TokenKind::Interval => syntax::INTERVAL, + TokenKind::Variable => syntax::VARIABLE, + TokenKind::Vary => syntax::VARY, + TokenKind::Generator => syntax::GENERATOR, + TokenKind::Default => syntax::DEFAULT, + }; + let style = Style::default().fg(fg).bg(bg); + if matches!(self, TokenKind::Emit) { + style.add_modifier(Modifier::BOLD) + } else { + style } } pub fn gap_style() -> Style { - Style::default().bg(Color::Rgb(25, 25, 25)) + Style::default().bg(syntax::GAP_BG) } } @@ -231,9 +205,6 @@ pub fn highlight_line_with_runtime( let tokens = tokenize_line(line); let mut result = Vec::new(); let mut last_end = 0; - - let executed_bg = Color::Rgb(40, 35, 50); - let selected_bg = Color::Rgb(80, 60, 20); let gap_style = TokenKind::gap_style(); for token in tokens { @@ -253,9 +224,9 @@ pub fn highlight_line_with_runtime( style = style.add_modifier(Modifier::UNDERLINED); } if is_selected { - style = style.bg(selected_bg).add_modifier(Modifier::BOLD); + style = style.bg(syntax::SELECTED_BG).add_modifier(Modifier::BOLD); } else if is_executed { - style = style.bg(executed_bg); + style = style.bg(syntax::EXECUTED_BG); } result.push((style, line[token.start..token.end].to_string())); diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 5207e77..5b8608a 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -5,6 +5,7 @@ use ratatui::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; +use crate::theme::{meter, selection, tile, ui}; use crate::widgets::{Orientation, Scope, Spectrum, VuMeter}; pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { @@ -67,7 +68,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, if area.width < 50 { let msg = Paragraph::new("Terminal too narrow") .alignment(Alignment::Center) - .style(Style::new().fg(Color::Rgb(120, 125, 135))); + .style(Style::new().fg(ui::TEXT_MUTED)); frame.render_widget(msg, area); return; } @@ -147,41 +148,27 @@ fn render_tile( }; let link_color = step.and_then(|s| s.source).map(|src| { - const BRIGHT: [(u8, u8, u8); 5] = [ - (180, 140, 220), - (220, 140, 170), - (220, 180, 130), - (130, 180, 220), - (170, 220, 140), - ]; - const DIM: [(u8, u8, u8); 5] = [ - (90, 70, 120), - (120, 70, 85), - (120, 90, 65), - (65, 90, 120), - (85, 120, 70), - ]; let i = src % 5; - (BRIGHT[i], DIM[i]) + (tile::LINK_BRIGHT[i], tile::LINK_DIM[i]) }); let (bg, fg) = match (is_playing, is_active, is_selected, is_linked, in_selection) { - (true, true, _, _, _) => (Color::Rgb(195, 85, 65), Color::White), - (true, false, _, _, _) => (Color::Rgb(180, 120, 45), Color::Black), + (true, true, _, _, _) => (tile::PLAYING_ACTIVE_BG, tile::PLAYING_ACTIVE_FG), + (true, false, _, _, _) => (tile::PLAYING_INACTIVE_BG, tile::PLAYING_INACTIVE_FG), (false, true, true, true, _) => { let (r, g, b) = link_color.unwrap().0; - (Color::Rgb(r, g, b), Color::Black) + (Color::Rgb(r, g, b), selection::CURSOR_FG) } - (false, true, true, false, _) => (Color::Rgb(0, 220, 180), Color::Black), - (false, true, _, _, true) => (Color::Rgb(0, 170, 140), Color::Black), + (false, true, true, false, _) => (tile::ACTIVE_SELECTED_BG, selection::CURSOR_FG), + (false, true, _, _, true) => (tile::ACTIVE_IN_RANGE_BG, selection::CURSOR_FG), (false, true, false, true, _) => { let (r, g, b) = link_color.unwrap().1; - (Color::Rgb(r, g, b), Color::White) + (Color::Rgb(r, g, b), tile::ACTIVE_FG) } - (false, true, false, false, _) => (Color::Rgb(45, 106, 95), Color::White), - (false, false, true, _, _) => (Color::Rgb(80, 180, 255), Color::Black), - (false, false, _, _, true) => (Color::Rgb(60, 140, 200), Color::Black), - (false, false, false, _, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), + (false, true, false, false, _) => (tile::ACTIVE_BG, tile::ACTIVE_FG), + (false, false, true, _, _) => (selection::SELECTED, selection::CURSOR_FG), + (false, false, _, _, true) => (selection::IN_RANGE, selection::CURSOR_FG), + (false, false, false, _, _) => (tile::INACTIVE_BG, tile::INACTIVE_FG), }; let source_idx = step.and_then(|s| s.source); @@ -246,7 +233,7 @@ fn render_tile( fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let scope = Scope::new(&app.metrics.scope) .orientation(Orientation::Horizontal) - .color(Color::Green); + .color(meter::LOW); frame.render_widget(scope, area); } diff --git a/src/views/options_view.rs b/src/views/options_view.rs index a4c2c18..cab427e 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -1,5 +1,5 @@ use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; @@ -7,17 +7,13 @@ use ratatui::Frame; use crate::app::App; use crate::engine::LinkState; use crate::state::OptionsFocus; - -const LABEL_COLOR: Color = Color::Rgb(120, 125, 135); -const HEADER_COLOR: Color = Color::Rgb(100, 160, 180); -const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70); -const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95); +use crate::theme::{hint, link_status, modal, ui, values}; pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Options ") - .border_style(Style::new().fg(Color::Cyan)); + .border_style(Style::new().fg(modal::INPUT)); let inner = block.inner(area); frame.render_widget(block, area); @@ -36,11 +32,11 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let enabled = link.is_enabled(); let peers = link.peers(); let (status_text, status_color) = if !enabled { - ("DISABLED", Color::Rgb(120, 60, 60)) + ("DISABLED", link_status::DISABLED) } else if peers > 0 { - ("CONNECTED", Color::Rgb(60, 120, 60)) + ("CONNECTED", link_status::CONNECTED) } else { - ("LISTENING", Color::Rgb(120, 120, 60)) + ("LISTENING", link_status::LISTENING) }; let peer_text = if enabled && peers > 0 { if peers == 1 { @@ -55,14 +51,14 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let link_header = Line::from(vec![ Span::styled( "ABLETON LINK", - Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD), + Style::new().fg(ui::HEADER).add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled( status_text, Style::new().fg(status_color).add_modifier(Modifier::BOLD), ), - Span::styled(peer_text, Style::new().fg(LABEL_COLOR)), + Span::styled(peer_text, Style::new().fg(ui::TEXT_MUTED)), ]); // Prepare values @@ -72,10 +68,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let beat_str = format!("{:.2}", link.beat()); let phase_str = format!("{:.2}", link.phase()); - let tempo_style = Style::new() - .fg(Color::Rgb(220, 180, 100)) - .add_modifier(Modifier::BOLD); - let value_style = Style::new().fg(Color::Rgb(140, 145, 155)); + let tempo_style = Style::new().fg(values::TEMPO).add_modifier(Modifier::BOLD); + let value_style = Style::new().fg(values::VALUE); // Build flat list of all lines let lines: Vec = vec![ @@ -178,7 +172,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { frame.render_widget(Paragraph::new(visible_lines), padded); // Render scroll indicators - let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR); + let indicator_style = Style::new().fg(ui::TEXT_DIM); let indicator_x = padded.x + padded.width.saturating_sub(1); if scroll_offset > 0 { @@ -201,21 +195,21 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { fn render_section_header(title: &str) -> Line<'static> { Line::from(Span::styled( title.to_string(), - Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD), + Style::new().fg(ui::HEADER).add_modifier(Modifier::BOLD), )) } fn render_divider(width: usize) -> Line<'static> { Line::from(Span::styled( "─".repeat(width), - Style::new().fg(DIVIDER_COLOR), + Style::new().fg(ui::BORDER), )) } fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> { - let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let normal = Style::new().fg(Color::White); - let label_style = Style::new().fg(LABEL_COLOR); + let highlight = Style::new().fg(hint::KEY).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(ui::TEXT_PRIMARY); + let label_style = Style::new().fg(ui::TEXT_MUTED); let prefix = if focused { "> " } else { " " }; let prefix_style = if focused { highlight } else { normal }; @@ -238,7 +232,7 @@ fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line } fn render_readonly_line<'a>(label: &'a str, value: &'a str, value_style: Style) -> Line<'a> { - let label_style = Style::new().fg(LABEL_COLOR); + let label_style = Style::new().fg(ui::TEXT_MUTED); let label_width = 20; let padded_label = format!("{label: (Color::Cyan, Color::Black, ""), - (false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "), - (false, false, true) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "), - (false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""), - (false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), - (false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""), + (true, _, _) => (selection::CURSOR, selection::CURSOR_FG, ""), + (false, true, _) => (list::PLAYING_BG, list::PLAYING_FG, "> "), + (false, false, true) => (list::STAGED_PLAY_BG, list::STAGED_PLAY_FG, "+ "), + (false, false, false) if is_selected => (list::HOVER_BG, list::HOVER_FG, ""), + (false, false, false) if is_edit => (list::EDIT_BG, list::EDIT_FG, ""), + (false, false, false) => (ui::BG, ui::TEXT_MUTED, ""), }; let name = app.project_state.project.banks[idx] @@ -139,7 +136,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area } // Scroll indicators - let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let indicator_style = Style::new().fg(ui::TEXT_MUTED); if scroll_offset > 0 { let indicator = Paragraph::new("▲") .style(indicator_style) @@ -163,11 +160,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let [title_area, inner] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); - let title_color = if is_focused { - Color::Rgb(100, 160, 180) - } else { - Color::Rgb(70, 75, 85) - }; + let title_color = if is_focused { ui::HEADER } else { ui::UNFOCUSED }; let bank = app.patterns_nav.bank_cursor; let bank_name = app.project_state.project.banks[bank].name.as_deref(); @@ -256,13 +249,13 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let is_staged_stop = staged_to_stop.contains(&idx); let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) { - (true, _, _, _) => (Color::Cyan, Color::Black, ""), - (false, true, _, true) => (Color::Rgb(120, 60, 80), Color::Magenta, "- "), - (false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "), - (false, false, true, _) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "), - (false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""), - (false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), - (false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""), + (true, _, _, _) => (selection::CURSOR, selection::CURSOR_FG, ""), + (false, true, _, true) => (list::STAGED_STOP_BG, list::STAGED_STOP_FG, "- "), + (false, true, _, false) => (list::PLAYING_BG, list::PLAYING_FG, "> "), + (false, false, true, _) => (list::STAGED_PLAY_BG, list::STAGED_PLAY_FG, "+ "), + (false, false, false, _) if is_selected => (list::HOVER_BG, list::HOVER_FG, ""), + (false, false, false, _) if is_edit => (list::EDIT_BG, list::EDIT_FG, ""), + (false, false, false, _) => (ui::BG, ui::TEXT_MUTED, ""), }; let pattern = &app.project_state.project.banks[bank].patterns[idx]; @@ -321,7 +314,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a } // Scroll indicators - let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let indicator_style = Style::new().fg(ui::TEXT_MUTED); if scroll_offset > 0 { let indicator = Paragraph::new("▲") .style(indicator_style) diff --git a/src/views/render.rs b/src/views/render.rs index 1df5ca8..1ecbc0d 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -18,6 +18,7 @@ use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::{SourceSpan, StepContext, Value}; use crate::page::Page; use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache}; +use crate::theme::{browser, flash, header, hint, modal, search, status, table, ui}; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; use crate::widgets::{ ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal, @@ -132,10 +133,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc let term = frame.area(); let bg_color = if app.ui.event_flash > 0.0 { - let i = (app.ui.event_flash * app.ui.flash_brightness * 60.0) as u8; - Color::Rgb(i, i, i) + let t = (app.ui.event_flash * app.ui.flash_brightness).min(1.0); + let (base_r, base_g, base_b) = ui::BG_RGB; + let (tgt_r, tgt_g, tgt_b) = flash::EVENT_RGB; + let r = base_r + ((tgt_r as f32 - base_r as f32) * t) as u8; + let g = base_g + ((tgt_g as f32 - base_g as f32) * t) as u8; + let b = base_b + ((tgt_b as f32 - base_b as f32) * t) as u8; + Color::Rgb(r, g, b) } else { - Color::Reset + ui::BG }; let blank = " ".repeat(term.width as usize); @@ -294,11 +300,11 @@ fn render_header( // Transport block let (transport_bg, transport_text) = if app.playback.playing { - (Color::Rgb(30, 80, 30), " ▶ PLAYING ") + (status::PLAYING_BG, " ▶ PLAYING ") } else { - (Color::Rgb(80, 30, 30), " ■ STOPPED ") + (status::STOPPED_BG, " ■ STOPPED ") }; - let transport_style = Style::new().bg(transport_bg).fg(Color::White); + let transport_style = Style::new().bg(transport_bg).fg(ui::TEXT_PRIMARY); frame.render_widget( Paragraph::new(transport_text) .style(transport_style) @@ -308,15 +314,8 @@ fn render_header( // Fill indicator let fill = app.live_keys.fill(); - let fill_style = if fill { - Style::new() - .bg(Color::Rgb(30, 30, 35)) - .fg(Color::Rgb(100, 220, 100)) - } else { - Style::new() - .bg(Color::Rgb(30, 30, 35)) - .fg(Color::Rgb(60, 60, 70)) - }; + let fill_fg = if fill { status::FILL_ON } else { status::FILL_OFF }; + let fill_style = Style::new().bg(status::FILL_BG).fg(fill_fg); frame.render_widget( Paragraph::new(if fill { "F" } else { "·" }) .style(fill_style) @@ -326,8 +325,8 @@ fn render_header( // Tempo block let tempo_style = Style::new() - .bg(Color::Rgb(60, 30, 60)) - .fg(Color::White) + .bg(header::TEMPO_BG) + .fg(ui::TEXT_PRIMARY) .add_modifier(Modifier::BOLD); frame.render_widget( Paragraph::new(format!(" {:.1} BPM ", link.tempo())) @@ -342,7 +341,7 @@ fn render_header( .as_deref() .map(|n| format!(" {n} ")) .unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1)); - let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White); + let bank_style = Style::new().bg(header::BANK_BG).fg(ui::TEXT_PRIMARY); frame.render_widget( Paragraph::new(bank_name) .style(bank_style) @@ -373,7 +372,7 @@ fn render_header( " {} · {} steps{}{}{} ", pattern_name, pattern.length, speed_info, page_info, iter_info ); - let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White); + let pattern_style = Style::new().bg(header::PATTERN_BG).fg(ui::TEXT_PRIMARY); frame.render_widget( Paragraph::new(pattern_text) .style(pattern_style) @@ -386,9 +385,7 @@ fn render_header( let peers = link.peers(); let voices = app.metrics.active_voices; let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} "); - let stats_style = Style::new() - .bg(Color::Rgb(35, 35, 40)) - .fg(Color::Rgb(150, 150, 160)); + let stats_style = Style::new().bg(header::STATS_BG).fg(header::STATS_FG); frame.render_widget( Paragraph::new(stats_text) .style(stats_style) @@ -415,10 +412,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { Line::from(vec![ Span::styled( page_indicator.to_string(), - Style::new().fg(Color::White).add_modifier(Modifier::DIM), + Style::new().fg(ui::TEXT_PRIMARY).add_modifier(Modifier::DIM), ), Span::raw(" "), - Span::styled(msg.clone(), Style::new().fg(Color::Yellow)), + Span::styled(msg.clone(), Style::new().fg(modal::CONFIRM)), ]) } else { let bindings: Vec<(&str, &str)> = match app.page { @@ -480,7 +477,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { let mut spans = vec![ Span::styled( page_indicator.to_string(), - Style::new().fg(Color::White).add_modifier(Modifier::DIM), + Style::new().fg(ui::TEXT_PRIMARY).add_modifier(Modifier::DIM), ), Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })), ]; @@ -488,11 +485,11 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { for (i, (key, action)) in bindings.into_iter().enumerate() { spans.push(Span::styled( key.to_string(), - Style::new().fg(Color::Yellow), + Style::new().fg(hint::KEY), )); spans.push(Span::styled( format!(":{action}"), - Style::new().fg(Color::Rgb(120, 125, 135)), + Style::new().fg(hint::TEXT), )); if i < n - 1 { @@ -542,8 +539,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term use crate::state::file_browser::FileBrowserMode; use crate::widgets::FileBrowserModal; let (title, border_color) = match state.mode { - FileBrowserMode::Save => ("Save As", Color::Green), - FileBrowserMode::Load => ("Load From", Color::Blue), + FileBrowserMode::Save => ("Save As", flash::SUCCESS_FG), + FileBrowserMode::Load => ("Load From", browser::DIRECTORY), }; let entries: Vec<(String, bool, bool)> = state .entries @@ -561,7 +558,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term Modal::RenameBank { bank, name } => { TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) .width(40) - .border_color(Color::Magenta) + .border_color(modal::RENAME) .render_centered(frame, term); } Modal::RenamePattern { @@ -574,13 +571,13 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term name, ) .width(40) - .border_color(Color::Magenta) + .border_color(modal::RENAME) .render_centered(frame, term); } Modal::RenameStep { step, name, .. } => { TextInputModal::new(&format!("Name Step {:02}", step + 1), name) .width(40) - .border_color(Color::Cyan) + .border_color(modal::INPUT) .render_centered(frame, term); } Modal::SetPattern { field, input } => { @@ -591,14 +588,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term TextInputModal::new(title, input) .hint(hint) .width(45) - .border_color(Color::Yellow) + .border_color(modal::CONFIRM) .render_centered(frame, term); } Modal::SetTempo(input) => { TextInputModal::new("Set Tempo (20-300 BPM)", input) .hint("Enter BPM") .width(30) - .border_color(Color::Magenta) + .border_color(modal::RENAME) .render_centered(frame, term); } Modal::AddSamplePath(state) => { @@ -611,7 +608,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term FileBrowserModal::new("Add Sample Path", &state.input, &entries) .selected(state.selected) .scroll_offset(state.scroll_offset) - .border_color(Color::Magenta) + .border_color(modal::RENAME) .width(60) .height(18) .render_centered(frame, term); @@ -636,14 +633,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let inner = ModalFrame::new(&title) .width(width) .height(height) - .border_color(Color::Rgb(120, 125, 135)) + .border_color(modal::PREVIEW) .render_centered(frame, term); let script = pattern.resolve_script(step_idx).unwrap_or(""); if script.is_empty() { let empty = Paragraph::new("(empty)") .alignment(Alignment::Center) - .style(Style::new().fg(Color::Rgb(80, 85, 95))); + .style(Style::new().fg(ui::TEXT_DIM)); let centered_area = Rect { y: inner.y + inner.height / 2, height: 1, @@ -698,10 +695,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let flash_kind = app.ui.flash_kind(); let border_color = match flash_kind { - Some(FlashKind::Error) => Color::Red, - Some(FlashKind::Info) => Color::White, - Some(FlashKind::Success) => Color::Green, - None => Color::Rgb(100, 160, 180), + Some(FlashKind::Error) => flash::ERROR_FG, + Some(FlashKind::Info) => ui::TEXT_PRIMARY, + Some(FlashKind::Success) => flash::SUCCESS_FG, + None => modal::EDITOR, }; let title = if let Some(ref name) = step.and_then(|s| s.name.as_ref()) { @@ -768,9 +765,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term if let Some(sa) = search_area { let style = if app.editor_ctx.editor.search_active() { - Style::default().fg(Color::Yellow) + Style::default().fg(search::ACTIVE) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(search::INACTIVE) }; let cursor = if app.editor_ctx.editor.search_active() { "_" @@ -783,9 +780,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term if let Some(kind) = flash_kind { let bg = match kind { - FlashKind::Error => Color::Rgb(60, 10, 10), - FlashKind::Info => Color::Rgb(30, 30, 40), - FlashKind::Success => Color::Rgb(10, 30, 10), + FlashKind::Error => flash::ERROR_BG, + FlashKind::Info => flash::INFO_BG, + FlashKind::Success => flash::SUCCESS_BG, }; let flash_block = Block::default().style(Style::default().bg(bg)); frame.render_widget(flash_block, editor_area); @@ -794,8 +791,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .editor .render(frame, editor_area, &highlighter); - let dim = Style::default().fg(Color::DarkGray); - let key = Style::default().fg(Color::Yellow); + let dim = Style::default().fg(hint::TEXT); + let key = Style::default().fg(hint::KEY); if app.editor_ctx.editor.search_active() { let hint = Line::from(vec![ @@ -863,7 +860,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let block = Block::bordered() .title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) - .border_style(Style::default().fg(Color::Cyan)); + .border_style(Style::default().fg(modal::INPUT)); let inner = block.inner(area); frame.render_widget(Clear, area); @@ -898,14 +895,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let (label_style, value_style) = if *selected { ( Style::default() - .fg(Color::Cyan) + .fg(hint::KEY) .add_modifier(Modifier::BOLD), - Style::default().fg(Color::White).bg(Color::DarkGray), + Style::default().fg(ui::TEXT_PRIMARY).bg(ui::SURFACE), ) } else { ( - Style::default().fg(Color::Gray), - Style::default().fg(Color::White), + Style::default().fg(ui::TEXT_MUTED), + Style::default().fg(ui::TEXT_PRIMARY), ) }; @@ -920,17 +917,17 @@ 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 hint = Line::from(vec![ - Span::styled("↑↓", Style::default().fg(Color::Yellow)), - Span::styled(" nav ", Style::default().fg(Color::DarkGray)), - Span::styled("←→", Style::default().fg(Color::Yellow)), - Span::styled(" change ", Style::default().fg(Color::DarkGray)), - Span::styled("Enter", Style::default().fg(Color::Yellow)), - Span::styled(" save ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc", Style::default().fg(Color::Yellow)), - Span::styled(" cancel", Style::default().fg(Color::DarkGray)), + let hint_line = Line::from(vec![ + Span::styled("↑↓", Style::default().fg(hint::KEY)), + Span::styled(" nav ", Style::default().fg(hint::TEXT)), + Span::styled("←→", Style::default().fg(hint::KEY)), + Span::styled(" change ", Style::default().fg(hint::TEXT)), + Span::styled("Enter", Style::default().fg(hint::KEY)), + Span::styled(" save ", Style::default().fg(hint::TEXT)), + Span::styled("Esc", Style::default().fg(hint::KEY)), + Span::styled(" cancel", Style::default().fg(hint::TEXT)), ]); - frame.render_widget(Paragraph::new(hint), hint_area); + frame.render_widget(Paragraph::new(hint_line), hint_area); } Modal::KeybindingsHelp { scroll } => { let width = (term.width * 80 / 100).clamp(60, 100); @@ -940,7 +937,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let inner = ModalFrame::new(&title) .width(width) .height(height) - .border_color(Color::Rgb(100, 160, 180)) + .border_color(modal::EDITOR) .render_centered(frame, term); let bindings = super::keybindings::bindings_for(app.page); @@ -952,15 +949,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .skip(*scroll) .take(visible_rows) .map(|(i, (key, name, desc))| { - let bg = if i % 2 == 0 { - Color::Rgb(25, 25, 30) - } else { - Color::Rgb(35, 35, 42) - }; + let bg = if i % 2 == 0 { table::ROW_EVEN } else { table::ROW_ODD }; Row::new(vec![ - Cell::from(*key).style(Style::default().fg(Color::Yellow)), - Cell::from(*name).style(Style::default().fg(Color::Cyan)), - Cell::from(*desc).style(Style::default().fg(Color::White)), + Cell::from(*key).style(Style::default().fg(modal::CONFIRM)), + Cell::from(*name).style(Style::default().fg(modal::INPUT)), + Cell::from(*desc).style(Style::default().fg(ui::TEXT_PRIMARY)), ]) .style(Style::default().bg(bg)) }) @@ -990,15 +983,15 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term width: inner.width, height: 1, }; - let hint = Line::from(vec![ - Span::styled("↑↓", Style::default().fg(Color::Yellow)), - Span::styled(" scroll ", Style::default().fg(Color::DarkGray)), - Span::styled("PgUp/Dn", Style::default().fg(Color::Yellow)), - Span::styled(" page ", Style::default().fg(Color::DarkGray)), - Span::styled("Esc/?", Style::default().fg(Color::Yellow)), - Span::styled(" close", Style::default().fg(Color::DarkGray)), + let keybind_hint = Line::from(vec![ + Span::styled("↑↓", Style::default().fg(hint::KEY)), + Span::styled(" scroll ", Style::default().fg(hint::TEXT)), + Span::styled("PgUp/Dn", Style::default().fg(hint::KEY)), + Span::styled(" page ", Style::default().fg(hint::TEXT)), + Span::styled("Esc/?", Style::default().fg(hint::KEY)), + Span::styled(" close", Style::default().fg(hint::TEXT)), ]); - frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + frame.render_widget(Paragraph::new(keybind_hint).alignment(Alignment::Right), hint_area); } } } diff --git a/src/views/title_view.rs b/src/views/title_view.rs index 5d9d5c6..a497916 100644 --- a/src/views/title_view.rs +++ b/src/views/title_view.rs @@ -1,22 +1,23 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; -use ratatui::style::{Color, Style, Stylize}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; use tui_big_text::{BigText, PixelSize}; use crate::state::ui::UiState; +use crate::theme::title; pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { frame.render_widget(&ui.sparkles, area); - let author_style = Style::new().fg(Color::Rgb(180, 140, 200)); - let link_style = Style::new().fg(Color::Rgb(120, 200, 180)); - let license_style = Style::new().fg(Color::Rgb(200, 160, 100)); + let author_style = Style::new().fg(title::AUTHOR); + let link_style = Style::new().fg(title::LINK); + let license_style = Style::new().fg(title::LICENSE); let big_title = BigText::builder() .pixel_size(PixelSize::Quadrant) - .style(Style::new().cyan().bold()) + .style(Style::new().fg(title::BIG_TITLE).bold()) .lines(vec!["CAGIRE".into()]) .centered() .build(); @@ -25,7 +26,7 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { Line::from(""), Line::from(Span::styled( "A Forth Music Sequencer", - Style::new().fg(Color::White), + Style::new().fg(title::SUBTITLE), )), Line::from(""), Line::from(Span::styled("by BuboBubo", author_style)), @@ -37,7 +38,7 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { Line::from(""), Line::from(Span::styled( "Press any key to continue", - Style::new().fg(Color::Rgb(140, 160, 170)), + Style::new().fg(title::PROMPT), )), ];