Files
Cagire/crates/plugins/src/editor.rs

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