Monster commit: native version

This commit is contained in:
2026-01-30 15:03:49 +01:00
parent 22ee5f97e6
commit 2731eea037
35 changed files with 1491 additions and 366 deletions

View File

@@ -1,8 +1,8 @@
name: CI
on:
workflow_dispatch:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]

View File

@@ -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"

View File

@@ -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

View File

@@ -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"] }

View File

@@ -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()
};

View File

@@ -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<Line> = (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:<width$}", width = list_width as usize - 2);
Line::from(Span::styled(display, style.bg(Color::Rgb(30, 30, 40))))
Line::from(Span::styled(display, style.bg(editor_widget::COMPLETION_BG)))
})
.collect();
@@ -450,15 +451,15 @@ impl Editor {
let candidate = &self.completion.candidates[selected_idx];
let name_style = Style::default()
.fg(Color::Yellow)
.fg(editor_widget::COMPLETION_SELECTED)
.add_modifier(Modifier::BOLD)
.bg(Color::Rgb(30, 30, 40));
.bg(editor_widget::COMPLETION_BG);
let desc_style = Style::default()
.fg(Color::White)
.bg(Color::Rgb(30, 30, 40));
.fg(editor_widget::COMPLETION_FG)
.bg(editor_widget::COMPLETION_BG);
let example_style = Style::default()
.fg(Color::Rgb(120, 200, 160))
.bg(Color::Rgb(30, 30, 40));
.fg(editor_widget::COMPLETION_EXAMPLE)
.bg(editor_widget::COMPLETION_BG);
let w = doc_width as usize;
let mut doc_lines: Vec<Line> = Vec::new();

View File

@@ -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)))
})

View File

@@ -9,6 +9,7 @@ mod scope;
mod sparkles;
mod spectrum;
mod text_input;
pub mod theme;
mod vu_meter;
pub use confirm::ConfirmModal;

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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(),
}
};

View File

@@ -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,
}
}

View File

@@ -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)));
}
}

View File

@@ -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;

View File

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

377
crates/ratatui/src/theme.rs Normal file
View File

@@ -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;
}

View File

@@ -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
}
}
}

View File

@@ -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(),

531
src/bin/desktop.rs Normal file
View File

@@ -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<std::path::PathBuf>,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long)]
input: Option<String>,
#[arg(short, long)]
channels: Option<u16>,
#[arg(short, long)]
buffer: Option<u32>,
}
#[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<RataguiBackend<EmbeddedGraphics>>;
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::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
}
struct CagireDesktop {
app: App,
terminal: TerminalType,
link: Arc<LinkState>,
sequencer: Option<SequencerHandle>,
playing: Arc<AtomicBool>,
nudge_us: Arc<AtomicI64>,
lookahead_ms: Arc<AtomicU32>,
metrics: Arc<EngineMetrics>,
scope_buffer: Arc<ScopeBuffer>,
spectrum_buffer: Arc<SpectrumBuffer>,
audio_sample_pos: Arc<AtomicU64>,
sample_rate_shared: Arc<AtomicU32>,
_stream: Option<cpal::Stream>,
_analysis_handle: Option<AnalysisHandle>,
current_font: FontChoice,
pending_font: Option<FontChoice>,
}
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)))),
)
}

View File

@@ -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,
};

193
src/input_egui.rs Normal file
View File

@@ -0,0 +1,193 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub fn convert_egui_events(ctx: &egui::Context) -> Vec<KeyEvent> {
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<KeyEvent> {
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<KeyCode> {
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
)
}

View File

@@ -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;

View File

@@ -7,6 +7,7 @@ mod page;
mod services;
mod settings;
mod state;
mod theme;
mod views;
mod widgets;

View File

@@ -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(),
}
}
}

3
src/theme.rs Normal file
View File

@@ -0,0 +1,3 @@
//! Re-export theme from cagire-ratatui crate.
pub use cagire_ratatui::theme::*;

View File

@@ -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<RLine> = 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);

View File

@@ -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<Line> = (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<Line> = 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);
}

View File

@@ -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<RLine<'static>> {
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<Span<'static
style = style.add_modifier(Modifier::ITALIC);
}
if compound.code {
style = Style::new().fg(Color::Green);
style = Style::new().fg(markdown::CODE);
}
if compound.strikeout {
style = style.add_modifier(Modifier::CROSSED_OUT);
@@ -316,7 +317,7 @@ fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec<Span<'static
let src = compound.src.to_string();
let link_style = Style::new()
.fg(Color::Rgb(120, 200, 180))
.fg(markdown::LINK)
.add_modifier(Modifier::UNDERLINED);
let mut rest = src.as_str();
@@ -336,7 +337,7 @@ fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec<Span<'static
out.push(Span::styled(text.to_string(), link_style));
out.push(Span::styled(
format!(" ({url})"),
Style::new().fg(Color::Rgb(100, 100, 100)),
Style::new().fg(markdown::LINK_URL),
));
}
rest = &rest[url_start + url_end + 1..];

View File

@@ -1,6 +1,7 @@
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::{Modifier, Style};
use crate::model::{SourceSpan, WordCompile, WORDS};
use crate::theme::syntax;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
@@ -24,61 +25,34 @@ pub enum TokenKind {
impl TokenKind {
pub fn style(self) -> 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()));

View File

@@ -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);
}

View File

@@ -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<Line> = 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:<label_width$}");

View File

@@ -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, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
@@ -8,6 +8,7 @@ use crate::app::App;
use crate::engine::SequencerSnapshot;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::state::PatternsColumn;
use crate::theme::{list, selection, ui};
const MIN_ROW_HEIGHT: u16 = 1;
@@ -31,11 +32,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
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 title = Paragraph::new("Banks")
.style(Style::new().fg(title_color))
.alignment(ratatui::layout::Alignment::Center);
@@ -94,12 +91,12 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
let is_staged = banks_with_staged.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) {
(true, _, _) => (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)

View File

@@ -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);
}
}
}

View File

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