Feat: bank / pattern import / export feature + documentation

This commit is contained in:
2026-02-26 00:20:46 +01:00
parent 6dd265067f
commit 71bd09d5ea
15 changed files with 412 additions and 3 deletions

View File

@@ -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"

View File

@@ -2,6 +2,7 @@
mod file;
mod project;
pub mod share;
pub const MAX_BANKS: usize = 32;
pub const MAX_PATTERNS: usize = 32;

209
crates/project/src/share.rs Normal file
View File

@@ -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<Vec<u8>, 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, &params).map_err(ShareError::Compress)?;
Ok(output)
}
fn decompress(data: &[u8]) -> Result<Vec<u8>, ShareError> {
let mut output = Vec::new();
brotli::BrotliDecompress(&mut &data[..], &mut output).map_err(ShareError::Decompress)?;
Ok(output)
}
fn encode<T: serde::Serialize>(value: &T, prefix: &str) -> Result<String, ShareError> {
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<T: serde::de::DeserializeOwned>(text: &str, prefix: &str) -> Result<T, ShareError> {
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<String, ShareError> {
encode(pattern, PATTERN_PREFIX)
}
pub fn import(text: &str) -> Result<Pattern, ShareError> {
decode(text, PATTERN_PREFIX)
}
pub fn export_bank(bank: &Bank) -> Result<String, ShareError> {
encode(bank, BANK_PREFIX)
}
pub fn import_bank(text: &str) -> Result<Bank, ShareError> {
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");
}
}

19
docs/tutorials/sharing.md Normal file
View File

@@ -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.

View File

@@ -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();

View File

@@ -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),

View File

@@ -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 })
}

View File

@@ -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<usize>,

View File

@@ -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

View File

@@ -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

View File

@@ -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('?') => {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -11,6 +11,7 @@ pub enum ConfirmAction {
ResetBank { bank: usize },
ResetPatterns { bank: usize, patterns: Vec<usize> },
ResetBanks { banks: Vec<usize> },
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),
}
}
}

View File

@@ -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"));