mirror of
https://codeberg.org/unspeaker/tek.git
synced 2025-12-06 11:46:41 +01:00
369 lines
14 KiB
Rust
369 lines
14 KiB
Rust
use super::*;
|
|
|
|
mod phrase_length; pub(crate) use phrase_length::*;
|
|
mod phrase_rename; pub(crate) use phrase_rename::*;
|
|
|
|
#[derive(Debug)]
|
|
pub struct PoolModel {
|
|
pub(crate) visible: bool,
|
|
/// Collection of phrases
|
|
pub(crate) phrases: Vec<Arc<RwLock<MidiClip>>>,
|
|
/// Selected phrase
|
|
pub(crate) phrase: AtomicUsize,
|
|
/// Mode switch
|
|
pub(crate) mode: Option<PoolMode>,
|
|
/// Rendered size
|
|
size: Measure<Tui>,
|
|
/// Scroll offset
|
|
scroll: usize,
|
|
}
|
|
|
|
/// Modes for phrase pool
|
|
#[derive(Debug, Clone)]
|
|
pub enum PoolMode {
|
|
/// Renaming a pattern
|
|
Rename(usize, String),
|
|
/// Editing the length of a pattern
|
|
Length(usize, usize, PhraseLengthFocus),
|
|
/// Load phrase from disk
|
|
Import(usize, FileBrowser),
|
|
/// Save phrase to disk
|
|
Export(usize, FileBrowser),
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub enum PoolCommand {
|
|
Show(bool),
|
|
/// Update the contents of the phrase pool
|
|
Phrase(PhrasePoolCommand),
|
|
/// Select a phrase from the phrase pool
|
|
Select(usize),
|
|
/// Rename a phrase
|
|
Rename(PhraseRenameCommand),
|
|
/// Change the length of a phrase
|
|
Length(PhraseLengthCommand),
|
|
/// Import from file
|
|
Import(FileBrowserCommand),
|
|
/// Export to file
|
|
Export(FileBrowserCommand),
|
|
}
|
|
|
|
command!(|self:PoolCommand, state: PoolModel|{
|
|
use PoolCommand::*;
|
|
match self {
|
|
Show(visible) => {
|
|
state.visible = visible;
|
|
Some(Self::Show(!visible))
|
|
}
|
|
Rename(command) => match command {
|
|
PhraseRenameCommand::Begin => {
|
|
let length = state.phrases()[state.phrase_index()].read().unwrap().length;
|
|
*state.phrases_mode_mut() = Some(
|
|
PoolMode::Length(state.phrase_index(), length, PhraseLengthFocus::Bar)
|
|
);
|
|
None
|
|
},
|
|
_ => command.execute(state)?.map(Rename)
|
|
},
|
|
Length(command) => match command {
|
|
PhraseLengthCommand::Begin => {
|
|
let name = state.phrases()[state.phrase_index()].read().unwrap().name.clone();
|
|
*state.phrases_mode_mut() = Some(
|
|
PoolMode::Rename(state.phrase_index(), name)
|
|
);
|
|
None
|
|
},
|
|
_ => command.execute(state)?.map(Length)
|
|
},
|
|
Import(command) => match command {
|
|
FileBrowserCommand::Begin => {
|
|
*state.phrases_mode_mut() = Some(
|
|
PoolMode::Import(state.phrase_index(), FileBrowser::new(None)?)
|
|
);
|
|
None
|
|
},
|
|
_ => command.execute(state)?.map(Import)
|
|
},
|
|
Export(command) => match command {
|
|
FileBrowserCommand::Begin => {
|
|
*state.phrases_mode_mut() = Some(
|
|
PoolMode::Export(state.phrase_index(), FileBrowser::new(None)?)
|
|
);
|
|
None
|
|
},
|
|
_ => command.execute(state)?.map(Export)
|
|
},
|
|
Select(phrase) => {
|
|
state.set_phrase_index(phrase);
|
|
None
|
|
},
|
|
Phrase(command) => command.execute(state)?.map(Phrase),
|
|
}
|
|
});
|
|
|
|
input_to_command!(PoolCommand:<Tui>|state: PoolModel,input|match state.phrases_mode() {
|
|
Some(PoolMode::Rename(..)) => Self::Rename(PhraseRenameCommand::input_to_command(state, input)?),
|
|
Some(PoolMode::Length(..)) => Self::Length(PhraseLengthCommand::input_to_command(state, input)?),
|
|
Some(PoolMode::Import(..)) => Self::Import(FileBrowserCommand::input_to_command(state, input)?),
|
|
Some(PoolMode::Export(..)) => Self::Export(FileBrowserCommand::input_to_command(state, input)?),
|
|
_ => to_phrases_command(state, input)?
|
|
});
|
|
|
|
fn to_phrases_command (state: &PoolModel, input: &TuiInput) -> Option<PoolCommand> {
|
|
use KeyCode::{Up, Down, Delete, Char};
|
|
use PoolCommand as Cmd;
|
|
let index = state.phrase_index();
|
|
let count = state.phrases().len();
|
|
Some(match input.event() {
|
|
key_pat!(Char('n')) => Cmd::Rename(PhraseRenameCommand::Begin),
|
|
key_pat!(Char('t')) => Cmd::Length(PhraseLengthCommand::Begin),
|
|
key_pat!(Char('m')) => Cmd::Import(FileBrowserCommand::Begin),
|
|
key_pat!(Char('x')) => Cmd::Export(FileBrowserCommand::Begin),
|
|
key_pat!(Char('c')) => Cmd::Phrase(PhrasePoolCommand::SetColor(index, ItemColor::random())),
|
|
key_pat!(Char('[')) | key_pat!(Up) => Cmd::Select(
|
|
index.overflowing_sub(1).0.min(state.phrases().len() - 1)
|
|
),
|
|
key_pat!(Char(']')) | key_pat!(Down) => Cmd::Select(
|
|
index.saturating_add(1) % state.phrases().len()
|
|
),
|
|
key_pat!(Char('<')) => if index > 1 {
|
|
state.set_phrase_index(state.phrase_index().saturating_sub(1));
|
|
Cmd::Phrase(PhrasePoolCommand::Swap(index - 1, index))
|
|
} else {
|
|
return None
|
|
},
|
|
key_pat!(Char('>')) => if index < count.saturating_sub(1) {
|
|
state.set_phrase_index(state.phrase_index() + 1);
|
|
Cmd::Phrase(PhrasePoolCommand::Swap(index + 1, index))
|
|
} else {
|
|
return None
|
|
},
|
|
key_pat!(Delete) => if index > 0 {
|
|
state.set_phrase_index(index.min(count.saturating_sub(1)));
|
|
Cmd::Phrase(PhrasePoolCommand::Delete(index))
|
|
} else {
|
|
return None
|
|
},
|
|
key_pat!(Char('a')) | key_pat!(Shift-Char('A')) => Cmd::Phrase(PhrasePoolCommand::Add(count, MidiClip::new(
|
|
String::from("(new)"), true, 4 * PPQ, None, Some(ItemPalette::random())
|
|
))),
|
|
key_pat!(Char('i')) => Cmd::Phrase(PhrasePoolCommand::Add(index + 1, MidiClip::new(
|
|
String::from("(new)"), true, 4 * PPQ, None, Some(ItemPalette::random())
|
|
))),
|
|
key_pat!(Char('d')) | key_pat!(Shift-Char('D')) => {
|
|
let mut phrase = state.phrases()[index].read().unwrap().duplicate();
|
|
phrase.color = ItemPalette::random_near(phrase.color, 0.25);
|
|
Cmd::Phrase(PhrasePoolCommand::Add(index + 1, phrase))
|
|
},
|
|
_ => return None
|
|
})
|
|
}
|
|
impl Default for PoolModel {
|
|
fn default () -> Self {
|
|
Self {
|
|
visible: true,
|
|
phrases: vec![RwLock::new(MidiClip::default()).into()],
|
|
phrase: 0.into(),
|
|
scroll: 0,
|
|
mode: None,
|
|
size: Measure::new(),
|
|
}
|
|
}
|
|
}
|
|
from!(|phrase:&Arc<RwLock<MidiClip>>|PoolModel = {
|
|
let mut model = Self::default();
|
|
model.phrases.push(phrase.clone());
|
|
model.phrase.store(1, Relaxed);
|
|
model
|
|
});
|
|
has_phrases!(|self: PoolModel|self.phrases);
|
|
has_phrase!(|self: PoolModel|self.phrases[self.phrase_index()]);
|
|
impl PoolModel {
|
|
pub(crate) fn phrase_index (&self) -> usize {
|
|
self.phrase.load(Relaxed)
|
|
}
|
|
pub(crate) fn set_phrase_index (&self, value: usize) {
|
|
self.phrase.store(value, Relaxed);
|
|
}
|
|
pub(crate) fn phrases_mode (&self) -> &Option<PoolMode> {
|
|
&self.mode
|
|
}
|
|
pub(crate) fn phrases_mode_mut (&mut self) -> &mut Option<PoolMode> {
|
|
&mut self.mode
|
|
}
|
|
}
|
|
pub struct PoolView<'a>(pub(crate) &'a PoolModel);
|
|
// TODO: Display phrases always in order of appearance
|
|
render!(<Tui>|self: PoolView<'a>|{
|
|
let PoolModel { phrases, mode, .. } = self.0;
|
|
let bg = TuiTheme::g(32);
|
|
let title_color = TuiTheme::ti1();
|
|
let upper_left = "Pool:";
|
|
let upper_right = format!("({})", phrases.len());
|
|
let color = self.0.phrase().read().unwrap().color;
|
|
Tui::bg(bg, lay!(move|add|{
|
|
add(&Fill::wh(Outer(Style::default().fg(color.base.rgb).bg(bg))))?;
|
|
//add(&Lozenge(Style::default().bg(border_bg).fg(border_color)))?;
|
|
add(&Tui::inset_xy(0, 1, Fill::wh(col!(move|add|match mode {
|
|
Some(PoolMode::Import(_, ref file_picker)) => add(file_picker),
|
|
Some(PoolMode::Export(_, ref file_picker)) => add(file_picker),
|
|
_ => Ok(for (i, phrase) in phrases.iter().enumerate() {
|
|
add(&lay!(|add|{
|
|
let MidiClip { ref name, color, length, .. } = *phrase.read().unwrap();
|
|
let mut length = PhraseLength::new(length, None);
|
|
if let Some(PoolMode::Length(phrase, new_length, focus)) = mode {
|
|
if i == *phrase {
|
|
length.pulses = *new_length;
|
|
length.focus = Some(*focus);
|
|
}
|
|
}
|
|
add(&Tui::bg(color.base.rgb, Fill::w(col!([
|
|
Fill::w(lay!(|add|{
|
|
add(&Fill::w(Align::w(format!(" {i}"))))?;
|
|
add(&Fill::w(Align::e(Tui::pull_x(1, length.clone()))))
|
|
})),
|
|
Tui::bold(true, {
|
|
let mut row2 = format!(" {name}");
|
|
if let Some(PoolMode::Rename(phrase, _)) = mode {
|
|
if i == *phrase {
|
|
row2 = format!("{row2}▄");
|
|
}
|
|
};
|
|
row2
|
|
}),
|
|
]))))?;
|
|
if i == self.0.phrase_index() {
|
|
add(&CORNERS)?;
|
|
}
|
|
Ok(())
|
|
}))?;
|
|
})
|
|
}))))?;
|
|
add(&Fill::w(Align::nw(Tui::push_x(1, Tui::fg(title_color, upper_left.to_string())))))?;
|
|
add(&Fill::w(Align::ne(Tui::pull_x(1, Tui::fg(title_color, upper_right.to_string())))))?;
|
|
add(&self.0.size)
|
|
}))
|
|
});
|
|
pub struct PhraseSelector {
|
|
pub(crate) title: &'static str,
|
|
pub(crate) name: String,
|
|
pub(crate) color: ItemPalette,
|
|
pub(crate) time: String,
|
|
}
|
|
// TODO: Display phrases always in order of appearance
|
|
render!(<Tui>|self: PhraseSelector|Fixed::wh(24, 1, row!([
|
|
Tui::fg(self.color.lightest.rgb, Tui::bold(true, &self.title)),
|
|
Tui::fg_bg(self.color.lighter.rgb, self.color.base.rgb, row!([
|
|
format!("{:8}", &self.name[0..8.min(self.name.len())]),
|
|
Tui::bg(self.color.dark.rgb, &self.time),
|
|
])),
|
|
])));
|
|
impl PhraseSelector {
|
|
// beats elapsed
|
|
pub fn play_phrase <T: HasPlayPhrase + HasClock> (state: &T) -> Self {
|
|
let (name, color) = if let Some((_, Some(phrase))) = state.play_phrase() {
|
|
let MidiClip { ref name, color, .. } = *phrase.read().unwrap();
|
|
(name.clone(), color)
|
|
} else {
|
|
("".to_string(), ItemPalette::from(TuiTheme::g(64)))
|
|
};
|
|
let time = if let Some(elapsed) = state.pulses_since_start_looped() {
|
|
format!("+{:>}", state.clock().timebase.format_beats_0(elapsed))
|
|
} else {
|
|
String::from(" ")
|
|
};
|
|
Self { title: " Now|", time, name, color, }
|
|
}
|
|
// beats until switchover
|
|
pub fn next_phrase <T: HasPlayPhrase> (state: &T) -> Self {
|
|
let (time, name, color) = if let Some((t, Some(phrase))) = state.next_phrase() {
|
|
let MidiClip { ref name, color, .. } = *phrase.read().unwrap();
|
|
let time = {
|
|
let target = t.pulse.get();
|
|
let current = state.clock().playhead.pulse.get();
|
|
if target > current {
|
|
let remaining = target - current;
|
|
format!("-{:>}", state.clock().timebase.format_beats_0(remaining))
|
|
} else {
|
|
String::new()
|
|
}
|
|
};
|
|
(time, name.clone(), color)
|
|
} else if let Some((_, Some(phrase))) = state.play_phrase() {
|
|
let phrase = phrase.read().unwrap();
|
|
if phrase.looped {
|
|
(" ".into(), phrase.name.clone(), phrase.color)
|
|
} else {
|
|
(" ".into(), " ".into(), TuiTheme::g(64).into())
|
|
}
|
|
} else {
|
|
(" ".into(), " ".into(), TuiTheme::g(64).into())
|
|
};
|
|
Self { title: " Next|", time, name, color, }
|
|
}
|
|
}
|
|
command!(|self: FileBrowserCommand, state: PoolModel|{
|
|
use PoolMode::*;
|
|
use FileBrowserCommand::*;
|
|
let mode = &mut state.mode;
|
|
match mode {
|
|
Some(Import(index, ref mut browser)) => match self {
|
|
Cancel => { *mode = None; },
|
|
Chdir(cwd) => { *mode = Some(Import(*index, FileBrowser::new(Some(cwd))?)); },
|
|
Select(index) => { browser.index = index; },
|
|
Confirm => if browser.is_file() {
|
|
let index = *index;
|
|
let path = browser.path();
|
|
*mode = None;
|
|
PhrasePoolCommand::Import(index, path).execute(state)?;
|
|
} else if browser.is_dir() {
|
|
*mode = Some(Import(*index, browser.chdir()?));
|
|
},
|
|
_ => todo!(),
|
|
},
|
|
Some(Export(index, ref mut browser)) => match self {
|
|
Cancel => { *mode = None; },
|
|
Chdir(cwd) => { *mode = Some(Export(*index, FileBrowser::new(Some(cwd))?)); },
|
|
Select(index) => { browser.index = index; },
|
|
_ => unreachable!()
|
|
},
|
|
_ => unreachable!(),
|
|
};
|
|
None
|
|
});
|
|
input_to_command!(FileBrowserCommand:<Tui>|state: PoolModel,from|{
|
|
|
|
use FileBrowserCommand::*;
|
|
use KeyCode::{Up, Down, Left, Right, Enter, Esc, Backspace, Char};
|
|
if let Some(PoolMode::Import(_index, browser)) = &state.mode {
|
|
match from.event() {
|
|
key_pat!(Up) => Select(browser.index.overflowing_sub(1).0
|
|
.min(browser.len().saturating_sub(1))),
|
|
key_pat!(Down) => Select(browser.index.saturating_add(1)
|
|
% browser.len()),
|
|
key_pat!(Right) => Chdir(browser.cwd.clone()),
|
|
key_pat!(Left) => Chdir(browser.cwd.clone()),
|
|
key_pat!(Enter) => Confirm,
|
|
key_pat!(Char(_)) => { todo!() },
|
|
key_pat!(Backspace) => { todo!() },
|
|
key_pat!(Esc) => Cancel,
|
|
_ => return None
|
|
}
|
|
} else if let Some(PoolMode::Export(_index, browser)) = &state.mode {
|
|
match from.event() {
|
|
key_pat!(Up) => Select(browser.index.overflowing_sub(1).0
|
|
.min(browser.len())),
|
|
key_pat!(Down) => Select(browser.index.saturating_add(1)
|
|
% browser.len()),
|
|
key_pat!(Right) => Chdir(browser.cwd.clone()),
|
|
key_pat!(Left) => Chdir(browser.cwd.clone()),
|
|
key_pat!(Enter) => Confirm,
|
|
key_pat!(Char(_)) => { todo!() },
|
|
key_pat!(Backspace) => { todo!() },
|
|
key_pat!(Esc) => Cancel,
|
|
_ => return None
|
|
}
|
|
} else {
|
|
unreachable!()
|
|
}
|
|
});
|