use super::{ClipSnapEdge, timeline_sample_to_x, timeline_x_to_sample_f32};
use crate::consts::workspace::{
CONTEXT_MENU_ITEM_HEIGHT, CONTEXT_MENU_WIDTH, LEFT_HIT_WIDTH, TEMPO_HEIGHT, TEMPO_HIT_HEIGHT,
TIME_SIG_HIT_X_SPLIT,
};
use crate::message::{Message, SnapMode};
use iced::{
Color, Element, Length, Point, Rectangle, Renderer, Theme,
event::Event,
mouse,
widget::canvas,
widget::canvas::{Action as CanvasAction, Frame, Geometry, Path, Stroke, Text},
};
use maolan_engine::message::Action as EngineAction;
use std::cell::Cell;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn clip_kind_key(kind: maolan_engine::kind::Kind) -> u8 {
match kind {
maolan_engine::kind::Kind::Audio => 0,
maolan_engine::kind::Kind::MIDI => 1,
}
}
#[derive(Debug, Default)]
pub struct Tempo;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkerLane {
Tempo,
TimeSignature,
}
#[derive(Debug, Clone)]
enum DragMode {
None,
Punch {
drag_start_x: f32,
last_x: f32,
},
AdjustPunchEdge {
adjust_start: bool,
fixed_sample: usize,
current_sample: usize,
},
Marker {
lane: MarkerLane,
original_samples: Vec<usize>,
start_sample: usize,
current_sample: usize,
},
}
#[derive(Debug, Clone, Copy)]
struct ContextMenuState {
lane: MarkerLane,
x: f32,
y: f32,
}
#[derive(Debug)]
struct TempoState {
drag_mode: DragMode,
context_menu: Option<ContextMenuState>,
cache: canvas::Cache,
last_hash: Cell<u64>,
}
impl Default for TempoState {
fn default() -> Self {
Self {
drag_mode: DragMode::None,
context_menu: None,
cache: canvas::Cache::default(),
last_hash: Cell::new(0),
}
}
}
#[derive(Debug, Clone)]
struct TempoCanvas {
bpm: f32,
time_signature: (u8, u8),
pixels_per_sample: f32,
playhead_x: Option<f32>,
punch_range_samples: Option<(usize, usize)>,
clip_snap_edges: Vec<ClipSnapEdge>,
snap_mode: SnapMode,
samples_per_beat: f64,
samples_per_bar: f64,
shift_pressed: bool,
tempo_points: Vec<(usize, f32)>,
time_signature_points: Vec<(usize, u8, u8)>,
selected_tempo_points: Vec<usize>,
selected_time_signature_points: Vec<usize>,
timeline_left_inset_px: f32,
clip_start_samples: usize,
}
#[derive(Debug, Clone)]
pub struct TempoViewArgs {
pub bpm: f32,
pub time_signature: (u8, u8),
pub pixels_per_sample: f32,
pub playhead_x: Option<f32>,
pub punch_range_samples: Option<(usize, usize)>,
pub clip_snap_edges: Vec<ClipSnapEdge>,
pub snap_mode: SnapMode,
pub samples_per_beat: f64,
pub samples_per_bar: f64,
pub content_width: f32,
pub shift_pressed: bool,
pub tempo_points: Vec<(usize, f32)>,
pub time_signature_points: Vec<(usize, u8, u8)>,
pub selected_tempo_points: Vec<usize>,
pub selected_time_signature_points: Vec<usize>,
pub timeline_left_inset_px: f32,
pub clip_start_samples: usize,
}
impl Tempo {
const RANGE_EDGE_HIT_PX: f32 = 8.0;
pub fn new() -> Self {
Self
}
pub fn height(&self) -> f32 {
TEMPO_HEIGHT
}
fn snap_mode_key(mode: SnapMode) -> u8 {
match mode {
SnapMode::NoSnap => 0,
SnapMode::Clips => 1,
SnapMode::Bar => 2,
SnapMode::Beat => 3,
SnapMode::Eighth => 4,
SnapMode::Sixteenth => 5,
SnapMode::ThirtySecond => 6,
SnapMode::SixtyFourth => 7,
}
}
fn lane_key(lane: MarkerLane) -> u8 {
match lane {
MarkerLane::Tempo => 0,
MarkerLane::TimeSignature => 1,
}
}
pub fn view(&self, args: TempoViewArgs) -> Element<'_, Message> {
canvas(TempoCanvas {
bpm: args.bpm,
time_signature: args.time_signature,
pixels_per_sample: args.pixels_per_sample,
playhead_x: args.playhead_x,
punch_range_samples: args.punch_range_samples,
clip_snap_edges: args.clip_snap_edges,
snap_mode: args.snap_mode,
samples_per_beat: args.samples_per_beat,
samples_per_bar: args.samples_per_bar,
shift_pressed: args.shift_pressed,
tempo_points: args.tempo_points,
time_signature_points: args.time_signature_points,
selected_tempo_points: args.selected_tempo_points,
selected_time_signature_points: args.selected_time_signature_points,
timeline_left_inset_px: args.timeline_left_inset_px,
clip_start_samples: args.clip_start_samples,
})
.width(Length::Fixed(args.content_width.max(1.0)))
.height(Length::Fill)
.into()
}
}
impl canvas::Program<Message> for TempoCanvas {
type State = TempoState;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<CanvasAction<Message>> {
let signed_step = |v: f32| if v >= 0.0 { 1_i8 } else { -1_i8 };
let sample_at_x = |x: f32| {
timeline_x_to_sample_f32(x, self.pixels_per_sample, self.timeline_left_inset_px)
.round()
.max(0.0) as usize
};
let snap_sample = |sample: usize| {
let interval = match self.snap_mode {
SnapMode::NoSnap => 1.0,
SnapMode::Clips => 1.0,
SnapMode::Bar => self.samples_per_bar.max(1.0),
SnapMode::Beat => self.samples_per_beat.max(1.0),
SnapMode::Eighth => (self.samples_per_beat / 2.0).max(1.0),
SnapMode::Sixteenth => (self.samples_per_beat / 4.0).max(1.0),
SnapMode::ThirtySecond => (self.samples_per_beat / 8.0).max(1.0),
SnapMode::SixtyFourth => (self.samples_per_beat / 16.0).max(1.0),
};
if matches!(self.snap_mode, SnapMode::Clips) {
let threshold_samples = (12.0 / self.pixels_per_sample.max(1.0e-6)).max(1.0);
let mut snap_targets = self
.clip_snap_edges
.iter()
.filter_map(|edge| {
let distance = (sample as f32 - edge.sample as f32).abs();
(distance <= threshold_samples).then_some((distance, edge))
})
.map(|(_, edge)| edge.clip_id.clone())
.collect::<Vec<_>>();
snap_targets.sort_unstable_by(|a, b| {
a.track_idx
.cmp(&b.track_idx)
.then_with(|| a.clip_idx.cmp(&b.clip_idx))
.then_with(|| clip_kind_key(a.kind).cmp(&clip_kind_key(b.kind)))
});
snap_targets.dedup();
let snapped_sample = self
.clip_snap_edges
.iter()
.filter_map(|edge| {
let distance = (sample as f32 - edge.sample as f32).abs();
(distance <= threshold_samples).then_some((distance, edge.sample))
})
.min_by(|(a, a_edge), (b, b_edge)| {
a.partial_cmp(b)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a_edge.cmp(b_edge))
})
.map(|(_, edge)| edge)
.unwrap_or(sample);
(snapped_sample, snap_targets)
} else if matches!(self.snap_mode, SnapMode::NoSnap) {
(sample, Vec::new())
} else {
(
((sample as f64 / interval).round() * interval).max(0.0) as usize,
Vec::new(),
)
}
};
let nearest_tempo_point_at_x = |x: f32| -> Option<usize> {
self.tempo_points
.iter()
.filter(|(sample, _)| *sample > 0)
.map(|(sample, _)| *sample)
.min_by(|a, b| {
let ax = timeline_sample_to_x(
*a,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
let bx = timeline_sample_to_x(
*b,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
(ax - x)
.abs()
.partial_cmp(&(bx - x).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.filter(|sample| {
let sx = timeline_sample_to_x(
*sample,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
(sx - x).abs() <= 6.0
})
};
let nearest_tsig_point_at_x = |x: f32| -> Option<usize> {
self.time_signature_points
.iter()
.filter(|(sample, _, _)| *sample > 0)
.map(|(sample, _, _)| *sample)
.min_by(|a, b| {
let ax = timeline_sample_to_x(
*a,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
let bx = timeline_sample_to_x(
*b,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
(ax - x)
.abs()
.partial_cmp(&(bx - x).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.filter(|sample| {
let sx = timeline_sample_to_x(
*sample,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
(sx - x).abs() <= 6.0
})
};
let cursor_position = cursor.position_in(bounds);
let cursor_x = cursor
.position()
.map(|pos| (pos.x - bounds.x).clamp(0.0, bounds.width.max(0.0)));
let context_menu_hit = |pos: Point, menu: ContextMenuState| -> Option<usize> {
if pos.x < menu.x
|| pos.x > menu.x + CONTEXT_MENU_WIDTH
|| pos.y < menu.y
|| pos.y > menu.y + CONTEXT_MENU_ITEM_HEIGHT * 3.0
{
return None;
}
Some(
((pos.y - menu.y) / CONTEXT_MENU_ITEM_HEIGHT)
.floor()
.max(0.0) as usize,
)
};
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(pos) = cursor_position {
if let Some(menu) = state.context_menu
&& let Some(item_idx) = context_menu_hit(pos, menu)
{
state.context_menu = None;
return Some(match (menu.lane, item_idx) {
(MarkerLane::Tempo, 0) => {
CanvasAction::publish(Message::TempoSelectionDuplicate)
.and_capture()
}
(MarkerLane::Tempo, 1) => {
CanvasAction::publish(Message::TempoSelectionResetToPrevious)
.and_capture()
}
(MarkerLane::Tempo, _) => {
CanvasAction::publish(Message::TempoSelectionDelete).and_capture()
}
(MarkerLane::TimeSignature, 0) => {
CanvasAction::publish(Message::TimeSignatureSelectionDuplicate)
.and_capture()
}
(MarkerLane::TimeSignature, 1) => CanvasAction::publish(
Message::TimeSignatureSelectionResetToPrevious,
)
.and_capture(),
(MarkerLane::TimeSignature, _) => {
CanvasAction::publish(Message::TimeSignatureSelectionDelete)
.and_capture()
}
});
}
state.context_menu = None;
if pos.x <= LEFT_HIT_WIDTH {
return Some(CanvasAction::capture());
}
if pos.y <= TEMPO_HIT_HEIGHT
&& let Some(sample) = nearest_tempo_point_at_x(pos.x)
{
let sample_selected = self.selected_tempo_points.contains(&sample);
let mut originals = if sample_selected && !self.shift_pressed {
self.selected_tempo_points.to_vec()
} else {
vec![sample]
};
originals.sort_unstable();
originals.dedup();
state.drag_mode = DragMode::Marker {
lane: MarkerLane::Tempo,
original_samples: originals,
start_sample: sample,
current_sample: sample,
};
if !self.shift_pressed && sample_selected {
return Some(CanvasAction::capture());
}
return Some(
CanvasAction::publish(Message::TempoPointSelect {
sample,
additive: self.shift_pressed,
})
.and_capture(),
);
}
if pos.y > TEMPO_HIT_HEIGHT
&& let Some(sample) = nearest_tsig_point_at_x(pos.x)
{
let sample_selected = self.selected_time_signature_points.contains(&sample);
let mut originals = if sample_selected && !self.shift_pressed {
self.selected_time_signature_points.to_vec()
} else {
vec![sample]
};
originals.sort_unstable();
originals.dedup();
state.drag_mode = DragMode::Marker {
lane: MarkerLane::TimeSignature,
original_samples: originals,
start_sample: sample,
current_sample: sample,
};
if !self.shift_pressed && sample_selected {
return Some(CanvasAction::capture());
}
return Some(
CanvasAction::publish(Message::TimeSignaturePointSelect {
sample,
additive: self.shift_pressed,
})
.and_capture(),
);
}
let x = cursor_x.unwrap_or(pos.x.clamp(0.0, bounds.width.max(0.0)));
state.drag_mode = DragMode::Punch {
drag_start_x: x,
last_x: x,
};
return Some(
CanvasAction::publish(Message::ClearTimingPointSelection).and_capture(),
);
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let Some(x) = cursor_x {
match &mut state.drag_mode {
DragMode::None => {}
DragMode::Punch { last_x, .. } => {
*last_x = x;
if matches!(self.snap_mode, SnapMode::Clips) {
let drag_start_x = match state.drag_mode {
DragMode::Punch { drag_start_x, .. } => drag_start_x,
_ => x,
};
let start_x = drag_start_x.min(x).max(0.0);
let end_x = drag_start_x.max(x).max(0.0);
let mut snap_targets = snap_sample(sample_at_x(start_x)).1;
snap_targets.extend(snap_sample(sample_at_x(end_x)).1);
snap_targets.sort_unstable_by(|a, b| {
a.track_idx
.cmp(&b.track_idx)
.then_with(|| a.clip_idx.cmp(&b.clip_idx))
.then_with(|| {
clip_kind_key(a.kind).cmp(&clip_kind_key(b.kind))
})
});
snap_targets.dedup();
return Some(
CanvasAction::publish(Message::SetClipSnapTargets(
snap_targets,
))
.and_capture(),
);
}
return Some(CanvasAction::request_redraw().and_capture());
}
DragMode::AdjustPunchEdge { current_sample, .. } => {
let (snapped_sample, snap_targets) = snap_sample(sample_at_x(x));
*current_sample = snapped_sample;
if matches!(self.snap_mode, SnapMode::Clips) {
return Some(
CanvasAction::publish(Message::SetClipSnapTargets(
snap_targets,
))
.and_capture(),
);
}
return Some(CanvasAction::request_redraw().and_capture());
}
DragMode::Marker { current_sample, .. } => {
*current_sample = snap_sample(sample_at_x(x)).0;
return Some(CanvasAction::request_redraw().and_capture());
}
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let drag_mode = std::mem::replace(&mut state.drag_mode, DragMode::None);
match drag_mode {
DragMode::None => {}
DragMode::Punch {
drag_start_x,
last_x,
} => {
if self.pixels_per_sample <= 1.0e-9 {
return None;
}
let drag_delta = (last_x - drag_start_x).abs();
if drag_delta < 3.0 {
let sample = snap_sample(sample_at_x(last_x)).0;
return Some(CanvasAction::publish(Message::Request(
EngineAction::TransportPosition(sample),
)));
}
let snap_interval = match self.snap_mode {
SnapMode::NoSnap => 1.0,
SnapMode::Clips => 1.0,
SnapMode::Bar => (self.samples_per_beat * 4.0).max(1.0),
SnapMode::Beat => self.samples_per_beat.max(1.0),
SnapMode::Eighth => (self.samples_per_beat / 2.0).max(1.0),
SnapMode::Sixteenth => (self.samples_per_beat / 4.0).max(1.0),
SnapMode::ThirtySecond => (self.samples_per_beat / 8.0).max(1.0),
SnapMode::SixtyFourth => (self.samples_per_beat / 16.0).max(1.0),
};
let start_x = drag_start_x.min(last_x).max(0.0);
let end_x = drag_start_x.max(last_x).max(0.0);
let snap_interval_f32 = snap_interval as f32;
let start_sample = if matches!(self.snap_mode, SnapMode::Clips) {
snap_sample(sample_at_x(start_x)).0 as f32
} else if matches!(self.snap_mode, SnapMode::NoSnap) {
(start_x / self.pixels_per_sample).max(0.0)
} else {
((start_x / self.pixels_per_sample) / snap_interval_f32).floor()
* snap_interval_f32
};
let mut end_sample = if matches!(self.snap_mode, SnapMode::Clips) {
snap_sample(sample_at_x(end_x)).0 as f32
} else if matches!(self.snap_mode, SnapMode::NoSnap) {
(end_x / self.pixels_per_sample).max(0.0)
} else {
((end_x / self.pixels_per_sample) / snap_interval_f32).ceil()
* snap_interval_f32
};
if end_sample <= start_sample {
end_sample = start_sample + snap_interval_f32;
}
return Some(CanvasAction::publish(Message::SetPunchRange(Some((
start_sample.max(0.0) as usize,
end_sample.max(0.0) as usize,
)))));
}
DragMode::Marker {
lane,
original_samples,
start_sample,
current_sample,
} => {
if current_sample == start_sample || original_samples.is_empty() {
return Some(CanvasAction::capture());
}
let delta = current_sample as i64 - start_sample as i64;
let mut to_samples = original_samples
.iter()
.map(|sample| (*sample as i64 + delta).max(1) as usize)
.collect::<Vec<_>>();
to_samples.sort_unstable();
to_samples.dedup();
return Some(match lane {
MarkerLane::Tempo => CanvasAction::publish(Message::TempoPointsMove {
from_samples: original_samples,
to_samples,
})
.and_capture(),
MarkerLane::TimeSignature => {
CanvasAction::publish(Message::TimeSignaturePointsMove {
from_samples: original_samples,
to_samples,
})
.and_capture()
}
});
}
DragMode::AdjustPunchEdge { .. } => return Some(CanvasAction::capture()),
}
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
if let Some(pos) = cursor_position {
if pos.x <= LEFT_HIT_WIDTH {
return Some(CanvasAction::capture());
}
if pos.y <= TEMPO_HIT_HEIGHT
&& let Some(sample) = nearest_tempo_point_at_x(pos.x)
{
state.context_menu = Some(ContextMenuState {
lane: MarkerLane::Tempo,
x: pos.x.min((bounds.width - CONTEXT_MENU_WIDTH).max(0.0)),
y: pos
.y
.min((bounds.height - CONTEXT_MENU_ITEM_HEIGHT * 3.0).max(0.0)),
});
return Some(
CanvasAction::publish(Message::TempoPointSelect {
sample,
additive: self.shift_pressed,
})
.and_capture(),
);
}
if pos.y > TEMPO_HIT_HEIGHT
&& let Some(sample) = nearest_tsig_point_at_x(pos.x)
{
state.context_menu = Some(ContextMenuState {
lane: MarkerLane::TimeSignature,
x: pos.x.min((bounds.width - CONTEXT_MENU_WIDTH).max(0.0)),
y: pos
.y
.min((bounds.height - CONTEXT_MENU_ITEM_HEIGHT * 3.0).max(0.0)),
});
return Some(
CanvasAction::publish(Message::TimeSignaturePointSelect {
sample,
additive: self.shift_pressed,
})
.and_capture(),
);
}
state.context_menu = None;
let x = cursor_x.unwrap_or(pos.x.clamp(0.0, bounds.width.max(0.0)));
state.drag_mode = DragMode::Punch {
drag_start_x: x,
last_x: x,
};
return Some(
CanvasAction::publish(Message::ClearTimingPointSelection).and_capture(),
);
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => {
let drag_mode = std::mem::replace(&mut state.drag_mode, DragMode::None);
match drag_mode {
DragMode::None => {}
DragMode::Punch {
drag_start_x,
last_x,
} => {
if self.pixels_per_sample <= 1.0e-9 {
return None;
}
let drag_delta = (last_x - drag_start_x).abs();
if drag_delta < 3.0 {
return Some(
CanvasAction::publish(Message::SetPunchRange(None)).and_capture(),
);
}
let snap_interval = match self.snap_mode {
SnapMode::NoSnap => 1.0,
SnapMode::Clips => 1.0,
SnapMode::Bar => (self.samples_per_beat * 4.0).max(1.0),
SnapMode::Beat => self.samples_per_beat.max(1.0),
SnapMode::Eighth => (self.samples_per_beat / 2.0).max(1.0),
SnapMode::Sixteenth => (self.samples_per_beat / 4.0).max(1.0),
SnapMode::ThirtySecond => (self.samples_per_beat / 8.0).max(1.0),
SnapMode::SixtyFourth => (self.samples_per_beat / 16.0).max(1.0),
};
let start_x = drag_start_x.min(last_x).max(0.0);
let end_x = drag_start_x.max(last_x).max(0.0);
let snap_interval_f32 = snap_interval as f32;
let start_sample = if matches!(self.snap_mode, SnapMode::Clips) {
snap_sample(sample_at_x(start_x)).0 as f32
} else if matches!(self.snap_mode, SnapMode::NoSnap) {
(start_x / self.pixels_per_sample).max(0.0)
} else {
((start_x / self.pixels_per_sample) / snap_interval_f32).floor()
* snap_interval_f32
};
let mut end_sample = if matches!(self.snap_mode, SnapMode::Clips) {
snap_sample(sample_at_x(end_x)).0 as f32
} else if matches!(self.snap_mode, SnapMode::NoSnap) {
(end_x / self.pixels_per_sample).max(0.0)
} else {
((end_x / self.pixels_per_sample) / snap_interval_f32).ceil()
* snap_interval_f32
};
if end_sample <= start_sample {
end_sample = start_sample + snap_interval_f32;
}
return Some(CanvasAction::publish(Message::SetPunchRange(Some((
start_sample.max(0.0) as usize,
end_sample.max(0.0) as usize,
)))));
}
DragMode::Marker { .. } => return Some(CanvasAction::capture()),
DragMode::AdjustPunchEdge { .. } => return Some(CanvasAction::capture()),
}
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
if let Some(pos) = cursor_position
&& pos.x > LEFT_HIT_WIDTH
{
if let Some((punch_start, punch_end)) = self.punch_range_samples
&& punch_end > punch_start
{
let x = cursor_x.unwrap_or(pos.x.clamp(0.0, bounds.width.max(0.0)));
let start_x = timeline_sample_to_x(
punch_start,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
let end_x = timeline_sample_to_x(
punch_end,
self.pixels_per_sample,
self.timeline_left_inset_px,
);
let start_hit = (x - start_x).abs() <= Tempo::RANGE_EDGE_HIT_PX;
let end_hit = (x - end_x).abs() <= Tempo::RANGE_EDGE_HIT_PX;
if start_hit || end_hit {
state.drag_mode = DragMode::AdjustPunchEdge {
adjust_start: start_hit,
fixed_sample: if start_hit { punch_end } else { punch_start },
current_sample: if start_hit { punch_start } else { punch_end },
};
return Some(CanvasAction::capture());
}
if x >= start_x.min(end_x) && x <= start_x.max(end_x) {
return Some(CanvasAction::capture());
}
}
let sample = snap_sample(sample_at_x(pos.x)).0;
if pos.y <= TEMPO_HIT_HEIGHT {
return Some(
CanvasAction::publish(Message::TempoPointAdd(sample)).and_capture(),
);
}
return Some(
CanvasAction::publish(Message::TimeSignaturePointAdd(sample)).and_capture(),
);
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => {
let drag_mode = std::mem::replace(&mut state.drag_mode, DragMode::None);
match drag_mode {
DragMode::AdjustPunchEdge {
adjust_start,
fixed_sample,
current_sample,
} => {
let (start, end) = if adjust_start {
(
current_sample.min(fixed_sample.saturating_sub(1)),
fixed_sample,
)
} else {
(
fixed_sample,
current_sample.max(fixed_sample.saturating_add(1)),
)
};
return Some(
CanvasAction::publish(Message::SetPunchRange(Some((start, end))))
.and_capture(),
);
}
DragMode::None => {}
other => {
state.drag_mode = other;
}
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
if let Some(pos) = cursor_position {
let scroll_y = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.0,
};
if scroll_y.abs() < f32::EPSILON {
return Some(CanvasAction::capture());
}
if pos.x <= LEFT_HIT_WIDTH {
if pos.y <= TEMPO_HIT_HEIGHT {
return Some(
CanvasAction::publish(Message::TempoAdjust(scroll_y.signum()))
.and_capture(),
);
}
if pos.x <= TIME_SIG_HIT_X_SPLIT {
return Some(
CanvasAction::publish(Message::TimeSignatureNumeratorAdjust(
signed_step(scroll_y),
))
.and_capture(),
);
}
return Some(
CanvasAction::publish(Message::TimeSignatureDenominatorAdjust(
signed_step(scroll_y),
))
.and_capture(),
);
}
return Some(CanvasAction::capture());
}
}
_ => {}
}
None
}
fn draw(
&self,
state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<Geometry> {
let cursor_hash = cursor
.position_in(bounds)
.map(|p| (p.x.to_bits(), p.y.to_bits()));
let mut hasher = DefaultHasher::new();
bounds.width.to_bits().hash(&mut hasher);
bounds.height.to_bits().hash(&mut hasher);
self.bpm.to_bits().hash(&mut hasher);
self.time_signature.hash(&mut hasher);
self.pixels_per_sample.to_bits().hash(&mut hasher);
self.playhead_x.map(f32::to_bits).hash(&mut hasher);
self.punch_range_samples.hash(&mut hasher);
Tempo::snap_mode_key(self.snap_mode).hash(&mut hasher);
self.samples_per_beat.to_bits().hash(&mut hasher);
self.samples_per_bar.to_bits().hash(&mut hasher);
self.timeline_left_inset_px.to_bits().hash(&mut hasher);
self.shift_pressed.hash(&mut hasher);
self.tempo_points.len().hash(&mut hasher);
for (sample, bpm) in &self.tempo_points {
sample.hash(&mut hasher);
bpm.to_bits().hash(&mut hasher);
}
self.time_signature_points.hash(&mut hasher);
self.selected_tempo_points.hash(&mut hasher);
self.selected_time_signature_points.hash(&mut hasher);
cursor_hash.hash(&mut hasher);
match &state.drag_mode {
DragMode::None => {
0_u8.hash(&mut hasher);
}
DragMode::Punch {
drag_start_x,
last_x,
} => {
1_u8.hash(&mut hasher);
drag_start_x.to_bits().hash(&mut hasher);
last_x.to_bits().hash(&mut hasher);
}
DragMode::Marker {
lane,
original_samples,
start_sample,
current_sample,
} => {
2_u8.hash(&mut hasher);
Tempo::lane_key(*lane).hash(&mut hasher);
original_samples.hash(&mut hasher);
start_sample.hash(&mut hasher);
current_sample.hash(&mut hasher);
}
DragMode::AdjustPunchEdge {
adjust_start,
fixed_sample,
current_sample,
} => {
3_u8.hash(&mut hasher);
adjust_start.hash(&mut hasher);
fixed_sample.hash(&mut hasher);
current_sample.hash(&mut hasher);
}
}
if let Some(menu) = state.context_menu {
1_u8.hash(&mut hasher);
Tempo::lane_key(menu.lane).hash(&mut hasher);
menu.x.to_bits().hash(&mut hasher);
menu.y.to_bits().hash(&mut hasher);
} else {
0_u8.hash(&mut hasher);
}
let hash = hasher.finish();
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 sample_to_x = |sample: usize| {
timeline_sample_to_x(
sample.saturating_sub(self.clip_start_samples),
self.pixels_per_sample,
self.timeline_left_inset_px,
)
};
frame.fill(
&Path::rectangle(Point::new(0.0, 0.0), bounds.size()),
Color::from_rgba(0.12, 0.12, 0.12, 1.0),
);
for (sample, bpm) in self.tempo_points.iter().filter(|(sample, _)| *sample > 0) {
let x = sample_to_x(*sample);
let selected = self.selected_tempo_points.contains(sample);
frame.stroke(
&Path::line(Point::new(x, 0.0), Point::new(x, TEMPO_HIT_HEIGHT)),
Stroke::default()
.with_width(if selected { 2.0 } else { 1.0 })
.with_color(if selected {
Color::from_rgba(1.0, 0.95, 0.45, 1.0)
} else {
Color::from_rgba(0.9, 0.9, 0.35, 0.85)
}),
);
frame.fill_text(Text {
content: format!("{:.0}", bpm),
position: Point::new(x + 3.0, 2.0),
color: Color::from_rgba(0.95, 0.95, 0.78, 0.92),
size: 9.0.into(),
..Default::default()
});
}
for (sample, n, d) in self
.time_signature_points
.iter()
.filter(|(sample, _, _)| *sample > 0)
{
let x = sample_to_x(*sample);
let selected = self.selected_time_signature_points.contains(sample);
frame.stroke(
&Path::line(
Point::new(x, TEMPO_HIT_HEIGHT),
Point::new(x, bounds.height),
),
Stroke::default()
.with_width(if selected { 2.0 } else { 1.0 })
.with_color(if selected {
Color::from_rgba(0.52, 1.0, 1.0, 1.0)
} else {
Color::from_rgba(0.45, 0.9, 0.9, 0.85)
}),
);
frame.fill_text(Text {
content: format!("{n}/{d}"),
position: Point::new(x + 3.0, TEMPO_HIT_HEIGHT + 1.0),
color: Color::from_rgba(0.8, 0.98, 0.98, 0.9),
size: 9.0.into(),
..Default::default()
});
}
if !matches!(state.drag_mode, DragMode::Punch { .. })
&& let Some((punch_start, punch_end)) = self.punch_range_samples
&& self.pixels_per_sample > 1.0e-9
&& punch_end > punch_start
{
let start_x = sample_to_x(punch_start);
let end_x = sample_to_x(punch_end);
frame.fill(
&Path::rectangle(
Point::new(start_x.max(0.0), 0.0),
iced::Size::new((end_x - start_x).max(1.0), bounds.height),
),
Color::from_rgba(0.55, 0.18, 0.18, 0.30),
);
frame.stroke(
&Path::line(
Point::new(start_x.max(0.0), 0.0),
Point::new(start_x.max(0.0), bounds.height),
),
Stroke::default()
.with_width(1.5)
.with_color(Color::from_rgba(0.92, 0.45, 0.45, 0.9)),
);
frame.stroke(
&Path::line(
Point::new(end_x.max(0.0), 0.0),
Point::new(end_x.max(0.0), bounds.height),
),
Stroke::default()
.with_width(1.5)
.with_color(Color::from_rgba(0.92, 0.45, 0.45, 0.9)),
);
}
match &state.drag_mode {
DragMode::None => {}
DragMode::Punch {
drag_start_x,
last_x,
} => {
let start_x = drag_start_x.min(*last_x).max(0.0);
let end_x = drag_start_x.max(*last_x).max(0.0);
frame.fill(
&Path::rectangle(
Point::new(start_x, 0.0),
iced::Size::new((end_x - start_x).max(1.0), bounds.height),
),
Color::from_rgba(0.92, 0.36, 0.36, 0.22),
);
frame.stroke(
&Path::line(
Point::new(start_x, 0.0),
Point::new(start_x, bounds.height),
),
Stroke::default()
.with_width(1.5)
.with_color(Color::from_rgba(0.97, 0.58, 0.58, 0.95)),
);
frame.stroke(
&Path::line(Point::new(end_x, 0.0), Point::new(end_x, bounds.height)),
Stroke::default()
.with_width(1.5)
.with_color(Color::from_rgba(0.97, 0.58, 0.58, 0.95)),
);
}
DragMode::AdjustPunchEdge {
adjust_start,
fixed_sample,
current_sample,
} => {
let fixed_x = sample_to_x(*fixed_sample);
let current_x = sample_to_x(*current_sample);
let start_x = if *adjust_start {
current_x.min(fixed_x)
} else {
fixed_x.min(current_x)
};
let end_x = if *adjust_start {
fixed_x.max(current_x)
} else {
current_x.max(fixed_x)
};
frame.fill(
&Path::rectangle(
Point::new(start_x.max(0.0), 0.0),
iced::Size::new((end_x - start_x).max(1.0), bounds.height),
),
Color::from_rgba(0.92, 0.36, 0.36, 0.22),
);
frame.stroke(
&Path::line(
Point::new(start_x.max(0.0), 0.0),
Point::new(start_x.max(0.0), bounds.height),
),
Stroke::default()
.with_width(1.5)
.with_color(Color::from_rgba(0.97, 0.58, 0.58, 0.95)),
);
frame.stroke(
&Path::line(
Point::new(end_x.max(0.0), 0.0),
Point::new(end_x.max(0.0), bounds.height),
),
Stroke::default()
.with_width(1.5)
.with_color(Color::from_rgba(0.97, 0.58, 0.58, 0.95)),
);
}
DragMode::Marker {
lane,
original_samples,
start_sample,
current_sample,
} => {
let delta = *current_sample as i64 - *start_sample as i64;
for sample in original_samples {
let moved_sample = (*sample as i64 + delta).max(1) as usize;
let x = sample_to_x(moved_sample);
let (y0, y1, color) = match lane {
MarkerLane::Tempo => (
0.0,
TEMPO_HIT_HEIGHT,
Color::from_rgba(1.0, 0.95, 0.45, 0.85),
),
MarkerLane::TimeSignature => (
TEMPO_HIT_HEIGHT,
bounds.height,
Color::from_rgba(0.52, 1.0, 1.0, 0.85),
),
};
frame.stroke(
&Path::line(Point::new(x, y0), Point::new(x, y1)),
Stroke::default().with_width(2.0).with_color(color),
);
}
}
}
frame.fill_text(Text {
content: format!("{:.0}", self.bpm),
position: Point::new(10.0, 2.0),
color: Color::WHITE,
size: 14.0.into(),
..Default::default()
});
frame.fill_text(Text {
content: format!("{}/{}", self.time_signature.0, self.time_signature.1),
position: Point::new(10.0, 15.0),
color: Color::WHITE,
size: 10.0.into(),
..Default::default()
});
if let Some(menu) = state.context_menu {
let menu_height = CONTEXT_MENU_ITEM_HEIGHT * 3.0;
frame.fill(
&Path::rectangle(
Point::new(menu.x, menu.y),
iced::Size::new(CONTEXT_MENU_WIDTH, menu_height),
),
Color::from_rgba(0.16, 0.16, 0.16, 0.98),
);
for i in 1..3 {
let y = menu.y + CONTEXT_MENU_ITEM_HEIGHT * i as f32;
frame.stroke(
&Path::line(
Point::new(menu.x, y),
Point::new(menu.x + CONTEXT_MENU_WIDTH, y),
),
Stroke::default()
.with_width(1.0)
.with_color(Color::from_rgba(0.32, 0.32, 0.32, 0.9)),
);
}
let labels = ["Duplicate", "Reset to Previous", "Delete"];
for (idx, label) in labels.iter().enumerate() {
frame.fill_text(Text {
content: (*label).to_string(),
position: Point::new(
menu.x + 6.0,
menu.y + 2.0 + CONTEXT_MENU_ITEM_HEIGHT * idx as f32,
),
color: Color::from_rgba(0.95, 0.95, 0.95, 0.95),
size: 10.0.into(),
..Default::default()
});
}
}
if let Some(pos) = cursor.position_in(bounds)
&& pos.x > LEFT_HIT_WIDTH
{
let tooltip = if pos.y <= TEMPO_HIT_HEIGHT {
self.tempo_points
.iter()
.filter(|(sample, _)| *sample > 0)
.min_by(|(sa, _), (sb, _)| {
let ax = sample_to_x(*sa);
let bx = sample_to_x(*sb);
(ax - pos.x)
.abs()
.partial_cmp(&(bx - pos.x).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.and_then(|(sample, bpm)| {
let x = sample_to_x(*sample);
if (x - pos.x).abs() <= 6.0 {
Some(format!("s:{} {:.2} BPM", sample, bpm))
} else {
None
}
})
} else {
self.time_signature_points
.iter()
.filter(|(sample, _, _)| *sample > 0)
.min_by(|(sa, _, _), (sb, _, _)| {
let ax = sample_to_x(*sa);
let bx = sample_to_x(*sb);
(ax - pos.x)
.abs()
.partial_cmp(&(bx - pos.x).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.and_then(|(sample, n, d)| {
let x = sample_to_x(*sample);
if (x - pos.x).abs() <= 6.0 {
Some(format!("s:{} {}/{}", sample, n, d))
} else {
None
}
})
};
if let Some(text) = tooltip {
let tip_x = (pos.x + 8.0).min((bounds.width - 150.0).max(0.0));
let tip_y = (pos.y + 2.0).min((bounds.height - 14.0).max(0.0));
frame.fill(
&Path::rectangle(
Point::new(tip_x, tip_y),
iced::Size::new(148.0, 12.0),
),
Color::from_rgba(0.08, 0.08, 0.08, 0.9),
);
frame.fill_text(Text {
content: text,
position: Point::new(tip_x + 4.0, tip_y + 1.0),
color: Color::from_rgba(0.96, 0.96, 0.96, 0.95),
size: 9.0.into(),
..Default::default()
});
}
}
if let Some(x) = self.playhead_x {
let path = Path::line(
Point::new(x.max(0.0), 0.0),
Point::new(x.max(0.0), bounds.height),
);
frame.stroke(
&path,
Stroke::default().with_width(1.5).with_color(Color {
r: 0.95,
g: 0.18,
b: 0.14,
a: 0.95,
}),
);
}
});
vec![geom]
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::widget::canvas::Program;
use iced::{Point, Rectangle, Size, event, mouse};
fn action_message(action: CanvasAction<Message>) -> (Option<Message>, event::Status) {
let (message, _redraw, status) = action.into_inner();
(message, status)
}
#[test]
fn clip_kind_key_returns_expected_values() {
assert_eq!(clip_kind_key(maolan_engine::kind::Kind::Audio), 0);
assert_eq!(clip_kind_key(maolan_engine::kind::Kind::MIDI), 1);
}
#[test]
fn snap_mode_key_returns_expected_values() {
assert_eq!(Tempo::snap_mode_key(SnapMode::NoSnap), 0);
assert_eq!(Tempo::snap_mode_key(SnapMode::Clips), 1);
assert_eq!(Tempo::snap_mode_key(SnapMode::Bar), 2);
assert_eq!(Tempo::snap_mode_key(SnapMode::Beat), 3);
assert_eq!(Tempo::snap_mode_key(SnapMode::Eighth), 4);
assert_eq!(Tempo::snap_mode_key(SnapMode::Sixteenth), 5);
assert_eq!(Tempo::snap_mode_key(SnapMode::ThirtySecond), 6);
assert_eq!(Tempo::snap_mode_key(SnapMode::SixtyFourth), 7);
}
#[test]
fn lane_key_returns_expected_values() {
assert_eq!(Tempo::lane_key(MarkerLane::Tempo), 0);
assert_eq!(Tempo::lane_key(MarkerLane::TimeSignature), 1);
}
#[test]
fn tempo_new_creates_instance() {
let tempo = Tempo::new();
assert_eq!(tempo.height(), TEMPO_HEIGHT);
}
#[test]
fn update_middle_click_in_tempo_lane_adds_tempo_point() {
let canvas = TempoCanvas {
bpm: 120.0,
time_signature: (4, 4),
pixels_per_sample: 1.0,
playhead_x: None,
punch_range_samples: None,
clip_snap_edges: Vec::new(),
snap_mode: SnapMode::NoSnap,
samples_per_beat: 4.0,
samples_per_bar: 16.0,
shift_pressed: false,
tempo_points: vec![(0, 120.0)],
time_signature_points: vec![(0, 4, 4)],
selected_tempo_points: Vec::new(),
selected_time_signature_points: Vec::new(),
timeline_left_inset_px: 0.0,
clip_start_samples: 0,
};
let bounds = Rectangle::new(Point::ORIGIN, Size::new(400.0, 60.0));
let mut state = TempoState::default();
let cursor = mouse::Cursor::Available(Point::new(100.0, TEMPO_HIT_HEIGHT - 2.0));
let action = canvas
.update(
&mut state,
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
bounds,
cursor,
)
.expect("action");
let (message, status) = action_message(action);
match message {
Some(Message::TempoPointAdd(sample)) => assert_eq!(sample, 100),
other => panic!("unexpected message: {other:?}"),
}
assert_eq!(status, event::Status::Captured);
}
}