From 71bd09d5eada7b678518c3cde09322cfe787b671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Thu, 26 Feb 2026 00:20:46 +0100 Subject: [PATCH] Feat: bank / pattern import / export feature + documentation --- crates/project/Cargo.toml | 6 ++ crates/project/src/lib.rs | 1 + crates/project/src/share.rs | 209 ++++++++++++++++++++++++++++++++++++ docs/tutorials/sharing.md | 19 ++++ src/app/clipboard.rs | 105 ++++++++++++++++++ src/app/dispatch.rs | 4 + src/app/undo.rs | 5 +- src/commands.rs | 14 +++ src/input/main_page.rs | 8 ++ src/input/modal.rs | 3 + src/input/patterns_page.rs | 27 +++++ src/model/docs.rs | 4 + src/model/mod.rs | 4 +- src/state/modal.rs | 2 + src/views/keybindings.rs | 4 + 15 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 crates/project/src/share.rs create mode 100644 docs/tutorials/sharing.md diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 91615d0..99cd7b0 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -10,3 +10,9 @@ description = "Project data structures for cagire sequencer" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +rmp-serde = "1" +brotli = "7" +base64 = "0.22" + +[dev-dependencies] +flate2 = "1" diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index ad606d4..e2f1572 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -2,6 +2,7 @@ mod file; mod project; +pub mod share; pub const MAX_BANKS: usize = 32; pub const MAX_PATTERNS: usize = 32; diff --git a/crates/project/src/share.rs b/crates/project/src/share.rs new file mode 100644 index 0000000..b7b472f --- /dev/null +++ b/crates/project/src/share.rs @@ -0,0 +1,209 @@ +//! Pattern and project sharing via compact text strings. +//! +//! Export: data → MessagePack → Brotli → base64 URL-safe → prefix +//! Import: strip prefix → base64 decode → Brotli decompress → MessagePack → data + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; + +use crate::{Bank, Pattern}; + +const PATTERN_PREFIX: &str = "cgr:"; +const BANK_PREFIX: &str = "cgrb:"; + +#[derive(Debug)] +pub enum ShareError { + InvalidPrefix, + Base64(base64::DecodeError), + Decompress(std::io::Error), + Deserialize(rmp_serde::decode::Error), + Serialize(rmp_serde::encode::Error), + Compress(std::io::Error), +} + +impl std::fmt::Display for ShareError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidPrefix => write!(f, "missing cgr:/cgrb: prefix"), + Self::Base64(e) => write!(f, "base64: {e}"), + Self::Decompress(e) => write!(f, "decompress: {e}"), + Self::Deserialize(e) => write!(f, "deserialize: {e}"), + Self::Serialize(e) => write!(f, "serialize: {e}"), + Self::Compress(e) => write!(f, "compress: {e}"), + } + } +} + +fn compress(data: &[u8]) -> Result, ShareError> { + let mut output = Vec::new(); + let params = brotli::enc::BrotliEncoderParams { + quality: 11, + lgwin: 22, + lgblock: 0, + ..Default::default() + }; + brotli::BrotliCompress(&mut &data[..], &mut output, ¶ms).map_err(ShareError::Compress)?; + Ok(output) +} + +fn decompress(data: &[u8]) -> Result, ShareError> { + let mut output = Vec::new(); + brotli::BrotliDecompress(&mut &data[..], &mut output).map_err(ShareError::Decompress)?; + Ok(output) +} + +fn encode(value: &T, prefix: &str) -> Result { + let packed = rmp_serde::to_vec_named(value).map_err(ShareError::Serialize)?; + let compressed = compress(&packed)?; + let encoded = URL_SAFE_NO_PAD.encode(&compressed); + Ok(format!("{prefix}{encoded}")) +} + +fn decode(text: &str, prefix: &str) -> Result { + let text = text.trim(); + let payload = text.strip_prefix(prefix).ok_or(ShareError::InvalidPrefix)?; + let compressed = URL_SAFE_NO_PAD.decode(payload).map_err(ShareError::Base64)?; + let packed = decompress(&compressed)?; + rmp_serde::from_slice(&packed).map_err(ShareError::Deserialize) +} + +pub fn export(pattern: &Pattern) -> Result { + encode(pattern, PATTERN_PREFIX) +} + +pub fn import(text: &str) -> Result { + decode(text, PATTERN_PREFIX) +} + +pub fn export_bank(bank: &Bank) -> Result { + encode(bank, BANK_PREFIX) +} + +pub fn import_bank(text: &str) -> Result { + decode(text, BANK_PREFIX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Step; + + #[test] + fn roundtrip_empty() { + let pattern = Pattern::default(); + let encoded = export(&pattern).unwrap(); + assert!(encoded.starts_with("cgr:")); + let decoded = import(&encoded).unwrap(); + assert_eq!(decoded.length, pattern.length); + assert_eq!(decoded.steps.len(), pattern.steps.len()); + } + + #[test] + fn roundtrip_with_steps() { + let mut pattern = Pattern::default(); + pattern.steps[0] = Step { + active: true, + script: "kick 60 note".to_string(), + source: None, + name: Some("kick".to_string()), + }; + pattern.steps[1] = Step { + active: false, + script: "snare".to_string(), + source: None, + name: None, + }; + pattern.steps[3] = Step { + active: true, + script: String::new(), + source: Some(0), + name: None, + }; + pattern.length = 8; + pattern.name = Some("Test".to_string()); + + let encoded = export(&pattern).unwrap(); + let decoded = import(&encoded).unwrap(); + + assert_eq!(decoded.length, 8); + assert_eq!(decoded.name.as_deref(), Some("Test")); + assert_eq!(decoded.steps[0].script, "kick 60 note"); + assert_eq!(decoded.steps[0].name.as_deref(), Some("kick")); + assert!(!decoded.steps[1].active); + assert_eq!(decoded.steps[1].script, "snare"); + assert_eq!(decoded.steps[3].source, Some(0)); + } + + #[test] + fn bad_prefix() { + assert!(matches!(import("xxx:abc"), Err(ShareError::InvalidPrefix))); + } + + #[test] + fn bad_base64() { + assert!(matches!(import("cgr:!!!"), Err(ShareError::Base64(_)))); + } + + #[test] + fn whitespace_trimming() { + let pattern = Pattern::default(); + let encoded = export(&pattern).unwrap(); + let padded = format!(" {encoded} \n"); + let decoded = import(&padded).unwrap(); + assert_eq!(decoded.length, pattern.length); + } + + #[test] + fn msgpack_brotli_smaller_than_json_deflate() { + let mut pattern = Pattern::default(); + for i in 0..16 { + pattern.steps[i] = Step { + active: true, + script: format!("kick {i} note 0.5 dur"), + source: None, + name: Some(format!("step_{i}")), + }; + } + pattern.length = 16; + + // Current (msgpack+brotli) + let new_encoded = export(&pattern).unwrap(); + + // Old pipeline (json+deflate) for comparison + use std::io::Write; + let json = serde_json::to_vec(&pattern).unwrap(); + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&json).unwrap(); + let old_compressed = encoder.finish().unwrap(); + let old_encoded = format!("cgr:{}", URL_SAFE_NO_PAD.encode(&old_compressed)); + + assert!( + new_encoded.len() < old_encoded.len(), + "msgpack+brotli ({}) should be smaller than json+deflate ({})", + new_encoded.len(), + old_encoded.len() + ); + } + + #[test] + fn roundtrip_bank() { + let mut bank = Bank::default(); + bank.patterns[0].steps[0] = Step { + active: true, + script: "kick 60 note".to_string(), + source: None, + name: Some("kick".to_string()), + }; + bank.patterns[0].length = 8; + bank.name = Some("Drums".to_string()); + + let encoded = export_bank(&bank).unwrap(); + assert!(encoded.starts_with("cgrb:")); + let decoded = import_bank(&encoded).unwrap(); + + assert_eq!(decoded.name.as_deref(), Some("Drums")); + assert_eq!(decoded.patterns[0].length, 8); + assert_eq!(decoded.patterns[0].steps[0].script, "kick 60 note"); + } +} diff --git a/docs/tutorials/sharing.md b/docs/tutorials/sharing.md new file mode 100644 index 0000000..c5b57e7 --- /dev/null +++ b/docs/tutorials/sharing.md @@ -0,0 +1,19 @@ +# Sharing + +Export and import patterns or entire banks via the clipboard. Data is serialized into a compact text string you can paste anywhere -- a chat message, a text file, a note. + +## Export + +Press `g` to copy the current pattern to the clipboard as a share string. The string starts with `cgr:` followed by compressed data. + +On the patterns page, `g` exports a pattern or a bank depending on which column the cursor is in. Bank strings start with `cgrb:`. + +## Import + +Press `G` to import from the clipboard. The share string is decoded and the result replaces the current pattern. + +On the patterns page, importing into the banks column replaces an entire bank. Because this is destructive, a confirmation prompt appears first. + +## Undo + +Both pattern and bank imports are covered by undo. Press `u` to revert an import. diff --git a/src/app/clipboard.rs b/src/app/clipboard.rs index cc1bb05..9758783 100644 --- a/src/app/clipboard.rs +++ b/src/app/clipboard.rs @@ -168,6 +168,111 @@ impl App { } } + pub fn share_pattern(&mut self, bank: usize, pattern: usize) { + let pattern_data = &self.project_state.project.banks[bank].patterns[pattern]; + match model::share::export(pattern_data) { + Ok(encoded) => { + let len = encoded.len(); + if let Some(clip) = &mut self.clipboard { + let _ = clip.set_text(encoded); + } + if len > 2000 { + self.ui.flash( + &format!("Shared ({len} chars — too long for Discord)"), + 200, + FlashKind::Error, + ); + } else { + self.ui + .flash(&format!("Shared ({len} chars)"), 150, FlashKind::Success); + } + } + Err(e) => { + self.ui + .flash(&format!("Share failed: {e}"), 200, FlashKind::Error); + } + } + } + + pub fn share_bank(&mut self, bank: usize) { + let bank_data = &self.project_state.project.banks[bank]; + match model::share::export_bank(bank_data) { + Ok(encoded) => { + let len = encoded.len(); + if let Some(clip) = &mut self.clipboard { + let _ = clip.set_text(encoded); + } + if len > 2000 { + self.ui.flash( + &format!("Bank shared ({len} chars — too long for Discord)"), + 200, + FlashKind::Error, + ); + } else { + self.ui + .flash(&format!("Bank shared ({len} chars)"), 150, FlashKind::Success); + } + } + Err(e) => { + self.ui + .flash(&format!("Share failed: {e}"), 200, FlashKind::Error); + } + } + } + + pub fn import_bank(&mut self, bank: usize) { + let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) { + Some(t) => t, + None => { + self.ui.flash("Clipboard empty", 150, FlashKind::Error); + return; + } + }; + match model::share::import_bank(&text) { + Ok(imported) => { + self.project_state.project.banks[bank] = imported; + for pattern in 0..model::MAX_PATTERNS { + self.project_state.mark_dirty(bank, pattern); + } + if self.editor_ctx.bank == bank { + self.load_step_to_editor(); + } + self.ui + .flash("Bank imported", 150, FlashKind::Success); + } + Err(e) => { + self.ui + .flash(&format!("Import failed: {e}"), 200, FlashKind::Error); + } + } + } + + pub fn import_pattern(&mut self, bank: usize, pattern: usize) { + let text = match self.clipboard.as_mut().and_then(|c| c.get_text().ok()) { + Some(t) => t, + None => { + self.ui + .flash("Clipboard empty", 150, FlashKind::Error); + return; + } + }; + match model::share::import(&text) { + Ok(imported) => { + self.project_state.project.banks[bank].patterns[pattern] = imported; + self.project_state.mark_dirty(bank, pattern); + if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern { + self.load_step_to_editor(); + } + self.ui + .flash("Pattern imported", 150, FlashKind::Success); + } + Err(e) => { + self.ui + .flash(&format!("Import failed: {e}"), 200, FlashKind::Error); + } + } + } + pub fn harden_steps(&mut self) { let (bank, pattern) = self.current_bank_pattern(); let indices = self.selected_steps(); diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index cdfd60e..7f61519 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -105,6 +105,10 @@ impl App { AppCommand::ResetBank { bank } => self.reset_bank(bank), AppCommand::CopyPattern { bank, pattern } => self.copy_pattern(bank, pattern), AppCommand::PastePattern { bank, pattern } => self.paste_pattern(bank, pattern), + AppCommand::SharePattern { bank, pattern } => self.share_pattern(bank, pattern), + AppCommand::ImportPattern { bank, pattern } => self.import_pattern(bank, pattern), + AppCommand::ShareBank { bank } => self.share_bank(bank), + AppCommand::ImportBank { bank } => self.import_bank(bank), AppCommand::CopyPatterns { bank, patterns } => self.copy_patterns(bank, &patterns), AppCommand::PastePatterns { bank, start } => self.paste_patterns(bank, start), AppCommand::CopyBank { bank } => self.copy_bank(bank), diff --git a/src/app/undo.rs b/src/app/undo.rs index d1bd3bc..9b5fa3a 100644 --- a/src/app/undo.rs +++ b/src/app/undo.rs @@ -28,6 +28,7 @@ impl App { | AppCommand::DeleteSteps { bank, pattern, .. } | AppCommand::ResetPattern { bank, pattern } | AppCommand::PastePattern { bank, pattern } + | AppCommand::ImportPattern { bank, pattern } | AppCommand::RenamePattern { bank, pattern, .. } => { let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) @@ -41,7 +42,9 @@ impl App { let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) } - AppCommand::ResetBank { bank } | AppCommand::PasteBank { bank } => { + AppCommand::ResetBank { bank } + | AppCommand::PasteBank { bank } + | AppCommand::ImportBank { bank } => { let data = self.project_state.project.banks[*bank].clone(); Some(UndoScope::Bank { bank: *bank, data }) } diff --git a/src/commands.rs b/src/commands.rs index 60616cb..cf9e2ad 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -67,6 +67,20 @@ pub enum AppCommand { bank: usize, pattern: usize, }, + SharePattern { + bank: usize, + pattern: usize, + }, + ImportPattern { + bank: usize, + pattern: usize, + }, + ShareBank { + bank: usize, + }, + ImportBank { + bank: usize, + }, CopyPatterns { bank: usize, patterns: Vec, diff --git a/src/input/main_page.rs b/src/input/main_page.rs index 4ea3ca2..040ed75 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -236,6 +236,14 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool KeyCode::Char('D') => { ctx.dispatch(AppCommand::EvaluatePrelude); } + KeyCode::Char('g') => { + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + ctx.dispatch(AppCommand::SharePattern { bank, pattern }); + } + KeyCode::Char('G') => { + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + ctx.dispatch(AppCommand::ImportPattern { bank, pattern }); + } _ => {} } InputResult::Continue diff --git a/src/input/modal.rs b/src/input/modal.rs index 4fe339a..5dbfdd6 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -638,6 +638,9 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul ConfirmAction::ResetBanks { banks } => { ctx.dispatch(AppCommand::ResetBanks { banks: banks.clone() }); } + ConfirmAction::ImportBank { bank } => { + ctx.dispatch(AppCommand::ImportBank { bank: *bank }); + } } ctx.dispatch(AppCommand::CloseModal); InputResult::Continue diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs index 7185d64..4b7e771 100644 --- a/src/input/patterns_page.rs +++ b/src/input/patterns_page.rs @@ -265,6 +265,33 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp ctx.dispatch(AppCommand::ClearSolos); ctx.app.send_mute_state(ctx.seq_cmd_tx); } + KeyCode::Char('g') => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::SharePattern { bank, pattern }); + } + PatternsColumn::Banks => { + ctx.dispatch(AppCommand::ShareBank { bank }); + } + } + } + KeyCode::Char('G') => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::ImportPattern { bank, pattern }); + } + PatternsColumn::Banks => { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::ImportBank { bank }, + selected: false, + })); + } + } + } KeyCode::Char('s') => super::open_save(ctx), KeyCode::Char('l') => super::open_load(ctx), KeyCode::Char('?') => { diff --git a/src/model/docs.rs b/src/model/docs.rs index 58bd0c9..f478ec3 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -127,6 +127,10 @@ pub const DOCS: &[DocEntry] = &[ "Soundfonts", include_str!("../../docs/tutorials/soundfont.md"), ), + Topic( + "Sharing", + include_str!("../../docs/tutorials/sharing.md"), + ), ]; pub fn topic_count() -> usize { diff --git a/src/model/mod.rs b/src/model/mod.rs index 59fa8f6..7e88138 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -9,8 +9,8 @@ pub use cagire_forth::{ Variables, Word, WordCompile, WORDS, }; pub use cagire_project::{ - load, load_str, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, - SyncMode, MAX_BANKS, MAX_PATTERNS, + load, load_str, save, share, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, + Project, SyncMode, MAX_BANKS, MAX_PATTERNS, }; pub use script::ScriptEngine; diff --git a/src/state/modal.rs b/src/state/modal.rs index 8888c22..b1d172a 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -11,6 +11,7 @@ pub enum ConfirmAction { ResetBank { bank: usize }, ResetPatterns { bank: usize, patterns: Vec }, ResetBanks { banks: Vec }, + ImportBank { bank: usize }, } impl ConfirmAction { @@ -26,6 +27,7 @@ impl ConfirmAction { Self::ResetBank { bank } => format!("Reset bank {}?", bank + 1), Self::ResetPatterns { patterns, .. } => format!("Reset {} patterns?", patterns.len()), Self::ResetBanks { banks } => format!("Reset {} banks?", banks.len()), + Self::ImportBank { bank } => format!("Import bank from clipboard? (replaces bank {:02})", bank + 1), } } } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index dbe70e8..b0c7fd7 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -52,6 +52,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("X", "Clear solos", "Clear all solos")); bindings.push(("d", "Prelude", "Edit prelude script")); bindings.push(("D", "Eval prelude", "Re-evaluate prelude without editing")); + bindings.push(("g", "Share", "Export pattern to clipboard")); + bindings.push(("G", "Import", "Import pattern from clipboard")); } Page::Patterns => { bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); @@ -71,6 +73,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("x", "Solo", "Stage solo for pattern")); bindings.push(("M", "Clear mutes", "Clear all mutes")); bindings.push(("X", "Clear solos", "Clear all solos")); + bindings.push(("g", "Share", "Export bank or pattern to clipboard")); + bindings.push(("G", "Import", "Import bank or pattern from clipboard")); bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern")); bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern")); bindings.push(("Del", "Reset", "Reset bank/pattern"));