ItemPalette

This commit is contained in:
🪞👃🪞 2024-12-11 19:16:28 +01:00
parent fa8316c651
commit 042d480b67
12 changed files with 505 additions and 424 deletions

View file

@ -75,7 +75,7 @@ impl<T: HasPhrases> Command<T> for PhrasePoolCommand {
Some(Self::SetLength(index, old_len))
},
SetColor(index, color) => {
let mut color = ItemColorTriplet::from(color);
let mut color = ItemPalette::from(color);
std::mem::swap(&mut color, &mut model.phrases()[index].write().unwrap().color);
Some(Self::SetColor(index, color.base))
},
@ -104,7 +104,7 @@ pub struct Phrase {
/// All notes are displayed with minimum length
pub percussive: bool,
/// Identifying color of phrase
pub color: ItemColorTriplet,
pub color: ItemPalette,
}
/// MIDI message structural
@ -116,7 +116,7 @@ impl Phrase {
loop_on: bool,
length: usize,
notes: Option<PhraseData>,
color: Option<ItemColorTriplet>,
color: Option<ItemPalette>,
) -> Self {
Self {
uuid: uuid::Uuid::new_v4(),
@ -128,7 +128,7 @@ impl Phrase {
loop_start: 0,
loop_length: length,
percussive: true,
color: color.unwrap_or_else(ItemColorTriplet::random)
color: color.unwrap_or_else(ItemPalette::random)
}
}
pub fn set_length (&mut self, length: usize) {

View file

@ -10,7 +10,7 @@ pub struct ItemColor {
}
/// A color in OKHSL and RGB with lighter and darker variants.
#[derive(Debug, Default, Copy, Clone, PartialEq)]
pub struct ItemColorTriplet {
pub struct ItemPalette {
pub base: ItemColor,
pub light: ItemColor,
pub dark: ItemColor,
@ -44,7 +44,7 @@ impl ItemColor {
self.okhsl.mix(other.okhsl, distance).into()
}
}
impl From<ItemColor> for ItemColorTriplet {
impl From<ItemColor> for ItemPalette {
fn from (base: ItemColor) -> Self {
let mut light = base.okhsl.clone();
light.lightness = (light.lightness * 1.15).min(Okhsl::<f32>::max_lightness());
@ -54,7 +54,7 @@ impl From<ItemColor> for ItemColorTriplet {
Self { base, light: light.into(), dark: dark.into() }
}
}
impl ItemColorTriplet {
impl ItemPalette {
pub fn random () -> Self {
ItemColor::random().into()
}

View file

@ -110,7 +110,10 @@ pub trait Area<N: Coordinate>: Copy {
[self.x(), self.y(), a, self.h()],
[self.x() + a, self.y(), self.w().minus(a), self.h()],
),
_ => todo!(),
Direction::Left => (
[self.x() + self.w() - a, self.y(), a, self.h()],
[self.x(), self.y(), self.w() - a, self.h()],
),
}
}
}

View file

@ -1,7 +1,8 @@
use crate::*;
/// A widget that tracks its render width and height
pub struct Measure<E: Engine>(PhantomData<E>, AtomicUsize, AtomicUsize);
#[derive(Default)]
pub struct Measure<E: Engine>(PhantomData<E>, AtomicUsize, AtomicUsize, bool);
impl<E: Engine> Clone for Measure<E> {
fn clone (&self) -> Self {
@ -9,6 +10,7 @@ impl<E: Engine> Clone for Measure<E> {
Default::default(),
AtomicUsize::from(self.1.load(Ordering::Relaxed)),
AtomicUsize::from(self.2.load(Ordering::Relaxed)),
self.3
)
}
}
@ -16,8 +18,8 @@ impl<E: Engine> Clone for Measure<E> {
impl<E: Engine> std::fmt::Debug for Measure<E> {
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
f.debug_struct("Measure")
.field("width", &self.0)
.field("height", &self.1)
.field("width", &self.1)
.field("height", &self.2)
.finish()
}
}
@ -29,18 +31,24 @@ impl<E: Engine> Measure<E> {
pub fn set_w (&self, w: impl Into<usize>) { self.1.store(w.into(), Ordering::Relaxed) }
pub fn set_h (&self, h: impl Into<usize>) { self.2.store(h.into(), Ordering::Relaxed) }
pub fn set_wh (&self, w: impl Into<usize>, h: impl Into<usize>) { self.set_w(w); self.set_h(h); }
pub fn new () -> Self { Self(PhantomData::default(), 0.into(), 0.into()) }
pub fn new () -> Self { Self(PhantomData::default(), 0.into(), 0.into(), false) }
pub fn debug () -> Self { Self(PhantomData::default(), 0.into(), 0.into(), true) }
pub fn format (&self) -> String { format!("{}x{}", self.w(), self.h()) }
}
impl<E: Engine> Render<E> for Measure<E> {
fn min_size (&self, _: E::Size) -> Perhaps<E::Size> {
impl Render<Tui> for Measure<Tui> {
fn min_size (&self, _: [u16;2]) -> Perhaps<[u16;2]> {
Ok(Some([0u16.into(), 0u16.into()].into()))
}
fn render (&self, to: &mut E::Output) -> Usually<()> {
self.set_w(to.area().w());
self.set_h(to.area().h());
Ok(())
fn render (&self, to: &mut TuiOutput) -> Usually<()> {
let w = to.area().w();
self.set_w(w);
let h = to.area().h();
self.set_h(h);
Ok(if self.3 {
to.blit(&format!(" {w} x {h} "), to.area.x(), to.area.y(), Some(
Style::default().bold().italic().bg(Color::Rgb(255, 0, 255)).fg(Color::Rgb(0,0,0))
))
})
}
}

View file

@ -4,41 +4,52 @@ impl<E: Engine> LayoutSplit<E> for E {}
pub trait LayoutSplit<E: Engine> {
fn split <A: Render<E>, B: Render<E>> (
direction: Direction, amount: E::Unit, a: A, b: B
flip: bool, direction: Direction, amount: E::Unit, a: A, b: B
) -> Split<E, A, B> {
Split::new(direction, amount, a, b)
Split::new(flip, direction, amount, a, b)
}
fn split_up <A: Render<E>, B: Render<E>> (
amount: E::Unit, a: A, b: B
flip: bool, amount: E::Unit, a: A, b: B
) -> Split<E, A, B> {
Split::new(Direction::Up, amount, a, b)
Self::split(flip, Direction::Up, amount, a, b)
}
fn split_down <A: Render<E>, B: Render<E>> (
flip: bool, amount: E::Unit, a: A, b: B
) -> Split<E, A, B> {
Self::split(flip, Direction::Down, amount, a, b)
}
fn split_left <A: Render<E>, B: Render<E>> (
flip: bool, amount: E::Unit, a: A, b: B
) -> Split<E, A, B> {
Self::split(flip, Direction::Left, amount, a, b)
}
fn split_right <A: Render<E>, B: Render<E>> (
flip: bool, amount: E::Unit, a: A, b: B
) -> Split<E, A, B> {
Self::split(flip, Direction::Right, amount, a, b)
}
//fn split_flip <W: Render<E>> (
//self, direction: Direction, amount: E::Unit, other: W
//) -> Split<E, W, Self> { Split::new(direction, amount, other, self) }
}
/// A binary split with fixed proportion
pub struct Split<E: Engine, A: Render<E>, B: Render<E>>(
pub Direction, pub E::Unit, A, B, PhantomData<E>
pub bool, pub Direction, pub E::Unit, A, B, PhantomData<E>
);
impl<E: Engine, A: Render<E>, B: Render<E>> Split<E, A, B> {
pub fn new (direction: Direction, proportion: E::Unit, a: A, b: B) -> Self {
Self(direction, proportion, a, b, Default::default())
pub fn new (flip: bool, direction: Direction, proportion: E::Unit, a: A, b: B) -> Self {
Self(flip, direction, proportion, a, b, Default::default())
}
pub fn up (proportion: E::Unit, a: A, b: B) -> Self {
Self(Direction::Up, proportion, a, b, Default::default())
pub fn up (flip: bool, proportion: E::Unit, a: A, b: B) -> Self {
Self(flip, Direction::Up, proportion, a, b, Default::default())
}
pub fn down (proportion: E::Unit, a: A, b: B) -> Self {
Self(Direction::Down, proportion, a, b, Default::default())
pub fn down (flip: bool, proportion: E::Unit, a: A, b: B) -> Self {
Self(flip, Direction::Down, proportion, a, b, Default::default())
}
pub fn left (proportion: E::Unit, a: A, b: B) -> Self {
Self(Direction::Left, proportion, a, b, Default::default())
pub fn left (flip: bool, proportion: E::Unit, a: A, b: B) -> Self {
Self(flip, Direction::Left, proportion, a, b, Default::default())
}
pub fn right (proportion: E::Unit, a: A, b: B) -> Self {
Self(Direction::Right, proportion, a, b, Default::default())
pub fn right (flip: bool, proportion: E::Unit, a: A, b: B) -> Self {
Self(flip, Direction::Right, proportion, a, b, Default::default())
}
}
@ -47,9 +58,13 @@ impl<E: Engine, A: Render<E>, B: Render<E>> Render<E> for Split<E, A, B> {
Ok(Some(to))
}
fn render (&self, to: &mut E::Output) -> Usually<()> {
let (a, b) = to.area().split_fixed(self.0, self.1);
to.render_in(a.into(), &self.2)?;
to.render_in(b.into(), &self.3)?;
Ok(())
let (a, b) = to.area().split_fixed(self.1, self.2);
Ok(if self.0 {
to.render_in(a.into(), &self.4)?;
to.render_in(b.into(), &self.3)?;
} else {
to.render_in(a.into(), &self.3)?;
to.render_in(b.into(), &self.4)?;
})
}
}

View file

@ -147,6 +147,7 @@ render!(|self: ArrangerTui|{
))
])),
Split::right(
false,
self.splits[1],
PhraseListView::from(self),
PhraseView::from(self),
@ -174,6 +175,18 @@ impl HasPhrases for ArrangerTui {
}
}
impl HasEditor for ArrangerTui {
fn editor (&self) -> &PhraseEditorModel {
&self.editor
}
fn editor_focused (&self) -> bool {
self.focused() == ArrangerFocus::PhraseEditor
}
fn editor_entered (&self) -> bool {
self.entered() && self.editor_focused()
}
}
/// Sections in the arranger app that may be focused
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ArrangerFocus {
@ -1173,7 +1186,7 @@ impl ArrangerControl for ArrangerTui {
},
ArrangerSelection::Clip(t, s) => {
if let Some(phrase) = &self.scenes_mut()[s].clips[t] {
phrase.write().unwrap().color = ItemColorTriplet::random();
phrase.write().unwrap().color = ItemPalette::random();
}
}
}

View file

View file

@ -41,7 +41,7 @@ impl TryFrom<&Arc<RwLock<JackClient>>> for SequencerTui {
midi_buf: vec![vec![];65536],
note_buf: vec![],
perf: PerfModel::default(),
focus: FocusState::Focused(SequencerFocus::PhraseEditor)
focus: FocusState::Entered(SequencerFocus::PhraseEditor)
})
}
@ -107,9 +107,9 @@ impl Audio for SequencerTui {
}
}
render!(|self: SequencerTui|lay!([self.size, Tui::split_up(1,
Tui::fill_xy(Tui::at_s(SequencerStatusBar::from(self))),
Tui::split_up(2,
render!(|self: SequencerTui|lay!([self.size, Tui::split_up(false, 1,
Tui::fill_xy(SequencerStatusBar::from(self)),
Tui::split_down(false, 2,
TransportView::from((
self,
self.player.play_phrase().as_ref().map(|(_,p)|p.as_ref().map(|p|p.read().unwrap().color)).flatten(),
@ -119,28 +119,22 @@ render!(|self: SequencerTui|lay!([self.size, Tui::split_up(1,
false
}
)),
row!([
Tui::fixed_x(20, Tui::split_up(4, col!([
PhraseSelector::next_phrase(
&self.player,
self.focused() == SequencerFocus::PhraseNext,
self.entered()
),
Tui::split_left(false, 20,
Tui::fixed_x(20, Tui::split_down(false, 4, col!([
PhraseSelector::play_phrase(
&self.player,
self.focused() == SequencerFocus::PhrasePlay,
self.entered()
&self.player, self.focused() == SequencerFocus::PhrasePlay, self.entered()
),
]), col!([
PhraseSelector::next_phrase(
&self.player, self.focused() == SequencerFocus::PhraseNext, self.entered()
),
]), Tui::split_up(false, 2,
PhraseSelector::edit_phrase(
&self.editor.phrase,
self.focused() == SequencerFocus::PhraseEditor,
self.entered()
&self.editor.phrase, self.focused() == SequencerFocus::PhraseEditor, self.entered()
),
PhraseListView::from(self),
]))),
))),
PhraseView::from(self)
])
)
)
)]));
@ -159,6 +153,18 @@ impl HasPhrases for SequencerTui {
}
}
impl HasEditor for SequencerTui {
fn editor (&self) -> &PhraseEditorModel {
&self.editor
}
fn editor_focused (&self) -> bool {
self.focused() == SequencerFocus::PhraseEditor
}
fn editor_entered (&self) -> bool {
self.entered() && self.editor_focused()
}
}
/// Sections in the sequencer app that may be focused
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum SequencerFocus {
@ -176,7 +182,7 @@ pub enum SequencerFocus {
impl From<&SequencerTui> for Option<TransportFocus> {
fn from (state: &SequencerTui) -> Self {
match state.focus.inner() {
SequencerFocus::Transport(focus) => Some(focus),
Transport(focus) => Some(focus),
_ => None
}
}
@ -219,7 +225,7 @@ impl_focus!(SequencerTui SequencerFocus [
PhraseEditor,
],
] => [self: {
if self.focus.is_entered() && self.focus.inner() == SequencerFocus::PhraseEditor {
if self.focus.is_entered() && self.focus.inner() == PhraseEditor {
self.editor.edit_mode = PhraseEditMode::Note
} else {
self.editor.edit_mode = PhraseEditMode::Scroll
@ -403,9 +409,9 @@ pub enum SequencerCommand {
Phrases(PhrasesCommand),
Editor(PhraseCommand),
Enqueue(Option<Arc<RwLock<Phrase>>>),
Clear,
Undo,
Redo,
//Clear,
//Undo,
//Redo,
}
impl Command<SequencerTui> for SequencerCommand {
@ -419,9 +425,9 @@ impl Command<SequencerTui> for SequencerCommand {
state.player.enqueue_next(phrase.as_ref());
None
},
Self::Undo => { todo!() },
Self::Redo => { todo!() },
Self::Clear => { todo!() },
//Self::Undo => { todo!() },
//Self::Redo => { todo!() },
//Self::Clear => { todo!() },
})
}
}
@ -453,37 +459,34 @@ impl InputToCommand<Tui, SequencerTui> for SequencerCommand {
}
pub fn to_sequencer_command (state: &SequencerTui, input: &TuiInput) -> Option<SequencerCommand> {
let stopped = state.clock().is_stopped();
Some(match input.event() {
// Play/pause
key!(Char(' ')) => Clock(
if state.clock().is_stopped() { Play(None) } else { Pause(None) }
),
key!(Char(' ')) => Clock(if stopped { Play(None) } else { Pause(None) }),
// Play from start/rewind to start
key!(Shift-Char(' ')) => Clock(
if state.clock().is_stopped() { Play(Some(0)) } else { Pause(Some(0)) }
),
key!(Shift-Char(' ')) => Clock(if stopped { Play(Some(0)) } else { Pause(Some(0)) }),
// Edit phrase
key!(Char('e')) => match state.focused() {
SequencerFocus::PhrasePlay => Editor(Show(
PhrasePlay => Editor(Show(
state.player.play_phrase().as_ref().map(|x|x.1.as_ref()).flatten().map(|x|x.clone())
)),
SequencerFocus::PhraseNext => Editor(Show(
PhraseNext => Editor(Show(
state.player.next_phrase().as_ref().map(|x|x.1.as_ref()).flatten().map(|x|x.clone())
)),
SequencerFocus::PhraseList => Editor(Show(
PhraseList => Editor(Show(
Some(state.phrases.phrases[state.phrases.phrase.load(Ordering::Relaxed)].clone())
)),
_ => return None,
},
_ => match state.focused() {
SequencerFocus::Transport(_) => match TransportCommand::input_to_command(state, input)? {
Transport(_) => match TransportCommand::input_to_command(state, input)? {
TransportCommand::Clock(command) => Clock(command),
_ => return None,
},
SequencerFocus::PhraseEditor => Editor(
PhraseEditor => Editor(
PhraseCommand::input_to_command(&state.editor, input)?
),
SequencerFocus::PhraseList => match input.event() {
PhraseList => match input.event() {
key!(Enter) => Enqueue(Some(
state.phrases.phrases[state.phrases.phrase.load(Ordering::Relaxed)].clone()
)),

View file

@ -75,13 +75,13 @@ pub struct TransportView {
current_second: f64,
}
impl<T: HasClock> From<(&T, Option<ItemColorTriplet>, bool)> for TransportView {
fn from ((state, color, focused): (&T, Option<ItemColorTriplet>, bool)) -> Self {
impl<T: HasClock> From<(&T, Option<ItemPalette>, bool)> for TransportView {
fn from ((state, color, focused): (&T, Option<ItemPalette>, bool)) -> Self {
let clock = state.clock();
let sr = format!("{:.1}k", clock.timebase.sr.get() / 1000.0);
let bpm = format!("{:.3}", clock.timebase.bpm.get());
let ppq = format!("{:.0}", clock.timebase.ppq.get());
let color = color.unwrap_or(ItemColorTriplet::from(ItemColor::from(TuiTheme::g(100))));
let color = color.unwrap_or(ItemPalette::from(ItemColor::from(TuiTheme::g(100))));
let bg = if focused { color.light.rgb } else { color.dark.rgb };
if let Some(started) = clock.started.read().unwrap().as_ref() {
let current_sample = (clock.global.sample.get() - started.sample.get())/1000.;
@ -114,7 +114,7 @@ impl<T: HasClock> From<(&T, Option<ItemColorTriplet>, bool)> for TransportView {
global_second: format!("{:.1}s", clock.global.usec.get()/1000000.),
current_sample: 0.0,
current_second: 0.0,
beat: format!("0.0.00")
beat: format!("000.0.00")
}
}
}
@ -133,31 +133,39 @@ render!(|self: TransportView|{
let border_style = Style::default()
.bg(bg)
.fg(TuiTheme::border_fg(self.focused));
let play_bg = if self.started{Color::Rgb(0,128,0)}else{Color::Rgb(128,64,0)};
Tui::bg(bg, lay!(move|add|{
add(&Tui::fill_x(Tui::at_w(lay!(move|add|{
add(&Lozenge(border_style))?;
add(&Tui::outset_x(1, row!([
TransportField("Beat", self.beat.as_str()), " ",
TransportField("BPM ", self.bpm.as_str()), " ",
])))
}))))?;
add(&Tui::fill_x(Tui::center_x(Tui::pull_x(2, row!([
col!(|add|{
if self.started {
add(&col!([Tui::fg(Color::Rgb(0, 255, 0), "▶ PLAYING"), ""]))
} else {
add(&col!(["", Tui::fg(Color::Rgb(255, 128, 0), "⏹ STOPPED")]))
}
}),
])))))?;
add(&Tui::fill_x(Tui::at_e(lay!(move|add|{
add(&Lozenge(border_style))?;
add(&Tui::outset_x(1, row!([
TransportField("Second", format!("{:.1}s", self.current_second).as_str()), " ",
TransportField("Rate ", self.sr.as_str()), " ",
TransportField("Sample", format!("{:.0}k", self.current_sample).as_str()),
])))
}))))
add(&Tui::fill_x(Tui::at_w(lay!([
//Lozenge(border_style),
Tui::outset_x(0, row!([
Tui::bg(play_bg, Tui::outset_x(1, Tui::fixed_x(9, col!(|add|{
if self.started {
add(&col!([Tui::fg(Color::Rgb(0, 255, 0), "▶ PLAYING"), ""]))
} else {
add(&col!(["", Tui::fg(Color::Rgb(255, 128, 0), "⏹ STOPPED")]))
}
})))), " ",
Tui::fixed_x(10, TransportField("Beat",
self.beat.as_str())), " ",
Tui::fixed_x(8, TransportField("BPM ",
self.bpm.as_str())), " ",
Tui::fixed_x(8, TransportField("Second",
format!("{:.1}s", self.current_second).as_str())), " ",
Tui::fixed_x(8, TransportField("Rate ",
self.sr.as_str())), " ",
Tui::fixed_x(8, TransportField("Sample",
format!("{:.0}k", self.current_sample).as_str())),
]))
]))))?;
//add(&Tui::fill_x(Tui::center_x(Tui::pull_x(2, row!([
//<center>
//])))))?;
//add(&Tui::fill_x(Tui::at_e(lay!(move|add|{
////add(&Lozenge(border_style))?;
//add(&Tui::outset_x(1, row!([
//])))
//}))))
Ok(())
}))
});

View file

@ -10,6 +10,9 @@ pub trait Theme {
fn null () -> Color {
Color::Reset
}
fn bg0 () -> Color {
Color::Rgb(20, 20, 20)
}
fn bg () -> Color {
Color::Rgb(28, 35, 25)
}

View file

@ -1,13 +1,17 @@
use crate::*;
pub trait HasEditor {
fn editor (&self) -> &PhraseEditorModel;
fn editor_focused (&self) -> bool;
fn editor_entered (&self) -> bool;
}
/// Contains state for viewing and editing a phrase
pub struct PhraseEditorModel {
/// Phrase being played
pub(crate) phrase: Option<Arc<RwLock<Phrase>>>,
/// Length of note that will be inserted, in pulses
pub(crate) note_len: usize,
/// The full piano roll is rendered to this buffer
pub(crate) buffer: BigBuffer,
/// Notes currently held at input
pub(crate) notes_in: Arc<RwLock<[bool; 128]>>,
/// Notes currently held at output
@ -24,25 +28,7 @@ pub struct PhraseEditorModel {
pub(crate) time_point: AtomicUsize,
pub(crate) edit_mode: PhraseEditMode,
pub(crate) view_mode: PhraseViewMode,
}
#[derive(Copy, Clone, Debug)]
pub enum PhraseEditMode {
Note,
Scroll,
}
#[derive(Copy, Clone, Debug)]
pub enum PhraseViewMode {
PianoHorizontal {
time_zoom: usize,
note_zoom: PhraseViewNoteZoom,
},
PianoVertical {
time_zoom: usize,
note_zoom: PhraseViewNoteZoom,
},
pub(crate) view_mode: Box<dyn PhraseViewMode + Send + Sync>,
}
impl std::fmt::Debug for PhraseEditorModel {
@ -65,7 +51,6 @@ impl Default for PhraseEditorModel {
Self {
phrase: None,
note_len: 24,
buffer: Default::default(),
notes_in: RwLock::new([false;128]).into(),
notes_out: RwLock::new([false;128]).into(),
now: Pulse::default().into(),
@ -75,10 +60,11 @@ impl Default for PhraseEditorModel {
note_point: 0.into(),
time_start: 0.into(),
time_point: 0.into(),
view_mode: PhraseViewMode::PianoHorizontal {
view_mode: Box::new(PianoHorizontal {
buffer: Default::default(),
time_zoom: 24,
note_zoom: PhraseViewNoteZoom::N(1)
},
}),
}
}
}
@ -96,7 +82,7 @@ impl PhraseEditorModel {
let end = (start + self.note_len) % phrase.length;
phrase.notes[time].push(MidiMessage::NoteOn { key, vel });
phrase.notes[end].push(MidiMessage::NoteOff { key, vel });
self.buffer = self.view_mode.draw(&phrase);
self.view_mode.show(Some(&phrase), self.note_len);
}
}
/// Move time cursor forward by current note length
@ -109,61 +95,43 @@ impl PhraseEditorModel {
/// Select which pattern to display. This pre-renders it to the buffer at full resolution.
pub fn show_phrase (&mut self, phrase: Option<Arc<RwLock<Phrase>>>) {
if phrase.is_some() {
self.buffer = self.view_mode.draw(&*phrase.as_ref().unwrap().read().unwrap());
self.phrase = phrase;
let phrase = &*self.phrase.as_ref().unwrap().read().unwrap();
self.view_mode.show(Some(&phrase), self.note_len);
} else {
self.buffer = Default::default();
self.view_mode.show(None, self.note_len);
self.phrase = None;
}
}
}
pub trait HasEditor {
fn editor (&self) -> &PhraseEditorModel;
fn editor_focused (&self) -> bool;
fn editor_entered (&self) -> bool;
}
impl HasEditor for SequencerTui {
fn editor (&self) -> &PhraseEditorModel {
&self.editor
}
fn editor_focused (&self) -> bool {
self.focused() == SequencerFocus::PhraseEditor
}
fn editor_entered (&self) -> bool {
self.entered() && self.editor_focused()
}
}
impl HasEditor for ArrangerTui {
fn editor (&self) -> &PhraseEditorModel {
&self.editor
}
fn editor_focused (&self) -> bool {
self.focused() == ArrangerFocus::PhraseEditor
}
fn editor_entered (&self) -> bool {
self.entered() && self.editor_focused()
}
}
pub struct PhraseView<'a> {
focused: bool,
entered: bool,
phrase: &'a Option<Arc<RwLock<Phrase>>>,
buffer: &'a BigBuffer,
note_len: usize,
now: &'a Arc<Pulse>,
size: &'a Measure<Tui>,
view_mode: &'a PhraseViewMode,
note_point: usize,
note_range: (usize, usize),
note_names: (&'a str, &'a str),
time_start: usize,
time_point: usize,
note_len: usize,
phrase: &'a Option<Arc<RwLock<Phrase>>>,
view_mode: &'a Box<dyn PhraseViewMode + Send + Sync>,
now: &'a Arc<Pulse>,
size: &'a Measure<Tui>,
focused: bool,
entered: bool,
}
render!(|self: PhraseView<'a>|{
let bg = if self.focused { TuiTheme::g(32) } else { Color::Reset };
let fg = self.phrase.as_ref()
.map(|p|p.read().unwrap().color.clone())
.unwrap_or(ItemPalette::from(ItemColor::from(TuiTheme::g(64))));
Tui::bg(bg, lay!([
PhraseTimeline(&self, fg),
PhraseViewNotes(&self, fg),
PhraseViewCursor(&self),
PhraseViewKeys(&self, fg),
PhraseViewStats(&self, fg),
//Measure::debug(),
]))
});
impl<'a, T: HasEditor> From<&'a T> for PhraseView<'a> {
fn from (state: &'a T) -> Self {
let editor = state.editor();
@ -181,88 +149,118 @@ impl<'a, T: HasEditor> From<&'a T> for PhraseView<'a> {
editor.note_lo.store(note_lo, Ordering::Relaxed);
}
Self {
focused: state.editor_focused(),
entered: state.editor_entered(),
note_len: editor.note_len,
phrase: &editor.phrase,
buffer: &editor.buffer,
now: &editor.now,
size: &editor.size,
view_mode: &editor.view_mode,
note_point,
note_range: (note_lo, note_hi),
note_names: (to_note_name(note_lo), to_note_name(note_hi)),
time_start: editor.time_start.load(Ordering::Relaxed),
time_point: editor.time_point.load(Ordering::Relaxed),
note_len: editor.note_len,
phrase: &editor.phrase,
view_mode: &editor.view_mode,
size: &editor.size,
now: &editor.now,
focused: state.editor_focused(),
entered: state.editor_entered(),
}
}
}
render!(|self: PhraseView<'a>|{
let Self {
focused, entered, size,
phrase, view_mode, buffer,
note_point, note_len,
note_range: (note_lo, note_hi),
time_start, time_point,
//now: _,
..
} = self;
let phrase_color = phrase.as_ref().map(|p|p.read().unwrap().color.clone())
.unwrap_or(ItemColorTriplet::from(ItemColor::from(TuiTheme::g(64))));
let title_color = if *focused{phrase_color.light.rgb}else{phrase_color.dark.rgb};
let bg = if self.focused { TuiTheme::bg() } else { Color::Reset };
pub struct PhraseTimeline<'a>(&'a PhraseView<'a>, ItemPalette);
render!(|self: PhraseTimeline<'a>|Tui::fg(TuiTheme::g(224), Tui::push_x(5, format!("|000.00.00"))));
pub struct PhraseViewStats<'a>(&'a PhraseView<'a>, ItemPalette);
render!(|self: PhraseViewStats<'a>|{
let title_color = if self.0.focused{self.1.light.rgb}else{self.1.dark.rgb};
lay!([
Tui::bg(bg, Tui::inset_x(1, Tui::fill_x(row!([
Tui::push_y(1, Tui::fill_y(Widget::new(|to:[u16;2]|Ok(Some(to.clip_w(5))), move|to: &mut TuiOutput|{
Ok(if to.area().h() >= 2 { view_mode.render_keys(to, *note_hi, *note_lo) })
}))),
Tui::fill_x(lay!([
Tui::push_y(1, Tui::fill_x(Widget::new(|to|Ok(Some(to)), |to: &mut TuiOutput|{
size.set_wh(to.area.w(), to.area.h() as usize - 1);
let draw = to.area().h() >= 2;
Ok(if draw { view_mode.render_notes(to, buffer, *time_start, *note_hi) })
}))),
Tui::push_y(1, Widget::new(|to|Ok(Some(to)), move|to: &mut TuiOutput|{
Ok(if *focused && *entered {
view_mode.render_cursor(
to,
*time_point, *time_start, view_mode.time_zoom(),
*note_point, *note_len, *note_hi, *note_lo,
)
})
}))
])),
])))),
lay!(move|add|{
add(&Lozenge(Style::default().bg(phrase_color.base.rgb).fg(phrase_color.base.rgb)))?;
let upper_left = format!("{}",
phrase.as_ref().map(|p|p.read().unwrap().name.clone()).unwrap_or(String::new())
);
let lower_left = format!("");
let mut lower_right = format!(" {} ", size.format());
if *focused && *entered {
lower_right = format!("Note: {} ({}) {} {lower_right}",
note_point, to_note_name(*note_point), pulses_to_name(*note_len)
Tui::at_sw({
let mut lower_right = format!(" {} ", self.0.size.format());
if self.0.focused && self.0.entered {
lower_right = format!(
"Note: {} ({}) {} {lower_right}",
self.0.note_point,
to_note_name(self.0.note_point),
pulses_to_name(self.0.note_len),
);
}
let mut upper_right = format!("[{}]", if *entered {""} else {" "});
if let Some(phrase) = phrase {
upper_right = format!("Time: {}/{} {} {upper_right}",
time_point, phrase.read().unwrap().length, pulses_to_name(view_mode.time_zoom()),
Tui::bg(title_color, Tui::fg(TuiTheme::g(224), lower_right))
}),
Tui::fill_xy(Tui::at_se({
let mut upper_right = format!("[{}]", if self.0.entered {""} else {" "});
if let Some(phrase) = self.0.phrase {
upper_right = format!(
"Time: {}/{} {} {upper_right}",
self.0.time_point,
phrase.read().unwrap().length,
pulses_to_name(self.0.view_mode.time_zoom()),
)
};
add(&Tui::push_x(1, Tui::at_nw(Tui::fg(TuiTheme::g(224), Tui::bg(title_color, upper_left)))))?;
add(&Tui::at_sw(Tui::bg(title_color, Tui::fg(TuiTheme::g(224), lower_left))))?;
add(&Tui::fill_xy(Tui::at_ne(Tui::pull_x(1, Tui::bg(title_color, Tui::fg(TuiTheme::g(224), upper_right))))))?;
add(&Tui::fill_xy(Tui::at_se(Tui::pull_x(1, Tui::bg(title_color, Tui::fg(TuiTheme::g(224), lower_right))))))?;
Ok(())
}),
Tui::pull_x(1, Tui::bg(title_color, Tui::fg(TuiTheme::g(224), upper_right)))
})),
])
});
struct PhraseViewKeys<'a>(&'a PhraseView<'a>, ItemPalette);
render!(|self: PhraseViewKeys<'a>|{
let layout = |to:[u16;2]|Ok(Some(to.clip_w(5)));
Tui::fill_xy(Widget::new(layout, |to: &mut TuiOutput|Ok(
self.0.view_mode.render_keys(to, self.1.dark.rgb, Some(self.0.note_point), self.0.note_range)
)))
});
struct PhraseViewNotes<'a>(&'a PhraseView<'a>, ItemPalette);
render!(|self: PhraseViewNotes<'a>|Tui::fill_xy(render(|to: &mut TuiOutput|{
self.0.size.set_wh(to.area.w(), to.area.h() as usize);
Ok(self.0.view_mode.render_notes(to, self.0.time_start, self.0.note_range.1))
})));
struct PhraseViewCursor<'a>(&'a PhraseView<'a>);
render!(|self: PhraseViewCursor<'a>|Tui::fill_xy(render(|to: &mut TuiOutput|Ok(
self.0.view_mode.render_cursor(
to,
self.0.time_point,
self.0.time_start,
self.0.view_mode.time_zoom(),
self.0.note_point,
self.0.note_len,
self.0.note_range.1,
self.0.note_range.0,
)
))));
#[derive(Copy, Clone, Debug)]
pub enum PhraseEditMode {
Note,
Scroll,
}
pub trait PhraseViewMode {
fn show (&mut self, phrase: Option<&Phrase>, note_len: usize);
fn time_zoom (&self) -> usize;
fn set_time_zoom (&mut self, time_zoom: usize);
fn buffer_width (&self, phrase: &Phrase) -> usize;
fn buffer_height (&self, phrase: &Phrase) -> usize;
fn render_keys (&self,
to: &mut TuiOutput, color: Color, point: Option<usize>, range: (usize, usize));
fn render_notes (&self,
to: &mut TuiOutput, time_start: usize, note_hi: usize);
fn render_cursor (
&self,
to: &mut TuiOutput,
time_point: usize,
time_start: usize,
time_zoom: usize,
note_point: usize,
note_len: usize,
note_hi: usize,
note_lo: usize,
);
}
pub struct PianoHorizontal {
time_zoom: usize,
note_zoom: PhraseViewNoteZoom,
buffer: BigBuffer,
}
#[derive(Copy, Clone, Debug)]
pub enum PhraseViewNoteZoom {
N(usize),
@ -270,97 +268,100 @@ pub enum PhraseViewNoteZoom {
Octant,
}
impl PhraseViewMode {
pub fn time_zoom (&self) -> usize {
match self {
Self::PianoHorizontal { time_zoom, .. } => *time_zoom,
impl PhraseViewMode for PianoHorizontal {
fn time_zoom (&self) -> usize {
self.time_zoom
}
fn set_time_zoom (&mut self, time_zoom: usize) {
self.time_zoom = time_zoom
}
fn show (&mut self, phrase: Option<&Phrase>, note_len: usize) {
if let Some(phrase) = phrase {
self.buffer = BigBuffer::new(self.buffer_width(phrase), self.buffer_height(phrase));
draw_piano_horizontal(&mut self.buffer, phrase, self.time_zoom, note_len);
} else {
self.buffer = Default::default();
}
}
fn buffer_width (&self, phrase: &Phrase) -> usize {
phrase.length / self.time_zoom
}
/// Determine the required height to render the phrase.
fn buffer_height (&self, phrase: &Phrase) -> usize {
match self.note_zoom {
PhraseViewNoteZoom::Half => 64,
PhraseViewNoteZoom::N(n) => 128*n,
_ => unimplemented!()
}
}
pub fn set_time_zoom (&mut self, time_zoom: usize) {
*self = match self {
Self::PianoHorizontal { note_zoom, .. } => Self::PianoHorizontal {
note_zoom: *note_zoom,
time_zoom,
},
_ => unimplemented!()
}
}
/// Return a new [BigBuffer] containing a render of the phrase.
pub fn draw (&self, phrase: &Phrase) -> BigBuffer {
let mut buffer = BigBuffer::new(self.buffer_width(phrase), self.buffer_height(phrase));
match self {
Self::PianoHorizontal { time_zoom, note_zoom } => match note_zoom {
PhraseViewNoteZoom::N(_) => Self::draw_piano_horizontal(
&mut buffer, phrase, *time_zoom
),
_ => unimplemented!(),
},
_ => unimplemented!(),
}
buffer
}
/// Draw a subsection of the [BigBuffer] onto a regular ratatui [Buffer].
fn render_notes (
&self,
target: &mut TuiOutput,
source: &BigBuffer,
target: &mut TuiOutput,
time_start: usize,
note_hi: usize,
) {
let area = target.area();
let [x0, y0, w, h] = target.area().xywh();
let source = &self.buffer;
let target = &mut target.buffer;
match self {
Self::PianoHorizontal { .. } => {
let [x0, y0, w, h] = area.xywh();
for (x, target_x) in (x0..x0+w).enumerate() {
for (y, target_y) in (y0..y0+h).enumerate() {
if y > note_hi {
break
}
let source_x = time_start + x;
let source_y = note_hi - y;
// TODO: enable loop rollover:
//let source_x = (time_start + x) % source.width.max(1);
//let source_y = (note_hi - y) % source.height.max(1);
if source_x < source.width && source_y < source.height {
let target_cell = target.get_mut(target_x, target_y);
if let Some(source_cell) = source.get(source_x, source_y) {
*target_cell = source_cell.clone();
}
}
for (x, target_x) in (x0..x0+w).enumerate() {
for (y, target_y) in (y0..y0+h).enumerate() {
if y > note_hi {
break
}
let source_x = time_start + x;
let source_y = note_hi - y;
// TODO: enable loop rollover:
//let source_x = (time_start + x) % source.width.max(1);
//let source_y = (note_hi - y) % source.height.max(1);
if source_x < source.width && source_y < source.height {
let target_cell = target.get_mut(target_x, target_y);
if let Some(source_cell) = source.get(source_x, source_y) {
*target_cell = source_cell.clone();
}
}
},
_ => unimplemented!()
}
}
}
fn render_keys (&self, to: &mut TuiOutput, note_hi: usize, note_lo: usize) {
let style = Some(Style::default().fg(Color::Rgb(192, 192, 192)).bg(Color::Rgb(0, 0, 0)));
match self {
Self::PianoHorizontal { .. } => {
let [x0, y0, _, _] = to.area().xywh();
for (y, note) in (note_lo..=note_hi).rev().enumerate() {
let key = match note % 12 {
11 => "█████",
10 => " ",
9 => "█████",
8 => " ",
7 => "█████",
6 => " ",
5 => "█████",
4 => "█████",
3 => " ",
2 => "█████",
1 => " ",
0 => "█████",
_ => unreachable!(),
};
to.blit(&key, x0, y0 + y as u16, style);
to.blit(&format!("{}", to_note_name(note)), x0, y0 + y as u16, None);
}
},
_ => unimplemented!()
fn render_keys (
&self,
to: &mut TuiOutput,
color: Color,
point: Option<usize>,
(note_lo, note_hi): (usize, usize)
) {
let [x, y0, _, _] = to.area().xywh();
let key_style = Some(Style::default().fg(Color::Rgb(192, 192, 192)).bg(Color::Rgb(0, 0, 0)));
let note_off_style = Some(Style::default().fg(TuiTheme::g(160)));
let note_on_style = Some(Style::default().fg(TuiTheme::g(255)).bg(color).bold());
for (y, note) in (note_lo..=note_hi).rev().enumerate().map(|(y, n)|(y0 + y as u16, n)) {
let key = match note % 12 {
11 => "████▌",
10 => " ",
9 => "████▌",
8 => " ",
7 => "████▌",
6 => " ",
5 => "████▌",
4 => "████▌",
3 => " ",
2 => "████▌",
1 => " ",
0 => "████▌",
_ => unreachable!(),
};
to.blit(&key, x, y, key_style);
if Some(note) == point {
to.blit(&format!("{:<5}", to_note_name(note)), x, y, note_on_style)
} else {
to.blit(&to_note_name(note), x, y, note_off_style)
};
}
}
fn render_cursor (
@ -374,121 +375,147 @@ impl PhraseViewMode {
note_hi: usize,
note_lo: usize,
) {
let [x0, y0, w, _] = to.area().xywh();
let style = Some(Style::default().fg(Color::Rgb(0,255,0)));
match self {
Self::PianoHorizontal { .. } => {
let [x0, y0, w, _] = to.area().xywh();
for (y, note) in (note_lo..=note_hi).rev().enumerate() {
if note == note_point {
for x in 0..w {
let time_1 = time_start + x as usize * time_zoom;
let time_2 = time_1 + time_zoom;
if time_1 <= time_point && time_point < time_2 {
to.blit(&"", x0 + x as u16, y0 + y as u16, style);
let tail = note_len as u16 / time_zoom as u16;
for x_tail in (x0 + x + 1)..(x0 + x + tail) {
to.blit(&"", x_tail, y0 + y as u16, style);
}
break
}
for (y, note) in (note_lo..=note_hi).rev().enumerate() {
if note == note_point {
for x in 0..w {
let time_1 = time_start + x as usize * time_zoom;
let time_2 = time_1 + time_zoom;
if time_1 <= time_point && time_point < time_2 {
to.blit(&"", x0 + x as u16, y0 + y as u16, style);
let tail = note_len as u16 / time_zoom as u16;
for x_tail in (x0 + x + 1)..(x0 + x + tail) {
to.blit(&"", x_tail, y0 + y as u16, style);
}
break
}
}
},
_ => unimplemented!()
break
}
}
}
/// Determine the required width to render the phrase.
fn buffer_width (&self, phrase: &Phrase) -> usize {
match self {
Self::PianoHorizontal { time_zoom, .. } => {
phrase.length / time_zoom
},
Self::PianoVertical { note_zoom, .. } => match note_zoom {
PhraseViewNoteZoom::Half => 64,
PhraseViewNoteZoom::N(n) => 128*n,
_ => unimplemented!()
},
}
fn draw_piano_horizontal_bg (
target: &mut BigBuffer,
phrase: &Phrase,
time_zoom: usize,
note_len: usize,
) {
for (y, note) in (0..127).rev().enumerate() {
for (x, time) in (0..target.width).map(|x|(x, x*time_zoom)) {
let cell = target.get_mut(x, y).unwrap();
//cell.set_fg(Color::Rgb(48, 55, 45));
cell.set_fg(phrase.color.dark.rgb);
cell.set_char(if time % 384 == 0 {
'│'
} else if time % 96 == 0 {
'╎'
} else if time % note_len == 0 {
'┊'
} else if (127 - note) % 12 == 1 {
'='
} else {
'·'
});
}
}
/// Determine the required height to render the phrase.
fn buffer_height (&self, phrase: &Phrase) -> usize {
match self {
Self::PianoHorizontal { note_zoom, .. } => match note_zoom {
PhraseViewNoteZoom::Half => 64,
PhraseViewNoteZoom::N(n) => 128*n,
_ => unimplemented!()
},
Self::PianoVertical { time_zoom, .. } => {
phrase.length / time_zoom
},
}
}
/// Draw the piano roll using full blocks on note on and half blocks on legato: █▄ █▄ █▄
fn draw_piano_horizontal (
target: &mut BigBuffer,
phrase: &Phrase,
time_zoom: usize,
) {
let color = phrase.color.light.rgb;
let style = Style::default().fg(color);//.bg(Color::Rgb(0, 0, 0));
}
fn draw_piano_horizontal_fg (
target: &mut BigBuffer,
phrase: &Phrase,
time_zoom: usize,
) {
let style = Style::default().fg(phrase.color.light.rgb);//.bg(Color::Rgb(0, 0, 0));
let mut notes_on = [false;128];
for (x, time_start) in (0..phrase.length).step_by(time_zoom).enumerate() {
for (y, note) in (0..127).rev().enumerate() {
for (x, time) in (0..target.width).map(|x|(x, x*time_zoom)) {
let cell = target.get_mut(x, y).unwrap();
//cell.set_fg(Color::Rgb(48, 55, 45));
cell.set_fg(phrase.color.dark.rgb);
cell.set_char(if time % 384 == 0 {
'│'
} else if time % 96 == 0 {
'╎'
} else if note % 12 == 0 {
'='
} else {
'·'
});
let cell = target.get_mut(x, note).unwrap();
if notes_on[note] {
cell.set_char('▄');
cell.set_style(style);
}
}
let mut notes_on = [false;128];
for (x, time_start) in (0..phrase.length).step_by(time_zoom).enumerate() {
let time_end = time_start + time_zoom;
for (y, note) in (0..127).rev().enumerate() {
let cell = target.get_mut(x, note).unwrap();
if notes_on[note] {
cell.set_char('▄');
cell.set_style(style);
}
}
for time in time_start..time_end {
for event in phrase.notes[time].iter() {
match event {
MidiMessage::NoteOn { key, .. } => {
let note = key.as_int() as usize;
let cell = target.get_mut(x, note).unwrap();
cell.set_char('█');
cell.set_style(style);
notes_on[note] = true
},
MidiMessage::NoteOff { key, .. } => {
notes_on[key.as_int() as usize] = false
},
_ => {}
}
let time_end = time_start + time_zoom;
for time in time_start..time_end {
for event in phrase.notes[time].iter() {
match event {
MidiMessage::NoteOn { key, .. } => {
let note = key.as_int() as usize;
let cell = target.get_mut(x, note).unwrap();
cell.set_char('█');
cell.set_style(style);
notes_on[note] = true
},
MidiMessage::NoteOff { key, .. } => {
notes_on[key.as_int() as usize] = false
},
_ => {}
}
}
}
}
/// TODO: Draw the piano roll using octant blocks (U+1CD00-U+1CDE5)
fn draw_piano_horizontal_octant (
_: &mut BigBuffer, _: &Phrase, _: usize
) {
unimplemented!()
}
/// Draw the piano roll using full blocks on note on and half blocks on legato: █▄ █▄ █▄
fn draw_piano_horizontal (
target: &mut BigBuffer,
phrase: &Phrase,
time_zoom: usize,
note_len: usize,
) {
let color = phrase.color.light.rgb;
let style = Style::default().fg(color);//.bg(Color::Rgb(0, 0, 0));
for (y, note) in (0..127).rev().enumerate() {
for (x, time) in (0..target.width).map(|x|(x, x*time_zoom)) {
let cell = target.get_mut(x, y).unwrap();
//cell.set_fg(Color::Rgb(48, 55, 45));
cell.set_fg(phrase.color.dark.rgb);
cell.set_char(if time % 384 == 0 {
'│'
} else if time % 96 == 0 {
'╎'
} else if time % note_len == 0 {
'┊'
} else if (127 - note) % 12 == 1 {
'='
} else {
'·'
});
}
}
/// TODO: Draw the piano roll using half blocks: ▄▀▄
fn draw_piano_horizontal_half (
_: &mut BigBuffer, _: &Phrase, _: usize
) {
unimplemented!()
let mut notes_on = [false;128];
for (x, time_start) in (0..phrase.length).step_by(time_zoom).enumerate() {
let time_end = time_start + time_zoom;
for (y, note) in (0..127).rev().enumerate() {
let cell = target.get_mut(x, note).unwrap();
if notes_on[note] {
cell.set_char('▄');
cell.set_style(style);
}
}
for time in time_start..time_end {
for event in phrase.notes[time].iter() {
match event {
MidiMessage::NoteOn { key, .. } => {
let note = key.as_int() as usize;
let cell = target.get_mut(x, note).unwrap();
cell.set_char('█');
cell.set_style(style);
notes_on[note] = true
},
MidiMessage::NoteOff { key, .. } => {
notes_on[key.as_int() as usize] = false
},
_ => {}
}
}
}
}
}

View file

@ -122,12 +122,13 @@ impl<'a, T: HasPhraseList> From<&'a T> for PhraseListView<'a> {
// TODO: Display phrases always in order of appearance
render!(|self: PhraseListView<'a>|{
let Self { title, focused, entered, phrases, index, mode } = self;
let bg = if *focused {TuiTheme::g(32)} else {TuiTheme::null()};
let border_bg = if *focused {TuiTheme::bg()} else {TuiTheme::null()};
let border_color = if *entered {TuiTheme::bo1()} else {TuiTheme::bo2()};
let title_color = if *focused {TuiTheme::ti1()} else {TuiTheme::ti2()};
let upper_left = format!("{title}");
let upper_right = format!("({})", phrases.len());
Tui::bg(border_bg, lay!(move|add|{
Tui::bg(bg, lay!(move|add|{
//add(&Lozenge(Style::default().bg(border_bg).fg(border_color)))?;
add(&Tui::inset_xy(0, 1, Tui::fill_xy(col!(move|add|match mode {
Some(PhrasesMode::Import(_, ref browser)) => {
@ -289,14 +290,14 @@ fn to_phrases_command (state: &PhraseListModel, input: &TuiInput) -> Option<Phra
return None
},
key!(Char('a')) => Cmd::Phrase(Pool::Add(count, Phrase::new(
String::from("(new)"), true, 4 * PPQ, None, Some(ItemColorTriplet::random())
String::from("(new)"), true, 4 * PPQ, None, Some(ItemPalette::random())
))),
key!(Char('i')) => Cmd::Phrase(Pool::Add(index + 1, Phrase::new(
String::from("(new)"), true, 4 * PPQ, None, Some(ItemColorTriplet::random())
String::from("(new)"), true, 4 * PPQ, None, Some(ItemPalette::random())
))),
key!(Char('d')) => {
let mut phrase = state.phrases()[index].read().unwrap().duplicate();
phrase.color = ItemColorTriplet::random_near(phrase.color, 0.25);
phrase.color = ItemPalette::random_near(phrase.color, 0.25);
Cmd::Phrase(Pool::Add(index + 1, phrase))
},
_ => return None