use super::VisibleTrackWindow;
use crate::{
consts::{
state_ids::METRONOME_TRACK_ID,
state_track::{TRACK_SUBTRACK_GAP, TRACK_SUBTRACK_MIN_HEIGHT},
workspace::{AUDIO_CLIP_SELECTED_BASE, MIDI_CLIP_SELECTED_BASE, MIN_TICK_SPACING_PX},
},
message::{DraggedClip, Message, SnapMode},
state::{ClipPeaks, MidiClipPreviewMap, State, StateData, Track},
};
use iced::{
Background, Border, Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse,
widget::{
Space, Stack, canvas,
canvas::{Frame, Geometry, Path},
column, container, mouse_area, pin, text,
},
};
use maolan_engine::kind::Kind;
use maolan_widgets::clip::{
AudioClip as AudioClipWidget, AudioClipData as WidgetAudioClipData,
AudioClipInteraction as WidgetAudioClipInteraction, ClipEdgeMessages as WidgetClipEdgeMessages,
MIDIClip as MIDIClipWidget, MIDIClipData as WidgetMIDIClipData,
MIDIClipInteraction as WidgetMIDIClipInteraction,
};
use maolan_widgets::curve_editor::{CurveEditor, CurvePoint};
use std::{
cell::Cell,
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
path::PathBuf,
sync::Arc,
};
fn widget_audio_clip_data(
clip: &crate::state::AudioClip,
stretch_ratio: f32,
) -> WidgetAudioClipData {
WidgetAudioClipData {
name: clip.name.clone(),
start: clip.start,
length: clip.length,
offset: clip.offset,
muted: clip.muted,
max_length_samples: clip.max_length_samples,
source_length_samples: clip.source_length_samples,
peaks: clip.peaks.clone(),
fade_enabled: clip.fade_enabled,
fade_in_samples: clip.fade_in_samples,
fade_out_samples: clip.fade_out_samples,
grouped_clips: clip
.grouped_clips
.iter()
.map(|c| widget_audio_clip_data(c, stretch_ratio))
.collect(),
stretch_ratio,
}
}
fn widget_midi_clip_data(clip: &crate::state::MIDIClip) -> WidgetMIDIClipData {
WidgetMIDIClipData {
name: clip.name.clone(),
start: clip.start,
length: clip.length,
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
max_length_samples: clip.max_length_samples,
grouped_clips: clip
.grouped_clips
.iter()
.map(widget_midi_clip_data)
.collect(),
}
}
struct EditorTrackNode<'a> {
track: &'a Track,
children: Vec<EditorTrackNode<'a>>,
}
fn build_editor_track_tree<'a>(all_tracks: &'a [Track]) -> Vec<EditorTrackNode<'a>> {
let visible: Vec<&'a Track> = all_tracks
.iter()
.filter(|t| t.name != METRONOME_TRACK_ID && !t.is_inside_closed_folder(all_tracks))
.collect();
let mut children_by_parent: std::collections::HashMap<String, Vec<&'a Track>> =
std::collections::HashMap::new();
for track in &visible {
if let Some(parent) = track.parent_track.as_deref() {
children_by_parent
.entry(parent.to_string())
.or_default()
.push(*track);
}
}
let mut roots = Vec::new();
for track in &visible {
if track.parent_track.is_none() {
roots.push(build_editor_track_subtree(track, &children_by_parent));
}
}
roots
}
fn build_editor_track_subtree<'a>(
track: &'a Track,
children_by_parent: &std::collections::HashMap<String, Vec<&'a Track>>,
) -> EditorTrackNode<'a> {
let mut children = Vec::new();
if let Some(child_tracks) = children_by_parent.get(&track.name) {
for child in child_tracks {
children.push(build_editor_track_subtree(child, children_by_parent));
}
}
EditorTrackNode { track, children }
}
fn hash_track_node<H: Hasher>(node: &EditorTrackNode<'_>, hasher: &mut H) {
let track = node.track;
track.name.hash(hasher);
track.height.to_bits().hash(hasher);
match &track.lane_heights {
Some(heights) => {
1u8.hash(hasher);
heights.len().hash(hasher);
for h in heights {
h.to_bits().hash(hasher);
}
}
None => 0u8.hash(hasher),
}
track.armed.hash(hasher);
track.audio.ins.hash(hasher);
track.midi.ins.hash(hasher);
track.midi_lane_channels.hash(hasher);
std::mem::discriminant(&track.automation_mode).hash(hasher);
for lane in &track.automation_lanes {
lane.visible.hash(hasher);
std::mem::discriminant(&lane.target).hash(hasher);
lane.points.len().hash(hasher);
if let Some(first) = lane.points.first() {
first.sample.hash(hasher);
first.value.to_bits().hash(hasher);
}
if let Some(last) = lane.points.last() {
last.sample.hash(hasher);
last.value.to_bits().hash(hasher);
}
}
for clip in &track.audio.clips {
clip.name.hash(hasher);
clip.start.hash(hasher);
clip.length.hash(hasher);
clip.offset.hash(hasher);
clip.input_channel.hash(hasher);
clip.muted.hash(hasher);
clip.fade_enabled.hash(hasher);
clip.fade_in_samples.hash(hasher);
clip.fade_out_samples.hash(hasher);
clip.take_lane_override.hash(hasher);
clip.take_lane_pinned.hash(hasher);
clip.take_lane_locked.hash(hasher);
clip.peaks.len().hash(hasher);
for channel in clip.peaks.iter() {
channel.len().hash(hasher);
if channel.is_empty() {
continue;
}
for i in 0..crate::consts::workspace_editor::CHECKPOINTS {
let idx = (i * channel.len()) / crate::consts::workspace_editor::CHECKPOINTS;
let sample = channel[idx.min(channel.len() - 1)];
sample[0].to_bits().hash(hasher);
sample[1].to_bits().hash(hasher);
}
}
}
for clip in &track.midi.clips {
clip.name.hash(hasher);
clip.start.hash(hasher);
clip.length.hash(hasher);
clip.offset.hash(hasher);
clip.input_channel.hash(hasher);
clip.muted.hash(hasher);
clip.take_lane_override.hash(hasher);
clip.take_lane_pinned.hash(hasher);
clip.take_lane_locked.hash(hasher);
}
for child in &node.children {
hash_track_node(child, hasher);
}
}
struct TrackNodeViewArgs<'a> {
state: &'a StateData,
session_root: Option<&'a PathBuf>,
pixels_per_sample: f32,
samples_per_bar: f32,
active_clip_drag: Option<&'a DraggedClip>,
active_target_track: Option<&'a str>,
active_target_valid: bool,
active_clip_snap_adjust_samples: f32,
active_clip_snap_targets: &'a [crate::state::ClipId],
recording_preview_bounds: Option<(usize, usize)>,
recording_preview_peaks: Option<&'a HashMap<String, ClipPeaks>>,
midi_clip_previews: Option<&'a MidiClipPreviewMap>,
}
fn view_track_node(
node: &EditorTrackNode<'_>,
args: &TrackNodeViewArgs<'_>,
) -> Element<'static, Message> {
let mut column = column![];
column = column.push(view_track_elements(TrackElementViewArgs {
state: args.state,
track: node.track,
session_root: args.session_root,
pixels_per_sample: args.pixels_per_sample,
samples_per_bar: args.samples_per_bar,
active_clip_drag: args.active_clip_drag,
active_target_track: args.active_target_track,
active_target_valid: args.active_target_valid,
active_clip_snap_adjust_samples: args.active_clip_snap_adjust_samples,
active_clip_snap_targets: args.active_clip_snap_targets,
recording_preview_bounds: args.recording_preview_bounds,
recording_preview_peaks: args.recording_preview_peaks,
midi_clip_previews: args.midi_clip_previews,
}));
for child in &node.children {
column = column.push(view_track_node(child, args));
}
column.into()
}
struct TrackElementViewArgs<'a> {
state: &'a StateData,
track: &'a Track,
session_root: Option<&'a PathBuf>,
pixels_per_sample: f32,
samples_per_bar: f32,
active_clip_drag: Option<&'a DraggedClip>,
active_target_track: Option<&'a str>,
active_target_valid: bool,
active_clip_snap_adjust_samples: f32,
active_clip_snap_targets: &'a [crate::state::ClipId],
recording_preview_bounds: Option<(usize, usize)>,
recording_preview_peaks: Option<&'a HashMap<String, ClipPeaks>>,
midi_clip_previews: Option<&'a MidiClipPreviewMap>,
}
fn automation_point_color(target: crate::message::TrackAutomationTarget) -> Color {
match target {
crate::message::TrackAutomationTarget::Volume => Color::from_rgba(0.98, 0.78, 0.22, 0.95),
crate::message::TrackAutomationTarget::Balance => Color::from_rgba(0.88, 0.62, 0.24, 0.95),
crate::message::TrackAutomationTarget::MidiCc { .. } => {
Color::from_rgba(0.95, 0.45, 0.22, 0.95)
}
crate::message::TrackAutomationTarget::Lv2Parameter { .. } => {
Color::from_rgba(0.6, 0.5, 0.95, 0.95)
}
crate::message::TrackAutomationTarget::Vst3Parameter { .. } => {
Color::from_rgba(0.28, 0.82, 0.78, 0.95)
}
crate::message::TrackAutomationTarget::ClapParameter { .. } => {
Color::from_rgba(0.4, 0.72, 0.98, 0.95)
}
}
}
#[derive(Clone)]
struct TrackBarGridCanvas {
bar_pixels: f32,
}
#[derive(Default)]
struct TrackBarGridCanvasState {
cache: canvas::Cache,
last_hash: Cell<u64>,
}
impl TrackBarGridCanvas {
fn step_for_spacing(base_px: f32, min_spacing_px: f32) -> usize {
if base_px <= 0.0 {
return 1;
}
let mut step = 1usize;
while base_px * (step as f32) < min_spacing_px {
step *= 2;
}
step
}
fn shape_hash(&self, bounds: Rectangle) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bounds.width.to_bits().hash(&mut hasher);
bounds.height.to_bits().hash(&mut hasher);
self.bar_pixels.to_bits().hash(&mut hasher);
hasher.finish()
}
}
impl canvas::Program<Message> for TrackBarGridCanvas {
type State = TrackBarGridCanvasState;
fn draw(
&self,
state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return vec![];
}
let hash = self.shape_hash(bounds);
if state.last_hash.get() != hash {
state.cache.clear();
state.last_hash.set(hash);
}
let geom = state
.cache
.draw(renderer, bounds.size(), |frame: &mut Frame| {
let step = self.bar_pixels.max(1.0)
* Self::step_for_spacing(self.bar_pixels, MIN_TICK_SPACING_PX) as f32;
let color = Color::from_rgba(0.86, 0.96, 0.74, 0.14);
let mut x = 0.0_f32;
while x <= bounds.width + 1.0 {
frame.stroke(
&Path::line(Point::new(x, 0.0), Point::new(x, bounds.height)),
canvas::Stroke::default().with_color(color).with_width(1.0),
);
x += step;
}
});
vec![geom]
}
}
fn track_bar_grid_overlay(
height: f32,
samples_per_bar: f32,
pixels_per_sample: f32,
) -> Element<'static, Message> {
let bar_pixels = (samples_per_bar.max(1.0) * pixels_per_sample).max(1.0);
canvas(TrackBarGridCanvas { bar_pixels })
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}
#[derive(Clone)]
struct TrackLaneBackgroundCanvas {
header_height: f32,
lane_heights: Vec<f32>,
audio_lanes: usize,
midi_lanes: usize,
}
#[derive(Default)]
struct TrackLaneBackgroundCanvasState {
cache: canvas::Cache,
last_hash: Cell<u64>,
}
impl TrackLaneBackgroundCanvas {
fn shape_hash(&self, bounds: Rectangle) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bounds.width.to_bits().hash(&mut hasher);
bounds.height.to_bits().hash(&mut hasher);
self.header_height.to_bits().hash(&mut hasher);
for h in &self.lane_heights {
h.to_bits().hash(&mut hasher);
}
self.audio_lanes.hash(&mut hasher);
self.midi_lanes.hash(&mut hasher);
hasher.finish()
}
}
impl canvas::Program<Message> for TrackLaneBackgroundCanvas {
type State = TrackLaneBackgroundCanvasState;
fn draw(
&self,
state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return vec![];
}
let hash = self.shape_hash(bounds);
if state.last_hash.get() != hash {
state.cache.clear();
state.last_hash.set(hash);
}
let geom = state
.cache
.draw(renderer, bounds.size(), |frame: &mut Frame| {
let mut y = self.header_height;
for i in 0..self.audio_lanes {
let lane_h = self
.lane_heights
.get(i)
.copied()
.unwrap_or(TRACK_SUBTRACK_MIN_HEIGHT);
let is_last = i + 1 == self.audio_lanes && self.midi_lanes == 0;
let h = if is_last {
(bounds.height - y - 1.0).max(0.0)
} else {
lane_h - 2.0
};
frame.fill(
&Path::rectangle(
Point::new(0.0, y + 1.0),
iced::Size::new(bounds.width, h),
),
Color::from_rgba(0.15, 0.20, 0.28, 0.12),
);
y += lane_h + TRACK_SUBTRACK_GAP;
}
for i in 0..self.midi_lanes {
let lane_h = self
.lane_heights
.get(self.audio_lanes + i)
.copied()
.unwrap_or(TRACK_SUBTRACK_MIN_HEIGHT);
let is_last = i + 1 == self.midi_lanes;
let h = if is_last {
(bounds.height - y - 1.0).max(0.0)
} else {
lane_h - 2.0
};
frame.fill(
&Path::rectangle(
Point::new(0.0, y + 1.0),
iced::Size::new(bounds.width, h),
),
Color::from_rgba(0.12, 0.26, 0.14, 0.12),
);
y += lane_h + TRACK_SUBTRACK_GAP;
}
});
vec![geom]
}
}
fn track_lane_background_overlay(
height: f32,
header_height: f32,
lane_heights: Vec<f32>,
audio_lanes: usize,
midi_lanes: usize,
) -> Element<'static, Message> {
canvas(TrackLaneBackgroundCanvas {
header_height,
lane_heights,
audio_lanes,
midi_lanes,
})
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}
fn view_track_elements(args: TrackElementViewArgs<'_>) -> Element<'static, Message> {
let TrackElementViewArgs {
state,
track,
session_root,
pixels_per_sample,
samples_per_bar,
active_clip_drag,
active_target_track,
active_target_valid,
active_clip_snap_adjust_samples,
active_clip_snap_targets,
recording_preview_bounds,
recording_preview_peaks,
midi_clip_previews,
..
} = args;
let clip_snap_outline = |clip_width: f32, clip_height: f32, radius: f32| {
container("")
.width(Length::Fixed(clip_width))
.height(Length::Fixed(clip_height))
.style(move |_theme| container::Style {
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
border: Border {
color: Color::from_rgba(1.0, 0.92, 0.18, 0.98),
width: 4.0,
radius: radius.into(),
},
..container::Style::default()
})
};
let mut clips: Vec<Element<'static, Message>> = vec![
mouse_area(container("").width(Length::Fill).height(Length::Fill))
.on_press(Message::DeselectClips)
.into(),
];
let height = if track.is_folder {
track.folder_content_height()
} else {
track.height
};
let layout = track.lane_layout();
let track_name_cloned = track.name.clone();
let mut selected_audio_indices = HashSet::new();
let mut selected_midi_indices = HashSet::new();
for selected in &state.selected_clips {
if selected.track_idx != track_name_cloned {
continue;
}
match selected.kind {
Kind::Audio => {
selected_audio_indices.insert(selected.clip_idx);
}
Kind::MIDI => {
selected_midi_indices.insert(selected.clip_idx);
}
}
}
let selected_audio_count = selected_audio_indices.len();
let selected_midi_count = selected_midi_indices.len();
clips.push(
pin(track_lane_background_overlay(
height,
layout.header_height,
layout.lane_heights.clone(),
if track.is_master { 0 } else { track.audio.ins },
if track.is_master { 0 } else { track.midi.ins },
))
.position(Point::new(0.0, 0.0))
.into(),
);
clips.push(
pin(track_bar_grid_overlay(
height,
samples_per_bar,
pixels_per_sample,
))
.position(Point::new(0.0, 0.0))
.into(),
);
let visible_automation_lanes: Vec<_> = track
.automation_lanes
.iter()
.filter(|lane| lane.visible)
.collect();
if !track.collapsed() {
for (lane_index, lane) in visible_automation_lanes.iter().enumerate() {
let lane_top = track.automation_lane_top(lane_index);
let point_color = automation_point_color(lane.target);
let curve_points: Vec<CurvePoint> = lane
.points
.iter()
.map(|point| CurvePoint {
sample: point.sample,
value: point.value,
})
.collect();
clips.push(
pin(canvas(CurveEditor {
context: (track_name_cloned.clone(), lane.target),
points: curve_points,
pixels_per_sample,
color: point_color,
dot_radius: 2.5,
line_width: 2.0,
})
.width(Length::Fill)
.height(Length::Fixed(
layout.automation_lane_height(lane_index).max(12.0),
)))
.position(Point::new(0.0, lane_top))
.into(),
);
clips.push(
pin(
container(text(format!("Automation {}", lane.target)).size(10))
.width(Length::Shrink)
.height(Length::Fixed(12.0))
.padding([1, 4])
.style(|_theme| container::Style {
background: Some(Background::Color(Color {
r: 0.35,
g: 0.24,
b: 0.12,
a: 0.45,
})),
..container::Style::default()
}),
)
.position(Point::new(4.0, lane_top + 2.0))
.into(),
);
}
}
if !track.is_master && !track.is_folder {
for (index, clip) in track.audio.clips.iter().enumerate() {
let clip_name = clip.name.clone();
let clip_label = format!(
"{}{}{}",
AudioClipWidget::<Message>::clean_name(&clip_name),
if clip.take_lane_pinned { " [P]" } else { "" },
if clip.take_lane_locked { " [L]" } else { "" }
);
let is_selected = selected_audio_indices.contains(&index);
let active_drag = active_clip_drag.filter(|d| {
d.kind == Kind::Audio && d.track_index == track_name_cloned && d.index == index
});
let group_drag = active_clip_drag.filter(|d| {
d.kind == Kind::Audio
&& d.track_index == track_name_cloned
&& selected_audio_indices.contains(&d.index)
&& selected_audio_count > 1
&& is_selected
});
let drag_for_clip = group_drag.or(active_drag);
let dragged_to_other_track = drag_for_clip.is_some_and(|d| {
!d.copy
&& active_target_valid
&& active_target_track
.is_some_and(|target| target != track_name_cloned.as_str())
});
let show_preview_in_this_track = drag_for_clip.is_some_and(|_| {
active_target_track.is_some_and(|target| target == track_name_cloned.as_str())
});
let dragged_start = drag_for_clip
.filter(|d| !d.copy && !show_preview_in_this_track)
.map(|d| {
let delta_samples = (d.end.x - d.start.x) / pixels_per_sample.max(1.0e-6);
clip.start as f32 + delta_samples + active_clip_snap_adjust_samples
})
.unwrap_or(clip.start as f32);
let lane = 0;
let lane_top = track.lane_top(Kind::Audio, lane) + 1.0;
let clip_width = (clip.length as f32 * pixels_per_sample).max(12.0);
let clip_height = (layout.lane_height_for(Kind::Audio, lane) - 2.0).max(8.0);
let display_clip_label =
AudioClipWidget::<Message>::label_for_width(&clip_label, clip_width);
let audio_left_handle_hovered = state.hovered_clip_resize_handle.as_ref().is_some_and(
|(track_idx, clip_idx, kind, is_right_side)| {
track_idx == &track_name_cloned
&& *clip_idx == index
&& *kind == Kind::Audio
&& !*is_right_side
},
);
let audio_right_handle_hovered = state.hovered_clip_resize_handle.as_ref().is_some_and(
|(track_idx, clip_idx, kind, is_right_side)| {
track_idx == &track_name_cloned
&& *clip_idx == index
&& *kind == Kind::Audio
&& *is_right_side
},
);
let stretch_ratio = match &state.resizing {
Some(crate::state::Resizing::Clip {
kind: maolan_engine::kind::Kind::Audio,
track_name: t,
index: i,
stretch_mode: true,
initial_length,
..
}) if t == &track_name_cloned && *i == index => {
(clip.length as f32 / initial_length.max(1.0)).max(0.01)
}
_ => 1.0,
};
if !dragged_to_other_track {
let track_name_for_drag = track_name_cloned.clone();
clips.push(
pin(
AudioClipWidget::new(widget_audio_clip_data(clip, stretch_ratio))
.with_session_root(session_root)
.with_pixels_per_sample(pixels_per_sample)
.with_size(clip_width, clip_height)
.with_label(display_clip_label.clone())
.selected(is_selected)
.hovered_handles(audio_left_handle_hovered, audio_right_handle_hovered)
.interactive(WidgetAudioClipInteraction {
on_select: Message::SelectClip {
track_idx: track_name_cloned.clone(),
clip_idx: index,
kind: Kind::Audio,
},
on_open: Message::OpenClipPlugins {
track_idx: track_name_cloned.clone(),
clip_idx: index,
},
on_drag: (!clip.take_lane_locked).then_some(
Arc::new(move |point| {
let mut clip_data = DraggedClip::new(
Kind::Audio,
index,
track_name_for_drag.clone(),
);
clip_data.start = point;
Message::ClipDrag(clip_data)
})
as Arc<dyn Fn(Point) -> Message + Send + Sync + 'static>,
),
edges: WidgetClipEdgeMessages {
left_hover_enter: Message::ClipResizeHandleHover {
kind: Kind::Audio,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: false,
hovered: true,
},
left_hover_exit: Message::ClipResizeHandleHover {
kind: Kind::Audio,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: false,
hovered: false,
},
left_press: Message::ClipResizeStart(
Kind::Audio,
track_name_cloned.clone(),
index,
false,
),
right_hover_enter: Message::ClipResizeHandleHover {
kind: Kind::Audio,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: true,
hovered: true,
},
right_hover_exit: Message::ClipResizeHandleHover {
kind: Kind::Audio,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: true,
hovered: false,
},
right_press: Message::ClipResizeStart(
Kind::Audio,
track_name_cloned.clone(),
index,
true,
),
},
fade_in_press: Some(Message::FadeResizeStart {
kind: Kind::Audio,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_fade_out: false,
}),
fade_out_press: Some(Message::FadeResizeStart {
kind: Kind::Audio,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_fade_out: true,
}),
})
.into_element(),
)
.position(Point::new(dragged_start * pixels_per_sample, lane_top))
.into(),
);
}
if let Some(drag) = drag_for_clip.filter(|_| show_preview_in_this_track) {
let delta_samples = (drag.end.x - drag.start.x) / pixels_per_sample.max(1.0e-6);
let preview_start =
clip.start as f32 + delta_samples + active_clip_snap_adjust_samples;
let preview_fill = if active_target_valid {
Background::Color(Color {
r: 0.72,
g: 0.86,
b: 1.0,
a: 0.7,
})
} else {
Background::Color(Color {
r: 0.92,
g: 0.32,
b: 0.32,
a: 0.55,
})
};
let preview_border = if active_target_valid {
Color {
r: 0.98,
g: 0.98,
b: 0.98,
a: 0.9,
}
} else {
Color {
r: 1.0,
g: 0.45,
b: 0.45,
a: 0.95,
}
};
clips.push(
pin(AudioClipWidget::new(widget_audio_clip_data(clip, 1.0))
.with_session_root(session_root)
.with_size(clip_width, clip_height)
.with_label(display_clip_label.clone())
.preview(preview_fill, preview_border)
.into_element())
.position(Point::new(preview_start * pixels_per_sample, lane_top))
.into(),
);
}
if active_clip_snap_targets.contains(&crate::state::ClipId {
track_idx: track_name_cloned.clone(),
clip_idx: index,
kind: Kind::Audio,
}) {
clips.push(
pin(clip_snap_outline(clip_width, clip_height, 8.0))
.position(Point::new(clip.start as f32 * pixels_per_sample, lane_top))
.into(),
);
}
}
}
if !track.is_master && !track.is_folder {
for (index, clip) in track.midi.clips.iter().enumerate() {
let clip_name = clip.name.clone();
let clip_label = format!(
"{}{}{}",
MIDIClipWidget::<Message>::clean_name(&clip_name),
if clip.take_lane_pinned { " [P]" } else { "" },
if clip.take_lane_locked { " [L]" } else { "" }
);
let is_selected = selected_midi_indices.contains(&index);
let active_drag = active_clip_drag.filter(|d| {
d.kind == Kind::MIDI && d.track_index == track_name_cloned && d.index == index
});
let group_drag = active_clip_drag.filter(|d| {
d.kind == Kind::MIDI
&& d.track_index == track_name_cloned
&& selected_midi_indices.contains(&d.index)
&& selected_midi_count > 1
&& is_selected
});
let drag_for_clip = group_drag.or(active_drag);
let dragged_to_other_track = drag_for_clip.is_some_and(|d| {
!d.copy
&& active_target_valid
&& active_target_track
.is_some_and(|target| target != track_name_cloned.as_str())
});
let show_preview_in_this_track = drag_for_clip.is_some_and(|_| {
active_target_track.is_some_and(|target| target == track_name_cloned.as_str())
});
let dragged_start = drag_for_clip
.filter(|d| !d.copy && !show_preview_in_this_track)
.map(|d| {
let delta_samples = (d.end.x - d.start.x) / pixels_per_sample.max(1.0e-6);
clip.start as f32 + delta_samples + active_clip_snap_adjust_samples
})
.unwrap_or(clip.start as f32);
let lane = clip.input_channel.min(track.midi.ins.saturating_sub(1));
let lane_top = track.lane_top(Kind::MIDI, lane) + 1.0;
let clip_width = (clip.length as f32 * pixels_per_sample).max(12.0);
let clip_height = (layout.lane_height_for(Kind::MIDI, lane) - 2.0).max(8.0);
let display_clip_label =
MIDIClipWidget::<Message>::label_for_width(&clip_label, clip_width);
let midi_left_handle_hovered = state.hovered_clip_resize_handle.as_ref().is_some_and(
|(track_idx, clip_idx, kind, is_right_side)| {
track_idx == &track_name_cloned
&& *clip_idx == index
&& *kind == Kind::MIDI
&& !*is_right_side
},
);
let midi_right_handle_hovered = state.hovered_clip_resize_handle.as_ref().is_some_and(
|(track_idx, clip_idx, kind, is_right_side)| {
track_idx == &track_name_cloned
&& *clip_idx == index
&& *kind == Kind::MIDI
&& *is_right_side
},
);
let midi_notes_for_clip = midi_clip_previews
.and_then(|map| map.get(&(track_name_cloned.clone(), index)))
.cloned();
if !dragged_to_other_track {
let track_name_for_drag = track_name_cloned.clone();
clips.push(
pin(MIDIClipWidget::new(widget_midi_clip_data(clip))
.with_size(clip_width, clip_height)
.with_label(display_clip_label.clone())
.selected(is_selected)
.hovered_handles(midi_left_handle_hovered, midi_right_handle_hovered)
.with_notes(midi_notes_for_clip.clone())
.interactive(WidgetMIDIClipInteraction {
on_select: Message::SelectClip {
track_idx: track_name_cloned.clone(),
clip_idx: index,
kind: Kind::MIDI,
},
on_open: Message::OpenMidiPiano {
track_idx: track_name_cloned.clone(),
clip_idx: index,
},
on_drag: (!clip.take_lane_locked).then_some(Arc::new(move |point| {
let mut clip_data = DraggedClip::new(
Kind::MIDI,
index,
track_name_for_drag.clone(),
);
clip_data.start = point;
Message::ClipDrag(clip_data)
})
as Arc<dyn Fn(Point) -> Message + Send + Sync + 'static>),
edges: WidgetClipEdgeMessages {
left_hover_enter: Message::ClipResizeHandleHover {
kind: Kind::MIDI,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: false,
hovered: true,
},
left_hover_exit: Message::ClipResizeHandleHover {
kind: Kind::MIDI,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: false,
hovered: false,
},
left_press: Message::ClipResizeStart(
Kind::MIDI,
track_name_cloned.clone(),
index,
false,
),
right_hover_enter: Message::ClipResizeHandleHover {
kind: Kind::MIDI,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: true,
hovered: true,
},
right_hover_exit: Message::ClipResizeHandleHover {
kind: Kind::MIDI,
track_idx: track_name_cloned.clone(),
clip_idx: index,
is_right_side: true,
hovered: false,
},
right_press: Message::ClipResizeStart(
Kind::MIDI,
track_name_cloned.clone(),
index,
true,
),
},
})
.into_element())
.position(Point::new(dragged_start * pixels_per_sample, lane_top))
.into(),
);
}
if let Some(drag) = drag_for_clip.filter(|_| show_preview_in_this_track) {
let delta_samples = (drag.end.x - drag.start.x) / pixels_per_sample.max(1.0e-6);
let preview_start =
clip.start as f32 + delta_samples + active_clip_snap_adjust_samples;
let preview_fill = if active_target_valid {
MIDIClipWidget::<Message>::two_edge_gradient(
MIDI_CLIP_SELECTED_BASE,
0.66,
0.66,
false,
)
} else {
MIDIClipWidget::<Message>::two_edge_gradient(
Color {
r: 0.72,
g: 0.18,
b: 0.18,
a: 1.0,
},
0.72,
0.72,
false,
)
};
let preview_border = if active_target_valid {
Color {
r: 0.88,
g: 1.0,
b: 0.78,
a: 0.92,
}
} else {
Color {
r: 1.0,
g: 0.45,
b: 0.45,
a: 0.95,
}
};
clips.push(
pin(MIDIClipWidget::new(widget_midi_clip_data(clip))
.with_size(clip_width, clip_height)
.with_label(display_clip_label.clone())
.with_notes(midi_notes_for_clip.clone())
.preview(preview_fill, preview_border, 8.0)
.into_element())
.position(Point::new(preview_start * pixels_per_sample, lane_top))
.into(),
);
}
if active_clip_snap_targets.contains(&crate::state::ClipId {
track_idx: track_name_cloned.clone(),
clip_idx: index,
kind: Kind::MIDI,
}) {
clips.push(
pin(clip_snap_outline(clip_width, clip_height, 8.0))
.position(Point::new(clip.start as f32 * pixels_per_sample, lane_top))
.into(),
);
}
}
}
if !track.is_master
&& let Some(drag) = active_clip_drag
&& let Some(target) = active_target_track
&& target == track_name_cloned.as_str()
&& drag.track_index != track_name_cloned
{
let delta_samples = (drag.end.x - drag.start.x) / pixels_per_sample.max(1.0e-6);
if let Some(source_track) = state.tracks.iter().find(|t| t.name == drag.track_index) {
match drag.kind {
Kind::Audio => {
let mut preview_indices: Vec<usize> = state
.selected_clips
.iter()
.filter(|id| {
id.kind == Kind::Audio
&& id.track_idx == drag.track_index
&& id.clip_idx < source_track.audio.clips.len()
})
.map(|id| id.clip_idx)
.collect();
preview_indices.sort_unstable();
preview_indices.dedup();
if preview_indices.len() <= 1 || !preview_indices.contains(&drag.index) {
preview_indices = vec![drag.index];
}
for clip_index in preview_indices {
let Some(source_clip) = source_track.audio.clips.get(clip_index) else {
continue;
};
let preview_fill = if active_target_valid {
AudioClipWidget::<Message>::two_edge_gradient(
AUDIO_CLIP_SELECTED_BASE,
0.7,
0.7,
true,
)
} else {
AudioClipWidget::<Message>::two_edge_gradient(
Color {
r: 0.72,
g: 0.18,
b: 0.18,
a: 1.0,
},
0.72,
0.72,
true,
)
};
let preview_border = if active_target_valid {
Color {
r: 0.98,
g: 0.98,
b: 0.98,
a: 0.9,
}
} else {
Color {
r: 1.0,
g: 0.45,
b: 0.45,
a: 0.95,
}
};
let clip_width = (source_clip.length as f32 * pixels_per_sample).max(12.0);
let clip_height = (layout.lane_height_for(Kind::Audio, 0) - 2.0).max(8.0);
let lane_top = track.lane_top(Kind::Audio, 0) + 1.0;
let preview_start = source_clip.start as f32
+ delta_samples
+ active_clip_snap_adjust_samples;
let display_clip_label = AudioClipWidget::<Message>::label_for_width(
&AudioClipWidget::<Message>::clean_name(&source_clip.name),
clip_width,
);
clips.push(
pin(
AudioClipWidget::new(widget_audio_clip_data(source_clip, 1.0))
.with_session_root(session_root)
.with_size(clip_width, clip_height)
.with_label(display_clip_label)
.preview(
if active_target_valid {
preview_fill
} else {
Background::Color(Color::TRANSPARENT)
},
preview_border,
)
.into_element(),
)
.position(Point::new(preview_start * pixels_per_sample, lane_top))
.into(),
);
}
}
Kind::MIDI => {
let mut preview_indices: Vec<usize> = state
.selected_clips
.iter()
.filter(|id| {
id.kind == Kind::MIDI
&& id.track_idx == drag.track_index
&& id.clip_idx < source_track.midi.clips.len()
})
.map(|id| id.clip_idx)
.collect();
preview_indices.sort_unstable();
preview_indices.dedup();
if preview_indices.len() <= 1 || !preview_indices.contains(&drag.index) {
preview_indices = vec![drag.index];
}
for clip_index in preview_indices {
let Some(source_clip) = source_track.midi.clips.get(clip_index) else {
continue;
};
let preview_fill = if active_target_valid {
MIDIClipWidget::<Message>::two_edge_gradient(
MIDI_CLIP_SELECTED_BASE,
0.7,
0.7,
false,
)
} else {
MIDIClipWidget::<Message>::two_edge_gradient(
Color {
r: 0.72,
g: 0.18,
b: 0.18,
a: 1.0,
},
0.72,
0.72,
false,
)
};
let preview_border = if active_target_valid {
Color {
r: 0.98,
g: 0.98,
b: 0.98,
a: 0.9,
}
} else {
Color {
r: 1.0,
g: 0.45,
b: 0.45,
a: 0.95,
}
};
let clip_width = (source_clip.length as f32 * pixels_per_sample).max(12.0);
let lane = source_clip
.input_channel
.min(track.midi.ins.saturating_sub(1));
let lane_top = track.lane_top(Kind::MIDI, lane) + 1.0;
let preview_start = source_clip.start as f32
+ delta_samples
+ active_clip_snap_adjust_samples;
let display_clip_label = MIDIClipWidget::<Message>::label_for_width(
&MIDIClipWidget::<Message>::clean_name(&source_clip.name),
clip_width,
);
clips.push(
pin(MIDIClipWidget::new(widget_midi_clip_data(source_clip))
.with_size(
clip_width,
(layout.lane_height_for(Kind::MIDI, lane) - 2.0).max(8.0),
)
.with_label(display_clip_label)
.preview(
if active_target_valid {
preview_fill
} else {
Background::Color(Color::TRANSPARENT)
},
preview_border,
8.0,
)
.into_element())
.position(Point::new(preview_start * pixels_per_sample, lane_top))
.into(),
);
}
}
}
}
}
if track.armed
&& let Some((preview_start, preview_current)) = recording_preview_bounds
&& preview_current > preview_start
{
let preview_width =
((preview_current - preview_start) as f32 * pixels_per_sample).max(12.0);
let preview_top = track.lane_top(Kind::Audio, 0) + 1.0;
let preview_peaks = recording_preview_peaks
.and_then(|map| map.get(&track.name))
.cloned()
.unwrap_or_default();
let preview_length = preview_current - preview_start;
let preview_clip = container(
container(Stack::with_children(vec![
AudioClipWidget::<Message>::waveform_overlay(
preview_peaks,
None,
0,
preview_length,
preview_length,
preview_length,
),
container(text("REC").size(12))
.width(Length::Fill)
.height(Length::Fill)
.padding(5)
.into(),
]))
.width(Length::Fill)
.height(Length::Fill)
.padding(0)
.style(|_theme| container::Style {
background: Some(Background::Color(Color {
r: 0.85,
g: 0.25,
b: 0.25,
a: 0.35,
})),
..container::Style::default()
}),
)
.width(Length::Fixed(preview_width))
.height(Length::Fill)
.style(|_theme| container::Style {
background: None,
border: Border {
color: Color {
r: 0.9,
g: 0.3,
b: 0.3,
a: 0.9,
},
width: 1.0,
radius: 8.0.into(),
},
..container::Style::default()
});
clips.push(
pin(preview_clip)
.position(Point::new(
preview_start as f32 * pixels_per_sample,
preview_top,
))
.into(),
);
}
let lane_divider_h = 6.0_f32;
let visual_lane_count = layout.lane_heights.len();
if !track.collapsed() && visual_lane_count >= 2 {
let mut y = layout.header_height;
for divider in 0..(visual_lane_count - 1) {
y += layout.lane_heights[divider] + TRACK_SUBTRACK_GAP;
let handle_y = (y - TRACK_SUBTRACK_GAP / 2.0 - lane_divider_h / 2.0).max(0.0);
let handle = mouse_area(
container("")
.width(Length::Fill)
.height(Length::Fixed(lane_divider_h)),
)
.interaction(mouse::Interaction::ResizingVertically)
.on_press(Message::TrackLaneResizeStart {
track_name: track_name_cloned.clone(),
divider,
})
.on_double_click(Message::TrackLaneDividerReset {
track_name: track_name_cloned.clone(),
divider,
});
clips.push(pin(handle).position(Point::new(0.0, handle_y)).into());
}
}
container(
Stack::from_vec(clips)
.height(Length::Fill)
.width(Length::Fill),
)
.id(track_name_cloned)
.width(Length::Fill)
.height(Length::Fixed(height))
.style(|_theme| container::Style {
background: Some(Background::Color(Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.0,
})),
border: Border {
color: Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
width: 1.0,
radius: 0.0.into(),
},
..container::Style::default()
})
.into()
}
fn selected_session_slot_for_track(state: &StateData, track_name: &str) -> Option<usize> {
state
.selected_slots
.iter()
.find(|(name, _)| name == track_name)
.map(|(_, scene_index)| *scene_index)
}
pub(super) fn clip_context_menu_overlay(
state: &StateData,
transport_active: bool,
) -> Option<(Point, Element<'static, Message>)> {
fn can_group_selected_clips(state: &StateData, menu_clip: &crate::state::ClipId) -> bool {
let mut selected: Vec<_> = state.selected_clips.iter().collect();
if selected.len() < 2 {
return false;
}
if !selected.contains(&menu_clip) {
return false;
}
selected.sort_by_key(|clip| clip.clip_idx);
let Some(first) = selected.first() else {
return false;
};
if selected
.iter()
.any(|clip| clip.track_idx != first.track_idx || clip.kind != first.kind)
{
return false;
}
let Some(track) = state
.tracks
.iter()
.find(|track| track.name == first.track_idx)
else {
return false;
};
match first.kind {
Kind::Audio => selected.iter().all(|clip| {
track
.audio
.clips
.get(clip.clip_idx)
.is_some_and(|clip| !clip.is_group())
}),
Kind::MIDI => selected.iter().all(|clip| {
track
.midi
.clips
.get(clip.clip_idx)
.is_some_and(|clip| !clip.is_group())
}),
}
}
let menu = state.clip_context_menu.as_ref()?;
let clip = &menu.clip;
let track = state.tracks.iter().find(|t| t.name == clip.track_idx)?;
let content: Element<'static, Message> = match clip.kind {
Kind::Audio => {
let clip_ref = track.audio.clips.get(clip.clip_idx)?;
let fade_enabled = clip_ref.fade_enabled;
let muted = clip_ref.muted;
let is_group = clip_ref.is_group();
let track_idx = clip.track_idx.clone();
let clip_idx = clip.clip_idx;
column![
crate::menu::menu_item_maybe(
if is_group { "Ungroup" } else { "Group" },
if is_group {
Some(Message::UngroupClip {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::Audio,
})
} else {
can_group_selected_clips(state, clip).then_some(Message::GroupSelectedClips)
},
),
crate::menu::menu_item(
"Rename",
Message::ClipRenameShow {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::Audio,
},
),
crate::menu::menu_item(
if muted { "Unmute" } else { "Mute" },
Message::ClipSetMuted {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::Audio,
muted: !muted,
},
),
crate::menu::menu_item_maybe(
"Pitch Correction",
(!transport_active).then_some(Message::ClipOpenPitchCorrection {
track_idx: track_idx.clone(),
clip_idx,
}),
),
crate::menu::menu_item_maybe(
"Export Pitch MIDI",
(!clip_ref.pitch_correction_points.is_empty()).then_some(
Message::ClipExportPitchCorrectionMidi {
track_idx: track_idx.clone(),
clip_idx,
},
),
),
crate::menu::menu_item(
if fade_enabled {
"Disable Fade"
} else {
"Enable Fade"
},
Message::ClipToggleFade {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::Audio,
},
),
crate::menu::menu_item_maybe(
"Assign to session slot",
selected_session_slot_for_track(state, &track_idx).map(|_| {
Message::ClipAssignToSessionSlot {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::Audio,
}
}),
),
]
.spacing(2)
.into()
}
Kind::MIDI => {
let clip_ref = track.midi.clips.get(clip.clip_idx)?;
let muted = clip_ref.muted;
let is_group = clip_ref.is_group();
let track_idx = clip.track_idx.clone();
let clip_idx = clip.clip_idx;
column![
crate::menu::menu_item_maybe(
if is_group { "Ungroup" } else { "Group" },
if is_group {
Some(Message::UngroupClip {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::MIDI,
})
} else {
can_group_selected_clips(state, clip).then_some(Message::GroupSelectedClips)
},
),
crate::menu::menu_item(
"Rename",
Message::ClipRenameShow {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::MIDI,
},
),
crate::menu::menu_item(
if muted { "Unmute" } else { "Mute" },
Message::ClipSetMuted {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::MIDI,
muted: !muted,
},
),
crate::menu::menu_item_maybe(
"Assign to session slot",
selected_session_slot_for_track(state, &track_idx).map(|_| {
Message::ClipAssignToSessionSlot {
track_idx: track_idx.clone(),
clip_idx,
kind: Kind::MIDI,
}
}),
),
]
.spacing(2)
.into()
}
};
let panel = container(content)
.width(Length::Fixed(210.0))
.padding(6)
.style(|theme| {
let palette = theme.extended_palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
color: palette.background.strong.color,
width: 1.0,
radius: 6.0.into(),
},
..container::Style::default()
}
})
.into();
Some((menu.anchor, panel))
}
#[derive(Debug, Clone)]
pub struct Editor {
state: State,
}
pub struct EditorViewArgs<'a> {
pub session_root: Option<&'a PathBuf>,
pub pixels_per_sample: f32,
pub samples_per_bar: f32,
pub snap_mode: SnapMode,
pub samples_per_beat: f64,
pub active_clip_drag: Option<&'a DraggedClip>,
pub active_target_track: Option<&'a str>,
pub active_target_valid: bool,
pub active_clip_snap_adjust_samples: f32,
pub active_clip_snap_targets: &'a [crate::state::ClipId],
pub recording_preview_bounds: Option<(usize, usize)>,
pub recording_preview_peaks: Option<&'a HashMap<String, ClipPeaks>>,
pub midi_clip_previews: Option<&'a MidiClipPreviewMap>,
pub visible_track_window: VisibleTrackWindow,
}
#[derive(Clone)]
pub struct OwnedEditorViewArgs {
pub session_root: Option<PathBuf>,
pub pixels_per_sample: f32,
pub samples_per_bar: f32,
pub active_clip_drag: Option<DraggedClip>,
pub active_target_track: Option<String>,
pub active_target_valid: bool,
pub active_clip_snap_adjust_samples: f32,
pub active_clip_snap_targets: Vec<crate::state::ClipId>,
pub recording_preview_bounds: Option<(usize, usize)>,
pub recording_preview_peaks: Option<HashMap<String, ClipPeaks>>,
pub midi_clip_previews: Option<MidiClipPreviewMap>,
pub visible_track_window: VisibleTrackWindow,
}
impl Editor {
pub fn new(state: State) -> Self {
Self { state }
}
pub fn render_hash(&self, args: &EditorViewArgs<'_>) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let state = self.state.blocking_read();
args.session_root.hash(&mut hasher);
args.pixels_per_sample.to_bits().hash(&mut hasher);
args.samples_per_bar.to_bits().hash(&mut hasher);
args.samples_per_beat.to_bits().hash(&mut hasher);
std::mem::discriminant(&args.snap_mode).hash(&mut hasher);
args.recording_preview_bounds.hash(&mut hasher);
args.active_target_track.hash(&mut hasher);
args.active_target_valid.hash(&mut hasher);
args.active_clip_snap_adjust_samples
.to_bits()
.hash(&mut hasher);
args.active_clip_snap_targets.hash(&mut hasher);
args.visible_track_window.start_index.hash(&mut hasher);
args.visible_track_window.end_index.hash(&mut hasher);
args.visible_track_window
.top_padding
.to_bits()
.hash(&mut hasher);
args.visible_track_window
.bottom_padding
.to_bits()
.hash(&mut hasher);
if let Some(drag) = args.active_clip_drag {
std::mem::discriminant(&drag.kind).hash(&mut hasher);
drag.index.hash(&mut hasher);
drag.track_index.hash(&mut hasher);
drag.start.x.to_bits().hash(&mut hasher);
drag.start.y.to_bits().hash(&mut hasher);
drag.end.x.to_bits().hash(&mut hasher);
drag.end.y.to_bits().hash(&mut hasher);
drag.copy.hash(&mut hasher);
}
let mut selected_clips: Vec<_> = state.selected_clips.iter().collect();
selected_clips.sort_by(|a, b| {
a.track_idx
.cmp(&b.track_idx)
.then_with(|| {
let ak = match a.kind {
Kind::Audio => 0_u8,
Kind::MIDI => 1_u8,
};
let bk = match b.kind {
Kind::Audio => 0_u8,
Kind::MIDI => 1_u8,
};
ak.cmp(&bk)
})
.then_with(|| a.clip_idx.cmp(&b.clip_idx))
});
for clip in selected_clips {
clip.track_idx.hash(&mut hasher);
clip.clip_idx.hash(&mut hasher);
std::mem::discriminant(&clip.kind).hash(&mut hasher);
}
state.hovered_clip_resize_handle.hash(&mut hasher);
state
.clip_marquee_start
.map(|p| (p.x.to_bits(), p.y.to_bits()))
.hash(&mut hasher);
state
.clip_marquee_end
.map(|p| (p.x.to_bits(), p.y.to_bits()))
.hash(&mut hasher);
state
.midi_clip_create_start
.map(|p| (p.x.to_bits(), p.y.to_bits()))
.hash(&mut hasher);
state
.midi_clip_create_end
.map(|p| (p.x.to_bits(), p.y.to_bits()))
.hash(&mut hasher);
state
.cut_indicator
.map(|(x, y, h)| (x.to_bits(), y.to_bits(), h.to_bits()))
.hash(&mut hasher);
if let Some(menu) = state.clip_context_menu.as_ref() {
menu.clip.track_idx.hash(&mut hasher);
menu.clip.clip_idx.hash(&mut hasher);
std::mem::discriminant(&menu.clip.kind).hash(&mut hasher);
menu.anchor.x.to_bits().hash(&mut hasher);
menu.anchor.y.to_bits().hash(&mut hasher);
}
let root_nodes = build_editor_track_tree(&state.tracks);
for root in root_nodes
.iter()
.skip(args.visible_track_window.start_index)
.take(
args.visible_track_window
.end_index
.saturating_sub(args.visible_track_window.start_index),
)
{
hash_track_node(root, &mut hasher);
}
if let Some(peaks_by_track) = args.recording_preview_peaks {
let mut keys: Vec<_> = peaks_by_track.keys().collect();
keys.sort_unstable();
for key in keys {
key.hash(&mut hasher);
if let Some(peaks) = peaks_by_track.get(key) {
peaks.len().hash(&mut hasher);
for channel in peaks.iter() {
channel.len().hash(&mut hasher);
if let Some(first) = channel.first() {
first[0].to_bits().hash(&mut hasher);
first[1].to_bits().hash(&mut hasher);
}
if let Some(last) = channel.last() {
last[0].to_bits().hash(&mut hasher);
last[1].to_bits().hash(&mut hasher);
}
}
}
}
}
if let Some(previews) = args.midi_clip_previews {
let mut keys: Vec<_> = previews.keys().collect();
keys.sort_unstable();
for (track_name, clip_index) in keys {
track_name.hash(&mut hasher);
clip_index.hash(&mut hasher);
if let Some(notes) = previews.get(&(track_name.clone(), *clip_index)) {
notes.len().hash(&mut hasher);
if let Some(first) = notes.first() {
first.start_sample.hash(&mut hasher);
first.length_samples.hash(&mut hasher);
first.pitch.hash(&mut hasher);
first.velocity.hash(&mut hasher);
}
if let Some(last) = notes.last() {
last.start_sample.hash(&mut hasher);
last.length_samples.hash(&mut hasher);
last.pitch.hash(&mut hasher);
last.velocity.hash(&mut hasher);
}
}
}
}
hasher.finish()
}
pub fn into_view_owned(self, args: OwnedEditorViewArgs) -> Element<'static, Message> {
let OwnedEditorViewArgs {
session_root,
pixels_per_sample,
samples_per_bar,
active_clip_drag,
active_target_track,
active_target_valid,
active_clip_snap_adjust_samples,
active_clip_snap_targets,
recording_preview_bounds,
recording_preview_peaks,
midi_clip_previews,
visible_track_window,
} = args;
let state_handle = self.state;
let session_root_ref = session_root.as_ref();
let active_clip_drag_ref = active_clip_drag.as_ref();
let active_target_track_ref = active_target_track.as_deref();
let recording_preview_peaks_ref = recording_preview_peaks.as_ref();
let midi_clip_previews_ref = midi_clip_previews.as_ref();
let mut result = column![];
if visible_track_window.top_padding > 0.0 {
result =
result.push(Space::new().height(Length::Fixed(visible_track_window.top_padding)));
}
let state = state_handle.blocking_read();
let root_nodes = build_editor_track_tree(&state.tracks);
for root in root_nodes
.iter()
.skip(visible_track_window.start_index)
.take(
visible_track_window
.end_index
.saturating_sub(visible_track_window.start_index),
)
{
let track_node_args = TrackNodeViewArgs {
state: &state,
session_root: session_root_ref,
pixels_per_sample,
samples_per_bar,
active_clip_drag: active_clip_drag_ref,
active_target_track: active_target_track_ref,
active_target_valid,
active_clip_snap_adjust_samples,
active_clip_snap_targets: &active_clip_snap_targets,
recording_preview_bounds,
recording_preview_peaks: recording_preview_peaks_ref,
midi_clip_previews: midi_clip_previews_ref,
};
result = result.push(view_track_node(root, &track_node_args));
}
if visible_track_window.bottom_padding > 0.0 {
result = result
.push(Space::new().height(Length::Fixed(visible_track_window.bottom_padding)));
}
let mut layers: Vec<Element<'static, Message>> =
vec![result.width(Length::Fill).height(Length::Fill).into()];
if let (Some(start), Some(end)) = (state.clip_marquee_start, state.clip_marquee_end) {
let mut x = start.x.min(end.x);
let mut y = start.y.min(end.y);
let mut w = (start.x - end.x).abs();
let mut h = (start.y - end.y).abs();
if w > 1.0 || h > 1.0 {
w = w.max(2.0);
h = h.max(2.0);
x = x.max(0.0);
y = y.max(0.0);
layers.push(
pin(container("")
.width(Length::Fixed(w))
.height(Length::Fixed(h))
.style(|_theme| container::Style {
background: Some(Background::Color(Color {
r: 0.45,
g: 0.75,
b: 1.0,
a: 0.12,
})),
border: Border {
color: Color {
r: 0.65,
g: 0.85,
b: 1.0,
a: 0.95,
},
width: 1.0,
radius: 0.0.into(),
},
..container::Style::default()
}))
.position(Point::new(x, y))
.into(),
);
}
}
if let (Some(start), Some(end)) = (state.midi_clip_create_start, state.midi_clip_create_end)
{
let x = start.x.min(end.x).max(0.0);
let y = start.y.min(end.y).max(0.0);
let w = (start.x - end.x).abs().max(2.0);
let h = (start.y - end.y).abs().max(2.0);
layers.push(
pin(container("")
.width(Length::Fixed(w))
.height(Length::Fixed(h))
.style(|_theme| container::Style {
background: Some(Background::Color(Color {
r: 0.5,
g: 0.9,
b: 0.55,
a: 0.18,
})),
border: Border {
color: Color {
r: 0.7,
g: 1.0,
b: 0.72,
a: 0.95,
},
width: 1.0,
radius: 8.0.into(),
},
..container::Style::default()
}))
.position(Point::new(x, y))
.into(),
);
}
if let Some((x, y, h)) = state.cut_indicator {
layers.push(
pin(container("")
.width(Length::Fixed(1.0))
.height(Length::Fixed(h))
.style(|_theme| container::Style {
background: Some(Background::Color(Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 0.3,
})),
..container::Style::default()
}))
.position(Point::new(x.max(0.0), y))
.into(),
);
}
let hovered_resize_handle = state.hovered_clip_resize_handle.clone();
let editor_surface = mouse_area(
Stack::from_vec(layers)
.width(Length::Fill)
.height(Length::Fill),
)
.on_move(Message::EditorMouseMoved)
.on_press(
hovered_resize_handle
.as_ref()
.map(|(track_name, clip_index, kind, is_right_side)| {
Message::ClipResizeStart(*kind, track_name.clone(), *clip_index, *is_right_side)
})
.unwrap_or(Message::DeselectClips),
)
.on_enter(Message::ShortcutsHint(Some("Marquee select".to_string())))
.on_exit(Message::ShortcutsHint(None));
let editor_surface = if hovered_resize_handle.is_some() {
editor_surface.interaction(mouse::Interaction::Pointer)
} else {
editor_surface
};
container(editor_surface)
.style(|_theme| crate::style::app_background())
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automation_point_color_returns_correct_colors() {
use crate::message::TrackAutomationTarget;
let volume_color = automation_point_color(TrackAutomationTarget::Volume);
let balance_color = automation_point_color(TrackAutomationTarget::Balance);
let midi_cc_color =
automation_point_color(TrackAutomationTarget::MidiCc { channel: 0, cc: 7 });
assert!(volume_color.a > 0.0);
assert!(balance_color.a > 0.0);
assert!(midi_cc_color.a > 0.0);
assert_ne!(volume_color.r, balance_color.r);
}
#[test]
fn automation_point_color_plugin_targets_have_colors() {
use crate::message::TrackAutomationTarget;
let lv2_color = automation_point_color(TrackAutomationTarget::Lv2Parameter {
instance_id: 1,
index: 0,
min: 0.0,
max: 1.0,
});
let vst3_color = automation_point_color(TrackAutomationTarget::Vst3Parameter {
instance_id: 1,
param_id: 0,
});
let clap_color = automation_point_color(TrackAutomationTarget::ClapParameter {
instance_id: 1,
param_id: 0,
min: 0.0,
max: 1.0,
});
assert!(lv2_color.a > 0.0);
assert!(vst3_color.a > 0.0);
assert!(clap_color.a > 0.0);
}
}