Add piano roll note snapping

This commit is contained in:
Skyler Lehmkuhl 2026-03-24 19:24:24 -04:00
parent 123fe3f21a
commit 65a550d8f4
6 changed files with 286 additions and 29 deletions

View File

@ -1673,6 +1673,7 @@ impl Engine {
Ok(json) => { Ok(json) => {
match crate::audio::node_graph::preset::GraphPreset::from_json(&json) { match crate::audio::node_graph::preset::GraphPreset::from_json(&json) {
Ok(preset) => { Ok(preset) => {
let preset_name = preset.metadata.name.clone();
// Extract the directory path from the preset path for resolving relative sample paths // Extract the directory path from the preset path for resolving relative sample paths
let preset_base_path = std::path::Path::new(&preset_path).parent(); let preset_base_path = std::path::Path::new(&preset_path).parent();
@ -1684,19 +1685,19 @@ impl Engine {
track.instrument_graph = graph; track.instrument_graph = graph;
track.graph_is_default = true; track.graph_is_default = true;
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name));
} }
Some(TrackNode::Audio(track)) => { Some(TrackNode::Audio(track)) => {
track.effects_graph = graph; track.effects_graph = graph;
track.graph_is_default = true; track.graph_is_default = true;
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name));
} }
Some(TrackNode::Group(track)) => { Some(TrackNode::Group(track)) => {
track.audio_graph = graph; track.audio_graph = graph;
track.graph_is_default = true; track.graph_is_default = true;
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name));
} }
_ => {} _ => {}
} }
@ -1729,6 +1730,7 @@ impl Engine {
Command::GraphLoadLbins(track_id, path) => { Command::GraphLoadLbins(track_id, path) => {
match crate::audio::node_graph::lbins::load_lbins(&path) { match crate::audio::node_graph::lbins::load_lbins(&path) {
Ok((preset, assets)) => { Ok((preset, assets)) => {
let preset_name = preset.metadata.name.clone();
match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) { match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) {
Ok(graph) => { Ok(graph) => {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
@ -1736,19 +1738,19 @@ impl Engine {
track.instrument_graph = graph; track.instrument_graph = graph;
track.graph_is_default = true; track.graph_is_default = true;
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name));
} }
Some(TrackNode::Audio(track)) => { Some(TrackNode::Audio(track)) => {
track.effects_graph = graph; track.effects_graph = graph;
track.graph_is_default = true; track.graph_is_default = true;
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name));
} }
Some(TrackNode::Group(track)) => { Some(TrackNode::Group(track)) => {
track.audio_graph = graph; track.audio_graph = graph;
track.graph_is_default = true; track.graph_is_default = true;
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name));
} }
_ => {} _ => {}
} }

View File

@ -310,8 +310,8 @@ pub enum AudioEvent {
GraphConnectionError(TrackId, String), GraphConnectionError(TrackId, String),
/// Graph state changed (for full UI sync) /// Graph state changed (for full UI sync)
GraphStateChanged(TrackId), GraphStateChanged(TrackId),
/// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded /// Preset fully loaded (track_id, preset_name) - emitted after all nodes and samples are loaded
GraphPresetLoaded(TrackId), GraphPresetLoaded(TrackId, String),
/// Preset has been saved to file (track_id, preset_path) /// Preset has been saved to file (track_id, preset_path)
GraphPresetSaved(TrackId, String), GraphPresetSaved(TrackId, String),
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source) /// Script compilation result (track_id, node_id, success, error, ui_declaration, source)

View File

