use std::collections::BTreeSet;
use super::*;
const SAVE_MESSAGE_TTL: std::time::Duration = std::time::Duration::from_secs(3);
#[derive(Clone, Copy, PartialEq, Default)]
pub(crate) enum ChordDrill {
#[default]
None,
Progression,
Slot(usize),
}
fn chords_selected_index(tab: Tab, chord_drill: ChordDrill, selected: usize) -> usize {
if tab == Tab::Chords {
chords_flat_index(chord_drill, selected)
} else {
selected
}
}
pub(crate) fn ui_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
controls: Arc<ArcSwap<FluidControls>>,
automation_shared: Arc<ArcSwap<AutomationState>>,
telemetry: Arc<FluidTelemetry>,
initial_automation: AutomationState,
updates: UpdateNotice,
) -> Result<(), Box<dyn Error>> {
let mut tab = Tab::Master;
let mut selected = 0usize;
let mut lfo_selected = 0usize;
let mut chord_drill = ChordDrill::None;
let mut flipped = FlippedUnits::new();
let mut numeric_entry: Option<NumericEntry> = None;
let mut fluid = FluidState::new();
let mut last = Instant::now();
let started = Instant::now();
let mut save_message: Option<(String, Instant)> = None;
let mut automation = PublishedAutomation::new(initial_automation, automation_shared);
'ui: loop {
let c = FluidControls::clone(&controls.load());
if save_message
.as_ref()
.is_some_and(|(_, shown_at)| shown_at.elapsed() >= SAVE_MESSAGE_TTL)
{
save_message = None;
}
let update_message = updates.message();
let automation_message = automation_footer(automation.state());
let chords_message = chords_footer(tab, chord_drill);
let footer_message = save_message
.as_ref()
.map(|(message, _)| message.as_str())
.or(automation_message.as_deref())
.or(chords_message.as_deref())
.or(update_message.as_deref());
let items = if tab == Tab::Chords {
chords_tab_controls(&c, chord_drill)
} else {
tab_controls(tab, &c)
};
let items_len = items.len();
selected = selected.min(items_len.saturating_sub(1));
let now = Instant::now();
let dt = (now - last).as_secs_f32().min(0.05);
last = now;
fluid.tick(dt, &telemetry);
let cursor_visible = (started.elapsed().as_millis() / 400).is_multiple_of(2);
let beat = telemetry.beat();
terminal.draw(|f| {
render(
f,
&items,
tab,
selected,
lfo_selected,
beat,
NumericDisplay {
entry: numeric_entry.as_ref().map(|entry| entry.buffer.as_str()),
cursor_visible,
},
&fluid,
automation.state(),
&c,
footer_message,
&flipped,
chord_drill,
)
})?;
let mut pending = event::poll(std::time::Duration::from_millis(16))?;
while pending {
let event = event::read()?;
pending = event::poll(std::time::Duration::ZERO)?;
let Event::Key(key) = event else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if let Some(entry) = numeric_entry.as_mut() {
match key.code {
KeyCode::Esc => numeric_entry = None,
KeyCode::Enter => {
if entry.is_complete_number()
&& let Ok(value) = entry.buffer.parse::<f32>()
{
set_modulator_or_control(
&mut automation,
lfo_selected,
&controls,
tab,
chords_selected_index(tab, chord_drill, selected),
value,
beat,
&flipped,
);
}
numeric_entry = None;
}
KeyCode::Backspace => {
entry.buffer.pop();
}
KeyCode::Char(c) => entry.push(c),
_ => {}
}
continue;
}
match key.code {
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
save_message = Some(match copy_launch_line(&controls, automation.state()) {
Ok(()) => ("nooise copied to clipboard".to_string(), Instant::now()),
Err(err) => (format!("Save failed: {err}"), Instant::now()),
});
}
KeyCode::Esc if automation.state().is_editor_open() => {
close_one_level(&mut automation, &mut lfo_selected);
}
KeyCode::Esc if tab == Tab::Chords && chord_drill != ChordDrill::None => {
selected = match chord_drill {
ChordDrill::Slot(n) => n,
_ => 4,
};
chord_drill = match chord_drill {
ChordDrill::Slot(_) => ChordDrill::Progression,
_ => ChordDrill::None,
};
}
KeyCode::Esc => {}
KeyCode::Char('q') => break 'ui,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break 'ui,
KeyCode::Tab | KeyCode::BackTab => {
if automation.state().is_editor_open() {
automation.edit(AutomationState::close_editor);
}
tab = if key.code == KeyCode::Tab {
tab.next()
} else {
tab.previous()
};
selected = 0;
lfo_selected = 0;
chord_drill = ChordDrill::None;
}
KeyCode::Up | KeyCode::Char('k') => {
if automation.state().is_editor_open() {
if lfo_selected <= 1 {
automation.edit(AutomationState::close_editor);
lfo_selected = 0;
} else {
lfo_selected -= 1;
}
} else {
selected = selected.saturating_sub(1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if automation.state().is_editor_open() {
if lfo_selected >= active_field_count(automation.state()) {
automation.edit(AutomationState::close_editor);
selected = selected.saturating_add(1).min(items_len.saturating_sub(1));
lfo_selected = 0;
} else {
lfo_selected += 1;
}
} else {
selected = selected.saturating_add(1).min(items_len.saturating_sub(1));
}
}
KeyCode::Char('H') => {
reset_lfo_or_control(
&mut automation,
lfo_selected,
&controls,
tab,
chords_selected_index(tab, chord_drill, selected),
beat,
);
}
KeyCode::Left | KeyCode::Char('h')
if key.modifiers.contains(KeyModifiers::SHIFT) =>
{
reset_lfo_or_control(
&mut automation,
lfo_selected,
&controls,
tab,
chords_selected_index(tab, chord_drill, selected),
beat,
);
}
KeyCode::Left | KeyCode::Char('h') => {
adjust_lfo_or_control(
&mut automation,
lfo_selected,
&controls,
tab,
chords_selected_index(tab, chord_drill, selected),
-1.0,
beat,
&flipped,
);
}
KeyCode::Right | KeyCode::Char('l') => {
adjust_lfo_or_control(
&mut automation,
lfo_selected,
&controls,
tab,
chords_selected_index(tab, chord_drill, selected),
1.0,
beat,
&flipped,
);
}
KeyCode::Char('f') => {
open_modulator(&mut automation, &items, selected, ModKind::Lfo, &mut lfo_selected);
}
KeyCode::Char('e') => {
open_modulator(
&mut automation,
&items,
selected,
ModKind::Envelope,
&mut lfo_selected,
);
}
KeyCode::Char('v') => {
match active_field(automation.state(), lfo_selected) {
ActiveField::Lfo(address, field)
if !is_macro_id(address.id()) && field.macro_key().is_some() =>
{
let key = unit_key(address.id(), field.macro_key());
let was_open = automation.state().open_field() == Some(key.as_str());
automation.edit(|state| state.toggle_open_field(key));
let base = field_row_index(automation.state(), address, field);
lfo_selected = if was_open { base } else { base + 1 };
}
ActiveField::LfoMacro(..) => {
close_one_level(&mut automation, &mut lfo_selected);
}
ActiveField::Lfo(_, field) if field.macro_key().is_none() => {}
_ => {
open_modulator(
&mut automation,
&items,
selected,
ModKind::Macro,
&mut lfo_selected,
);
}
}
}
KeyCode::Char('x') | KeyCode::Char('X') => {
match active_field(automation.state(), lfo_selected) {
ActiveField::LfoMacro(address, field, _) => {
let key = unit_key(address.id(), field.macro_key());
automation.edit(|state| state.remove_field_macro(&key));
lfo_selected = field_row_index(automation.state(), address, field);
}
_ if automation.state().is_editor_open() => {
automation.edit(AutomationState::remove_open_route);
lfo_selected = 0;
}
_ => {
if let Some(item) = items.get(selected) {
let address = ControlAddress::new(item.id);
automation.edit(|state| state.clear_control(address));
}
}
}
}
KeyCode::Enter => {
if !automation.state().is_editor_open() {
if tab == Tab::Chords {
match (chord_drill, items.get(selected).map(|i| i.id)) {
(ChordDrill::None, Some("pad.progression"))
if is_custom_progression(progression_index(
c.pad.progression,
)) =>
{
chord_drill = ChordDrill::Progression;
selected = 0;
}
(ChordDrill::Progression, Some(_)) => {
chord_drill = ChordDrill::Slot(selected);
selected = 0;
}
_ => {}
}
} else if let Some(item) = items.get(selected)
&& let Some(owner) = tab_owning_control(item.id)
&& owner != tab
{
tab = owner;
selected = 0;
lfo_selected = 0;
chord_drill = ChordDrill::None;
}
}
}
KeyCode::Char('t') | KeyCode::Char('T') => {
if let Some(key) =
unit_toggle_key(automation.state(), lfo_selected, &items, selected)
{
let now_flipped = !flipped.remove(&key);
if now_flipped {
flipped.insert(key);
}
snap_after_unit_flip(
&mut automation,
lfo_selected,
&controls,
tab,
chords_selected_index(tab, chord_drill, selected),
now_flipped,
beat,
);
}
}
KeyCode::Char('r') | KeyCode::Char('R') => {
if let Some(address) = automation.state().active_address()
&& automation.state().active_kind() == Some(ModKind::Lfo)
{
automation.edit(|state| {
if let Some(route) = state.route_mut(address)
&& route.shape.is_random()
{
route.reseed();
}
});
}
}
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' || c == '-' => {
let mut entry = NumericEntry::default();
entry.push(c);
numeric_entry = Some(entry);
}
_ => {}
}
}
}
Ok(())
}
pub(crate) type FlippedUnits = BTreeSet<String>;
pub(crate) fn unit_key(id: &str, field: Option<&str>) -> String {
match field {
Some(field) => format!("{id}#{field}"),
None => id.to_string(),
}
}
pub(crate) fn beats_to_ms(beats: f32, bpm: f32) -> f32 {
beats * 60_000.0 / bpm.max(1.0)
}
pub(crate) fn ms_to_beats(ms: f32, bpm: f32) -> f32 {
ms * bpm.max(1.0) / 60_000.0
}
fn fmt_ms(ms: f32) -> String {
if ms >= 1000.0 {
format!("{:.2} s", ms / 1000.0)
} else {
format!("{ms:.0} ms")
}
}
fn fmt_beats(beats: f32) -> String {
format!("{beats:.3} beats")
}
fn flip_display(base: TimeBase, value: f32, bpm: f32) -> Option<String> {
match base {
TimeBase::Beats => Some(fmt_ms(beats_to_ms(value, bpm))),
TimeBase::Ms => Some(fmt_beats(ms_to_beats(value, bpm))),
TimeBase::None => None,
}
}
fn flip_entry(base: TimeBase, value: f32, bpm: f32) -> f32 {
match base {
TimeBase::Beats => ms_to_beats(value, bpm),
TimeBase::Ms => beats_to_ms(value, bpm),
TimeBase::None => value,
}
}
const FLIP_MS_STEP: f32 = 10.0;
const FLIP_BEAT_STEP: f32 = 0.125;
fn flipped_step(native: TimeBase, value: f32, dir: f32, bpm: f32) -> f32 {
match native {
TimeBase::Beats => ms_to_beats(
snap_step(beats_to_ms(value, bpm) + dir * FLIP_MS_STEP, FLIP_MS_STEP),
bpm,
),
TimeBase::Ms => beats_to_ms(
snap_step(ms_to_beats(value, bpm) + dir * FLIP_BEAT_STEP, FLIP_BEAT_STEP),
bpm,
),
TimeBase::None => value,
}
}
pub(crate) fn snap_after_unit_flip(
automation: &mut PublishedAutomation,
lfo_selected: usize,
controls: &Arc<ArcSwap<FluidControls>>,
tab: Tab,
selected: usize,
now_flipped: bool,
beat: f64,
) {
let bpm = controls.load().master.bpm;
match active_field(automation.state(), lfo_selected) {
ActiveField::Lfo(address, field) if !now_flipped => automation.edit(|state| {
if let Some(route) = state.route_mut(address) {
match field {
LfoField::Interval => route.set_field_at(field, route.cycle_beats, beat),
LfoField::Offset => route.set_field_at(field, route.phase_offset_beats, beat),
_ => {}
}
}
}),
ActiveField::Envelope(address, field) if !now_flipped => automation.edit(|state| {
if let Some(route) = state.envelope_mut(address) {
match field {
EnvField::Attack => route.set_field(field, route.attack_beats),
EnvField::Decay => route.set_field(field, route.decay_beats),
EnvField::Amount | EnvField::Trigger => {}
}
}
}),
ActiveField::Control => {
let Some(spec) = tab_specs(tab).get(selected) else {
return;
};
let mut next = FluidControls::clone(&controls.load());
let current = (spec.get)(&next);
match (spec.time_base, now_flipped) {
(TimeBase::Beats, false) => spec.apply_quantized_value(current, &mut next),
(TimeBase::Ms, true) => {
let beats = snap_step(ms_to_beats(current, bpm), FLIP_BEAT_STEP)
.max(FLIP_BEAT_STEP);
spec.apply_raw(beats_to_ms(beats, bpm), &mut next);
}
_ => return,
}
controls.store(Arc::new(next));
}
_ => {}
}
}
fn lfo_time_key(field: LfoField) -> Option<&'static str> {
match field {
LfoField::Interval => Some("lfo.interval"),
LfoField::Offset => Some("lfo.offset"),
_ => None,
}
}
fn env_time_key(field: EnvField) -> Option<&'static str> {
match field {
EnvField::Attack => Some("env.attack"),
EnvField::Decay => Some("env.decay"),
EnvField::Amount | EnvField::Trigger => None,
}
}
fn unit_toggle_key(
automation: &AutomationState,
lfo_selected: usize,
items: &[ControlItem],
selected: usize,
) -> Option<String> {
match active_field(automation, lfo_selected) {
ActiveField::Lfo(address, field) => {
lfo_time_key(field).map(|key| unit_key(address.id(), Some(key)))
}
ActiveField::Envelope(address, field) => {
env_time_key(field).map(|key| unit_key(address.id(), Some(key)))
}
ActiveField::LfoMacro(..) | ActiveField::Macro(..) => None,
ActiveField::Control => {
let item = items.get(selected)?;
let spec = spec_by_id(item.id)?;
(spec.time_base != TimeBase::None).then(|| unit_key(item.id, None))
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum LfoSubRow {
Field(LfoField),
FieldMacro(LfoField, MacroField),
}
pub(crate) fn lfo_submenu_rows(
automation: &AutomationState,
address: ControlAddress,
) -> Vec<LfoSubRow> {
let mut rows = Vec::with_capacity(LfoField::ALL.len() * (1 + MacroField::ALL.len()));
for field in LfoField::ALL {
rows.push(LfoSubRow::Field(field));
if is_macro_id(address.id()) {
continue;
}
if let Some(key_str) = field.macro_key() {
let key = unit_key(address.id(), Some(key_str));
if automation.open_field() == Some(key.as_str()) {
for slot in MacroField::ALL {
rows.push(LfoSubRow::FieldMacro(field, slot));
}
}
}
}
rows
}
fn field_row_index(automation: &AutomationState, address: ControlAddress, field: LfoField) -> usize {
lfo_submenu_rows(automation, address)
.iter()
.position(|row| *row == LfoSubRow::Field(field))
.map_or(0, |pos| pos + 1)
}
fn field_macro_owner(automation: &AutomationState, address: ControlAddress) -> Option<LfoField> {
let open_key = automation.open_field()?;
LfoField::ALL.into_iter().find(|field| {
field
.macro_key()
.is_some_and(|key_str| unit_key(address.id(), Some(key_str)) == open_key)
})
}
pub(crate) fn close_one_level(automation: &mut PublishedAutomation, lfo_selected: &mut usize) {
let Some(address) = automation.state().active_address() else {
return;
};
if let Some(field) = field_macro_owner(automation.state(), address) {
automation.edit(AutomationState::close_open_field);
*lfo_selected = field_row_index(automation.state(), address, field);
} else {
automation.edit(AutomationState::close_editor);
*lfo_selected = 0;
}
}
pub(crate) fn env_field_at(index: usize) -> Option<EnvField> {
EnvField::ALL.get(index.checked_sub(1)?).copied()
}
pub(crate) fn macro_field_at(index: usize) -> Option<MacroField> {
MacroField::ALL.get(index.checked_sub(1)?).copied()
}
pub(crate) fn active_field_count(automation: &AutomationState) -> usize {
match automation.active_kind() {
Some(ModKind::Lfo) => automation
.active_address()
.map_or(LfoField::ALL.len(), |address| {
lfo_submenu_rows(automation, address).len()
}),
Some(ModKind::Envelope) => EnvField::ALL.len(),
Some(ModKind::Macro) => MacroField::ALL.len(),
None => 0,
}
}
fn kind_allowed_on(kind: ModKind, id: &str) -> bool {
match kind {
ModKind::Lfo => true,
ModKind::Envelope => is_macro_id(id),
ModKind::Macro => !is_macro_id(id),
}
}
pub(crate) fn open_modulator(
automation: &mut PublishedAutomation,
items: &[ControlItem],
selected: usize,
kind: ModKind,
sub_selected: &mut usize,
) {
if let Some(item) = items.get(selected) {
if !kind_allowed_on(kind, item.id) {
return;
}
let address = ControlAddress::new(item.id);
automation.edit(|state| {
let already =
state.active_address() == Some(address) && state.active_kind() == Some(kind);
state.close_editor();
if !already {
match kind {
ModKind::Lfo => {
state.open_or_create(address);
}
ModKind::Envelope => {
state.open_or_create_envelope(address);
}
ModKind::Macro => {
state.open_or_create_macro(address);
}
}
}
});
*sub_selected = 1;
}
}
pub(crate) struct PublishedAutomation {
state: AutomationState,
shared: Arc<ArcSwap<AutomationState>>,
}
impl PublishedAutomation {
pub(crate) fn new(state: AutomationState, shared: Arc<ArcSwap<AutomationState>>) -> Self {
shared.store(Arc::new(state.clone()));
Self { state, shared }
}
pub(crate) fn state(&self) -> &AutomationState {
&self.state
}
pub(crate) fn edit(&mut self, edit: impl FnOnce(&mut AutomationState)) {
edit(&mut self.state);
self.shared.store(Arc::new(self.state.clone()));
}
}
enum ActiveField {
Lfo(ControlAddress, LfoField),
LfoMacro(ControlAddress, LfoField, MacroField),
Envelope(ControlAddress, EnvField),
Macro(ControlAddress, MacroField),
Control,
}
fn active_field(automation: &AutomationState, lfo_selected: usize) -> ActiveField {
let Some(address) = automation.active_address() else {
return ActiveField::Control;
};
if lfo_selected == 0 {
return ActiveField::Control;
}
match automation.active_kind() {
Some(ModKind::Lfo) => match lfo_submenu_rows(automation, address).get(lfo_selected - 1) {
Some(LfoSubRow::Field(field)) => ActiveField::Lfo(address, *field),
Some(LfoSubRow::FieldMacro(field, mf)) => ActiveField::LfoMacro(address, *field, *mf),
None => ActiveField::Control,
},
Some(ModKind::Envelope) => match env_field_at(lfo_selected) {
Some(field) => ActiveField::Envelope(address, field),
None => ActiveField::Control,
},
Some(ModKind::Macro) => match macro_field_at(lfo_selected) {
Some(field) => ActiveField::Macro(address, field),
None => ActiveField::Control,
},
None => ActiveField::Control,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn adjust_lfo_or_control(
automation: &mut PublishedAutomation,
lfo_selected: usize,
controls: &Arc<ArcSwap<FluidControls>>,
tab: Tab,
selected: usize,
dir: f32,
beat: f64,
flipped: &FlippedUnits,
) {
let bpm = controls.load().master.bpm;
match active_field(automation.state(), lfo_selected) {
ActiveField::Lfo(address, field) => {
let is_flipped = lfo_time_key(field)
.is_some_and(|key| flipped.contains(&unit_key(address.id(), Some(key))));
automation.edit(|state| {
let Some(route) = state.route_mut(address) else {
return;
};
match (is_flipped, field) {
(true, LfoField::Interval) => {
let next = flipped_step(TimeBase::Beats, route.cycle_beats, dir, bpm);
route.set_field_raw_at(field, next, beat);
}
(true, LfoField::Offset) => {
let next =
flipped_step(TimeBase::Beats, route.phase_offset_beats, dir, bpm);
route.set_field_raw_at(field, next, beat);
}
_ => route.adjust_field_at(field, dir, beat),
}
});
}
ActiveField::Envelope(address, field) => {
let is_flipped = env_time_key(field)
.is_some_and(|key| flipped.contains(&unit_key(address.id(), Some(key))));
automation.edit(|state| {
let Some(route) = state.envelope_mut(address) else {
return;
};
match (is_flipped, field) {
(true, EnvField::Attack) => {
let next = flipped_step(TimeBase::Beats, route.attack_beats, dir, bpm);
route.set_field_raw(field, next);
}
(true, EnvField::Decay) => {
let next = flipped_step(TimeBase::Beats, route.decay_beats, dir, bpm);
route.set_field_raw(field, next);
}
_ => route.adjust_field(field, dir),
}
});
}
ActiveField::LfoMacro(address, field, macro_field) => automation.edit(|state| {
let key = unit_key(address.id(), field.macro_key());
if let Some(route) = state.field_macro_mut(&key) {
route.adjust_field(macro_field, dir);
}
}),
ActiveField::Macro(address, field) => automation.edit(|state| {
if let Some(route) = state.macro_route_mut(address) {
route.adjust_field(field, dir);
}
}),
ActiveField::Control => {
let flipped_spec = tab_specs(tab).get(selected).filter(|spec| {
spec.time_base != TimeBase::None && flipped.contains(&unit_key(spec.id, None))
});
match flipped_spec {
Some(spec) => {
let mut next = FluidControls::clone(&controls.load());
let current = (spec.get)(&next);
spec.apply_raw(flipped_step(spec.time_base, current, dir, bpm), &mut next);
controls.store(Arc::new(next));
}
None => adjust(controls, tab, selected, dir),
}
}
}
}
fn reset_lfo_or_control(
automation: &mut PublishedAutomation,
lfo_selected: usize,
controls: &Arc<ArcSwap<FluidControls>>,
tab: Tab,
selected: usize,
beat: f64,
) {
match active_field(automation.state(), lfo_selected) {
ActiveField::Lfo(address, field) => automation.edit(|state| {
if let Some(route) = state.route_mut(address) {
route.reset_field_at(field, beat);
}
}),
ActiveField::Envelope(address, field) => automation.edit(|state| {
if let Some(route) = state.envelope_mut(address) {
route.reset_field(field);
}
}),
ActiveField::LfoMacro(address, field, macro_field) => automation.edit(|state| {
let key = unit_key(address.id(), field.macro_key());
if let Some(route) = state.field_macro_mut(&key) {
route.reset_field(macro_field);
}
}),
ActiveField::Macro(address, field) => automation.edit(|state| {
if let Some(route) = state.macro_route_mut(address) {
route.reset_field(field);
}
}),
ActiveField::Control => reset_to_min(controls, tab, selected),
}
}
#[allow(clippy::too_many_arguments)]
fn set_modulator_or_control(
automation: &mut PublishedAutomation,
lfo_selected: usize,
controls: &Arc<ArcSwap<FluidControls>>,
tab: Tab,
selected: usize,
value: f32,
beat: f64,
flipped: &FlippedUnits,
) {
let bpm = controls.load().master.bpm;
match active_field(automation.state(), lfo_selected) {
ActiveField::Lfo(address, field) => automation.edit(|state| {
let is_flipped = lfo_time_key(field)
.is_some_and(|key| flipped.contains(&unit_key(address.id(), Some(key))));
if let Some(route) = state.route_mut(address) {
if is_flipped {
route.set_field_raw_at(field, flip_entry(TimeBase::Beats, value, bpm), beat);
} else {
route.set_field_at(field, value, beat);
}
}
}),
ActiveField::Envelope(address, field) => automation.edit(|state| {
let is_flipped = env_time_key(field)
.is_some_and(|key| flipped.contains(&unit_key(address.id(), Some(key))));
if let Some(route) = state.envelope_mut(address) {
if is_flipped {
route.set_field_raw(field, flip_entry(TimeBase::Beats, value, bpm));
} else {
route.set_field(field, value);
}
}
}),
ActiveField::LfoMacro(address, field, macro_field) => automation.edit(|state| {
let key = unit_key(address.id(), field.macro_key());
if let Some(route) = state.field_macro_mut(&key) {
route.set_field(macro_field, value);
}
}),
ActiveField::Macro(address, field) => automation.edit(|state| {
if let Some(route) = state.macro_route_mut(address) {
route.set_field(field, value);
}
}),
ActiveField::Control => {
match tab_specs(tab).get(selected) {
Some(spec)
if spec.time_base != TimeBase::None
&& flipped.contains(&unit_key(spec.id, None)) =>
{
let mut next = FluidControls::clone(&controls.load());
spec.apply_raw(flip_entry(spec.time_base, value, bpm), &mut next);
controls.store(Arc::new(next));
}
_ => set_value(controls, tab, selected, value),
}
}
}
}
fn automation_footer(automation: &AutomationState) -> Option<String> {
let address = automation.active_address()?;
match automation.active_kind()? {
ModKind::Lfo => {
let route = automation.route(address)?;
let reseed = if route.shape.is_random() {
" r reseed"
} else {
""
};
Some(format!(
"LFO {} {} {:.2} beats depth {:.0}%{reseed} x remove Esc close",
address.id(),
route.shape.label(),
route.cycle_beats,
route.depth_ratio * 100.0
))
}
ModKind::Envelope => {
let route = automation.envelope(address)?;
Some(format!(
"ENV {} {} amount {:+.0}% x remove Esc close",
address.id(),
route.field_display(EnvField::Trigger),
route.amount * 100.0
))
}
ModKind::Macro => {
let route = automation.macro_route(address)?;
Some(format!(
"MACRO {} {} x remove Esc close",
address.id(),
route.summary(),
))
}
}
}
pub(crate) fn chords_footer(tab: Tab, chord_drill: ChordDrill) -> Option<String> {
if tab != Tab::Chords {
return None;
}
match chord_drill {
ChordDrill::None => None,
ChordDrill::Progression => Some("Progression Enter: open chord Esc: back".to_string()),
ChordDrill::Slot(n) => Some(format!("Chord {} Esc: back", n + 1)),
}
}
fn copy_launch_line(
controls: &Arc<ArcSwap<FluidControls>>,
automation: &AutomationState,
) -> Result<(), Box<dyn Error>> {
let c = FluidControls::clone(&controls.load());
let line = launch_line(&SongState {
controls: c,
automation: automation.clone(),
})?;
let mut clipboard = arboard::Clipboard::new()?;
clipboard.set_text(line)?;
Ok(())
}
pub(crate) fn adjust(controls: &Arc<ArcSwap<FluidControls>>, tab: Tab, selected: usize, dir: f32) {
let mut next = FluidControls::clone(&controls.load());
apply_delta(tab, selected, dir, &mut next);
controls.store(Arc::new(next));
}
pub(crate) fn reset_to_min(controls: &Arc<ArcSwap<FluidControls>>, tab: Tab, selected: usize) {
let mut next = FluidControls::clone(&controls.load());
apply_min(tab, selected, &mut next);
controls.store(Arc::new(next));
}
pub(crate) fn set_value(
controls: &Arc<ArcSwap<FluidControls>>,
tab: Tab,
selected: usize,
value: f32,
) {
let mut next = FluidControls::clone(&controls.load());
apply_value(tab, selected, value, &mut next);
controls.store(Arc::new(next));
}
pub(crate) struct NumericDisplay<'a> {
entry: Option<&'a str>,
cursor_visible: bool,
}
#[cfg(test)]
impl NumericDisplay<'_> {
pub(crate) fn empty() -> Self {
Self {
entry: None,
cursor_visible: false,
}
}
}
pub(crate) const FLUID_GRADIENT: &[char] = &[' ', '·', '∙', '•', '●', '◉', '⬤'];
pub(crate) const RIPPLE_LIFETIME: f32 = 3.0;
pub(crate) const RIPPLE_SPEED: f32 = 0.42;
pub(crate) fn hue_for_chord(index: u64) -> f32 {
const HUES: [f32; 5] = [205.0, 270.0, 325.0, 158.0, 38.0];
HUES[(index % HUES.len() as u64) as usize]
}
pub(crate) struct FluidState {
t: f32,
ripples: Vec<(f32, f32, f32)>, last_kick: u64,
hue: f32,
}
impl FluidState {
pub(crate) fn new() -> Self {
Self {
t: 0.0,
ripples: Vec::new(),
last_kick: 0,
hue: hue_for_chord(0),
}
}
pub(crate) fn tick(&mut self, dt: f32, telemetry: &FluidTelemetry) {
self.t += dt;
let kick = telemetry.kick_pulse.load(Ordering::Relaxed);
if kick > self.last_kick {
let new = (kick - self.last_kick).min(4);
for k in 0..new {
let n = (self.last_kick + k + 1) as f32;
let cx = (n * 0.618_034).fract();
let cy = 0.92 + (n * 0.381_966).fract() * 0.06;
self.ripples.push((cx.clamp(0.06, 0.94), cy, 0.0));
}
self.last_kick = kick;
}
for r in &mut self.ripples {
r.2 += dt;
}
self.ripples.retain(|r| r.2 < RIPPLE_LIFETIME);
}
pub(crate) fn field(&self, nx: f32, ny: f32) -> f32 {
let z = self.t * 0.5;
let mut v = 0.0;
v += (nx * 6.0 + z).sin() * (ny * 5.0 - z * 0.7).cos();
v += ((nx * 3.3 - ny * 4.1) + z * 1.3).sin() * 0.7;
v += (nx * 11.0 + ny * 9.0 - z * 0.4).sin() * 0.35;
v += ((nx + ny) * 7.5 + (z * 0.9).sin() * 2.0).cos() * 0.5;
for &(cx, cy, age) in &self.ripples {
let dx = nx - cx;
let dy = ny - cy;
let dist = (dx * dx + dy * dy).sqrt();
let front = age * RIPPLE_SPEED;
let fade = (1.0 - age / RIPPLE_LIFETIME).max(0.0);
let ring = (-((dist - front) * 12.0).powi(2)).exp();
v += (dist * 34.0 - age * 9.0).sin() * ring * fade * 1.6;
}
(v / 3.0).tanh() * 0.5 + 0.5
}
}
pub(crate) fn fluid_hsv(h: f32, s: f32, v: f32) -> Color {
let h = ((h % 360.0) + 360.0) % 360.0;
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = match (h / 60.0) as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
Color::Rgb(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
pub(crate) struct FluidWidget<'a> {
fluid: &'a FluidState,
}
impl Widget for FluidWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let w = area.width.max(1) as f32;
let h = area.height.max(1) as f32;
let base = self.fluid.hue;
for y in 0..area.height {
for x in 0..area.width {
let nx = x as f32 / w;
let ny = y as f32 / h;
let v = self.fluid.field(nx, ny);
let edge_x = (nx.min(1.0 - nx) * 2.0).min(1.0);
let edge_y = (ny.min(1.0 - ny) * 2.0).min(1.0);
let vig = (edge_x.min(edge_y) * 1.4).clamp(0.2, 1.0);
let hue = base + (v - 0.5) * 45.0;
let sat = (0.5 + v * 0.3).clamp(0.0, 1.0);
let val = ((0.12 + v * 0.8) * vig).clamp(0.0, 1.0);
let gi = ((v * (FLUID_GRADIENT.len() - 1) as f32).round() as usize)
.min(FLUID_GRADIENT.len() - 1);
buf[(area.x + x, area.y + y)]
.set_char(FLUID_GRADIENT[gi])
.set_style(Style::default().fg(fluid_hsv(hue, sat, val)));
}
}
}
}
pub(crate) fn darken(c: Color, factor: f32) -> Color {
if let Color::Rgb(r, g, b) = c {
Color::Rgb(
(r as f32 * factor) as u8,
(g as f32 * factor) as u8,
(b as f32 * factor) as u8,
)
} else {
c
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
f: &mut Frame,
items: &[ControlItem],
active_tab: Tab,
selected: usize,
lfo_selected: usize,
beat: f64,
numeric: NumericDisplay<'_>,
fluid: &FluidState,
automation: &AutomationState,
controls: &FluidControls,
update_message: Option<&str>,
flipped: &FlippedUnits,
chord_drill: ChordDrill,
) {
let bpm = controls.master.bpm;
let mod_ctx = ModContext {
beat,
kick_interval_beats: controls.kick.interval_beats,
kick_offset_beats: controls.kick.offset_beats,
};
let area = f.area();
f.render_widget(FluidWidget { fluid }, area);
let pw = ((area.width as f32 * 0.62) as u16)
.clamp(46, area.width.saturating_sub(2).max(46))
.min(area.width);
let ph = ((area.height as f32 * 0.92) as u16)
.clamp(10, area.height.saturating_sub(2).max(10))
.min(area.height);
let px = area.x + (area.width.saturating_sub(pw)) / 2;
let py = area.y + (area.height.saturating_sub(ph)) / 2;
let panel = Rect::new(px, py, pw, ph);
{
let buf = f.buffer_mut();
for y in panel.top()..panel.bottom() {
for x in panel.left()..panel.right() {
let cell = &mut buf[(x, y)];
let tint = darken(cell.fg, 0.30);
cell.set_char(' ');
cell.set_bg(tint);
cell.set_fg(Color::Rgb(30, 34, 44));
}
}
}
let block = Block::default()
.title(format!(" {APP_ID} "))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(150, 160, 185)));
let inner = block.inner(panel);
f.render_widget(block, panel);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(inner);
let tab_line: String = Tab::all()
.iter()
.map(|t| {
let name = if *t == Tab::Chords {
match chord_drill {
ChordDrill::Progression => format!("{} › Progression", t.name()),
ChordDrill::Slot(n) => format!("{} › Chord {}", t.name(), n + 1),
ChordDrill::None => t.name().to_string(),
}
} else {
t.name().to_string()
};
if *t == active_tab {
format!("[{name}]")
} else {
name
}
})
.collect::<Vec<_>>()
.join(" ");
f.render_widget(
Paragraph::new(tab_line).alignment(Alignment::Center).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
layout[2],
);
let bar_w = (inner.width as usize).saturating_sub(34).clamp(6, 80);
let mut rows: Vec<Line> = Vec::with_capacity(items.len() * 3);
for (i, item) in items.iter().enumerate() {
let active = i == selected;
let address = ControlAddress::new(item.id);
let route = automation.route(address);
let envelope = automation.envelope(address);
let macro_route = automation.macro_route(address);
let macro_mod = live_macro_contribution(automation, controls, address, mod_ctx);
let editor_here = automation.active_address() == Some(address);
let lfo_open_here = editor_here && automation.active_kind() == Some(ModKind::Lfo);
let env_open_here = editor_here && automation.active_kind() == Some(ModKind::Envelope);
let macro_open_here = editor_here && automation.active_kind() == Some(ModKind::Macro);
let editor_open_here = editor_here;
let parent_active = active && (!editor_open_here || lfo_selected == 0);
let prefix = if parent_active { "▶ " } else { " " };
let display = if parent_active {
if let Some(entry) = numeric.entry {
let cursor = if numeric.cursor_visible { "_" } else { " " };
format!("> {entry}{cursor}")
} else {
item.display.clone()
}
} else {
item.display.clone()
};
let display = if (numeric.entry.is_some() && parent_active)
|| !flipped.contains(&unit_key(item.id, None))
{
display
} else {
flip_display(address.spec().time_base, item.value, bpm).unwrap_or(display)
};
let fg = if parent_active {
Color::Rgb(120, 230, 255)
} else {
Color::Rgb(170, 178, 195)
};
let mut style = Style::default().fg(fg);
if parent_active {
style = style.add_modifier(Modifier::BOLD);
}
let effective_lfo =
route.map(|r| live_effective_lfo_route(automation, controls, address, r, mod_ctx));
let markers = {
let spec = address.spec();
let base = match spec.bar {
Bar::Linear => item.value,
Bar::Log2 => 2f32.powf(item.value),
};
let range = item.max - item.min;
let ratio_of = |value: f32| {
let value = match spec.bar {
Bar::Linear => value,
Bar::Log2 => value.log2(),
};
if range.abs() <= f32::EPSILON {
0.0
} else {
((value - item.min) / range).clamp(0.0, 1.0)
}
};
let lfo = effective_lfo
.as_ref()
.filter(|r| r.depth_ratio > f32::EPSILON);
let env = envelope.filter(|r| r.amount.abs() > f32::EPSILON);
let single = |l: Option<&LfoRoute>, e: Option<&EnvelopeRoute>, m: Option<f32>| {
ratio_of(modulated_control_value_full(spec, l, e, m, base, mod_ctx))
};
let mod_range = spec.max - spec.min;
let shadow = editor_here.then(|| {
let mut lo = base;
let mut hi = base;
if let Some(r) = effective_lfo.as_ref() {
let swing = mod_range * r.depth_ratio.clamp(0.0, 1.0);
lo = lo.min(base - swing);
hi = hi.max(base + swing);
}
if let Some(r) = envelope {
let swing = mod_range * r.amount.clamp(-1.0, 1.0);
lo = lo.min(base + swing.min(0.0));
hi = hi.max(base + swing.max(0.0));
}
if let Some(r) = macro_route {
let (swing_lo, swing_hi) = r.swing(mod_range);
lo = lo.min(base + swing_lo);
hi = hi.max(base + swing_hi);
}
(
ratio_of(lo.clamp(spec.min, spec.max)),
ratio_of(hi.clamp(spec.min, spec.max)),
)
});
SliderMarkers {
effective: (lfo.is_some() || env.is_some() || macro_mod.is_some())
.then(|| single(lfo, env, macro_mod)),
lfo: lfo.map(|r| single(Some(r), None, None)),
envelope: env.map(|r| single(None, Some(r), None)),
macro_: macro_mod.map(|combined| single(None, None, Some(combined))),
shadow,
}
};
let mut spans = vec![Span::styled(format!("{prefix}{:<15} ", item.label), style)];
spans.extend(slider_spans(item_ratio(item), markers, bar_w, style));
spans.push(Span::styled(format!(" {display}"), style));
rows.push(Line::from(spans));
if let Some(route) = route {
if lfo_open_here {
for (fi, sub_row) in lfo_submenu_rows(automation, address).iter().enumerate() {
match *sub_row {
LfoSubRow::Field(field) => {
let value_display = match field {
LfoField::Interval
if flipped
.contains(&unit_key(item.id, Some("lfo.interval"))) =>
{
flip_display(TimeBase::Beats, route.cycle_beats, bpm)
}
LfoField::Offset
if flipped
.contains(&unit_key(item.id, Some("lfo.offset"))) =>
{
flip_display(TimeBase::Beats, route.phase_offset_beats, bpm)
}
_ => None,
}
.unwrap_or_else(|| route.field_display(field));
rows.push(field_line(
field.label(),
route.field_ratio(field),
value_display,
lfo_selected == fi + 1,
&numeric,
bar_w,
LFO_PALETTE,
));
if let Some(key_str) = field.macro_key() {
let key = unit_key(item.id, Some(key_str));
if let Some(field_route) = automation.field_macro(&key)
&& !field_route.is_neutral()
{
rows.push(macro_chip_line(field_route));
}
}
}
LfoSubRow::FieldMacro(field, macro_field) => {
let key = unit_key(item.id, field.macro_key());
let Some(field_route) = automation.field_macro(&key) else {
continue;
};
rows.push(field_line(
&format!("· {}", macro_field.label()),
field_route.field_ratio(macro_field),
field_route.field_display(macro_field),
lfo_selected == fi + 1,
&numeric,
bar_w,
MACRO_PALETTE,
));
}
}
}
}
rows.push(lfo_lane_line(route, beat, bar_w, lfo_open_here));
}
if let Some(route) = envelope {
if env_open_here {
for (fi, field) in EnvField::ALL.iter().enumerate() {
let value_display = match field {
EnvField::Attack
if flipped.contains(&unit_key(item.id, Some("env.attack"))) =>
{
flip_display(TimeBase::Beats, route.attack_beats, bpm)
}
EnvField::Decay
if route.decay_beats > 0.0
&& flipped
.contains(&unit_key(item.id, Some("env.decay"))) =>
{
flip_display(TimeBase::Beats, route.decay_beats, bpm)
}
_ => None,
}
.unwrap_or_else(|| route.field_display(*field));
rows.push(field_line(
field.label(),
route.field_ratio(*field),
value_display,
lfo_selected == fi + 1,
&numeric,
bar_w,
ENV_PALETTE,
));
}
}
rows.push(env_lane_line(route, mod_ctx, bar_w, env_open_here));
}
if let Some(route) = macro_route {
if macro_open_here {
for (fi, field) in MacroField::ALL.iter().enumerate() {
rows.push(field_line(
&field.label(),
route.field_ratio(*field),
route.field_display(*field),
lfo_selected == fi + 1,
&numeric,
bar_w,
MACRO_PALETTE,
));
}
} else {
rows.push(macro_chip_line(route));
}
}
if i + 1 < items.len() {
rows.push(Line::from(""));
}
}
f.render_widget(Paragraph::new(rows), layout[4]);
let footer = update_message
.unwrap_or("jk select h/l adjust f LFO v macro T units Enter set q quit");
let footer_style = if update_message.is_some() {
Style::default()
.fg(Color::Rgb(255, 220, 120))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Rgb(120, 128, 145))
};
f.render_widget(
Paragraph::new(footer)
.alignment(Alignment::Center)
.style(footer_style),
layout[5],
);
}
#[derive(Clone, Copy)]
pub(crate) struct FieldPalette {
active: Color,
idle: Color,
}
pub(crate) const LFO_PALETTE: FieldPalette = FieldPalette {
active: Color::Rgb(255, 130, 210),
idle: Color::Rgb(190, 105, 210),
};
pub(crate) const ENV_PALETTE: FieldPalette = FieldPalette {
active: Color::Rgb(140, 235, 175),
idle: Color::Rgb(95, 195, 140),
};
pub(crate) const MACRO_PALETTE: FieldPalette = FieldPalette {
active: Color::Rgb(255, 200, 120),
idle: Color::Rgb(210, 160, 90),
};
fn macro_chip_line(route: &MacroRoute) -> Line<'static> {
Line::from(Span::styled(
format!(" {:<15} ⇒ {}", "", route.summary()),
Style::default().fg(MACRO_PALETTE.idle),
))
}
fn field_line(
label: &str,
ratio: f32,
value_display: String,
active: bool,
numeric: &NumericDisplay<'_>,
bar_w: usize,
palette: FieldPalette,
) -> Line<'static> {
let mut style = Style::default().fg(if active { palette.active } else { palette.idle });
if active {
style = style.add_modifier(Modifier::BOLD);
}
let prefix = if active { "▶ " } else { " " };
let display = if active && let Some(entry) = numeric.entry {
let cursor = if numeric.cursor_visible { "_" } else { " " };
format!("> {entry}{cursor}")
} else {
value_display
};
let bar = ratio_bar(ratio, bar_w, '█', '░');
Line::from(Span::styled(
format!("{prefix} {label:<13} {bar} {display}"),
style,
))
}
const LANE_WAVE: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
const RANDOM_LANE_CYCLES: f32 = 4.0;
fn lane_glyph(level: f32) -> &'static str {
let level = level.clamp(0.0, 1.0);
LANE_WAVE[((level * (LANE_WAVE.len() - 1) as f32).round() as usize).min(LANE_WAVE.len() - 1)]
}
pub(crate) fn lfo_lane_line(
route: &LfoRoute,
beat: f64,
width: usize,
active: bool,
) -> Line<'static> {
let width = width.clamp(6, 80);
let floor = if active { 0.35 } else { 0.25 };
let mut spans = Vec::with_capacity(width + 1);
spans.push(Span::styled(
format!(" {:<15} ", ""),
Style::default().fg(Color::Rgb(130, 136, 160)),
));
if route.shape.is_random() {
let window = f64::from(route.cycle_beats.max(MIN_LFO_CYCLE_BEATS) * RANDOM_LANE_CYCLES);
for i in 0..width {
let age = (width - 1 - i) as f64 / width as f64;
let wave = route.wave_at(beat - age * window) * route.depth_ratio;
let level = wave * 0.5 + 0.5;
let brightness = (floor + (i as f32 / (width - 1) as f32) * 0.6).clamp(0.0, 1.0);
let hue = 300.0 + wave * 25.0;
spans.push(Span::styled(
lane_glyph(level),
Style::default().fg(fluid_hsv(hue, 0.6, brightness)),
));
}
return Line::from(spans);
}
let head = (route.phase_at(beat) * width as f64) as usize % width;
for i in 0..width {
let phase = i as f32 / width as f32;
let wave = route.shape_value_at_phase(phase) * route.depth_ratio;
let level = wave * 0.5 + 0.5;
let raw = i.abs_diff(head);
let wrapped = raw.min(width - raw);
let falloff = 1.0 - (wrapped as f32 / width as f32) * 2.0;
let brightness = (floor + falloff.max(0.0) * 0.6).clamp(0.0, 1.0);
let hue = 300.0 + wave * 25.0;
spans.push(Span::styled(
lane_glyph(level),
Style::default().fg(fluid_hsv(hue, 0.6, brightness)),
));
}
Line::from(spans)
}
pub(crate) fn env_lane_line(
route: &EnvelopeRoute,
ctx: ModContext,
width: usize,
active: bool,
) -> Line<'static> {
let width = width.clamp(6, 80);
let floor = if active { 0.35 } else { 0.25 };
let window = f64::from(route.window_beats());
let head_phase = route.lane_head_phase(ctx);
let head = ((head_phase * width as f32) as usize).min(width - 1);
let mut spans = Vec::with_capacity(width + 1);
spans.push(Span::styled(
format!(" {:<15} ", ""),
Style::default().fg(Color::Rgb(130, 136, 160)),
));
for i in 0..width {
let col_since = (i as f64 / width as f64 * window) as f32;
let level = route.level_for_lane(col_since) * route.amount.abs();
let raw = i.abs_diff(head);
let falloff = 1.0 - (raw as f32 / width as f32) * 2.0;
let brightness = (floor + falloff.max(0.0) * 0.6).clamp(0.0, 1.0);
let hue = if route.amount >= 0.0 { 150.0 } else { 15.0 };
spans.push(Span::styled(
lane_glyph(level).to_string(),
Style::default().fg(fluid_hsv(hue, 0.55, brightness)),
));
}
Line::from(spans)
}
#[derive(Default, Clone, Copy)]
pub(crate) struct SliderMarkers {
pub(crate) effective: Option<f32>,
pub(crate) lfo: Option<f32>,
pub(crate) envelope: Option<f32>,
pub(crate) macro_: Option<f32>,
pub(crate) shadow: Option<(f32, f32)>,
}
const EFFECTIVE_MARKER_COLOR: Color = Color::Rgb(235, 245, 255);
const SHADOW_COLOR: Color = Color::Rgb(95, 100, 115);
fn slider_spans(
ratio: f32,
markers: SliderMarkers,
width: usize,
style: Style,
) -> Vec<Span<'static>> {
let filled = (ratio.clamp(0.0, 1.0) * width as f32).round() as usize;
let cell = |value: Option<f32>| {
value.map(|v| (v.clamp(0.0, 1.0) * width.saturating_sub(1) as f32).round() as usize)
};
let effective = cell(markers.effective);
let ghosts = [
(cell(markers.lfo), LFO_PALETTE.idle),
(cell(markers.envelope), ENV_PALETTE.idle),
(cell(markers.macro_), MACRO_PALETTE.idle),
];
let shadow_range = markers.shadow.map(|(lo, hi)| {
let lo = cell(Some(lo)).unwrap_or(0);
let hi = cell(Some(hi)).unwrap_or(0);
lo.min(hi)..=lo.max(hi)
});
(0..width)
.map(|i| {
if Some(i) == effective {
Span::styled(
"◆",
Style::default()
.fg(EFFECTIVE_MARKER_COLOR)
.add_modifier(Modifier::BOLD),
)
} else if let Some((_, color)) = ghosts.iter().find(|(pos, _)| *pos == Some(i)) {
Span::styled("◇", Style::default().fg(*color))
} else if i < filled {
Span::styled("█", style)
} else if shadow_range.as_ref().is_some_and(|r| r.contains(&i)) {
Span::styled("▒", Style::default().fg(SHADOW_COLOR))
} else {
Span::styled("░", style)
}
})
.collect()
}
pub(crate) fn item_ratio(item: &ControlItem) -> f32 {
let range = item.max - item.min;
if range.abs() <= f32::EPSILON {
0.0
} else {
let value = match item.kind {
ControlKind::Discrete => item.value.round(),
ControlKind::Gain | ControlKind::Continuous | ControlKind::Timing => item.value,
};
((value - item.min) / range).clamp(0.0, 1.0)
}
}
pub(crate) fn ratio_bar(ratio: f32, width: usize, filled: char, empty: char) -> String {
let filled_count = (ratio.clamp(0.0, 1.0) * width as f32).round() as usize;
let filled_count = filled_count.min(width);
let empty_count = width.saturating_sub(filled_count);
format!(
"{}{}",
filled.to_string().repeat(filled_count),
empty.to_string().repeat(empty_count)
)
}