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::block_renderer::BlockCharBackend; 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>; #[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, ]; } 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 eg = SoftBackend::::new(80, 24, regular, bold, italic); let soft = SoftBackend { buffer: eg.buffer, cursor: eg.cursor, cursor_pos: eg.cursor_pos, char_width: eg.char_width, char_height: eg.char_height, blink_counter: eg.blink_counter, blinking_fast: eg.blinking_fast, blinking_slow: eg.blinking_slow, rgb_pixmap: eg.rgb_pixmap, always_redraw_list: eg.always_redraw_list, raster_backend: BlockCharBackend { inner: eg.raster_backend, }, }; Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") } struct EditorState { app: App, terminal: TerminalType, link: Arc, snapshot: SequencerSnapshot, playing: Arc, nudge_us: Arc, audio_tx: Arc>>, bridge: Arc, params: Arc, 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, egui_state: Arc, bridge: Arc, variables: Variables, dict: Dictionary, rng: Rng, ) -> Option> { let egui_state_clone = Arc::clone(&egui_state); create_egui_editor( egui_state, None::, |ctx, _state| { ctx.set_visuals(egui::Visuals { panel_fill: egui::Color32::BLACK, ..egui::Visuals::dark() }); ctx.style_mut(|style| { style.spacing.window_margin = egui::Margin::ZERO; }); }, 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(); // Init window size from egui state let (w, h) = egui_state_clone.size(); app.ui.window_width = w; app.ui.window_height = h; 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()); // Sync host tempo into LinkState so title bar shows real tempo if shared.tempo > 0.0 { editor.link.set_tempo(shared.tempo); } // 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); // Sync font/zoom from App state (options page or context menu may have changed them) let desired_font = FontChoice::from_setting(&editor.app.ui.font); if desired_font != editor.current_font { editor.terminal = create_terminal(desired_font); editor.current_font = desired_font; } if (editor.app.ui.zoom_factor - editor.zoom_factor).abs() > 0.01 { editor.zoom_factor = editor.app.ui.zoom_factor; ctx.set_zoom_factor(editor.zoom_factor); } // Sync window size: if app state differs from egui state, request resize let (cur_w, cur_h) = egui_state_clone.size(); if editor.app.ui.window_width != cur_w || editor.app.ui.window_height != cur_h { egui_state_clone.set_requested_size((editor.app.ui.window_width, editor.app.ui.window_height)); } 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) .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.app.ui.font = choice.to_setting().to_string(); 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.app.ui.zoom_factor = level; editor.zoom_factor = level; ctx.set_zoom_factor(level); ui.close(); } } }); }); }); }, ) }