Compare commits
2 Commits
005155e486
...
e1cf72542c
| Author | SHA1 | Date | |
|---|---|---|---|
| e1cf72542c | |||
| 97a1a997f6 |
@@ -11,6 +11,24 @@ use crate::{Bank, Pattern};
|
||||
const PATTERN_PREFIX: &str = "cgr:";
|
||||
const BANK_PREFIX: &str = "cgrb:";
|
||||
|
||||
pub enum ImportResult {
|
||||
Pattern(Pattern),
|
||||
Bank(Bank),
|
||||
}
|
||||
|
||||
/// Auto-detect format from the prefix and decode.
|
||||
pub fn import_auto(text: &str) -> Result<ImportResult, ShareError> {
|
||||
// Strip everything non-ASCII — valid share strings are pure ASCII
|
||||
let clean: String = text.chars().filter(|c| c.is_ascii_graphic()).collect();
|
||||
if clean.starts_with(BANK_PREFIX) {
|
||||
Ok(ImportResult::Bank(decode(&clean, BANK_PREFIX)?))
|
||||
} else if clean.starts_with(PATTERN_PREFIX) {
|
||||
Ok(ImportResult::Pattern(decode(&clean, PATTERN_PREFIX)?))
|
||||
} else {
|
||||
Err(ShareError::InvalidPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error during pattern or bank import/export.
|
||||
#[derive(Debug)]
|
||||
pub enum ShareError {
|
||||
@@ -63,7 +81,12 @@ fn encode<T: serde::Serialize>(value: &T, prefix: &str) -> Result<String, ShareE
|
||||
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)?;
|
||||
// Strip invisible characters that clipboard managers / web copies can inject
|
||||
let clean: String = payload
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||
.collect();
|
||||
let compressed = URL_SAFE_NO_PAD.decode(&clean).map_err(ShareError::Base64)?;
|
||||
let packed = decompress(&compressed)?;
|
||||
rmp_serde::from_slice(&packed).map_err(ShareError::Deserialize)
|
||||
}
|
||||
@@ -146,7 +169,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bad_base64() {
|
||||
assert!(matches!(import("cgr:!!!"), Err(ShareError::Base64(_))));
|
||||
assert!(import("cgr:not-valid-data").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -222,7 +222,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_bank(&mut self, bank: usize) {
|
||||
pub fn import_shared(&mut self, bank: usize, pattern: usize) {
|
||||
let text = match arboard::Clipboard::new().ok().and_then(|mut c| c.get_text().ok()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -230,36 +230,8 @@ impl App {
|
||||
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 arboard::Clipboard::new().ok().and_then(|mut 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) => {
|
||||
match model::share::import_auto(&text) {
|
||||
Ok(model::share::ImportResult::Pattern(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 {
|
||||
@@ -268,6 +240,17 @@ impl App {
|
||||
self.ui
|
||||
.flash("Pattern imported", 150, FlashKind::Success);
|
||||
}
|
||||
Ok(model::share::ImportResult::Bank(imported)) => {
|
||||
self.project_state.project.banks[bank] = imported;
|
||||
for p in 0..model::MAX_PATTERNS {
|
||||
self.project_state.mark_dirty(bank, p);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -111,9 +111,8 @@ impl App {
|
||||
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::ImportShared { bank, pattern } => self.import_shared(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),
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 })
|
||||
@@ -47,9 +46,9 @@ impl App {
|
||||
let data = self.project_state.project.banks[bank].clone();
|
||||
Some(UndoScope::Bank { bank, data })
|
||||
}
|
||||
AppCommand::ResetBank { bank }
|
||||
| AppCommand::PasteBank { bank }
|
||||
| AppCommand::ImportBank { bank } => {
|
||||
AppCommand::ImportShared { bank, .. }
|
||||
| AppCommand::ResetBank { bank }
|
||||
| AppCommand::PasteBank { bank } => {
|
||||
let data = self.project_state.project.banks[*bank].clone();
|
||||
Some(UndoScope::Bank { bank: *bank, data })
|
||||
}
|
||||
|
||||
@@ -75,16 +75,13 @@ pub enum AppCommand {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
},
|
||||
ImportPattern {
|
||||
ImportShared {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
},
|
||||
ShareBank {
|
||||
bank: usize,
|
||||
},
|
||||
ImportBank {
|
||||
bank: usize,
|
||||
},
|
||||
CopyPatterns {
|
||||
bank: usize,
|
||||
patterns: Vec<usize>,
|
||||
|
||||
@@ -224,16 +224,16 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
DeviceKind::Output => {
|
||||
let cursor = ctx.app.audio.output_list.cursor;
|
||||
if cursor < ctx.app.audio.output_devices.len() {
|
||||
let name = ctx.app.audio.output_devices[cursor].name.clone();
|
||||
ctx.dispatch(AppCommand::SetOutputDevice(name));
|
||||
let index = ctx.app.audio.output_devices[cursor].index;
|
||||
ctx.dispatch(AppCommand::SetOutputDevice(index.to_string()));
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
}
|
||||
DeviceKind::Input => {
|
||||
let cursor = ctx.app.audio.input_list.cursor;
|
||||
if cursor < ctx.app.audio.input_devices.len() {
|
||||
let name = ctx.app.audio.input_devices[cursor].name.clone();
|
||||
ctx.dispatch(AppCommand::SetInputDevice(name));
|
||||
let index = ctx.app.audio.input_devices[cursor].index;
|
||||
ctx.dispatch(AppCommand::SetInputDevice(index.to_string()));
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||
ctx.dispatch(AppCommand::ImportPattern { bank, pattern });
|
||||
ctx.dispatch(AppCommand::ImportShared { bank, pattern });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -699,8 +699,8 @@ 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 });
|
||||
ConfirmAction::ImportShared { bank, pattern } => {
|
||||
ctx.dispatch(AppCommand::ImportShared { bank: *bank, pattern: *pattern });
|
||||
}
|
||||
}
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
|
||||
@@ -279,14 +279,14 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||
match ctx.app.patterns_nav.column {
|
||||
PatternsColumn::Patterns => {
|
||||
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||
ctx.dispatch(AppCommand::ImportPattern { bank, pattern });
|
||||
ctx.dispatch(AppCommand::ImportShared { bank, pattern });
|
||||
}
|
||||
PatternsColumn::Banks => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
||||
action: ConfirmAction::ImportBank { bank },
|
||||
action: ConfirmAction::ImportShared { bank, pattern },
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -395,15 +395,15 @@ pub static COMMANDS: LazyLock<Vec<CommandEntry>> = LazyLock::new(|| {
|
||||
})),
|
||||
},
|
||||
CommandEntry {
|
||||
name: "Import Pattern",
|
||||
description: "Import pattern from clipboard",
|
||||
name: "Import Shared",
|
||||
description: "Import pattern or bank from clipboard",
|
||||
category: "Pattern",
|
||||
keybinding: "G",
|
||||
pages: &[Main, Patterns],
|
||||
normal_mode: false,
|
||||
action: Some(PaletteAction::Resolve(|app| {
|
||||
let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern);
|
||||
Some(AppCommand::ImportPattern { bank, pattern })
|
||||
Some(AppCommand::ImportShared { bank, pattern })
|
||||
})),
|
||||
},
|
||||
CommandEntry {
|
||||
@@ -503,8 +503,9 @@ pub static COMMANDS: LazyLock<Vec<CommandEntry>> = LazyLock::new(|| {
|
||||
normal_mode: false,
|
||||
action: Some(PaletteAction::Resolve(|app| {
|
||||
let bank = app.editor_ctx.bank;
|
||||
let pattern = app.editor_ctx.pattern;
|
||||
Some(AppCommand::OpenModal(Modal::Confirm {
|
||||
action: crate::state::ConfirmAction::ImportBank { bank },
|
||||
action: crate::state::ConfirmAction::ImportShared { bank, pattern },
|
||||
selected: false,
|
||||
}))
|
||||
})),
|
||||
|
||||
@@ -448,11 +448,19 @@ impl AudioSettings {
|
||||
|
||||
pub fn current_output_device_index(&self) -> usize {
|
||||
match &self.config.output_device {
|
||||
Some(name) => self
|
||||
.output_devices
|
||||
Some(spec) => {
|
||||
if let Ok(idx) = spec.parse::<usize>() {
|
||||
self.output_devices
|
||||
.iter()
|
||||
.position(|d| &d.name == name)
|
||||
.unwrap_or(0),
|
||||
.position(|d| d.index == idx)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
self.output_devices
|
||||
.iter()
|
||||
.position(|d| &d.name == spec)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
None => self
|
||||
.output_devices
|
||||
.iter()
|
||||
@@ -463,11 +471,19 @@ impl AudioSettings {
|
||||
|
||||
pub fn current_input_device_index(&self) -> usize {
|
||||
match &self.config.input_device {
|
||||
Some(name) => self
|
||||
.input_devices
|
||||
Some(spec) => {
|
||||
if let Ok(idx) = spec.parse::<usize>() {
|
||||
self.input_devices
|
||||
.iter()
|
||||
.position(|d| &d.name == name)
|
||||
.unwrap_or(0),
|
||||
.position(|d| d.index == idx)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
self.input_devices
|
||||
.iter()
|
||||
.position(|d| &d.name == spec)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
None => self
|
||||
.input_devices
|
||||
.iter()
|
||||
|
||||
@@ -11,7 +11,7 @@ pub enum ConfirmAction {
|
||||
ResetBank { bank: usize },
|
||||
ResetPatterns { bank: usize, patterns: Vec<usize> },
|
||||
ResetBanks { banks: Vec<usize> },
|
||||
ImportBank { bank: usize },
|
||||
ImportShared { bank: usize, pattern: usize },
|
||||
}
|
||||
|
||||
impl ConfirmAction {
|
||||
@@ -27,7 +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),
|
||||
Self::ImportShared { bank, .. } => format!("Import from clipboard? (target: bank {:02})", bank + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user