274 lines
10 KiB
Rust
274 lines
10 KiB
Rust
use std::sync::atomic::{AtomicBool, AtomicI64};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use arc_swap::ArcSwap;
|
|
use crossbeam_channel::Sender;
|
|
use egui_ratatui::RataguiBackend;
|
|
use nih_plug::prelude::*;
|
|
use nih_plug_egui::egui;
|
|
use nih_plug_egui::{create_egui_editor, EguiState};
|
|
use ratatui::Terminal;
|
|
use soft_ratatui::embedded_graphics_unicodefonts::{
|
|
mono_10x20_atlas, 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,
|
|
};
|
|
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
|
|
|
|
use cagire::app::App;
|
|
use cagire::engine::{AudioCommand, LinkState, SequencerSnapshot};
|
|
use cagire::input::{handle_key, handle_mouse, InputContext};
|
|
use cagire::model::{Dictionary, Rng, Variables};
|
|
use cagire::theme;
|
|
use cagire::views;
|
|
|
|
use crate::input_egui::{convert_egui_events, convert_egui_mouse};
|
|
use crate::params::CagireParams;
|
|
use crate::PluginBridge;
|
|
|
|
type TerminalType = Terminal<RataguiBackend<EmbeddedGraphics>>;
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum FontChoice {
|
|
Size6x13,
|
|
Size7x13,
|
|
Size8x13,
|
|
Size9x15,
|
|
Size9x18,
|
|
Size10x20,
|
|
}
|
|
|
|
impl FontChoice {
|
|
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,
|
|
];
|
|
}
|
|
|
|
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 EditorState {
|
|
app: App,
|
|
terminal: TerminalType,
|
|
link: Arc<LinkState>,
|
|
snapshot: SequencerSnapshot,
|
|
playing: Arc<AtomicBool>,
|
|
nudge_us: Arc<AtomicI64>,
|
|
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
|
|
bridge: Arc<PluginBridge>,
|
|
params: Arc<CagireParams>,
|
|
last_frame: Instant,
|
|
current_font: FontChoice,
|
|
zoom_factor: f32,
|
|
}
|
|
|
|
// SAFETY: EditorState is only accessed from the GUI thread via RwLock.
|
|
// The non-Send types (RefCell in App's tachyonfx effects) are never shared
|
|
// across threads — baseview's event loop guarantees single-threaded access.
|
|
unsafe impl Send for EditorState {}
|
|
unsafe impl Sync for EditorState {}
|
|
|
|
pub fn create_editor(
|
|
params: Arc<CagireParams>,
|
|
egui_state: Arc<EguiState>,
|
|
bridge: Arc<PluginBridge>,
|
|
variables: Variables,
|
|
dict: Dictionary,
|
|
rng: Rng,
|
|
) -> Option<Box<dyn Editor>> {
|
|
create_egui_editor(
|
|
egui_state,
|
|
None::<EditorState>,
|
|
|ctx, _state| {
|
|
ctx.set_visuals(egui::Visuals {
|
|
panel_fill: egui::Color32::BLACK,
|
|
..egui::Visuals::dark()
|
|
});
|
|
},
|
|
move |ctx, _setter, state| {
|
|
let editor = state.get_or_insert_with(|| {
|
|
let palette =
|
|
cagire::state::ColorScheme::default().to_palette();
|
|
theme::set(cagire_ratatui::theme::build::build(&palette));
|
|
|
|
let mut app = App::new_plugin(
|
|
Arc::clone(&variables),
|
|
Arc::clone(&dict),
|
|
Arc::clone(&rng),
|
|
);
|
|
app.audio.section = cagire::state::EngineSection::Settings;
|
|
app.audio.setting_kind = cagire::state::SettingKind::Polyphony;
|
|
|
|
// Load persisted project
|
|
app.project_state.project = params.project.lock().clone();
|
|
app.mark_all_patterns_dirty();
|
|
|
|
EditorState {
|
|
app,
|
|
terminal: create_terminal(FontChoice::Size8x13),
|
|
link: Arc::new(LinkState::new(
|
|
params.tempo.value() as f64,
|
|
4.0,
|
|
)),
|
|
snapshot: SequencerSnapshot::empty(),
|
|
playing: Arc::new(AtomicBool::new(false)),
|
|
nudge_us: Arc::new(AtomicI64::new(0)),
|
|
audio_tx: Arc::new(ArcSwap::from_pointee(bridge.audio_cmd_tx.clone())),
|
|
bridge: Arc::clone(&bridge),
|
|
params: Arc::clone(¶ms),
|
|
last_frame: Instant::now(),
|
|
current_font: FontChoice::Size8x13,
|
|
zoom_factor: 1.0,
|
|
}
|
|
});
|
|
|
|
// Flush pattern data and queued changes to the audio thread
|
|
let had_dirty = editor.app.flush_dirty_patterns(&editor.bridge.cmd_tx);
|
|
editor.app.flush_queued_changes(&editor.bridge.cmd_tx);
|
|
|
|
// Sync project changes back to persisted params
|
|
if had_dirty {
|
|
*editor.params.project.lock() = editor.app.project_state.project.clone();
|
|
}
|
|
|
|
// Sync sample registry from the audio engine (set once after initialize)
|
|
if editor.app.audio.sample_registry.is_none() {
|
|
if let Some(reg) = editor.bridge.sample_registry.load().as_ref() {
|
|
editor.app.audio.sample_registry = Some(Arc::clone(reg));
|
|
}
|
|
}
|
|
|
|
// Read live snapshot from the audio thread
|
|
let shared = editor.bridge.shared_state.load();
|
|
editor.snapshot = SequencerSnapshot::from(shared.as_ref());
|
|
|
|
// Feed scope and spectrum data into app metrics
|
|
editor.app.metrics.scope = editor.bridge.scope_buffer.read();
|
|
(editor.app.metrics.peak_left, editor.app.metrics.peak_right) =
|
|
editor.bridge.scope_buffer.peaks();
|
|
editor.app.metrics.spectrum = editor.bridge.spectrum_buffer.read();
|
|
|
|
// Handle input
|
|
let term = editor.terminal.get_frame().area();
|
|
let widget_rect = ctx.content_rect();
|
|
|
|
for mouse in convert_egui_mouse(ctx, widget_rect, term) {
|
|
let mut input_ctx = InputContext {
|
|
app: &mut editor.app,
|
|
link: &editor.link,
|
|
snapshot: &editor.snapshot,
|
|
playing: &editor.playing,
|
|
audio_tx: &editor.audio_tx,
|
|
seq_cmd_tx: &editor.bridge.cmd_tx,
|
|
nudge_us: &editor.nudge_us,
|
|
};
|
|
handle_mouse(&mut input_ctx, mouse, term);
|
|
}
|
|
|
|
for key in convert_egui_events(ctx) {
|
|
let mut input_ctx = InputContext {
|
|
app: &mut editor.app,
|
|
link: &editor.link,
|
|
snapshot: &editor.snapshot,
|
|
playing: &editor.playing,
|
|
audio_tx: &editor.audio_tx,
|
|
seq_cmd_tx: &editor.bridge.cmd_tx,
|
|
nudge_us: &editor.nudge_us,
|
|
};
|
|
let _ = handle_key(&mut input_ctx, key);
|
|
}
|
|
|
|
cagire::state::effects::tick_effects(&mut editor.app.ui, editor.app.page);
|
|
|
|
let elapsed = editor.last_frame.elapsed();
|
|
editor.last_frame = Instant::now();
|
|
|
|
let link = &editor.link;
|
|
let app = &editor.app;
|
|
let snapshot = &editor.snapshot;
|
|
editor
|
|
.terminal
|
|
.draw(|frame| views::render(frame, app, link, snapshot, elapsed))
|
|
.expect("draw");
|
|
|
|
let current_font = editor.current_font;
|
|
let current_zoom = editor.zoom_factor;
|
|
|
|
egui::CentralPanel::default()
|
|
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
|
|
.show(ctx, |ui| {
|
|
ui.add(editor.terminal.backend_mut());
|
|
|
|
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 {
|
|
if ui.selectable_label(current_font == choice, choice.label()).clicked() {
|
|
editor.terminal = create_terminal(choice);
|
|
editor.current_font = choice;
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
ui.menu_button("Zoom", |ui| {
|
|
for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
|
|
let selected = (current_zoom - level).abs() < 0.01;
|
|
let label = format!("{:.0}%", level * 100.0);
|
|
if ui.selectable_label(selected, label).clicked() {
|
|
editor.zoom_factor = level;
|
|
ctx.set_zoom_factor(level);
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
},
|
|
)
|
|
}
|