@ -20,6 +20,8 @@ pub enum LayerProperty {
Visible(bool), Visible(bool),
/// Video layer only: toggle live webcam preview /// Video layer only: toggle live webcam preview
CameraEnabled(bool), CameraEnabled(bool),
/// Rename the layer; sets has_custom_name = true
Name(String),
} }
/// Stored old value for rollback /// Stored old value for rollback
@ -33,6 +35,7 @@ enum OldValue {
Opacity(f64), Opacity(f64),
Visible(bool), Visible(bool),
CameraEnabled(bool), CameraEnabled(bool),
Name(String),
} }
/// Action that sets a property on one or more layers /// Action that sets a property on one or more layers
@ -101,6 +104,7 @@ impl Action for SetLayerPropertiesAction {
}; };
OldValue::CameraEnabled(val) OldValue::CameraEnabled(val)
} }
LayerProperty::Name(_) => OldValue::Name(layer.name().to_string()),
}); });
} }
@ -118,6 +122,10 @@ impl Action for SetLayerPropertiesAction {
v.camera_enabled = *c; v.camera_enabled = *c;
} }
} }
LayerProperty::Name(n) => {
layer.set_name(n.clone());
layer.set_has_custom_name(true);
}
} }
} }
} }
@ -143,6 +151,10 @@ impl Action for SetLayerPropertiesAction {
v.camera_enabled = *c; v.camera_enabled = *c;
} }
} }
OldValue::Name(n) => {
layer.set_name(n.clone());
layer.set_has_custom_name(false);
}
} }
} }
} }
@ -206,6 +218,7 @@ impl Action for SetLayerPropertiesAction {
LayerProperty::InputGain(_) => "input gain", LayerProperty::InputGain(_) => "input gain",
LayerProperty::Muted(_) => "mute", LayerProperty::Muted(_) => "mute",
LayerProperty::Soloed(_) => "solo", LayerProperty::Soloed(_) => "solo",
LayerProperty::Name(_) => "name",
LayerProperty::Locked(_) => "lock", LayerProperty::Locked(_) => "lock",
LayerProperty::Opacity(_) => "opacity", LayerProperty::Opacity(_) => "opacity",
LayerProperty::Visible(_) => "visibility", LayerProperty::Visible(_) => "visibility",

View File

@ -5267,7 +5267,7 @@ impl eframe::App for EditorApp {
self.waveform_gpu_dirty.insert(pool_index); self.waveform_gpu_dirty.insert(pool_index);
ctx.request_repaint(); ctx.request_repaint();
} }
AudioEvent::GraphPresetLoaded(_track_id) => { AudioEvent::GraphPresetLoaded(track_id, preset_name) => {
// Preset was loaded on the audio thread — bump generation // Preset was loaded on the audio thread — bump generation
// so the node graph pane reloads from backend // so the node graph pane reloads from backend
self.project_generation += 1; self.project_generation += 1;
@ -5278,6 +5278,17 @@ impl eframe::App for EditorApp {
std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed,
|v| if v > 0 { Some(v - 1) } else { Some(0) }, |v| if v > 0 { Some(v - 1) } else { Some(0) },
); );
// Auto-rename the MIDI layer to match the preset name, unless
// the user has already given it a custom name
if let Some(&layer_uuid) = self.track_to_layer_map.get(&track_id) {
let doc = self.action_executor.document_mut();
if let Some(layer) = doc.get_layer_mut(&layer_uuid) {
use lightningbeam_core::layer::LayerTrait;
if !layer.has_custom_name() {
layer.set_name(preset_name);
}
}
}
ctx.request_repaint(); ctx.request_repaint();
} }
AudioEvent::InputLevel(peak) => { AudioEvent::InputLevel(peak) => {

View File

@ -34,6 +34,48 @@ enum PitchBendZone {
End, // Last 30%: ramp from 0 → bend End, // Last 30%: ramp from 0 → bend
} }
#[derive(Clone, Copy, Debug, PartialEq, Default)]
enum SnapValue {
#[default] None,
Whole, Half, Quarter, Eighth, Sixteenth, ThirtySecond,
QuarterTriplet, EighthTriplet, SixteenthTriplet, ThirtySecondTriplet,
EighthSwingLight, SixteenthSwingLight,
EighthSwingHeavy, SixteenthSwingHeavy,
}
impl SnapValue {
fn label(self) -> &'static str {
match self {
Self::None => "None",
Self::Whole => "1/1",
Self::Half => "1/2",
Self::Quarter => "1/4",
Self::Eighth => "1/8",
Self::Sixteenth => "1/16",
Self::ThirtySecond => "1/32",
Self::QuarterTriplet => "1/4T",
Self::EighthTriplet => "1/8T",
Self::SixteenthTriplet => "1/16T",
Self::ThirtySecondTriplet => "1/32T",
Self::EighthSwingLight => "1/8 swing light",
Self::SixteenthSwingLight => "1/16 swing light",
Self::EighthSwingHeavy => "1/8 swing heavy",
Self::SixteenthSwingHeavy => "1/16 swing heavy",
}
}
fn all() -> &'static [SnapValue] {
&[
Self::None, Self::Whole, Self::Half, Self::Quarter,
Self::Eighth, Self::Sixteenth, Self::ThirtySecond,
Self::QuarterTriplet, Self::EighthTriplet,
Self::SixteenthTriplet, Self::ThirtySecondTriplet,
Self::EighthSwingLight, Self::SixteenthSwingLight,
Self::EighthSwingHeavy, Self::SixteenthSwingHeavy,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
enum DragMode { enum DragMode {
MoveNotes { start_time_offset: f64, start_note_offset: i32 }, MoveNotes { start_time_offset: f64, start_note_offset: i32 },
@ -122,6 +164,11 @@ pub struct PianoRollPane {
pitch_bend_range: f32, pitch_bend_range: f32,
// Layer ID for which pitch_bend_range was last queried // Layer ID for which pitch_bend_range was last queried
pitch_bend_range_layer: Option<uuid::Uuid>, pitch_bend_range_layer: Option<uuid::Uuid>,
// Snap / quantize
snap_value: SnapValue,
last_snap_selection: HashSet<usize>,
snap_user_changed: bool, // set in render_header, consumed before handle_input
} }
impl PianoRollPane { impl PianoRollPane {
@ -155,6 +202,9 @@ impl PianoRollPane {
header_mod: 0.0, header_mod: 0.0,
pitch_bend_range: 2.0, pitch_bend_range: 2.0,
pitch_bend_range_layer: None, pitch_bend_range_layer: None,
snap_value: SnapValue::None,
last_snap_selection: HashSet::new(),
snap_user_changed: false,
} }
} }
@ -263,6 +313,61 @@ impl PianoRollPane {
// ── MIDI mode rendering ────────────────────────────────────────────── // ── MIDI mode rendering ──────────────────────────────────────────────
} // end impl PianoRollPane (snap helpers follow as free functions)
fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 {
let beat = 60.0 / bpm;
match snap {
SnapValue::None => t,
SnapValue::Whole => round_to_grid(t, beat * 4.0),
SnapValue::Half => round_to_grid(t, beat * 2.0),
SnapValue::Quarter => round_to_grid(t, beat),
SnapValue::Eighth => round_to_grid(t, beat * 0.5),
SnapValue::Sixteenth => round_to_grid(t, beat * 0.25),
SnapValue::ThirtySecond => round_to_grid(t, beat * 0.125),
SnapValue::QuarterTriplet => round_to_grid(t, beat * 2.0 / 3.0),
SnapValue::EighthTriplet => round_to_grid(t, beat / 3.0),
SnapValue::SixteenthTriplet => round_to_grid(t, beat / 6.0),
SnapValue::ThirtySecondTriplet => round_to_grid(t, beat / 12.0),
SnapValue::EighthSwingLight => snap_swing(t, beat, 2.0 / 3.0),
SnapValue::SixteenthSwingLight => snap_swing(t, beat * 0.5, 2.0 / 3.0),
SnapValue::EighthSwingHeavy => snap_swing(t, beat, 3.0 / 4.0),
SnapValue::SixteenthSwingHeavy => snap_swing(t, beat * 0.5, 3.0 / 4.0),
}
}
fn round_to_grid(t: f64, interval: f64) -> f64 {
(t / interval).round() * interval
}
fn snap_swing(t: f64, cell: f64, ratio: f64) -> f64 {
let cell_n = (t / cell).floor() as i64;
let cell_start = cell_n as f64 * cell;
let cands = [cell_start, cell_start + ratio * cell, cell_start + cell];
*cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap()
}
fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue {
const EPS: f64 = 0.002;
if notes.is_empty() { return SnapValue::None; }
let order = [
SnapValue::Whole, SnapValue::Half, SnapValue::Quarter,
SnapValue::EighthSwingHeavy, SnapValue::EighthSwingLight, SnapValue::Eighth,
SnapValue::SixteenthSwingHeavy, SnapValue::SixteenthSwingLight,
SnapValue::QuarterTriplet, SnapValue::Sixteenth,
SnapValue::EighthTriplet, SnapValue::ThirtySecond,
SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet,
];
for &sv in &order {
if notes.iter().all(|n| (snap_to_value(n.start_time, sv, bpm) - n.start_time).abs() < EPS) {
return sv;
}
}
SnapValue::None
}
impl PianoRollPane {
fn render_midi_mode( fn render_midi_mode(
&mut self, &mut self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -345,6 +450,18 @@ impl PianoRollPane {
} }
} }
// Apply quantize if the user changed the snap dropdown (must happen before handle_input
// which may clear the selection when the ComboBox click propagates to the grid).
if self.snap_user_changed {
self.snap_user_changed = false;
if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() {
if let Some(clip_id) = self.selected_clip_id {
let bpm = shared.action_executor.document().bpm;
self.quantize_selected_notes(clip_id, bpm, shared);
}
}
}
// Handle input before rendering // Handle input before rendering
self.handle_input(ui, grid_rect, keyboard_rect, shared, &clip_data); self.handle_input(ui, grid_rect, keyboard_rect, shared, &clip_data);
@ -1095,7 +1212,9 @@ impl PianoRollPane {
// Immediate press detection (fires on the actual press frame, before egui's drag threshold). // Immediate press detection (fires on the actual press frame, before egui's drag threshold).
// This ensures note preview and hit testing use the real press position. // This ensures note preview and hit testing use the real press position.
let pointer_just_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); // Skip when any popup (e.g. ComboBox dropdown) is open so clicks there don't pass through.
let pointer_just_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary))
&& !ui.ctx().is_popup_open();
if pointer_just_pressed { if pointer_just_pressed {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if full_rect.contains(pos) { if full_rect.contains(pos) {
@ -1255,7 +1374,11 @@ impl PianoRollPane {
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) { if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
let clip_start = selected_clip.1; let clip_start = selected_clip.1;
let trim_start = selected_clip.2; let trim_start = selected_clip.2;
let clip_local_time = (time - clip_start).max(0.0) + trim_start; let bpm = shared.action_executor.document().bpm;
let clip_local_time = snap_to_value(
(time - clip_start).max(0.0) + trim_start,
self.snap_value, bpm,
);
self.creating_note = Some(TempNote { self.creating_note = Some(TempNote {
note, note,
start_time: clip_local_time, start_time: clip_local_time,
@ -1266,11 +1389,18 @@ impl PianoRollPane {
self.preview_note_on(note, DEFAULT_VELOCITY, None, now, shared); self.preview_note_on(note, DEFAULT_VELOCITY, None, now, shared);
} }
} else { } else {
// Start selection rectangle // Start selection rectangle and seek playhead to clicked time
self.selected_note_indices.clear(); self.selected_note_indices.clear();
self.update_focus(shared); self.update_focus(shared);
self.selection_rect = Some((pos, pos)); self.selection_rect = Some((pos, pos));
self.drag_mode = Some(DragMode::SelectRect); self.drag_mode = Some(DragMode::SelectRect);
let bpm = shared.action_executor.document().bpm;
let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm);
*shared.playback_time = seek_time;
if let Some(ctrl) = shared.audio_controller.as_ref() {
if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); }
}
} }
} }
@ -1652,10 +1782,12 @@ impl PianoRollPane {
let resolved = Self::resolve_notes(events); let resolved = Self::resolve_notes(events);
let old_notes = Self::notes_to_backend_format(&resolved); let old_notes = Self::notes_to_backend_format(&resolved);
let bpm = shared.action_executor.document().bpm;
let mut new_resolved = resolved.clone(); let mut new_resolved = resolved.clone();
for &idx in &self.selected_note_indices { for &idx in &self.selected_note_indices {
if idx < new_resolved.len() { if idx < new_resolved.len() {
new_resolved[idx].start_time = (new_resolved[idx].start_time + dt).max(0.0); let raw_time = (new_resolved[idx].start_time + dt).max(0.0);
new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, bpm);
new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8; new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8;
} }
} }
@ -1831,6 +1963,23 @@ impl PianoRollPane {
shared.pending_actions.push(Box::new(action)); shared.pending_actions.push(Box::new(action));
} }
fn quantize_selected_notes(&mut self, clip_id: u32, bpm: f64, shared: &mut SharedPaneState) {
let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return };
let resolved = Self::resolve_notes(events);
let old_notes = Self::notes_to_backend_format(&resolved);
let mut new_resolved = resolved.clone();
for &idx in &self.selected_note_indices {
if idx < new_resolved.len() {
new_resolved[idx].start_time =
snap_to_value(new_resolved[idx].start_time, self.snap_value, bpm).max(0.0);
}
}
let new_notes = Self::notes_to_backend_format(&new_resolved);
Self::update_cache_from_resolved(clip_id, &new_resolved, shared);
self.push_update_action("Quantize notes", clip_id, old_notes, new_notes, shared, &[]);
self.cached_clip_id = None;
}
fn push_events_action( fn push_events_action(
&self, &self,
description: &str, description: &str,
@ -2256,6 +2405,46 @@ impl PaneRenderer for PianoRollPane {
.max_decimals(1), .max_decimals(1),
); );
} }
// Snap-to dropdown — only in Measures mode
let doc = shared.action_executor.document();
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
let bpm = doc.bpm;
drop(doc);
if is_measures {
// Auto-detect grid when selection changes
if self.selected_note_indices != self.last_snap_selection {
if !self.selected_note_indices.is_empty() {
if let Some(clip_id) = self.selected_clip_id {
if let Some(events) = shared.midi_event_cache.get(&clip_id) {
let resolved = Self::resolve_notes(events);
let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter()
.filter_map(|&i| resolved.get(i))
.collect();
self.snap_value = detect_snap(&sel, bpm);
}
}
}
self.last_snap_selection = self.selected_note_indices.clone();
}
ui.separator();
ui.label(egui::RichText::new("Snap to:").color(header_secondary).size(10.0));
let old_snap = self.snap_value;
egui::ComboBox::from_id_salt("piano_roll_snap")
.selected_text(self.snap_value.label())
.width(110.0)
.show_ui(ui, |ui| {
for &sv in SnapValue::all() {
ui.selectable_value(&mut self.snap_value, sv, sv.label());
}
});
if self.snap_value != old_snap {
self.snap_user_changed = true;
}
}
}); });
true true
} }

