Cycle recording phase 1: transport loop region
Adds a cycle (loop) region: a range on the timeline ruler that the transport wraps at during playback. This is the substrate for GarageBand-style multi-take cycle recording (phases 2 and 3). The region is authored in BEATS so it stays put musically across tempo changes. It lives on the Document (saved in the .beam, serde-defaulted so old files load) and is edited through SetCycleRegionAction, so it is undoable and marks the document modified like any other edit. Backend: - Engine gains loop_region/loop_enabled plus a wrap at the single playhead-advance point in process(). The wrap is phase-preserving (modulo, so an overshoot larger than the loop can't strand the playhead outside the region) and gated on playhead >= 0 so a count-in pre-roll never wraps. - Sounding voices are deliberately NOT reset at the wrap the way Command::Seek does, since that would chop sustain and reverb tails at every pass. - MidiRecordingState::wrap_at_cycle writes note-offs for held notes at the region end and re-opens them at the region start, so a key held across the boundary can't end up with a negative duration or hang. - loop_bounds_frozen freezes the region's sample bounds for the duration of an *audio* recording. Phrased positively on audio so future multi-track recording inherits it: MIDI is beat-segmented and tempo-invariant, but audio is segmented geometrically and we have no time-stretch, so cross-tempo audio takes wouldn't be compable anyway. - Command::Play jumps to loop_start when starting from outside the region; starting inside it plays from where you are. Editor: - Cycle lane along the bottom of the ruler (bottom, so it doesn't cover the bar numbers), painted inside render_ruler under the ticks. Only exists while looping is armed; with cycle off the ruler is entirely the playhead scrubber, as before. - Drag to create/move/resize with a three-zone hit test, previewed locally and committed as ONE action on release. Driven off raw pointer state rather than an egui Response: the lane sits inside the timeline's content response, and a second widget on the same pixels just contests hover every frame. - snap_to_grid/quantize_grid_size take a min_grid_px "visual coarseness" parameter instead of a hardcoded constant, with two named profiles: SNAP_PX_FINE for the playhead and clip edges, SNAP_PX_CYCLE (coarser) for the cycle region, so loops land on bars rather than odd subdivisions. - Cycle toggle button using the Lucide repeat glyph. Cargo.lock picks up the 1.0.9-alpha version bump it missed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9f67a82e3e
commit
f957b01dcf
|
|
@ -33,6 +33,15 @@ pub struct Engine {
|
||||||
playing: bool,
|
playing: bool,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
|
|
||||||
|
/// Transport cycle (loop) region, authored in BEATS so it survives tempo changes.
|
||||||
|
/// Sample bounds are derived from the tempo map at the wrap check.
|
||||||
|
loop_region: Option<(Beats, Beats)>,
|
||||||
|
/// Whether the transport wraps at the end of `loop_region`.
|
||||||
|
loop_enabled: bool,
|
||||||
|
/// Cycle-region sample bounds frozen for the duration of an **audio** recording.
|
||||||
|
/// See `loop_bounds_samples` for why. `None` = derive live from the tempo map.
|
||||||
|
loop_bounds_frozen: Option<(i64, i64)>,
|
||||||
|
|
||||||
// Lock-free communication
|
// Lock-free communication
|
||||||
command_rx: rtrb::Consumer<Command>,
|
command_rx: rtrb::Consumer<Command>,
|
||||||
midi_command_rx: Option<rtrb::Consumer<Command>>,
|
midi_command_rx: Option<rtrb::Consumer<Command>>,
|
||||||
|
|
@ -155,6 +164,9 @@ impl Engine {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
playing: false,
|
playing: false,
|
||||||
channels,
|
channels,
|
||||||
|
loop_region: None,
|
||||||
|
loop_enabled: false,
|
||||||
|
loop_bounds_frozen: None,
|
||||||
command_rx,
|
command_rx,
|
||||||
midi_command_rx: None,
|
midi_command_rx: None,
|
||||||
event_tx,
|
event_tx,
|
||||||
|
|
@ -508,6 +520,38 @@ impl Engine {
|
||||||
// Update playhead (convert total samples to frames)
|
// Update playhead (convert total samples to frames)
|
||||||
self.playhead += (output.len() / self.channels as usize) as i64;
|
self.playhead += (output.len() / self.channels as usize) as i64;
|
||||||
|
|
||||||
|
// Cycle/loop wrap. Gated on playhead >= 0 so a count-in pre-roll never wraps.
|
||||||
|
// Sounding voices are deliberately left alone — no `stop_all_notes()` /
|
||||||
|
// `reset_all_graphs()` like Command::Seek does, since that would chop sustain and
|
||||||
|
// reverb tails at every wrap.
|
||||||
|
if self.loop_enabled && self.playhead >= 0 {
|
||||||
|
if let Some((ls_beats, le_beats)) = self.loop_region {
|
||||||
|
// Sample bounds are frozen while audio is recording (see loop_bounds_samples).
|
||||||
|
let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats);
|
||||||
|
if le > ls && self.playhead >= le {
|
||||||
|
// Phase-preserving wrap. Modulo (not a single subtraction) so an overshoot
|
||||||
|
// larger than the loop — e.g. after a tempo change shrank it — can't strand
|
||||||
|
// the playhead outside the region.
|
||||||
|
self.playhead = ls + (self.playhead - ls) % (le - ls);
|
||||||
|
|
||||||
|
// A MIDI recording in progress stores note times as offsets from its
|
||||||
|
// start, and the playhead just jumped backwards — so any note still held
|
||||||
|
// across the boundary needs its note-off written at the region end (and
|
||||||
|
// re-opened at the region start if the key is still down). Otherwise it
|
||||||
|
// would get a negative duration, or never close at all.
|
||||||
|
if let Some(ref mut rec) = self.midi_recording_state {
|
||||||
|
rec.wrap_at_cycle(le_beats, ls_beats);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref mut dr) = self.disk_reader {
|
||||||
|
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek {
|
||||||
|
frame: self.playhead.max(0) as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll)
|
// Update atomic playhead for UI reads (clamped to 0; negative = count-in pre-roll)
|
||||||
self.playhead_atomic
|
self.playhead_atomic
|
||||||
.store(self.playhead.max(0) as u64, Ordering::Relaxed);
|
.store(self.playhead.max(0) as u64, Ordering::Relaxed);
|
||||||
|
|
@ -679,6 +723,8 @@ impl Engine {
|
||||||
format!("Recording write error: {}", e)
|
format!("Recording write error: {}", e)
|
||||||
));
|
));
|
||||||
self.recording_state = None;
|
self.recording_state = None;
|
||||||
|
// Audio recording is over — let the cycle region track tempo again.
|
||||||
|
self.loop_bounds_frozen = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -783,12 +829,71 @@ impl Engine {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a beats position to a sample position using the current tempo map.
|
||||||
|
///
|
||||||
|
/// The cycle region is authored in beats (so it survives tempo changes); its sample bounds are
|
||||||
|
/// derived here at the wrap check rather than cached, which keeps it correct across tempo edits
|
||||||
|
/// with no invalidation bookkeeping.
|
||||||
|
fn beats_to_samples(&self, beats: Beats) -> i64 {
|
||||||
|
(self.tempo_map.beats_to_seconds(beats).seconds_to_f64() * self.sample_rate as f64) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample bounds of the cycle region.
|
||||||
|
///
|
||||||
|
/// These are FROZEN while an audio recording is in flight (see `loop_bounds_frozen`, captured
|
||||||
|
/// in `handle_start_recording`), so a tempo change mid-take cannot resize the loop.
|
||||||
|
///
|
||||||
|
/// Why freeze only for audio: take segmentation for audio is geometric in samples — every pass
|
||||||
|
/// is exactly `loop_len` frames, which is what lets us pad all takes to a uniform length and
|
||||||
|
/// comp between them. A tempo change would resize `loop_len` mid-session and break that. And
|
||||||
|
/// since we have no time-stretching, audio takes captured at two different tempos could never
|
||||||
|
/// be comped together anyway, so honoring the change would buy nothing and cost the invariant.
|
||||||
|
///
|
||||||
|
/// MIDI is deliberately unaffected: it is segmented in *beats*, which are tempo-invariant, so
|
||||||
|
/// changing tempo during a MIDI-only recording is fully supported (slow down a hard passage and
|
||||||
|
/// keep stacking takes). The freeze is keyed on *audio* being recorded, not on "not MIDI", so
|
||||||
|
/// when multi-track recording lands it will correctly freeze if ANY recorded track is audio.
|
||||||
|
fn loop_bounds_samples(&self, ls_beats: Beats, le_beats: Beats) -> (i64, i64) {
|
||||||
|
if let Some(frozen) = self.loop_bounds_frozen {
|
||||||
|
return frozen;
|
||||||
|
}
|
||||||
|
(self.beats_to_samples(ls_beats), self.beats_to_samples(le_beats))
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a command from the UI thread
|
/// Handle a command from the UI thread
|
||||||
fn handle_command(&mut self, cmd: Command) {
|
fn handle_command(&mut self, cmd: Command) {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::Play => {
|
Command::Play => {
|
||||||
|
// Starting playback from outside the cycle region jumps to its start — otherwise
|
||||||
|
// you'd play forward from wherever the playhead happened to be and only fall into
|
||||||
|
// the loop if you happened to cross its end. Inside the region we start where we
|
||||||
|
// are, so you can still audition from the middle of a loop.
|
||||||
|
//
|
||||||
|
// A negative playhead is a count-in pre-roll that was deliberately placed *before*
|
||||||
|
// the region, so leave it alone.
|
||||||
|
if self.loop_enabled && self.playhead >= 0 {
|
||||||
|
if let Some((ls_beats, le_beats)) = self.loop_region {
|
||||||
|
let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats);
|
||||||
|
if le > ls && (self.playhead < ls || self.playhead >= le) {
|
||||||
|
self.playhead = ls;
|
||||||
|
self.playhead_atomic.store(ls.max(0) as u64, Ordering::Relaxed);
|
||||||
|
if let Some(ref mut dr) = self.disk_reader {
|
||||||
|
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek {
|
||||||
|
frame: ls.max(0) as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.playing = true;
|
self.playing = true;
|
||||||
}
|
}
|
||||||
|
Command::SetLoopRegion(region) => {
|
||||||
|
// Stored in beats; sample bounds are derived at the wrap check.
|
||||||
|
self.loop_region = region;
|
||||||
|
}
|
||||||
|
Command::SetLoopEnabled(enabled) => {
|
||||||
|
self.loop_enabled = enabled;
|
||||||
|
}
|
||||||
Command::Stop => {
|
Command::Stop => {
|
||||||
self.playing = false;
|
self.playing = false;
|
||||||
self.playhead = 0;
|
self.playhead = 0;
|
||||||
|
|
@ -1309,6 +1414,12 @@ impl Engine {
|
||||||
// Stop any active recording
|
// Stop any active recording
|
||||||
self.recording_state = None;
|
self.recording_state = None;
|
||||||
|
|
||||||
|
// Clear the cycle region — it's a document property, and the new document will
|
||||||
|
// push its own (or none) once loaded.
|
||||||
|
self.loop_region = None;
|
||||||
|
self.loop_enabled = false;
|
||||||
|
self.loop_bounds_frozen = None;
|
||||||
|
|
||||||
// Clear all project data
|
// Clear all project data
|
||||||
self.project = Project::new(self.sample_rate);
|
self.project = Project::new(self.sample_rate);
|
||||||
|
|
||||||
|
|
@ -3034,6 +3145,19 @@ impl Engine {
|
||||||
use crate::io::WavWriter;
|
use crate::io::WavWriter;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
|
// Freeze the cycle region's sample bounds for the duration of this AUDIO recording, so a
|
||||||
|
// tempo change mid-take can't resize the loop and break the uniform-take invariant that
|
||||||
|
// take segmentation and comping depend on. See `loop_bounds_samples`. (MIDI-only
|
||||||
|
// recordings never take this path, so they stay free to change tempo.)
|
||||||
|
if self.loop_enabled {
|
||||||
|
if let Some((ls_beats, le_beats)) = self.loop_region {
|
||||||
|
self.loop_bounds_frozen = Some((
|
||||||
|
self.beats_to_samples(ls_beats),
|
||||||
|
self.beats_to_samples(le_beats),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if track exists and is an audio track
|
// Check if track exists and is an audio track
|
||||||
if let Some(crate::audio::track::TrackNode::Audio(_)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Audio(_)) = self.project.get_track_mut(track_id) {
|
||||||
// Generate a unique temp file path
|
// Generate a unique temp file path
|
||||||
|
|
@ -3118,6 +3242,9 @@ impl Engine {
|
||||||
fn handle_stop_recording(&mut self) {
|
fn handle_stop_recording(&mut self) {
|
||||||
eprintln!("[STOP_RECORDING] handle_stop_recording called");
|
eprintln!("[STOP_RECORDING] handle_stop_recording called");
|
||||||
|
|
||||||
|
// Audio is no longer recording, so the cycle region can track the tempo map again.
|
||||||
|
self.loop_bounds_frozen = None;
|
||||||
|
|
||||||
// Check if we have an active MIDI recording first
|
// Check if we have an active MIDI recording first
|
||||||
if self.midi_recording_state.is_some() {
|
if self.midi_recording_state.is_some() {
|
||||||
eprintln!("[STOP_RECORDING] Detected active MIDI recording, delegating to handle_stop_midi_recording");
|
eprintln!("[STOP_RECORDING] Detected active MIDI recording, delegating to handle_stop_midi_recording");
|
||||||
|
|
@ -3342,6 +3469,20 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::Pause);
|
let _ = self.command_tx.push(Command::Pause);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the cycle region the transport loops over (None clears it).
|
||||||
|
///
|
||||||
|
/// Authored in beats so it survives tempo changes. Note the region's sample bounds are frozen
|
||||||
|
/// while an audio recording is in flight, so a call made mid-take won't resize the loop until
|
||||||
|
/// recording stops (see `Engine::loop_bounds_samples`).
|
||||||
|
pub fn set_loop_region(&mut self, region: Option<(Beats, Beats)>) {
|
||||||
|
let _ = self.command_tx.push(Command::SetLoopRegion(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable wrapping at the end of the cycle region.
|
||||||
|
pub fn set_loop_enabled(&mut self, enabled: bool) {
|
||||||
|
let _ = self.command_tx.push(Command::SetLoopEnabled(enabled));
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop playback and reset to beginning
|
/// Stop playback and reset to beginning
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
let _ = self.command_tx.push(Command::Stop);
|
let _ = self.command_tx.push(Command::Stop);
|
||||||
|
|
|
||||||
|
|
@ -261,4 +261,27 @@ impl MidiRecordingState {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a transport cycle wrap during MIDI recording.
|
||||||
|
///
|
||||||
|
/// Note times are stored as offsets from `start_time`, and the playhead jumps *backwards* at a
|
||||||
|
/// wrap — so a note still held across the boundary would otherwise get a nonsensical (negative)
|
||||||
|
/// duration, or never be closed at all. Write its note-off at `region_end` (exactly as
|
||||||
|
/// `close_active_notes` does when recording stops), then re-open it at `region_start` so a key
|
||||||
|
/// the player is still physically holding keeps being captured in the next pass. Mirrors the
|
||||||
|
/// way `handle_start_midi_recording` re-injects already-held notes at the recording start.
|
||||||
|
pub fn wrap_at_cycle(&mut self, region_end: Beats, region_start: Beats) {
|
||||||
|
// Snapshot the held notes (close_active_notes drains them and loses the velocities).
|
||||||
|
let held: Vec<(u8, u8)> = self
|
||||||
|
.active_notes
|
||||||
|
.values()
|
||||||
|
.map(|n| (n.note, n.velocity))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.close_active_notes(region_end);
|
||||||
|
|
||||||
|
for (note, velocity) in held {
|
||||||
|
self.note_on(note, velocity, region_start);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,13 @@ pub enum Command {
|
||||||
/// Enable/disable an automation lane (track_id, lane_id, enabled)
|
/// Enable/disable an automation lane (track_id, lane_id, enabled)
|
||||||
SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool),
|
SetAutomationLaneEnabled(TrackId, AutomationLaneId, bool),
|
||||||
|
|
||||||
|
// Transport cycle (loop) region
|
||||||
|
/// Set the cycle region the transport loops over, in beats (None clears it).
|
||||||
|
/// Authored in beats so it survives tempo changes.
|
||||||
|
SetLoopRegion(Option<(Beats, Beats)>),
|
||||||
|
/// Enable/disable wrapping at the cycle region's end.
|
||||||
|
SetLoopEnabled(bool),
|
||||||
|
|
||||||
// Recording commands
|
// Recording commands
|
||||||
/// Start recording on a track (track_id, start_time)
|
/// Start recording on a track (track_id, start_time)
|
||||||
StartRecording(TrackId, Beats),
|
StartRecording(TrackId, Beats),
|
||||||
|
|
|
||||||
|
|
@ -3628,7 +3628,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lightningbeam-editor"
|
name = "lightningbeam-editor"
|
||||||
version = "1.0.8-alpha"
|
version = "1.0.9-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"beamdsp",
|
"beamdsp",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub mod move_clip_instances;
|
||||||
pub mod reorder_clip_instances;
|
pub mod reorder_clip_instances;
|
||||||
pub mod paint_bucket;
|
pub mod paint_bucket;
|
||||||
pub mod remove_effect;
|
pub mod remove_effect;
|
||||||
|
pub mod set_cycle_region;
|
||||||
pub mod set_document_properties;
|
pub mod set_document_properties;
|
||||||
pub mod set_instance_properties;
|
pub mod set_instance_properties;
|
||||||
pub mod set_layer_properties;
|
pub mod set_layer_properties;
|
||||||
|
|
@ -50,6 +51,7 @@ pub mod set_text_content;
|
||||||
pub mod resize_text_box;
|
pub mod resize_text_box;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
|
pub use set_cycle_region::SetCycleRegionAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
pub use add_layer::AddLayerAction;
|
pub use add_layer::AddLayerAction;
|
||||||
pub use add_shape::AddShapeAction;
|
pub use add_shape::AddShapeAction;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
//! Set the transport cycle (loop) region.
|
||||||
|
//!
|
||||||
|
//! The cycle region is document state (it's saved in the `.beam`), so changing it goes through the
|
||||||
|
//! action system like any other edit: it's undoable and it marks the document modified.
|
||||||
|
//!
|
||||||
|
//! The region is stored in **beats** so it stays put musically across tempo changes. Callers commit
|
||||||
|
//! one action per gesture (e.g. on drag release, or a toggle click) rather than one per frame —
|
||||||
|
//! the timeline previews the drag from its own local state, exactly like a clip drag does.
|
||||||
|
|
||||||
|
use crate::action::{Action, BackendContext};
|
||||||
|
use crate::document::Document;
|
||||||
|
use daw_backend::Beats;
|
||||||
|
|
||||||
|
/// Action that sets the cycle region and/or whether the transport loops over it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SetCycleRegionAction {
|
||||||
|
old_region: Option<(Beats, Beats)>,
|
||||||
|
old_enabled: bool,
|
||||||
|
new_region: Option<(Beats, Beats)>,
|
||||||
|
new_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetCycleRegionAction {
|
||||||
|
/// Build from the document's current state and the desired new region/enabled flag.
|
||||||
|
pub fn new(
|
||||||
|
document: &Document,
|
||||||
|
new_region: Option<(Beats, Beats)>,
|
||||||
|
new_enabled: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
old_region: document.cycle_region,
|
||||||
|
old_enabled: document.cycle_enabled,
|
||||||
|
new_region,
|
||||||
|
new_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle looping on/off, leaving the region itself alone.
|
||||||
|
pub fn toggle_enabled(document: &Document) -> Self {
|
||||||
|
Self::new(document, document.cycle_region, !document.cycle_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this action would not actually change anything (lets callers skip a no-op undo entry).
|
||||||
|
pub fn is_noop(&self) -> bool {
|
||||||
|
self.old_region == self.new_region && self.old_enabled == self.new_enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for SetCycleRegionAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
|
document.cycle_region = self.new_region;
|
||||||
|
document.cycle_enabled = self.new_enabled;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
|
document.cycle_region = self.old_region;
|
||||||
|
document.cycle_enabled = self.old_enabled;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
"Set cycle region".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut BackendContext,
|
||||||
|
_document: &Document,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
controller.set_loop_region(self.new_region);
|
||||||
|
controller.set_loop_enabled(self.new_enabled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut BackendContext,
|
||||||
|
_document: &Document,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
controller.set_loop_region(self.old_region);
|
||||||
|
controller.set_loop_enabled(self.old_enabled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -201,6 +201,17 @@ pub struct Document {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub time_signature: TimeSignature,
|
pub time_signature: TimeSignature,
|
||||||
|
|
||||||
|
/// Transport cycle (loop) region, as `(start, end)` in **beats**.
|
||||||
|
///
|
||||||
|
/// Authored in beats so it stays put musically when the tempo changes. `None` = no region set.
|
||||||
|
/// Saved with the project; `#[serde(default)]` keeps older `.beam` files loading.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cycle_region: Option<(Beats, Beats)>,
|
||||||
|
|
||||||
|
/// Whether the transport loops over `cycle_region`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cycle_enabled: bool,
|
||||||
|
|
||||||
/// Master track (master bus + tempo automation lane).
|
/// Master track (master bus + tempo automation lane).
|
||||||
/// Stored separately from the root layer tree; shown in timeline when
|
/// Stored separately from the root layer tree; shown in timeline when
|
||||||
/// `show_master_track` is enabled in the editor state.
|
/// `show_master_track` is enabled in the editor state.
|
||||||
|
|
@ -297,6 +308,8 @@ impl Default for Document {
|
||||||
height: 1080.0,
|
height: 1080.0,
|
||||||
framerate: 60.0,
|
framerate: 60.0,
|
||||||
time_signature: TimeSignature::default(),
|
time_signature: TimeSignature::default(),
|
||||||
|
cycle_region: None,
|
||||||
|
cycle_enabled: false,
|
||||||
master_layer: {
|
master_layer: {
|
||||||
let mut ml = GroupLayer::new_master(120.0);
|
let mut ml = GroupLayer::new_master(120.0);
|
||||||
ml.layer.id = uuid::Uuid::new_v4();
|
ml.layer.id = uuid::Uuid::new_v4();
|
||||||
|
|
|
||||||
|
|
@ -2039,6 +2039,22 @@ impl EditorApp {
|
||||||
fn sync_audio_layers_to_backend(&mut self) {
|
fn sync_audio_layers_to_backend(&mut self) {
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||||
|
|
||||||
|
// Push the document's cycle region to the engine. Needed on load/new: the region is
|
||||||
|
// document state, but the engine starts blank (and is cleared on Reset), so without this a
|
||||||
|
// loaded project would show its cycle strip while the transport never actually looped.
|
||||||
|
// Changes made later go through SetCycleRegionAction's execute_backend.
|
||||||
|
{
|
||||||
|
let (region, enabled) = {
|
||||||
|
let doc = self.action_executor.document();
|
||||||
|
(doc.cycle_region, doc.cycle_enabled)
|
||||||
|
};
|
||||||
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.set_loop_region(region);
|
||||||
|
controller.set_loop_enabled(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the master layer has a backend group track.
|
// Ensure the master layer has a backend group track.
|
||||||
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
let master_layer_id = self.action_executor.document().master_layer.layer.id;
|
||||||
if !self.layer_to_track_map.contains_key(&master_layer_id) {
|
if !self.layer_to_track_map.contains_key(&master_layer_id) {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ pub const GRIP_HORIZONTAL: &str = "\u{e0ea}";
|
||||||
pub const CHEVRONS_UP: &str = "\u{e074}";
|
pub const CHEVRONS_UP: &str = "\u{e074}";
|
||||||
pub const PLAY: &str = "\u{e13c}";
|
pub const PLAY: &str = "\u{e13c}";
|
||||||
pub const PAUSE: &str = "\u{e12e}";
|
pub const PAUSE: &str = "\u{e12e}";
|
||||||
|
pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle
|
||||||
pub const SETTINGS: &str = "\u{e154}";
|
pub const SETTINGS: &str = "\u{e154}";
|
||||||
pub const SEARCH: &str = "\u{e151}";
|
pub const SEARCH: &str = "\u{e151}";
|
||||||
pub const PLUS: &str = "\u{e13d}";
|
pub const PLUS: &str = "\u{e13d}";
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,18 @@ const MOBILE_LAYER_HEADER_WIDTH: f32 = LAYER_HEIGHT * 0.5;
|
||||||
const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
|
const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
|
||||||
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
||||||
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
|
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
|
||||||
|
/// Height of the cycle lane carved off the *bottom* of the ruler. Dragging here edits the cycle
|
||||||
|
/// region; the ruler above it still scrubs the playhead as before. It goes at the bottom so the
|
||||||
|
/// bar numbers (which are drawn at the top of the ruler) stay legible.
|
||||||
|
const CYCLE_LANE_HEIGHT: f32 = 11.0;
|
||||||
|
|
||||||
|
// Snap "visual coarseness" profiles — the minimum on-screen spacing a grid line may have.
|
||||||
|
// See `Timeline::quantize_grid_size`.
|
||||||
|
/// Playhead scrubbing and clip edges: snap fine, down to 16ths when there's room.
|
||||||
|
const SNAP_PX_FINE: f64 = 15.0;
|
||||||
|
/// Cycle region: snap coarse, so loops land on whole bars rather than odd subdivisions.
|
||||||
|
/// Only drops to beats once you're zoomed in far enough to clearly be asking for it.
|
||||||
|
const SNAP_PX_CYCLE: f64 = 60.0;
|
||||||
const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip
|
const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip
|
||||||
const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
|
const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
|
||||||
const AUTOMATION_LANE_HEIGHT: f32 = 40.0;
|
const AUTOMATION_LANE_HEIGHT: f32 = 40.0;
|
||||||
|
|
@ -230,6 +242,20 @@ enum ClipDragType {
|
||||||
LoopExtendLeft,
|
LoopExtendLeft,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A drag on the cycle strip (the thin lane at the top of the ruler).
|
||||||
|
///
|
||||||
|
/// Mirrors `ClipDragType`'s three-zone model (left edge / body / right edge), reusing
|
||||||
|
/// `EDGE_DETECTION_PIXELS` for the handles.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum CycleDrag {
|
||||||
|
/// Dragging out a brand-new region; `anchor` is the beat the drag started from.
|
||||||
|
Create { anchor: Beats },
|
||||||
|
/// Sliding the whole region; `grab_offset` is where inside it the user grabbed.
|
||||||
|
Move { grab_offset: Beats },
|
||||||
|
ResizeStart,
|
||||||
|
ResizeEnd,
|
||||||
|
}
|
||||||
|
|
||||||
use lightningbeam_core::document::TimelineMode;
|
use lightningbeam_core::document::TimelineMode;
|
||||||
|
|
||||||
/// State for an in-progress layer header drag-to-reorder operation.
|
/// State for an in-progress layer header drag-to-reorder operation.
|
||||||
|
|
@ -267,6 +293,12 @@ pub struct TimelinePane {
|
||||||
/// Is the user currently dragging the playhead?
|
/// Is the user currently dragging the playhead?
|
||||||
is_scrubbing: bool,
|
is_scrubbing: bool,
|
||||||
|
|
||||||
|
/// In-flight drag on the cycle strip, if any.
|
||||||
|
cycle_drag: Option<CycleDrag>,
|
||||||
|
/// Live preview of the cycle region while dragging. The document is only updated on release
|
||||||
|
/// (via one `SetCycleRegionAction`), so a drag doesn't spam the undo stack — same as clip drags.
|
||||||
|
cycle_preview: Option<(Beats, Beats)>,
|
||||||
|
|
||||||
/// Is the user panning the timeline?
|
/// Is the user panning the timeline?
|
||||||
is_panning: bool,
|
is_panning: bool,
|
||||||
last_pan_pos: Option<egui::Pos2>,
|
last_pan_pos: Option<egui::Pos2>,
|
||||||
|
|
@ -693,6 +725,8 @@ impl TimelinePane {
|
||||||
keyframe_diamond_hits: Vec::new(),
|
keyframe_diamond_hits: Vec::new(),
|
||||||
duration: 10.0, // Default 10 seconds
|
duration: 10.0, // Default 10 seconds
|
||||||
is_scrubbing: false,
|
is_scrubbing: false,
|
||||||
|
cycle_drag: None,
|
||||||
|
cycle_preview: None,
|
||||||
is_panning: false,
|
is_panning: false,
|
||||||
last_pan_pos: None,
|
last_pan_pos: None,
|
||||||
lp_time: None,
|
lp_time: None,
|
||||||
|
|
@ -858,6 +892,35 @@ impl TimelinePane {
|
||||||
self.automation_cache.insert(layer_id, lanes);
|
self.automation_cache.insert(layer_id, lanes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggle the transport cycle (loop) on/off.
|
||||||
|
///
|
||||||
|
/// This is the only way to arm looping; the cycle strip on the ruler is shown (and draggable)
|
||||||
|
/// only while armed. Arming with no region set seeds a default one at the playhead so the
|
||||||
|
/// button does something immediately rather than revealing an empty strip.
|
||||||
|
pub(crate) fn toggle_cycle(&mut self, shared: &mut SharedPaneState) {
|
||||||
|
let document = shared.action_executor.document();
|
||||||
|
let arming = !document.cycle_enabled;
|
||||||
|
|
||||||
|
let region = if arming && document.cycle_region.is_none() {
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let beats_per_bar = (document.time_signature.numerator.max(1)) as f64;
|
||||||
|
// Start at the bar containing the playhead, and run for a few bars.
|
||||||
|
let playhead_beats = tempo_map.seconds_to_beats(Seconds(*shared.playback_time));
|
||||||
|
let bar = (playhead_beats.beats_to_f64() / beats_per_bar).floor().max(0.0);
|
||||||
|
let start = Beats(bar * beats_per_bar);
|
||||||
|
const DEFAULT_BARS: f64 = 4.0;
|
||||||
|
Some((start, start + Beats(beats_per_bar * DEFAULT_BARS)))
|
||||||
|
} else {
|
||||||
|
document.cycle_region
|
||||||
|
};
|
||||||
|
|
||||||
|
let action =
|
||||||
|
lightningbeam_core::actions::SetCycleRegionAction::new(document, region, arming);
|
||||||
|
if !action.is_noop() {
|
||||||
|
shared.pending_actions.push(Box::new(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle recording on/off
|
/// Toggle recording on/off
|
||||||
/// In Auto mode, records to the active layer (audio or video with camera)
|
/// In Auto mode, records to the active layer (audio or video with camera)
|
||||||
pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
|
pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
|
||||||
|
|
@ -1418,6 +1481,119 @@ impl TimelinePane {
|
||||||
self.viewport_start_time = (self.viewport_start_time + time_delta as f64).max(0.0);
|
self.viewport_start_time = (self.viewport_start_time + time_delta as f64).max(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cycle lane: a thin band carved off the *bottom* of the ruler. Dragging here edits the
|
||||||
|
/// cycle region; the ruler above it still scrubs the playhead. Bottom rather than top so it
|
||||||
|
/// doesn't sit on the bar numbers.
|
||||||
|
fn cycle_lane_rect(ruler_rect: egui::Rect) -> egui::Rect {
|
||||||
|
let h = CYCLE_LANE_HEIGHT.min(ruler_rect.height());
|
||||||
|
egui::Rect::from_min_max(
|
||||||
|
egui::pos2(ruler_rect.min.x, ruler_rect.max.y - h),
|
||||||
|
ruler_rect.max,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cycle region currently being shown: the live drag preview if one is in flight,
|
||||||
|
/// otherwise whatever the document has.
|
||||||
|
fn shown_cycle_region(
|
||||||
|
&self,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
) -> Option<(Beats, Beats)> {
|
||||||
|
self.cycle_preview.or(document.cycle_region)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-zone hit test on the cycle lane: left edge / body / right edge of the existing region,
|
||||||
|
/// mirroring how clips detect their trim handles. Anywhere else draws a fresh region.
|
||||||
|
///
|
||||||
|
/// `pos_x` is in screen coords; `content_min_x` is the content area's left edge (which the
|
||||||
|
/// ruler shares, so beats→x lands in the same space).
|
||||||
|
fn cycle_drag_at(
|
||||||
|
&self,
|
||||||
|
pos_x: f32,
|
||||||
|
beat: Beats,
|
||||||
|
content_min_x: f32,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
) -> CycleDrag {
|
||||||
|
let Some((s, e)) = document.cycle_region else {
|
||||||
|
return CycleDrag::Create { anchor: beat };
|
||||||
|
};
|
||||||
|
let sx = content_min_x + self.beats_to_x(s, document.tempo_map());
|
||||||
|
let ex = content_min_x + self.beats_to_x(e, document.tempo_map());
|
||||||
|
if (pos_x - sx).abs() <= EDGE_DETECTION_PIXELS {
|
||||||
|
CycleDrag::ResizeStart
|
||||||
|
} else if (pos_x - ex).abs() <= EDGE_DETECTION_PIXELS {
|
||||||
|
CycleDrag::ResizeEnd
|
||||||
|
} else if pos_x > sx && pos_x < ex {
|
||||||
|
CycleDrag::Move { grab_offset: beat - s }
|
||||||
|
} else {
|
||||||
|
CycleDrag::Create { anchor: beat }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel x (relative to the content area) → beats, snapped on the coarse cycle grid.
|
||||||
|
fn x_to_beats_cycle_snapped(
|
||||||
|
&self,
|
||||||
|
x: f32,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
) -> Beats {
|
||||||
|
let secs = self.snap_to_grid(
|
||||||
|
self.x_to_time(x.max(0.0)).max(0.0),
|
||||||
|
document.tempo_map(),
|
||||||
|
&document.time_signature,
|
||||||
|
document.framerate,
|
||||||
|
SNAP_PX_CYCLE,
|
||||||
|
);
|
||||||
|
document.tempo_map().seconds_to_beats(Seconds(secs.max(0.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the cycle lane and the cycle region band, *inside* the ruler.
|
||||||
|
///
|
||||||
|
/// Called from `render_ruler` right after the ruler's background and before its ticks/labels,
|
||||||
|
/// so the tick marks read through the band and the bar numbers (up top) are never covered.
|
||||||
|
///
|
||||||
|
/// The lane only exists while looping is armed — with cycle off there's no lane and the ruler
|
||||||
|
/// is entirely the playhead scrubber, exactly as before this feature.
|
||||||
|
fn paint_cycle_lane(
|
||||||
|
&self,
|
||||||
|
ui: &egui::Ui,
|
||||||
|
ruler_rect: egui::Rect,
|
||||||
|
theme: &crate::theme::Theme,
|
||||||
|
tempo_map: &daw_backend::TempoMap,
|
||||||
|
region: Option<(Beats, Beats)>,
|
||||||
|
) {
|
||||||
|
let painter = ui.painter();
|
||||||
|
let lane = Self::cycle_lane_rect(ruler_rect);
|
||||||
|
|
||||||
|
// A faint bed, so the lane still reads as a drag target when there's no region to grab.
|
||||||
|
painter.rect_filled(
|
||||||
|
lane,
|
||||||
|
0.0,
|
||||||
|
theme.bg_color(&["#timeline", ".cycle-lane"], ui.ctx(), egui::Color32::from_gray(48)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some((start, end)) = region else { return };
|
||||||
|
if end <= start {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sx = self.beats_to_x(start, tempo_map);
|
||||||
|
let ex = self.beats_to_x(end, tempo_map);
|
||||||
|
if ex < 0.0 || sx > ruler_rect.width() {
|
||||||
|
return; // off-screen
|
||||||
|
}
|
||||||
|
|
||||||
|
let fill = theme.bg_color(
|
||||||
|
&["#timeline", ".cycle-region"],
|
||||||
|
ui.ctx(),
|
||||||
|
egui::Color32::from_rgb(230, 190, 60),
|
||||||
|
);
|
||||||
|
|
||||||
|
let band = egui::Rect::from_min_max(
|
||||||
|
egui::pos2((ruler_rect.min.x + sx).max(ruler_rect.min.x), lane.min.y),
|
||||||
|
egui::pos2((ruler_rect.min.x + ex).min(ruler_rect.max.x), lane.max.y),
|
||||||
|
);
|
||||||
|
painter.rect_filled(band, 2.0, fill);
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert time (seconds) to pixel x-coordinate
|
/// Convert time (seconds) to pixel x-coordinate
|
||||||
fn time_to_x(&self, time: f64) -> f32 {
|
fn time_to_x(&self, time: f64) -> f32 {
|
||||||
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
||||||
|
|
@ -1459,11 +1635,19 @@ impl TimelinePane {
|
||||||
/// - Measures mode: zoom-adaptive (coarser when zoomed out, None when very zoomed in)
|
/// - Measures mode: zoom-adaptive (coarser when zoomed out, None when very zoomed in)
|
||||||
/// - Frames mode: always 1/framerate regardless of zoom
|
/// - Frames mode: always 1/framerate regardless of zoom
|
||||||
/// - Seconds mode: no snapping
|
/// - Seconds mode: no snapping
|
||||||
|
///
|
||||||
|
/// `min_grid_px` is the **visual coarseness**: the smallest on-screen spacing a grid line is
|
||||||
|
/// allowed to have. The finest musical subdivision at least that wide wins, so a larger value
|
||||||
|
/// snaps to coarser units at the same zoom. Callers pick a profile: [`SNAP_PX_FINE`] for the
|
||||||
|
/// playhead and clip edges, [`SNAP_PX_CYCLE`] for the cycle region (you nearly always want to
|
||||||
|
/// loop whole bars — "four bars and an eighth" is essentially never intended; zoom in if you
|
||||||
|
/// really do want it).
|
||||||
fn quantize_grid_size(
|
fn quantize_grid_size(
|
||||||
&self,
|
&self,
|
||||||
tempo_map: &daw_backend::TempoMap,
|
tempo_map: &daw_backend::TempoMap,
|
||||||
time_sig: &lightningbeam_core::document::TimeSignature,
|
time_sig: &lightningbeam_core::document::TimeSignature,
|
||||||
framerate: f64,
|
framerate: f64,
|
||||||
|
min_grid_px: f64,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
match self.time_display_format {
|
match self.time_display_format {
|
||||||
TimelineMode::Frames => Some(1.0 / framerate),
|
TimelineMode::Frames => Some(1.0 / framerate),
|
||||||
|
|
@ -1472,17 +1656,17 @@ impl TimelinePane {
|
||||||
let beat = beat_duration(0.0, tempo_map);
|
let beat = beat_duration(0.0, tempo_map);
|
||||||
let measure = measure_duration(0.0, tempo_map, time_sig);
|
let measure = measure_duration(0.0, tempo_map, time_sig);
|
||||||
let pps = self.pixels_per_second as f64;
|
let pps = self.pixels_per_second as f64;
|
||||||
// Very zoomed in: 16th note > 40px → no snap
|
// So zoomed in that even the finest subdivision is a huge target → free positioning.
|
||||||
if pps * beat / 4.0 > 40.0 { return None; }
|
// Scaled off the coarseness so a coarse profile doesn't give up snapping early.
|
||||||
// Find finest subdivision with >= 15px spacing (finest → coarsest)
|
if pps * beat / 4.0 > min_grid_px * 2.5 { return None; }
|
||||||
const MIN_PX: f64 = 15.0;
|
// Find the finest subdivision with >= min_grid_px spacing (finest → coarsest)
|
||||||
for &sub in &[beat / 4.0, beat / 2.0, beat, beat * 2.0, measure] {
|
for &sub in &[beat / 4.0, beat / 2.0, beat, beat * 2.0, measure] {
|
||||||
if pps * sub >= MIN_PX { return Some(sub); }
|
if pps * sub >= min_grid_px { return Some(sub); }
|
||||||
}
|
}
|
||||||
// Very zoomed out: try 2x, 4x, ... multiples of a measure
|
// Very zoomed out: try 2x, 4x, ... multiples of a measure
|
||||||
let mut m = measure * 2.0;
|
let mut m = measure * 2.0;
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
if pps * m >= MIN_PX { return Some(m); }
|
if pps * m >= min_grid_px { return Some(m); }
|
||||||
m *= 2.0;
|
m *= 2.0;
|
||||||
}
|
}
|
||||||
Some(measure)
|
Some(measure)
|
||||||
|
|
@ -1492,14 +1676,16 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snap a time value to the nearest quantization grid point (or return unchanged).
|
/// Snap a time value to the nearest quantization grid point (or return unchanged).
|
||||||
|
/// See [`Self::quantize_grid_size`] for `min_grid_px` (the visual coarseness).
|
||||||
fn snap_to_grid(
|
fn snap_to_grid(
|
||||||
&self,
|
&self,
|
||||||
t: f64,
|
t: f64,
|
||||||
tempo_map: &daw_backend::TempoMap,
|
tempo_map: &daw_backend::TempoMap,
|
||||||
time_sig: &lightningbeam_core::document::TimeSignature,
|
time_sig: &lightningbeam_core::document::TimeSignature,
|
||||||
framerate: f64,
|
framerate: f64,
|
||||||
|
min_grid_px: f64,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
match self.quantize_grid_size(tempo_map, time_sig, framerate) {
|
match self.quantize_grid_size(tempo_map, time_sig, framerate, min_grid_px) {
|
||||||
Some(grid) => (t / grid).round() * grid,
|
Some(grid) => (t / grid).round() * grid,
|
||||||
None => t,
|
None => t,
|
||||||
}
|
}
|
||||||
|
|
@ -1516,7 +1702,7 @@ impl TimelinePane {
|
||||||
framerate: f64,
|
framerate: f64,
|
||||||
) -> Beats {
|
) -> Beats {
|
||||||
let anchor = self.drag_anchor_start; // seconds
|
let anchor = self.drag_anchor_start; // seconds
|
||||||
let target = match self.quantize_grid_size(tempo_map, time_sig, framerate) {
|
let target = match self.quantize_grid_size(tempo_map, time_sig, framerate, SNAP_PX_FINE) {
|
||||||
Some(grid) => ((anchor + self.drag_offset) / grid).round() * grid,
|
Some(grid) => ((anchor + self.drag_offset) / grid).round() * grid,
|
||||||
None => anchor + self.drag_offset,
|
None => anchor + self.drag_offset,
|
||||||
};
|
};
|
||||||
|
|
@ -1564,7 +1750,8 @@ impl TimelinePane {
|
||||||
|
|
||||||
/// Render the time ruler at the top
|
/// Render the time ruler at the top
|
||||||
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme,
|
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme,
|
||||||
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) {
|
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64,
|
||||||
|
cycle: Option<Option<(Beats, Beats)>>) {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
|
|
@ -1572,6 +1759,12 @@ impl TimelinePane {
|
||||||
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
||||||
painter.rect_filled(rect, 0.0, bg_color);
|
painter.rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
// Cycle lane goes under the ticks/labels: `Some(region)` when looping is armed (the region
|
||||||
|
// itself may still be `None`), `None` when it's off and the lane shouldn't exist at all.
|
||||||
|
if let Some(region) = cycle {
|
||||||
|
self.paint_cycle_lane(ui, rect, theme, tempo_map, region);
|
||||||
|
}
|
||||||
|
|
||||||
let text_style = theme.style(".text-primary", ui.ctx());
|
let text_style = theme.style(".text-primary", ui.ctx());
|
||||||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||||||
|
|
||||||
|
|
@ -3265,7 +3458,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClipDragType::TrimLeft => {
|
ClipDragType::TrimLeft => {
|
||||||
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate).max(0.0).min(clip_dur_secs);
|
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs);
|
||||||
let trim_offset_secs = new_trim - ci.trim_start;
|
let trim_offset_secs = new_trim - ci.trim_start;
|
||||||
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
||||||
let dur_secs = if let Some(trim_end) = ci.trim_end {
|
let dur_secs = if let Some(trim_end) = ci.trim_end {
|
||||||
|
|
@ -3277,7 +3470,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
ClipDragType::TrimRight => {
|
ClipDragType::TrimRight => {
|
||||||
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||||
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur_secs);
|
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs);
|
||||||
let dur_secs = (new_trim_end - ci.trim_start).max(0.0);
|
let dur_secs = (new_trim_end - ci.trim_start).max(0.0);
|
||||||
duration = secs_to_beats_at(start, dur_secs);
|
duration = secs_to_beats_at(start, dur_secs);
|
||||||
}
|
}
|
||||||
|
|
@ -3287,7 +3480,7 @@ impl TimelinePane {
|
||||||
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
||||||
let current_right = ci.timeline_duration.unwrap_or(content_window);
|
let current_right = ci.timeline_duration.unwrap_or(content_window);
|
||||||
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
|
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
|
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE);
|
||||||
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
|
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
|
||||||
let new_right = (snapped_edge - ci.timeline_start).max(content_window);
|
let new_right = (snapped_edge - ci.timeline_start).max(content_window);
|
||||||
let loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
let loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
||||||
|
|
@ -3371,7 +3564,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
ClipDragType::TrimLeft => {
|
ClipDragType::TrimLeft => {
|
||||||
// Trim left: calculate new trim_start with snap to adjacent clips
|
// Trim left: calculate new trim_start with snap to adjacent clips
|
||||||
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
|
||||||
.max(0.0)
|
.max(0.0)
|
||||||
.min(clip_duration.seconds_to_f64());
|
.min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
|
|
@ -3411,7 +3604,7 @@ impl TimelinePane {
|
||||||
ClipDragType::TrimRight => {
|
ClipDragType::TrimRight => {
|
||||||
// Trim right: extend or reduce duration with snap to adjacent clips
|
// Trim right: extend or reduce duration with snap to adjacent clips
|
||||||
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
|
||||||
.max(clip_instance.trim_start)
|
.max(clip_instance.trim_start)
|
||||||
.min(clip_duration.seconds_to_f64());
|
.min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
|
|
@ -3454,7 +3647,7 @@ impl TimelinePane {
|
||||||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||||
// Snap the right edge in the seconds/pixel domain (drag_offset is seconds).
|
// Snap the right edge in the seconds/pixel domain (drag_offset is seconds).
|
||||||
let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
|
let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
|
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE);
|
||||||
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
|
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
|
||||||
let desired_right = (snapped_edge - ts).max(content_window);
|
let desired_right = (snapped_edge - ts).max(content_window);
|
||||||
|
|
||||||
|
|
@ -4585,7 +4778,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// New trim_start is snapped then clamped to valid range
|
// New trim_start is snapped then clamped to valid range
|
||||||
let desired_trim_start = self.snap_to_grid(
|
let desired_trim_start = self.snap_to_grid(
|
||||||
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
|
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
|
||||||
).max(0.0).min(clip_duration.seconds_to_f64());
|
).max(0.0).min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
// Apply overlap prevention when extending left (content-seconds gap).
|
// Apply overlap prevention when extending left (content-seconds gap).
|
||||||
|
|
@ -4633,7 +4826,7 @@ impl TimelinePane {
|
||||||
clip_instance.effective_duration(clip_duration, document.tempo_map());
|
clip_instance.effective_duration(clip_duration, document.tempo_map());
|
||||||
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
let desired_trim_end = self.snap_to_grid(
|
let desired_trim_end = self.snap_to_grid(
|
||||||
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
|
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
|
||||||
).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64());
|
).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
// Apply overlap prevention when extending right (content-seconds gap).
|
// Apply overlap prevention when extending right (content-seconds gap).
|
||||||
|
|
@ -4715,7 +4908,7 @@ impl TimelinePane {
|
||||||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||||
// Snap the right edge in the seconds/pixel domain.
|
// Snap the right edge in the seconds/pixel domain.
|
||||||
let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
|
let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
|
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE);
|
||||||
let desired_right = tmap.seconds_to_beats(Seconds(snapped_edge_secs)) - ts;
|
let desired_right = tmap.seconds_to_beats(Seconds(snapped_edge_secs)) - ts;
|
||||||
|
|
||||||
let new_right = if desired_right > current_right {
|
let new_right = if desired_right > current_right {
|
||||||
|
|
@ -4917,14 +5110,120 @@ impl TimelinePane {
|
||||||
let visible_height = content_rect.height();
|
let visible_height = content_rect.height();
|
||||||
let max_scroll_y = (total_content_height - visible_height).max(0.0);
|
let max_scroll_y = (total_content_height - visible_height).max(0.0);
|
||||||
|
|
||||||
// Scrubbing (clicking/dragging on ruler, but only when not panning)
|
// ---- Cycle region (the lane along the bottom of the ruler) ----
|
||||||
let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default()));
|
// The lane only exists while looping is armed; with cycle off the ruler is entirely the
|
||||||
|
// playhead scrubber, as it was before this feature.
|
||||||
|
//
|
||||||
|
// The lane is driven straight off raw pointer state rather than an egui `Response`. It sits
|
||||||
|
// inside the timeline's content response — which spans the whole ruler + content and already
|
||||||
|
// drives scrubbing, clip drags and panning — so registering a second widget on the same
|
||||||
|
// pixels just makes the two contest hover every frame (the cursor visibly flickers and
|
||||||
|
// neither reliably owns the press). Reading the pointer directly sidesteps egui's widget
|
||||||
|
// layering entirely. The lane is also carved out of the scrub area below, so a loop drag
|
||||||
|
// never also yanks the playhead.
|
||||||
|
let cycle_armed = document.cycle_enabled;
|
||||||
|
let cycle_lane = Self::cycle_lane_rect(ruler_rect);
|
||||||
|
let hover_pos = ui.input(|i| i.pointer.hover_pos());
|
||||||
|
let (primary_pressed, primary_down, interact_pos) = ui.input(|i| {
|
||||||
|
(
|
||||||
|
i.pointer.primary_pressed(),
|
||||||
|
i.pointer.primary_down(),
|
||||||
|
i.pointer.interact_pos(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if cycle_armed {
|
||||||
|
// Begin: press inside the lane. Three-zone hit test picks resize / move / draw-new.
|
||||||
|
if self.cycle_drag.is_none() && !alt_held && !self.is_panning && primary_pressed {
|
||||||
|
if let Some(pos) = interact_pos.filter(|p| cycle_lane.contains(*p)) {
|
||||||
|
let beat = self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document);
|
||||||
|
self.cycle_drag =
|
||||||
|
Some(self.cycle_drag_at(pos.x, beat, content_rect.min.x, document));
|
||||||
|
self.cycle_preview = document.cycle_region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegraph what a press would do: resize at the edges, move over the body.
|
||||||
|
if let Some(pos) = hover_pos.filter(|p| cycle_lane.contains(*p)) {
|
||||||
|
let zone = self.cycle_drag.unwrap_or_else(|| {
|
||||||
|
let beat = self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document);
|
||||||
|
self.cycle_drag_at(pos.x, beat, content_rect.min.x, document)
|
||||||
|
});
|
||||||
|
ui.output_mut(|o| {
|
||||||
|
o.cursor_icon = match zone {
|
||||||
|
CycleDrag::ResizeStart | CycleDrag::ResizeEnd => {
|
||||||
|
egui::CursorIcon::ResizeHorizontal
|
||||||
|
}
|
||||||
|
CycleDrag::Move { .. } => egui::CursorIcon::Grab,
|
||||||
|
CycleDrag::Create { .. } => egui::CursorIcon::Crosshair,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(drag) = self.cycle_drag {
|
||||||
|
if primary_down {
|
||||||
|
// Track the pointer even when it leaves the lane, like any other drag.
|
||||||
|
if let Some(pos) = interact_pos {
|
||||||
|
let beat =
|
||||||
|
self.x_to_beats_cycle_snapped(pos.x - content_rect.min.x, document);
|
||||||
|
let base = self.cycle_preview.or(document.cycle_region);
|
||||||
|
self.cycle_preview = match drag {
|
||||||
|
CycleDrag::Create { anchor } => {
|
||||||
|
let (a, b) =
|
||||||
|
if beat < anchor { (beat, anchor) } else { (anchor, beat) };
|
||||||
|
Some((a, b))
|
||||||
|
}
|
||||||
|
CycleDrag::ResizeStart => base.map(|(_, e)| (beat.min(e), e)),
|
||||||
|
CycleDrag::ResizeEnd => base.map(|(s, _)| (s, beat.max(s))),
|
||||||
|
CycleDrag::Move { grab_offset } => base.map(|(s, e)| {
|
||||||
|
let len = e - s;
|
||||||
|
let ns = (beat - grab_offset).max(Beats::ZERO);
|
||||||
|
(ns, ns + len)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Released — commit the gesture as ONE undoable action (the drag itself only
|
||||||
|
// ever touched `cycle_preview`, so the undo stack doesn't get a frame-by-frame
|
||||||
|
// trail). Looping is already armed (the lane wouldn't be there otherwise), so
|
||||||
|
// `cycle_enabled` is left alone.
|
||||||
|
let preview = self.cycle_preview.take();
|
||||||
|
let collapsed = preview.map_or(true, |(s, e)| e <= s);
|
||||||
|
let drawing_new = matches!(drag, CycleDrag::Create { .. });
|
||||||
|
self.cycle_drag = None;
|
||||||
|
|
||||||
|
// A bare click on empty lane is a zero-width "draw" — treat it as nothing
|
||||||
|
// happened rather than silently wiping the region out from under the user.
|
||||||
|
// Collapsing an *existing* region by dragging an edge past the other one is
|
||||||
|
// still a deliberate "clear it".
|
||||||
|
if !(collapsed && drawing_new) {
|
||||||
|
let action = lightningbeam_core::actions::SetCycleRegionAction::new(
|
||||||
|
document,
|
||||||
|
preview.filter(|(s, e)| e > s),
|
||||||
|
document.cycle_enabled,
|
||||||
|
);
|
||||||
|
if !action.is_noop() {
|
||||||
|
pending_actions.push(Box::new(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if self.cycle_drag.is_some() {
|
||||||
|
// Looping was disarmed mid-drag — abandon the gesture rather than commit it.
|
||||||
|
self.cycle_drag = None;
|
||||||
|
self.cycle_preview = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrubbing (clicking/dragging on ruler, but only when not panning).
|
||||||
|
let cursor_over_ruler = hover_pos.map_or(false, |p| {
|
||||||
|
ruler_rect.contains(p) && !(cycle_armed && cycle_lane.contains(p))
|
||||||
|
}) && self.cycle_drag.is_none();
|
||||||
|
|
||||||
// Start scrubbing if cursor is over ruler and we click/drag
|
// Start scrubbing if cursor is over ruler and we click/drag
|
||||||
if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) {
|
if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) {
|
||||||
if let Some(pos) = response.interact_pointer_pos() {
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
let x = (pos.x - content_rect.min.x).max(0.0);
|
let x = (pos.x - content_rect.min.x).max(0.0);
|
||||||
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate);
|
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE);
|
||||||
*playback_time = new_time;
|
*playback_time = new_time;
|
||||||
self.is_scrubbing = true;
|
self.is_scrubbing = true;
|
||||||
// Seek immediately so it works while playing
|
// Seek immediately so it works while playing
|
||||||
|
|
@ -4938,7 +5237,7 @@ impl TimelinePane {
|
||||||
else if self.is_scrubbing && response.dragged() && !self.is_panning {
|
else if self.is_scrubbing && response.dragged() && !self.is_panning {
|
||||||
if let Some(pos) = response.interact_pointer_pos() {
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
let x = (pos.x - content_rect.min.x).max(0.0);
|
let x = (pos.x - content_rect.min.x).max(0.0);
|
||||||
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate);
|
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE);
|
||||||
*playback_time = new_time;
|
*playback_time = new_time;
|
||||||
if let Some(controller_arc) = audio_controller {
|
if let Some(controller_arc) = audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
|
@ -5179,6 +5478,27 @@ impl PaneRenderer for TimelinePane {
|
||||||
self.toggle_recording(shared);
|
self.toggle_recording(shared);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cycle (loop) toggle. This is the only way to arm looping — the cycle strip on the
|
||||||
|
// ruler is only shown (and only draggable) while it's armed.
|
||||||
|
let cycle_on = shared.action_executor.document().cycle_enabled;
|
||||||
|
let cycle_color = if cycle_on {
|
||||||
|
egui::Color32::from_rgb(230, 190, 60)
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_gray(140)
|
||||||
|
};
|
||||||
|
let cycle_button = egui::Button::new(
|
||||||
|
egui::RichText::new(crate::mobile::icons::REPEAT)
|
||||||
|
.font(crate::mobile::icons::font(15.0))
|
||||||
|
.color(cycle_color),
|
||||||
|
);
|
||||||
|
if ui
|
||||||
|
.add_sized(button_size, cycle_button)
|
||||||
|
.on_hover_text("Cycle (loop region)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.toggle_cycle(shared);
|
||||||
|
}
|
||||||
|
|
||||||
// Request repaint while recording for pulse animation
|
// Request repaint while recording for pulse animation
|
||||||
if *shared.is_recording {
|
if *shared.is_recording {
|
||||||
ui.ctx().request_repaint();
|
ui.ctx().request_repaint();
|
||||||
|
|
@ -5530,7 +5850,10 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render time ruler (clip to ruler rect)
|
// Render time ruler (clip to ruler rect)
|
||||||
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
||||||
self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate);
|
let cycle = document
|
||||||
|
.cycle_enabled
|
||||||
|
.then(|| self.shown_cycle_region(document));
|
||||||
|
self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate, cycle);
|
||||||
|
|
||||||
// Render layer rows with clipping
|
// Render layer rows with clipping
|
||||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue