use crate::{
consts::{state_ids::METRONOME_TRACK_ID, workspace_mixer::*},
message::Message,
state::{State, Track},
style,
};
use iced::{
Alignment, Element, Length,
widget::{
Space, button, column, container, lazy, mouse_area, row, scrollable, text, text_input,
},
};
use maolan_engine::message::Action;
use maolan_widgets::{horizontal_slider::horizontal_slider, meters, slider::slider, ticks};
const STRIP_SPACING: f32 = 2.0;
const STRIP_ROW_PADDING_X: f32 = 8.0;
const MIXER_OVERSCAN_PX: f32 = 160.0;
#[derive(Debug, Default)]
pub struct Mixer {
state: State,
}
struct StripReadout<'a> {
track_name: String,
editing: bool,
edit_input: &'a str,
level_label: &'static str,
}
struct TrackStripSpec<'a> {
track: &'a Track,
width: f32,
}
impl Mixer {
pub fn new(state: State) -> Self {
Self { state }
}
fn fader_height_from_panel(height: Length) -> f32 {
match height {
Length::Fixed(panel_h) => (panel_h - 146.0).max(92.0),
_ => 160.0,
}
}
fn trim_strip_name(name: &str, width: f32) -> String {
const STRIP_SHELL_H_PAD: f32 = 6.0;
const STRIP_NAME_H_PAD: f32 = 2.0;
let total_pad = (STRIP_SHELL_H_PAD + STRIP_NAME_H_PAD) * 2.0;
let usable_width = (width - total_pad).max(0.0);
let max_chars = (usable_width / STRIP_NAME_CHAR_PX).floor() as usize;
if max_chars == 0 {
String::new()
} else {
name.chars().take(max_chars).collect()
}
}
fn strip_name(name: String, width: f32) -> Element<'static, Message> {
container(text(Self::trim_strip_name(&name, width)).size(12))
.width(Length::Fill)
.align_x(Alignment::Center)
.padding([0, 2])
.into()
}
fn strip_name_cached(name: String, width: f32) -> Element<'static, Message> {
let dep = (
name,
(width.max(0.0) * 10.0).round().clamp(0.0, u16::MAX as f32) as u16,
);
lazy(dep, |(name, width_tenths)| -> Element<'static, Message> {
Self::strip_name(name.clone(), *width_tenths as f32 / 10.0)
})
.into()
}
fn format_level_db(level: f32) -> &'static str {
if level <= FADER_MIN_DB {
"-inf dB"
} else {
let clamped = level.clamp(FADER_MIN_DB, FADER_MAX_DB);
let idx = ((clamped - FADER_MIN_DB) * 10.0).round() as usize;
LEVEL_LABELS[idx.min(LEVEL_LABELS.len() - 1)]
}
}
fn format_balance(balance: f32) -> &'static str {
let b = balance.clamp(-1.0, 1.0);
let idx = ((b * 100.0).round() as i32 + 100).clamp(0, 200) as usize;
BALANCE_LABELS[idx]
}
fn value_pill<'a>(
track_name: String,
content: &'static str,
editing: bool,
edit_input: &'a str,
) -> Element<'a, Message> {
if editing {
container(
text_input("dB", edit_input)
.on_input(Message::MixerLevelEditInput)
.on_submit(Message::MixerLevelEditCommit)
.padding([2, 4])
.size(11),
)
.width(Length::Fixed(READOUT_WIDTH))
.style(|_theme| style::mixer::readout())
.into()
} else {
mouse_area(
container(text(content).size(11))
.width(Length::Fixed(READOUT_WIDTH))
.padding([4, 6])
.align_x(Alignment::Center)
.style(|_theme| style::mixer::readout()),
)
.on_press(Message::MixerLevelEditStart(track_name))
.into()
}
}
fn value_pill_cached<'a>(
track_name: String,
content: &'static str,
editing: bool,
edit_input: &'a str,
) -> Element<'a, Message> {
if editing {
return Self::value_pill(track_name, content, true, edit_input);
}
let dep = (track_name, content);
lazy(dep, |(track_name, content)| -> Element<'static, Message> {
mouse_area(
container(text(*content).size(11))
.width(Length::Fixed(READOUT_WIDTH))
.padding([4, 6])
.align_x(Alignment::Center)
.style(|_theme| style::mixer::readout()),
)
.on_press(Message::MixerLevelEditStart(track_name.clone()))
.into()
})
.into()
}
fn pan_section(track_name: String, value: f32) -> Element<'static, Message> {
let on_change_track = track_name.clone();
row![
container(text(Self::format_balance(value)).size(9))
.width(Length::Fixed(24.0))
.align_x(Alignment::Center),
horizontal_slider(-1.0..=1.0, value, move |value| {
Message::Request(Action::TrackBalance(on_change_track.clone(), value))
})
.width(Length::Fixed(PAN_SLIDER_WIDTH))
.height(Length::Fixed(PAN_ROW_HEIGHT))
.double_click_reset(0.0),
]
.spacing(4)
.align_y(Alignment::Center)
.into()
}
fn quantized_balance_hundredths(balance: f32) -> i16 {
(balance.clamp(-1.0, 1.0) * 100.0).round() as i16
}
fn pan_section_cached(track_name: String, value: f32) -> Element<'static, Message> {
let dep = (track_name, Self::quantized_balance_hundredths(value));
lazy(
dep,
move |(track_name, value_hundredths)| -> Element<'static, Message> {
let value = (*value_hundredths as f32) / 100.0;
Self::pan_section(track_name.clone(), value)
},
)
.into()
}
fn pan_placeholder() -> Element<'static, Message> {
Space::new()
.width(Length::Fill)
.height(Length::Fixed(PAN_ROW_HEIGHT))
.into()
}
fn level_to_qdb(level_db: f32) -> u8 {
(level_db
.clamp(FADER_MIN_DB, FADER_MAX_DB)
.round()
.max(FADER_MIN_DB) as i16)
.saturating_add(90)
.clamp(0, 110) as u8
}
fn qdb_to_level(q: u8) -> f32 {
q as f32 - 90.0
}
fn fader_bay(
track_name: String,
channels: usize,
levels_db: &[f32],
value: f32,
fader_height: f32,
show_ticks: bool,
) -> Element<'static, Message> {
let channels = channels.max(1);
container(
row![
lazy(
(
track_name.clone(),
channels,
Self::level_to_qdb(value),
(fader_height.max(0.0) * 10.0).round() as u16,
show_ticks,
),
move |(track_name, _channels, value_qdb, fader_height_tenths, show_ticks)| -> Element<'static, Message> {
let value = Self::qdb_to_level(*value_qdb);
let fader_height = *fader_height_tenths as f32 / 10.0;
let on_change_track = track_name.clone();
let slider = container(
slider(FADER_MIN_DB..=FADER_MAX_DB, value, move |value| {
Message::Request(Action::TrackLevel(on_change_track.clone(), value))
})
.width(Length::Fixed(FADER_WIDTH))
.height(Length::Fixed(fader_height))
.double_click_reset(0.0)
)
.padding([7.0, 8.0]);
if *show_ticks {
row![slider, ticks::ticks(FADER_MIN_DB..=FADER_MAX_DB, fader_height)].into()
} else {
slider.into()
}
},
),
meters::meters(channels, levels_db, fader_height),
]
.spacing(8.0),
)
.width(Length::Fill)
.padding(BAY_INSET)
.style(|_theme| style::mixer::bay())
.into()
}
fn fader_bay_cached(
track_name: String,
channels: usize,
levels_db: &[f32],
value: f32,
fader_height: f32,
show_ticks: bool,
) -> Element<'static, Message> {
Self::fader_bay(
track_name,
channels,
levels_db,
value,
fader_height,
show_ticks,
)
}
fn strip_width_for_channels(channels: usize) -> f32 {
(FADER_WIDTH
+ SCALE_WIDTH
+ 3.0
+ 8.0
+ meters::total_width(channels.max(1))
+ 16.0
+ (BAY_INSET * 2.0))
.max(STRIP_WIDTH)
}
fn output_strips_width(
metronome_width: Option<f32>,
master_channels: usize,
metronome_enabled: bool,
) -> f32 {
let strip_width = Self::strip_width_for_channels(master_channels.max(1));
let mut widths = vec![strip_width];
if metronome_enabled && let Some(width) = metronome_width {
widths.insert(0, width);
}
let spacing_count = widths.len().saturating_sub(1) as f32;
widths.iter().copied().sum::<f32>() + (STRIP_SPACING * spacing_count)
}
fn visible_track_window(
track_specs: &[TrackStripSpec<'_>],
viewport_width: f32,
scroll_x: f32,
) -> (usize, usize, f32, f32) {
if track_specs.is_empty() {
return (0, 0, 0.0, 0.0);
}
let content_width = track_specs.iter().map(|spec| spec.width).sum::<f32>()
+ (STRIP_SPACING * track_specs.len().saturating_sub(1) as f32)
+ (STRIP_ROW_PADDING_X * 2.0);
if viewport_width <= 0.0 || content_width <= viewport_width {
return (0, track_specs.len(), 0.0, 0.0);
}
let max_scroll = (content_width - viewport_width).max(0.0);
let left_edge = (scroll_x.clamp(0.0, 1.0) * max_scroll - MIXER_OVERSCAN_PX).max(0.0);
let right_edge =
(left_edge + viewport_width + (MIXER_OVERSCAN_PX * 2.0)).min(content_width);
let mut current_x = STRIP_ROW_PADDING_X;
let mut first_visible = track_specs.len();
let mut last_visible = 0usize;
for (idx, spec) in track_specs.iter().enumerate() {
let strip_start = current_x;
let strip_end = strip_start + spec.width;
if strip_end >= left_edge && strip_start <= right_edge {
first_visible = first_visible.min(idx);
last_visible = idx + 1;
}
current_x = strip_end + STRIP_SPACING;
}
if first_visible == track_specs.len() {
return (0, track_specs.len(), 0.0, 0.0);
}
let left_spacer = if first_visible == 0 {
0.0
} else {
STRIP_ROW_PADDING_X
+ track_specs[..first_visible]
.iter()
.map(|spec| spec.width)
.sum::<f32>()
+ (STRIP_SPACING * first_visible as f32)
};
let right_spacer = if last_visible >= track_specs.len() {
0.0
} else {
STRIP_ROW_PADDING_X
+ track_specs[last_visible..]
.iter()
.map(|spec| spec.width)
.sum::<f32>()
+ (STRIP_SPACING * (track_specs.len() - last_visible) as f32)
};
(first_visible, last_visible, left_spacer, right_spacer)
}
fn strip_controls(track: &Track, solo_upstream: bool) -> Option<Element<'static, Message>> {
if track.is_master || track.name == METRONOME_TRACK_ID {
return None;
}
let track_name = track.name.clone();
let muted = track.muted;
let soloed = track.soloed;
let armed = track.armed;
let mut controls = row![].spacing(4).align_y(Alignment::Center);
controls = controls.push(
button(
container(text("M").size(13))
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.width(Length::Fixed(22.0))
.height(Length::Fixed(22.0))
.padding(0)
.style(move |theme, _state| style::mute::style(theme, muted))
.on_press(Message::Request(Action::TrackToggleMute(
track_name.clone(),
))),
);
let track_name = track.name.clone();
controls = controls.push(
button(
container(text("S").size(13))
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.width(Length::Fixed(22.0))
.height(Length::Fixed(22.0))
.padding(0)
.style(move |theme, _state| style::solo::style(theme, soloed, solo_upstream))
.on_press(Message::Request(Action::TrackToggleSolo(
track_name.clone(),
))),
);
let track_name = track.name.clone();
controls = controls.push(
button(
container(text("R").size(13))
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.width(Length::Fixed(22.0))
.height(Length::Fixed(22.0))
.padding(0)
.style(move |theme, _state| style::arm::style(theme, armed))
.on_press(Message::Request(Action::TrackToggleArm(track_name.clone()))),
);
Some(controls.into())
}
#[allow(clippy::too_many_arguments)]
fn strip_shell<'a>(
name: String,
selected: bool,
color: Option<iced::Color>,
width: f32,
pan_section: Option<Element<'static, Message>>,
bay: Element<'static, Message>,
readout: StripReadout<'a>,
controls: Option<Element<'static, Message>>,
) -> Element<'a, Message> {
let mut content = column![].spacing(8).width(Length::Fill);
content = content.push(pan_section.unwrap_or_else(Self::pan_placeholder));
content = content.push(bay).push(Self::value_pill_cached(
readout.track_name,
readout.level_label,
readout.editing,
readout.edit_input,
));
if let Some(controls) = controls {
content = content.push(controls);
}
content = content.push(Self::strip_name_cached(name, width));
container(content)
.width(Length::Fixed(width))
.height(Length::Fill)
.padding([8, 6])
.style(move |_theme| style::mixer::strip(selected, color))
.into()
}
pub fn view<'a>(
&'a self,
editing_track: Option<&'a str>,
editing_input: &'a str,
viewport_width: f32,
scroll_x: f32,
) -> Element<'a, Message> {
let mut strips = row![].spacing(2).align_y(Alignment::Start);
let state = self.state.blocking_read();
let height = state.mixer_height;
let hw_out_channels = state.hw_out.as_ref().map(|hw| hw.channels).unwrap_or(0);
let hw_out_level = state.hw_out_level;
let hw_out_balance = state.hw_out_balance;
let master_selected = state.selected.contains("hw:out");
let fader_height = Self::fader_height_from_panel(height);
let metronome_enabled = state.metronome_enabled;
let mut metronome_strip: Option<Element<'a, Message>> = None;
let track_specs: Vec<_> = state
.tracks
.iter()
.map(|track| TrackStripSpec {
track,
width: Self::strip_width_for_channels(track.audio.outs),
})
.collect();
let metronome_width = track_specs
.iter()
.find(|spec| spec.track.name == METRONOME_TRACK_ID)
.map(|spec| spec.width);
let output_strips_width =
Self::output_strips_width(metronome_width, hw_out_channels, metronome_enabled);
let track_viewport_width = (viewport_width - output_strips_width).max(0.0);
let normal_track_specs: Vec<_> = track_specs
.into_iter()
.filter(|spec| spec.track.name != METRONOME_TRACK_ID)
.collect();
let soloed_track_names: std::collections::HashSet<String> = state
.tracks
.iter()
.filter(|t| t.soloed)
.map(|t| t.name.clone())
.collect();
let mut upstream_track_names = std::collections::HashSet::new();
if !soloed_track_names.is_empty() {
let mut to_process: Vec<String> = soloed_track_names.iter().cloned().collect();
let mut processed = std::collections::HashSet::new();
while let Some(target_name) = to_process.pop() {
if !processed.insert(target_name.clone()) {
continue;
}
for conn in &state.connections {
if conn.kind == maolan_engine::kind::Kind::Audio
&& conn.to_track == target_name
&& !soloed_track_names.contains(&conn.from_track)
{
upstream_track_names.insert(conn.from_track.clone());
to_process.push(conn.from_track.clone());
}
}
}
}
let (first_visible, last_visible, left_spacer, right_spacer) =
Self::visible_track_window(&normal_track_specs, track_viewport_width, scroll_x);
if metronome_enabled
&& let Some(track) = state
.tracks
.iter()
.find(|track| track.name == METRONOME_TRACK_ID)
{
let strip_width = Self::strip_width_for_channels(track.audio.outs);
let pan = if track.audio.outs == 2 {
Some(Self::pan_section_cached(track.name.clone(), track.balance))
} else {
None
};
let bay = Self::fader_bay_cached(
track.name.clone(),
track.audio.outs,
&track.meter_out_db,
track.level,
fader_height,
true,
);
metronome_strip = Some(
mouse_area(Self::strip_shell(
track.name.clone(),
state.selected.contains(track.name.as_str()),
track.color,
strip_width,
pan,
bay,
StripReadout {
track_name: track.name.clone(),
editing: editing_track == Some(track.name.as_str()),
edit_input: editing_input,
level_label: Self::format_level_db(track.level),
},
None,
))
.on_press(Message::SelectTrackFromMixer(track.name.clone()))
.into(),
);
}
for (index, spec) in normal_track_specs.iter().enumerate() {
let track = spec.track;
if index < first_visible || index >= last_visible {
continue;
}
let strip_name = track.name.clone();
let strip_width = spec.width;
let pan = if track.audio.outs == 2 {
Some(Self::pan_section_cached(track.name.clone(), track.balance))
} else {
None
};
let bay = Self::fader_bay_cached(
track.name.clone(),
track.audio.outs,
&track.meter_out_db,
track.level,
fader_height,
true,
);
let solo_upstream = upstream_track_names.contains(&track.name);
let strip: Element<'a, Message> = mouse_area(Self::strip_shell(
strip_name,
state.selected.contains(track.name.as_str()),
track.color,
strip_width,
pan,
bay,
StripReadout {
track_name: track.name.clone(),
editing: editing_track == Some(track.name.as_str()),
edit_input: editing_input,
level_label: Self::format_level_db(track.level),
},
Self::strip_controls(track, solo_upstream),
))
.on_press(Message::SelectTrackFromMixer(track.name.clone()))
.on_double_click(Message::OpenTrackPlugins(track.name.clone()))
.into();
strips = strips.push(strip);
}
if left_spacer > 0.0 {
strips = row![Space::new().width(Length::Fixed(left_spacer)), strips]
.spacing(STRIP_SPACING)
.align_y(Alignment::Start);
}
if right_spacer > 0.0 {
strips = strips.push(Space::new().width(Length::Fixed(right_spacer)));
}
let master_strip_width = Self::strip_width_for_channels(hw_out_channels.max(1));
let master_strip: Element<'a, Message> = mouse_area(Self::strip_shell(
"Master".to_string(),
master_selected,
None,
master_strip_width,
if hw_out_channels == 2 {
Some(Self::pan_section_cached(
"hw:out".to_string(),
hw_out_balance,
))
} else {
None
},
Self::fader_bay_cached(
"hw:out".to_string(),
hw_out_channels.max(1),
&state.hw_out_meter_db,
hw_out_level,
fader_height,
true,
),
StripReadout {
track_name: "hw:out".to_string(),
editing: editing_track == Some("hw:out"),
edit_input: editing_input,
level_label: Self::format_level_db(hw_out_level),
},
None,
))
.on_press(Message::SelectTrackFromMixer("hw:out".to_string()))
.into();
let mut output_strips = row![].spacing(2).align_y(Alignment::Start);
if let Some(strip) = metronome_strip {
output_strips = output_strips.push(strip);
}
output_strips = output_strips.push(master_strip);
let track_strips = scrollable(
row![strips, Space::new().width(Length::Fill)]
.height(height)
.padding([8, 6])
.align_y(Alignment::Start),
)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::new(),
))
.on_scroll(|viewport| Message::MixerScrollXChanged(viewport.relative_offset().x))
.width(Length::Fill)
.height(height);
container(
mouse_area(
row![track_strips, output_strips]
.height(height)
.align_y(Alignment::Start),
)
.on_press(Message::DeselectAll),
)
.style(|_theme| crate::style::app_background())
.width(Length::Fill)
.height(height)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fader_height_from_panel_fixed() {
let height = Mixer::fader_height_from_panel(Length::Fixed(400.0));
assert!(height >= 92.0);
}
#[test]
fn fader_height_from_panel_fill() {
let height = Mixer::fader_height_from_panel(Length::Fill);
assert_eq!(height, 160.0);
}
#[test]
fn trim_strip_name_truncates_long_names() {
let name = "VeryLongTrackNameThatExceedsWidth";
let trimmed = Mixer::trim_strip_name(name, 50.0);
assert!(trimmed.len() < name.len());
}
#[test]
fn trim_strip_name_keeps_short_names() {
let name = "Short";
let trimmed = Mixer::trim_strip_name(name, 200.0);
assert_eq!(trimmed, name);
}
#[test]
fn trim_strip_name_returns_empty_for_zero_width() {
let trimmed = Mixer::trim_strip_name("Name", 0.0);
assert!(trimmed.is_empty());
}
#[test]
fn format_level_db_inf() {
let label = Mixer::format_level_db(-100.0);
assert_eq!(label, "-inf dB");
}
#[test]
fn format_level_db_clamps_min() {
let label = Mixer::format_level_db(-95.0);
assert_eq!(label, "-inf dB");
}
#[test]
fn format_level_db_valid_range() {
let label = Mixer::format_level_db(0.0);
assert!(!label.is_empty());
}
#[test]
fn format_balance_center() {
let label = Mixer::format_balance(0.0);
assert_eq!(label, "C");
}
#[test]
fn format_balance_left() {
let label = Mixer::format_balance(-0.5);
assert!(label.starts_with('L'));
}
#[test]
fn format_balance_right() {
let label = Mixer::format_balance(0.5);
assert!(label.starts_with('R'));
}
#[test]
fn format_balance_clamps() {
let label_min = Mixer::format_balance(-10.0);
let label_max = Mixer::format_balance(10.0);
assert!(!label_min.is_empty());
assert!(!label_max.is_empty());
}
#[test]
fn mixer_new_creates_instance() {
let state = crate::state::State::default();
let mixer = Mixer::new(state);
let _ = &mixer;
}
#[test]
fn mixer_default_creates_instance() {
let mixer: Mixer = Default::default();
let _ = &mixer;
}
}