diff --git a/CHANGELOG.md b/CHANGELOG.md index a22d5ea..3ccfd59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,14 @@ All notable changes to this project will be documented in this file. - TachyonFX based animations +### Changed +- Hue rotation step size increased from 1° to 5° for faster adjustment. +- Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion. +- Extracted shared initialization into `src/init.rs`, deduplicating ~140 lines between terminal and desktop binaries. + ### Fixed +- Desktop binary now loads color theme and connects MIDI devices on startup (was missing). +- Audio commands no longer silently dropped when channel is full; switched to unbounded channel matching MIDI dispatch pattern. - PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default. - Changing pattern properties is now a stage/commit operation. - Changing pattern speed only happens at pattern boundaries. diff --git a/refacto.md b/refacto.md new file mode 100644 index 0000000..83e8e7d --- /dev/null +++ b/refacto.md @@ -0,0 +1,227 @@ +# Refactoring Plan: Isolation and Lower Connascence + +This document outlines practical, incremental refactoring tasks to improve architectural isolation and reduce connascence in this codebase. The goal is to make changes safe and reversible, while reducing coupling between UI, engine, and state. + +Principles: +- Preserve behavior. Refactor in small steps with clear boundaries. +- Reduce connascence by making dependencies explicit and localized. +- Make render paths pure views over state. +- Centralize bootstrapping and cross-cutting wiring. + +## Task 1: Extract Shared Bootstrap (Detailed Checklist) +Problem: +- `src/main.rs` and `src/bin/desktop.rs` duplicate long initialization sequences (settings, audio config, MIDI discovery, samples, sequencer startup). This creates drift and implicit coupling. + +Refactor: +- Create a `bootstrap` module (e.g., `src/bootstrap.rs`) that returns a fully configured `App`, `SequencerHandle`, and any shared runtime state. +- Both binaries call into this module with minimal differences (e.g., terminal vs desktop display setup). + +Checklist: +1. Inventory duplicated blocks + - In `src/main.rs`, locate: settings load, link setup, `App::new`, audio config assignment, MIDI load, sample scan, sequencer spawn, `build_stream`. + - In `src/bin/desktop.rs`, locate the analogous blocks (very similar ordering). +2. Define a shared config struct + - Create a `BootstrapArgs` struct with fields: `samples`, `output`, `input`, `channels`, `buffer`. + - This mirrors CLI args from both binaries. +3. Extract app init + - Create `fn build_app(settings: &Settings, args: &BootstrapArgs) -> App` in a new module (e.g., `src/bootstrap.rs`). + - Move these from both binaries: + - `App::new()` + - app playback queued changes setup + - audio config assignments from settings/args + - UI settings (runtime_highlight, show_scope, show_spectrum, completion, color scheme/hue in terminal path) + - MIDI device selection (output/input device selection based on stored names) +4. Extract audio/sample init + - Create `fn load_samples(paths: &[PathBuf]) -> (Vec, usize)` returning the sample list and count. + - Replace inline `scan_samples_dir` loops in both binaries. +5. Extract sequencer init + - Create `fn build_sequencer(app: &App, settings: &Settings, link: Arc, ...) -> SequencerHandle` that: + - Builds `SequencerConfig` + - Calls `spawn_sequencer` + - Returns the handle + receivers needed for MIDI/audio. +6. Extract stream init + - Create `fn build_audio_stream(config: &AudioStreamConfig, ...) -> Result<(Stream, StreamInfo, AnalysisHandle), Error>` + - Use it in both binaries to set `app.audio` fields. +7. Replace duplicated code + - Update `src/main.rs` and `src/bin/desktop.rs` to call the bootstrap functions. +8. Keep env-specific parts in binary + - Terminal UI init (`crossterm` setup) remains in `src/main.rs`. + - Desktop font/egui setup remains in `src/bin/desktop.rs`. + +Outcome: +- One place to reason about initialization and one place to fix config drift. + +Connascence reduced: +- Reduced connascence of meaning and position across two binary entry points. + +## Task 2: Remove State-to-View Layer Inversion (Detailed Checklist) +Problem: +- `UiState::default` depends on view modules (`dict_view::category_count()`, `help_view::topic_count()`). This inverts layering and creates brittle dependencies. + +Refactor: +- Move those counts into a pure data module, for example `src/state/ui_defaults.rs` or `src/data/help_topics.rs`. +- `UiState::default` should depend only on state/data modules, not views. + +Checklist: +1. Identify view-level sources + - `src/views/help_view.rs` defines topics using `Topic(...)` list. + - `src/views/dict_view.rs` defines category list and `category_count()`. +2. Create a pure data module + - Add a new module (e.g., `src/data/help_dict.rs`) with: + - `pub const HELP_TOPIC_COUNT: usize = ...` + - `pub const DICT_CATEGORY_COUNT: usize = ...` + - If the counts are derived from arrays, move the arrays into this module and re-export. +3. Update views to depend on data module + - `help_view::topic_count()` should return `HELP_TOPIC_COUNT`. + - `dict_view::category_count()` should return `DICT_CATEGORY_COUNT`. +4. Update `UiState::default` + - Replace calls to view modules with `data` module constants in `src/state/ui.rs`. +5. Confirm no other state files import `views` + - Use `rg "views::" src/state` to verify. + +Outcome: +- UI state is decoupled from rendering code. + +Connascence reduced: +- Reduced connascence of type (views no longer required by state). + +## Task 3: Split App Dispatch into Subsystem Handlers (Detailed Checklist) +Problem: +- `App::dispatch` is a very large match that mutates many unrelated parts of state. This makes the App a God object and introduces high connascence across domains. + +Refactor: +- Introduce subsystem handlers: `ProjectController`, `PlaybackController`, `UiController`, `AudioController`, `MidiController`. +- Dispatch routes to a handler based on command domain. + +Checklist: +1. Partition commands by domain + - Playback: `TogglePlaying`, `TempoUp`, `TempoDown`, staging changes. + - Project edits: rename/save/load, pattern/bank changes. + - UI-only: modals, status, help/dict navigation. + - Audio settings: device list navigation, buffer/channels settings. + - MIDI: connect/disconnect, CC, etc. +2. Introduce handler modules + - Create `src/controllers/` (or `src/handlers/`) with `playback.rs`, `project.rs`, `ui.rs`, `audio.rs`, `midi.rs`. +3. Move code in thin slices + - Start with the smallest domain (UI-only commands). + - Move those match arms into `UiController::handle` and call from `App::dispatch`. + - Repeat for each domain. +4. Define minimal interfaces + - Each controller takes only the state it needs (e.g., `&mut UiState`, not `&mut App`). +5. Keep `App::dispatch` as a router + - Single match that forwards to controller or returns early. +6. Reduce `App` field exposure + - Make fields private where possible and provide accessors for controllers. +7. Update tests (if/when added) + - New controllers can be unit-tested without UI/engine wiring. + +Outcome: +- Clear separation of concerns. Smaller, testable units. + +Connascence reduced: +- Reduced connascence of execution and meaning inside one massive switch. + +## Task 4: Make Rendering Pure (No Side Effects / Heavy Work) (Detailed Checklist) +Problem: +- Rendering does real work (`compute_stack_display` runs a Forth evaluation each frame). This couples rendering to runtime behavior and risks UI jank. + +Refactor: +- Compute stack previews in a controller step when editor state changes, store result in `UiState` or a new cache state. +- Renderers become pure read-only views of state. + +Checklist: +1. Locate render-time work + - `src/views/render.rs` → `compute_stack_display` builds a Forth engine and evaluates script. +2. Introduce cache state + - Add a `StackPreview` struct to `src/state/editor.rs` or `src/state/ui.rs`: + - `last_cursor_line`, `lines_hash`, `result`. +3. Define update points + - Update cache when editor content changes or cursor moves. + - Likely locations: + - `input.rs` when editor receives input. + - `app.rs` functions that mutate editor state. +4. Move compute logic to controller/service + - Create `src/services/stack_preview.rs` that computes preview from editor state. + - Call it from update points, store in state. +5. Make render pure + - Replace `compute_stack_display` usage with cached state from `UiState`/`EditorContext`. +6. Verify consistency + - Ensure preview updates on paste and modal editor inputs (see `Event::Paste` handling in `src/main.rs`). + +Outcome: +- Rendering becomes deterministic and cheap. + +Connascence reduced: +- Reduced connascence of timing (render cycle no longer coupled to evaluation). + +## Task 5: Concurrency Boundary for Script Runtime (Detailed Checklist) +Problem: +- `variables`, `dict`, `rng` are shared between UI and sequencer threads via `Arc` and `ArcSwap`. This is a hidden concurrency coupling and risks contention in RT threads. + +Refactor: +- Introduce a message-based interface: UI posts commands to a dedicated script runtime in the sequencer thread. +- UI no longer directly mutates shared `dict`/`vars`. + +Checklist: +1. Identify shared state + - `App::new` constructs `variables`, `dict`, `rng`, `script_engine`. + - These are passed into `spawn_sequencer` in `src/main.rs`. +2. Define a runtime command channel + - New enum `ScriptRuntimeCommand` (e.g., `Reset`, `UpdateVar`, `ClearDict`, etc.). + - Add channel to sequencer config and main loop. +3. Move ownership into sequencer thread + - `spawn_sequencer` creates its own `variables/dict/rng` and `ScriptEngine`. + - Sequencer thread consumes `ScriptRuntimeCommand`s and applies changes. +4. Update UI interaction points + - Anywhere UI currently mutates `variables` or `dict` should send a runtime command instead. + - Example: `App::dispatch` branch that does `self.variables.store(...)` / `self.dict.lock().clear()`. +5. Replace direct shared references + - Remove `ArcSwap` and `Mutex` from `App` for script runtime. +6. Verify audio thread safety + - Ensure no locks are taken in RT critical sections. + +Outcome: +- Clear ownership and fewer concurrency hazards. + +Connascence reduced: +- Reduced connascence of timing and execution across threads. + +## Task 6: Isolate Model from Re-Exports (Detailed Checklist) +Problem: +- `src/model/mod.rs` is mostly re-exports. This blurs boundaries and encourages wide imports. + +Refactor: +- Introduce a narrower domain API layer (e.g., `model::ProjectFacade`) that exposes only the subset the app needs. +- Limit broad re-exports. + +Checklist: +1. Identify heavy imports + - `use crate::model::{...}` in `src/app.rs`, `src/input.rs`, `src/views/*`. +2. Create small facade modules + - e.g., `src/model/project_facade.rs` exporting only `Project`, `Pattern`, `Bank`, etc. + - e.g., `src/model/script_facade.rs` for script-related types. +3. Replace `pub use` in `model/mod.rs` + - Narrow to explicit re-exports or remove when not needed. +4. Update call sites + - Replace `crate::model::*` with focused imports from facade modules. +5. Document boundary + - Add brief module docs explaining what is safe to use externally. + +Outcome: +- Clearer ownership and lower coupling to external crate internals. + +Connascence reduced: +- Reduced connascence of meaning across module boundaries. + +## Suggested Order +- Task 1 (bootstrap) and Task 2 (state/view decoupling) first. These are low risk and improve clarity. +- Task 3 (split dispatch) next to make the codebase easier to change. +- Task 4 (pure rendering) and Task 5 (runtime boundary) after the above to avoid broad ripple effects. +- Task 6 (model isolation) last, because it benefits from clearer boundaries established earlier. + +## Success Criteria +- Startup is defined in one module and reused by both binaries. +- `UiState` compiles without importing anything from `views`. +- `App::dispatch` reduced by at least 50% and sub-handlers exist. +- Render path is a pure function of state (no evaluation, no locks). +- Script runtime state is owned by the sequencer thread. diff --git a/src/app.rs b/src/app.rs index 9be8036..5bc081a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ use crate::state::{ MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState, }; -use crate::views::{dict_view, help_view}; +use crate::model::{categories, docs}; const STEPS_PER_PAGE: usize = 32; @@ -1240,11 +1240,11 @@ impl App { }; } AppCommand::HelpNextTopic(n) => { - let count = help_view::topic_count(); + let count = docs::topic_count(); self.ui.help_topic = (self.ui.help_topic + n) % count; } AppCommand::HelpPrevTopic(n) => { - let count = help_view::topic_count(); + let count = docs::topic_count(); self.ui.help_topic = (self.ui.help_topic + count - (n % count)) % count; } AppCommand::HelpScrollDown(n) => { @@ -1264,7 +1264,7 @@ impl App { } AppCommand::HelpSearchInput(c) => { self.ui.help_search_query.push(c); - if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) { + if let Some((topic, line)) = docs::find_match(&self.ui.help_search_query) { self.ui.help_topic = topic; self.ui.help_scrolls[topic] = line; } @@ -1274,7 +1274,7 @@ impl App { if self.ui.help_search_query.is_empty() { return; } - if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) { + if let Some((topic, line)) = docs::find_match(&self.ui.help_search_query) { self.ui.help_topic = topic; self.ui.help_scrolls[topic] = line; } @@ -1291,11 +1291,11 @@ impl App { }; } AppCommand::DictNextCategory => { - let count = dict_view::category_count(); + let count = categories::category_count(); self.ui.dict_category = (self.ui.dict_category + 1) % count; } AppCommand::DictPrevCategory => { - let count = dict_view::category_count(); + let count = categories::category_count(); self.ui.dict_category = (self.ui.dict_category + count - 1) % count; } AppCommand::DictScrollDown(n) => { diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs index b3e8d0f..dd54f75 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop.rs @@ -15,15 +15,14 @@ use soft_ratatui::embedded_graphics_unicodefonts::{ }; use soft_ratatui::{EmbeddedGraphics, SoftBackend}; -use cagire::app::App; +use cagire::init::{init, InitArgs}; use cagire::engine::{ - build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, - ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer, + build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, + 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; use crossbeam_channel::Receiver; @@ -129,7 +128,7 @@ fn create_terminal(font: FontChoice) -> TerminalType { } struct CagireDesktop { - app: App, + app: cagire::app::App, terminal: TerminalType, link: Arc, sequencer: Option, @@ -153,139 +152,39 @@ struct CagireDesktop { impl CagireDesktop { fn new(cc: &eframe::CreationContext<'_>, args: Args) -> Self { - let settings = Settings::load(); + let b = init(InitArgs { + samples: args.samples, + output: args.output, + input: args.input, + channels: args.channels, + buffer: args.buffer, + }); - 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; - - 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 mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); - let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits())); - let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits())); - - 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), - cc_access: Some( - Arc::new(app.midi.cc_memory.clone()) as Arc - ), - mouse_x: Arc::clone(&mouse_x), - mouse_y: Arc::clone(&mouse_y), - mouse_down: Arc::clone(&mouse_down), - }; - - let (sequencer, initial_audio_rx, midi_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, info, analysis)) => { - app.audio.config.sample_rate = info.sample_rate; - sample_rate_shared.store(info.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 current_font = FontChoice::from_setting(&b.settings.display.font); let terminal = create_terminal(current_font); cc.egui_ctx.set_visuals(egui::Visuals::dark()); Self { - app, + app: b.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, - midi_rx, + link: b.link, + sequencer: Some(b.sequencer), + playing: b.playing, + nudge_us: b.nudge_us, + lookahead_ms: b.lookahead_ms, + metrics: b.metrics, + scope_buffer: b.scope_buffer, + spectrum_buffer: b.spectrum_buffer, + audio_sample_pos: b.audio_sample_pos, + sample_rate_shared: b.sample_rate_shared, + _stream: b.stream, + _analysis_handle: b.analysis_handle, + midi_rx: b.midi_rx, current_font, - mouse_x, - mouse_y, - mouse_down, + mouse_x: b.mouse_x, + mouse_y: b.mouse_y, + mouse_down: b.mouse_down, last_frame: std::time::Instant::now(), } } @@ -334,6 +233,8 @@ impl CagireDesktop { self._stream = Some(new_stream); self._analysis_handle = Some(new_analysis); self.app.audio.config.sample_rate = info.sample_rate; + self.app.audio.config.host_name = info.host_name; + self.app.audio.config.channels = info.channels; self.sample_rate_shared .store(info.sample_rate as u32, Ordering::Relaxed); self.app.audio.error = None; @@ -419,7 +320,6 @@ impl eframe::App for CagireDesktop { 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.flush_queued_changes(&sequencer.cmd_tx); self.app.flush_dirty_patterns(&sequencer.cmd_tx); @@ -562,7 +462,6 @@ fn load_icon() -> egui::IconData { } fn main() -> eframe::Result<()> { - // Lock memory BEFORE any threads are spawned to prevent page faults in RT context #[cfg(unix)] cagire::engine::realtime::lock_memory(); diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index f61a372..e2abea5 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -154,14 +154,12 @@ pub struct SharedSequencerState { pub active_patterns: Vec, pub step_traces: Arc, pub event_count: usize, - pub dropped_events: usize, } pub struct SequencerSnapshot { pub active_patterns: Vec, step_traces: Arc, pub event_count: usize, - pub dropped_events: usize, } impl SequencerSnapshot { @@ -205,12 +203,11 @@ impl SequencerHandle { active_patterns: state.active_patterns.clone(), step_traces: Arc::clone(&state.step_traces), event_count: state.event_count, - dropped_events: state.dropped_events, } } pub fn swap_audio_channel(&self) -> Receiver { - let (new_tx, new_rx) = bounded::(256); + let (new_tx, new_rx) = unbounded::(); self.audio_tx.store(Arc::new(new_tx)); new_rx } @@ -294,7 +291,7 @@ pub fn spawn_sequencer( Receiver, ) { let (cmd_tx, cmd_rx) = bounded::(64); - let (audio_tx, audio_rx) = bounded::(256); + let (audio_tx, audio_rx) = unbounded::(); let (midi_tx, midi_rx) = bounded::(256); let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx)); @@ -535,7 +532,6 @@ pub(crate) struct SequencerState { runs_counter: RunsCounter, step_traces: Arc, event_count: usize, - dropped_events: usize, script_engine: ScriptEngine, variables: Variables, speed_overrides: HashMap<(usize, usize), f64>, @@ -564,7 +560,6 @@ impl SequencerState { runs_counter: RunsCounter::new(), step_traces: Arc::new(HashMap::new()), event_count: 0, - dropped_events: 0, script_engine, variables, speed_overrides: HashMap::with_capacity(MAX_PATTERNS), @@ -1057,7 +1052,6 @@ impl SequencerState { .collect(), step_traces: Arc::clone(&self.step_traces), event_count: self.event_count, - dropped_events: self.dropped_events, } } } @@ -1175,7 +1169,7 @@ fn sequencer_loop( } } else { // Audio direct to doux — sample-accurate scheduling via /time/ parameter - let _ = audio_tx.load().try_send(AudioCommand::Evaluate { + let _ = audio_tx.load().send(AudioCommand::Evaluate { cmd: tsc.cmd, time: tsc.time, }); diff --git a/src/init.rs b/src/init.rs new file mode 100644 index 0000000..e0735a8 --- /dev/null +++ b/src/init.rs @@ -0,0 +1,217 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; + +use crossbeam_channel::Receiver; +use doux::EngineMetrics; + +use crate::app::App; +use crate::engine::{ + build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, + PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer, +}; +use crate::midi; +use crate::model; +use crate::settings::Settings; +use crate::state::audio::RefreshRate; +use crate::state::StagedChange; +use crate::theme; + +pub struct InitArgs { + pub samples: Vec, + pub output: Option, + pub input: Option, + pub channels: Option, + pub buffer: Option, +} + +pub struct Init { + pub app: App, + pub link: Arc, + pub sequencer: SequencerHandle, + pub playing: Arc, + pub nudge_us: Arc, + pub lookahead_ms: Arc, + pub metrics: Arc, + pub scope_buffer: Arc, + pub spectrum_buffer: Arc, + pub audio_sample_pos: Arc, + pub sample_rate_shared: Arc, + pub stream: Option, + pub analysis_handle: Option, + pub midi_rx: Receiver, + #[cfg(feature = "desktop")] + pub settings: Settings, + #[cfg(feature = "desktop")] + pub mouse_x: Arc, + #[cfg(feature = "desktop")] + pub mouse_y: Arc, + #[cfg(feature = "desktop")] + pub mouse_down: Arc, +} + +pub fn init(args: InitArgs) -> Init { + 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(StagedChange { + change: PatternChange::Start { + bank: 0, + pattern: 0, + }, + quantization: model::LaunchQuantization::Immediate, + sync_mode: model::SyncMode::Reset, + }); + + app.audio.config.output_device = args.output.or(settings.audio.output_device.clone()); + app.audio.config.input_device = args.input.or(settings.audio.input_device.clone()); + 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.color_scheme = settings.display.color_scheme; + app.ui.hue_rotation = settings.display.hue_rotation; + app.audio.config.layout = settings.display.layout; + + let base_theme = settings.display.color_scheme.to_theme(); + let rotated = + cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation); + theme::set(rotated); + + // MIDI connections + let outputs = midi::list_midi_outputs(); + let inputs = midi::list_midi_inputs(); + for (slot, name) in settings.midi.output_devices.iter().enumerate() { + if !name.is_empty() { + if let Some(idx) = outputs.iter().position(|d| &d.name == name) { + let _ = app.midi.connect_output(slot, idx); + } + } + } + for (slot, name) in settings.midi.input_devices.iter().enumerate() { + if !name.is_empty() { + if let Some(idx) = inputs.iter().position(|d| &d.name == name) { + let _ = app.midi.connect_input(slot, idx); + } + } + } + + 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); + } + + #[cfg(feature = "desktop")] + let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); + #[cfg(feature = "desktop")] + let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits())); + #[cfg(feature = "desktop")] + let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits())); + + 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), + cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc), + #[cfg(feature = "desktop")] + mouse_x: Arc::clone(&mouse_x), + #[cfg(feature = "desktop")] + mouse_y: Arc::clone(&mouse_y), + #[cfg(feature = "desktop")] + mouse_down: Arc::clone(&mouse_down), + }; + + let (sequencer, initial_audio_rx, midi_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, info, analysis)) => { + app.audio.config.sample_rate = info.sample_rate; + app.audio.config.host_name = info.host_name; + app.audio.config.channels = info.channels; + sample_rate_shared.store(info.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(); + + Init { + app, + link, + sequencer, + playing, + nudge_us, + lookahead_ms, + metrics, + scope_buffer, + spectrum_buffer, + audio_sample_pos, + sample_rate_shared, + stream, + analysis_handle, + midi_rx, + #[cfg(feature = "desktop")] + settings, + #[cfg(feature = "desktop")] + mouse_x, + #[cfg(feature = "desktop")] + mouse_y, + #[cfg(feature = "desktop")] + mouse_down, + } +} diff --git a/src/input.rs b/src/input.rs index 3b3f58b..9cd29a8 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1435,7 +1435,7 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.dispatch(AppCommand::SetColorScheme(new_scheme)); } OptionsFocus::HueRotation => { - let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 }; + let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 }; let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0); ctx.dispatch(AppCommand::SetHueRotation(new_rotation)); } diff --git a/src/lib.rs b/src/lib.rs index e921b28..a620a3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub use cagire_forth as forth; pub mod app; +pub mod init; pub mod commands; pub mod engine; pub mod input; diff --git a/src/main.rs b/src/main.rs index 319ecfe..6cd7b47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod init; mod commands; mod engine; mod input; @@ -14,7 +15,7 @@ mod widgets; use std::io; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -24,18 +25,12 @@ use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use crossterm::ExecutableCommand; -use doux::EngineMetrics; use ratatui::prelude::CrosstermBackend; use ratatui::Terminal; -use app::App; -use engine::{ - build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SequencerConfig, - SpectrumBuffer, -}; +use engine::{build_stream, AudioStreamConfig}; +use init::InitArgs; use input::{handle_key, InputContext, InputResult}; -use settings::Settings; -use state::audio::RefreshRate; #[derive(Parser)] #[command(name = "cagire", version, about = "Forth-based live coding sequencer")] @@ -62,149 +57,33 @@ struct Args { } fn main() -> io::Result<()> { - // Lock memory BEFORE any threads are spawned to prevent page faults in RT context #[cfg(unix)] engine::realtime::lock_memory(); let args = Args::parse(); - let settings = Settings::load(); - let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum)); - if settings.link.enabled { - link.enable(); - } + let b = init::init(InitArgs { + samples: args.samples, + output: args.output, + input: args.input, + channels: args.channels, + buffer: args.buffer, + }); - 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(crate::state::StagedChange { - change: engine::PatternChange::Start { - bank: 0, - pattern: 0, - }, - quantization: crate::model::LaunchQuantization::Immediate, - sync_mode: crate::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.color_scheme = settings.display.color_scheme; - app.ui.hue_rotation = settings.display.hue_rotation; - app.audio.config.layout = settings.display.layout; - let base_theme = settings.display.color_scheme.to_theme(); - let rotated = - cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation); - theme::set(rotated); - - // Load MIDI settings - let outputs = midi::list_midi_outputs(); - let inputs = midi::list_midi_inputs(); - for (slot, name) in settings.midi.output_devices.iter().enumerate() { - if !name.is_empty() { - if let Some(idx) = outputs.iter().position(|d| &d.name == name) { - let _ = app.midi.connect_output(slot, idx); - } - } - } - for (slot, name) in settings.midi.input_devices.iter().enumerate() { - if !name.is_empty() { - if let Some(idx) = inputs.iter().position(|d| &d.name == name) { - let _ = app.midi.connect_input(slot, idx); - } - } - } - - 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); - } - - #[cfg(feature = "desktop")] - let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); - #[cfg(feature = "desktop")] - let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits())); - #[cfg(feature = "desktop")] - let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits())); - - 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), - cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc), - #[cfg(feature = "desktop")] - mouse_x: Arc::clone(&mouse_x), - #[cfg(feature = "desktop")] - mouse_y: Arc::clone(&mouse_y), - #[cfg(feature = "desktop")] - mouse_down: Arc::clone(&mouse_down), - }; - - let (sequencer, initial_audio_rx, mut midi_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 (mut _stream, mut _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, info, analysis)) => { - app.audio.config.sample_rate = info.sample_rate; - app.audio.config.host_name = info.host_name; - app.audio.config.channels = info.channels; - sample_rate_shared.store(info.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 mut app = b.app; + let link = b.link; + let sequencer = b.sequencer; + let playing = b.playing; + let nudge_us = b.nudge_us; + let lookahead_ms = b.lookahead_ms; + let metrics = b.metrics; + let scope_buffer = b.scope_buffer; + let spectrum_buffer = b.spectrum_buffer; + let audio_sample_pos = b.audio_sample_pos; + let sample_rate_shared = b.sample_rate_shared; + let mut _stream = b.stream; + let mut _analysis_handle = b.analysis_handle; + let mut midi_rx = b.midi_rx; enable_raw_mode()?; io::stdout().execute(EnableBracketedPaste)?; @@ -268,7 +147,6 @@ fn main() -> io::Result<()> { app.playback.playing = playing.load(Ordering::Relaxed); - // Process pending MIDI commands while let Ok(midi_cmd) = midi_rx.try_recv() { match midi_cmd { engine::MidiCommand::NoteOn { @@ -335,7 +213,6 @@ fn main() -> io::Result<()> { let seq_snapshot = sequencer.snapshot(); app.metrics.event_count = seq_snapshot.event_count; - app.metrics.dropped_events = seq_snapshot.dropped_events; app.flush_queued_changes(&sequencer.cmd_tx); app.flush_dirty_patterns(&sequencer.cmd_tx); diff --git a/src/model/categories.rs b/src/model/categories.rs new file mode 100644 index 0000000..0adfc14 --- /dev/null +++ b/src/model/categories.rs @@ -0,0 +1,65 @@ +pub enum CatEntry { + Section(&'static str), + Category(&'static str), +} + +use CatEntry::{Category, Section}; + +pub const CATEGORIES: &[CatEntry] = &[ + // Forth core + Section("Forth"), + Category("Stack"), + Category("Arithmetic"), + Category("Comparison"), + Category("Logic"), + Category("Control"), + Category("Variables"), + Category("Probability"), + Category("Definitions"), + // Live coding + Section("Live Coding"), + Category("Sound"), + Category("Time"), + Category("Context"), + Category("Music"), + Category("LFO"), + // Synthesis + Section("Synthesis"), + Category("Oscillator"), + Category("Wavetable"), + Category("Generator"), + Category("Envelope"), + Category("Sample"), + // Effects + Section("Effects"), + Category("Filter"), + Category("FM"), + Category("Modulation"), + Category("Mod FX"), + Category("Lo-fi"), + Category("Stereo"), + Category("Delay"), + Category("Reverb"), + // External I/O + Section("I/O"), + Category("MIDI"), + Category("Desktop"), +]; + +pub fn category_count() -> usize { + CATEGORIES + .iter() + .filter(|e| matches!(e, Category(_))) + .count() +} + +pub fn get_category_name(index: usize) -> &'static str { + CATEGORIES + .iter() + .filter_map(|e| match e { + Category(name) => Some(*name), + Section(_) => None, + }) + .nth(index) + .unwrap_or("Unknown") +} diff --git a/src/model/docs.rs b/src/model/docs.rs new file mode 100644 index 0000000..82943a1 --- /dev/null +++ b/src/model/docs.rs @@ -0,0 +1,86 @@ +pub enum DocEntry { + Section(&'static str), + Topic(&'static str, &'static str), +} + +use DocEntry::{Section, Topic}; + +pub const DOCS: &[DocEntry] = &[ + // Getting Started + Section("Getting Started"), + Topic("Welcome", include_str!("../../docs/welcome.md")), + Topic("Moving Around", include_str!("../../docs/navigation.md")), + Topic( + "How Does It Work?", + include_str!("../../docs/how_it_works.md"), + ), + Topic( + "Banks & Patterns", + include_str!("../../docs/banks_patterns.md"), + ), + Topic("Stage / Commit", include_str!("../../docs/staging.md")), + Topic("Using the Sequencer", include_str!("../../docs/grid.md")), + Topic("Editing a Step", include_str!("../../docs/editing.md")), + // Forth fundamentals + Section("Forth"), + Topic("About Forth", include_str!("../../docs/about_forth.md")), + Topic("The Dictionary", include_str!("../../docs/dictionary.md")), + Topic("The Stack", include_str!("../../docs/stack.md")), + Topic("Creating Words", include_str!("../../docs/definitions.md")), + Topic("Oddities", include_str!("../../docs/oddities.md")), + // Audio Engine + Section("Audio Engine"), + Topic("Introduction", include_str!("../../docs/engine_intro.md")), + Topic("Settings", include_str!("../../docs/engine_settings.md")), + Topic("Sources", include_str!("../../docs/engine_sources.md")), + Topic("Samples", include_str!("../../docs/engine_samples.md")), + Topic("Wavetables", include_str!("../../docs/engine_wavetable.md")), + Topic("Filters", include_str!("../../docs/engine_filters.md")), + Topic( + "Modulation", + include_str!("../../docs/engine_modulation.md"), + ), + Topic( + "Distortion", + include_str!("../../docs/engine_distortion.md"), + ), + Topic("Space & Time", include_str!("../../docs/engine_space.md")), + Topic("Words & Sounds", include_str!("../../docs/engine_words.md")), + // MIDI + Section("MIDI"), + Topic("Introduction", include_str!("../../docs/midi_intro.md")), + Topic("MIDI Output", include_str!("../../docs/midi_output.md")), + Topic("MIDI Input", include_str!("../../docs/midi_input.md")), +]; + +pub fn topic_count() -> usize { + DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count() +} + +pub fn get_topic(index: usize) -> Option<(&'static str, &'static str)> { + DOCS.iter() + .filter_map(|e| match e { + Topic(name, content) => Some((*name, *content)), + Section(_) => None, + }) + .nth(index) +} + +pub fn find_match(query: &str) -> Option<(usize, usize)> { + let query = query.to_lowercase(); + for (topic_idx, (_, content)) in DOCS + .iter() + .filter_map(|e| match e { + Topic(name, content) => Some((*name, *content)), + Section(_) => None, + }) + .enumerate() + { + for (line_idx, line) in content.lines().enumerate() { + if line.to_lowercase().contains(&query) { + return Some((topic_idx, line_idx)); + } + } + } + None +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 4298009..483998e 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,5 @@ +pub mod categories; +pub mod docs; mod script; pub use cagire_forth::{lookup_word, Word, WordCompile, WORDS}; diff --git a/src/state/audio.rs b/src/state/audio.rs index b7d6071..8755db1 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -188,7 +188,6 @@ impl CyclicEnum for SettingKind { pub struct Metrics { pub event_count: usize, - pub dropped_events: usize, pub active_voices: usize, pub peak_voices: usize, pub cpu_load: f32, @@ -204,7 +203,6 @@ impl Default for Metrics { fn default() -> Self { Self { event_count: 0, - dropped_events: 0, active_voices: 0, peak_voices: 0, cpu_load: 0.0, diff --git a/src/state/ui.rs b/src/state/ui.rs index 726e9db..48ebaa6 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -70,12 +70,12 @@ impl Default for UiState { modal: Modal::None, help_focus: HelpFocus::default(), help_topic: 0, - help_scrolls: vec![0; crate::views::help_view::topic_count()], + help_scrolls: vec![0; crate::model::docs::topic_count()], help_search_active: false, help_search_query: String::new(), dict_focus: DictFocus::default(), dict_category: 0, - dict_scrolls: vec![0; crate::views::dict_view::category_count()], + dict_scrolls: vec![0; crate::model::categories::category_count()], dict_search_query: String::new(), dict_search_active: false, show_title: true, diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs index 368ca4e..21f1672 100644 --- a/src/views/dict_view.rs +++ b/src/views/dict_view.rs @@ -5,58 +5,13 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use ratatui::Frame; use crate::app::App; +use crate::model::categories::{get_category_name, CatEntry, CATEGORIES}; use crate::model::{Word, WORDS}; use crate::state::DictFocus; use crate::theme; -enum CatEntry { - Section(&'static str), - Category(&'static str), -} - use CatEntry::{Category, Section}; -const CATEGORIES: &[CatEntry] = &[ - // Forth core - Section("Forth"), - Category("Stack"), - Category("Arithmetic"), - Category("Comparison"), - Category("Logic"), - Category("Control"), - Category("Variables"), - Category("Probability"), - Category("Definitions"), - // Live coding - Section("Live Coding"), - Category("Sound"), - Category("Time"), - Category("Context"), - Category("Music"), - Category("LFO"), - // Synthesis - Section("Synthesis"), - Category("Oscillator"), - Category("Wavetable"), - Category("Generator"), - Category("Envelope"), - Category("Sample"), - // Effects - Section("Effects"), - Category("Filter"), - Category("FM"), - Category("Modulation"), - Category("Mod FX"), - Category("Lo-fi"), - Category("Stereo"), - Category("Delay"), - Category("Reverb"), - // External I/O - Section("I/O"), - Category("MIDI"), - Category("Desktop"), -]; - pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [header_area, body_area] = Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(area); @@ -165,17 +120,6 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) { frame.render_widget(list, area); } -fn get_category_name(index: usize) -> &'static str { - CATEGORIES - .iter() - .filter_map(|e| match e { - Category(name) => Some(*name), - Section(_) => None, - }) - .nth(index) - .unwrap_or("Unknown") -} - fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { let theme = theme::get(); let focused = app.ui.dict_focus == DictFocus::Words; @@ -299,9 +243,3 @@ fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(vec![line]), area); } -pub fn category_count() -> usize { - CATEGORIES - .iter() - .filter(|e| matches!(e, Category(_))) - .count() -} diff --git a/src/views/help_view.rs b/src/views/help_view.rs index a4568c9..76deb7a 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -7,10 +7,13 @@ use ratatui::Frame; use tui_big_text::{BigText, PixelSize}; use crate::app::App; +use crate::model::docs::{get_topic, DocEntry, DOCS}; use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; +use DocEntry::{Section, Topic}; + struct AppTheme; impl MarkdownTheme for AppTheme { @@ -83,74 +86,6 @@ impl CodeHighlighter for ForthHighlighter { } } -enum DocEntry { - Section(&'static str), - Topic(&'static str, &'static str), -} - -use DocEntry::{Section, Topic}; - -const DOCS: &[DocEntry] = &[ - // Getting Started - Section("Getting Started"), - Topic("Welcome", include_str!("../../docs/welcome.md")), - Topic("Moving Around", include_str!("../../docs/navigation.md")), - Topic( - "How Does It Work?", - include_str!("../../docs/how_it_works.md"), - ), - Topic( - "Banks & Patterns", - include_str!("../../docs/banks_patterns.md"), - ), - Topic("Stage / Commit", include_str!("../../docs/staging.md")), - Topic("Using the Sequencer", include_str!("../../docs/grid.md")), - Topic("Editing a Step", include_str!("../../docs/editing.md")), - // Forth fundamentals - Section("Forth"), - Topic("About Forth", include_str!("../../docs/about_forth.md")), - Topic("The Dictionary", include_str!("../../docs/dictionary.md")), - Topic("The Stack", include_str!("../../docs/stack.md")), - Topic("Creating Words", include_str!("../../docs/definitions.md")), - Topic("Oddities", include_str!("../../docs/oddities.md")), - // Audio Engine - Section("Audio Engine"), - Topic("Introduction", include_str!("../../docs/engine_intro.md")), - Topic("Settings", include_str!("../../docs/engine_settings.md")), - Topic("Sources", include_str!("../../docs/engine_sources.md")), - Topic("Samples", include_str!("../../docs/engine_samples.md")), - Topic("Wavetables", include_str!("../../docs/engine_wavetable.md")), - Topic("Filters", include_str!("../../docs/engine_filters.md")), - Topic( - "Modulation", - include_str!("../../docs/engine_modulation.md"), - ), - Topic( - "Distortion", - include_str!("../../docs/engine_distortion.md"), - ), - Topic("Space & Time", include_str!("../../docs/engine_space.md")), - Topic("Words & Sounds", include_str!("../../docs/engine_words.md")), - // MIDI - Section("MIDI"), - Topic("Introduction", include_str!("../../docs/midi_intro.md")), - Topic("MIDI Output", include_str!("../../docs/midi_output.md")), - Topic("MIDI Input", include_str!("../../docs/midi_input.md")), -]; - -pub fn topic_count() -> usize { - DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count() -} - -fn get_topic(index: usize) -> Option<(&'static str, &'static str)> { - DOCS.iter() - .filter_map(|e| match e { - Topic(name, content) => Some((*name, *content)), - Section(_) => None, - }) - .nth(index) -} - pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [topics_area, content_area] = Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area); @@ -386,22 +321,3 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } -/// Find first line matching query across all topics. Returns (topic_index, line_index). -pub fn find_match(query: &str) -> Option<(usize, usize)> { - let query = query.to_lowercase(); - for (topic_idx, (_, content)) in DOCS - .iter() - .filter_map(|e| match e { - Topic(name, content) => Some((*name, *content)), - Section(_) => None, - }) - .enumerate() - { - for (line_idx, line) in content.lines().enumerate() { - if line.to_lowercase().contains(&query) { - return Some((topic_idx, line_idx)); - } - } - } - None -} diff --git a/website/public/script.js b/website/public/script.js index ad19a37..4c87d9d 100644 --- a/website/public/script.js +++ b/website/public/script.js @@ -52,3 +52,27 @@ if (document.readyState === 'loading') { } else { highlightForth(); } + +const kofiModal = document.getElementById('kofi-modal'); +const kofiFrame = document.getElementById('kofi-frame'); + +document.querySelectorAll('.downloads-table a').forEach(link => { + link.addEventListener('click', () => { + if (sessionStorage.getItem('kofi-dismissed')) return; + kofiFrame.src = 'https://ko-fi.com/raphaelbubo/?hidefeed=true&widget=true&embed=true'; + kofiModal.showModal(); + }); +}); + +kofiModal.addEventListener('close', () => { + sessionStorage.setItem('kofi-dismissed', '1'); + kofiFrame.src = 'about:blank'; +}); + +document.getElementById('kofi-close').addEventListener('click', () => { + kofiModal.close(); +}); + +kofiModal.addEventListener('click', (e) => { + if (e.target === kofiModal) kofiModal.close(); +}); diff --git a/website/public/style.css b/website/public/style.css index 615cea4..88ba560 100644 --- a/website/public/style.css +++ b/website/public/style.css @@ -111,6 +111,47 @@ pre { color: var(--text); } +#kofi-modal { + background: var(--bg); + color: var(--text); + border: 1px solid var(--text-muted); + border-radius: 0; + padding: 1rem; + max-width: 420px; + width: 90vw; +} + +#kofi-modal::backdrop { + background: rgba(0, 0, 0, 0.7); +} + +#kofi-modal p { + margin-bottom: 0.5rem; +} + +#kofi-frame { + border: none; + width: 100%; + height: 570px; +} + +#kofi-close { + font-family: 'CozetteVector', monospace; + background: none; + color: var(--text-muted); + border: none; + padding: 0; + cursor: pointer; + font-size: inherit; + text-decoration: underline; + display: block; + margin: 0.5rem auto 0; +} + +#kofi-close:hover { + color: var(--text); +} + #theme-toggle { font-family: 'CozetteVector', monospace; background: none; diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 9485050..1f40799 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -54,6 +54,12 @@

All releases are available on GitHub. You can also compile the software yourself by getting it from Cargo!

+ +

If you enjoy Cagire, consider supporting the project:

+ + +
+

About