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:
Skyler Lehmkuhl 2026-07-13 02:13:06 -04:00
parent 9f67a82e3e
commit f957b01dcf
10 changed files with 643 additions and 24 deletions

View File

@ -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);

View File

@ -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);
}
}
} }

View File

@ -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),

View File

@ -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",

View File

@ -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;

View File

@ -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(())
}
}

View File

@ -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();

View File

@ -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) {

View File

@ -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}";

View File

@ -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));