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.
This commit is contained in:
parent
9f67a82e3e
commit
8cde113797
|
|
@ -33,6 +33,15 @@ pub struct Engine {
|
|||
playing: bool,
|
||||
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
|
||||
command_rx: rtrb::Consumer<Command>,
|
||||
midi_command_rx: Option<rtrb::Consumer<Command>>,
|
||||
|
|
@ -155,6 +164,9 @@ impl Engine {
|
|||
sample_rate,
|
||||
playing: false,
|
||||
channels,
|
||||
loop_region: None,
|
||||
loop_enabled: false,
|
||||
loop_bounds_frozen: None,
|
||||
command_rx,
|
||||
midi_command_rx: None,
|
||||
event_tx,
|
||||
|
|
@ -508,6 +520,38 @@ impl Engine {
|
|||
// Update playhead (convert total samples to frames)
|
||||
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)
|
||||
self.playhead_atomic
|
||||
.store(self.playhead.max(0) as u64, Ordering::Relaxed);
|
||||
|
|
@ -679,6 +723,8 @@ impl Engine {
|
|||
format!("Recording write error: {}", e)
|
||||
));
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn handle_command(&mut self, cmd: Command) {
|
||||
match cmd {
|
||||
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;
|
||||
}
|
||||
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 => {
|
||||
self.playing = false;
|
||||
self.playhead = 0;
|
||||
|
|
@ -1309,6 +1414,12 @@ impl Engine {
|
|||
// Stop any active recording
|
||||
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
|
||||
self.project = Project::new(self.sample_rate);
|
||||
|
||||
|
|
@ -3034,6 +3145,19 @@ impl Engine {
|
|||
use crate::io::WavWriter;
|
||||
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
|
||||
if let Some(crate::audio::track::TrackNode::Audio(_)) = self.project.get_track_mut(track_id) {
|
||||
// Generate a unique temp file path
|
||||
|
|
@ -3118,6 +3242,9 @@ impl Engine {
|
|||
fn handle_stop_recording(&mut self) {
|
||||
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
|
||||
if self.midi_recording_state.is_some() {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub fn stop(&mut self) {
|
||||
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)
|
||||
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
|
||||
/// Start recording on a track (track_id, start_time)
|
||||
StartRecording(TrackId, Beats),
|
||||
|
|
|
|||
|
|
@ -3628,7 +3628,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.8-alpha"
|
||||
version = "1.0.9-alpha"
|
||||
dependencies = [
|
||||
"beamdsp",
|
||||
"bytemuck",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod move_clip_instances;
|
|||
pub mod reorder_clip_instances;
|
||||
pub mod paint_bucket;
|
||||
pub mod remove_effect;
|
||||
pub mod set_cycle_region;
|
||||
pub mod set_document_properties;
|
||||
pub mod set_instance_properties;
|
||||
pub mod set_layer_properties;
|
||||
|
|
@ -50,6 +51,7 @@ pub mod set_text_content;
|
|||
pub mod resize_text_box;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use set_cycle_region::SetCycleRegionAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
pub use add_layer::AddLayerAction;
|
||||
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)]
|
||||
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).
|
||||
/// Stored separately from the root layer tree; shown in timeline when
|
||||
/// `show_master_track` is enabled in the editor state.
|
||||
|
|
@ -297,6 +308,8 @@ impl Default for Document {
|
|||
height: 1080.0,
|
||||
framerate: 60.0,
|
||||
time_signature: TimeSignature::default(),
|
||||
cycle_region: None,
|
||||
cycle_enabled: false,
|
||||
master_layer: {
|
||||
let mut ml = GroupLayer::new_master(120.0);
|
||||
ml.layer.id = uuid::Uuid::new_v4();
|
||||
|
|
|
|||
|
|
@ -2039,6 +2039,22 @@ impl EditorApp {
|
|||
fn sync_audio_layers_to_backend(&mut self) {
|
||||
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.
|
||||
let master_layer_id = self.action_executor.document().master_layer.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 PLAY: &str = "\u{e13c}";
|
||||
pub const PAUSE: &str = "\u{e12e}";
|
||||
pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle
|
||||
pub const SETTINGS: &str = "\u{e154}";
|
||||
pub const SEARCH: &str = "\u{e151}";
|
||||
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 MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
||||
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 MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
|
||||
const AUTOMATION_LANE_HEIGHT: f32 = 40.0;
|
||||
|
|
@ -230,6 +242,20 @@ enum ClipDragType {
|
|||
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;
|
||||
|
||||
/// 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_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_panning: bool,
|
||||
last_pan_pos: Option<egui::Pos2>,
|
||||
|
|
@ -693,6 +725,8 @@ impl TimelinePane {
|
|||
keyframe_diamond_hits: Vec::new(),
|
||||
duration: 10.0, // Default 10 seconds
|
||||
is_scrubbing: false,
|
||||
cycle_drag: None,
|
||||
cycle_preview: None,
|
||||
is_panning: false,
|
||||
last_pan_pos: None,
|
||||
lp_time: None,
|
||||
|
|
@ -858,6 +892,35 @@ impl TimelinePane {
|
|||
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
|
||||
/// In Auto mode, records to the active layer (audio or video with camera)
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn time_to_x(&self, time: f64) -> 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)
|
||||
/// - Frames mode: always 1/framerate regardless of zoom
|
||||
/// - 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(
|
||||
&self,
|
||||
tempo_map: &daw_backend::TempoMap,
|
||||
time_sig: &lightningbeam_core::document::TimeSignature,
|
||||
framerate: f64,
|
||||
min_grid_px: f64,
|
||||
) -> Option<f64> {
|
||||
match self.time_display_format {
|
||||
TimelineMode::Frames => Some(1.0 / framerate),
|
||||
|
|
@ -1472,17 +1656,17 @@ impl TimelinePane {
|
|||
let beat = beat_duration(0.0, tempo_map);
|
||||
let measure = measure_duration(0.0, tempo_map, time_sig);
|
||||
let pps = self.pixels_per_second as f64;
|
||||
// Very zoomed in: 16th note > 40px → no snap
|
||||
if pps * beat / 4.0 > 40.0 { return None; }
|
||||
// Find finest subdivision with >= 15px spacing (finest → coarsest)
|
||||
const MIN_PX: f64 = 15.0;
|
||||
// So zoomed in that even the finest subdivision is a huge target → free positioning.
|
||||
// Scaled off the coarseness so a coarse profile doesn't give up snapping early.
|
||||
if pps * beat / 4.0 > min_grid_px * 2.5 { return None; }
|
||||
// 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] {
|
||||
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
|
||||
let mut m = measure * 2.0;
|
||||
for _ in 0..10 {
|
||||
if pps * m >= MIN_PX { return Some(m); }
|
||||
if pps * m >= min_grid_px { return Some(m); }
|
||||
m *= 2.0;
|
||||
}
|
||||
Some(measure)
|
||||
|
|
@ -1492,14 +1676,16 @@ impl TimelinePane {
|
|||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
t: f64,
|
||||
tempo_map: &daw_backend::TempoMap,
|
||||
time_sig: &lightningbeam_core::document::TimeSignature,
|
||||
framerate: f64,
|
||||
min_grid_px: 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,
|
||||
None => t,
|
||||
}
|
||||
|
|
@ -1516,7 +1702,7 @@ impl TimelinePane {
|
|||
framerate: f64,
|
||||
) -> Beats {
|
||||
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,
|
||||
None => anchor + self.drag_offset,
|
||||
};
|
||||
|
|
@ -1564,7 +1750,8 @@ impl TimelinePane {
|
|||
|
||||
/// Render the time ruler at the top
|
||||
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();
|
||||
|
||||
// Background
|
||||
|
|
@ -1572,6 +1759,12 @@ impl TimelinePane {
|
|||
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
||||
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_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||||
|
||||
|
|
@ -3265,7 +3458,7 @@ impl TimelinePane {
|
|||
}
|
||||
}
|
||||
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;
|
||||
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
||||
let dur_secs = if let Some(trim_end) = ci.trim_end {
|
||||
|
|
@ -3277,7 +3470,7 @@ impl TimelinePane {
|
|||
}
|
||||
ClipDragType::TrimRight => {
|
||||
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);
|
||||
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 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 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 new_right = (snapped_edge - ci.timeline_start).max(content_window);
|
||||
let loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
||||
|
|
@ -3371,7 +3564,7 @@ impl TimelinePane {
|
|||
}
|
||||
ClipDragType::TrimLeft => {
|
||||
// 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)
|
||||
.min(clip_duration.seconds_to_f64());
|
||||
|
||||
|
|
@ -3411,7 +3604,7 @@ impl TimelinePane {
|
|||
ClipDragType::TrimRight => {
|
||||
// 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 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)
|
||||
.min(clip_duration.seconds_to_f64());
|
||||
|
||||
|
|
@ -3454,7 +3647,7 @@ impl TimelinePane {
|
|||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||
// 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 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 desired_right = (snapped_edge - ts).max(content_window);
|
||||
|
||||
|
|
@ -4585,7 +4778,7 @@ impl TimelinePane {
|
|||
|
||||
// New trim_start is snapped then clamped to valid range
|
||||
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());
|
||||
|
||||
// Apply overlap prevention when extending left (content-seconds gap).
|
||||
|
|
@ -4633,7 +4826,7 @@ impl TimelinePane {
|
|||
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 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());
|
||||
|
||||
// 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);
|
||||
// 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 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 new_right = if desired_right > current_right {
|
||||
|
|
@ -4917,14 +5110,120 @@ impl TimelinePane {
|
|||
let visible_height = content_rect.height();
|
||||
let max_scroll_y = (total_content_height - visible_height).max(0.0);
|
||||
|
||||
// Scrubbing (clicking/dragging on ruler, but only when not panning)
|
||||
let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default()));
|
||||
// ---- Cycle region (the lane along the bottom of the ruler) ----
|
||||
// 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
|
||||
if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) {
|
||||
if let Some(pos) = response.interact_pointer_pos() {
|
||||
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;
|
||||
self.is_scrubbing = true;
|
||||
// Seek immediately so it works while playing
|
||||
|
|
@ -4938,7 +5237,7 @@ impl TimelinePane {
|
|||
else if self.is_scrubbing && response.dragged() && !self.is_panning {
|
||||
if let Some(pos) = response.interact_pointer_pos() {
|
||||
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;
|
||||
if let Some(controller_arc) = audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
|
|
@ -5179,6 +5478,27 @@ impl PaneRenderer for TimelinePane {
|
|||
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
|
||||
if *shared.is_recording {
|
||||
ui.ctx().request_repaint();
|
||||
|
|
@ -5530,7 +5850,10 @@ impl PaneRenderer for TimelinePane {
|
|||
|
||||
// Render time ruler (clip to ruler 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
|
||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||
|
|
|
|||
Loading…
Reference in New Issue