Add beat mode
This commit is contained in:
parent
205dc9dd67
commit
eab116c930
|
|
@ -1136,6 +1136,11 @@ impl Engine {
|
||||||
self.metronome.set_enabled(enabled);
|
self.metronome.set_enabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::SetTempo(bpm, time_sig) => {
|
||||||
|
self.metronome.update_timing(bpm, time_sig);
|
||||||
|
self.project.set_tempo(bpm, time_sig.0);
|
||||||
|
}
|
||||||
|
|
||||||
// Node graph commands
|
// Node graph commands
|
||||||
Command::GraphAddNode(track_id, node_type, x, y) => {
|
Command::GraphAddNode(track_id, node_type, x, y) => {
|
||||||
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
|
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
|
||||||
|
|
@ -3197,6 +3202,11 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled));
|
let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set project tempo (BPM) and time signature
|
||||||
|
pub fn set_tempo(&mut self, bpm: f32, time_signature: (u32, u32)) {
|
||||||
|
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
||||||
|
}
|
||||||
|
|
||||||
// Node graph operations
|
// Node graph operations
|
||||||
|
|
||||||
/// Add a node to a track's instrument graph
|
/// Add a node to a track's instrument graph
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@ pub struct AudioGraph {
|
||||||
/// Current playback time (for automation nodes)
|
/// Current playback time (for automation nodes)
|
||||||
playback_time: f64,
|
playback_time: f64,
|
||||||
|
|
||||||
|
/// Project tempo (synced from Engine via SetTempo)
|
||||||
|
bpm: f32,
|
||||||
|
/// Beats per bar (time signature numerator)
|
||||||
|
beats_per_bar: u32,
|
||||||
|
|
||||||
/// Cached topological sort order (invalidated on graph mutation)
|
/// Cached topological sort order (invalidated on graph mutation)
|
||||||
topo_cache: Option<Vec<NodeIndex>>,
|
topo_cache: Option<Vec<NodeIndex>>,
|
||||||
|
|
||||||
|
|
@ -119,11 +124,19 @@ impl AudioGraph {
|
||||||
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
|
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
|
||||||
node_positions: std::collections::HashMap::new(),
|
node_positions: std::collections::HashMap::new(),
|
||||||
playback_time: 0.0,
|
playback_time: 0.0,
|
||||||
|
bpm: 120.0,
|
||||||
|
beats_per_bar: 4,
|
||||||
topo_cache: None,
|
topo_cache: None,
|
||||||
frontend_groups: Vec::new(),
|
frontend_groups: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the project tempo and time signature for BeatNodes
|
||||||
|
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
|
||||||
|
self.bpm = bpm;
|
||||||
|
self.beats_per_bar = beats_per_bar;
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a node to the graph
|
/// Add a node to the graph
|
||||||
pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeIndex {
|
pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeIndex {
|
||||||
let graph_node = GraphNode::new(node, self.buffer_size);
|
let graph_node = GraphNode::new(node, self.buffer_size);
|
||||||
|
|
@ -452,6 +465,7 @@ impl AudioGraph {
|
||||||
auto_node.set_playback_time(playback_time);
|
auto_node.set_playback_time(playback_time);
|
||||||
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
|
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
|
||||||
beat_node.set_playback_time(playback_time);
|
beat_node.set_playback_time(playback_time);
|
||||||
|
beat_node.set_tempo(self.bpm, self.beats_per_bar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ use crate::audio::midi::MidiEvent;
|
||||||
|
|
||||||
const PARAM_RESOLUTION: u32 = 0;
|
const PARAM_RESOLUTION: u32 = 0;
|
||||||
|
|
||||||
/// Hardcoded BPM until project tempo is implemented
|
|
||||||
const DEFAULT_BPM: f32 = 120.0;
|
const DEFAULT_BPM: f32 = 120.0;
|
||||||
|
const DEFAULT_BEATS_PER_BAR: u32 = 4;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum BeatResolution {
|
pub enum BeatResolution {
|
||||||
|
|
@ -47,17 +47,19 @@ impl BeatResolution {
|
||||||
|
|
||||||
/// Beat clock node — generates tempo-synced CV signals.
|
/// Beat clock node — generates tempo-synced CV signals.
|
||||||
///
|
///
|
||||||
|
/// BPM and time signature are synced from the project document via SetTempo.
|
||||||
/// When playing: synced to timeline position.
|
/// When playing: synced to timeline position.
|
||||||
/// When stopped: free-runs continuously at the set BPM.
|
/// When stopped: free-runs continuously at the project BPM.
|
||||||
///
|
///
|
||||||
/// Outputs:
|
/// Outputs:
|
||||||
/// - BPM: constant CV proportional to tempo (bpm / 240)
|
/// - BPM: constant CV proportional to tempo (bpm / 240)
|
||||||
/// - Beat Phase: sawtooth 0→1 per beat subdivision
|
/// - Beat Phase: sawtooth 0→1 per beat subdivision
|
||||||
/// - Bar Phase: sawtooth 0→1 per bar (4 beats)
|
/// - Bar Phase: sawtooth 0→1 per bar (uses project time signature)
|
||||||
/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise
|
/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise
|
||||||
pub struct BeatNode {
|
pub struct BeatNode {
|
||||||
name: String,
|
name: String,
|
||||||
bpm: f32,
|
bpm: f32,
|
||||||
|
beats_per_bar: u32,
|
||||||
resolution: BeatResolution,
|
resolution: BeatResolution,
|
||||||
/// Playback time in seconds, set by the graph before process()
|
/// Playback time in seconds, set by the graph before process()
|
||||||
playback_time: f64,
|
playback_time: f64,
|
||||||
|
|
@ -88,6 +90,7 @@ impl BeatNode {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
bpm: DEFAULT_BPM,
|
bpm: DEFAULT_BPM,
|
||||||
|
beats_per_bar: DEFAULT_BEATS_PER_BAR,
|
||||||
resolution: BeatResolution::Quarter,
|
resolution: BeatResolution::Quarter,
|
||||||
playback_time: 0.0,
|
playback_time: 0.0,
|
||||||
prev_playback_time: -1.0,
|
prev_playback_time: -1.0,
|
||||||
|
|
@ -101,6 +104,11 @@ impl BeatNode {
|
||||||
pub fn set_playback_time(&mut self, time: f64) {
|
pub fn set_playback_time(&mut self, time: f64) {
|
||||||
self.playback_time = time;
|
self.playback_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
|
||||||
|
self.bpm = bpm;
|
||||||
|
self.beats_per_bar = beats_per_bar;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioNode for BeatNode {
|
impl AudioNode for BeatNode {
|
||||||
|
|
@ -167,8 +175,8 @@ impl AudioNode for BeatNode {
|
||||||
// Beat subdivision phase: 0→1 sawtooth
|
// Beat subdivision phase: 0→1 sawtooth
|
||||||
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
|
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
|
||||||
|
|
||||||
// Bar phase: 0→1 over 4 quarter-note beats
|
// Bar phase: 0→1 over one bar (beats_per_bar beats)
|
||||||
let bar_phase = ((beat_pos / 4.0) % 1.0) as f32;
|
let bar_phase = ((beat_pos / self.beats_per_bar as f64) % 1.0) as f32;
|
||||||
|
|
||||||
// Gate: high for first half of each subdivision
|
// Gate: high for first half of each subdivision
|
||||||
let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 };
|
let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 };
|
||||||
|
|
@ -201,6 +209,7 @@ impl AudioNode for BeatNode {
|
||||||
Box::new(Self {
|
Box::new(Self {
|
||||||
name: self.name.clone(),
|
name: self.name.clone(),
|
||||||
bpm: self.bpm,
|
bpm: self.bpm,
|
||||||
|
beats_per_bar: self.beats_per_bar,
|
||||||
resolution: self.resolution,
|
resolution: self.resolution,
|
||||||
playback_time: 0.0,
|
playback_time: 0.0,
|
||||||
prev_playback_time: -1.0,
|
prev_playback_time: -1.0,
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,17 @@ impl Project {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Propagate tempo to all audio graphs (for BeatNode sync)
|
||||||
|
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
|
||||||
|
for track in self.tracks.values_mut() {
|
||||||
|
match track {
|
||||||
|
TrackNode::Audio(t) => t.effects_graph.set_tempo(bpm, beats_per_bar),
|
||||||
|
TrackNode::Midi(t) => t.instrument_graph.set_tempo(bpm, beats_per_bar),
|
||||||
|
TrackNode::Group(g) => g.audio_graph.set_tempo(bpm, beats_per_bar),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Process live MIDI input from all MIDI tracks (called even when not playing)
|
/// Process live MIDI input from all MIDI tracks (called even when not playing)
|
||||||
pub fn process_live_midi(&mut self, output: &mut [f32], sample_rate: u32, channels: u32) {
|
pub fn process_live_midi(&mut self, output: &mut [f32], sample_rate: u32, channels: u32) {
|
||||||
// Process all MIDI tracks to handle queued live input events
|
// Process all MIDI tracks to handle queued live input events
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,8 @@ pub enum Command {
|
||||||
// Metronome command
|
// Metronome command
|
||||||
/// Enable or disable the metronome click track
|
/// Enable or disable the metronome click track
|
||||||
SetMetronomeEnabled(bool),
|
SetMetronomeEnabled(bool),
|
||||||
|
/// Set project tempo and time signature (bpm, (numerator, denominator))
|
||||||
|
SetTempo(f32, (u32, u32)),
|
||||||
|
|
||||||
// Node graph commands
|
// Node graph commands
|
||||||
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
|
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
//! Beat/measure ↔ seconds conversion utilities
|
||||||
|
|
||||||
|
use crate::document::TimeSignature;
|
||||||
|
|
||||||
|
/// Position expressed as measure, beat, tick
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct MeasurePosition {
|
||||||
|
pub measure: u32, // 1-indexed
|
||||||
|
pub beat: u32, // 1-indexed
|
||||||
|
pub tick: u32, // 0-999 (subdivision of beat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a time in seconds to a measure position
|
||||||
|
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition {
|
||||||
|
let beats_per_second = bpm / 60.0;
|
||||||
|
let total_beats = (time * beats_per_second).max(0.0);
|
||||||
|
let beats_per_measure = time_sig.numerator as f64;
|
||||||
|
|
||||||
|
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
|
||||||
|
let beat = (total_beats.rem_euclid(beats_per_measure)).floor() as u32 + 1;
|
||||||
|
let tick = ((total_beats.rem_euclid(1.0)) * 1000.0).floor() as u32;
|
||||||
|
|
||||||
|
MeasurePosition { measure, beat, tick }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a measure position to seconds
|
||||||
|
pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 {
|
||||||
|
let beats_per_measure = time_sig.numerator as f64;
|
||||||
|
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
|
||||||
|
+ (pos.beat as f64 - 1.0)
|
||||||
|
+ (pos.tick as f64 / 1000.0);
|
||||||
|
let beats_per_second = bpm / 60.0;
|
||||||
|
total_beats / beats_per_second
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the duration of one beat in seconds
|
||||||
|
pub fn beat_duration(bpm: f64) -> f64 {
|
||||||
|
60.0 / bpm
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the duration of one measure in seconds
|
||||||
|
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 {
|
||||||
|
beat_duration(bpm) * time_sig.numerator as f64
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,21 @@ impl Default for GraphicsObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Musical time signature
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TimeSignature {
|
||||||
|
pub numerator: u32, // beats per measure (e.g., 4)
|
||||||
|
pub denominator: u32, // beat unit (e.g., 4 = quarter note)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TimeSignature {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { numerator: 4, denominator: 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bpm() -> f64 { 120.0 }
|
||||||
|
|
||||||
/// Asset category for folder tree access
|
/// Asset category for folder tree access
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AssetCategory {
|
pub enum AssetCategory {
|
||||||
|
|
@ -101,6 +116,14 @@ pub struct Document {
|
||||||
/// Framerate (frames per second)
|
/// Framerate (frames per second)
|
||||||
pub framerate: f64,
|
pub framerate: f64,
|
||||||
|
|
||||||
|
/// Tempo in beats per minute
|
||||||
|
#[serde(default = "default_bpm")]
|
||||||
|
pub bpm: f64,
|
||||||
|
|
||||||
|
/// Time signature
|
||||||
|
#[serde(default)]
|
||||||
|
pub time_signature: TimeSignature,
|
||||||
|
|
||||||
/// Duration in seconds
|
/// Duration in seconds
|
||||||
pub duration: f64,
|
pub duration: f64,
|
||||||
|
|
||||||
|
|
@ -182,6 +205,8 @@ impl Default for Document {
|
||||||
width: 1920.0,
|
width: 1920.0,
|
||||||
height: 1080.0,
|
height: 1080.0,
|
||||||
framerate: 60.0,
|
framerate: 60.0,
|
||||||
|
bpm: 120.0,
|
||||||
|
time_signature: TimeSignature::default(),
|
||||||
duration: 10.0,
|
duration: 10.0,
|
||||||
root: GraphicsObject::default(),
|
root: GraphicsObject::default(),
|
||||||
vector_clips: HashMap::new(),
|
vector_clips: HashMap::new(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Lightningbeam Core Library
|
// Lightningbeam Core Library
|
||||||
// Shared data structures and types
|
// Shared data structures and types
|
||||||
|
|
||||||
|
pub mod beat_time;
|
||||||
pub mod gpu;
|
pub mod gpu;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod pane;
|
pub mod pane;
|
||||||
|
|
|
||||||
|
|
@ -2940,6 +2940,13 @@ impl EditorApp {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
|
// Sync BPM/time signature to metronome
|
||||||
|
let doc = self.action_executor.document();
|
||||||
|
controller.set_tempo(
|
||||||
|
doc.bpm as f32,
|
||||||
|
(doc.time_signature.numerator, doc.time_signature.denominator),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset state and restore track mappings
|
// Reset state and restore track mappings
|
||||||
|
|
|
||||||
|
|
@ -205,16 +205,23 @@ impl PianoRollPane {
|
||||||
|
|
||||||
// ── Ruler interval calculation ───────────────────────────────────────
|
// ── Ruler interval calculation ───────────────────────────────────────
|
||||||
|
|
||||||
fn ruler_interval(&self) -> f64 {
|
fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
|
||||||
let min_pixel_gap = 80.0;
|
let min_pixel_gap = 80.0;
|
||||||
let min_seconds = min_pixel_gap / self.pixels_per_second;
|
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
|
||||||
let intervals = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0];
|
|
||||||
for &interval in &intervals {
|
// Use beat-aligned intervals
|
||||||
if interval >= min_seconds as f64 {
|
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||||
|
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
||||||
|
let beat_intervals = [
|
||||||
|
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
|
||||||
|
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
|
||||||
|
];
|
||||||
|
for &interval in &beat_intervals {
|
||||||
|
if interval >= min_seconds {
|
||||||
return interval;
|
return interval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
60.0
|
measure_dur * 4.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MIDI mode rendering ──────────────────────────────────────────────
|
// ── MIDI mode rendering ──────────────────────────────────────────────
|
||||||
|
|
@ -287,7 +294,11 @@ impl PianoRollPane {
|
||||||
|
|
||||||
// Render grid (clipped to grid area)
|
// Render grid (clipped to grid area)
|
||||||
let grid_painter = ui.painter_at(grid_rect);
|
let grid_painter = ui.painter_at(grid_rect);
|
||||||
self.render_grid(&grid_painter, grid_rect);
|
let (grid_bpm, grid_time_sig) = {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
(doc.bpm, doc.time_signature.clone())
|
||||||
|
};
|
||||||
|
self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig);
|
||||||
|
|
||||||
// Render clip boundaries and notes
|
// Render clip boundaries and notes
|
||||||
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
|
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
|
||||||
|
|
@ -419,7 +430,8 @@ impl PianoRollPane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect) {
|
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||||
|
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||||
// Horizontal lines (note separators)
|
// Horizontal lines (note separators)
|
||||||
for note in MIN_NOTE..=MAX_NOTE {
|
for note in MIN_NOTE..=MAX_NOTE {
|
||||||
let y = self.note_to_y(note, grid_rect);
|
let y = self.note_to_y(note, grid_rect);
|
||||||
|
|
@ -445,8 +457,11 @@ impl PianoRollPane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical lines (time grid)
|
// Vertical lines (beat-aligned time grid)
|
||||||
let interval = self.ruler_interval();
|
let interval = self.ruler_interval(bpm, time_sig);
|
||||||
|
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||||
|
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
||||||
|
|
||||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||||
let end = (end_time / interval).ceil() as i64;
|
let end = (end_time / interval).ceil() as i64;
|
||||||
|
|
@ -458,27 +473,36 @@ impl PianoRollPane {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_major = (i % 4 == 0) || interval >= 1.0;
|
// Determine tick importance: measure boundary > beat > subdivision
|
||||||
let alpha = if is_major { 50 } else { 20 };
|
let is_measure = (time / measure_dur).fract().abs() < 1e-9 || (time / measure_dur).fract() > 1.0 - 1e-9;
|
||||||
|
let is_beat = (time / beat_dur).fract().abs() < 1e-9 || (time / beat_dur).fract() > 1.0 - 1e-9;
|
||||||
|
let alpha = if is_measure { 60 } else if is_beat { 35 } else { 20 };
|
||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[pos2(x, grid_rect.min.y), pos2(x, grid_rect.max.y)],
|
[pos2(x, grid_rect.min.y), pos2(x, grid_rect.max.y)],
|
||||||
Stroke::new(1.0, Color32::from_white_alpha(alpha)),
|
Stroke::new(1.0, Color32::from_white_alpha(alpha)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Time labels at major lines
|
// Labels at measure boundaries
|
||||||
if is_major && x > grid_rect.min.x + 20.0 {
|
if is_measure && x > grid_rect.min.x + 20.0 {
|
||||||
let label = if time >= 60.0 {
|
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
||||||
format!("{}:{:05.2}", (time / 60.0) as u32, time % 60.0)
|
|
||||||
} else {
|
|
||||||
format!("{:.2}s", time)
|
|
||||||
};
|
|
||||||
painter.text(
|
painter.text(
|
||||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||||
Align2::LEFT_TOP,
|
Align2::LEFT_TOP,
|
||||||
label,
|
format!("{}", pos.measure),
|
||||||
FontId::proportional(9.0),
|
FontId::proportional(9.0),
|
||||||
Color32::from_white_alpha(80),
|
Color32::from_white_alpha(80),
|
||||||
);
|
);
|
||||||
|
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
|
||||||
|
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
|
||||||
|
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
||||||
|
painter.text(
|
||||||
|
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||||
|
Align2::LEFT_TOP,
|
||||||
|
format!("{}.{}", pos.measure, pos.beat),
|
||||||
|
FontId::proportional(9.0),
|
||||||
|
Color32::from_white_alpha(50),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -578,9 +602,10 @@ impl PianoRollPane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect) {
|
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||||
|
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||||
// Collect visible time grid positions
|
// Collect visible time grid positions
|
||||||
let interval = self.ruler_interval();
|
let interval = self.ruler_interval(bpm, time_sig);
|
||||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||||
let end = (end_time / interval).ceil() as i64;
|
let end = (end_time / interval).ceil() as i64;
|
||||||
|
|
@ -1414,7 +1439,13 @@ impl PianoRollPane {
|
||||||
|
|
||||||
// Dot grid background (visible where the spectrogram doesn't draw)
|
// Dot grid background (visible where the spectrogram doesn't draw)
|
||||||
let grid_painter = ui.painter_at(view_rect);
|
let grid_painter = ui.painter_at(view_rect);
|
||||||
self.render_dot_grid(&grid_painter, view_rect);
|
{
|
||||||
|
let (dot_bpm, dot_ts) = {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
(doc.bpm, doc.time_signature.clone())
|
||||||
|
};
|
||||||
|
self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts);
|
||||||
|
}
|
||||||
|
|
||||||
// Find audio pool index for the active layer's clips
|
// Find audio pool index for the active layer's clips
|
||||||
let layer_id = match *shared.active_layer_id {
|
let layer_id = match *shared.active_layer_id {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,13 @@ enum ClipDragType {
|
||||||
LoopExtendLeft,
|
LoopExtendLeft,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How time is displayed in the ruler and header
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum TimeDisplayFormat {
|
||||||
|
Seconds,
|
||||||
|
Measures,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TimelinePane {
|
pub struct TimelinePane {
|
||||||
/// Horizontal zoom level (pixels per second)
|
/// Horizontal zoom level (pixels per second)
|
||||||
pixels_per_second: f32,
|
pixels_per_second: f32,
|
||||||
|
|
@ -163,6 +170,9 @@ pub struct TimelinePane {
|
||||||
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
|
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
|
||||||
/// clip_id is None when right-clicking on empty timeline space
|
/// clip_id is None when right-clicking on empty timeline space
|
||||||
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
||||||
|
|
||||||
|
/// Whether to display time as seconds or measures
|
||||||
|
time_display_format: TimeDisplayFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a clip type can be dropped on a layer type
|
/// Check if a clip type can be dropped on a layer type
|
||||||
|
|
@ -231,6 +241,7 @@ impl TimelinePane {
|
||||||
mousedown_pos: None,
|
mousedown_pos: None,
|
||||||
layer_control_clicked: false,
|
layer_control_clicked: false,
|
||||||
context_menu_clip: None,
|
context_menu_clip: None,
|
||||||
|
time_display_format: TimeDisplayFormat::Seconds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -548,72 +559,105 @@ 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,
|
||||||
|
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
let bg_style = theme.style(".timeline-background", ui.ctx());
|
let bg_style = theme.style(".timeline-background", ui.ctx());
|
||||||
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(
|
painter.rect_filled(rect, 0.0, bg_color);
|
||||||
rect,
|
|
||||||
0.0,
|
|
||||||
bg_color,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get text color from theme
|
|
||||||
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));
|
||||||
|
|
||||||
// Calculate interval for tick marks
|
match self.time_display_format {
|
||||||
let interval = self.calculate_ruler_interval();
|
TimeDisplayFormat::Seconds => {
|
||||||
|
let interval = self.calculate_ruler_interval();
|
||||||
|
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||||||
|
let end_time = self.x_to_time(rect.width());
|
||||||
|
|
||||||
// Draw tick marks and labels
|
let mut time = start_time;
|
||||||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
while time <= end_time {
|
||||||
let end_time = self.x_to_time(rect.width());
|
let x = self.time_to_x(time);
|
||||||
|
if x >= 0.0 && x <= rect.width() {
|
||||||
let mut time = start_time;
|
painter.line_segment(
|
||||||
while time <= end_time {
|
[rect.min + egui::vec2(x, rect.height() - 10.0),
|
||||||
let x = self.time_to_x(time);
|
rect.min + egui::vec2(x, rect.height())],
|
||||||
|
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
||||||
if x >= 0.0 && x <= rect.width() {
|
);
|
||||||
// Major tick mark
|
painter.text(
|
||||||
painter.line_segment(
|
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||||||
[
|
format!("{:.1}s", time), egui::FontId::proportional(12.0), text_color,
|
||||||
rect.min + egui::vec2(x, rect.height() - 10.0),
|
);
|
||||||
rect.min + egui::vec2(x, rect.height()),
|
}
|
||||||
],
|
let minor_interval = interval / 5.0;
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
for i in 1..5 {
|
||||||
);
|
let minor_x = self.time_to_x(time + minor_interval * i as f64);
|
||||||
|
if minor_x >= 0.0 && minor_x <= rect.width() {
|
||||||
// Time label
|
painter.line_segment(
|
||||||
let label = format!("{:.1}s", time);
|
[rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
||||||
painter.text(
|
rect.min + egui::vec2(minor_x, rect.height())],
|
||||||
rect.min + egui::vec2(x + 2.0, 5.0),
|
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
|
||||||
egui::Align2::LEFT_TOP,
|
);
|
||||||
label,
|
}
|
||||||
egui::FontId::proportional(12.0),
|
}
|
||||||
text_color,
|
time += interval;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minor tick marks (subdivisions)
|
|
||||||
let minor_interval = interval / 5.0;
|
|
||||||
for i in 1..5 {
|
|
||||||
let minor_time = time + minor_interval * i as f64;
|
|
||||||
let minor_x = self.time_to_x(minor_time);
|
|
||||||
|
|
||||||
if minor_x >= 0.0 && minor_x <= rect.width() {
|
|
||||||
painter.line_segment(
|
|
||||||
[
|
|
||||||
rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
|
||||||
rect.min + egui::vec2(minor_x, rect.height()),
|
|
||||||
],
|
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TimeDisplayFormat::Measures => {
|
||||||
|
let beats_per_second = bpm / 60.0;
|
||||||
|
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||||
|
let bpm_count = time_sig.numerator;
|
||||||
|
let px_per_beat = beat_dur as f32 * self.pixels_per_second;
|
||||||
|
|
||||||
time += interval;
|
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
||||||
|
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
|
||||||
|
|
||||||
|
// Adaptive: how often to label measures
|
||||||
|
let measure_px = px_per_beat * bpm_count as f32;
|
||||||
|
let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 };
|
||||||
|
|
||||||
|
for beat_idx in start_beat..=end_beat {
|
||||||
|
if beat_idx < 0 { continue; }
|
||||||
|
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
|
||||||
|
if x < 0.0 || x > rect.width() { continue; }
|
||||||
|
|
||||||
|
let beat_in_measure = (beat_idx as u32) % bpm_count;
|
||||||
|
let measure = (beat_idx as u32) / bpm_count + 1;
|
||||||
|
let is_measure_boundary = beat_in_measure == 0;
|
||||||
|
|
||||||
|
// Tick height, stroke width, and brightness based on beat importance
|
||||||
|
let (tick_h, stroke_w, gray) = if is_measure_boundary {
|
||||||
|
(12.0, 2.0, 140u8)
|
||||||
|
} else if beat_in_measure % 2 == 0 {
|
||||||
|
(8.0, 1.0, 80)
|
||||||
|
} else {
|
||||||
|
(5.0, 1.0, 50)
|
||||||
|
};
|
||||||
|
|
||||||
|
painter.line_segment(
|
||||||
|
[rect.min + egui::vec2(x, rect.height() - tick_h),
|
||||||
|
rect.min + egui::vec2(x, rect.height())],
|
||||||
|
egui::Stroke::new(stroke_w, egui::Color32::from_gray(gray)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Labels: measure numbers at boundaries, beat numbers when zoomed in
|
||||||
|
if is_measure_boundary && (label_every == 1 || measure % label_every == 1) {
|
||||||
|
painter.text(
|
||||||
|
rect.min + egui::vec2(x + 3.0, 3.0), egui::Align2::LEFT_TOP,
|
||||||
|
format!("{}", measure), egui::FontId::proportional(12.0), text_color,
|
||||||
|
);
|
||||||
|
} else if !is_measure_boundary && px_per_beat > 40.0 {
|
||||||
|
let alpha = if beat_in_measure % 2 == 0 { 0.5 } else if px_per_beat > 80.0 { 0.25 } else { continue };
|
||||||
|
painter.text(
|
||||||
|
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||||||
|
format!("{}.{}", measure, beat_in_measure + 1),
|
||||||
|
egui::FontId::proportional(10.0), text_color.gamma_multiply(alpha),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1104,25 +1148,42 @@ impl TimelinePane {
|
||||||
painter.rect_filled(layer_rect, 0.0, bg_color);
|
painter.rect_filled(layer_rect, 0.0, bg_color);
|
||||||
|
|
||||||
// Grid lines matching ruler
|
// Grid lines matching ruler
|
||||||
let interval = self.calculate_ruler_interval();
|
match self.time_display_format {
|
||||||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
TimeDisplayFormat::Seconds => {
|
||||||
let end_time = self.x_to_time(rect.width());
|
let interval = self.calculate_ruler_interval();
|
||||||
|
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||||||
let mut time = start_time;
|
let end_time = self.x_to_time(rect.width());
|
||||||
while time <= end_time {
|
let mut time = start_time;
|
||||||
let x = self.time_to_x(time);
|
while time <= end_time {
|
||||||
|
let x = self.time_to_x(time);
|
||||||
if x >= 0.0 && x <= rect.width() {
|
if x >= 0.0 && x <= rect.width() {
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[
|
[egui::pos2(rect.min.x + x, y),
|
||||||
egui::pos2(rect.min.x + x, y),
|
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||||||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT),
|
egui::Stroke::new(1.0, egui::Color32::from_gray(30)),
|
||||||
],
|
);
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(30)),
|
}
|
||||||
);
|
time += interval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimeDisplayFormat::Measures => {
|
||||||
|
let beats_per_second = document.bpm / 60.0;
|
||||||
|
let bpm_count = document.time_signature.numerator;
|
||||||
|
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
||||||
|
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
|
||||||
|
for beat_idx in start_beat..=end_beat {
|
||||||
|
if beat_idx < 0 { continue; }
|
||||||
|
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
|
||||||
|
if x < 0.0 || x > rect.width() { continue; }
|
||||||
|
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
|
||||||
|
let gray = if is_measure_boundary { 45 } else { 25 };
|
||||||
|
painter.line_segment(
|
||||||
|
[egui::pos2(rect.min.x + x, y),
|
||||||
|
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||||||
|
egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, egui::Color32::from_gray(gray)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
time += interval;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw clip instances for this layer
|
// Draw clip instances for this layer
|
||||||
|
|
@ -2647,13 +2708,95 @@ impl PaneRenderer for TimelinePane {
|
||||||
let text_style = shared.theme.style(".text-primary", ui.ctx());
|
let text_style = shared.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));
|
||||||
|
|
||||||
// Time display
|
// Time display (format-dependent)
|
||||||
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
|
{
|
||||||
|
let (bpm, time_sig_num, time_sig_den) = {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
(doc.bpm, doc.time_signature.numerator, doc.time_signature.denominator)
|
||||||
|
};
|
||||||
|
|
||||||
ui.separator();
|
match self.time_display_format {
|
||||||
|
TimeDisplayFormat::Seconds => {
|
||||||
|
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
|
||||||
|
}
|
||||||
|
TimeDisplayFormat::Measures => {
|
||||||
|
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
|
||||||
|
let pos = lightningbeam_core::beat_time::time_to_measure(
|
||||||
|
*shared.playback_time, bpm, &time_sig,
|
||||||
|
);
|
||||||
|
ui.colored_label(text_color, format!(
|
||||||
|
"BAR: {}.{} | BPM: {:.0} | {}/{}",
|
||||||
|
pos.measure, pos.beat, bpm,
|
||||||
|
time_sig_num, time_sig_den,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Zoom display
|
ui.separator();
|
||||||
ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second));
|
|
||||||
|
// Zoom display
|
||||||
|
ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second));
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Time display format toggle
|
||||||
|
egui::ComboBox::from_id_salt("time_format")
|
||||||
|
.selected_text(match self.time_display_format {
|
||||||
|
TimeDisplayFormat::Seconds => "Seconds",
|
||||||
|
TimeDisplayFormat::Measures => "Measures",
|
||||||
|
})
|
||||||
|
.width(80.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Seconds, "Seconds");
|
||||||
|
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Measures, "Measures");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// BPM control
|
||||||
|
let mut bpm_val = bpm;
|
||||||
|
ui.label("BPM:");
|
||||||
|
let bpm_response = ui.add(egui::DragValue::new(&mut bpm_val)
|
||||||
|
.range(20.0..=300.0)
|
||||||
|
.speed(0.5)
|
||||||
|
.fixed_decimals(1));
|
||||||
|
if bpm_response.changed() {
|
||||||
|
shared.action_executor.document_mut().bpm = bpm_val;
|
||||||
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Time signature selector
|
||||||
|
let time_sig_presets: [(u32, u32); 8] = [
|
||||||
|
(2, 4), (3, 4), (4, 4), (5, 4),
|
||||||
|
(6, 8), (7, 8), (9, 8), (12, 8),
|
||||||
|
];
|
||||||
|
let current_ts_label = format!("{}/{}", time_sig_num, time_sig_den);
|
||||||
|
egui::ComboBox::from_id_salt("time_sig")
|
||||||
|
.selected_text(¤t_ts_label)
|
||||||
|
.width(60.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (num, den) in &time_sig_presets {
|
||||||
|
let label = format!("{}/{}", num, den);
|
||||||
|
if ui.selectable_label(
|
||||||
|
time_sig_num == *num && time_sig_den == *den,
|
||||||
|
&label,
|
||||||
|
).clicked() {
|
||||||
|
let doc = shared.action_executor.document_mut();
|
||||||
|
doc.time_signature.numerator = *num;
|
||||||
|
doc.time_signature.denominator = *den;
|
||||||
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
controller.set_tempo(doc.bpm as f32, (*num, *den));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -2750,7 +2893,7 @@ 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);
|
self.render_ruler(ui, ruler_rect, shared.theme, document.bpm, &document.time_signature);
|
||||||
|
|
||||||
// Render layer rows with clipping
|
// Render layer rows with clipping
|
||||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue