use super::*;
use crate::fluid::interaction::{
AutomationKind, AutomationMode, ChordDrill, InteractionMode, InteractionModel, LeadDrill,
Navigation, PerformanceAction, PerformanceInstrument, PerformanceMode, PerformanceTargets,
SequenceStage,
};
pub(crate) const MIN_TERMINAL_WIDTH: u16 = 46;
pub(crate) const MIN_TERMINAL_HEIGHT: u16 = 10;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum KeyboardOwner {
Browsing,
Numeric,
Palette,
Lfo,
Envelope,
PerformanceDeck,
PerformanceSequence,
Lead,
}
impl KeyboardOwner {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Browsing => "BROWSE",
Self::Numeric => "NUMERIC",
Self::Palette => "PALETTE",
Self::Lfo => AutomationKind::Lfo.label(),
Self::Envelope => AutomationKind::Envelope.label(),
Self::PerformanceDeck => "DECK",
Self::PerformanceSequence => "SEQUENCE",
Self::Lead => "LEAD",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NoticeKind {
Effect,
PendingCommit,
Auto,
Update,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct ViewNotices {
pub(crate) effect: Option<String>,
pub(crate) pending_commit: Option<String>,
pub(crate) auto: Option<String>,
pub(crate) update: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HelpSurface {
Owner { owner: KeyboardOwner, text: String },
Notice { kind: NoticeKind, text: String },
Browsing { text: String },
}
impl HelpSurface {
pub(crate) fn text(&self) -> &str {
match self {
Self::Owner { text, .. } | Self::Notice { text, .. } | Self::Browsing { text } => text,
}
}
pub(crate) fn emphasized(&self) -> bool {
!matches!(self, Self::Browsing { .. })
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct NavigationView {
pub(crate) tab: Tab,
pub(crate) chord_drill: ChordDrill,
pub(crate) lead_drill: LeadDrill,
pub(crate) module_slot: Option<usize>,
pub(crate) selected: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct TelemetryView {
pub(crate) beat: f64,
pub(crate) active_chord: u64,
}
pub(crate) struct ViewPresentation<'a> {
pub(crate) fluid: &'a RippleField,
pub(crate) flipped: &'a FlippedUnits,
pub(crate) cursor_visible: bool,
pub(crate) notices: ViewNotices,
}
pub(crate) struct ViewProjection<'a> {
pub(crate) interaction: &'a InteractionModel,
pub(crate) session: &'a LiveSessionSnapshot,
pub(crate) telemetry: TelemetryView,
pub(crate) presentation: ViewPresentation<'a>,
}
pub(crate) struct UiViewModel<'a> {
pub(crate) owner: KeyboardOwner,
pub(crate) mode: ModeSurface<'a>,
pub(crate) navigation: NavigationView,
pub(crate) items: Vec<ControlItem>,
pub(crate) session: &'a LiveSessionSnapshot,
pub(crate) telemetry: TelemetryView,
pub(crate) fluid: &'a RippleField,
pub(crate) flipped: &'a FlippedUnits,
pub(crate) mute: &'a MuteState,
pub(crate) cursor_visible: bool,
pub(crate) help: HelpSurface,
}
pub(crate) enum ModeSurface<'a> {
Browsing,
Numeric {
entry: String,
resume: Option<AutomationSurface<'a>>,
},
Palette(PaletteSurface),
Automation(AutomationSurface<'a>),
Performance(PerformanceSurface),
Lead(LeadSurface),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct LeadSurface {
pub(crate) last_tone: Option<usize>,
pub(crate) octave: i32,
pub(crate) level_pct: u8,
pub(crate) holds: Option<bool>,
pub(crate) reach_len: usize,
}
pub(crate) struct PaletteSurface {
pub(crate) state: PaletteState,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AutomationUnavailable {
NoOpenEditor,
KindMismatch { active: ModKind },
MissingRoute,
}
pub(crate) enum AutomationSurface<'a> {
Lfo {
selected: usize,
address: ControlAddress,
lane_index: usize,
lane_count: usize,
route: &'a LfoRoute,
state: &'a AutomationState,
},
Envelope {
selected: usize,
address: ControlAddress,
lane_index: usize,
lane_count: usize,
route: &'a EnvelopeRoute,
},
Unavailable {
requested: AutomationKind,
selected: usize,
reason: AutomationUnavailable,
},
}
impl AutomationSurface<'_> {
pub(crate) fn selected(&self) -> usize {
match self {
Self::Lfo { selected, .. }
| Self::Envelope { selected, .. }
| Self::Unavailable { selected, .. } => *selected,
}
}
pub(crate) fn active_address(&self) -> Option<ControlAddress> {
match self {
Self::Lfo { address, .. } | Self::Envelope { address, .. } => Some(*address),
Self::Unavailable { .. } => None,
}
}
}
pub(crate) struct PerformanceInstrumentSurface {
pub(crate) instrument: PerformanceInstrument,
pub(crate) focused: bool,
pub(crate) held: bool,
pub(crate) level: ControlItem,
pub(crate) length: ControlItem,
pub(crate) density: ControlItem,
}
pub(crate) enum PerformanceSurface {
Deck {
selected: Option<usize>,
held_selectors: PerformanceTargets,
instruments: Vec<PerformanceInstrumentSurface>,
},
SequenceChoose {
held_selector: Option<usize>,
},
SequencePerform {
instrument: Option<usize>,
held_selector: Option<usize>,
values: Option<PerformanceInstrumentSurface>,
},
SequenceComplete {
instrument: Option<usize>,
release_pending: bool,
values: Option<PerformanceInstrumentSurface>,
},
}
impl<'a> UiViewModel<'a> {
pub(crate) fn project(projection: ViewProjection<'a>) -> Self {
let ViewProjection {
interaction,
session,
telemetry,
presentation,
} = projection;
let navigation = navigation_view(interaction.navigation);
let items = match navigation.module_slot {
Some(slot) => module_detail_controls(navigation.tab, slot, &session.controls),
None => match navigation.tab {
Tab::Chords => chords_tab_controls(&session.controls, navigation.chord_drill),
Tab::Lead => lead_tab_controls(&session.controls, navigation.lead_drill),
tab => tab_controls(tab, &session.controls),
},
};
let mut navigation = navigation;
navigation.selected = navigation.selected.min(items.len().saturating_sub(1));
let owner = keyboard_owner(&interaction.mode);
let mode = mode_surface(
&interaction.mode,
navigation.tab,
&session.controls,
&session.automation,
);
let help = help_surface(owner, &mode, navigation, presentation.notices);
Self {
owner,
mode,
navigation,
items,
session,
telemetry,
fluid: presentation.fluid,
flipped: presentation.flipped,
mute: &session.muted,
cursor_visible: presentation.cursor_visible,
help,
}
}
}
fn mode_surface<'a>(
mode: &InteractionMode,
tab: Tab,
controls: &FluidControls,
automation: &'a AutomationState,
) -> ModeSurface<'a> {
match mode {
InteractionMode::Browsing => ModeSurface::Browsing,
InteractionMode::Numeric(entry) => ModeSurface::Numeric {
entry: entry.buffer.clone(),
resume: entry
.resume
.map(|mode| automation_surface(mode, automation)),
},
InteractionMode::Palette(palette) => ModeSurface::Palette(PaletteSurface {
state: palette.project(tab),
}),
InteractionMode::Automation(mode) => {
ModeSurface::Automation(automation_surface(*mode, automation))
}
InteractionMode::Lead(play) => ModeSurface::Lead(LeadSurface {
last_tone: play.last_tone,
holds: play.holds,
octave: controls.lead.octave.round() as i32,
level_pct: (controls.lead.level * 100.0).round() as u8,
reach_len: lead_page_reach(&controls.lead, &controls.pad).len(),
}),
InteractionMode::Performance(PerformanceMode::Deck {
selected,
held_selectors,
}) => ModeSurface::Performance(PerformanceSurface::Deck {
selected: selected.map(crate::fluid::interaction::PerformanceInstrument::index),
held_selectors: *held_selectors,
instruments: if held_selectors.is_empty() {
selected
.iter()
.copied()
.map(|instrument| {
performance_instrument_surface(controls, instrument, *selected, false)
})
.collect()
} else {
held_selectors
.iter()
.map(|instrument| {
performance_instrument_surface(controls, instrument, *selected, true)
})
.collect()
},
}),
InteractionMode::Performance(PerformanceMode::Sequence {
stage: SequenceStage::ChooseInstrument,
held_selector,
}) => ModeSurface::Performance(PerformanceSurface::SequenceChoose {
held_selector: held_selector
.map(crate::fluid::interaction::PerformanceInstrument::index),
}),
InteractionMode::Performance(PerformanceMode::Sequence {
stage: SequenceStage::Perform { instrument },
held_selector,
}) => ModeSurface::Performance(PerformanceSurface::SequencePerform {
instrument: Some(instrument.index()),
held_selector: held_selector
.map(crate::fluid::interaction::PerformanceInstrument::index),
values: Some(performance_instrument_surface(
controls,
*instrument,
Some(*instrument),
held_selector.is_some_and(|held| held == *instrument),
)),
}),
InteractionMode::Performance(PerformanceMode::Sequence {
stage: SequenceStage::AwaitActionRelease { instrument, .. },
..
}) => ModeSurface::Performance(PerformanceSurface::SequenceComplete {
instrument: Some(instrument.index()),
release_pending: true,
values: Some(performance_instrument_surface(
controls,
*instrument,
Some(*instrument),
false,
)),
}),
InteractionMode::Performance(PerformanceMode::Sequence {
stage: SequenceStage::CompletedFallback { instrument },
..
}) => ModeSurface::Performance(PerformanceSurface::SequenceComplete {
instrument: Some(instrument.index()),
release_pending: false,
values: Some(performance_instrument_surface(
controls,
*instrument,
Some(*instrument),
false,
)),
}),
}
}
fn performance_instrument_surface(
controls: &FluidControls,
instrument: PerformanceInstrument,
selected: Option<PerformanceInstrument>,
held: bool,
) -> PerformanceInstrumentSurface {
let item = |action| {
let (_, _, spec, _) = performance_target(instrument, action)
.expect("closed performance grammar has every target");
spec.item(controls)
};
PerformanceInstrumentSurface {
instrument,
focused: selected == Some(instrument),
held,
level: item(PerformanceAction::Louder),
length: item(PerformanceAction::Longer),
density: item(PerformanceAction::Denser),
}
}
fn automation_surface(mode: AutomationMode, automation: &AutomationState) -> AutomationSurface<'_> {
let (requested, selected) = match mode {
AutomationMode::Lfo { selected, .. } => (AutomationKind::Lfo, selected),
AutomationMode::Envelope { selected } => (AutomationKind::Envelope, selected),
};
let Some(address) = automation.active_address() else {
return AutomationSurface::Unavailable {
requested,
selected,
reason: AutomationUnavailable::NoOpenEditor,
};
};
let Some(active) = automation.active_kind() else {
return AutomationSurface::Unavailable {
requested,
selected,
reason: AutomationUnavailable::NoOpenEditor,
};
};
let expected = ModKind::from(requested);
if active != expected {
return AutomationSurface::Unavailable {
requested,
selected,
reason: AutomationUnavailable::KindMismatch { active },
};
}
match mode {
AutomationMode::Lfo { selected, .. } => automation.route(address).map_or(
AutomationSurface::Unavailable {
requested,
selected,
reason: AutomationUnavailable::MissingRoute,
},
|route| AutomationSurface::Lfo {
selected,
address,
lane_index: automation.active_lane_index().unwrap_or(0),
lane_count: automation.active_lane_count().unwrap_or(1),
route,
state: automation,
},
),
AutomationMode::Envelope { selected } => automation.envelope(address).map_or(
AutomationSurface::Unavailable {
requested,
selected,
reason: AutomationUnavailable::MissingRoute,
},
|route| AutomationSurface::Envelope {
selected,
address,
lane_index: automation.active_lane_index().unwrap_or(0),
lane_count: automation.active_lane_count().unwrap_or(1),
route,
},
),
}
}
fn navigation_view(navigation: Navigation) -> NavigationView {
let mut view = NavigationView {
tab: navigation.tab(),
chord_drill: ChordDrill::None,
lead_drill: LeadDrill::None,
module_slot: None,
selected: navigation.selected(),
};
match navigation {
Navigation::Chords { drill, .. } => view.chord_drill = drill,
Navigation::Lead { drill, .. } => view.lead_drill = drill,
Navigation::Module { slot, .. } => view.module_slot = Some(slot),
Navigation::Standard { .. } | Navigation::Master { .. } => {}
}
view
}
pub(crate) fn keyboard_owner(mode: &InteractionMode) -> KeyboardOwner {
match mode {
InteractionMode::Browsing => KeyboardOwner::Browsing,
InteractionMode::Numeric(_) => KeyboardOwner::Numeric,
InteractionMode::Palette(_) => KeyboardOwner::Palette,
InteractionMode::Automation(AutomationMode::Lfo { .. }) => KeyboardOwner::Lfo,
InteractionMode::Automation(AutomationMode::Envelope { .. }) => KeyboardOwner::Envelope,
InteractionMode::Performance(PerformanceMode::Deck { .. }) => {
KeyboardOwner::PerformanceDeck
}
InteractionMode::Performance(PerformanceMode::Sequence { .. }) => {
KeyboardOwner::PerformanceSequence
}
InteractionMode::Lead(_) => KeyboardOwner::Lead,
}
}
fn help_surface(
owner: KeyboardOwner,
mode: &ModeSurface<'_>,
navigation: NavigationView,
notices: ViewNotices,
) -> HelpSurface {
if owner != KeyboardOwner::Browsing {
return HelpSurface::Owner {
owner,
text: owner_help(owner, mode),
};
}
let ViewNotices {
effect,
pending_commit,
auto,
update,
} = notices;
if let Some((kind, text)) = effect
.map(|text| (NoticeKind::Effect, text))
.or_else(|| pending_commit.map(|text| (NoticeKind::PendingCommit, text)))
{
return HelpSurface::Notice { kind, text };
}
let local_help = match (
navigation.tab,
navigation.chord_drill,
navigation.module_slot,
) {
(_, _, Some(_)) => Some("BROWSE · Module detail Esc: back".to_string()),
(Tab::Chords, ChordDrill::Progression { .. }, None) => {
Some("BROWSE · Progression Enter: open chord Esc: back".to_string())
}
(Tab::Chords, ChordDrill::Slot { slot, .. }, None) => {
Some(format!("BROWSE · Chord {} Esc: back", slot + 1))
}
(Tab::Lead, _, None) if navigation.lead_drill != LeadDrill::None => {
Some("BROWSE · Pattern r random Enter: play Esc: back".to_string())
}
_ => None,
};
if let Some(text) = local_help {
return HelpSurface::Browsing { text };
}
if let Some((kind, text)) = auto
.map(|text| (NoticeKind::Auto, text))
.or_else(|| update.map(|text| (NoticeKind::Update, text)))
{
return HelpSurface::Notice { kind, text };
}
HelpSurface::Browsing {
text:
"BROWSE · jk select h/l adjust / find f LFO e ENV a auto T units ^Q quit"
.to_string(),
}
}
pub(crate) fn lead_owner_help(lead: LeadSurface) -> String {
if lead.level_pct == 0 {
return "LEAD · level is 0 · Esc, raise Level, Enter".to_string();
}
if lead.holds == Some(false) {
return format!("LEAD · no key-up here: taps only oct {:+}", lead.octave);
}
format!("LEAD · asdfghjkl play z/x oct {:+} Esc", lead.octave)
}
fn owner_help(owner: KeyboardOwner, mode: &ModeSurface<'_>) -> String {
match owner {
KeyboardOwner::Browsing => unreachable!("browsing help is resolved separately"),
KeyboardOwner::Numeric => "NUMERIC · type value Enter: apply Esc: cancel".to_string(),
KeyboardOwner::Palette => {
"PALETTE · type to find Tab: complete Enter: stage Esc: cancel".to_string()
}
KeyboardOwner::Lfo | KeyboardOwner::Envelope => match mode {
ModeSurface::Automation(surface) => automation_owner_help(surface),
_ => unreachable!("automation owner requires automation surface"),
},
KeyboardOwner::Lead => match mode {
ModeSurface::Lead(lead) => lead_owner_help(*lead),
_ => unreachable!("lead owner requires lead surface"),
},
KeyboardOwner::PerformanceDeck => match mode {
ModeSurface::Performance(PerformanceSurface::Deck {
selected,
held_selectors,
..
}) => format!(
"DECK · selected {} held {} a/s/d/f choose h/l j/k u/i act Esc",
selector_text(*selected),
performance_targets_text(*held_selectors)
),
_ => unreachable!("deck owner requires deck mode"),
},
KeyboardOwner::PerformanceSequence => match mode {
ModeSurface::Performance(PerformanceSurface::SequenceChoose { held_selector }) => {
format!(
"SEQUENCE · a/s/d/f choose held {} Esc",
selector_text(*held_selector)
)
}
ModeSurface::Performance(PerformanceSurface::SequencePerform {
instrument,
held_selector,
..
}) => format!(
"SEQUENCE · instrument {} h/l j/k u/i act once held {} Esc",
selector_text(*instrument),
selector_text(*held_selector)
),
ModeSurface::Performance(PerformanceSurface::SequenceComplete {
instrument,
release_pending,
..
}) => {
if *release_pending {
format!(
"SEQUENCE · {} applied release action to return",
selector_text(*instrument)
)
} else {
format!(
"SEQUENCE · {} applied Space: rearm Esc: back",
selector_text(*instrument)
)
}
}
_ => unreachable!("sequence owner requires sequence mode"),
},
}
}
pub(crate) fn selector_text(selector: Option<usize>) -> String {
selector
.and_then(|index| index.checked_add(1))
.map_or_else(|| "none".to_string(), |index| index.to_string())
}
pub(crate) fn performance_targets_text(targets: PerformanceTargets) -> String {
let held = targets
.iter()
.map(|instrument| instrument.key().to_string())
.collect::<Vec<_>>();
if held.is_empty() {
"none".to_string()
} else {
held.join("+")
}
}
fn automation_owner_help(surface: &AutomationSurface<'_>) -> String {
match surface {
AutomationSurface::Lfo {
address,
lane_index,
lane_count,
route,
..
} => {
let reseed = if route.shape.is_random() {
" r reseed"
} else {
""
};
let text = format!(
"LFO {}/{} · {} {} {:.2} beats depth {:.0}%{reseed} f next Shift+F add x remove Esc close",
lane_index + 1,
lane_count,
address.id(),
route.shape.label(),
route.cycle_beats,
route.depth_ratio * 100.0
);
text
}
AutomationSurface::Envelope {
address,
lane_index,
lane_count,
route,
..
} => format!(
"ENV {}/{} · {} {} amount {:+.0}% e next Shift+E add x remove Esc close",
lane_index + 1,
lane_count,
address.id(),
route.field_display(EnvField::Trigger),
route.amount * 100.0
),
AutomationSurface::Unavailable {
requested, reason, ..
} => {
let label = requested.label();
let reason = match reason {
AutomationUnavailable::NoOpenEditor => "editor unavailable",
AutomationUnavailable::KindMismatch { .. } => "editor kind mismatch",
AutomationUnavailable::MissingRoute => "route unavailable",
};
format!("{label} · {reason} Esc: close")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fluid::interaction::{
AutomationMode, InputPhase, Intent, LeadPlay, LfoDepth, NumericEntry, Page, PaletteMode,
PerformanceInstrument, PerformanceMode, SemanticAction,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn session() -> LiveSessionSnapshot {
LiveSessionSnapshot::from_controls(FluidControls::default())
}
fn snapshot_symbols(buffer: &Buffer) -> String {
let area = buffer.area;
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
let symbol = buffer[(x, y)].symbol();
if symbol == " " { "␠" } else { symbol }
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_model_with_session(
interaction: &InteractionModel,
session: &LiveSessionSnapshot,
) -> String {
let fluid = RippleField::new();
let flipped = FlippedUnits::new();
let view = UiViewModel::project(ViewProjection {
interaction,
session,
telemetry: TelemetryView::default(),
presentation: ViewPresentation {
fluid: &fluid,
flipped: &flipped,
cursor_visible: true,
notices: ViewNotices::default(),
},
});
let mut terminal =
Terminal::new(TestBackend::new(MIN_TERMINAL_WIDTH, MIN_TERMINAL_HEIGHT)).unwrap();
terminal.draw(|frame| render(frame, &view)).unwrap();
snapshot_symbols(terminal.backend().buffer())
}
fn render_model(interaction: &InteractionModel) -> String {
let mut session = session();
let address = ControlAddress::new("pad.level");
match &interaction.mode {
InteractionMode::Automation(AutomationMode::Lfo { .. }) => {
session.automation.open_or_create(address);
}
InteractionMode::Automation(AutomationMode::Envelope { .. }) => {
session.automation.open_or_create_envelope(address);
}
_ => {}
}
render_model_with_session(interaction, &session)
}
fn render_mode(mode: InteractionMode) -> String {
render_model(&InteractionModel {
navigation: Navigation::default(),
mode,
})
}
#[test]
fn projection_reads_one_session_generation_and_clamps_navigation() {
let mut session = session();
session.generation = 42;
session.controls.master.bpm = 91.0;
let interaction = InteractionModel {
navigation: Navigation::Master {
selected: usize::MAX,
},
mode: InteractionMode::Browsing,
};
let fluid = RippleField::new();
let flipped = FlippedUnits::new();
let view = UiViewModel::project(ViewProjection {
interaction: &interaction,
session: &session,
telemetry: TelemetryView::default(),
presentation: ViewPresentation {
fluid: &fluid,
flipped: &flipped,
cursor_visible: false,
notices: ViewNotices::default(),
},
});
assert_eq!(view.session.generation, 42);
assert_eq!(view.session.controls.master.bpm, 91.0);
assert_eq!(view.navigation.selected, view.items.len() - 1);
assert!(matches!(view.mode, ModeSurface::Browsing));
}
#[test]
fn palette_owner_projects_complete_mode_local_render_state() {
let model = InteractionModel {
navigation: Navigation::default(),
mode: InteractionMode::Palette(PaletteMode {
query: "bass".to_string(),
selected: 1,
recent: vec!["master.bpm"],
locked: Some(1),
value_buffer: "42".to_string(),
staged: vec![crate::fluid::interaction::PaletteStagedEdit {
id: "master.bpm",
value_bits: 91.0f32.to_bits(),
}],
resume: None,
module_scope: None,
}),
};
let transition = model.update(SemanticAction::press(Intent::TypeCharacter('5')));
let session = session();
let fluid = RippleField::new();
let flipped = FlippedUnits::new();
let view = UiViewModel::project(ViewProjection {
interaction: &transition.model,
session: &session,
telemetry: TelemetryView::default(),
presentation: ViewPresentation {
fluid: &fluid,
flipped: &flipped,
cursor_visible: false,
notices: ViewNotices::default(),
},
});
let ModeSurface::Palette(palette) = view.mode else {
panic!("palette interaction must always project a palette surface");
};
assert_eq!(palette.state.query, "bass");
assert_eq!(palette.state.selected, 1);
assert_eq!(palette.state.locked, Some(1));
assert_eq!(palette.state.value_buf, "425");
assert_eq!(
palette.state.staged,
vec![StagedEdit {
id: "master.bpm",
value: 91.0,
}]
);
let mut model = transition.model;
for _ in 0..3 {
model = model.update(SemanticAction::press(Intent::Backspace)).model;
}
let InteractionMode::Palette(palette) = &model.mode else {
panic!("deleting a value keeps the palette open");
};
assert_eq!(palette.value_buffer, "");
assert_eq!(palette.locked, Some(1));
model = model.update(SemanticAction::press(Intent::Backspace)).model;
let InteractionMode::Palette(palette) = model.mode else {
panic!("empty-value backspace keeps the palette open");
};
assert_eq!(palette.locked, None);
}
#[test]
fn projection_and_render_sanitize_arbitrary_mode_indices() {
let invalid_palette = InteractionMode::Palette(PaletteMode {
query: "bass".to_string(),
locked: Some(usize::MAX),
value_buffer: "42".to_string(),
..PaletteMode::default()
});
let session = session();
let ModeSurface::Palette(palette) = mode_surface(
&invalid_palette,
Tab::Bass,
&session.controls,
&session.automation,
) else {
panic!("palette mode projects a palette surface");
};
assert_eq!(palette.state.locked, None);
assert_eq!(palette.state.value_buf, "");
assert!(render_mode(invalid_palette).contains("/bass"));
}
#[test]
fn keyboard_owner_help_preempts_browsing_notices() {
let session = session();
let interaction = InteractionModel {
navigation: Navigation::default(),
mode: InteractionMode::Numeric(NumericEntry {
buffer: "12".to_string(),
resume: None,
}),
};
let fluid = RippleField::new();
let flipped = FlippedUnits::new();
let view = UiViewModel::project(ViewProjection {
interaction: &interaction,
session: &session,
telemetry: TelemetryView::default(),
presentation: ViewPresentation {
fluid: &fluid,
flipped: &flipped,
cursor_visible: false,
notices: ViewNotices {
effect: Some("saved".to_string()),
pending_commit: Some("pending".to_string()),
auto: Some("auto".to_string()),
update: Some("update".to_string()),
},
},
});
assert_eq!(view.owner, KeyboardOwner::Numeric);
assert!(matches!(
&view.mode,
ModeSurface::Numeric { entry, resume: None } if entry == "12"
));
assert!(matches!(
view.help,
HelpSurface::Owner {
owner: KeyboardOwner::Numeric,
..
}
));
assert!(view.help.text().starts_with("NUMERIC"));
}
#[test]
fn numeric_entry_opened_inside_an_editor_keeps_that_editor_on_screen() {
let address = ControlAddress::new("pad.level");
let mut session = session();
session.automation.open_or_create(address);
let model = InteractionModel {
navigation: Navigation::default(),
mode: InteractionMode::Numeric(NumericEntry {
buffer: "42".to_string(),
resume: Some(AutomationMode::Lfo {
depth: LfoDepth::Editor,
selected: 2,
}),
}),
};
let frame = render_model_with_session(&model, &session);
assert!(frame.contains("rate"), "editor collapsed:\n{frame}");
let entry_row = frame
.lines()
.find(|line| line.contains(">␠42"))
.unwrap_or_else(|| panic!("numeric buffer missing:\n{frame}"));
assert!(
entry_row.contains("rate"),
"buffer rendered off its field:\n{frame}"
);
}
#[test]
fn performance_deck_shows_live_values_for_every_held_instrument() {
let mut held_selectors = PerformanceTargets::single(PerformanceInstrument::Kick);
held_selectors.insert(PerformanceInstrument::Perc);
let interaction = InteractionModel {
mode: InteractionMode::Performance(PerformanceMode::Deck {
selected: Some(PerformanceInstrument::Perc),
held_selectors,
}),
..InteractionModel::default()
};
let mut session = session();
session.controls.kick.interval_beats = 1.25;
session.controls.perc.interval_beats = 0.5;
let rendered = render_model_with_session(&interaction, &session);
assert!(
rendered.contains("●␠d␠Kick") && rendered.contains("D█░░1.25b"),
"Kick's live density is visible: {rendered:?}"
);
assert!(
rendered.contains("●␠f␠Perc") && rendered.contains("D░░░0.50b"),
"Perc's live density is visible: {rendered:?}"
);
}
#[test]
fn performance_deck_rows_carry_the_apps_colour_language() {
let held_selectors = PerformanceTargets::single(PerformanceInstrument::Kick);
let interaction = InteractionModel {
mode: InteractionMode::Performance(PerformanceMode::Deck {
selected: Some(PerformanceInstrument::Kick),
held_selectors,
}),
..InteractionModel::default()
};
let session = session();
let fluid = RippleField::new();
let flipped = FlippedUnits::new();
let view = UiViewModel::project(ViewProjection {
interaction: &interaction,
session: &session,
telemetry: TelemetryView::default(),
presentation: ViewPresentation {
fluid: &fluid,
flipped: &flipped,
cursor_visible: true,
notices: ViewNotices::default(),
},
});
let mut terminal =
Terminal::new(TestBackend::new(MIN_TERMINAL_WIDTH, MIN_TERMINAL_HEIGHT)).unwrap();
terminal.draw(|frame| render(frame, &view)).unwrap();
let buffer = terminal.backend().buffer();
let area = buffer.area;
let row = (0..area.height)
.find(|&y| {
let text = (0..area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>();
text.contains('●') && text.contains("Kick")
})
.expect("the held Kick row is drawn");
let held = (0..area.width)
.map(|x| buffer[(x, row)].fg)
.any(|fg| fg == Color::Rgb(255, 200, 90));
assert!(
held,
"held deck row must be amber, not the terminal default"
);
}
#[test]
fn accepted_transition_is_visible_in_the_immediately_following_frame() {
let transition = InteractionModel::default().update(SemanticAction {
phase: InputPhase::Press,
intent: Intent::OpenPalette,
});
assert!(transition.effects.is_empty());
let frame = render_model(&transition.model);
let browsing = render_mode(InteractionMode::Browsing);
assert!(frame.contains("PALETTE"));
assert!(frame.contains("/▌"));
assert!(frame.contains("complete"));
assert_ne!(frame, browsing);
}
#[test]
fn lead_owner_renders_its_keyboard_at_the_minimum_frame() {
let mut session = session();
session.controls.lead.level = 0.5;
session.controls.lead.octave = -1.0;
let model = InteractionModel {
navigation: Navigation::for_page(Page::Lead),
mode: InteractionMode::Lead(LeadPlay {
last_tone: Some(3),
..LeadPlay::default()
}),
};
let frame = render_model_with_session(&model, &session);
assert!(frame.contains("LEAD"), "{frame}");
assert!(frame.contains("a1␠"), "{frame}");
assert!(frame.contains("oct␠-1"), "{frame}");
session.controls.lead.level = 0.0;
let silent = render_model_with_session(&model, &session);
assert!(silent.contains("level␠is␠0"), "{silent}");
}
}