Add piano roll note snapping
This commit is contained in:
parent
123fe3f21a
commit
65a550d8f4
|
|
@ -1673,6 +1673,7 @@ impl Engine {
|
|||
Ok(json) => {
|
||||
match crate::audio::node_graph::preset::GraphPreset::from_json(&json) {
|
||||
Ok(preset) => {
|
||||
let preset_name = preset.metadata.name.clone();
|
||||
// Extract the directory path from the preset path for resolving relative sample paths
|
||||
let preset_base_path = std::path::Path::new(&preset_path).parent();
|
||||
|
||||
|
|
@ -1684,19 +1685,19 @@ impl Engine {
|
|||
track.instrument_graph = graph;
|
||||
track.graph_is_default = true;
|
||||
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)) => {
|
||||
track.effects_graph = graph;
|
||||
track.graph_is_default = true;
|
||||
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)) => {
|
||||
track.audio_graph = graph;
|
||||
track.graph_is_default = true;
|
||||
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) => {
|
||||
match crate::audio::node_graph::lbins::load_lbins(&path) {
|
||||
Ok((preset, assets)) => {
|
||||
let preset_name = preset.metadata.name.clone();
|
||||
match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) {
|
||||
Ok(graph) => {
|
||||
match self.project.get_track_mut(track_id) {
|
||||
|
|
@ -1736,19 +1738,19 @@ impl Engine {
|
|||
track.instrument_graph = graph;
|
||||
track.graph_is_default = true;
|
||||
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)) => {
|
||||
track.effects_graph = graph;
|
||||
track.graph_is_default = true;
|
||||
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)) => {
|
||||
track.audio_graph = graph;
|
||||
track.graph_is_default = true;
|
||||
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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,8 +310,8 @@ pub enum AudioEvent {
|
|||
GraphConnectionError(TrackId, String),
|
||||
/// Graph state changed (for full UI sync)
|
||||
GraphStateChanged(TrackId),
|
||||
/// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded
|
||||
GraphPresetLoaded(TrackId),
|
||||
/// Preset fully loaded (track_id, preset_name) - emitted after all nodes and samples are loaded
|
||||
GraphPresetLoaded(TrackId, String),
|
||||
/// Preset has been saved to file (track_id, preset_path)
|
||||
GraphPresetSaved(TrackId, String),
|
||||
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ pub enum LayerProperty {
|
|||
Visible(bool),
|
||||
/// Video layer only: toggle live webcam preview
|
||||
CameraEnabled(bool),
|
||||
/// Rename the layer; sets has_custom_name = true
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// Stored old value for rollback
|
||||
|
|
@ -33,6 +35,7 @@ enum OldValue {
|
|||
Opacity(f64),
|
||||
Visible(bool),
|
||||
CameraEnabled(bool),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// Action that sets a property on one or more layers
|
||||
|
|
@ -101,6 +104,7 @@ impl Action for SetLayerPropertiesAction {
|
|||
};
|
||||
OldValue::CameraEnabled(val)
|
||||
}
|
||||
LayerProperty::Name(_) => OldValue::Name(layer.name().to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +122,10 @@ impl Action for SetLayerPropertiesAction {
|
|||
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;
|
||||
}
|
||||
}
|
||||
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::Muted(_) => "mute",
|
||||
LayerProperty::Soloed(_) => "solo",
|
||||
LayerProperty::Name(_) => "name",
|
||||
LayerProperty::Locked(_) => "lock",
|
||||
LayerProperty::Opacity(_) => "opacity",
|
||||
LayerProperty::Visible(_) => "visibility",
|
||||
|
|
|
|||
|
|
@ -5267,7 +5267,7 @@ impl eframe::App for EditorApp {
|
|||
self.waveform_gpu_dirty.insert(pool_index);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AudioEvent::GraphPresetLoaded(_track_id) => {
|
||||
AudioEvent::GraphPresetLoaded(track_id, preset_name) => {
|
||||
// Preset was loaded on the audio thread — bump generation
|
||||
// so the node graph pane reloads from backend
|
||||
self.project_generation += 1;
|
||||
|
|
@ -5278,6 +5278,17 @@ impl eframe::App for EditorApp {
|
|||
std::sync::atomic::Ordering::Relaxed,
|
||||
|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();
|
||||
}
|
||||
AudioEvent::InputLevel(peak) => {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,48 @@ enum PitchBendZone {
|
|||
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)]
|
||||
enum DragMode {
|
||||
MoveNotes { start_time_offset: f64, start_note_offset: i32 },
|
||||
|
|
@ -122,6 +164,11 @@ pub struct PianoRollPane {
|
|||
pitch_bend_range: f32,
|
||||
// Layer ID for which pitch_bend_range was last queried
|
||||
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 {
|
||||
|
|
@ -155,6 +202,9 @@ impl PianoRollPane {
|
|||
header_mod: 0.0,
|
||||
pitch_bend_range: 2.0,
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
} // 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(
|
||||
&mut self,
|
||||
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
|
||||
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).
|
||||
// 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 let Some(pos) = ui.input(|i| i.pointer.interact_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) {
|
||||
let clip_start = selected_clip.1;
|
||||
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 {
|
||||
note,
|
||||
start_time: clip_local_time,
|
||||
|
|
@ -1266,11 +1389,18 @@ impl PianoRollPane {
|
|||
self.preview_note_on(note, DEFAULT_VELOCITY, None, now, shared);
|
||||
}
|
||||
} else {
|
||||
// Start selection rectangle
|
||||
// Start selection rectangle and seek playhead to clicked time
|
||||
self.selected_note_indices.clear();
|
||||
self.update_focus(shared);
|
||||
self.selection_rect = Some((pos, pos));
|
||||
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 old_notes = Self::notes_to_backend_format(&resolved);
|
||||
|
||||
let bpm = shared.action_executor.document().bpm;
|
||||
let mut new_resolved = resolved.clone();
|
||||
for &idx in &self.selected_note_indices {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1831,6 +1963,23 @@ impl PianoRollPane {
|
|||
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(
|
||||
&self,
|
||||
description: &str,
|
||||
|
|
@ -2256,6 +2405,46 @@ impl PaneRenderer for PianoRollPane {
|
|||
.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@ pub struct TimelinePane {
|
|||
metronome_icon: Option<egui::TextureHandle>,
|
||||
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires
|
||||
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
|
||||
|
|
@ -680,6 +683,7 @@ impl TimelinePane {
|
|||
automation_topology_generation: u64::MAX,
|
||||
metronome_icon: None,
|
||||
pending_recording_start: None,
|
||||
renaming_layer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1924,14 +1928,51 @@ impl TimelinePane {
|
|||
name_x_offset = 10.0 + indent + 18.0;
|
||||
}
|
||||
|
||||
// Layer name
|
||||
ui.painter().text(
|
||||
header_rect.min + egui::vec2(name_x_offset, 10.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
&layer_name,
|
||||
egui::FontId::proportional(14.0),
|
||||
text_color,
|
||||
// Layer name — double-click to rename inline
|
||||
let name_pos = header_rect.min + egui::vec2(name_x_offset, 4.0);
|
||||
let name_rect = egui::Rect::from_min_size(
|
||||
name_pos,
|
||||
egui::vec2(header_rect.max.x - name_pos.x - 8.0, 22.0),
|
||||
);
|
||||
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)
|
||||
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; };
|
||||
|
||||
// Layer controls (mute, solo, lock, volume)
|
||||
let controls_top = header_rect.min.y + 4.0;
|
||||
// Layer controls: volume slider top-right, buttons below it
|
||||
let controls_right = header_rect.max.x - 8.0;
|
||||
let button_size = egui::vec2(20.0, 20.0);
|
||||
let slider_width = 60.0;
|
||||
|
||||
// Position controls from right to left
|
||||
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),
|
||||
);
|
||||
|
||||
// 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(
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
|
|
@ -2123,6 +2164,7 @@ impl TimelinePane {
|
|||
// 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
|
||||
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)
|
||||
let slider_value = if current_volume <= 1.0 {
|
||||
// 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 audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
|
||||
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),
|
||||
);
|
||||
let current_gain = audio_layer.layer.input_gain;
|
||||
|
|
@ -2215,7 +2257,7 @@ impl TimelinePane {
|
|||
|
||||
// Label
|
||||
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),
|
||||
);
|
||||
ui.painter().text(
|
||||
|
|
|
|||
Loading…
Reference in New Issue