View File

@ -219,6 +219,9 @@ pub struct TimelinePane {
metronome_icon: Option<egui::TextureHandle>, metronome_icon: Option<egui::TextureHandle>,
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires /// Count-in pre-roll state: set when count-in is active, cleared when recording fires
pending_recording_start: Option<PendingRecordingStart>, pending_recording_start: Option<PendingRecordingStart>,
/// Layer currently being renamed via inline text edit (layer_id, buffer)
renaming_layer: Option<(uuid::Uuid, String)>,
} }
/// Deferred recording start created during count-in pre-roll /// Deferred recording start created during count-in pre-roll
@ -680,6 +683,7 @@ impl TimelinePane {
automation_topology_generation: u64::MAX, automation_topology_generation: u64::MAX,
metronome_icon: None, metronome_icon: None,
pending_recording_start: None, pending_recording_start: None,
renaming_layer: None,
} }
} }
@ -1924,14 +1928,51 @@ impl TimelinePane {
name_x_offset = 10.0 + indent + 18.0; name_x_offset = 10.0 + indent + 18.0;
} }
// Layer name // Layer name — double-click to rename inline
ui.painter().text( let name_pos = header_rect.min + egui::vec2(name_x_offset, 4.0);
header_rect.min + egui::vec2(name_x_offset, 10.0), let name_rect = egui::Rect::from_min_size(
egui::Align2::LEFT_TOP, name_pos,
&layer_name, egui::vec2(header_rect.max.x - name_pos.x - 8.0, 22.0),
egui::FontId::proportional(14.0),
text_color,
); );
let is_renaming = self.renaming_layer.as_ref().map_or(false, |(id, _)| *id == layer_id);
if is_renaming {
let buf = &mut self.renaming_layer.as_mut().unwrap().1;
let te = egui::TextEdit::singleline(buf)
.font(egui::FontId::proportional(14.0))
.text_color(text_color)
.frame(false)
.desired_width(name_rect.width());
let te_resp = ui.put(name_rect, te);
te_resp.request_focus();
let done = te_resp.lost_focus()
|| ui.input(|i| i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Escape));
if done {
let new_name = self.renaming_layer.take().unwrap().1;
let new_name = new_name.trim().to_string();
if !new_name.is_empty() && new_name != layer_name {
pending_actions.push(Box::new(
lightningbeam_core::actions::SetLayerPropertiesAction::new(
layer_id,
lightningbeam_core::actions::LayerProperty::Name(new_name),
)
));
}
self.layer_control_clicked = true;
}
} else {
let name_resp = ui.allocate_rect(name_rect, egui::Sense::click());
ui.painter().text(
name_pos + egui::vec2(0.0, 6.0),
egui::Align2::LEFT_TOP,
&layer_name,
egui::FontId::proportional(14.0),
text_color,
);
if name_resp.double_clicked() {
self.renaming_layer = Some((layer_id, layer_name.clone()));
self.layer_control_clicked = true;
}
}
// Layer type (smaller text below name with colored background) // Layer type (smaller text below name with colored background)
let type_text_pos = header_rect.min + egui::vec2(name_x_offset, 28.0); let type_text_pos = header_rect.min + egui::vec2(name_x_offset, 28.0);
@ -1972,30 +2013,30 @@ impl TimelinePane {
let Some(layer_for_controls) = any_layer_for_controls else { continue; }; let Some(layer_for_controls) = any_layer_for_controls else { continue; };
// Layer controls (mute, solo, lock, volume) // Layer controls: volume slider top-right, buttons below it
let controls_top = header_rect.min.y + 4.0;
let controls_right = header_rect.max.x - 8.0; let controls_right = header_rect.max.x - 8.0;
let button_size = egui::vec2(20.0, 20.0); let button_size = egui::vec2(20.0, 20.0);
let slider_width = 60.0; let slider_width = 60.0;
// Position controls from right to left
let volume_slider_rect = egui::Rect::from_min_size( let volume_slider_rect = egui::Rect::from_min_size(
egui::pos2(controls_right - slider_width, controls_top), egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0),
egui::vec2(slider_width, 20.0), egui::vec2(slider_width, 20.0),
); );
// Buttons sit below the slider, right-aligned to match it
let buttons_top = volume_slider_rect.max.y + 4.0;
let lock_button_rect = egui::Rect::from_min_size( let lock_button_rect = egui::Rect::from_min_size(
egui::pos2(volume_slider_rect.min.x - button_size.x - 4.0, controls_top), egui::pos2(controls_right - button_size.x, buttons_top),
button_size, button_size,
); );
let solo_button_rect = egui::Rect::from_min_size( let solo_button_rect = egui::Rect::from_min_size(
egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, controls_top), egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top),
button_size, button_size,
); );
let mute_button_rect = egui::Rect::from_min_size( let mute_button_rect = egui::Rect::from_min_size(
egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, controls_top), egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top),
button_size, button_size,
); );
@ -2123,6 +2164,7 @@ impl TimelinePane {
// Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume) // Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume)
// Disabled when the user has edited the Volume automation curve beyond the default single keyframe // Disabled when the user has edited the Volume automation curve beyond the default single keyframe
let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| {
ui.spacing_mut().slider_width = slider_width;
// Map volume (0.0-2.0) to slider position (0.0-1.0) // Map volume (0.0-2.0) to slider position (0.0-1.0)
let slider_value = if current_volume <= 1.0 { let slider_value = if current_volume <= 1.0 {
// 0.0-1.0 volume maps to 0.0-0.7 slider (70%) // 0.0-1.0 volume maps to 0.0-0.7 slider (70%)
@ -2185,7 +2227,7 @@ impl TimelinePane {
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls {
if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled { if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
let gain_slider_rect = egui::Rect::from_min_size( let gain_slider_rect = egui::Rect::from_min_size(
egui::pos2(controls_right - slider_width, controls_top + 22.0), egui::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0),
egui::vec2(slider_width, 16.0), egui::vec2(slider_width, 16.0),
); );
let current_gain = audio_layer.layer.input_gain; let current_gain = audio_layer.layer.input_gain;
@ -2215,7 +2257,7 @@ impl TimelinePane {
// Label // Label
let label_rect = egui::Rect::from_min_size( let label_rect = egui::Rect::from_min_size(
egui::pos2(gain_slider_rect.min.x - 26.0, controls_top + 22.0), egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0),
egui::vec2(24.0, 16.0), egui::vec2(24.0, 16.0),
); );
ui.painter().text( ui.painter().text(