diff --git a/Changelog.md b/Changelog.md index 9d4eaf4..d331668 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,23 @@ +# 1.0.8-alpha: +Changes: +- Mobile/touch UI (experimental, testing only — not built or packaged for mobile yet; enabled on desktop with the LB_MOBILE_UI environment variable): work-in-progress phone-friendly interface with a vertical sliding-window pane stack you drag to reveal panes, a new-file intent picker, a selection inspector sheet, a keyboard-primary music surface, a Focus/Patch node editor, long-press context menus, a command palette, and landscape/orientation support +- Text layers: add and edit text with a chosen font; non-bundled fonts are embedded in the project so it renders on machines that lack them +- Animated GIF export (parallel palette encoding) +- Audio tag metadata (title, artist, album, genre, year, track, comment) is written into exports — ID3v2 for MP3, iTunes/MP4 atoms for AAC, Vorbis comments for FLAC, RIFF INFO for WAV — with sensible defaults (year, artist/album remembered between exports) +- Lossy WebP image export, so the quality control now actually applies +- SVG export now includes text layers, as real font-independent glyph outlines +- Crash recovery: the editor autosaves your work to a recovery file in the background and offers to restore it after an unclean shutdown +- Prompt to save unsaved changes before starting a new file, opening another, or quitting +- Faster saves on painting projects: unchanged raster frames are no longer re-encoded every save + +Bugfixes: +- FLAC export previously wrote a WAV file with a .flac extension; it now encodes real (compressed) FLAC +- ProRes 422 export always failed; it now encodes 10-bit 4:2:2 correctly +- Fix VP8 video export with audio (muxed into WebM instead of an incompatible container) +- The WebP "Quality" slider had no effect +- Starting a new file now fully resets the audio engine, so instruments and voices from the previous project no longer linger +- Fix oscillator/synth phase drift over long playback + # 1.0.7-alpha: Changes: - HDR video support: PQ/HLG/BT.2020 video is now read correctly (decoded to scene-linear), with a per-document output mode (clip vs highlight rolloff) and 10-bit HDR export (HEVC Main10, PQ or HLG) diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 83d3858..789b9d8 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -50,6 +50,10 @@ pub struct ExportSettings { pub end_time: Seconds, /// Tempo map for beat-position scheduling pub tempo_map: TempoMap, + /// Tag metadata as (ffmpeg-key, value) pairs (e.g. ("title", "…"), ("artist", "…")). Written to + /// the container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis comments (FLAC), RIFF INFO + /// (WAV). Empty = no tags. + pub metadata: Vec<(String, String)>, } impl Default for ExportSettings { @@ -63,10 +67,26 @@ impl Default for ExportSettings { start_time: Seconds::ZERO, end_time: Seconds(60.0), tempo_map: TempoMap::constant(120.0), + metadata: Vec::new(), } } } +/// Set tag metadata on an ffmpeg output context (before `write_header`). FFmpeg maps the standard +/// keys to each container's native tags. +fn apply_metadata(output: &mut ffmpeg_next::format::context::Output, metadata: &[(String, String)]) { + if metadata.is_empty() { + return; + } + let mut dict = ffmpeg_next::Dictionary::new(); + for (k, v) in metadata { + if !v.is_empty() { + dict.set(k, v); + } + } + output.set_metadata(dict); +} + /// Export the project to an audio file /// /// This performs offline rendering, processing the entire timeline @@ -108,17 +128,21 @@ pub fn export_audio>( // Route to appropriate export implementation based on format. // Ensure export mode is disabled even if an error occurs. let result = match settings.format { - ExportFormat::Wav | ExportFormat::Flac => { + ExportFormat::Wav => { let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?; - // Signal that rendering is done and we're now writing the file if let Some(ref mut tx) = event_tx { let _ = tx.push(AudioEvent::ExportFinalizing); } - match settings.format { - ExportFormat::Wav => write_wav(&samples, settings, &output_path), - ExportFormat::Flac => write_flac(&samples, settings, &output_path), - _ => unreachable!(), + write_wav(&samples, settings, &output_path) + // hound writes no metadata; append a RIFF INFO chunk for tags. + .and_then(|_| append_wav_info_chunk(output_path.as_ref(), &settings.metadata)) + } + ExportFormat::Flac => { + let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?; + if let Some(ref mut tx) = event_tx { + let _ = tx.push(AudioEvent::ExportFinalizing); } + export_flac(&samples, settings, &output_path) } ExportFormat::Mp3 => { export_mp3(project, pool, settings, output_path, event_tx) @@ -273,48 +297,174 @@ fn write_wav>( Ok(()) } -/// Write FLAC file using hound (FLAC is essentially lossless WAV) -fn write_flac>( +/// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment +/// metadata). Replaces the former `write_flac`, which wrote WAV bytes to a `.flac` file. 16-bit +/// uses S16; 24-bit uses S32 (ffmpeg's flac encoder emits `bits_per_raw_sample = 24` for S32, +/// taking the top 24 bits). +fn export_flac>( samples: &[f32], settings: &ExportSettings, output_path: P, ) -> Result<(), String> { - // For now, we'll use hound to write a WAV-like FLAC file - // In the future, we could use a dedicated FLAC encoder - let spec = hound::WavSpec { - channels: settings.channels as u16, - sample_rate: settings.sample_rate, - bits_per_sample: settings.bit_depth, - sample_format: hound::SampleFormat::Int, + use ffmpeg_next as ffmpeg; + + ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC) + .ok_or("FLAC encoder not found in this ffmpeg build")?; + let mut output = ffmpeg::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + let channel_layout = match settings.channels { + 1 => ffmpeg::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", settings.channels)), }; - let mut writer = hound::WavWriter::create(output_path, spec) - .map_err(|e| format!("Failed to create FLAC file: {}", e))?; + // FLAC accepts packed S16 or S32; S32 → 24-bit output. + let use_24 = settings.bit_depth >= 24; + let sample_fmt = if use_24 { + ffmpeg::format::Sample::I32(ffmpeg::format::sample::Type::Packed) + } else { + ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Packed) + }; - // Write samples (same as WAV for now) - match settings.bit_depth { - 16 => { - for &sample in samples { - let clamped = sample.max(-1.0).min(1.0); - let pcm_value = (clamped * 32767.0) as i16; - writer.write_sample(pcm_value) - .map_err(|e| format!("Failed to write sample: {}", e))?; + let mut encoder = ffmpeg::codec::Context::new_with_codec(codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create FLAC encoder: {}", e))?; + encoder.set_rate(settings.sample_rate as i32); + encoder.set_channel_layout(channel_layout); + encoder.set_format(sample_fmt); + encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32)); + let mut encoder = encoder.open_as(codec) + .map_err(|e| format!("Failed to open FLAC encoder: {}", e))?; + + { + let mut stream = output.add_stream(codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } + apply_metadata(&mut output, &settings.metadata); + output.write_header() + .map_err(|e| format!("Failed to write FLAC header: {}", e))?; + + let channels = settings.channels as usize; + let num_frames = samples.len() / channels; + let frame_size = if encoder.frame_size() > 0 { encoder.frame_size() as usize } else { 4096 }; + + let mut done = 0usize; + while done < num_frames { + let n = (num_frames - done).min(frame_size); + let mut frame = ffmpeg::frame::Audio::new(sample_fmt, n, channel_layout); + frame.set_rate(settings.sample_rate); + frame.set_pts(Some(done as i64)); // samples; the FLAC muxer requires PTS + + let buf = frame.data_mut(0); // packed interleaved → plane 0 + let base = done * channels; + if use_24 { + for i in 0..n * channels { + let s = samples[base + i].clamp(-1.0, 1.0); + let v = (s as f64 * 2_147_483_647.0) as i32; // full-scale S32; encoder takes top 24 + buf[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + } else { + for i in 0..n * channels { + let s = samples[base + i].clamp(-1.0, 1.0); + let v = (s * 32767.0) as i16; + buf[i * 2..i * 2 + 2].copy_from_slice(&v.to_le_bytes()); } } - 24 => { - for &sample in samples { - let clamped = sample.max(-1.0).min(1.0); - let pcm_value = (clamped * 8388607.0) as i32; - writer.write_sample(pcm_value) - .map_err(|e| format!("Failed to write sample: {}", e))?; - } + + encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?; + flac_write_packets(&mut encoder, &mut output)?; + done += n; + } + encoder.send_eof().map_err(|e| format!("Failed to flush FLAC encoder: {}", e))?; + flac_write_packets(&mut encoder, &mut output)?; + output.write_trailer().map_err(|e| format!("Failed to finalize FLAC: {}", e))?; + Ok(()) +} + +/// Drain encoded FLAC packets and write them (non-interleaved). Skips the trailing empty flush +/// packet, which the FLAC muxer otherwise rejects as "Invalid data". Rescales packet ts from the +/// encoder time base to the stream's. +fn flac_write_packets( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, +) -> Result<(), String> { + let mut pkt = ffmpeg_next::Packet::empty(); + let enc_tb = encoder.time_base(); + let stream_tb = output.stream(0).map(|s| s.time_base()).unwrap_or(enc_tb); + while encoder.receive_packet(&mut pkt).is_ok() { + if pkt.size() == 0 { + continue; } - _ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), + pkt.set_stream(0); + pkt.rescale_ts(enc_tb, stream_tb); + pkt.write(output).map_err(|e| format!("Failed to write FLAC packet: {}", e))?; + } + Ok(()) +} + +/// Append a RIFF `LIST`/`INFO` metadata chunk to a finished WAV file (hound writes no tags), then +/// fix up the top-level RIFF size. Maps ffmpeg-style keys to RIFF INFO sub-chunk IDs. Trailing INFO +/// chunks are ignored by players that don't read them. +fn append_wav_info_chunk(path: &Path, metadata: &[(String, String)]) -> Result<(), String> { + use std::io::{Seek, SeekFrom, Write}; + + let riff_id = |key: &str| -> Option<&'static [u8; 4]> { + match key { + "title" => Some(b"INAM"), + "artist" => Some(b"IART"), + "album" => Some(b"IPRD"), + "genre" => Some(b"IGNR"), + "comment" => Some(b"ICMT"), + "date" => Some(b"ICRD"), + "track" => Some(b"ITRK"), + _ => None, + } + }; + + let mut info: Vec = Vec::new(); + info.extend_from_slice(b"INFO"); + for (key, val) in metadata { + if val.is_empty() { + continue; + } + let Some(id) = riff_id(key) else { continue }; + let mut bytes = val.as_bytes().to_vec(); + bytes.push(0); // NUL-terminate + if bytes.len() % 2 == 1 { + bytes.push(0); // pad to even + } + info.extend_from_slice(id); + info.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + info.extend_from_slice(&bytes); + } + if info.len() <= 4 { + return Ok(()); // nothing but the "INFO" tag } - writer.finalize() - .map_err(|e| format!("Failed to finalize FLAC file: {}", e))?; + let mut list: Vec = Vec::with_capacity(info.len() + 8); + list.extend_from_slice(b"LIST"); + list.extend_from_slice(&(info.len() as u32).to_le_bytes()); + list.extend_from_slice(&info); + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| format!("Failed to open WAV for tagging: {}", e))?; + let end = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; + if end % 2 == 1 { + f.write_all(&[0]).map_err(|e| e.to_string())?; + } + f.write_all(&list).map_err(|e| format!("Failed to write WAV tags: {}", e))?; + let new_len = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; + f.seek(SeekFrom::Start(4)).map_err(|e| e.to_string())?; + f.write_all(&((new_len - 8) as u32).to_le_bytes()) + .map_err(|e| format!("Failed to update RIFF size: {}", e))?; Ok(()) } @@ -362,6 +512,7 @@ fn export_mp3>( stream.set_parameters(&encoder); } + apply_metadata(&mut output, &settings.metadata); output.write_header() .map_err(|e| format!("Failed to write header: {}", e))?; @@ -531,6 +682,7 @@ fn export_aac>( stream.set_parameters(&encoder); } + apply_metadata(&mut output, &settings.metadata); output.write_header() .map_err(|e| format!("Failed to write header: {}", e))?; @@ -838,4 +990,61 @@ mod tests { assert_eq!(ExportFormat::Wav.extension(), "wav"); assert_eq!(ExportFormat::Flac.extension(), "flac"); } + + fn tagged_settings(format: ExportFormat) -> ExportSettings { + ExportSettings { + format, + sample_rate: 48000, + channels: 2, + bit_depth: 24, + mp3_bitrate: 192, + start_time: Seconds::ZERO, + end_time: Seconds(0.2), // tiny render + tempo_map: TempoMap::constant(120.0), + metadata: vec![ + ("title".to_string(), "Test Title".to_string()), + ("artist".to_string(), "Test Artist".to_string()), + ], + } + } + + /// FLAC export must be a real FLAC container (not WAV bytes) carrying Vorbis-comment tags. + #[test] + fn flac_export_is_real_flac_with_tags() { + let settings = tagged_settings(ExportFormat::Flac); + let mut project = Project::new(48000); + let pool = AudioPool::new(); + let path = std::env::temp_dir().join("lb_be_flac_test.flac"); + + export_audio(&mut project, &pool, &settings, &path, None).expect("FLAC export failed"); + let bytes = std::fs::read(&path).unwrap(); + + assert_eq!(&bytes[0..4], b"fLaC", "not real FLAC (got {:?})", &bytes[0..4]); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("Test Title"), "title tag missing from FLAC"); + assert!(s.contains("Test Artist"), "artist tag missing from FLAC"); + std::fs::remove_file(&path).ok(); + } + + /// WAV export keeps a valid RIFF container and gains a LIST/INFO tag chunk with a fixed-up size. + #[test] + fn wav_export_has_info_chunk() { + let settings = tagged_settings(ExportFormat::Wav); + let mut project = Project::new(48000); + let pool = AudioPool::new(); + let path = std::env::temp_dir().join("lb_be_wav_test.wav"); + + export_audio(&mut project, &pool, &settings, &path, None).expect("WAV export failed"); + let bytes = std::fs::read(&path).unwrap(); + + assert_eq!(&bytes[0..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + assert_eq!(riff_size, bytes.len() - 8, "RIFF size not fixed up after tagging"); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("LIST") && s.contains("INFO") && s.contains("INAM"), + "no RIFF INFO chunk"); + assert!(s.contains("Test Title"), "title not in WAV INFO chunk"); + std::fs::remove_file(&path).ok(); + } } diff --git a/daw-backend/src/audio/node_graph/nodes/oscillator.rs b/daw-backend/src/audio/node_graph/nodes/oscillator.rs index 243a7bf..769a2b9 100644 --- a/daw-backend/src/audio/node_graph/nodes/oscillator.rs +++ b/daw-backend/src/audio/node_graph/nodes/oscillator.rs @@ -164,11 +164,11 @@ impl AudioNode for OscillatorNode { output[frame * 2] = sample; // Left output[frame * 2 + 1] = sample; // Right - // Update phase once per frame - self.phase += freq_mod / sample_rate_f32; - if self.phase >= 1.0 { - self.phase -= 1.0; - } + // Update phase once per frame. `rem_euclid(1.0)` gives a numerically stable + // wraparound into [0, 1): repeated conditional subtraction accumulates f32 + // rounding error over long-held notes (timbre drift), and FM can drive + // `freq_mod` negative, which a one-sided `if >= 1.0` wouldn't wrap at all. + self.phase = (self.phase + freq_mod / sample_rate_f32).rem_euclid(1.0); } } diff --git a/daw-backend/src/effects/synth.rs b/daw-backend/src/effects/synth.rs index 06e6cea..82a77b9 100644 --- a/daw-backend/src/effects/synth.rs +++ b/daw-backend/src/effects/synth.rs @@ -113,11 +113,10 @@ impl SynthVoice { // Simple sine wave let sample = (self.phase * 2.0 * PI).sin() * (self.velocity as f32 / 127.0) * 0.3; - // Update phase - self.phase += self.frequency / sample_rate; - if self.phase >= 1.0 { - self.phase -= 1.0; - } + // Update phase. Use `.fract()` for a numerically stable wraparound: repeated + // conditional subtraction accumulates f32 rounding error over long-held notes, + // which drifts the timbre. + self.phase = (self.phase + self.frequency / sample_rate).fract(); self.age += 1; diff --git a/lightningbeam-ui/.gitignore b/lightningbeam-ui/.gitignore index c91cf97..d08bb02 100644 --- a/lightningbeam-ui/.gitignore +++ b/lightningbeam-ui/.gitignore @@ -15,3 +15,8 @@ # OS .DS_Store Thumbs.db + +# Local, machine-specific cargo config (e.g. PKG_CONFIG_PATH for a local ffmpeg). +# CI/packaging build ffmpeg from source (see .github/workflows/build.yml), so this +# must never leak into a commit. +.cargo/config.toml diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index c0e474c..efbdbeb 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -197,7 +197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" dependencies = [ "alsa-sys", - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -219,7 +219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ "android-properties", - "bitflags 2.10.0", + "bitflags 2.13.0", "cc", "cesu8", "jni", @@ -319,7 +319,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -700,10 +700,10 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -741,9 +741,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -796,7 +796,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -910,7 +910,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -935,7 +935,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "log", "polling", "rustix 0.38.44", @@ -949,7 +949,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "polling", "rustix 1.1.2", "slab", @@ -1303,7 +1303,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "libc", ] @@ -1359,7 +1359,7 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", @@ -1369,7 +1369,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -1445,7 +1445,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crossterm_winapi", "libc", "mio", @@ -1754,10 +1754,10 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -1866,7 +1866,7 @@ version = "0.33.3" dependencies = [ "accesskit", "ahash 0.8.12", - "bitflags 2.10.0", + "bitflags 2.13.0", "emath", "epaint", "log", @@ -2234,7 +2234,7 @@ version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "ffmpeg-sys-next", "libc", ] @@ -2348,13 +2348,22 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bf886368962a7d8456f136073ed33ed1b0770c690d2063731bb6a776e99f33" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2371,6 +2380,27 @@ dependencies = [ "ttf-parser 0.21.1", ] +[[package]] +name = "fontique" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.40.2", + "roxmltree 0.21.1", + "smallvec", + "windows 0.62.2", + "windows-core 0.62.2", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2662,7 +2692,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -2727,7 +2757,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg_aliases", "cgl", "dispatch2", @@ -2735,7 +2765,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2804,7 +2834,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "gpu-alloc-types", ] @@ -2814,7 +2844,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2835,7 +2865,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -2846,7 +2876,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2937,6 +2967,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0589ddd0d2935dd2845827ac606b4081c266225d613b268ed2910f832889cab" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts 0.40.2", + "smallvec", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2984,6 +3026,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.9.1" @@ -3061,7 +3112,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -3075,35 +3126,58 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.1.1" +name = "icu_locale" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] [[package]] -name = "icu_normalizer" -version = "2.1.1" +name = "icu_locale_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3115,15 +3189,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3135,18 +3209,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3154,6 +3230,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "idna" version = "1.1.0" @@ -3362,7 +3459,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] @@ -3457,7 +3554,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "redox_syscall 0.5.18", ] @@ -3509,14 +3606,16 @@ dependencies = [ "image", "kurbo 0.12.0", "lru", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "parley", "pathdiff", "rstar", "rusqlite", "serde", "serde_json", + "skrifa 0.43.2", "tiny-skia", "uuid", "vello", @@ -3529,7 +3628,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.5-alpha" +version = "1.0.7-alpha" dependencies = [ "beamdsp", "bytemuck", @@ -3543,6 +3642,7 @@ dependencies = [ "egui_extras", "egui_node_graph2", "ffmpeg-next", + "gif", "gpu-video-encoder", "half", "image", @@ -3580,7 +3680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b407ca668368d1d5a86cea58ac82d9f9f9ca4bac1e9dce6f16f875f0f081a911" dependencies = [ "ahash 0.8.12", - "bitflags 2.10.0", + "bitflags 2.13.0", "const-str", "cssparser", "cssparser-color", @@ -3689,7 +3789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" dependencies = [ "cc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "time", ] @@ -3746,9 +3846,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3778,7 +3878,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block", "core-graphics-types 0.2.0", "foreign-types", @@ -3896,7 +3996,7 @@ checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -3927,7 +4027,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "jni-sys", "log", "ndk-sys", @@ -3974,7 +4074,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -4142,9 +4242,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -4155,7 +4255,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4171,10 +4271,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-cloud-kit 0.3.2", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -4192,9 +4292,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4207,7 +4307,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4217,7 +4317,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4230,8 +4330,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4253,7 +4353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4265,8 +4365,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", ] [[package]] @@ -4275,7 +4375,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4287,8 +4387,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4298,11 +4398,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -4311,9 +4411,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -4336,7 +4436,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4358,8 +4458,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] @@ -4370,8 +4470,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -4389,7 +4489,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "dispatch", "libc", @@ -4402,10 +4502,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -4415,8 +4515,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -4438,7 +4538,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4450,7 +4550,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4463,8 +4563,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4484,7 +4584,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -4516,7 +4616,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4525,9 +4625,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -4625,7 +4725,7 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cssparser", "log", "phf", @@ -4678,6 +4778,45 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parlance" +version = "0.1.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" + +[[package]] +name = "parley" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_core", + "parley_data", + "skrifa 0.43.2", +] + +[[package]] +name = "parley_core" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "parley_data" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "icu_properties", +] + [[package]] name = "password-hash" version = "0.4.2" @@ -4909,7 +5048,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -4963,6 +5102,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -5148,9 +5289,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -5238,7 +5379,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cassowary", "compact_str", "crossterm", @@ -5335,7 +5476,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", - "font-types", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487889119a5f19ff7c0a20637196bdc76b9f54ebec17e3588b5d75e4999f8773" +dependencies = [ + "bytemuck", + "font-types 0.12.0", + "once_cell", ] [[package]] @@ -5353,7 +5505,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -5452,7 +5604,7 @@ dependencies = [ "dispatch2", "js-sys", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5510,7 +5662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" dependencies = [ "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.0", "serde", "serde_derive", "unicode-ident", @@ -5522,6 +5674,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rstar" version = "0.12.2" @@ -5545,7 +5706,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -5580,7 +5741,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5593,7 +5754,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -5612,7 +5773,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytemuck", "smallvec", "ttf-parser 0.21.1", @@ -5862,7 +6023,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb" +dependencies = [ + "bytemuck", + "read-fonts 0.40.2", ] [[package]] @@ -5896,7 +6067,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -5921,7 +6092,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "calloop 0.14.3", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -5968,7 +6139,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -6473,11 +6644,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6755,7 +6927,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "rustybuzz", "simplecss", "siphasher", @@ -6781,7 +6953,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -6841,7 +7013,7 @@ dependencies = [ "log", "peniko", "png 0.17.16", - "skrifa", + "skrifa 0.37.0", "static_assertions", "thiserror 2.0.17", "vello_encoding", @@ -6857,7 +7029,7 @@ dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa", + "skrifa 0.37.0", "smallvec", ] @@ -6994,7 +7166,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "rustix 1.1.2", "wayland-backend", "wayland-scanner", @@ -7006,7 +7178,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cursor-icon", "wayland-backend", ] @@ -7028,7 +7200,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7040,7 +7212,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7053,7 +7225,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7066,7 +7238,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7079,7 +7251,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7139,7 +7311,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "url", "web-sys", @@ -7158,7 +7330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "document-features", @@ -7189,7 +7361,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.13.0", "bytemuck", "cfg_aliases", "document-features", @@ -7249,7 +7421,7 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags 2.10.0", + "bitflags 2.13.0", "block", "bytemuck", "cfg-if", @@ -7294,7 +7466,7 @@ version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytemuck", "js-sys", "log", @@ -7364,11 +7536,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -7380,6 +7564,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -7406,6 +7599,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -7414,7 +7620,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7483,6 +7700,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -7501,6 +7728,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.1.0" @@ -7520,6 +7756,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -7646,6 +7891,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -7844,7 +8098,7 @@ dependencies = [ "ahash 0.8.12", "android-activity", "atomic-waker", - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -8008,7 +8262,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "dlib", "log", "once_cell", @@ -8043,10 +8297,21 @@ dependencies = [ ] [[package]] -name = "yoke" -version = "0.8.1" +name = "yeslogic-fontconfig-sys" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8055,9 +8320,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -8206,21 +8471,23 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8228,9 +8495,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index 1c2c1b1..1b7ea56 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -24,6 +24,11 @@ vello = { git = "https://github.com/linebender/vello", branch = "main" } wgpu = { version = "27", features = ["vulkan", "metal", "gles"] } kurbo = { version = "0.12", features = ["serde"] } peniko = "0.5" +# Text layout/shaping for text layers. Pinned to git main to match the peniko 0.5 +# / skrifa 0.37 that vello's git-main resolves to — a released parley still pins +# peniko 0.4, which would split `peniko::Font` into two incompatible types at the +# vello `draw_glyphs` boundary. +parley = { git = "https://github.com/linebender/parley", branch = "main" } # Windowing winit = "0.30" @@ -78,6 +83,15 @@ opt-level = 2 [profile.dev.package.cpal] opt-level = 2 +# GIF export: NeuQuant palette quantization is tight numeric loops that are punishingly slow +# unoptimized (10–30× in debug). Optimize the encoder crates even in dev builds. +[profile.dev.package.gif] +opt-level = 3 +[profile.dev.package.color_quant] +opt-level = 3 +[profile.dev.package.weezl] +opt-level = 3 + # Use local egui fork with ibus/Wayland text input fix [patch.crates-io] egui = { path = "../../egui-fork/crates/egui" } diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index c8a8fef..11b15bc 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -18,6 +18,12 @@ bytemuck = { version = "1.14", features = ["derive"] } kurbo = { workspace = true } vello = { workspace = true } +# Text layout/shaping (text layers) +parley = { workspace = true } +# Glyph outline extraction for lossless text→SVG export. Pinned to the version parley resolves +# (0.43) so glyph IDs / normalized coords from parley layouts match this outliner. +skrifa = "0.43" + # Image decoding for image fills image = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationMono-Regular.ttf b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationMono-Regular.ttf new file mode 100644 index 0000000..1b3fc9b Binary files /dev/null and b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationMono-Regular.ttf differ diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSans-Regular.ttf b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..adb8947 Binary files /dev/null and b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSerif-Regular.ttf b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSerif-Regular.ttf new file mode 100644 index 0000000..3e98e40 Binary files /dev/null and b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSerif-Regular.ttf differ diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/README.txt b/lightningbeam-ui/lightningbeam-core/assets/fonts/README.txt new file mode 100644 index 0000000..aba7f4c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/assets/fonts/README.txt @@ -0,0 +1,14 @@ +Bundled fonts for Lightningbeam text layers +=========================================== + +These are the Liberation fonts, used as the built-in serif / sans-serif / monospaced +families so text renders deterministically without depending on system fonts: + + LiberationSans-Regular.ttf + LiberationSerif-Regular.ttf + LiberationMono-Regular.ttf + +License: SIL Open Font License, Version 1.1. +Copyright (c) Red Hat, Inc., with Reserved Font Name "Liberation". +Full license text: https://openfontlicense.org/ (also distributed with the Liberation +fonts package as its OFL.txt / LICENSE). diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 0a278f5..1e4bf10 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -247,6 +247,7 @@ impl ActionExecutor { Ok(()) => { // Move to redo stack self.redo_stack.push(action); + self.epoch = self.epoch.wrapping_add(1); Ok(true) } Err(e) => { @@ -271,6 +272,7 @@ impl ActionExecutor { Ok(()) => { // Move back to undo stack self.undo_stack.push(action); + self.epoch = self.epoch.wrapping_add(1); Ok(true) } Err(e) => { @@ -444,6 +446,7 @@ impl ActionExecutor { // Move to redo stack self.redo_stack.push(action); + self.epoch = self.epoch.wrapping_add(1); Ok(true) } else { @@ -481,6 +484,7 @@ impl ActionExecutor { // Move back to undo stack self.undo_stack.push(action); + self.epoch = self.epoch.wrapping_add(1); Ok(true) } else { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index a1f438b..326550a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -104,7 +104,7 @@ impl Action for AddClipInstanceAction { AnyLayer::Group(_) => { return Err("Cannot add clip instances directly to group layers".to_string()); } - AnyLayer::Raster(_) => { + AnyLayer::Raster(_) | AnyLayer::Text(_) => { return Err("Cannot add clip instances directly to group layers".to_string()); } } @@ -145,8 +145,8 @@ impl Action for AddClipInstanceAction { AnyLayer::Group(_) => { // Group layers don't have clip instances, nothing to rollback } - AnyLayer::Raster(_) => { - // Raster layers don't have clip instances, nothing to rollback + AnyLayer::Raster(_) | AnyLayer::Text(_) => { + // Raster/text layers don't have clip instances, nothing to rollback } } self.executed = false; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs index 7eac030..c2f7b79 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs @@ -138,6 +138,7 @@ impl Action for AddLayerAction { AnyLayer::Effect(_) => "Add effect layer", AnyLayer::Group(_) => "Add group layer", AnyLayer::Raster(_) => "Add raster layer", + AnyLayer::Text(_) => "Add text layer", } .to_string() } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/create_text_clip.rs b/lightningbeam-ui/lightningbeam-core/src/actions/create_text_clip.rs new file mode 100644 index 0000000..3df1f4c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/create_text_clip.rs @@ -0,0 +1,133 @@ +//! Create-text-clip action +//! +//! Used by the text tool when the active layer is a **vector** layer: it creates a +//! VectorClip containing a single text layer, registers it, and places a clip +//! instance in the parent vector layer. The editor then enters that clip so the +//! text is directly editable. All of this is one undoable step so undo never +//! leaves an orphan clip. + +use crate::action::Action; +use crate::clip::{ClipInstance, VectorClip}; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::text_layer::TextLayer; +use uuid::Uuid; + +/// Minimum clip duration (seconds) when the document has no duration set yet. +const FALLBACK_DURATION: f64 = 10.0; + +pub struct CreateTextClipAction { + /// The vector layer to place the clip instance in. + parent_layer_id: Uuid, + /// The text layer to embed (its id is stable across redo). + text_layer: TextLayer, + /// Instance position in the parent layer's space. + position: (f64, f64), + + // Assigned on first execute; reused on redo so ids are stable. + clip_id: Option, + instance_id: Option, + executed: bool, +} + +impl CreateTextClipAction { + pub fn new(parent_layer_id: Uuid, text_layer: TextLayer, position: (f64, f64)) -> Self { + Self { + parent_layer_id, + text_layer, + position, + clip_id: None, + instance_id: None, + executed: false, + } + } + + /// Preset the clip + instance ids (so the caller knows them up-front for + /// entering the clip immediately after execute). Ids stay stable across redo. + pub fn with_ids(mut self, clip_id: Uuid, instance_id: Uuid) -> Self { + self.clip_id = Some(clip_id); + self.instance_id = Some(instance_id); + self + } + + /// The vector clip id (after execute). + pub fn clip_id(&self) -> Option { + self.clip_id + } + + /// The clip instance id placed in the parent layer (after execute). + pub fn instance_id(&self) -> Option { + self.instance_id + } + + /// The embedded text layer's id. + pub fn text_layer_id(&self) -> Uuid { + self.text_layer.layer.id + } +} + +impl Action for CreateTextClipAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let clip_id = self.clip_id.unwrap_or_else(Uuid::new_v4); + self.clip_id = Some(clip_id); + let instance_id = self.instance_id.unwrap_or_else(Uuid::new_v4); + self.instance_id = Some(instance_id); + + let duration = if document.duration > 0.0 { document.duration } else { FALLBACK_DURATION }; + + // Build the clip with the text layer as its single root layer. + let mut clip = VectorClip::with_id( + clip_id, + "Text", + self.text_layer.box_width.max(1.0), + self.text_layer.box_height.max(1.0), + duration, + ); + // A movie clip (not keyframe-gated) so the text persists across the timeline. + clip.is_group = false; + clip.layers.add_root(AnyLayer::Text(self.text_layer.clone())); + // Registers the clip and its layers in layer_to_clip_map for O(1) lookup. + document.add_vector_clip(clip); + + // Place an instance of the clip in the parent vector layer. + let layer = document + .get_layer_mut(&self.parent_layer_id) + .ok_or_else(|| format!("Parent layer {} not found", self.parent_layer_id))?; + let AnyLayer::Vector(vector_layer) = layer else { + return Err("Text clip can only be created inside a vector layer".to_string()); + }; + let mut instance = ClipInstance::with_id(instance_id, clip_id); + instance.transform.x = self.position.0; + instance.transform.y = self.position.1; + vector_layer.clip_instances.push(instance); + + self.executed = true; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if !self.executed { + return Ok(()); + } + // Remove the clip instance from the parent layer. + if let (Some(instance_id), Some(AnyLayer::Vector(vector_layer))) = + (self.instance_id, document.get_layer_mut(&self.parent_layer_id)) + { + vector_layer.clip_instances.retain(|ci| ci.id != instance_id); + } + // Remove the clip and its layer_to_clip_map registrations. + if let Some(clip_id) = self.clip_id { + if let Some(clip) = document.vector_clips.remove(&clip_id) { + for node in &clip.layers.roots { + document.layer_to_clip_map.remove(&node.data.id()); + } + } + } + self.executed = false; + Ok(()) + } + + fn description(&self) -> String { + "Add text".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 9d8d79e..01fd71e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -37,6 +37,7 @@ impl Action for LoopClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops { @@ -61,6 +62,7 @@ impl Action for LoopClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 29db4f5..7bed42f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -11,6 +11,7 @@ pub mod add_layer; pub mod add_shape; pub mod modify_shape_path; pub mod move_clip_instances; +pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_document_properties; @@ -44,6 +45,9 @@ pub mod resize_raster_layer; pub mod move_layer; pub mod set_fill_paint; pub mod set_image_fill; +pub mod create_text_clip; +pub mod set_text_content; +pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -51,6 +55,7 @@ pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; pub use modify_shape_path::ModifyGraphAction; pub use move_clip_instances::MoveClipInstancesAction; +pub use reorder_clip_instances::ReorderClipInstancesAction; pub use paint_bucket::PaintBucketAction; pub use remove_effect::RemoveEffectAction; pub use set_document_properties::SetDocumentPropertiesAction; @@ -84,3 +89,6 @@ pub use set_fill_paint::SetFillPaintAction; pub use set_image_fill::SetImageFillAction; pub use change_bpm::ChangeBpmAction; pub use change_fps::ChangeFpsAction; +pub use create_text_clip::CreateTextClipAction; +pub use set_text_content::SetTextContentAction; +pub use resize_text_box::ResizeTextBoxAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index fba7ee7..dd70075 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -58,6 +58,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -97,6 +98,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| { @@ -132,6 +134,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Update timeline_start for each clip instance @@ -159,6 +162,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Restore original timeline_start for each clip instance diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index afadffb..a043c7b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -46,6 +46,7 @@ impl Action for RemoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Find and remove the instance, saving it for rollback @@ -72,6 +73,7 @@ impl Action for RemoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; clip_instances.push(instance); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/reorder_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/reorder_clip_instances.rs new file mode 100644 index 0000000..ae429ec --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/reorder_clip_instances.rs @@ -0,0 +1,78 @@ +//! Reorder clip instances within a layer's stacking order (Send to Back / Bring to Front). +//! +//! A layer's `clip_instances` Vec order *is* the stacking order — the last element renders on top +//! (hit-testing walks it in reverse). Geometry renders underneath and is unaffected. This action +//! moves the selected instances to the front (end) or back (start) of that Vec. + +use crate::action::Action; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +pub struct ReorderClipInstancesAction { + layer_id: Uuid, + instance_ids: Vec, + /// `true` = bring to front (top), `false` = send to back (bottom). + to_front: bool, + /// Full instance order captured on `execute`, for `rollback`. + old_order: Option>, +} + +impl ReorderClipInstancesAction { + pub fn new(layer_id: Uuid, instance_ids: Vec, to_front: bool) -> Self { + Self { layer_id, instance_ids, to_front, old_order: None } + } +} + +/// The clip-instance stack for a layer, if it has one (Group/Raster/Text don't). +fn clip_instances_mut<'a>(document: &'a mut Document, layer_id: &Uuid) -> Option<&'a mut Vec> { + match document.get_layer_mut(layer_id)? { + AnyLayer::Vector(l) => Some(&mut l.clip_instances), + AnyLayer::Audio(l) => Some(&mut l.clip_instances), + AnyLayer::Video(l) => Some(&mut l.clip_instances), + AnyLayer::Effect(l) => Some(&mut l.clip_instances), + _ => None, + } +} + +impl Action for ReorderClipInstancesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instances = clip_instances_mut(document, &self.layer_id) + .ok_or_else(|| "Layer has no clip-instance stack".to_string())?; + self.old_order = Some(instances.iter().map(|c| c.id).collect()); + + let mut selected = Vec::new(); + let mut rest = Vec::new(); + for ci in instances.drain(..) { + if self.instance_ids.contains(&ci.id) { + selected.push(ci); + } else { + rest.push(ci); + } + } + if self.to_front { + rest.extend(selected); // selected last → rendered on top + *instances = rest; + } else { + selected.extend(rest); // selected first → rendered at the bottom (of the stack) + *instances = selected; + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(order) = self.old_order.clone() else { + return Ok(()); + }; + let instances = clip_instances_mut(document, &self.layer_id) + .ok_or_else(|| "Layer has no clip-instance stack".to_string())?; + let rank = |id: &Uuid| order.iter().position(|o| o == id).unwrap_or(usize::MAX); + instances.sort_by(|a, b| rank(&a.id).cmp(&rank(&b.id))); + Ok(()) + } + + fn description(&self) -> String { + if self.to_front { "Bring to Front".to_string() } else { "Send to Back".to_string() } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/resize_text_box.rs b/lightningbeam-ui/lightningbeam-core/src/actions/resize_text_box.rs new file mode 100644 index 0000000..abe3d51 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/resize_text_box.rs @@ -0,0 +1,59 @@ +//! Resize-text-box action +//! +//! Updates a text layer's box origin and dimensions (which changes the wrap +//! width, so parley re-wraps the text). One undoable step. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use kurbo::Point; +use uuid::Uuid; + +pub struct ResizeTextBoxAction { + layer_id: Uuid, + new_origin: Point, + new_width: f64, + new_height: f64, + old: Option<(Point, f64, f64)>, +} + +impl ResizeTextBoxAction { + pub fn new(layer_id: Uuid, new_origin: Point, new_width: f64, new_height: f64) -> Self { + Self { layer_id, new_origin, new_width, new_height, old: None } + } +} + +impl Action for ResizeTextBoxAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Text(text_layer) = layer else { + return Err("ResizeTextBoxAction target is not a text layer".to_string()); + }; + if self.old.is_none() { + self.old = Some((text_layer.box_origin, text_layer.box_width, text_layer.box_height)); + } + text_layer.box_origin = self.new_origin; + text_layer.box_width = self.new_width.max(1.0); + text_layer.box_height = self.new_height.max(1.0); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some((origin, w, h)) = self.old else { return Ok(()) }; + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + if let AnyLayer::Text(text_layer) = layer { + text_layer.box_origin = origin; + text_layer.box_width = w; + text_layer.box_height = h; + } + Ok(()) + } + + fn description(&self) -> String { + "Resize text box".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_text_content.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_text_content.rs new file mode 100644 index 0000000..cb9c991 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_text_content.rs @@ -0,0 +1,61 @@ +//! Set-text-content action +//! +//! Replaces a text layer's [`TextContent`] (text string, font size, color, family, +//! alignment) as one undoable step. Used both by in-place editing (text changes) +//! and the info panel (style changes). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::text_layer::TextContent; +use uuid::Uuid; + +pub struct SetTextContentAction { + layer_id: Uuid, + new: TextContent, + old: Option, +} + +impl SetTextContentAction { + pub fn new(layer_id: Uuid, new: TextContent) -> Self { + Self { layer_id, new, old: None } + } + + /// Construct with an explicit `old` value. Used by in-place editing, which mutates + /// the document live for preview and then records one undoable step capturing the + /// content as it was *before* editing began. + pub fn with_old(layer_id: Uuid, old: TextContent, new: TextContent) -> Self { + Self { layer_id, new, old: Some(old) } + } +} + +impl Action for SetTextContentAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Text(text_layer) = layer else { + return Err("SetTextContentAction target is not a text layer".to_string()); + }; + if self.old.is_none() { + self.old = Some(text_layer.content.clone()); + } + text_layer.content = self.new.clone(); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(old) = self.old.clone() else { return Ok(()) }; + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + if let AnyLayer::Text(text_layer) = layer { + text_layer.content = old; + } + Ok(()) + } + + fn description(&self) -> String { + "Edit text".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index f5de9a4..5e2101d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -114,6 +114,7 @@ impl Action for SplitClipInstanceAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()), AnyLayer::Raster(_) => return Err("Cannot split clip instances on group layers".to_string()), + AnyLayer::Text(_) => return Err("Cannot split clip instances on group layers".to_string()), }; let instance = clip_instances @@ -233,7 +234,7 @@ impl Action for SplitClipInstanceAction { AnyLayer::Group(_) => { return Err("Cannot split clip instances on group layers".to_string()); } - AnyLayer::Raster(_) => { + AnyLayer::Raster(_) | AnyLayer::Text(_) => { return Err("Cannot split clip instances on group layers".to_string()); } } @@ -294,8 +295,8 @@ impl Action for SplitClipInstanceAction { AnyLayer::Group(_) => { // Group layers don't have clip instances, nothing to rollback } - AnyLayer::Raster(_) => { - // Raster layers don't have clip instances, nothing to rollback + AnyLayer::Raster(_) | AnyLayer::Text(_) => { + // Raster/text layers don't have clip instances, nothing to rollback } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs index 433e6dc..e8d21b9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs @@ -101,6 +101,7 @@ impl Action for TransformClipInstancesAction { AnyLayer::Effect(_) => {} AnyLayer::Group(_) => {} AnyLayer::Raster(_) => {} + AnyLayer::Text(_) => {} } Ok(()) } @@ -140,6 +141,7 @@ impl Action for TransformClipInstancesAction { AnyLayer::Effect(_) => {} AnyLayer::Group(_) => {} AnyLayer::Raster(_) => {} + AnyLayer::Text(_) => {} } Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 9595ba3..60c1bd7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -101,6 +101,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -138,6 +139,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -182,6 +184,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let instance = clip_instances.iter() @@ -275,6 +278,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Apply trims @@ -315,6 +319,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Restore original trim values diff --git a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs index 770b544..6300df3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs +++ b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs @@ -62,6 +62,10 @@ pub enum MediaKind { /// the keyframe id). Decoded eagerly on load and shown while the full-res pixels /// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`. RasterProxy = 6, + /// An embedded font file (TTF/OTF) used by a text layer (keyed by a content hash + /// of the bytes so identical fonts dedupe). Registered into the font collection + /// on load so documents render faithfully on machines lacking the font. + Font = 7, } impl MediaKind { @@ -74,6 +78,7 @@ impl MediaKind { 4 => Some(Self::Waveform), 5 => Some(Self::Thumbnail), 6 => Some(Self::RasterProxy), + 7 => Some(Self::Font), _ => None, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index f483380..17a306a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -122,6 +122,7 @@ impl VectorClip { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; for ci in clip_instances { // Compute end position of this clip instance in beats @@ -223,6 +224,17 @@ impl VectorClip { Some(existing) => existing.union(transformed_bounds), }); } + } else if let AnyLayer::Text(text_layer) = &layer_node.data { + // Text layers contribute their box bounds (so a text-only clip is + // selectable/draggable, not a degenerate point). + let r = Rect::from_origin_size( + text_layer.box_origin, + (text_layer.box_width, text_layer.box_height), + ); + combined_bounds = Some(match combined_bounds { + None => r, + Some(existing) => existing.union(r), + }); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs index 4355754..a9ea32b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -62,6 +62,7 @@ impl ClipboardLayerType { AnyLayer::Effect(_) => ClipboardLayerType::Effect, AnyLayer::Group(_) => ClipboardLayerType::Vector, AnyLayer::Raster(_) => ClipboardLayerType::Vector, + AnyLayer::Text(_) => ClipboardLayerType::Vector, } } @@ -449,6 +450,13 @@ fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap) -> AnyLay nl.layer.id = new_layer_id; AnyLayer::Raster(nl) } + AnyLayer::Text(tl) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(tl.layer.id, new_layer_id); + let mut nl = tl.clone(); + nl.layer.id = new_layer_id; + AnyLayer::Text(nl) + } AnyLayer::Group(gl) => { let new_layer_id = Uuid::new_v4(); id_map.insert(gl.layer.id, new_layer_id); diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 62eba1c..a27b405 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -489,8 +489,8 @@ impl Document { } } } - crate::layer::AnyLayer::Raster(_) => { - // Raster layers don't have clip instances + crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => { + // Raster and text layers don't have clip instances } crate::layer::AnyLayer::Group(group) => { // Recurse into group children to find their clip instance endpoints @@ -530,8 +530,8 @@ impl Document { } } } - crate::layer::AnyLayer::Raster(_) => { - // Raster layers don't have clip instances + crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => { + // Raster and text layers don't have clip instances } crate::layer::AnyLayer::Group(g) => { process_group_children(&g.children, doc, max_end, calc_end); @@ -937,6 +937,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; @@ -977,6 +978,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; for instance in instances { @@ -1039,6 +1041,7 @@ impl Document { AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips + AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips }; let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); @@ -1134,6 +1137,7 @@ impl Document { AnyLayer::Vector(v) => &v.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; // Collect non-group clip ranges @@ -1205,6 +1209,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; for other in instances { @@ -1253,6 +1258,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let mut nearest_start = f64::MAX; @@ -1300,6 +1306,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let mut nearest_end = 0.0; diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 6e62d50..833c8ba 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -51,6 +51,54 @@ impl AudioFormat { } } +/// Optional tag metadata written into the exported audio file. Empty fields are omitted. FFmpeg +/// maps these standard keys to each container's native tags: ID3v2 (MP3), iTunes/MP4 atoms (M4A), +/// Vorbis comments (FLAC), and RIFF INFO (WAV). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AudioMetadata { + pub title: String, + pub artist: String, + pub album: String, + pub genre: String, + pub comment: String, + /// Year or full date (written to the `date` tag). + pub year: String, + /// Track number (written to the `track` tag). + pub track: String, +} + +impl AudioMetadata { + /// True when no field is set (so no metadata need be written). + pub fn is_empty(&self) -> bool { + self.title.is_empty() + && self.artist.is_empty() + && self.album.is_empty() + && self.genre.is_empty() + && self.comment.is_empty() + && self.year.is_empty() + && self.track.is_empty() + } + + /// The set (ffmpeg-key, value) pairs for non-empty fields, in a stable order. + pub fn pairs(&self) -> Vec<(&'static str, &str)> { + let mut v = Vec::new(); + for (key, val) in [ + ("title", &self.title), + ("artist", &self.artist), + ("album", &self.album), + ("genre", &self.genre), + ("comment", &self.comment), + ("date", &self.year), + ("track", &self.track), + ] { + if !val.is_empty() { + v.push((key, val.as_str())); + } + } + v + } +} + /// Audio export settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioExportSettings { @@ -79,6 +127,10 @@ pub struct AudioExportSettings { /// Project BPM (for beat-position scheduling during export) pub bpm: f64, + + /// Tag metadata (title/artist/…) written into the file. Empty = none. + #[serde(default)] + pub metadata: AudioMetadata, } impl Default for AudioExportSettings { @@ -92,6 +144,7 @@ impl Default for AudioExportSettings { start_time: 0.0, end_time: 60.0, bpm: 120.0, + metadata: AudioMetadata::default(), } } } @@ -543,6 +596,82 @@ impl ImageExportSettings { } } +// ── Animated GIF export ────────────────────────────────────────────────────── + +/// Settings for exporting an animated GIF (multi-frame, palette-quantized, no audio). +/// +/// GIF stores a per-frame delay in centiseconds (1/100 s), so effective frame rate is quantized to +/// whole centiseconds — [`Self::frame_delay_ms`] rounds accordingly and the dialog offers sensible +/// GIF rates. Each frame is quantized to a 256-color palette by the encoder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GifExportSettings { + /// Output width in pixels (None = use document width). + pub width: Option, + /// Output height in pixels (None = use document height). + pub height: Option, + /// Frame rate (fps). Snapped to whole-centisecond delays at encode time. + pub framerate: f64, + /// Loop the animation forever (GIF `NETSCAPE2.0` infinite loop). False = play once. + pub loop_forever: bool, + /// Preserve full alpha as GIF 1-bit transparency (pixels below the alpha threshold become the + /// transparent index). When false, frames are composited onto an opaque background first. + pub transparency: bool, + /// How the document is fit into the output frame when aspect ratios differ (default Letterbox). + #[serde(default)] + pub fit: ExportFitMode, + /// Start time in seconds. + pub start_time: f64, + /// End time in seconds. + pub end_time: f64, +} + +impl Default for GifExportSettings { + fn default() -> Self { + Self { + width: None, + height: None, + framerate: 15.0, + loop_forever: true, + transparency: false, + fit: ExportFitMode::Letterbox, + start_time: 0.0, + end_time: 5.0, + } + } +} + +impl GifExportSettings { + pub fn validate(&self) -> Result<(), String> { + if let Some(w) = self.width { if w == 0 { return Err("Width must be > 0".into()); } } + if let Some(h) = self.height { if h == 0 { return Err("Height must be > 0".into()); } } + if self.framerate <= 0.0 { + return Err("Framerate must be greater than 0".into()); + } + if self.start_time < 0.0 { + return Err("Start time cannot be negative".into()); + } + if self.end_time <= self.start_time { + return Err("End time must be greater than start time".into()); + } + Ok(()) + } + + /// Duration in seconds. + pub fn duration(&self) -> f64 { self.end_time - self.start_time } + + /// Total number of frames to render. + pub fn total_frames(&self) -> usize { + (self.duration() * self.framerate).ceil().max(1.0) as usize + } + + /// Per-frame delay in milliseconds, from the framerate (GIF stores this at centisecond + /// resolution, so the effective rate is snapped to the nearest 10 ms, min 10 ms). + pub fn frame_delay_ms(&self) -> u32 { + let ms = 1000.0 / self.framerate; + ((ms / 10.0).round().max(1.0) * 10.0) as u32 + } +} + /// Progress updates during export #[derive(Debug, Clone)] pub enum ExportProgress { @@ -728,6 +857,33 @@ mod tests { assert_eq!(settings.total_frames(), 300); } + #[test] + fn test_gif_frame_delay_and_frames() { + // Frame rates that map to clean centisecond delays. + let mk = |fps: f64| GifExportSettings { framerate: fps, ..Default::default() }; + assert_eq!(mk(10.0).frame_delay_ms(), 100); // 100 ms + assert_eq!(mk(20.0).frame_delay_ms(), 50); // 50 ms + assert_eq!(mk(50.0).frame_delay_ms(), 20); // 20 ms + // 15 fps = 66.6 ms rounds to 70 ms (7 cs); 25 fps = 40 ms. + assert_eq!(mk(15.0).frame_delay_ms(), 70); + assert_eq!(mk(25.0).frame_delay_ms(), 40); + // Very high fps clamps to the 10 ms minimum (1 cs). + assert_eq!(mk(1000.0).frame_delay_ms(), 10); + + let settings = GifExportSettings { framerate: 20.0, start_time: 0.0, end_time: 3.0, ..Default::default() }; + assert_eq!(settings.total_frames(), 60); + } + + #[test] + fn test_gif_validate() { + let mut s = GifExportSettings::default(); + assert!(s.validate().is_ok()); + s.framerate = 0.0; + assert!(s.validate().is_err()); + s = GifExportSettings { start_time: 5.0, end_time: 2.0, ..Default::default() }; + assert!(s.validate().is_err()); + } + #[test] fn test_export_progress_percentage() { let progress = ExportProgress::FrameRendered { frame: 50, total: 100 }; diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 4df18b7..d9c31a3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -242,6 +242,21 @@ fn thumbnail_media_id(clip_id: Uuid) -> Uuid { Uuid::from_u128(clip_id.as_u128() ^ SENTINEL) } +/// Content-hash id for an embedded font row, so identical font files dedupe to one +/// row regardless of which / how many text layers use them. A 128-bit id is built +/// from two salted 64-bit hashes of the bytes. +fn font_media_id(bytes: &[u8]) -> Uuid { + use std::hash::{Hash, Hasher}; + let mut h1 = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h1); + let mut h2 = std::collections::hash_map::DefaultHasher::new(); + 0xF0E1_D2C3_B4A5_9687u64.hash(&mut h2); + bytes.hash(&mut h2); + let hi = h1.finish() as u128; + let lo = h2.finish() as u128; + Uuid::from_u128((hi << 64) | lo) +} + /// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's /// own full-res `Raster` row, which is keyed by the raw keyframe id). fn raster_proxy_media_id(kf_id: Uuid) -> Uuid { @@ -384,16 +399,29 @@ pub fn save_beam( } // --- raster keyframes -> media rows (PNG), keyed by keyframe id --- - // (Phase 0 writes all resident frames each save; a disk-dirty flag to skip - // unchanged frames in place is deferred to Phase 3.) + // Incremental: only (re)encode a keyframe whose pixels changed since the last save. + // `kf.dirty` means "current pixels are not yet in the container" (set on any edit, + // cleared on a successful save — see main.rs); a clean frame already stored is kept + // in place, skipping the PNG re-encode of every resident frame on every save. // Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes // are persisted too, and so `live_media` covers them — matching the load path, // which arms `needs_fault_in` recursively. Top-level-only projects are unaffected. let mut raster_count = 0usize; + let mut raster_skipped = 0usize; for layer in document.all_layers() { if let crate::layer::AnyLayer::Raster(rl) = layer { for kf in &rl.keyframes { if !kf.raw_pixels.is_empty() { + // Clean + already stored → keep the existing full + proxy rows untouched. + if !kf.dirty && txn.media_exists(kf.id)? { + live_media.insert(kf.id); + let proxy_id = raster_proxy_media_id(kf.id); + if txn.media_exists(proxy_id)? { + live_media.insert(proxy_id); + } + raster_skipped += 1; + continue; + } let img = crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height); match crate::brush_engine::encode_png(&img) { @@ -535,6 +563,32 @@ pub fn save_beam( } let _ = image_count; + // --- embedded fonts -> media rows (Font), keyed by content hash so identical + // fonts dedupe. Embed every non-bundled family used by a text layer so the + // document renders faithfully on machines lacking the font. The bundled + // three ship with the app and are never embedded. --- + { + use crate::layer::AnyLayer; + let mut families: HashSet = HashSet::new(); + for layer in document.all_layers() { + if let AnyLayer::Text(tl) = layer { + let fam = &tl.content.font_family; + if !fam.is_empty() && !crate::fonts::is_bundled(fam) { + families.insert(fam.clone()); + } + } + } + for fam in families { + if let Some(bytes) = crate::fonts::family_font_bytes(&fam) { + let id = font_media_id(&bytes); + if !txn.media_exists(id)? { + txn.put_media_packed(id, MediaKind::Font, &fam, &bytes, MediaMeta::default())?; + } + live_media.insert(id); + } + } + } + // --- orphan cleanup: drop media for removed clips/keyframes --- let removed = txn.retain_media(&live_media)?; @@ -567,9 +621,10 @@ pub fn save_beam( } eprintln!( - "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media, {} orphans removed, in {:.2}ms", + "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media ({} unchanged frames skipped), {} orphans removed, in {:.2}ms", audio_pool_entries.len(), raster_count, + raster_skipped, removed, fn_start.elapsed().as_secs_f64() * 1000.0 ); @@ -598,6 +653,17 @@ fn load_beam_sqlite(path: &Path) -> Result { eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)..."); let archive = BeamArchive::open(path)?; + + // Register document-embedded fonts into the runtime font collection so text + // layers referencing them render faithfully even if the host lacks the font. + if let Ok(font_ids) = archive.media_ids_of_kind(MediaKind::Font) { + for id in font_ids { + if let Ok(bytes) = archive.read_media_full(id) { + crate::fonts::register_embedded(bytes); + } + } + } + let json = archive.get_project_json()?; let beam_project: BeamProject = serde_json::from_str(&json) .map_err(|e| format!("Failed to deserialize project.json: {}", e))?; diff --git a/lightningbeam-ui/lightningbeam-core/src/fonts.rs b/lightningbeam-ui/lightningbeam-core/src/fonts.rs new file mode 100644 index 0000000..40f2e80 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/fonts.rs @@ -0,0 +1,296 @@ +//! Font registry + text layout for text layers. +//! +//! Wraps a thread-local parley [`FontContext`]/[`LayoutContext`]. Three fonts are +//! bundled (serif, sans-serif, monospaced) so text renders deterministically even +//! offline; system fonts are also enumerated (parley's fontique source) and +//! document-embedded fonts can be registered at load time (see `file_io`). +//! +//! The same `linebender_resource_handle` crate (0.1.1) backs both vello's +//! `peniko::FontData` and parley's `FontData`, so a glyph run's `run.font()` can be +//! handed straight to `vello::Scene::draw_glyphs` with no conversion. + +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use parley::{ + Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, Layout, LayoutContext, + PositionedLayoutItem, StyleProperty, +}; +use vello::peniko::Blob; + +use crate::text_layer::{TextAlign, TextContent}; + +// ── Bundled fonts (SIL OFL; vendored under assets/fonts) ───────────────────── +static BUNDLED_SANS: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf"); +static BUNDLED_SERIF: &[u8] = include_bytes!("../assets/fonts/LiberationSerif-Regular.ttf"); +static BUNDLED_MONO: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf"); + +/// Parley requires a brush type, but glyph color is applied by vello at draw time, +/// so we use a zero-sized placeholder. +#[derive(Clone, Copy, PartialEq, Default, Debug)] +pub struct NoBrush; + +struct FontStore { + fcx: FontContext, + lcx: LayoutContext, + /// Registered family names of the three bundled fonts (sans, serif, mono). + bundled: Vec, + /// The default family (bundled sans-serif), used when `font_family` is empty. + default_family: String, +} + +impl FontStore { + fn new() -> Self { + let mut fcx = FontContext::new(); + let mut bundled = Vec::new(); + for bytes in [BUNDLED_SANS, BUNDLED_SERIF, BUNDLED_MONO] { + let blob = Blob::new(Arc::new(bytes) as Arc + Send + Sync>); + for (family_id, _) in fcx.collection.register_fonts(blob, None) { + if let Some(name) = fcx.collection.family_name(family_id) { + let name = name.to_string(); + if !bundled.contains(&name) { + bundled.push(name); + } + } + } + } + let default_family = bundled.first().cloned().unwrap_or_else(|| "sans-serif".to_string()); + Self { fcx, lcx: LayoutContext::new(), bundled, default_family } + } +} + +thread_local! { + static STORE: RefCell = RefCell::new(FontStore::new()); +} + +/// The default family name (bundled sans-serif), used when a text layer's +/// `font_family` is empty. +pub fn default_family() -> String { + STORE.with(|s| s.borrow().default_family.clone()) +} + +/// Whether `family` is one of the three bundled families (which therefore must +/// never be embedded into a `.beam`). +pub fn is_bundled(family: &str) -> bool { + STORE.with(|s| s.borrow().bundled.iter().any(|f| f == family)) +} + +/// True if a shorter word-prefix of `name` is itself a family in `set` — i.e. `name` +/// is a variant of a base family (e.g. "Noto Sans Arabic" when "Noto Sans" exists). +/// Used to collapse the many script/region-specific fonts (mostly Noto) into their +/// base family in the picker; parley's fallback still resolves the right script font +/// automatically when rendering non-Latin text. +fn is_variant_of_listed_base(name: &str, set: &std::collections::HashSet<&str>) -> bool { + let words: Vec<&str> = name.split(' ').collect(); + for k in 1..words.len() { + if set.contains(words[..k].join(" ").as_str()) { + return true; + } + } + false +} + +/// Available family names for the info-panel picker: the three bundled families first, +/// then a consolidated, alphabetical list of system families — script/region variants +/// whose base family is present are dropped (monospace variants are kept). +pub fn families() -> Vec { + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + + let mut system: Vec = + s.fcx.collection.family_names().map(|n| n.to_string()).collect(); + system.sort(); + system.dedup(); + let set: std::collections::HashSet<&str> = system.iter().map(|x| x.as_str()).collect(); + + let mut out = s.bundled.clone(); + for name in &system { + if out.iter().any(|b| b == name) { + continue; + } + // Keep base families and monospace variants; drop script/region variants + // whose base family is already in the list. + if name.contains("Mono") || !is_variant_of_listed_base(name, &set) { + out.push(name.clone()); + } + } + out + }) +} + +/// Register a document-embedded font (raw TTF/OTF bytes) into the runtime font +/// collection so text layers referencing its family resolve to it. Returns the +/// registered family names. +pub fn register_embedded(bytes: Vec) -> Vec { + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + let blob = Blob::new(Arc::new(bytes) as Arc + Send + Sync>); + let mut names = Vec::new(); + for (family_id, _) in s.fcx.collection.register_fonts(blob, None) { + if let Some(name) = s.fcx.collection.family_name(family_id) { + names.push(name.to_string()); + } + } + names + }) +} + +/// Whether `family` currently resolves to a registered family (bundled, system, +/// or embedded). Used to flag "missing font" on load. +pub fn family_available(family: &str) -> bool { + if family.is_empty() { + return true; + } + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + s.fcx.collection.family_id(family).is_some() + }) +} + +/// The raw bytes of the font file that `family` resolves to (for embedding into a +/// `.beam`). Returns `None` if the family resolves to no glyphs. Lays out a single +/// glyph and reads the resolved run's font blob (the same `linebender_resource_handle` +/// blob vello uses). +pub fn family_font_bytes(family: &str) -> Option> { + let content = TextContent { + text: "A".to_string(), + font_family: family.to_string(), + ..TextContent::default() + }; + with_layout(&content, 10_000.0, |layout| { + for line in layout.lines() { + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(run) = item { + return Some(run.run().font().data.data().to_vec()); + } + } + } + None + }) +} + +// ── Background font preloading ─────────────────────────────────────────────── +// +// Loading a family's bytes (`family_font_bytes`) is the expensive part (font file IO + +// shaping). Doing it for the whole picker on the UI thread causes hitches, so a +// background thread (started at app launch) loads every picker family's bytes ahead of +// time into a shared map. The UI then only has to *register* them with its renderer +// (cheap), and synchronously loads the few stragglers only if needed before they're ready. + +struct PreloadState { + loaded: HashMap>, + started: bool, + done: bool, +} + +fn preload() -> &'static Mutex { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(|| Mutex::new(PreloadState { + loaded: HashMap::new(), + started: false, + done: false, + })) +} + +/// Start (once) a background thread that loads every picker font's bytes. Idempotent; +/// safe to call every frame or from app startup. +pub fn start_preload() { + { + let mut p = preload().lock().unwrap(); + if p.started { + return; + } + p.started = true; + } + let _ = std::thread::Builder::new() + .name("font-preload".into()) + .spawn(|| { + // This thread gets its own thread-local FontContext (enumerates system fonts + // off the UI thread). Bytes are plain `Vec` (Send) handed back via the map. + for fam in families() { + if let Some(bytes) = family_font_bytes(&fam) { + preload().lock().unwrap().loaded.insert(fam, bytes); + } + } + preload().lock().unwrap().done = true; + }); +} + +/// Remove and return a preloaded font's bytes if the background thread has them ready. +pub fn take_preloaded(family: &str) -> Option> { + preload().lock().unwrap().loaded.remove(family) +} + +/// Whether the background preloader has finished loading every family. +pub fn preload_done() -> bool { + preload().lock().unwrap().done +} + +/// Caret rectangle (x0, y0, x1, y1, in layout space relative to the box origin) for +/// `byte_index` into `content.text`, wrapped to `max_width`. +pub fn caret_geometry(content: &TextContent, max_width: f32, byte_index: usize) -> Option<(f64, f64, f64, f64)> { + let caret_w = (content.font_size as f32 * 0.06).max(1.0); + with_layout(content, max_width, |layout| { + let cur = parley::Cursor::from_byte_index(layout, byte_index, parley::Affinity::Downstream); + let bb = cur.geometry(layout, caret_w); + Some((bb.x0, bb.y0, bb.x1, bb.y1)) + }) +} + +/// Selection highlight rectangles (each x0, y0, x1, y1 in layout space) for the byte +/// range `[start, end)` into `content.text`, wrapped to `max_width`. +pub fn selection_geometry(content: &TextContent, max_width: f32, start: usize, end: usize) -> Vec<(f64, f64, f64, f64)> { + if start == end { + return Vec::new(); + } + with_layout(content, max_width, |layout| { + let anchor = parley::Cursor::from_byte_index(layout, start, parley::Affinity::Downstream); + let focus = parley::Cursor::from_byte_index(layout, end, parley::Affinity::Downstream); + let sel = parley::Selection::new(anchor, focus); + sel.geometry(layout) + .into_iter() + .map(|(bb, _)| (bb.x0, bb.y0, bb.x1, bb.y1)) + .collect() + }) +} + +fn alignment_of(a: TextAlign) -> Alignment { + match a { + TextAlign::Left => Alignment::Left, + TextAlign::Center => Alignment::Center, + TextAlign::Right => Alignment::Right, + TextAlign::Justify => Alignment::Justify, + } +} + +/// Build a parley layout for `content` wrapped to `max_width` (document units), +/// then invoke `f` with it. The layout is rebuilt on each call (v1: no cache). +pub fn with_layout( + content: &TextContent, + max_width: f32, + f: impl FnOnce(&Layout) -> R, +) -> R { + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + let default_family = s.default_family.clone(); + let FontStore { fcx, lcx, .. } = s; + + let mut builder = lcx.ranged_builder(fcx, &content.text, 1.0, true); + builder.push_default(StyleProperty::FontSize(content.font_size as f32)); + + let family_name = if content.font_family.is_empty() { + default_family + } else { + content.font_family.clone() + }; + let family = FontFamily::Single(FontFamilyName::Named(Cow::Owned(family_name))); + builder.push_default(StyleProperty::FontFamily(family)); + + let mut layout = builder.build(&content.text); + layout.break_all_lines(Some(max_width)); + layout.align(alignment_of(content.align), AlignmentOptions::default()); + f(&layout) + }) +} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index ef845c8..005e999 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -9,6 +9,7 @@ use crate::vector_graph::VectorGraph; use crate::effect_layer::EffectLayer; use crate::object::ShapeInstance; use crate::raster_layer::RasterLayer; +use crate::text_layer::TextLayer; use crate::shape::Shape; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -31,6 +32,8 @@ pub enum LayerType { Group, /// Raster pixel-buffer painting layer Raster, + /// Text layer (a single editable text box) + Text, } /// Common trait for all layer types @@ -866,6 +869,7 @@ impl GroupLayer { AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], // no nested groups AnyLayer::Raster(_) => &[], // raster layers have no clip instances + AnyLayer::Text(_) => &[], // raster layers have no clip instances }; for ci in instances { result.push((child_id, ci)); @@ -884,6 +888,7 @@ pub enum AnyLayer { Effect(EffectLayer), Group(GroupLayer), Raster(RasterLayer), + Text(TextLayer), } impl LayerTrait for AnyLayer { @@ -895,6 +900,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.id(), AnyLayer::Group(l) => l.id(), AnyLayer::Raster(l) => l.id(), + AnyLayer::Text(l) => l.id(), } } @@ -906,6 +912,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.name(), AnyLayer::Group(l) => l.name(), AnyLayer::Raster(l) => l.name(), + AnyLayer::Text(l) => l.name(), } } @@ -917,6 +924,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_name(name), AnyLayer::Group(l) => l.set_name(name), AnyLayer::Raster(l) => l.set_name(name), + AnyLayer::Text(l) => l.set_name(name), } } @@ -928,6 +936,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.has_custom_name(), AnyLayer::Group(l) => l.has_custom_name(), AnyLayer::Raster(l) => l.has_custom_name(), + AnyLayer::Text(l) => l.has_custom_name(), } } @@ -939,6 +948,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_has_custom_name(custom), AnyLayer::Group(l) => l.set_has_custom_name(custom), AnyLayer::Raster(l) => l.set_has_custom_name(custom), + AnyLayer::Text(l) => l.set_has_custom_name(custom), } } @@ -950,6 +960,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.visible(), AnyLayer::Group(l) => l.visible(), AnyLayer::Raster(l) => l.visible(), + AnyLayer::Text(l) => l.visible(), } } @@ -961,6 +972,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_visible(visible), AnyLayer::Group(l) => l.set_visible(visible), AnyLayer::Raster(l) => l.set_visible(visible), + AnyLayer::Text(l) => l.set_visible(visible), } } @@ -972,6 +984,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.opacity(), AnyLayer::Group(l) => l.opacity(), AnyLayer::Raster(l) => l.opacity(), + AnyLayer::Text(l) => l.opacity(), } } @@ -983,6 +996,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_opacity(opacity), AnyLayer::Group(l) => l.set_opacity(opacity), AnyLayer::Raster(l) => l.set_opacity(opacity), + AnyLayer::Text(l) => l.set_opacity(opacity), } } @@ -994,6 +1008,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.volume(), AnyLayer::Group(l) => l.volume(), AnyLayer::Raster(l) => l.volume(), + AnyLayer::Text(l) => l.volume(), } } @@ -1005,6 +1020,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_volume(volume), AnyLayer::Group(l) => l.set_volume(volume), AnyLayer::Raster(l) => l.set_volume(volume), + AnyLayer::Text(l) => l.set_volume(volume), } } @@ -1016,6 +1032,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.muted(), AnyLayer::Group(l) => l.muted(), AnyLayer::Raster(l) => l.muted(), + AnyLayer::Text(l) => l.muted(), } } @@ -1027,6 +1044,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_muted(muted), AnyLayer::Group(l) => l.set_muted(muted), AnyLayer::Raster(l) => l.set_muted(muted), + AnyLayer::Text(l) => l.set_muted(muted), } } @@ -1038,6 +1056,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.soloed(), AnyLayer::Group(l) => l.soloed(), AnyLayer::Raster(l) => l.soloed(), + AnyLayer::Text(l) => l.soloed(), } } @@ -1049,6 +1068,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_soloed(soloed), AnyLayer::Group(l) => l.set_soloed(soloed), AnyLayer::Raster(l) => l.set_soloed(soloed), + AnyLayer::Text(l) => l.set_soloed(soloed), } } @@ -1060,6 +1080,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.locked(), AnyLayer::Group(l) => l.locked(), AnyLayer::Raster(l) => l.locked(), + AnyLayer::Text(l) => l.locked(), } } @@ -1071,6 +1092,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_locked(locked), AnyLayer::Group(l) => l.set_locked(locked), AnyLayer::Raster(l) => l.set_locked(locked), + AnyLayer::Text(l) => l.set_locked(locked), } } } @@ -1085,6 +1107,7 @@ impl AnyLayer { AnyLayer::Effect(l) => &l.layer, AnyLayer::Group(l) => &l.layer, AnyLayer::Raster(l) => &l.layer, + AnyLayer::Text(l) => &l.layer, } } @@ -1097,6 +1120,7 @@ impl AnyLayer { AnyLayer::Effect(l) => &mut l.layer, AnyLayer::Group(l) => &mut l.layer, AnyLayer::Raster(l) => &mut l.layer, + AnyLayer::Text(l) => &mut l.layer, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index db5a9d1..eadda98 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -54,6 +54,8 @@ pub mod svg_export; pub mod snap; pub mod webcam; pub mod raster_layer; +pub mod text_layer; +pub mod fonts; pub mod raster_store; pub mod brush_settings; pub mod brush_engine; diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 63ffcb3..01b098d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -698,6 +698,11 @@ pub fn render_layer_isolated( }; } } + AnyLayer::Text(text_layer) => { + // Text composites as vector geometry (glyphs in the Vello scene). + rendered.has_content = + render_text_layer_to_scene(text_layer, time, &mut rendered.scene, base_transform); + } } rendered @@ -757,6 +762,57 @@ fn render_raster_layer_to_scene( scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); } +/// Render a text layer's glyphs into a Vello scene. +/// +/// Text is laid out with parley (wrapped to the box width) and drawn via +/// `Scene::draw_glyphs`. The box origin offsets the whole layout; `base_transform` +/// carries the layer/clip-instance transform and camera. Returns whether anything +/// was drawn. +fn render_text_layer_to_scene( + layer: &crate::text_layer::TextLayer, + time: f64, + scene: &mut Scene, + base_transform: Affine, +) -> bool { + let content = layer.content_at(time); + if content.text.is_empty() { + return false; + } + let color = vello::peniko::Color::new(content.color); + let origin = Affine::translate((layer.box_origin.x, layer.box_origin.y)); + let mut drew = false; + crate::fonts::with_layout(content, layer.box_width as f32, |layout| { + for line in layout.lines() { + for item in line.items() { + let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + let run = glyph_run.run(); + let font = run.font(); + let font_size = run.font_size(); + let synthesis = run.synthesis(); + let glyph_xform = synthesis + .skew() + .map(|angle| Affine::skew((angle as f64).to_radians().tan(), 0.0)); + drew = true; + scene + .draw_glyphs(font) + .font_size(font_size) + .brush(color) + .transform(base_transform * origin) + .glyph_transform(glyph_xform) + .draw( + Fill::NonZero, + glyph_run.positioned_glyphs().map(|g| vello::Glyph { + id: g.id as u32, + x: g.x, + y: g.y, + }), + ); + } + } + }); + drew +} + // ============================================================================ // Legacy Single-Scene Rendering (kept for backwards compatibility) // ============================================================================ @@ -884,6 +940,11 @@ fn render_layer( if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; } render_raster_layer_to_scene(raster_layer, time, scene, base_transform); } + AnyLayer::Text(text_layer) => { + // Text is non-video content — force the Vello fallback if extracting. + if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; } + render_text_layer_to_scene(text_layer, time, scene, base_transform); + } } } @@ -1900,7 +1961,9 @@ fn render_vector_content_cpu( render_vector_content_cpu(document, time, child, pixmap, base_transform, parent_opacity, image_cache); } } - AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) => {} + // Text is not rendered in the tiny-skia CPU fallback (GPU path only) for v1. + AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) + | AnyLayer::Text(_) => {} } } diff --git a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs index 008bc4e..cccf91d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs @@ -232,8 +232,9 @@ use crate::vector_graph::{FillId, VectorGraph}; use kurbo::{BezPath, PathEl, Rect, Shape}; /// Serialize the document's **vector** content to a standalone SVG string, at document time `time`. -/// Vector layers (and groups of them) only — raster/video/audio/effect layers are skipped (a later -/// pass can rasterize them to ``). Animation is a single static frame at `time`. +/// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/ +/// effect layers are skipped (a later pass can rasterize them to ``). Animation is a single +/// static frame at `time`. pub fn document_to_svg(document: &Document, time: f64) -> String { let (w, h) = (document.width, document.height); let mut defs = String::new(); @@ -300,11 +301,136 @@ fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut Str body.push_str(""); } } + AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body), // Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass. _ => {} } } +/// A skrifa outline pen that appends transformed glyph contours to an SVG path `d` string. +/// +/// skrifa emits outline points in y-up pixel space (origin at the glyph baseline); this maps each +/// point into document space: `x = gx + px + skew·py`, `y = gy − py` (Y flips, `skew` applies any +/// synthetic-italic slant), where `(gx, gy)` is the glyph's document-space pen position. +struct SvgOutlinePen<'a> { + gx: f64, + gy: f64, + skew: f64, + d: &'a mut String, +} + +impl<'a> SvgOutlinePen<'a> { + fn map(&self, px: f32, py: f32) -> (f64, f64) { + let (px, py) = (px as f64, py as f64); + (self.gx + px + self.skew * py, self.gy - py) + } +} + +impl skrifa::outline::OutlinePen for SvgOutlinePen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + let (x, y) = self.map(x, y); + self.d.push_str(&format!("M{x:.2} {y:.2}")); + } + fn line_to(&mut self, x: f32, y: f32) { + let (x, y) = self.map(x, y); + self.d.push_str(&format!("L{x:.2} {y:.2}")); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + let (cx, cy) = self.map(cx, cy); + let (x, y) = self.map(x, y); + self.d.push_str(&format!("Q{cx:.2} {cy:.2} {x:.2} {y:.2}")); + } + fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) { + let (c0x, c0y) = self.map(c0x, c0y); + let (c1x, c1y) = self.map(c1x, c1y); + let (x, y) = self.map(x, y); + self.d.push_str(&format!("C{c0x:.2} {c0y:.2} {c1x:.2} {c1y:.2} {x:.2} {y:.2}")); + } + fn close(&mut self) { + self.d.push('Z'); + } +} + +/// Append a text layer's glyphs to `body` as a single filled `` of real glyph outlines +/// (lossless — no font dependency in the SVG). Lays the text out with the same parley path the +/// renderer uses, then extracts each glyph's outline with skrifa. Variable-font axis positions and +/// synthetic-italic skew are honored; synthetic bold is not (rare). +fn text_layer_to_svg( + tl: &crate::text_layer::TextLayer, + time: f64, + parent_opacity: f64, + body: &mut String, +) { + use skrifa::MetadataProvider; + + if !tl.layer.visible { + return; + } + let content = tl.content_at(time); + if content.text.is_empty() { + return; + } + + let (ox, oy) = (tl.box_origin.x, tl.box_origin.y); + let mut d = String::new(); + + crate::fonts::with_layout(content, tl.box_width as f32, |layout| { + for line in layout.lines() { + for item in line.items() { + let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + let run = glyph_run.run(); + let font = run.font(); + let font_size = run.font_size(); + let skew = run + .synthesis() + .skew() + .map(|angle| (angle as f64).to_radians().tan()) + .unwrap_or(0.0); + + let Ok(font_ref) = skrifa::FontRef::from_index(font.data.data(), font.index) else { + continue; + }; + let outlines = font_ref.outline_glyphs(); + + // Variable-font axis position for this run (empty for static fonts). + let coords: Vec = run + .normalized_coords() + .iter() + .map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)) + .collect(); + let location = skrifa::instance::LocationRef::new(&coords); + let size = skrifa::instance::Size::new(font_size); + + for g in glyph_run.positioned_glyphs() { + let Some(glyph) = outlines.get(skrifa::GlyphId::new(g.id as u32)) else { + continue; + }; + let mut pen = SvgOutlinePen { + gx: ox + g.x as f64, + gy: oy + g.y as f64, + skew, + d: &mut d, + }; + let settings = skrifa::outline::DrawSettings::unhinted(size, location); + let _ = glyph.draw(settings, &mut pen); + } + } + } + }); + + if d.is_empty() { + return; + } + + let [r, g, b, a] = content.color; + let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8; + let fill_opacity = (a as f64 * parent_opacity * tl.layer.opacity).clamp(0.0, 1.0); + body.push_str(&format!( + r#""#, + to_u8(r), to_u8(g), to_u8(b), fill_opacity, d + )); +} + /// Emit a vector graph's fills (``) and stroked edges (``) into `body`, /// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform). fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) { @@ -488,4 +614,58 @@ mod export_tests { // 1 fill path + 3 stroked edges = 4 elements. assert_eq!(body.matches(" 80, "suspiciously short path: {body}"); + } + + #[test] + fn empty_text_layer_emits_nothing() { + use crate::text_layer::TextLayer; + let tl = TextLayer::new("t", Point::new(0.0, 0.0)); // no text set + let mut body = String::new(); + text_layer_to_svg(&tl, 0.0, 1.0, &mut body); + assert!(body.is_empty(), "empty text should emit nothing: {body}"); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/text_layer.rs b/lightningbeam-ui/lightningbeam-core/src/text_layer.rs new file mode 100644 index 0000000..96387b6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/text_layer.rs @@ -0,0 +1,145 @@ +//! Text layer for Lightningbeam +//! +//! A text layer holds a single editable text field inside a resizable box, +//! with editable size, color, and font. Text is rendered as vector glyphs +//! (via parley + Vello) so it composites through the same path as vector art. +//! +//! The text/style fields are grouped in [`TextContent`] so they can move to a +//! per-keyframe model later without touching call sites; v1 stores a single +//! static instance and reads it through [`TextLayer::content_at`]. + +use crate::layer::{Layer, LayerTrait, LayerType}; +use kurbo::Point; +use serde::{Deserialize, Serialize}; +use std::hash::{Hash, Hasher}; +use uuid::Uuid; + +/// Horizontal alignment of text within the box. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TextAlign { + Left, + Center, + Right, + Justify, +} + +impl Default for TextAlign { + fn default() -> Self { + TextAlign::Left + } +} + +/// The text content + styling for a text layer. +/// +/// Grouped as its own struct so a future keyframed model can store +/// `Vec` of these without changing the layer's public shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextContent { + /// The text string. + pub text: String, + /// Font size in pixels (document space). + pub font_size: f64, + /// Fill color, linear RGBA in 0..=1. + pub color: [f32; 4], + /// Logical font family name. Empty string = the bundled default font. + pub font_family: String, + /// Horizontal alignment within the box. + pub align: TextAlign, +} + +impl Default for TextContent { + fn default() -> Self { + Self { + text: String::new(), + font_size: 48.0, + color: [1.0, 1.0, 1.0, 1.0], + font_family: String::new(), + align: TextAlign::Left, + } + } +} + +impl TextContent { + /// A stable hash of everything that affects shaped layout (everything except + /// `color`, which only affects the brush, not glyph positions). Used to key + /// the renderer's external parley-layout cache, including the wrap width. + pub fn layout_hash(&self, box_width: f64) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.text.hash(&mut h); + self.font_size.to_bits().hash(&mut h); + self.font_family.hash(&mut h); + self.align.hash(&mut h); + box_width.to_bits().hash(&mut h); + h.finish() + } +} + +impl Hash for TextAlign { + fn hash(&self, state: &mut H) { + (*self as u8).hash(state); + } +} + +/// A text layer: a single resizable text box. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TextLayer { + /// Base layer properties (id, name, opacity, visibility, animation data, …). + pub layer: Layer, + /// Top-left of the text box, in layer/clip-local space. + pub box_origin: Point, + /// Box width in document units (drives text wrapping). + pub box_width: f64, + /// Box height in document units. + pub box_height: f64, + /// The text content + styling (single static instance for v1). + pub content: TextContent, +} + +impl TextLayer { + /// Default text-box dimensions for a freshly created layer. + pub const DEFAULT_WIDTH: f64 = 300.0; + pub const DEFAULT_HEIGHT: f64 = 100.0; + + /// Create a new, empty text layer with the given name, positioned at `origin`. + pub fn new(name: impl Into, origin: Point) -> Self { + Self { + layer: Layer::new(LayerType::Text, name), + box_origin: origin, + box_width: Self::DEFAULT_WIDTH, + box_height: Self::DEFAULT_HEIGHT, + content: TextContent::default(), + } + } + + /// Read the active text content at `time`. v1 is static, so `time` is unused; + /// a future keyframed model will select the active keyframe here. + pub fn content_at(&self, _time: f64) -> &TextContent { + &self.content + } + + /// Mutable access to the active text content at `time`. + pub fn content_at_mut(&mut self, _time: f64) -> &mut TextContent { + &mut self.content + } +} + +// Delegate all LayerTrait methods to self.layer (mirrors RasterLayer). +impl LayerTrait for TextLayer { + fn id(&self) -> Uuid { self.layer.id } + fn name(&self) -> &str { &self.layer.name } + fn set_name(&mut self, name: String) { self.layer.name = name; } + fn has_custom_name(&self) -> bool { self.layer.has_custom_name } + fn set_has_custom_name(&mut self, custom: bool) { self.layer.has_custom_name = custom; } + fn visible(&self) -> bool { self.layer.visible } + fn set_visible(&mut self, visible: bool) { self.layer.visible = visible; } + fn opacity(&self) -> f64 { self.layer.opacity } + fn set_opacity(&mut self, opacity: f64) { self.layer.opacity = opacity; } + fn volume(&self) -> f64 { self.layer.volume } + fn set_volume(&mut self, volume: f64) { self.layer.volume = volume; } + fn muted(&self) -> bool { self.layer.muted } + fn set_muted(&mut self, muted: bool) { self.layer.muted = muted; } + fn soloed(&self) -> bool { self.layer.soloed } + fn set_soloed(&mut self, soloed: bool) { self.layer.soloed = soloed; } + fn locked(&self) -> bool { self.layer.locked } + fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 3ec96b7..88d9c47 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -381,7 +381,9 @@ impl Tool { use crate::layer::LayerType; match layer_type { None | Some(LayerType::Vector) => Tool::all(), - Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], + // The Text tool is available on audio/video/raster too: clicking the + // stage with it there creates a new top-level text layer. + Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split, Tool::Text], Some(LayerType::Raster) => &[ // Brush tools Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush, @@ -397,9 +399,10 @@ impl Tool { // Transform Tool::Transform, Tool::Warp, Tool::Liquify, // Utility - Tool::Eyedropper, + Tool::Eyedropper, Tool::Text, ], - _ => &[Tool::Select], + Some(LayerType::Text) => &[Tool::Select, Tool::Text, Tool::Transform], + _ => &[Tool::Select, Tool::Text], } } diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index 646fe31..16cc15f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -351,6 +351,29 @@ impl VectorGraph { } } + /// Free any vertex no longer referenced by a non-deleted edge (e.g. after deleting a shape's + /// edges), so stale vertices don't linger as snap targets. + pub fn gc_isolated_vertices(&mut self) { + let mut referenced = vec![false; self.vertices.len()]; + for e in &self.edges { + if e.deleted { + continue; + } + for v in e.vertices.iter() { + if !v.is_none() { + referenced[v.idx()] = true; + } + } + } + let to_free: Vec = (0..self.vertices.len()) + .filter(|&i| !self.vertices[i].deleted && !referenced[i]) + .map(|i| VertexId(i as u32)) + .collect(); + for vid in to_free { + self.free_vertex(vid); + } + } + // ------------------------------------------------------------------- // Fill / hit-test queries // ------------------------------------------------------------------- diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index c80d068..2ec49a3 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightningbeam-editor" -version = "1.0.7-alpha" +version = "1.0.8-alpha" edition = "2021" description = "Multimedia editor for audio, video and 2D animation" license = "GPL-3.0-or-later" @@ -41,6 +41,10 @@ serde_json = { workspace = true } # Image loading image = { workspace = true } +# Animated GIF encoding — used directly (not just via `image`) so per-frame NeuQuant palette +# quantization can be parallelized across a worker pool. Pinned to the version `image` already +# resolves (0.13), so this adds no new transitive graph. +gif = "0.13" resvg = { workspace = true } tiny-skia = "0.11" diff --git a/lightningbeam-ui/lightningbeam-editor/assets/fonts/LICENSE-lucide.txt b/lightningbeam-ui/lightningbeam-editor/assets/fonts/LICENSE-lucide.txt new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/assets/fonts/LICENSE-lucide.txt @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lightningbeam-ui/lightningbeam-editor/assets/fonts/lucide.ttf b/lightningbeam-ui/lightningbeam-editor/assets/fonts/lucide.ttf new file mode 100644 index 0000000..0a69bf1 Binary files /dev/null and b/lightningbeam-ui/lightningbeam-editor/assets/fonts/lucide.ttf differ diff --git a/lightningbeam-ui/lightningbeam-editor/assets/styles.css b/lightningbeam-ui/lightningbeam-editor/assets/styles.css index 7599b9b..f947592 100644 --- a/lightningbeam-ui/lightningbeam-editor/assets/styles.css +++ b/lightningbeam-ui/lightningbeam-editor/assets/styles.css @@ -99,6 +99,14 @@ --font-size-small: 11px; --font-size-default: 13px; --pane-border-width: 1px; + + /* Modal backdrop (translucent) + intent/category accents (shared by light & dark) */ + --scrim: #10141ab0; + --accent-coral: #e8826b; + --accent-cyan: #54c3e8; + --accent-amber: #f4a340; + --accent-pink: #c75b8a; + --accent-violet: #8a6ec0; } /* ============================================ diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index 24c976e..a724288 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -70,6 +70,14 @@ pub struct AppConfig { /// sooner; larger = smaller pyramid, wider re-decode span. Default 256. #[serde(default = "defaults::waveform_floor_samples_per_texel")] pub waveform_floor_samples_per_texel: u32, + + /// Last-used audio-export "Artist" tag, remembered so it prefills next time. + #[serde(default)] + pub last_audio_artist: String, + + /// Last-used audio-export "Album" tag, remembered so it prefills next time. + #[serde(default)] + pub last_audio_album: String, } impl Default for AppConfig { @@ -90,6 +98,8 @@ impl Default for AppConfig { keybindings: KeybindingConfig::default(), large_media_default: LargeMediaMode::default(), waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), + last_audio_artist: String::new(), + last_audio_album: String::new(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs deleted file mode 100644 index 405fb14..0000000 --- a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs +++ /dev/null @@ -1,475 +0,0 @@ -#![allow(dead_code)] -//! Audio export functionality -//! -//! Exports audio from the timeline to various formats: -//! - WAV and FLAC: Use existing DAW backend export -//! - MP3 and AAC: Use FFmpeg encoding with rendered samples - -use lightningbeam_core::export::{AudioExportSettings, AudioFormat}; -use daw_backend::audio::{ - export::{ExportFormat, ExportSettings as DawExportSettings, render_to_memory}, - midi_pool::MidiClipPool, - pool::AudioPool, - project::Project, -}; -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// Export audio to a file -/// -/// This function routes to the appropriate export method based on the format: -/// - WAV/FLAC: Use DAW backend export -/// - MP3/AAC: Use FFmpeg encoding (TODO) -pub fn export_audio>( - project: &mut Project, - pool: &AudioPool, - midi_pool: &MidiClipPool, - settings: &AudioExportSettings, - output_path: P, - cancel_flag: &Arc, -) -> Result<(), String> { - // Validate settings - settings.validate()?; - - // Check for cancellation before starting - if cancel_flag.load(Ordering::Relaxed) { - return Err("Export cancelled by user".to_string()); - } - - match settings.format { - AudioFormat::Wav | AudioFormat::Flac => { - export_audio_daw_backend(project, pool, midi_pool, settings, output_path) - } - AudioFormat::Mp3 => { - export_audio_ffmpeg_mp3(project, pool, midi_pool, settings, output_path, cancel_flag) - } - AudioFormat::Aac => { - export_audio_ffmpeg_aac(project, pool, midi_pool, settings, output_path, cancel_flag) - } - } -} - -/// Export audio using the DAW backend (WAV/FLAC) -fn export_audio_daw_backend>( - project: &mut Project, - pool: &AudioPool, - _midi_pool: &MidiClipPool, - settings: &AudioExportSettings, - output_path: P, -) -> Result<(), String> { - // Convert our export settings to DAW backend format - let daw_settings = DawExportSettings { - format: match settings.format { - AudioFormat::Wav => ExportFormat::Wav, - AudioFormat::Flac => ExportFormat::Flac, - _ => unreachable!(), // This function only handles WAV/FLAC - }, - sample_rate: settings.sample_rate, - channels: settings.channels, - bit_depth: settings.bit_depth, - mp3_bitrate: 320, // Not used for WAV/FLAC - start_time: daw_backend::Seconds(settings.start_time), - end_time: daw_backend::Seconds(settings.end_time), - tempo_map: daw_backend::TempoMap::constant(settings.bpm), - }; - - // Use the existing DAW backend export function - // No progress reporting for this direct export path - daw_backend::audio::export::export_audio( - project, - pool, - &daw_settings, - output_path, - None, - ) -} - -/// Export audio as MP3 using FFmpeg -fn export_audio_ffmpeg_mp3>( - project: &mut Project, - pool: &AudioPool, - _midi_pool: &MidiClipPool, - settings: &AudioExportSettings, - output_path: P, - cancel_flag: &Arc, -) -> Result<(), String> { - use ffmpeg_next as ffmpeg; - - // Initialize FFmpeg - ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; - - // Convert settings to DAW backend format - let daw_settings = DawExportSettings { - format: ExportFormat::Wav, // Unused, but required - sample_rate: settings.sample_rate, - channels: settings.channels, - bit_depth: 16, // Unused - mp3_bitrate: settings.bitrate_kbps, - start_time: daw_backend::Seconds(settings.start_time), - end_time: daw_backend::Seconds(settings.end_time), - tempo_map: daw_backend::TempoMap::constant(settings.bpm), - }; - - // Step 1: Render audio to memory - let pcm_samples = render_to_memory( - project, - pool, - &daw_settings, - None, // No progress events for now - )?; - - // Check for cancellation - if cancel_flag.load(Ordering::Relaxed) { - return Err("Export cancelled".to_string()); - } - - // Step 2: Set up FFmpeg encoder - let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MP3) - .ok_or("MP3 encoder (libmp3lame) not found")?; - - // Create output file - let mut output = ffmpeg::format::output(&output_path) - .map_err(|e| format!("Failed to create output file: {}", e))?; - - // Create encoder - let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec) - .encoder() - .audio() - .map_err(|e| format!("Failed to create encoder: {}", e))?; - - // Configure encoder - let channel_layout = match settings.channels { - 1 => ffmpeg::channel_layout::ChannelLayout::MONO, - 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, - _ => return Err(format!("Unsupported channel count: {}", settings.channels)), - }; - - encoder.set_rate(settings.sample_rate as i32); - encoder.set_channel_layout(channel_layout); - encoder.set_format(ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar)); - encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize); - encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32)); - - // Open encoder - let mut encoder = encoder.open_as(encoder_codec) - .map_err(|e| format!("Failed to open MP3 encoder: {}", e))?; - - // Add stream and set parameters - { - let mut stream = output.add_stream(encoder_codec) - .map_err(|e| format!("Failed to add stream: {}", e))?; - stream.set_parameters(&encoder); - } // Drop stream here to release the borrow - - // Write header - output.write_header() - .map_err(|e| format!("Failed to write header: {}", e))?; - - // Step 3: Encode frames and write to output - // Convert interleaved f32 samples to planar i16 format - let num_frames = pcm_samples.len() / settings.channels as usize; - let planar_samples = convert_to_planar_i16(&pcm_samples, settings.channels); - - // Get encoder frame size - let frame_size = encoder.frame_size(); - let samples_per_frame = if frame_size > 0 { - frame_size as usize - } else { - 1152 // Default MP3 frame size - }; - - // Encode in chunks - let mut samples_encoded = 0; - while samples_encoded < num_frames { - if cancel_flag.load(Ordering::Relaxed) { - return Err("Export cancelled".to_string()); - } - - let samples_remaining = num_frames - samples_encoded; - let chunk_size = samples_remaining.min(samples_per_frame); - - // Create audio frame - let mut frame = ffmpeg::frame::Audio::new( - ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar), - chunk_size, - channel_layout, - ); - frame.set_rate(settings.sample_rate); - - // Copy planar samples to frame - // Use plane_mut:: instead of data_mut — data_mut(ch) is buggy for planar audio: - // FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0. - // plane_mut uses self.samples() for the length, which is correct for all planes. - for ch in 0..settings.channels as usize { - let plane = frame.plane_mut::(ch); - let offset = samples_encoded; - plane.copy_from_slice(&planar_samples[ch][offset..offset + chunk_size]); - } - - // Send frame to encoder - encoder.send_frame(&frame) - .map_err(|e| format!("Failed to send frame: {}", e))?; - - // Receive and write packets - receive_and_write_packets(&mut encoder, &mut output)?; - - samples_encoded += chunk_size; - } - - // Flush encoder - encoder.send_eof() - .map_err(|e| format!("Failed to send EOF: {}", e))?; - receive_and_write_packets(&mut encoder, &mut output)?; - - // Write trailer - output.write_trailer() - .map_err(|e| format!("Failed to write trailer: {}", e))?; - - Ok(()) -} - -/// Convert interleaved f32 samples to planar i16 format -fn convert_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec> { - let num_frames = interleaved.len() / channels as usize; - let mut planar = vec![vec![0i16; num_frames]; channels as usize]; - - for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { - for (ch, &sample) in chunk.iter().enumerate() { - // Clamp and convert f32 (-1.0 to 1.0) to i16 - let clamped = sample.max(-1.0).min(1.0); - planar[ch][i] = (clamped * 32767.0) as i16; - } - } - - planar -} - -/// Convert interleaved f32 samples to planar f32 format -fn convert_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec> { - let num_frames = interleaved.len() / channels as usize; - let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; - - for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { - for (ch, &sample) in chunk.iter().enumerate() { - planar[ch][i] = sample; - } - } - - planar -} - -/// Receive encoded packets and write to output -fn receive_and_write_packets( - encoder: &mut ffmpeg_next::encoder::Audio, - output: &mut ffmpeg_next::format::context::Output, -) -> Result<(), String> { - let mut encoded = ffmpeg_next::Packet::empty(); - - while encoder.receive_packet(&mut encoded).is_ok() { - encoded.set_stream(0); - encoded.write_interleaved(output) - .map_err(|e| format!("Failed to write packet: {}", e))?; - } - - Ok(()) -} - -/// Export audio as AAC using FFmpeg -fn export_audio_ffmpeg_aac>( - project: &mut Project, - pool: &AudioPool, - _midi_pool: &MidiClipPool, - settings: &AudioExportSettings, - output_path: P, - cancel_flag: &Arc, -) -> Result<(), String> { - use ffmpeg_next as ffmpeg; - - // Initialize FFmpeg - ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; - - // Convert settings to DAW backend format - let daw_settings = DawExportSettings { - format: ExportFormat::Wav, // Unused, but required - sample_rate: settings.sample_rate, - channels: settings.channels, - bit_depth: 16, // Unused - mp3_bitrate: settings.bitrate_kbps, - start_time: daw_backend::Seconds(settings.start_time), - end_time: daw_backend::Seconds(settings.end_time), - tempo_map: daw_backend::TempoMap::constant(settings.bpm), - }; - - // Step 1: Render audio to memory - let pcm_samples = render_to_memory( - project, - pool, - &daw_settings, - None, // No progress events for now - )?; - - // Check for cancellation - if cancel_flag.load(Ordering::Relaxed) { - return Err("Export cancelled".to_string()); - } - - // Step 2: Set up FFmpeg encoder - let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::AAC) - .ok_or("AAC encoder not found")?; - - // Create output file - let mut output = ffmpeg::format::output(&output_path) - .map_err(|e| format!("Failed to create output file: {}", e))?; - - // Create encoder - let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec) - .encoder() - .audio() - .map_err(|e| format!("Failed to create encoder: {}", e))?; - - // Configure encoder - let channel_layout = match settings.channels { - 1 => ffmpeg::channel_layout::ChannelLayout::MONO, - 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, - _ => return Err(format!("Unsupported channel count: {}", settings.channels)), - }; - - encoder.set_rate(settings.sample_rate as i32); - encoder.set_channel_layout(channel_layout); - // AAC encoder supports FLTP (F32 Planar) format - encoder.set_format(ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar)); - encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize); - encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32)); - - // Open encoder - let mut encoder = encoder.open_as(encoder_codec) - .map_err(|e| format!("Failed to open AAC encoder: {}", e))?; - - // Add stream and set parameters - { - let mut stream = output.add_stream(encoder_codec) - .map_err(|e| format!("Failed to add stream: {}", e))?; - stream.set_parameters(&encoder); - } // Drop stream here to release the borrow - - // Write header - output.write_header() - .map_err(|e| format!("Failed to write header: {}", e))?; - - // Step 3: Encode frames and write to output - // Convert interleaved f32 samples to planar f32 format (no conversion needed, just rearrange) - let num_frames = pcm_samples.len() / settings.channels as usize; - let planar_samples = convert_to_planar_f32(&pcm_samples, settings.channels); - - // Get encoder frame size - let frame_size = encoder.frame_size(); - let samples_per_frame = if frame_size > 0 { - frame_size as usize - } else { - 1024 // Default AAC frame size - }; - - // Encode in chunks - let mut samples_encoded = 0; - while samples_encoded < num_frames { - if cancel_flag.load(Ordering::Relaxed) { - return Err("Export cancelled".to_string()); - } - - let samples_remaining = num_frames - samples_encoded; - let chunk_size = samples_remaining.min(samples_per_frame); - - // Create audio frame - let mut frame = ffmpeg::frame::Audio::new( - ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar), - chunk_size, - channel_layout, - ); - frame.set_rate(settings.sample_rate); - - // Copy planar samples to frame - unsafe { - for ch in 0..settings.channels as usize { - let plane = frame.data_mut(ch); - let offset = samples_encoded; - let src = &planar_samples[ch][offset..offset + chunk_size]; - - std::ptr::copy_nonoverlapping( - src.as_ptr() as *const u8, - plane.as_mut_ptr(), - chunk_size * std::mem::size_of::(), - ); - } - } - - // Send frame to encoder - encoder.send_frame(&frame) - .map_err(|e| format!("Failed to send frame: {}", e))?; - - // Receive and write packets - receive_and_write_packets(&mut encoder, &mut output)?; - - samples_encoded += chunk_size; - } - - // Flush encoder - encoder.send_eof() - .map_err(|e| format!("Failed to send EOF: {}", e))?; - receive_and_write_packets(&mut encoder, &mut output)?; - - // Write trailer - output.write_trailer() - .map_err(|e| format!("Failed to write trailer: {}", e))?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_export_audio_validation() { - let mut settings = AudioExportSettings::default(); - settings.sample_rate = 0; // Invalid - - let project = Project::new(44100); - let pool = AudioPool::new(); - let midi_pool = MidiClipPool::new(); - let cancel_flag = Arc::new(AtomicBool::new(false)); - - let result = export_audio( - &mut project.clone(), - &pool, - &midi_pool, - &settings, - "/tmp/test.wav", - &cancel_flag, - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Sample rate")); - } - - #[test] - fn test_export_audio_cancellation() { - let settings = AudioExportSettings::default(); - let mut project = Project::new(44100); - let pool = AudioPool::new(); - let midi_pool = MidiClipPool::new(); - let cancel_flag = Arc::new(AtomicBool::new(true)); // Pre-cancelled - - let result = export_audio( - &mut project, - &pool, - &midi_pool, - &settings, - "/tmp/test.wav", - &cancel_flag, - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().contains("cancelled")); - } -} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs index bfd4271..54630b4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs @@ -101,6 +101,94 @@ impl CpuYuvConverter { } } +/// CPU RGBA→YUV422P10LE converter (10-bit, 4:2:2) via swscale, for ProRes 422 export. +/// +/// ProRes (`prores_ks`) requires a 10-bit 4:2:2 input; the SDR pipeline otherwise produces 8-bit +/// 4:2:0. Source is still 8-bit RGBA (bit-depth is promoted, not conjured), which is normal for +/// SDR ProRes. BT.709 with the requested range, matching the encoder's color tags. +pub struct CpuYuv422P10Converter { + width: u32, + height: u32, + scaler: ffmpeg::software::scaling::Context, + rgba_frame: ffmpeg::frame::Video, + yuv_frame: ffmpeg::frame::Video, +} + +impl CpuYuv422P10Converter { + pub fn new(width: u32, height: u32, full_range: bool) -> Result { + let mut scaler = ffmpeg::software::scaling::Context::get( + ffmpeg::format::Pixel::RGBA, width, height, + ffmpeg::format::Pixel::YUV422P10LE, width, height, + ffmpeg::software::scaling::Flags::BILINEAR, + ) + .map_err(|e| format!("Failed to create YUV422P10 swscale context: {}", e))?; + + // BT.709, requested output range (matches setup_video_encoder's SDR tags). No safe + // ffmpeg-next wrapper for sws_setColorspaceDetails, so this is the raw call (as in + // CpuYuvConverter::new above). + unsafe { + let coeffs = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709 as i32); + let dst_range = if full_range { 1 } else { 0 }; + let one = 1 << 16; + ffmpeg::ffi::sws_setColorspaceDetails( + scaler.as_mut_ptr(), + coeffs, 1, + coeffs, dst_range, + 0, one, one, + ); + } + + let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height); + let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV422P10LE, width, height); + Ok(Self { width, height, scaler, rgba_frame, yuv_frame }) + } + + /// Convert packed RGBA (width*height*4) to tight YUV422P10LE planes (little-endian, 2 bytes per + /// sample): Y is width×height, U and V are (width/2)×height. Planes are returned tight (stride + /// padding stripped) to match what `encode_frame` expects. + pub fn convert(&mut self, rgba_data: &[u8]) -> Result<(Vec, Vec, Vec), String> { + let expected = (self.width * self.height * 4) as usize; + assert_eq!(rgba_data.len(), expected, + "RGBA data size mismatch: expected {} bytes, got {}", expected, rgba_data.len()); + + // Copy RGBA into the source frame honoring its stride (may be padded). + let row_bytes = (self.width * 4) as usize; + let src_stride = self.rgba_frame.stride(0); + { + let dst = self.rgba_frame.data_mut(0); + for row in 0..self.height as usize { + let s = row * row_bytes; + let d = row * src_stride; + dst[d..d + row_bytes].copy_from_slice(&rgba_data[s..s + row_bytes]); + } + } + + self.scaler + .run(&self.rgba_frame, &mut self.yuv_frame) + .map_err(|e| format!("YUV422P10 swscale conversion failed: {}", e))?; + + // Extract each plane tight (2 bytes/sample). Y: width samples/row × height rows. + // Chroma (4:2:2): width/2 samples/row × height rows. + let extract = |frame: &ffmpeg::frame::Video, idx: usize, samples_w: usize, rows: usize| { + let bytes_per_row = samples_w * 2; + let stride = frame.stride(idx); + let data = frame.data(idx); + let mut out = Vec::with_capacity(bytes_per_row * rows); + for row in 0..rows { + let start = row * stride; + out.extend_from_slice(&data[start..start + bytes_per_row]); + } + out + }; + let (w, h) = (self.width as usize, self.height as usize); + let y_plane = extract(&self.yuv_frame, 0, w, h); + let u_plane = extract(&self.yuv_frame, 1, w / 2, h); + let v_plane = extract(&self.yuv_frame, 2, w / 2, h); + + Ok((y_plane, u_plane, v_plane)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -131,6 +219,20 @@ mod tests { assert_eq!(v.len(), (1920 / 2) * (1080 / 2)); } + #[test] + fn test_yuv422p10_output_sizes() { + // Use a width that forces swscale linesize padding (not a multiple of 32/64) to exercise + // the stride-stripping extraction. + let (w, h) = (1000u32, 720u32); + let mut c = CpuYuv422P10Converter::new(w, h, false).unwrap(); + let rgba = vec![0u8; (w * h * 4) as usize]; + let (y, u, v) = c.convert(&rgba).unwrap(); + // 10-bit → 2 bytes/sample. Y full res; U/V half width, full height (4:2:2). + assert_eq!(y.len(), (w * h * 2) as usize); + assert_eq!(u.len(), ((w / 2) * h * 2) as usize); + assert_eq!(v.len(), ((w / 2) * h * 2) as usize); + } + #[test] #[should_panic(expected = "RGBA data size mismatch")] fn test_wrong_input_size_panics() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index aa5dddb..e924486 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -5,11 +5,42 @@ use eframe::egui; use lightningbeam_core::export::{ AudioExportSettings, AudioFormat, + GifExportSettings, ImageExportSettings, ImageFormat, VideoExportSettings, VideoCodec, VideoQuality, ColorRange, }; use std::path::PathBuf; +/// The OS username (`$USER` / `%USERNAME%`), or empty if unavailable. Used as a default Artist tag. +fn os_username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default() +} + +/// Current civil year (UTC) computed from the system clock — avoids pulling in a date crate. +fn current_year() -> i64 { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + year_from_unix_secs(secs) +} + +/// Civil year (UTC) for a Unix timestamp, via Howard Hinnant's `civil_from_days` algorithm. +fn year_from_unix_secs(secs: i64) -> i64 { + let days = secs.div_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + y + if m <= 2 { 1 } else { 0 } +} + /// Hint about document content, used to pick a smart default export type. pub struct DocumentHint { pub has_video: bool, @@ -25,6 +56,8 @@ pub enum ExportType { Audio, Image, Video, + /// Animated GIF (multi-frame, palette-quantized, no audio). + Gif, /// Vector-only SVG of the current frame (lossless; raster/video layers skipped). Svg, } @@ -36,6 +69,8 @@ pub enum ExportResult { Image(ImageExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), + /// Animated GIF export. + Gif(GifExportSettings, PathBuf), /// SVG of vector layers at the given document time. Svg(f64, PathBuf), } @@ -57,6 +92,9 @@ pub struct ExportDialog { /// Video export settings pub video_settings: VideoExportSettings, + /// Animated GIF export settings + pub gif_settings: GifExportSettings, + /// Include audio with video? pub include_audio: bool, @@ -104,6 +142,7 @@ impl Default for ExportDialog { audio_settings: AudioExportSettings::standard_mp3(), image_settings: ImageExportSettings::default(), video_settings: VideoExportSettings::default(), + gif_settings: GifExportSettings::default(), include_audio: true, output_path: None, error_message: None, @@ -120,10 +159,18 @@ impl Default for ExportDialog { impl ExportDialog { /// Open the dialog with default settings, using `hint` to pick a smart default tab. - pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) { + pub fn open( + &mut self, + timeline_duration: f64, + project_name: &str, + hint: &DocumentHint, + last_artist: &str, + last_album: &str, + ) { self.open = true; self.audio_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration; + self.gif_settings.end_time = timeline_duration; self.image_settings.time = hint.current_time; // Propagate document dimensions as defaults (None means "use doc size"). self.image_settings.width = None; @@ -143,6 +190,24 @@ impl ExportDialog { else if only_raster { ExportType::Image } else { self.export_type } // keep current as fallback }; + // Sensible tag defaults, only filled when empty so a user's edits are never clobbered: + // • Title → project name (on a project switch) + // • Year → current year + // • Artist → last-used artist, else the OS username + // • Album → last-used album + let meta = &mut self.audio_settings.metadata; + if meta.title.is_empty() && !same_project { + meta.title = project_name.to_owned(); + } + if meta.year.is_empty() { + meta.year = current_year().to_string(); + } + if meta.artist.is_empty() { + meta.artist = if !last_artist.is_empty() { last_artist.to_owned() } else { os_username() }; + } + if meta.album.is_empty() && !last_album.is_empty() { + meta.album = last_album.to_owned(); + } self.current_project = project_name.to_owned(); // Restore the last exported path if available; otherwise default to project name. @@ -160,6 +225,7 @@ impl ExportDialog { ExportType::Audio => self.audio_settings.format.extension(), ExportType::Image => self.image_settings.format.extension(), ExportType::Video => self.video_settings.codec.container_format(), + ExportType::Gif => "gif", ExportType::Svg => "svg", } } @@ -203,12 +269,13 @@ impl ExportDialog { ExportType::Audio => "Export Audio", ExportType::Image => "Export Image", ExportType::Video => "Export Video", + ExportType::Gif => "Export GIF", ExportType::Svg => "Export SVG", }; let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal")) .show(ctx, |ui| { - ui.set_width(500.0); + ui.set_width(crate::mobile::dialog_width(ctx, 500.0)); ui.heading(window_title); ui.add_space(8.0); @@ -225,6 +292,7 @@ impl ExportDialog { (ExportType::Audio, "Audio"), (ExportType::Image, "Image"), (ExportType::Video, "Video"), + (ExportType::Gif, "GIF"), (ExportType::Svg, "SVG"), ] { if ui.selectable_value(&mut self.export_type, variant, label).clicked() { @@ -242,6 +310,7 @@ impl ExportDialog { ExportType::Audio => self.render_audio_basic(ui), ExportType::Image => self.render_image_settings(ui), ExportType::Video => self.render_video_basic(ui), + ExportType::Gif => self.render_gif_basic(ui), ExportType::Svg => self.render_svg_settings(ui), } @@ -261,6 +330,7 @@ impl ExportDialog { ExportType::Audio => self.render_audio_advanced(ui), ExportType::Image => self.render_image_advanced(ui), ExportType::Video => self.render_video_advanced(ui), + ExportType::Gif => self.render_gif_advanced(ui), ExportType::Svg => {} // SVG has no advanced settings } } @@ -460,10 +530,50 @@ impl ExportDialog { ui.add_space(8.0); + // Tag metadata (ID3 / MP4 / Vorbis / RIFF-INFO depending on format). + self.render_audio_metadata(ui); + + ui.add_space(8.0); + // Time range self.render_time_range(ui); } + /// Render the audio tag-metadata fields (title/artist/album/…). Written into the file on export. + fn render_audio_metadata(&mut self, ui: &mut egui::Ui) { + let m = &mut self.audio_settings.metadata; + ui.label(egui::RichText::new("Tags").strong()); + // Placeholder styling: italic + a clearly faded color so an empty field's hint never reads + // as a real value (the theme's default weak color is too close to the text color). An + // explicit color overrides egui's weak-color fallback for hint text. + let hint_color = { + let t = ui.visuals().text_color(); + egui::Color32::from_rgba_unmultiplied(t.r(), t.g(), t.b(), 100) + }; + let year_hint = format!("e.g. {}", current_year()); + egui::Grid::new("audio_metadata_grid") + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + let row = |ui: &mut egui::Ui, label: &str, val: &mut String, hint: &str| { + ui.label(label); + ui.add( + egui::TextEdit::singleline(val) + .hint_text(egui::RichText::new(hint).italics().color(hint_color)) + .desired_width(260.0), + ); + ui.end_row(); + }; + row(ui, "Title", &mut m.title, "e.g. My Song"); + row(ui, "Artist", &mut m.artist, "e.g. Jane Doe"); + row(ui, "Album", &mut m.album, "e.g. Greatest Hits"); + row(ui, "Genre", &mut m.genre, "e.g. Electronic"); + row(ui, "Year", &mut m.year, &year_hint); + row(ui, "Track", &mut m.track, "e.g. 1 or 1/12"); + row(ui, "Comment", &mut m.comment, "Optional notes…"); + }); + } + /// Video presets: (name, codec, quality, width, height, fps) const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[ ("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0), @@ -614,12 +724,65 @@ impl ExportDialog { self.render_time_range(ui); } + /// GIF frame-rate presets (fps). GIF delays are centisecond-quantized, so these map to clean + /// per-frame delays (10/15/20/25/50 fps → 100/70/50/40/20 ms after rounding). + const GIF_FPS: &'static [f64] = &[10.0, 15.0, 20.0, 25.0, 50.0]; + + /// Render basic GIF settings (frame rate + loop). + fn render_gif_basic(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Frame rate:"); + egui::ComboBox::from_id_salt("gif_fps") + .selected_text(format!("{} fps", self.gif_settings.framerate as u32)) + .show_ui(ui, |ui| { + for &fps in Self::GIF_FPS { + ui.selectable_value(&mut self.gif_settings.framerate, fps, format!("{} fps", fps as u32)); + } + }); + }); + + ui.checkbox(&mut self.gif_settings.loop_forever, "Loop forever"); + + ui.add_space(8.0); + self.render_time_range(ui); + } + + /// Render advanced GIF settings (resolution, fit, transparency). + fn render_gif_advanced(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Size:"); + let mut w = self.gif_settings.width.unwrap_or(0); + let mut h = self.gif_settings.height.unwrap_or(0); + let changed_w = ui.add(egui::DragValue::new(&mut w).range(0..=u32::MAX).prefix("W ")).changed(); + let changed_h = ui.add(egui::DragValue::new(&mut h).range(0..=u32::MAX).prefix("H ")).changed(); + if changed_w { self.gif_settings.width = if w == 0 { None } else { Some(w) }; } + if changed_h { self.gif_settings.height = if h == 0 { None } else { Some(h) }; } + ui.weak("(0 = document size)"); + }); + + ui.horizontal(|ui| { + use lightningbeam_core::export::ExportFitMode; + ui.label("Fit:"); + egui::ComboBox::from_id_salt("gif_fit_mode") + .selected_text(self.gif_settings.fit.name()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name()); + ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name()); + ui.selectable_value(&mut self.gif_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name()); + }); + }); + + ui.checkbox(&mut self.gif_settings.transparency, "Preserve transparency (1-bit)"); + ui.label(egui::RichText::new("GIF supports only on/off transparency; semi-transparent pixels are keyed out.").weak().small()); + } + /// Render time range UI (common to both audio and video) fn render_time_range(&mut self, ui: &mut egui::Ui) { let (start_time, end_time) = match self.export_type { ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), ExportType::Image | ExportType::Svg => return, // single time field, not a range ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), + ExportType::Gif => (&mut self.gif_settings.start_time, &mut self.gif_settings.end_time), }; ui.horizontal(|ui| { @@ -693,6 +856,13 @@ impl ExportDialog { Some(ExportResult::Image(self.image_settings.clone(), output_path)) } ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)), + ExportType::Gif => { + if let Err(err) = self.gif_settings.validate() { + self.error_message = Some(err); + return None; + } + Some(ExportResult::Gif(self.gif_settings.clone(), output_path)) + } ExportType::Audio => { // Validate audio settings if let Err(err) = self.audio_settings.validate() { @@ -801,7 +971,7 @@ impl ExportProgressDialog { egui::Modal::new(egui::Id::new("export_progress_modal")) .show(ctx, |ui| { - ui.set_width(400.0); + ui.set_width(crate::mobile::dialog_width(ctx, 400.0)); ui.heading("Exporting..."); ui.add_space(8.0); @@ -861,3 +1031,30 @@ impl ExportProgressDialog { should_cancel } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn year_from_unix_secs_known_values() { + assert_eq!(year_from_unix_secs(0), 1970); // Unix epoch + assert_eq!(year_from_unix_secs(946_684_800), 2000); // 2000-01-01 + assert_eq!(year_from_unix_secs(1_735_689_600), 2025); // 2025-01-01 + assert_eq!(year_from_unix_secs(1_767_225_599), 2025); // 2025-12-31 23:59:59 + assert_eq!(year_from_unix_secs(1_767_225_600), 2026); // 2026-01-01 + + // Post-2038: these timestamps exceed i32::MAX (2_147_483_647) — and the last exceeds + // u32::MAX — so a 32-bit time_t would wrap here. i64 math handles them correctly. + assert_eq!(year_from_unix_secs(2_148_595_200), 2038); // 2038-02-01 (> i32::MAX) + assert_eq!(year_from_unix_secs(2_223_331_200), 2040); // 2040-06-15 + assert_eq!(year_from_unix_secs(4_102_444_800), 2100); // 2100-01-01 (not a leap year) + assert_eq!(year_from_unix_secs(9_214_646_400), 2262); // 2262-01-01 (> u32::MAX) + } + + #[test] + fn current_year_is_plausible() { + let y = current_year(); + assert!((2020..3000).contains(&y), "implausible year: {y}"); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/gif_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/gif_exporter.rs new file mode 100644 index 0000000..51c95b7 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/export/gif_exporter.rs @@ -0,0 +1,196 @@ +//! Animated GIF encoding. +//! +//! Palette-quantizes a stream of RGBA8 frames and writes them to a `.gif`. The expensive part — +//! per-frame NeuQuant 256-color quantization — is embarrassingly parallel (each frame gets its own +//! local palette), so it's fanned out across a worker pool. A single writer thread collects the +//! quantized frames, reorders them, and LZW-encodes them to the file in sequence. +//! +//! Pipeline (all off the UI thread): +//! ```text +//! UI render thread ──RGBA──▶ coordinator ──round-robin──▶ N quantizer workers +//! │ (idx, gif::Frame) +//! ▼ +//! writer thread ──▶ .gif +//! ``` +//! Rendering + readback happen on the UI thread (see `render_next_gif_frame`); this module owns +//! everything after a raw RGBA frame arrives. + +use lightningbeam_core::export::ExportProgress; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::Arc; + +/// Message from the UI (render) thread to the GIF encoder coordinator. +pub enum GifFrameMessage { + /// One RGBA8 frame (top-left origin, tightly packed `width*height*4` bytes). + Frame { frame_num: usize, pixels: Vec }, + /// All frames have been sent. + Done, +} + +/// gif crate quantization speed (1 = slowest/best, 30 = fastest/worst). 10 balances palette quality +/// against per-frame cost; the parallelism below is what actually recovers the wall-clock. +const QUANT_SPEED: i32 = 10; + +/// Run the GIF encoder pipeline. Receives RGBA8 frames from `frame_rx`, quantizes them in parallel, +/// and writes the ordered result to `output_path`, reporting progress. `transparency == false` +/// composites each frame onto opaque black first (GIF's 1-bit transparency would otherwise key out +/// semi-transparent pixels). +#[allow(clippy::too_many_arguments)] +pub fn run_gif_encoder( + frame_rx: Receiver, + output_path: PathBuf, + width: u32, + height: u32, + total_frames: usize, + delay_ms: u32, + loop_forever: bool, + transparency: bool, + progress_tx: Sender, + cancel_flag: Arc, +) { + let _ = progress_tx.send(ExportProgress::Started { total_frames }); + + let delay_cs = ((delay_ms / 10).max(1)) as u16; + let expected_len = (width as usize) * (height as usize) * 4; + + // One quantizer worker per spare core (leave one for the UI render thread), capped so we don't + // spawn absurdly many for short exports. + let n_workers = std::thread::available_parallelism() + .map(|n| n.get().saturating_sub(1)) + .unwrap_or(1) + .clamp(1, 8); + + // Per-worker input channels (coordinator dispatches round-robin) + one shared result channel. + let mut worker_txs: Vec)>> = Vec::with_capacity(n_workers); + let (result_tx, result_rx) = channel::<(usize, gif::Frame<'static>)>(); + let mut worker_handles = Vec::with_capacity(n_workers); + + for _ in 0..n_workers { + let (wtx, wrx) = channel::<(usize, Vec)>(); + worker_txs.push(wtx); + let result_tx = result_tx.clone(); + let cancel = Arc::clone(&cancel_flag); + worker_handles.push(std::thread::spawn(move || { + while let Ok((idx, mut pixels)) = wrx.recv() { + if cancel.load(Ordering::Relaxed) { + break; + } + // NeuQuant local-palette quantization (the expensive step). `from_rgba_speed` uses + // the RGBA buffer as scratch, so it's fine that we own `pixels` here. + let mut frame = + gif::Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, QUANT_SPEED); + frame.delay = delay_cs; + if result_tx.send((idx, frame)).is_err() { + break; // writer gone + } + } + })); + } + drop(result_tx); // only the workers hold senders now; writer's rx ends when they all finish + + // Writer thread: order frames by index and LZW-encode them sequentially. + let writer_progress = progress_tx.clone(); + let writer_cancel = Arc::clone(&cancel_flag); + let writer_output = output_path.clone(); + let writer = std::thread::spawn(move || -> Result<(), String> { + let file = std::fs::File::create(&writer_output) + .map_err(|e| format!("Failed to create GIF file: {e}"))?; + let mut buf = std::io::BufWriter::new(file); + let mut encoder = gif::Encoder::new(&mut buf, width as u16, height as u16, &[]) + .map_err(|e| format!("GIF encoder init failed: {e}"))?; + if loop_forever { + encoder + .set_repeat(gif::Repeat::Infinite) + .map_err(|e| format!("GIF set_repeat failed: {e}"))?; + } + + // Frames may arrive out of order; hold stragglers until their turn. + let mut pending: HashMap> = HashMap::new(); + let mut next = 0usize; + let mut written = 0usize; + + while let Ok((idx, frame)) = result_rx.recv() { + if writer_cancel.load(Ordering::Relaxed) { + break; + } + pending.insert(idx, frame); + while let Some(f) = pending.remove(&next) { + encoder + .write_frame(&f) + .map_err(|e| format!("GIF write_frame failed: {e}"))?; + next += 1; + written += 1; + let _ = writer_progress.send(ExportProgress::FrameRendered { + frame: written, + total: total_frames, + }); + } + } + // Encoder/BufWriter flush on drop. + Ok(()) + }); + + // Coordinator: pull RGBA frames from the UI thread and dispatch round-robin to the workers. + let mut dispatched = 0usize; + let mut fatal: Option = None; + loop { + match frame_rx.recv() { + Ok(GifFrameMessage::Frame { frame_num, mut pixels }) => { + if cancel_flag.load(Ordering::Relaxed) { + break; + } + if pixels.len() != expected_len { + fatal = Some("GIF frame size mismatch".into()); + break; + } + if !transparency { + // Premultiply onto opaque black, then force alpha opaque. + for px in pixels.chunks_exact_mut(4) { + let a = px[3] as u32; + px[0] = (px[0] as u32 * a / 255) as u8; + px[1] = (px[1] as u32 * a / 255) as u8; + px[2] = (px[2] as u32 * a / 255) as u8; + px[3] = 255; + } + } + let w = dispatched % n_workers; + if worker_txs[w].send((frame_num, pixels)).is_err() { + fatal = Some("GIF quantizer worker died".into()); + break; + } + dispatched += 1; + } + Ok(GifFrameMessage::Done) => break, + Err(_) => break, // UI thread dropped the sender + } + } + + let _ = progress_tx.send(ExportProgress::Finalizing); + + // Close worker inputs → workers finish → their result senders drop → writer's loop ends. + drop(worker_txs); + for h in worker_handles { + let _ = h.join(); + } + let writer_result = writer.join().unwrap_or_else(|_| Err("GIF writer thread panicked".into())); + + if cancel_flag.load(Ordering::Relaxed) { + std::fs::remove_file(&output_path).ok(); + // Emit Complete so the UI poll loop clears its state; the dialog was closed on cancel. + let _ = progress_tx.send(ExportProgress::Complete { output_path }); + return; + } + + match fatal.or_else(|| writer_result.err()) { + Some(message) => { + std::fs::remove_file(&output_path).ok(); + let _ = progress_tx.send(ExportProgress::Error { message }); + } + None => { + let _ = progress_tx.send(ExportProgress::Complete { output_path }); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs b/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs index c5cc8a0..9dcda91 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/gpu_yuv.rs @@ -175,7 +175,9 @@ impl GpuYuv { } /// CPU reference for the exact math/layout the shader produces — used by unit tests so -/// the packing and BT.709 coefficients stay verifiable without a GPU. +/// the packing and BT.709 coefficients stay verifiable without a GPU. Test-only, so it isn't +/// compiled into (and flagged as unused by) release builds. +#[cfg(test)] fn cpu_reference(rgba: &[u8], width: u32, height: u32, full_range: bool) -> Vec { let w = width as usize; let h = height as usize; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs index 9352bfe..0695eb1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/image_exporter.rs @@ -45,13 +45,179 @@ pub fn save_rgba_image( encoder.encode_image(&rgb_img).map_err(|e| format!("JPEG encode failed: {e}")) } ImageFormat::WebP => { - if allow_transparency { - img.save(path).map_err(|e| format!("WebP save failed: {e}")) - } else { - let flat = flatten_alpha(img); - flat.save(path).map_err(|e| format!("WebP save failed: {e}")) + // `image` 0.25's WebP encoder is lossless-only, which ignored the quality slider and + // produced needlessly large files. Encode lossy WebP via ffmpeg's libwebp instead so + // the quality control is real; alpha is preserved (as YUVA420P) when requested. + save_webp_ffmpeg(pixels, width, height, quality, allow_transparency, path) + } + } +} + +/// Encode a single frame as lossy WebP via ffmpeg's `libwebp` encoder. +/// +/// `quality` is libwebp's 0–100 quality factor. When `allow_transparency` is true the source is +/// converted to YUVA420P so libwebp keeps the alpha channel; otherwise it's flattened onto black +/// and converted to YUV420P. Uses swscale's default BT.601 conversion (matching a plain +/// `ffmpeg -i in.png out.webp`). +fn save_webp_ffmpeg( + pixels: &[u8], + width: u32, + height: u32, + quality: u8, + allow_transparency: bool, + path: &Path, +) -> Result<(), String> { + use ffmpeg_next as ffmpeg; + + ffmpeg::init().map_err(|e| format!("Failed to initialize ffmpeg: {e}"))?; + + let codec = ffmpeg::encoder::find_by_name("libwebp") + .or_else(|| ffmpeg::encoder::find(ffmpeg::codec::Id::WEBP)) + .ok_or("libwebp encoder not available in this ffmpeg build")?; + + // Flatten onto black up front when alpha isn't wanted, so the source is fully opaque. + let src_rgba: Vec = if allow_transparency { + pixels.to_vec() + } else { + let mut v = pixels.to_vec(); + for px in v.chunks_exact_mut(4) { + let a = px[3] as u32; + px[0] = (px[0] as u32 * a / 255) as u8; + px[1] = (px[1] as u32 * a / 255) as u8; + px[2] = (px[2] as u32 * a / 255) as u8; + px[3] = 255; + } + v + }; + + let dst_pix = if allow_transparency { + ffmpeg::format::Pixel::YUVA420P + } else { + ffmpeg::format::Pixel::YUV420P + }; + + // RGBA → YUV(A)420P (swscale defaults: BT.601, limited range — what libwebp expects). + let mut scaler = ffmpeg::software::scaling::Context::get( + ffmpeg::format::Pixel::RGBA, width, height, + dst_pix, width, height, + ffmpeg::software::scaling::Flags::BILINEAR, + ) + .map_err(|e| format!("Failed to create swscale context: {e}"))?; + + let mut src = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height); + // Copy row-by-row honoring the frame's stride (may exceed width*4 due to alignment padding). + let stride = src.stride(0); + let row_bytes = (width * 4) as usize; + { + let dst = src.data_mut(0); + for y in 0..height as usize { + let s = y * row_bytes; + let d = y * stride; + dst[d..d + row_bytes].copy_from_slice(&src_rgba[s..s + row_bytes]); + } + } + + let mut yuv = ffmpeg::frame::Video::new(dst_pix, width, height); + scaler.run(&src, &mut yuv).map_err(|e| format!("swscale conversion failed: {e}"))?; + yuv.set_pts(Some(0)); + + let mut octx = ffmpeg::format::output(&path) + .map_err(|e| format!("Failed to create WebP output: {e}"))?; + + let mut enc = ffmpeg::codec::Context::new_with_codec(codec) + .encoder() + .video() + .map_err(|e| format!("Failed to create WebP encoder: {e}"))?; + enc.set_width(width); + enc.set_height(height); + enc.set_format(dst_pix); + enc.set_time_base(ffmpeg::Rational(1, 1)); + + // libwebp private options: quality 0–100, lossy. + let mut opts = ffmpeg::Dictionary::new(); + opts.set("quality", &quality.to_string()); + opts.set("lossless", "0"); + let mut enc = enc + .open_with(opts) + .map_err(|e| format!("Failed to open libwebp encoder: {e}"))?; + + { + let mut stream = octx.add_stream(codec) + .map_err(|e| format!("Failed to add WebP stream: {e}"))?; + stream.set_parameters(&enc); + stream.set_time_base(ffmpeg::Rational(1, 1)); + } + + octx.write_header().map_err(|e| format!("Failed to write WebP header: {e}"))?; + enc.send_frame(&yuv).map_err(|e| format!("Failed to send WebP frame: {e}"))?; + enc.send_eof().map_err(|e| format!("Failed to flush WebP encoder: {e}"))?; + + let mut packet = ffmpeg::Packet::empty(); + while enc.receive_packet(&mut packet).is_ok() { + packet.set_stream(0); + packet + .write_interleaved(&mut octx) + .map_err(|e| format!("Failed to write WebP packet: {e}"))?; + } + + octx.write_trailer().map_err(|e| format!("Failed to finalize WebP: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightningbeam_core::export::ImageFormat; + + /// A gradient RGBA image so the encoder has real content to quantize/compress. + fn gradient(width: u32, height: u32) -> Vec { + let mut px = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + for x in 0..width { + px.push((x * 255 / width.max(1)) as u8); + px.push((y * 255 / height.max(1)) as u8); + px.push(128); + px.push(255); } } + px + } + + /// The ffmpeg libwebp path must produce a valid *lossy* WebP (RIFF/WEBP container with a + /// `VP8 ` chunk — lossless would be `VP8L`), and the quality knob must actually change size. + #[test] + fn webp_export_is_real_lossy() { + let (w, h) = (96u32, 64u32); + let px = gradient(w, h); + let dir = std::env::temp_dir(); + let lo = dir.join("lb_webp_q10_test.webp"); + let hi = dir.join("lb_webp_q95_test.webp"); + + save_webp_ffmpeg(&px, w, h, 10, false, &lo).expect("low-quality webp encode"); + save_webp_ffmpeg(&px, w, h, 95, false, &hi).expect("high-quality webp encode"); + + let lo_bytes = std::fs::read(&lo).unwrap(); + let hi_bytes = std::fs::read(&hi).unwrap(); + + // RIFF....WEBP container. + assert_eq!(&lo_bytes[0..4], b"RIFF", "not a RIFF container"); + assert_eq!(&lo_bytes[8..12], b"WEBP", "not a WEBP file"); + // Lossy VP8 chunk (`VP8 ` with trailing space), NOT lossless `VP8L`. + assert_eq!(&lo_bytes[12..16], b"VP8 ", "expected lossy VP8, got {:?}", &lo_bytes[12..16]); + // The quality knob is honored: q10 is meaningfully smaller than q95. + assert!(lo_bytes.len() < hi_bytes.len(), + "quality ignored: q10 {} bytes >= q95 {} bytes", lo_bytes.len(), hi_bytes.len()); + + std::fs::remove_file(&lo).ok(); + std::fs::remove_file(&hi).ok(); + } + + /// The format enum still advertises a quality control for WebP (now that it works). + #[test] + fn webp_has_quality() { + assert!(ImageFormat::WebP.has_quality()); + assert!(ImageFormat::Jpeg.has_quality()); + assert!(!ImageFormat::Png.has_quality()); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index d5b2851..135ba06 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -3,8 +3,8 @@ //! This module provides the export orchestrator and progress tracking //! for exporting audio and video from the timeline. -pub mod audio_exporter; pub mod dialog; +pub mod gif_exporter; pub mod image_exporter; pub mod video_exporter; pub mod readback_pipeline; @@ -13,7 +13,8 @@ pub mod cpu_yuv_converter; pub mod gpu_yuv; pub mod hdr_frame; -use lightningbeam_core::export::{AudioExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; +use lightningbeam_core::export::{AudioExportSettings, GifExportSettings, ImageExportSettings, VideoExportSettings, ExportProgress}; +use gif_exporter::GifFrameMessage; use lightningbeam_core::document::Document; use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::video::VideoManager; @@ -68,6 +69,11 @@ pub struct VideoExportState { readback_pipeline: Option, /// CPU YUV converter for RGBA→YUV420p conversion cpu_yuv_converter: Option, + /// ProRes 422 export: forces the CPU (RGBA) readback path and converts to 10-bit 4:2:2 instead + /// of 8-bit 4:2:0. `true` only for `VideoCodec::ProRes422` (SDR). + prores: bool, + /// CPU RGBA→YUV422P10LE converter, used only on the ProRes path. + cpu_yuv422p10: Option, /// Frames that have been submitted to GPU but not yet encoded frames_in_flight: usize, /// Next frame number to send to encoder (for ordering) @@ -131,6 +137,38 @@ pub struct ExportOrchestrator { /// Single-frame image export state image_state: Option, + + /// Animated GIF export state (frames rendered on the UI thread, encoded on `thread_handle`). + gif_state: Option, +} + +/// State for an in-progress animated GIF export. Frames are rendered + read back on the UI thread +/// (one per `render_next_gif_frame` call) and streamed to the encoder thread over `frame_tx`. +struct GifExportState { + /// Resolved pixel dimensions (after applying any width/height overrides). + width: u32, + height: u32, + /// Total frames to render. + total_frames: usize, + /// Next frame index to render (0-based). + next_frame: usize, + /// Document time (seconds) of frame 0. + start_time: f64, + /// Seconds between frames (1 / framerate). + frame_step: f64, + /// How the document is fit into the export frame. + fit: lightningbeam_core::export::ExportFitMode, + /// Preserve alpha as GIF transparency (else the encoder flattens onto black). + transparency: bool, + /// GPU resources allocated on the first render call, reused each frame. + gpu_resources: Option, + /// Output RGBA texture (kept separate from gpu_resources to avoid split-borrow issues). + output_texture: Option, + output_texture_view: Option, + /// Staging buffer for synchronous GPU→CPU readback (reused each frame). + staging_buffer: Option, + /// Sender to the encoder thread; dropped after the final frame to signal completion. + frame_tx: Option>, } /// State for parallel audio+video export @@ -168,6 +206,7 @@ impl ExportOrchestrator { video_state: None, parallel_export: None, image_state: None, + gif_state: None, } } @@ -250,7 +289,7 @@ impl ExportOrchestrator { /// unconsumed terminal message). Used to gate the UI poll loop so it doesn't run every /// repaint forever after an export finishes. pub fn has_pending_progress(&self) -> bool { - self.parallel_export.is_some() || self.image_state.is_some() || self.progress_rx.is_some() + self.parallel_export.is_some() || self.image_state.is_some() || self.gif_state.is_some() || self.progress_rx.is_some() } /// Poll progress for parallel video+audio export @@ -534,6 +573,9 @@ impl ExportOrchestrator { } self.video_state = None; self.image_state = None; + // Dropping gif_state drops its frame sender, unblocking the encoder thread's recv() so it + // observes the cancel flag, removes the partial file, and exits. + self.gif_state = None; self.progress_rx = None; self.thread_handle = None; } @@ -542,6 +584,7 @@ impl ExportOrchestrator { pub fn is_exporting(&self) -> bool { if self.parallel_export.is_some() { return true; } if self.image_state.is_some() { return true; } + if self.gif_state.is_some() { return true; } if let Some(handle) = &self.thread_handle { !handle.is_finished() } else { @@ -719,6 +762,196 @@ impl ExportOrchestrator { result.map(|_| true) } + /// Enqueue an animated GIF export. Spawns the encoder thread now; call `render_next_gif_frame()` + /// from the egui update loop (where the wgpu device/queue are available) to render + stream each + /// frame to it. + pub fn start_gif_export( + &mut self, + settings: GifExportSettings, + output_path: PathBuf, + doc_width: u32, + doc_height: u32, + ) { + self.cancel_flag.store(false, Ordering::Relaxed); + + let width = settings.width.unwrap_or(doc_width).max(1); + let height = settings.height.unwrap_or(doc_height).max(1); + let total_frames = settings.total_frames(); + let delay_ms = settings.frame_delay_ms(); + let frame_step = 1.0 / settings.framerate; + + let (progress_tx, progress_rx) = channel(); + let (frame_tx, frame_rx) = channel(); + self.progress_rx = Some(progress_rx); + + let cancel_flag = Arc::clone(&self.cancel_flag); + let loop_forever = settings.loop_forever; + let transparency = settings.transparency; + let handle = std::thread::spawn(move || { + gif_exporter::run_gif_encoder( + frame_rx, output_path, width, height, total_frames, delay_ms, + loop_forever, transparency, progress_tx, cancel_flag, + ); + }); + self.thread_handle = Some(handle); + + self.gif_state = Some(GifExportState { + width, + height, + total_frames, + next_frame: 0, + start_time: settings.start_time, + frame_step, + fit: settings.fit, + transparency, + gpu_resources: None, + output_texture: None, + output_texture_view: None, + staging_buffer: None, + frame_tx: Some(frame_tx), + }); + } + + /// Drive the animated GIF export: render + read back one frame per call and stream it to the + /// encoder thread. Returns `Ok(true)` while more frames remain (call again next egui frame), + /// `Ok(false)` once every frame has been sent (encoding then finishes on the background thread). + pub fn render_next_gif_frame( + &mut self, + document: &mut Document, + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut vello::Renderer, + image_cache: &mut ImageCache, + video_manager: &Arc>, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, + ) -> Result { + if self.cancel_flag.load(Ordering::Relaxed) { + // Dropping frame_tx unblocks the encoder thread's recv() so it can clean up. + self.gif_state = None; + return Ok(false); + } + + let state = match self.gif_state.as_mut() { + Some(s) => s, + None => return Ok(false), + }; + + // All frames sent → drop the sender (signals the encoder to finalize) and finish. + if state.next_frame >= state.total_frames { + if let Some(tx) = state.frame_tx.take() { + let _ = tx.send(GifFrameMessage::Done); + drop(tx); + } + self.gif_state = None; + return Ok(false); + } + + let w = state.width; + let h = state.height; + let fit = state.fit; + let timestamp = state.start_time + state.next_frame as f64 * state.frame_step; + + if state.gpu_resources.is_none() { + state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, w, h)); + } + if state.output_texture.is_none() { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("gif_export_output"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + state.output_texture_view = Some(tex.create_view(&wgpu::TextureViewDescriptor::default())); + state.output_texture = Some(tex); + } + + // Render the frame (transparency preserved through readback; the encoder flattens if needed). + { + let gpu = state.gpu_resources.as_mut().unwrap(); + let output_view = state.output_texture_view.as_ref().unwrap(); + let encoder = video_exporter::render_frame_to_gpu_rgba( + document, + timestamp, + w, h, + device, queue, renderer, image_cache, video_manager, + gpu, + output_view, + None, // no floating raster selection during export + state.transparency, + raster_store, + true, // GIF export composites on the shared device + fit, + )?; + queue.submit(Some(encoder.finish())); + } + + // Synchronous readback (wgpu requires bytes_per_row aligned to 256). + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes_per_row = (w * 4 + align - 1) / align * align; + if state.staging_buffer.is_none() { + state.staging_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gif_export_staging"), + size: (bytes_per_row * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + })); + } + let staging = state.staging_buffer.as_ref().unwrap(); + + let mut copy_enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gif_export_copy"), + }); + let output_tex = state.output_texture.as_ref().unwrap(); + copy_enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: output_tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + queue.submit(Some(copy_enc.finish())); + + let slice = staging.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + + let pixels: Vec = { + let mapped = slice.get_mapped_range(); + let mut out = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h { + let start = (row * bytes_per_row) as usize; + out.extend_from_slice(&mapped[start..start + (w * 4) as usize]); + } + out + }; + staging.unmap(); + + let frame_num = state.next_frame; + if let Some(tx) = state.frame_tx.as_ref() { + // If the encoder thread died, stop — its dropped receiver returns an error here. + if tx.send(GifFrameMessage::Frame { frame_num, pixels }).is_err() { + self.gif_state = None; + return Ok(false); + } + } + state.next_frame += 1; + Ok(true) + } + /// Wait for the export to complete /// /// This blocks until the export thread finishes. @@ -774,6 +1007,12 @@ impl ExportOrchestrator { start_time: daw_backend::Seconds(settings.start_time), end_time: daw_backend::Seconds(settings.end_time), tempo_map: daw_backend::TempoMap::constant(settings.bpm), + metadata: settings + .metadata + .pairs() + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), }; // Use DAW backend export for all formats @@ -872,6 +1111,8 @@ impl ExportOrchestrator { let hdr = settings.hdr; let fit = settings.fit; let full_range = settings.color_range.is_full(); + let prores = matches!(settings.codec, lightningbeam_core::export::VideoCodec::ProRes422) + && !hdr.is_hdr(); let handle = std::thread::spawn(move || { Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); }); @@ -891,6 +1132,8 @@ impl ExportOrchestrator { gpu_resources: None, readback_pipeline: None, cpu_yuv_converter: None, + prores, + cpu_yuv422p10: None, frames_in_flight: 0, next_frame_to_encode: 0, perf_metrics: Some(perf_metrics::ExportMetrics::new()), @@ -1121,7 +1364,10 @@ impl ExportOrchestrator { .unwrap() .as_secs(); - let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.mp4", timestamp)); + // Use the codec's real container for the temp video, not a hardcoded .mp4 — VP8 isn't a + // valid MP4 codec, so an .mp4 temp made `write_header` fail for any VP8+audio export. + let temp_video_path = temp_dir.join(format!("lightningbeam_video_{}.{}", + timestamp, video_settings.codec.container_format())); let temp_audio_path = temp_dir.join(format!("lightningbeam_audio_{}.{}", timestamp, match audio_settings.format { @@ -1331,24 +1577,34 @@ impl ExportOrchestrator { // Enable GPU YUV only when the encoder's YUV420P planes are tight (no linesize // padding) — then the packed GPU planes copy in without row misalignment. // Otherwise fall back to RGBA readback + CPU swscale. - let gpu_yuv_tight = std::env::var("LB_DISABLE_GPU_YUV").is_err() && { + // ProRes needs 10-bit 4:2:2 (built on the CPU from the RGBA readback), so it forces the + // RGBA path — the GPU YUV converter only produces 8-bit 4:2:0. + let gpu_yuv_tight = !state.prores && std::env::var("LB_DISABLE_GPU_YUV").is_err() && { let probe = ffmpeg_next::frame::Video::new( ffmpeg_next::format::Pixel::YUV420P, width, height, ); probe.stride(0) == width as usize && probe.stride(1) == (width / 2) as usize }; - if !gpu_yuv_tight { + if !gpu_yuv_tight && !state.prores { println!("🎬 [VIDEO EXPORT] YUV planes are padded at {width}x{height}; using CPU YUV path"); } state.readback_pipeline = Some(readback_pipeline::ReadbackPipeline::new(device, queue, width, height, gpu_yuv_tight, state.full_range)); - state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?); + if state.prores { + state.cpu_yuv422p10 = Some(cpu_yuv_converter::CpuYuv422P10Converter::new(width, height, state.full_range)?); + println!("🎬 [VIDEO EXPORT] ProRes 422: 10-bit 4:2:2 (YUV422P10LE) CPU converter initialized"); + } else { + state.cpu_yuv_converter = Some(cpu_yuv_converter::CpuYuvConverter::new(width, height, state.full_range)?); + } println!("🚀 [ASYNC PIPELINE] Triple-buffered pipeline initialized"); println!("🚀 [CPU YUV] swscale converter initialized"); } let pipeline = state.readback_pipeline.as_mut().unwrap(); let gpu_resources = state.gpu_resources.as_mut().unwrap(); - let cpu_converter = state.cpu_yuv_converter.as_mut().unwrap(); + // Exactly one of these is present: cpu_yuv422p10 on the ProRes path, cpu_converter on the + // SDR fallback path (or neither is used when the GPU YUV converter is active). + let mut cpu_converter = state.cpu_yuv_converter.as_mut(); + let mut cpu_yuv422p10 = state.cpu_yuv422p10.as_mut(); let mut metrics = state.perf_metrics.as_mut(); // Poll for completed async readbacks (non-blocking) @@ -1375,12 +1631,17 @@ impl ExportOrchestrator { let data = pipeline.extract_rgba_data(result.buffer_id); let extraction_end = Instant::now(); - // YUV planes: GPU-converted (just slice) or CPU swscale fallback (timed). + // YUV planes: ProRes 10-bit 4:2:2, else GPU-converted (just slice), else CPU + // swscale 8-bit 4:2:0 fallback (timed). let conversion_start = Instant::now(); - let (y, u, v) = if pipeline.is_yuv_mode() { + let (y, u, v) = if let Some(conv) = cpu_yuv422p10.as_deref_mut() { + conv.convert(&data)? + } else if pipeline.is_yuv_mode() { pipeline.split_yuv(&data) } else { - cpu_converter.convert(&data)? + cpu_converter.as_deref_mut() + .ok_or("SDR export missing its CPU YUV converter")? + .convert(&data)? }; let conversion_end = Instant::now(); @@ -1469,6 +1730,7 @@ impl ExportOrchestrator { state.gpu_resources = None; state.readback_pipeline = None; state.cpu_yuv_converter = None; + state.cpu_yuv422p10 = None; state.perf_metrics = None; return Ok(false); } @@ -1718,6 +1980,8 @@ impl ExportOrchestrator { // Pixel format the encoder frames are built in (matches setup_video_encoder). let pixel_format = if settings.hdr.is_hdr() { ffmpeg_next::format::Pixel::YUV420P10LE + } else if matches!(settings.codec, VideoCodec::ProRes422) { + ffmpeg_next::format::Pixel::YUV422P10LE // ProRes 422: 10-bit 4:2:2 } else { ffmpeg_next::format::Pixel::YUV420P }; @@ -1832,8 +2096,17 @@ impl ExportOrchestrator { // Copy each plane row-by-row honoring the frame's stride (10-bit / arbitrary widths can have // row padding that a flat copy would misalign). `bytes_per_row` = samples × sample size. - let ten_bit = matches!(pixel_format, ffmpeg_next::format::Pixel::YUV420P10LE); - let sample_bytes = if ten_bit { 2usize } else { 1usize }; + // Sample size + chroma subsampling depend on the pixel format: + // YUV420P → 8-bit, 4:2:0 (chroma = w/2 × h/2) + // YUV420P10LE → 10-bit, 4:2:0 (chroma = w/2 × h/2) + // YUV422P10LE → 10-bit, 4:2:2 (chroma = w/2 × h, full-height) [ProRes] + use ffmpeg_next::format::Pixel; + let (sample_bytes, chroma_h_div) = match pixel_format { + Pixel::YUV420P => (1usize, 2usize), + Pixel::YUV420P10LE => (2usize, 2usize), + Pixel::YUV422P10LE => (2usize, 1usize), + _ => (1usize, 2usize), + }; let copy_plane = |frame: &mut ffmpeg_next::frame::Video, idx: usize, src: &[u8], w: usize, h: usize| { let bytes_per_row = w * sample_bytes; let stride = frame.stride(idx); @@ -1848,8 +2121,8 @@ impl ExportOrchestrator { }; let (w, h) = (width as usize, height as usize); copy_plane(&mut video_frame, 0, y_plane, w, h); - copy_plane(&mut video_frame, 1, u_plane, w / 2, h / 2); - copy_plane(&mut video_frame, 2, v_plane, w / 2, h / 2); + copy_plane(&mut video_frame, 1, u_plane, w / 2, h / chroma_h_div); + copy_plane(&mut video_frame, 2, v_plane, w / 2, h / chroma_h_div); // Set PTS (presentation timestamp) in encoder's time base // Encoder time base is 1/(framerate * 1000), so PTS = timestamp * (framerate * 1000) diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index b27694f..8a99d2a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -616,9 +616,12 @@ pub fn setup_video_encoder( // Configure encoder parameters BEFORE opening (critical!) encoder.set_width(aligned_width); encoder.set_height(aligned_height); - // HDR encodes 10-bit BT.2020 (limited range); SDR keeps 8-bit full-range BT.709. + // ProRes needs 10-bit 4:2:2; HDR needs 10-bit 4:2:0 BT.2020; other SDR is 8-bit 4:2:0. + let is_prores = codec_id == ffmpeg::codec::Id::PRORES; if hdr.is_hdr() { encoder.set_format(ffmpeg::format::Pixel::YUV420P10LE); + } else if is_prores { + encoder.set_format(ffmpeg::format::Pixel::YUV422P10LE); } else { encoder.set_format(ffmpeg::format::Pixel::YUV420P); } @@ -650,6 +653,10 @@ pub fn setup_video_encoder( }); color_opts.set("color_primaries", "bt709"); color_opts.set("color_trc", "bt709"); + if is_prores { + // prores_ks profile: 3 = HQ (4:2:2 10-bit). Matches the YUV422P10LE frames we feed. + color_opts.set("profile", "3"); + } } println!("📐 Video dimensions: {}×{} (aligned to {}×{}){}", @@ -1433,6 +1440,30 @@ mod tests { assert!(v[0] > 128, "V value: {}", v[0]); } + /// ProRes must actually open with the 10-bit 4:2:2 format we now feed it. Before the fix the + /// SDR path handed prores_ks 8-bit YUV420P and `open` failed every time — so this opening + /// successfully is the regression guard for "ProRes export always errored". + #[test] + fn prores_encoder_opens_with_yuv422p10() { + ffmpeg::init().unwrap(); + // Skip cleanly if this ffmpeg build lacks a ProRes encoder (rather than false-fail). + if ffmpeg::encoder::find(ffmpeg::codec::Id::PRORES).is_none() + && ffmpeg::encoder::find_by_name("prores_ks").is_none() + { + eprintln!("prores encoder not present in this ffmpeg build; skipping"); + return; + } + let r = setup_video_encoder( + ffmpeg::codec::Id::PRORES, + 640, 480, 30.0, 20_000, + lightningbeam_core::export::HdrExportMode::Sdr, + false, + ); + assert!(r.is_ok(), "ProRes encoder failed to open: {:?}", r.err()); + let (encoder, _codec) = r.unwrap(); + assert_eq!(encoder.format(), ffmpeg::format::Pixel::YUV422P10LE); + } + // NOTE: `rgba_to_yuv420p` rounds dimensions up to multiples of 16 (H.264 // macroblock alignment), so its plane lengths are the aligned sizes, not the // tight input dimensions. The former `test_rgba_to_yuv420p_dimensions` and diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 435f7cf..6a9caaa 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use clap::Parser; use uuid::Uuid; +mod mobile; mod panes; use panes::{PaneInstance, PaneRenderer}; @@ -197,8 +198,20 @@ fn main() -> eframe::Result { } }; + // When developing the mobile UI on desktop (LB_MOBILE_UI), open a phone-aspect window so + // the shell can be exercised at a realistic size. Gating the shell itself is done at + // render time on the flag, not on window aspect. + let initial_size = if mobile::is_mobile_landscape_env() { + [874.0, 402.0] // iPhone-ish landscape (LB_MOBILE_UI=2) + } else if mobile::is_mobile_env() { + [402.0, 874.0] // iPhone-ish portrait + } else { + [1920.0, 1080.0] + }; + let mut viewport_builder = egui::ViewportBuilder::default() - .with_inner_size([1920.0, 1080.0]) + .with_inner_size(initial_size) + .with_min_inner_size([360.0, 300.0]) // keep layouts (esp. the mobile shell) above degenerate sizes .with_title("Lightningbeam Editor") .with_app_id("lightningbeam-editor"); // Set app_id for Wayland @@ -668,6 +681,147 @@ enum FileOperation { }, } +/// How often (seconds) the background autosave runs when the document is dirty. +const AUTOSAVE_INTERVAL_SECS: f64 = 45.0; + +/// Background crash-recovery autosave state (see the `autosave` field on `EditorApp`). +struct AutosaveState { + /// Per-session recovery container path (in the app data dir). `None` disables autosave + /// (e.g. the data dir couldn't be resolved/created). + recovery_path: Option, + /// `ActionExecutor::epoch()` captured at the last dispatched autosave (or manual save). The + /// document is "dirty" when the current epoch differs, or `pending_event` is set. + baseline_epoch: u64, + /// Set by non-action changes that still need capturing (import, finished recording). + pending_event: bool, + /// True while a recovery write is in flight on the worker (don't dispatch another). + in_flight: bool, + /// Wall-clock of the last dispatched autosave, for interval throttling. + last_time: Option, + /// Progress channel for the in-flight recovery write. + progress_rx: Option>, + /// Recovery files left over from previous sessions that didn't exit cleanly (newest first), + /// discovered at startup. Presented to the user as a "recover unsaved work?" prompt. + leftover_recoveries: Vec, + /// Seconds between autosaves while dirty. Defaults to `AUTOSAVE_INTERVAL_SECS`; override with + /// `LB_AUTOSAVE_SECS` (e.g. `LB_AUTOSAVE_SECS=5`) to make manual testing practical. + interval_secs: f64, +} + +impl AutosaveState { + fn new() -> Self { + Self::gc_old_recovered(); + let recovery_path = Self::make_session_path(); + eprintln!("💾 [AUTOSAVE] recovery file for this session: {:?}", recovery_path); + let leftover_recoveries = Self::find_leftovers(recovery_path.as_deref()); + if !leftover_recoveries.is_empty() { + eprintln!("💾 [AUTOSAVE] found {} leftover recovery file(s) from a previous session", + leftover_recoveries.len()); + } + Self { + recovery_path, + baseline_epoch: 0, + pending_event: false, + in_flight: false, + last_time: None, + progress_rx: None, + leftover_recoveries, + interval_secs: std::env::var("LB_AUTOSAVE_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s > 0.0) + .unwrap_or(AUTOSAVE_INTERVAL_SECS), + } + } + + /// Session recovery `.beam` files present at startup (excluding this session's own path). + /// Their existence means a prior session didn't reach `on_exit` — i.e. it crashed or was + /// killed — so they hold unsaved work. Sorted newest-first by modification time. + fn find_leftovers(exclude: Option<&std::path::Path>) -> Vec { + let Some(dir) = Self::recovery_dir() else { return Vec::new() }; + let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.extension().and_then(|x| x.to_str()) == Some("beam") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("session-")) + && Some(p.as_path()) != exclude + }) + .filter_map(|p| { + let mtime = std::fs::metadata(&p).and_then(|m| m.modified()).ok()?; + Some((p, mtime)) + }) + .collect(); + files.sort_by(|a, b| b.1.cmp(&a.1)); // newest first + files.into_iter().map(|(p, _)| p).collect() + } + + /// Delete `recovered-*` files (already-recovered work the user relocated via Save As) older than + /// a week, so the recovery dir doesn't grow without bound. Best-effort. + fn gc_old_recovered() { + let Some(dir) = Self::recovery_dir() else { return }; + let Some(cutoff) = + std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(7 * 24 * 3600)) + else { + return; + }; + for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let p = entry.path(); + let is_recovered = p.extension().and_then(|x| x.to_str()) == Some("beam") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("recovered-")); + if !is_recovered { + continue; + } + if let Ok(mtime) = std::fs::metadata(&p).and_then(|m| m.modified()) { + if mtime < cutoff { + let _ = std::fs::remove_file(&p); + } + } + } + } + + /// The recovery directory (`/recovery`), created if needed. + fn recovery_dir() -> Option { + let proj = directories::ProjectDirs::from("", "", "lightningbeam")?; + let dir = proj.data_dir().join("recovery"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) + } + + /// A fresh per-session recovery file path (`recovery/session-.beam`). + fn make_session_path() -> Option { + Some(Self::recovery_dir()?.join(format!("session-{}.beam", uuid::Uuid::new_v4()))) + } + + /// Whether `path` is one of our recovery files (`session-*` / `recovered-*` in the recovery + /// dir). Saving one of these should behave as Save As so the work gets a real home instead of + /// being written back into the app data dir. + fn is_recovery_path(path: &std::path::Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("session-") || n.starts_with("recovered-")) + } +} + +/// Something to do after the unsaved-changes prompt resolves: the action deferred behind "Save +/// changes?" (Save runs it after saving; Don't Save runs it immediately; Cancel drops it). Covers +/// both file switches and quitting so they share one prompt + one save-then-continue path. +#[derive(Debug, Clone)] +enum PendingAction { + /// New File (return to the start screen). + NewFile, + /// Open a specific `.beam` (covers both Open… and Open Recent — the path is already resolved). + Open(std::path::PathBuf), + /// Quit (close the window). + Quit, +} + /// Information about an imported asset (for auto-placement) #[derive(Debug, Clone)] #[allow(dead_code)] // name/duration populated for future import UX features @@ -932,6 +1086,21 @@ impl EditingContext { self.stack.pop() } + /// The clip_id path from outermost to the current level (for breadcrumbs). + fn clip_path(&self) -> Vec { + self.stack.iter().map(|e| e.clip_id).collect() + } + + /// Pop down to `depth` entries, returning the entry at `depth` (whose saved state should be + /// restored). Returns None if already at or above that depth. + fn exit_to_depth(&mut self, depth: usize) -> Option { + if depth >= self.stack.len() { + return None; + } + let restore = self.stack[depth].clone(); + self.stack.truncate(depth); + Some(restore) + } } struct EditorApp { @@ -942,6 +1111,23 @@ struct EditorApp { hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal) selected_pane: Option, // Currently selected pane for editing split_preview_mode: SplitPreviewMode, + // === Mobile / phone UI (developed on desktop behind LB_MOBILE_UI) === + /// True if the mobile shell is requested via the env var (read once at startup). + mobile_ui: bool, + /// Runtime override of the mobile flag (None = follow `mobile_ui`). + mobile_ui_override: Option, + /// Persistent state for the mobile shell (active surface, ribbon tier). + mobile_state: mobile::MobileState, + /// Active mobile long-press context menu (set by a pane, rendered by the shell). + mobile_context_menu: Option, + /// Shared keyboard octave offset (C4-relative) for the mobile Virtual Piano + Piano Roll. + keyboard_octave: i8, + /// Shared horizontal keyboard pan (px) for the mobile keyboard + roll. + keyboard_pan_x: f32, + /// Mobile: request to open the instrument Preset Browser (set by the music pane header). + open_instrument_browser: bool, + /// Mobile: request to toggle recording (set by the music pane REC button). + pending_record_toggle: bool, icon_cache: IconCache, tool_icon_cache: ToolIconCache, focus_icon_cache: FocusIconCache, // Focus card icons (start screen) @@ -1118,6 +1304,26 @@ struct EditorApp { /// Current file operation in progress (if any) file_operation: Option, + /// Crash-recovery autosave state. The recovery container is a per-session `.beam` in the app's + /// data dir; it's written in the background (reusing the file worker) and deleted on a clean + /// exit. A leftover file on next launch signals an unclean shutdown → offer to restore. + autosave: AutosaveState, + + /// `ActionExecutor::epoch()` at the last manual save (or new/load) — the document is "modified" + /// (has unsaved changes vs. the user's file) when the current epoch differs, or `media_modified` + /// is set. Distinct from the autosave baseline, which also moves on every background autosave. + saved_epoch: u64, + /// Non-action changes since the last save (imports, finished recordings) that `epoch` doesn't + /// capture. Cleared on manual save / new / load. + media_modified: bool, + /// The pending action waiting on the "save changes?" prompt — a file switch (New/Open/Open + /// Recent) or a quit, requested while the document had unsaved work. + unsaved_prompt: Option, + /// The action to run once an in-flight save (triggered from that prompt via "Save") finishes. + after_save: Option, + /// The user agreed to quit — let the next window-close request through instead of intercepting it. + confirmed_close: bool, + /// Audio extraction channel for background thread communication audio_extraction_tx: std::sync::mpsc::Sender, audio_extraction_rx: std::sync::mpsc::Receiver, @@ -1190,6 +1396,9 @@ impl EditorApp { ) -> Self { let current_layout = layouts[0].layout.clone(); + // Register the bundled Lucide icon font (used by the mobile UI; harmless on desktop). + mobile::icons::install(&cc.egui_ctx); + // Disable egui's "Unaligned" debug overlay (on by default in debug builds) #[cfg(debug_assertions)] cc.egui_ctx.style_mut(|style| style.debug.show_unaligned = false); @@ -1198,7 +1407,17 @@ impl EditorApp { cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false); // Load application config - let config = AppConfig::load(); + let mut config = AppConfig::load(); + // One-time cleanup: earlier builds added a Recovered file to Recent on restore. Drop any + // recovery-dir paths that leaked in (and re-save if we removed any) so they don't show in + // Recent or get auto-reopened below. + let recent_before = config.recent_files.len(); + config + .recent_files + .retain(|p| !AutosaveState::is_recovery_path(p)); + if config.recent_files.len() != recent_before { + config.save(); + } // Check if we should auto-reopen last session let pending_auto_reopen = if config.reopen_last_session { @@ -1297,6 +1516,14 @@ impl EditorApp { hovered_divider: None, selected_pane: None, split_preview_mode: SplitPreviewMode::default(), + mobile_ui: mobile::is_mobile_env(), + mobile_ui_override: None, + mobile_state: mobile::MobileState::default(), + mobile_context_menu: None, + keyboard_octave: 0, + keyboard_pan_x: 0.0, + open_instrument_browser: false, + pending_record_toggle: false, icon_cache: IconCache::new(), tool_icon_cache: ToolIconCache::new(), focus_icon_cache: FocusIconCache::new(), @@ -1394,6 +1621,12 @@ impl EditorApp { config, file_command_tx, file_operation: None, // No file operation in progress initially + autosave: AutosaveState::new(), + saved_epoch: 0, + media_modified: false, + unsaved_prompt: None, + after_save: None, + confirmed_close: false, audio_extraction_tx, audio_extraction_rx, export_dialog: export::dialog::ExportDialog::default(), @@ -1679,10 +1912,39 @@ impl EditorApp { ); } + /// Tear down the audio backend for the currently-open project. + /// + /// Sends `Command::Reset` (fully rebuilds the backend `Project`, audio/buffer pools, and ID + /// counters) and clears the app-side track maps + backend-derived caches that pointed at the old + /// tracks. Must be called before building a new document's tracks, otherwise the previous file's + /// tracks/instruments stay resident in the backend and keep getting mixed (orphaned voices). + /// + /// Ordering is safe: the audio thread drains all `command_tx` commands before any `query_tx` + /// queries each callback, so a `reset()` pushed here always runs before the `create_*_track_sync` + /// queries that rebuild the project. + fn reset_audio_backend(&mut self) { + if let Some(ref controller_arc) = self.audio_controller { + controller_arc.lock().unwrap().reset(); + } + self.layer_to_track_map.clear(); + self.track_to_layer_map.clear(); + self.clip_instance_to_backend_map.clear(); + self.midi_event_cache.clear(); + self.audio_duration_cache.clear(); + self.raw_audio_cache.clear(); + self.waveform_gpu_dirty.clear(); + self.waveform_minmax_pools.clear(); + self.waveform_pyramid_blobs.clear(); + } + /// Create a new project with the specified focus/layout fn create_new_project_with_focus(&mut self, layout_index: usize) { use lightningbeam_core::layer::{AnyLayer, AudioLayer, VectorLayer, VideoLayer}; + // Drop the previous project's backend tracks/instruments before building the new one, so a + // "new file" while a project is open doesn't leave orphaned tracks resident in the backend. + self.reset_audio_backend(); + // Create a new blank document let mut document = lightningbeam_core::document::Document::with_size( "Untitled", @@ -1734,6 +1996,8 @@ impl EditorApp { // Reset action executor with new document self.action_executor = lightningbeam_core::action::ActionExecutor::new(document); + // Fresh document → nothing new to recover; rebase the autosave epoch. + self.reset_autosave_baseline(); // Apply the layout if layout_index < self.layouts.len() { @@ -2130,7 +2394,7 @@ impl EditorApp { AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document), AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document), AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document), - AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => vec![], }; for instance_id in active_layer_clips { @@ -2148,7 +2412,7 @@ impl EditorApp { AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document), AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document), AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document), - AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => vec![], }; if member_splittable.contains(member_instance_id) { clips_to_split.push((*member_layer_id, *member_instance_id)); @@ -2499,7 +2763,7 @@ impl EditorApp { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => &[], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[], }; let instances: Vec<_> = clip_slice .iter() @@ -2658,45 +2922,56 @@ impl EditorApp { } self.selection.clear_clip_instances(); + self.focus = lightningbeam_core::selection::FocusSelection::None; } else if self.selection.has_geometry_selection() { let active_layer_id = match self.active_layer_id { Some(id) => id, None => return, }; - // Delete the selected edges (region/marquee/click all populate the same sets). + // Delete the selected geometry (region/marquee/click all populate the same sets). + // Selecting a fill also selects its boundary edges, so removing edges alone would leave + // the fill's face orphaned — free the fills too so the whole shape is removed. let edge_ids: Vec = self.selection.selected_edges().iter().copied().collect(); + let fill_ids: Vec = + self.selection.selected_fills().iter().copied().collect(); - if !edge_ids.is_empty() { + if !edge_ids.is_empty() || !fill_ids.is_empty() { let document = self.action_executor.document(); - if let Some(layer) = document.get_layer(&active_layer_id) { - if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { - if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) { - let mut graph_after = graph_before.clone(); - for edge_id in &edge_ids { - if !graph_after.edge(*edge_id).deleted { - graph_after.remove_edge(*edge_id); - } + if let Some(lightningbeam_core::layer::AnyLayer::Vector(vector_layer)) = document.get_layer(&active_layer_id) { + if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) { + let mut graph_after = graph_before.clone(); + // Free fills first so their boundary edges become unreferenced and are GC'd. + for fill_id in &fill_ids { + if !graph_after.fill(*fill_id).deleted { + graph_after.free_fill(*fill_id); } - - let action = lightningbeam_core::actions::ModifyGraphAction::new( - active_layer_id, - self.playback_time, - graph_before.clone(), - graph_after, - "Delete selected edges", - ); - - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Delete DCEL edges failed: {}", e); + } + for edge_id in &edge_ids { + if !graph_after.edge(*edge_id).deleted { + graph_after.remove_edge(*edge_id); } } + graph_after.gc_isolated_vertices(); + + let action = lightningbeam_core::actions::ModifyGraphAction::new( + active_layer_id, + self.playback_time, + graph_before.clone(), + graph_after, + "Delete", + ); + + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Delete geometry failed: {}", e); + } } } } self.selection.clear_geometry_selection(); + self.focus = lightningbeam_core::selection::FocusSelection::None; } } @@ -2884,6 +3159,39 @@ impl EditorApp { } } + /// Send the selected clip/group instances to the back or front of their layer's stacking order. + fn reorder_selected_clips(&mut self, to_front: bool) { + use lightningbeam_core::layer::AnyLayer; + let Some(layer_id) = self.active_layer_id else { + return; + }; + let ids: Vec = { + let document = self.action_executor.document(); + let Some(layer) = document.get_layer(&layer_id) else { + return; + }; + let instances: &[lightningbeam_core::clip::ClipInstance] = match layer { + AnyLayer::Vector(l) => &l.clip_instances, + AnyLayer::Audio(l) => &l.clip_instances, + AnyLayer::Video(l) => &l.clip_instances, + AnyLayer::Effect(l) => &l.clip_instances, + _ => &[], + }; + instances + .iter() + .filter(|ci| self.selection.contains_clip_instance(&ci.id)) + .map(|ci| ci.id) + .collect() + }; + if ids.is_empty() { + return; + } + let action = lightningbeam_core::actions::ReorderClipInstancesAction::new(layer_id, ids, to_front); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to reorder clip instances: {e}"); + } + } + /// Duplicate the selected clip instances on the active layer. /// Each duplicate is placed immediately after the original clip. fn duplicate_selected_clips(&mut self) { @@ -2911,7 +3219,7 @@ impl EditorApp { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => &[], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[], }; instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) @@ -3083,28 +3391,7 @@ impl EditorApp { // File menu MenuAction::NewFile => { println!("Menu: New File"); - // TODO: Prompt to save current file if modified - - // Reset state and return to start screen - self.layer_to_track_map.clear(); - self.track_to_layer_map.clear(); - self.layer_to_track_map.clear(); - self.clip_instance_to_backend_map.clear(); - self.current_file_path = None; - self.selection.clear(); - self.editing_context = EditingContext::default(); - self.active_layer_id = None; - self.playback_time = 0.0; - self.is_playing = false; - self.midi_event_cache.clear(); - self.audio_duration_cache.clear(); - self.raw_audio_cache.clear(); - self.waveform_gpu_dirty.clear(); - self.waveform_minmax_pools.clear(); - self.waveform_pyramid_blobs.clear(); - self.pane_instances.clear(); - self.project_generation += 1; - self.app_mode = AppMode::StartScreen; + self.request_switch(PendingAction::NewFile); } MenuAction::NewWindow => { println!("Menu: New Window"); @@ -3113,11 +3400,17 @@ impl EditorApp { MenuAction::Save => { use rfd::FileDialog; - if let Some(path) = &self.current_file_path { + // A recovered file has no real home yet — Save behaves as Save As so the work lands + // where the user wants, not back in the app data dir. + let real_path = self + .current_file_path + .clone() + .filter(|p| !AutosaveState::is_recovery_path(p)); + if let Some(path) = real_path { // Save to existing path - self.save_to_file(path.clone()); + self.save_to_file(path); } else { - // No current path, fall through to Save As + // No real path (untitled or recovered): fall through to Save As if let Some(path) = FileDialog::new() .add_filter("Lightningbeam Project", &["beam"]) .set_file_name("Untitled.beam") @@ -3134,15 +3427,16 @@ impl EditorApp { .add_filter("Lightningbeam Project", &["beam"]) .set_file_name("Untitled.beam"); - // Set initial directory if we have a current file - let dialog = if let Some(current_path) = &self.current_file_path { - if let Some(parent) = current_path.parent() { - dialog.set_directory(parent) - } else { - dialog - } - } else { - dialog + // Default to the current file's directory — but not the recovery dir (a recovered + // file's parent), which the user never chose and shouldn't be steered back into. + let dialog = match self + .current_file_path + .as_ref() + .filter(|p| !AutosaveState::is_recovery_path(p)) + .and_then(|p| p.parent()) + { + Some(parent) => dialog.set_directory(parent), + None => dialog, }; if let Some(path) = dialog.save_file() { @@ -3152,21 +3446,19 @@ impl EditorApp { MenuAction::OpenFile => { use rfd::FileDialog; - // TODO: Prompt to save current file if modified - + // Pick the file first, then (if there are unsaved changes) prompt to save. if let Some(path) = FileDialog::new() .add_filter("Lightningbeam Project", &["beam"]) .pick_file() { - self.load_from_file(path); + self.request_switch(PendingAction::Open(path)); } } MenuAction::OpenRecent(index) => { let recent_files = self.config.get_recent_files(); if let Some(path) = recent_files.get(index) { - // TODO: Prompt to save current file if modified - self.load_from_file(path.clone()); + self.request_switch(PendingAction::Open(path.clone())); } } MenuAction::ClearRecentFiles => { @@ -3303,7 +3595,7 @@ impl EditorApp { AnyLayer::Video(_) => hint.has_video = true, AnyLayer::Audio(_) => hint.has_audio = true, AnyLayer::Raster(_) => hint.has_raster = true, - AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true, + AnyLayer::Vector(_) | AnyLayer::Effect(_) | AnyLayer::Text(_) => hint.has_vector = true, AnyLayer::Group(g) => scan(&g.children, hint), } } @@ -3320,7 +3612,13 @@ impl EditorApp { h }; - self.export_dialog.open(timeline_endpoint, &project_name, &hint); + self.export_dialog.open( + timeline_endpoint, + &project_name, + &hint, + &self.config.last_audio_artist, + &self.config.last_audio_album, + ); } MenuAction::Quit => { println!("Menu: Quit"); @@ -3524,12 +3822,10 @@ impl EditorApp { } } MenuAction::SendToBack => { - println!("Menu: Send to Back"); - // TODO: Implement send to back + self.reorder_selected_clips(false); } MenuAction::BringToFront => { - println!("Menu: Bring to Front"); - // TODO: Implement bring to front + self.reorder_selected_clips(true); } MenuAction::SplitClip => { self.split_clips_at_playhead(); @@ -3893,19 +4189,45 @@ impl EditorApp { } /// Prepare document for saving by storing current UI layout - fn prepare_document_for_save(&mut self) { - let doc = self.action_executor.document_mut(); - - // Store current layout state - doc.ui_layout = Some(self.current_layout.clone()); - - // Store base layout name for reference + /// Save the current document to a .beam file + /// Assemble the background save command for `path`: prepares the document (layout), clones it, + /// and snapshots the waveform/thumbnail side data. Shared by manual save and background autosave. + fn build_save_command( + &mut self, + path: std::path::PathBuf, + progress_tx: std::sync::mpsc::Sender, + ) -> FileCommand { + // Snapshot the document and stamp the current UI layout onto the SNAPSHOT — never the live + // document. Mutating the live doc here (as the old prepare_document_for_save did via + // Arc::make_mut) would touch UI state and could deep-clone the whole document if a render + // callback holds a reference — unacceptable for a frequent background autosave. The live + // doc's `ui_layout` is only read at save/serialize time, so leaving it stale is harmless. + let mut document = self.action_executor.document().clone(); + document.ui_layout = Some(self.current_layout.clone()); if self.current_layout_index < self.layouts.len() { - doc.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone()); + document.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone()); + } + let waveform_blobs: std::collections::HashMap> = self + .waveform_pyramid_blobs + .iter() + .map(|(&idx, blob)| (idx, blob.as_ref().clone())) + .collect(); + let (thumbnail_snapshot, complete_thumbnail_clips) = { + let vm = self.video_manager.lock().unwrap(); + (vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips()) + }; + FileCommand::Save { + path, + document, + layer_to_track_map: self.layer_to_track_map.clone(), + large_media_mode: self.config.large_media_default, + waveform_blobs, + thumbnail_snapshot, + complete_thumbnail_clips, + progress_tx, } } - /// Save the current document to a .beam file fn save_to_file(&mut self, path: std::path::PathBuf) { println!("Saving to: {}", path.display()); @@ -3914,42 +4236,9 @@ impl EditorApp { return; } - // Prepare document for save (including layout) - self.prepare_document_for_save(); - // Create progress channel let (progress_tx, progress_rx) = std::sync::mpsc::channel(); - - // Clone document for background thread - let document = self.action_executor.document().clone(); - - // Snapshot the generated waveform pyramids (by pool index) so the worker - // can persist them into the container alongside the audio. - let waveform_blobs: std::collections::HashMap> = self - .waveform_pyramid_blobs - .iter() - .map(|(&idx, blob)| (idx, blob.as_ref().clone())) - .collect(); - - // Snapshot all video thumbnails (cheap Arc-clone) + which clips finished - // generating; the worker PNG-encodes them with a complete/partial flag so a - // save mid-generation persists progress and resumes on load. - let (thumbnail_snapshot, complete_thumbnail_clips) = { - let vm = self.video_manager.lock().unwrap(); - (vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips()) - }; - - // Send save command to worker thread - let command = FileCommand::Save { - path: path.clone(), - document, - layer_to_track_map: self.layer_to_track_map.clone(), - large_media_mode: self.config.large_media_default, - waveform_blobs, - thumbnail_snapshot, - complete_thumbnail_clips, - progress_tx, - }; + let command = self.build_save_command(path.clone(), progress_tx); if let Err(e) = self.file_command_tx.send(command) { eprintln!("❌ Failed to send save command: {}", e); @@ -3963,6 +4252,301 @@ impl EditorApp { }); } + /// Mark the current document state as the autosave baseline — there is nothing new to recover + /// (called after a manual save, and after the document is replaced by new/load). Clears the + /// pending-event flag and resets the throttle so the next real change schedules cleanly. + fn reset_autosave_baseline(&mut self) { + self.autosave.baseline_epoch = self.action_executor.epoch(); + self.autosave.pending_event = false; + self.autosave.last_time = None; + // A fresh/loaded document also starts unmodified vs. its on-disk form. + self.saved_epoch = self.action_executor.epoch(); + self.media_modified = false; + } + + /// Whether the document has unsaved changes vs. the user's file (edits since the last manual + /// save, or an import/recording that `epoch` doesn't track). Recovered work counts as unsaved + /// until the user gives it a real home — its only copy is the transient recovery container. + fn document_modified(&self) -> bool { + self.action_executor.epoch() != self.saved_epoch + || self.media_modified + || self + .current_file_path + .as_ref() + .is_some_and(|p| AutosaveState::is_recovery_path(p)) + } + + /// Background crash-recovery autosave: poll any in-flight recovery write, then (if the document + /// is dirty, throttled to `AUTOSAVE_INTERVAL_SECS`, and no manual save is running) dispatch a + /// write of the current state into the per-session recovery container. Fully background — reuses + /// the file worker; the only UI-thread cost is the same document/side-data snapshot a manual + /// save does. Never touches `current_file_path` or the raster `dirty` flags (the recovery file + /// is a separate container from the user's file). + fn maybe_autosave(&mut self, ctx: &egui::Context) { + // Drain progress from an in-flight recovery write. + if let Some(rx) = &self.autosave.progress_rx { + while let Ok(p) = rx.try_recv() { + match p { + FileProgress::Done => self.autosave.in_flight = false, + FileProgress::Error(e) => { + eprintln!("⚠️ [AUTOSAVE] recovery write failed: {}", e); + self.autosave.in_flight = false; + } + _ => {} + } + } + if !self.autosave.in_flight { + self.autosave.progress_rx = None; + } + } + + let Some(recovery_path) = self.autosave.recovery_path.clone() else { return }; + if self.autosave.in_flight || self.audio_controller.is_none() { + return; + } + // Don't compete with a manual save on the single worker. + if matches!(self.file_operation, Some(FileOperation::Saving { .. })) { + return; + } + + let epoch = self.action_executor.epoch(); + let dirty = epoch != self.autosave.baseline_epoch || self.autosave.pending_event; + if !dirty { + return; + } + if let Some(t) = self.autosave.last_time { + let elapsed = t.elapsed().as_secs_f64(); + if elapsed < self.autosave.interval_secs { + // Dirty but throttled — wake up when the interval elapses even if the app goes idle, + // so an edit-then-idle session still gets its recovery snapshot. + ctx.request_repaint_after(std::time::Duration::from_secs_f64( + (self.autosave.interval_secs - elapsed).max(0.1), + )); + return; + } + } + + let (tx, rx) = std::sync::mpsc::channel(); + let command = self.build_save_command(recovery_path, tx); + if self.file_command_tx.send(command).is_err() { + return; + } + self.autosave.in_flight = true; + self.autosave.progress_rx = Some(rx); + self.autosave.baseline_epoch = epoch; + self.autosave.pending_event = false; + self.autosave.last_time = Some(std::time::Instant::now()); + eprintln!("💾 [AUTOSAVE] recovery snapshot dispatched"); + } + + /// Show the crash-recovery prompt when a previous session left a recovery file behind. Recover + /// loads it as an untitled document; Discard deletes it; Later keeps it for the next launch. + fn render_recovery_prompt(&mut self, ctx: &egui::Context) { + // Don't prompt over an in-flight file op (including a recovery load we just started). + if self.autosave.leftover_recoveries.is_empty() || self.file_operation.is_some() { + return; + } + let path = self.autosave.leftover_recoveries[0].clone(); + + #[derive(PartialEq)] + enum Choice { Recover, Discard, Later } + let mut choice: Option = None; + + egui::Modal::new(egui::Id::new("crash_recovery_modal")).show(ctx, |ui| { + ui.set_width(crate::mobile::dialog_width(ctx, 460.0)); + ui.heading("Recover unsaved work?"); + ui.add_space(6.0); + ui.label( + "Lightningbeam didn't shut down cleanly last time. You have unsaved work from your \ + previous session — recover it?", + ); + if self.autosave.leftover_recoveries.len() > 1 { + ui.add_space(4.0); + ui.weak(format!( + "{} snapshots available; this shows the most recent first.", + self.autosave.leftover_recoveries.len() + )); + } + ui.add_space(14.0); + ui.horizontal(|ui| { + if ui.button("Recover").clicked() { + choice = Some(Choice::Recover); + } + if ui.button("Discard").clicked() { + choice = Some(Choice::Discard); + } + if ui.button("Later").clicked() { + choice = Some(Choice::Later); + } + }); + }); + + match choice { + Some(Choice::Recover) => { + self.autosave.leftover_recoveries.remove(0); + // Rename out of the `session-*` namespace before opening it, so it isn't offered + // again next launch — but keep the file, since recovered raster keyframes page in + // from it on demand (deleting it would lose paged pixels). It opens as the current + // file; the user relocates the work with Save As. Old `recovered-*` files are + // garbage-collected at startup. + let open_path = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => { + let renamed = + path.with_file_name(name.replacen("session-", "recovered-", 1)); + if std::fs::rename(&path, &renamed).is_ok() { renamed } else { path } + } + None => path, + }; + self.load_from_file(open_path); + } + Some(Choice::Discard) => { + let _ = std::fs::remove_file(&path); + self.autosave.leftover_recoveries.remove(0); + } + Some(Choice::Later) => { + // Keep the files on disk but stop prompting this session. + self.autosave.leftover_recoveries.clear(); + } + None => {} + } + } + + /// The single "save changes?" prompt for any unsaved-work exit point — file switches (New / + /// Open / Open Recent) and quitting. Also intercepts the window-close request. Save persists + /// first (Save As for untitled/recovered docs) then runs the action; Don't Save runs it and + /// discards; Cancel stays put. + fn render_unsaved_prompt(&mut self, ctx: &egui::Context) { + // Intercept a window-close request with unsaved work → veto it and queue the Quit prompt. + if ctx.input(|i| i.viewport().close_requested()) && !self.confirmed_close { + if self.document_modified() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.unsaved_prompt = Some(PendingAction::Quit); + } + // Unmodified → let the close proceed. + } + + let Some(pending) = self.unsaved_prompt.as_ref() else { return }; + // The "…before X?" tail + the affirmative button label, per action. + let (desc, discard_label) = match pending { + PendingAction::NewFile => ("starting a new file", "Don't Save"), + PendingAction::Open(_) => ("opening another file", "Don't Save"), + PendingAction::Quit => ("quitting", "Discard & Quit"), + }; + + #[derive(PartialEq)] + enum Answer { + Save, + Discard, + Cancel, + } + let mut answer: Option = None; + + egui::Modal::new(egui::Id::new("unsaved_changes_modal")).show(ctx, |ui| { + ui.set_width(crate::mobile::dialog_width(ctx, 440.0)); + ui.heading("Save changes?"); + ui.add_space(6.0); + ui.label(format!( + "This document has unsaved changes. Save them before {desc}?" + )); + ui.add_space(14.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + answer = Some(Answer::Save); + } + if ui.button(discard_label).clicked() { + answer = Some(Answer::Discard); + } + if ui.button("Cancel").clicked() { + answer = Some(Answer::Cancel); + } + }); + }); + + match answer { + Some(Answer::Cancel) => { + self.unsaved_prompt = None; + } + Some(Answer::Discard) => { + if let Some(action) = self.unsaved_prompt.take() { + self.do_action(action, ctx); + } + } + Some(Answer::Save) => { + let action = self.unsaved_prompt.take(); + // Save to the existing file, or Save As for an untitled / recovered document. + let real_path = self + .current_file_path + .clone() + .filter(|p| !AutosaveState::is_recovery_path(p)); + let target = match real_path { + Some(p) => Some(p), + None => rfd::FileDialog::new() + .add_filter("Lightningbeam Project", &["beam"]) + .set_file_name("Untitled.beam") + .save_file(), + }; + match target { + Some(path) => { + // Run the action once the save completes. + self.after_save = action; + self.save_to_file(path); + } + None => { + // Save As cancelled → abort, keep the document (still unsaved). + } + } + } + None => {} + } + } + + /// New File: tear down the current project and return to the start screen. (The guard for + /// unsaved changes lives in the menu handler; this is the actual action.) + fn do_new_file(&mut self) { + // Tear down the backend (stops old instruments/voices immediately) and clear the app-side + // track maps + backend-derived caches. + self.reset_audio_backend(); + + // Reset UI state and return to the start screen. + self.current_file_path = None; + self.selection.clear(); + self.editing_context = EditingContext::default(); + self.active_layer_id = None; + self.playback_time = 0.0; + self.is_playing = false; + self.pane_instances.clear(); + self.project_generation += 1; + self.app_mode = AppMode::StartScreen; + } + + /// Carry out a deferred action once the unsaved-changes prompt is resolved (or when there was + /// nothing unsaved to begin with). + fn do_action(&mut self, action: PendingAction, ctx: &egui::Context) { + match action { + PendingAction::NewFile => self.do_new_file(), + PendingAction::Open(path) => self.load_from_file(path), + PendingAction::Quit => { + self.confirmed_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + + /// Begin a file switch (New / Open / Open Recent), prompting to save first if the document has + /// unsaved changes. Only ever called with `NewFile`/`Open` from the menus, so the immediate path + /// needs no `ctx` (only `Quit`, driven by the close interceptor, does). + fn request_switch(&mut self, action: PendingAction) { + if self.document_modified() { + self.unsaved_prompt = Some(action); + } else { + match action { + PendingAction::NewFile => self.do_new_file(), + PendingAction::Open(path) => self.load_from_file(path), + PendingAction::Quit => {} + } + } + } + /// Load a document from a .beam file fn load_from_file(&mut self, path: std::path::PathBuf) { println!("Loading from: {}", path.display()); @@ -4048,9 +4632,17 @@ impl EditorApp { // TODO Phase 5: Show recovery dialog } + // Tear down the previously-open project's backend tracks/instruments before restoring this + // file's audio pool + tracks, so an open-over-open doesn't leave orphaned tracks resident in + // the backend. Reset is a command; the audio-pool/track restoration below uses queries, which + // the audio thread drains after all commands each callback, so the ordering holds. + self.reset_audio_backend(); + // Replace document let step1_start = std::time::Instant::now(); self.action_executor = ActionExecutor::new(loaded_project.document); + // Freshly loaded document is clean → rebase the autosave epoch (no spurious recovery write). + self.reset_autosave_baseline(); eprintln!("📊 [APPLY] Step 1: Replace document took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); // Restore UI layout from loaded document @@ -4331,9 +4923,12 @@ impl EditorApp { // Point the raster paging store at the loaded container so faulting works. self.raster_store.set_path(self.current_file_path.clone()); - // Add to recent files - self.config.add_recent_file(path.clone()); - self.update_recent_files_menu(); + // Add to recent files — but never a recovery file (it's an internal, transient container in + // the app data dir, not a project the user opened). + if !AutosaveState::is_recovery_path(&path) { + self.config.add_recent_file(path.clone()); + self.update_recent_files_menu(); + } // Set active layer if let Some(first) = self.action_executor.document().root.children.first() { @@ -4589,6 +5184,9 @@ impl EditorApp { fn import_image(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::ImageAsset; + // Imported media lives outside the action/undo system, so flag it for the next autosave. + self.autosave.pending_event = true; + self.media_modified = true; self.note_possible_large_media(path); // Get filename for asset name @@ -4642,6 +5240,8 @@ impl EditorApp { /// GPU waveform cache. fn import_audio(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::AudioClip; + self.autosave.pending_event = true; + self.media_modified = true; self.note_possible_large_media(path); let name = path.file_stem() @@ -4768,6 +5368,8 @@ impl EditorApp { fn import_video(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::VideoClip; use lightningbeam_core::video::probe_video; + self.autosave.pending_event = true; + self.media_modified = true; self.note_possible_large_media(path); let name = path.file_stem() @@ -5310,6 +5912,14 @@ impl EditorApp { } impl eframe::App for EditorApp { + /// Clean shutdown → not a crash → delete this session's recovery file so it isn't offered for + /// restore on the next launch. (If we crash instead, `on_exit` never runs and the file remains.) + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + if let Some(path) = self.autosave.recovery_path.take() { + let _ = std::fs::remove_file(&path); + } + } + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { self.tablet.poll(ctx, raw_input, self.selected_tool); @@ -5320,6 +5930,21 @@ impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let _frame_start = std::time::Instant::now(); + // Apply the theme's palette to egui's global visuals so the standard widgets (dialogs, pane + // chrome, text) share the same colors as the mobile UI and respond to light/dark/user themes. + self.theme.apply_to_egui(ctx); + // On mobile, enlarge egui spacing/sizing so widgets in panes/dialogs are touch-friendly. + if self.mobile_active() { + mobile::apply_touch_style(ctx); + } + + // Background crash-recovery autosave (cheap early-out unless dirty + interval elapsed). + self.maybe_autosave(ctx); + // Offer to restore a previous session's unsaved work (if a recovery file was left behind). + self.render_recovery_prompt(ctx); + // Prompt to save unsaved changes before switching files (New / Open / Open Recent). + self.render_unsaved_prompt(ctx); + // === Raster fault-in (Phase 3 paging) === // The canvas records raster keyframe ids whose `raw_pixels` weren't resident // (it can't mutate the document while rendering). Drain that sink here, BEFORE @@ -5559,6 +6184,9 @@ impl eframe::App for EditorApp { let mut operation_complete = false; let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None; let mut update_recent_menu = false; // Track if we need to update recent files menu + // An action that was waiting on this save (from the unsaved-changes prompt), to run + // after the file_operation borrow ends. + let mut after_save: Option = None; match operation { FileOperation::Saving { ref mut progress_rx, ref path } => { @@ -5567,6 +6195,18 @@ impl eframe::App for EditorApp { FileProgress::Done => { println!("✅ Save complete!"); self.current_file_path = Some(path.clone()); + // Manual save persisted everything to the user's file → no unsaved + // work to recover; rebase the autosave epoch so it stays quiet until + // the next real edit. (Inlined rather than reset_autosave_baseline() + // to avoid a second &mut self borrow inside the file_operation match.) + self.autosave.baseline_epoch = self.action_executor.epoch(); + self.autosave.pending_event = false; + self.autosave.last_time = None; + // The document now matches its file → no unsaved changes. + self.saved_epoch = self.action_executor.epoch(); + self.media_modified = false; + // If a file switch was waiting on this save, run it after the borrow. + after_save = self.after_save.take(); // Container path may be new (Save As); update the // raster paging store so future faults read the right file. self.raster_store.set_path(self.current_file_path.clone()); @@ -5684,6 +6324,11 @@ impl eframe::App for EditorApp { self.update_recent_files_menu(); } + // An action that was waiting on this save ("Save" in the prompt → New/Open/Quit) runs now. + if let Some(action) = after_save { + self.do_action(action, ctx); + } + // Request repaint to keep updating progress ctx.request_repaint(); } @@ -5897,6 +6542,9 @@ impl eframe::App for EditorApp { if !clip_id.is_nil() { // Finalize the clip (update pool_index and duration) + // A finished recording (samples in the pool) needs capturing. + self.autosave.pending_event = true; + self.media_modified = true; if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) { if clip.finalize_recording(pool_index, duration) { clip.name = format!("Recording {}", pool_index); @@ -6220,9 +6868,29 @@ impl eframe::App for EditorApp { } false // synchronous; no progress dialog } + ExportResult::Gif(settings, output_path) => { + println!("🎞 [MAIN] Starting GIF export: {}", output_path.display()); + let doc = self.action_executor.document(); + orchestrator.start_gif_export( + settings, + output_path, + doc.width as u32, + doc.height as u32, + ); + true // background encode with progress dialog + } ExportResult::AudioOnly(settings, output_path) => { println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); + // Remember Artist/Album so they prefill next time. + if !settings.metadata.artist.is_empty() { + self.config.last_audio_artist = settings.metadata.artist.clone(); + } + if !settings.metadata.album.is_empty() { + self.config.last_audio_album = settings.metadata.album.clone(); + } + self.config.save(); + if let Some(audio_controller) = &self.audio_controller { orchestrator.start_audio_export( settings, @@ -6306,7 +6974,8 @@ impl eframe::App for EditorApp { } // Render preferences dialog - if let Some(result) = self.preferences_dialog.render(ctx, &mut self.config, &mut self.theme) { + let mobile = self.mobile_active(); + if let Some(result) = self.preferences_dialog.render(ctx, &mut self.config, &mut self.theme, mobile) { if result.buffer_size_changed { println!("⚠️ Audio buffer size will be applied on next app restart"); } @@ -6373,6 +7042,21 @@ impl eframe::App for EditorApp { } } + // Drive incremental GIF export (one frame rendered + streamed per call). + match orchestrator.render_next_gif_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + image_cache, + &self.video_manager, + Some(&self.raster_store), + ) { + Ok(true) => { ctx.request_repaint(); } // more frames to render + Ok(false) => {} // done or not a GIF export + Err(e) => { eprintln!("GIF export failed: {e}"); } + } + // Drive single-frame image export (two-frame async: render then readback). match orchestrator.render_image_frame( self.action_executor.document_mut(), @@ -6471,7 +7155,9 @@ impl eframe::App for EditorApp { } } - // Top menu bar (egui-rendered on all platforms) + // Top menu bar (egui-rendered on desktop). On mobile the shell's ⌕ palette + ⋯ overflow + // expose every command, so the desktop menu bar is suppressed. + if !self.mobile_active() { egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { if let Some(menu_system) = &self.menu_system { let recent_files = self.config.get_recent_files(); @@ -6507,10 +7193,15 @@ impl eframe::App for EditorApp { } } }); + } // Render start screen or editor based on app mode if self.app_mode == AppMode::StartScreen { - self.render_start_screen(ctx); + if self.mobile_active() { + mobile::intent::render(self, ctx); + } else { + self.render_start_screen(ctx); + } return; // Skip editor rendering } @@ -6535,44 +7226,40 @@ impl eframe::App for EditorApp { // Main pane area (editor mode) let mut layout_action: Option = None; - let mut clipboard_consumed = false; - egui::CentralPanel::default().show(ctx, |ui| { + // Per-frame scratch consumed by the post-render drain (see FrameScratch). + // Declared outside the closure because some of it (clipboard_consumed) is read + // after the panel closes. + let mut scratch = FrameScratch::default(); + #[cfg(debug_assertions)] + { + scratch.synthetic_input = test_mode_replay.synthetic_input; + } + // On mobile the shell is full-bleed to the window edges (it paints its own background), so + // drop the central panel's default inner margin — otherwise every pane is inset by it while + // the unclipped keyboard overdraws into it, leaving an inconsistent gutter. + let central = egui::CentralPanel::default(); + let central = if self.mobile_active() { + central.frame(egui::Frame::default()) + } else { + central + }; + central.show(ctx, |ui| { let available_rect = ui.available_rect_before_wrap(); // Reset hovered divider each frame self.hovered_divider = None; - // Track fallback pane priority for view actions (reset each frame) - let mut fallback_pane_priority: Option = None; - - // Registry for view action handlers (two-phase dispatch) - let mut pending_handlers: Vec = Vec::new(); - - // Registry for actions to execute after rendering (two-phase dispatch) - let mut pending_actions: Vec> = Vec::new(); - - // Menu actions queued by pane context menus - let mut pending_menu_actions: Vec = Vec::new(); - - // Editing context navigation requests from stage pane - let mut pending_enter_clip: Option<(Uuid, Uuid, Uuid)> = None; - let mut pending_exit_clip = false; - - // Synthetic input from test mode replay (debug builds only) - #[cfg(debug_assertions)] - let mut synthetic_input_storage: Option = test_mode_replay.synthetic_input; - - // Queue for effect thumbnail requests (collected during rendering) - let mut effect_thumbnail_requests: Vec = Vec::new(); - // Empty cache fallback if generator not initialized - let empty_thumbnail_cache: HashMap> = HashMap::new(); - // Sync clip instance transforms from animation data at current playback time. // This ensures selection boxes, hit testing, and interactive editing see the // animated transform values, not just the base values on the ClipInstance struct. { let time = self.playback_time; let document = self.action_executor.document_mut(); + // Keep document.current_time synced from the audio playback position every frame. + // The renderer reads current_time for animation eval + video-frame decode, so this + // must not depend on any particular pane (e.g. the timeline) being rendered — + // otherwise the Stage freezes during playback when that pane is off-screen. + document.current_time = time; // Bake animation transforms for root layers for layer in document.root.children.iter_mut() { if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { @@ -6601,133 +7288,69 @@ impl eframe::App for EditorApp { } } - // Create render context - let mut ctx = RenderContext { - shared: panes::SharedPaneState { - container_path: self.current_file_path.clone(), - onion: { - // Onion skinning is disabled during playback. - let mut o = self.onion_skin; - o.enabled = o.enabled && !self.is_playing; - o - }, - onion_skin: &mut self.onion_skin, - tool_icon_cache: &mut self.tool_icon_cache, - icon_cache: &mut self.icon_cache, - selected_tool: &mut self.selected_tool, - fill_color: &mut self.fill_color, - stroke_color: &mut self.stroke_color, - active_color_mode: &mut self.active_color_mode, - pending_view_action: &mut self.pending_view_action, - fallback_pane_priority: &mut fallback_pane_priority, - pending_handlers: &mut pending_handlers, - theme: &self.theme, - action_executor: &mut self.action_executor, - selection: &mut self.selection, - focus: &mut self.focus, - editing_clip_id: self.editing_context.current_clip_id(), - editing_instance_id: self.editing_context.current_instance_id(), - editing_parent_layer_id: self.editing_context.current_parent_layer_id(), - pending_enter_clip: &mut pending_enter_clip, - pending_exit_clip: &mut pending_exit_clip, - active_layer_id: &mut self.active_layer_id, - tool_state: &mut self.tool_state, - pending_actions: &mut pending_actions, - draw_simplify_mode: &mut self.draw_simplify_mode, - rdp_tolerance: &mut self.rdp_tolerance, - schneider_max_error: &mut self.schneider_max_error, - raster_settings: &mut self.raster_settings, - audio_controller: self.audio_controller.as_ref(), - clip_snapshot: self.audio_controller.as_ref().map(|arc| { - arc.lock().unwrap().clip_snapshot() - }), - audio_input_opener: &mut self.audio_input, - audio_input_stream: &mut self.audio_input_stream, - audio_buffer_size: self.audio_buffer_size, - video_manager: &self.video_manager, - playback_time: &mut self.playback_time, - is_playing: &mut self.is_playing, - is_recording: &mut self.is_recording, - metronome_enabled: &mut self.metronome_enabled, - count_in_enabled: &mut self.count_in_enabled, - recording_clips: &mut self.recording_clips, - recording_start_time: &mut self.recording_start_time, - recording_layer_ids: &mut self.recording_layer_ids, - dragging_asset: &mut self.dragging_asset, - stroke_width: &mut self.stroke_width, - fill_enabled: &mut self.fill_enabled, - snap_enabled: &mut self.snap_enabled, - paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, - polygon_sides: &mut self.polygon_sides, - layer_to_track_map: &self.layer_to_track_map, - clip_instance_to_backend_map: &self.clip_instance_to_backend_map, - midi_event_cache: &mut self.midi_event_cache, - audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, - raw_audio_cache: &self.raw_audio_cache, - waveform_gpu_dirty: &mut self.waveform_gpu_dirty, - waveform_minmax_pools: &self.waveform_minmax_pools, - raster_fault_requests: &self.raster_fault_requests, - effect_to_load: &mut self.effect_to_load, - effect_thumbnail_requests: &mut effect_thumbnail_requests, - effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() - .map(|g| g.thumbnail_cache()) - .unwrap_or(&empty_thumbnail_cache), - effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, - webcam_frame: self.webcam_frame.clone(), - webcam_record_command: &mut self.webcam_record_command, - target_format: self.target_format, - pending_menu_actions: &mut pending_menu_actions, - clipboard_manager: &mut self.clipboard_manager, - input_level: self.input_level, - output_level: self.output_level, - track_levels: &self.track_levels, - track_to_layer_map: &self.track_to_layer_map, - waveform_stereo: self.config.waveform_stereo, - project_generation: &mut self.project_generation, - graph_topology_generation: &mut self.graph_topology_generation, - script_to_edit: &mut self.script_to_edit, - script_saved: &mut self.script_saved, - pending_region_cut_base: &mut self.pending_region_cut_base, - region_select_mode: &mut self.region_select_mode, - lasso_mode: &mut self.lasso_mode, - pending_graph_loads: &self.pending_graph_loads, - clipboard_consumed: &mut clipboard_consumed, - keymap: &self.keymap, - pending_node_group: &mut self.pending_node_group, - pending_node_ungroup: &mut self.pending_node_ungroup, - #[cfg(debug_assertions)] - test_mode: &mut self.test_mode, - #[cfg(debug_assertions)] - synthetic_input: &mut synthetic_input_storage, - brush_preview_pixels: &self.brush_preview_pixels, - }, - pane_instances: &mut self.pane_instances, - }; + // Build the per-frame context (single source of truth for SharedPaneState, + // shared by the desktop and mobile paths). The bundle borrows `self` + `scratch`; + // it is dropped before the post-render drain below re-touches them. + let is_mobile = self.mobile_active(); + let mut bundle = self.build_frame(&mut scratch); - render_layout_node( - ui, - &mut self.current_layout, - available_rect, - &mut self.drag_state, - &mut self.hovered_divider, - &mut self.selected_pane, - &mut layout_action, - &mut self.split_preview_mode, - &Vec::new(), // Root path - &mut ctx, - ); + if is_mobile { + // Phone shell: a single hero surface + top tabs + resizable timeline ribbon + // + a fixed transport floor, reusing the same panes the desktop layout uses. + mobile::render_mobile_shell( + ui, + available_rect, + &mut bundle.rc, + bundle.mobile_state, + ); + + // Drive recording from the application, not the Timeline pane's render pass, so a + // REC request (from the music pane header) and count-in progression work regardless + // of whether the Timeline is currently visible. The Timeline pane still owns the + // recording *state*; we just tick it here each frame. + let rc = &mut bundle.rc; + for pane in rc.pane_instances.values_mut() { + if let PaneInstance::Timeline(t) = pane { + t.check_pending_recording_start(&mut rc.shared); + if *rc.shared.pending_record_toggle { + *rc.shared.pending_record_toggle = false; + t.toggle_recording(&mut rc.shared); + } + // Stopping playback stops recording (stop_recording clears is_recording, + // so this fires once). + if *rc.shared.is_recording && !*rc.shared.is_playing { + t.stop_recording(&mut rc.shared); + } + break; + } + } + } else { + render_layout_node( + ui, + bundle.current_layout, + available_rect, + bundle.drag_state, + bundle.hovered_divider, + bundle.selected_pane, + &mut layout_action, + bundle.split_preview_mode, + &Vec::new(), // Root path + &mut bundle.rc, + ); + } + drop(bundle); // Process collected effect thumbnail requests - if !effect_thumbnail_requests.is_empty() { + if !scratch.effect_thumbnail_requests.is_empty() { if let Some(generator) = &mut self.effect_thumbnail_generator { - generator.request_thumbnails(&effect_thumbnail_requests); + generator.request_thumbnails(&scratch.effect_thumbnail_requests); } } // Execute action on the best handler (two-phase dispatch) if let Some(action) = &self.pending_view_action { - if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { + if let Some(best_handler) = scratch.pending_handlers.iter().min_by_key(|h| h.priority) { // Look up the pane instance and execute the action if let Some(pane_instance) = self.pane_instances.get_mut(&best_handler.pane_path) { match pane_instance { @@ -6748,7 +7371,7 @@ impl eframe::App for EditorApp { self.sync_audio_layers_to_backend(); // Execute all pending actions (two-phase dispatch) - for action in pending_actions { + for action in std::mem::take(&mut scratch.pending_actions) { // Record action for test mode (debug builds only) #[cfg(debug_assertions)] let action_desc = action.description(); @@ -6780,7 +7403,7 @@ impl eframe::App for EditorApp { } // Process menu actions queued by pane context menus - for action in pending_menu_actions { + for action in std::mem::take(&mut scratch.pending_menu_actions) { self.handle_menu_action(action); } @@ -6942,7 +7565,7 @@ impl eframe::App for EditorApp { } // Process editing context navigation (enter/exit movie clips) - if let Some((clip_id, instance_id, parent_layer_id)) = pending_enter_clip { + if let Some((clip_id, instance_id, parent_layer_id)) = scratch.pending_enter_clip { let entry = EditingContextEntry { clip_id, instance_id, @@ -6966,7 +7589,7 @@ impl eframe::App for EditorApp { self.commit_raster_floating(); } - if pending_exit_clip { + if scratch.pending_exit_clip { if let Some(entry) = self.editing_context.pop() { self.selection.clear(); self.active_layer_id = entry.saved_active_layer_id; @@ -6974,6 +7597,15 @@ impl eframe::App for EditorApp { } } + // Breadcrumb jump: exit up to a specific depth (restoring that level's saved context). + if let Some(depth) = scratch.pending_exit_to_depth.take() { + if let Some(entry) = self.editing_context.exit_to_depth(depth) { + self.selection.clear(); + self.active_layer_id = entry.saved_active_layer_id; + self.playback_time = entry.saved_playback_time; + } + } + // Set cursor based on hover state if let Some((_, is_horizontal)) = self.hovered_divider { if is_horizontal { @@ -7030,8 +7662,8 @@ impl eframe::App for EditorApp { // Event::Copy/Cut/Paste instead of regular key events, so // check_shortcuts won't see them via key_pressed(). // Skip if a pane (e.g. piano roll) already handled the clipboard event. - let mut clipboard_handled = clipboard_consumed; - if !clipboard_consumed { + let mut clipboard_handled = scratch.clipboard_consumed; + if !scratch.clipboard_consumed { for event in &i.events { match event { egui::Event::Copy => { @@ -7194,6 +7826,175 @@ struct RenderContext<'a> { pane_instances: &'a mut HashMap, } +/// Per-frame scratch state used to build the `RenderContext`. These values live only +/// for the duration of one `update()` frame: panes write into them during rendering +/// and the post-render drain consumes them. Grouping them lets [`EditorApp::build_frame`] +/// hand back a context that borrows both `self` and this scratch, so the desktop and +/// mobile render paths share one construction site for the ~80-field `SharedPaneState`. +#[derive(Default)] +struct FrameScratch { + fallback_pane_priority: Option, + pending_handlers: Vec, + pending_enter_clip: Option<(Uuid, Uuid, Uuid)>, + pending_exit_clip: bool, + pending_exit_to_depth: Option, + pending_actions: Vec>, + pending_menu_actions: Vec, + effect_thumbnail_requests: Vec, + clipboard_consumed: bool, + empty_thumbnail_cache: HashMap>, + #[cfg(debug_assertions)] + synthetic_input: Option, +} + +/// Everything `update()` hands to a per-frame layout renderer: the [`RenderContext`] +/// plus the layout-editing borrows that ride alongside it (these are passed to +/// `render_layout_node` as separate `&mut` args and are disjoint from `SharedPaneState`). +/// Built by [`EditorApp::build_frame`] from a single `&mut self`, which is what lets the +/// huge `SharedPaneState` literal have one home shared by the desktop and mobile paths. +struct FrameBundle<'a> { + rc: RenderContext<'a>, + current_layout: &'a mut LayoutNode, + drag_state: &'a mut DragState, + hovered_divider: &'a mut Option<(NodePath, bool)>, + selected_pane: &'a mut Option, + split_preview_mode: &'a mut SplitPreviewMode, + mobile_state: &'a mut mobile::MobileState, +} + +impl EditorApp { + /// Whether the mobile shell is active this frame (runtime override wins over the env flag). + fn mobile_active(&self) -> bool { + self.mobile_ui_override.unwrap_or(self.mobile_ui) + } + + /// Build the per-frame [`FrameBundle`]. The returned bundle borrows `self` and + /// `scratch` for `'a`; it must be dropped before the post-render drain touches + /// `self`/`scratch` again. This is the single source of truth for `SharedPaneState`. + fn build_frame<'a>(&'a mut self, scratch: &'a mut FrameScratch) -> FrameBundle<'a> { + let is_mobile = self.mobile_active(); + FrameBundle { + rc: RenderContext { + shared: panes::SharedPaneState { + is_mobile, + is_portrait: true, // set by the mobile shell from the available rect + keyboard_octave: &mut self.keyboard_octave, + keyboard_pan_x: &mut self.keyboard_pan_x, + instrument_show_roll: false, + open_instrument_browser: &mut self.open_instrument_browser, + pending_record_toggle: &mut self.pending_record_toggle, + mobile_context_menu: &mut self.mobile_context_menu, + container_path: self.current_file_path.clone(), + onion: { + // Onion skinning is disabled during playback. + let mut o = self.onion_skin; + o.enabled = o.enabled && !self.is_playing; + o + }, + onion_skin: &mut self.onion_skin, + tool_icon_cache: &mut self.tool_icon_cache, + icon_cache: &mut self.icon_cache, + selected_tool: &mut self.selected_tool, + fill_color: &mut self.fill_color, + stroke_color: &mut self.stroke_color, + active_color_mode: &mut self.active_color_mode, + pending_view_action: &mut self.pending_view_action, + fallback_pane_priority: &mut scratch.fallback_pane_priority, + pending_handlers: &mut scratch.pending_handlers, + theme: &self.theme, + action_executor: &mut self.action_executor, + selection: &mut self.selection, + focus: &mut self.focus, + editing_clip_id: self.editing_context.current_clip_id(), + editing_instance_id: self.editing_context.current_instance_id(), + editing_parent_layer_id: self.editing_context.current_parent_layer_id(), + editing_clip_path: self.editing_context.clip_path(), + pending_enter_clip: &mut scratch.pending_enter_clip, + pending_exit_clip: &mut scratch.pending_exit_clip, + pending_exit_to_depth: &mut scratch.pending_exit_to_depth, + active_layer_id: &mut self.active_layer_id, + tool_state: &mut self.tool_state, + pending_actions: &mut scratch.pending_actions, + draw_simplify_mode: &mut self.draw_simplify_mode, + rdp_tolerance: &mut self.rdp_tolerance, + schneider_max_error: &mut self.schneider_max_error, + raster_settings: &mut self.raster_settings, + audio_controller: self.audio_controller.as_ref(), + clip_snapshot: self.audio_controller.as_ref().map(|arc| { + arc.lock().unwrap().clip_snapshot() + }), + audio_input_opener: &mut self.audio_input, + audio_input_stream: &mut self.audio_input_stream, + audio_buffer_size: self.audio_buffer_size, + video_manager: &self.video_manager, + playback_time: &mut self.playback_time, + is_playing: &mut self.is_playing, + is_recording: &mut self.is_recording, + metronome_enabled: &mut self.metronome_enabled, + count_in_enabled: &mut self.count_in_enabled, + recording_clips: &mut self.recording_clips, + recording_start_time: &mut self.recording_start_time, + recording_layer_ids: &mut self.recording_layer_ids, + dragging_asset: &mut self.dragging_asset, + stroke_width: &mut self.stroke_width, + fill_enabled: &mut self.fill_enabled, + snap_enabled: &mut self.snap_enabled, + paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, + polygon_sides: &mut self.polygon_sides, + layer_to_track_map: &self.layer_to_track_map, + clip_instance_to_backend_map: &self.clip_instance_to_backend_map, + midi_event_cache: &mut self.midi_event_cache, + audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, + raw_audio_cache: &self.raw_audio_cache, + waveform_gpu_dirty: &mut self.waveform_gpu_dirty, + waveform_minmax_pools: &self.waveform_minmax_pools, + raster_fault_requests: &self.raster_fault_requests, + effect_to_load: &mut self.effect_to_load, + effect_thumbnail_requests: &mut scratch.effect_thumbnail_requests, + effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() + .map(|g| g.thumbnail_cache()) + .unwrap_or(&scratch.empty_thumbnail_cache), + effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, + webcam_frame: self.webcam_frame.clone(), + webcam_record_command: &mut self.webcam_record_command, + target_format: self.target_format, + pending_menu_actions: &mut scratch.pending_menu_actions, + clipboard_manager: &mut self.clipboard_manager, + input_level: self.input_level, + output_level: self.output_level, + track_levels: &self.track_levels, + track_to_layer_map: &self.track_to_layer_map, + waveform_stereo: self.config.waveform_stereo, + project_generation: &mut self.project_generation, + graph_topology_generation: &mut self.graph_topology_generation, + script_to_edit: &mut self.script_to_edit, + script_saved: &mut self.script_saved, + pending_region_cut_base: &mut self.pending_region_cut_base, + region_select_mode: &mut self.region_select_mode, + lasso_mode: &mut self.lasso_mode, + pending_graph_loads: &self.pending_graph_loads, + clipboard_consumed: &mut scratch.clipboard_consumed, + keymap: &self.keymap, + pending_node_group: &mut self.pending_node_group, + pending_node_ungroup: &mut self.pending_node_ungroup, + #[cfg(debug_assertions)] + test_mode: &mut self.test_mode, + #[cfg(debug_assertions)] + synthetic_input: &mut scratch.synthetic_input, + brush_preview_pixels: &self.brush_preview_pixels, + }, + pane_instances: &mut self.pane_instances, + }, + current_layout: &mut self.current_layout, + drag_state: &mut self.drag_state, + hovered_divider: &mut self.hovered_divider, + selected_pane: &mut self.selected_pane, + split_preview_mode: &mut self.split_preview_mode, + mobile_state: &mut self.mobile_state, + } + } +} + /// Find which GroupLayer (if any) contains the given layer as a direct child. /// Returns None if the layer is at document root level. fn find_parent_group_id(doc: &lightningbeam_core::document::Document, layer_id: &uuid::Uuid) -> Option { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup b/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup deleted file mode 100644 index 3ffdea7..0000000 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup +++ /dev/null @@ -1,1568 +0,0 @@ -use eframe::egui; -use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; -use lightningbeam_core::pane::PaneType; -use lightningbeam_core::tool::Tool; -use std::collections::HashMap; -use clap::Parser; -use uuid::Uuid; - -mod panes; -use panes::{PaneInstance, PaneRenderer, SharedPaneState}; - -mod menu; -use menu::{MenuAction, MenuSystem}; - -mod theme; -use theme::{Theme, ThemeMode}; - -/// Lightningbeam Editor - Animation and video editing software -#[derive(Parser, Debug)] -#[command(name = "Lightningbeam Editor")] -#[command(author, version, about, long_about = None)] -struct Args { - /// Use light theme - #[arg(long, conflicts_with = "dark")] - light: bool, - - /// Use dark theme - #[arg(long, conflicts_with = "light")] - dark: bool, -} - -fn main() -> eframe::Result { - println!("🚀 Starting Lightningbeam Editor..."); - - // Parse command line arguments - let args = Args::parse(); - - // Determine theme mode from arguments - let theme_mode = if args.light { - ThemeMode::Light - } else if args.dark { - ThemeMode::Dark - } else { - ThemeMode::System - }; - - // Load theme - let mut theme = Theme::load_default().expect("Failed to load theme"); - theme.set_mode(theme_mode); - println!("✅ Loaded theme with {} selectors (mode: {:?})", theme.len(), theme_mode); - - // Debug: print theme info - theme.debug_print(); - - // Load layouts from JSON - let layouts = load_layouts(); - println!("✅ Loaded {} layouts", layouts.len()); - for layout in &layouts { - println!(" - {}: {}", layout.name, layout.description); - } - - // Initialize native menus for macOS (app-wide, doesn't need window) - #[cfg(target_os = "macos")] - { - if let Ok(menu_system) = MenuSystem::new() { - menu_system.init_for_macos(); - println!("✅ Native macOS menus initialized"); - } - } - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1920.0, 1080.0]) - .with_title("Lightningbeam Editor"), - ..Default::default() - }; - - eframe::run_native( - "Lightningbeam Editor", - options, - Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts, theme)))), - ) -} - -fn load_layouts() -> Vec { - let json = include_str!("../assets/layouts.json"); - serde_json::from_str(json).expect("Failed to parse layouts.json") -} - -/// Path to a node in the layout tree (indices of children) -type NodePath = Vec; - -#[derive(Default)] -struct DragState { - is_dragging: bool, - node_path: NodePath, - is_horizontal: bool, -} - -/// Action to perform on the layout tree -enum LayoutAction { - SplitHorizontal(NodePath, f32), // path, percent - SplitVertical(NodePath, f32), // path, percent - RemoveSplit(NodePath), - EnterSplitPreviewHorizontal, - EnterSplitPreviewVertical, -} - -#[derive(Default)] -enum SplitPreviewMode { - #[default] - None, - Active { - is_horizontal: bool, - hovered_pane: Option, - split_percent: f32, - }, -} - -/// Icon cache for pane type icons -struct IconCache { - icons: HashMap, - assets_path: std::path::PathBuf, -} - -impl IconCache { - fn new() -> Self { - let assets_path = std::path::PathBuf::from( - std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) - ).join("Dev/Lightningbeam-2/src/assets"); - - Self { - icons: HashMap::new(), - assets_path, - } - } - - fn get_or_load(&mut self, pane_type: PaneType) -> Option<&egui_extras::RetainedImage> { - if !self.icons.contains_key(&pane_type) { - // Load and cache the icon - let icon_path = self.assets_path.join(pane_type.icon_file()); - if let Ok(image) = egui_extras::RetainedImage::from_svg_bytes( - pane_type.icon_file(), - &std::fs::read(&icon_path).unwrap_or_default(), - ) { - self.icons.insert(pane_type, image); - } - } - self.icons.get(&pane_type) - } -} - -/// Icon cache for tool icons -struct ToolIconCache { - icons: HashMap, - assets_path: std::path::PathBuf, -} - -impl ToolIconCache { - fn new() -> Self { - let assets_path = std::path::PathBuf::from( - std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) - ).join("Dev/Lightningbeam-2/src/assets"); - - Self { - icons: HashMap::new(), - assets_path, - } - } - - fn get_or_load(&mut self, tool: Tool, ctx: &egui::Context) -> Option<&egui::TextureHandle> { - if !self.icons.contains_key(&tool) { - // Load SVG and rasterize at high resolution using resvg - let icon_path = self.assets_path.join(tool.icon_file()); - if let Ok(svg_data) = std::fs::read(&icon_path) { - // Rasterize at 3x size for crisp display (180px for 60px display) - let render_size = 180; - - if let Ok(tree) = resvg::usvg::Tree::from_data(&svg_data, &resvg::usvg::Options::default()) { - let pixmap_size = tree.size().to_int_size(); - let scale_x = render_size as f32 / pixmap_size.width() as f32; - let scale_y = render_size as f32 / pixmap_size.height() as f32; - let scale = scale_x.min(scale_y); - - let final_size = resvg::usvg::Size::from_wh( - pixmap_size.width() as f32 * scale, - pixmap_size.height() as f32 * scale, - ).unwrap_or(resvg::usvg::Size::from_wh(render_size as f32, render_size as f32).unwrap()); - - if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new( - final_size.width() as u32, - final_size.height() as u32, - ) { - let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); - resvg::render(&tree, transform, &mut pixmap.as_mut()); - - // Convert RGBA8 to egui ColorImage - let rgba_data = pixmap.data(); - let size = [pixmap.width() as usize, pixmap.height() as usize]; - let color_image = egui::ColorImage::from_rgba_unmultiplied(size, rgba_data); - - // Upload to GPU - let texture = ctx.load_texture( - tool.icon_file(), - color_image, - egui::TextureOptions::LINEAR, - ); - self.icons.insert(tool, texture); - } - } - } - } - self.icons.get(&tool) - } -} - -struct EditorApp { - layouts: Vec, - current_layout_index: usize, - current_layout: LayoutNode, // Mutable copy for editing - drag_state: DragState, - hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal) - selected_pane: Option, // Currently selected pane for editing - split_preview_mode: SplitPreviewMode, - icon_cache: IconCache, - tool_icon_cache: ToolIconCache, - selected_tool: Tool, // Currently selected drawing tool - fill_color: egui::Color32, // Fill color for drawing - stroke_color: egui::Color32, // Stroke color for drawing - pane_instances: HashMap, // Pane instances per path - menu_system: Option, // Native menu system for event checking - pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane - theme: Theme, // Theme system for colors and dimensions - action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo - active_layer_id: Option, // Currently active layer for editing - selection: lightningbeam_core::selection::Selection, // Current selection state - tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state -} - -impl EditorApp { - fn new(cc: &eframe::CreationContext, layouts: Vec, theme: Theme) -> Self { - let current_layout = layouts[0].layout.clone(); - - // Initialize native menu system - let menu_system = MenuSystem::new().ok(); - - // Create default document with a simple test scene - let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) - .with_duration(10.0) - .with_framerate(60.0); - - // Add a test layer with a simple shape to visualize - use lightningbeam_core::layer::{AnyLayer, VectorLayer}; - use lightningbeam_core::object::Object; - use lightningbeam_core::shape::{Shape, ShapeColor}; - use vello::kurbo::{Circle, Shape as KurboShape}; - - let circle = Circle::new((200.0, 150.0), 50.0); - let path = circle.to_path(0.1); - let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); - let object = Object::new(shape.id); - - let mut vector_layer = VectorLayer::new("Layer 1"); - vector_layer.add_shape(shape); - vector_layer.add_object(object); - let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); - - // Wrap document in ActionExecutor - let action_executor = lightningbeam_core::action::ActionExecutor::new(document); - - Self { - layouts, - current_layout_index: 0, - current_layout, - drag_state: DragState::default(), - hovered_divider: None, - selected_pane: None, - split_preview_mode: SplitPreviewMode::default(), - icon_cache: IconCache::new(), - tool_icon_cache: ToolIconCache::new(), - selected_tool: Tool::Select, // Default tool - fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill - stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke - pane_instances: HashMap::new(), // Initialize empty, panes created on-demand - menu_system, - pending_view_action: None, - theme, - action_executor, - active_layer_id: Some(layer_id), - selection: lightningbeam_core::selection::Selection::new(), - tool_state: lightningbeam_core::tool::ToolState::default(), - } - } - - fn switch_layout(&mut self, index: usize) { - self.current_layout_index = index; - self.current_layout = self.layouts[index].layout.clone(); - } - - fn current_layout_def(&self) -> &LayoutDefinition { - &self.layouts[self.current_layout_index] - } - - fn apply_layout_action(&mut self, action: LayoutAction) { - match action { - LayoutAction::SplitHorizontal(path, percent) => { - split_node(&mut self.current_layout, &path, true, percent); - } - LayoutAction::SplitVertical(path, percent) => { - split_node(&mut self.current_layout, &path, false, percent); - } - LayoutAction::RemoveSplit(path) => { - remove_split(&mut self.current_layout, &path); - } - LayoutAction::EnterSplitPreviewHorizontal => { - self.split_preview_mode = SplitPreviewMode::Active { - is_horizontal: false, // horizontal divider = vertical grid (top/bottom) - hovered_pane: None, - split_percent: 50.0, - }; - } - LayoutAction::EnterSplitPreviewVertical => { - self.split_preview_mode = SplitPreviewMode::Active { - is_horizontal: true, // vertical divider = horizontal grid (left/right) - hovered_pane: None, - split_percent: 50.0, - }; - } - } - } - - fn handle_menu_action(&mut self, action: MenuAction) { - match action { - // File menu - MenuAction::NewFile => { - println!("Menu: New File"); - // TODO: Implement new file - } - MenuAction::NewWindow => { - println!("Menu: New Window"); - // TODO: Implement new window - } - MenuAction::Save => { - println!("Menu: Save"); - // TODO: Implement save - } - MenuAction::SaveAs => { - println!("Menu: Save As"); - // TODO: Implement save as - } - MenuAction::OpenFile => { - println!("Menu: Open File"); - // TODO: Implement open file - } - MenuAction::Revert => { - println!("Menu: Revert"); - // TODO: Implement revert - } - MenuAction::Import => { - println!("Menu: Import"); - // TODO: Implement import - } - MenuAction::Export => { - println!("Menu: Export"); - // TODO: Implement export - } - MenuAction::Quit => { - println!("Menu: Quit"); - std::process::exit(0); - } - - // Edit menu - MenuAction::Undo => { - if self.action_executor.undo() { - println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); - } else { - println!("Nothing to undo"); - } - } - MenuAction::Redo => { - if self.action_executor.redo() { - println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); - } else { - println!("Nothing to redo"); - } - } - MenuAction::Cut => { - println!("Menu: Cut"); - // TODO: Implement cut - } - MenuAction::Copy => { - println!("Menu: Copy"); - // TODO: Implement copy - } - MenuAction::Paste => { - println!("Menu: Paste"); - // TODO: Implement paste - } - MenuAction::Delete => { - println!("Menu: Delete"); - // TODO: Implement delete - } - MenuAction::SelectAll => { - println!("Menu: Select All"); - // TODO: Implement select all - } - MenuAction::SelectNone => { - println!("Menu: Select None"); - // TODO: Implement select none - } - MenuAction::Preferences => { - println!("Menu: Preferences"); - // TODO: Implement preferences dialog - } - - // Modify menu - MenuAction::Group => { - println!("Menu: Group"); - // TODO: Implement group - } - MenuAction::SendToBack => { - println!("Menu: Send to Back"); - // TODO: Implement send to back - } - MenuAction::BringToFront => { - println!("Menu: Bring to Front"); - // TODO: Implement bring to front - } - - // Layer menu - MenuAction::AddLayer => { - println!("Menu: Add Layer"); - // TODO: Implement add layer - } - MenuAction::AddVideoLayer => { - println!("Menu: Add Video Layer"); - // TODO: Implement add video layer - } - MenuAction::AddAudioTrack => { - println!("Menu: Add Audio Track"); - // TODO: Implement add audio track - } - MenuAction::AddMidiTrack => { - println!("Menu: Add MIDI Track"); - // TODO: Implement add MIDI track - } - MenuAction::DeleteLayer => { - println!("Menu: Delete Layer"); - // TODO: Implement delete layer - } - MenuAction::ToggleLayerVisibility => { - println!("Menu: Toggle Layer Visibility"); - // TODO: Implement toggle layer visibility - } - - // Timeline menu - MenuAction::NewKeyframe => { - println!("Menu: New Keyframe"); - // TODO: Implement new keyframe - } - MenuAction::NewBlankKeyframe => { - println!("Menu: New Blank Keyframe"); - // TODO: Implement new blank keyframe - } - MenuAction::DeleteFrame => { - println!("Menu: Delete Frame"); - // TODO: Implement delete frame - } - MenuAction::DuplicateKeyframe => { - println!("Menu: Duplicate Keyframe"); - // TODO: Implement duplicate keyframe - } - MenuAction::AddKeyframeAtPlayhead => { - println!("Menu: Add Keyframe at Playhead"); - // TODO: Implement add keyframe at playhead - } - MenuAction::AddMotionTween => { - println!("Menu: Add Motion Tween"); - // TODO: Implement add motion tween - } - MenuAction::AddShapeTween => { - println!("Menu: Add Shape Tween"); - // TODO: Implement add shape tween - } - MenuAction::ReturnToStart => { - println!("Menu: Return to Start"); - // TODO: Implement return to start - } - MenuAction::Play => { - println!("Menu: Play"); - // TODO: Implement play/pause - } - - // View menu - MenuAction::ZoomIn => { - self.pending_view_action = Some(MenuAction::ZoomIn); - } - MenuAction::ZoomOut => { - self.pending_view_action = Some(MenuAction::ZoomOut); - } - MenuAction::ActualSize => { - self.pending_view_action = Some(MenuAction::ActualSize); - } - MenuAction::RecenterView => { - self.pending_view_action = Some(MenuAction::RecenterView); - } - MenuAction::NextLayout => { - println!("Menu: Next Layout"); - let next_index = (self.current_layout_index + 1) % self.layouts.len(); - self.switch_layout(next_index); - } - MenuAction::PreviousLayout => { - println!("Menu: Previous Layout"); - let prev_index = if self.current_layout_index == 0 { - self.layouts.len() - 1 - } else { - self.current_layout_index - 1 - }; - self.switch_layout(prev_index); - } - MenuAction::SwitchLayout(index) => { - println!("Menu: Switch to Layout {}", index); - if index < self.layouts.len() { - self.switch_layout(index); - } - } - - // Help menu - MenuAction::About => { - println!("Menu: About"); - // TODO: Implement about dialog - } - - // Lightningbeam menu (macOS) - MenuAction::Settings => { - println!("Menu: Settings"); - // TODO: Implement settings - } - MenuAction::CloseWindow => { - println!("Menu: Close Window"); - // TODO: Implement close window - } - } - } -} - -impl eframe::App for EditorApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Disable egui's built-in Ctrl+Plus/Minus zoom behavior - // We handle zoom ourselves for the Stage pane - ctx.options_mut(|o| { - o.zoom_with_keyboard = false; - }); - - // Check for native menu events (macOS) - if let Some(menu_system) = &self.menu_system { - if let Some(action) = menu_system.check_events() { - self.handle_menu_action(action); - } - } - - // Check keyboard shortcuts (works on all platforms) - ctx.input(|i| { - if let Some(action) = MenuSystem::check_shortcuts(i) { - self.handle_menu_action(action); - } - }); - - // Top menu bar (egui-rendered on all platforms) - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { - if let Some(action) = MenuSystem::render_egui_menu_bar(ui) { - self.handle_menu_action(action); - } - }); - - // Main pane area - let mut layout_action: Option = None; - egui::CentralPanel::default().show(ctx, |ui| { - let available_rect = ui.available_rect_before_wrap(); - - // Reset hovered divider each frame - self.hovered_divider = None; - - // Track fallback pane priority for view actions (reset each frame) - let mut fallback_pane_priority: Option = None; - - // Registry for view action handlers (two-phase dispatch) - let mut pending_handlers: Vec = Vec::new(); - - render_layout_node( - ui, - &mut self.current_layout, - available_rect, - &mut self.drag_state, - &mut self.hovered_divider, - &mut self.selected_pane, - &mut layout_action, - &mut self.split_preview_mode, - &mut self.icon_cache, - &mut self.tool_icon_cache, - &mut self.selected_tool, - &mut self.fill_color, - &mut self.stroke_color, - &mut self.pane_instances, - &Vec::new(), // Root path - &mut self.pending_view_action, - &mut fallback_pane_priority, - &mut pending_handlers, - &self.theme, - self.action_executor.document(), - &mut self.selection, - &self.active_layer_id, - &mut self.tool_state, - ); - - // Execute action on the best handler (two-phase dispatch) - if let Some(action) = &self.pending_view_action { - if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { - // Look up the pane instance and execute the action - if let Some(pane_instance) = self.pane_instances.get_mut(&best_handler.pane_path) { - match pane_instance { - panes::PaneInstance::Stage(stage_pane) => { - stage_pane.execute_view_action(action, best_handler.zoom_center); - } - _ => {} // Other pane types don't handle view actions yet - } - } - } - // Clear the pending action after execution - self.pending_view_action = None; - } - - // Set cursor based on hover state - if let Some((_, is_horizontal)) = self.hovered_divider { - if is_horizontal { - ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } else { - ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); - } - } - }); - - // Handle ESC key and click-outside to cancel split preview - if let SplitPreviewMode::Active { hovered_pane, .. } = &self.split_preview_mode { - let should_cancel = ctx.input(|i| { - // Cancel on ESC key - if i.key_pressed(egui::Key::Escape) { - return true; - } - // Cancel on click outside any pane - if i.pointer.primary_clicked() && hovered_pane.is_none() { - return true; - } - false - }); - - if should_cancel { - self.split_preview_mode = SplitPreviewMode::None; - } - } - - // Apply layout action after rendering to avoid borrow issues - if let Some(action) = layout_action { - self.apply_layout_action(action); - } - } - -} - -/// Recursively render a layout node with drag support -fn render_layout_node( - ui: &mut egui::Ui, - node: &mut LayoutNode, - rect: egui::Rect, - drag_state: &mut DragState, - hovered_divider: &mut Option<(NodePath, bool)>, - selected_pane: &mut Option, - layout_action: &mut Option, - split_preview_mode: &mut SplitPreviewMode, - icon_cache: &mut IconCache, - tool_icon_cache: &mut ToolIconCache, - selected_tool: &mut Tool, - fill_color: &mut egui::Color32, - stroke_color: &mut egui::Color32, - pane_instances: &mut HashMap, - path: &NodePath, - pending_view_action: &mut Option, - fallback_pane_priority: &mut Option, - pending_handlers: &mut Vec, - theme: &Theme, - document: &lightningbeam_core::document::Document, - selection: &mut lightningbeam_core::selection::Selection, - active_layer_id: &Option, - tool_state: &mut lightningbeam_core::tool::ToolState, -) { - match node { - LayoutNode::Pane { name } => { - render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document, selection, active_layer_id); - } - LayoutNode::HorizontalGrid { percent, children } => { - // Handle dragging - if drag_state.is_dragging && drag_state.node_path == *path { - if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { - // Calculate new percentage based on pointer position - let new_percent = ((pointer_pos.x - rect.left()) / rect.width() * 100.0) - .clamp(10.0, 90.0); // Clamp to prevent too small panes - *percent = new_percent; - } - } - - // Split horizontally (left | right) - let split_x = rect.left() + (rect.width() * *percent / 100.0); - - let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(split_x, rect.max.y)); - - let right_rect = - egui::Rect::from_min_max(egui::pos2(split_x, rect.min.y), rect.max); - - // Render children - let mut left_path = path.clone(); - left_path.push(0); - render_layout_node( - ui, - &mut children[0], - left_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &left_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - let mut right_path = path.clone(); - right_path.push(1); - render_layout_node( - ui, - &mut children[1], - right_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &right_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - // Draw divider with interaction - let divider_width = 8.0; - let divider_rect = egui::Rect::from_min_max( - egui::pos2(split_x - divider_width / 2.0, rect.min.y), - egui::pos2(split_x + divider_width / 2.0, rect.max.y), - ); - - let divider_id = ui.id().with(("divider", path)); - let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); - - // Check if pointer is over divider - if response.hovered() { - *hovered_divider = Some((path.clone(), true)); - } - - // Handle drag start - if response.drag_started() { - drag_state.is_dragging = true; - drag_state.node_path = path.clone(); - drag_state.is_horizontal = true; - } - - // Handle drag end - if response.drag_stopped() { - drag_state.is_dragging = false; - } - - // Context menu on right-click - response.context_menu(|ui| { - ui.set_min_width(180.0); - - if ui.button("Split Horizontal ->").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); - ui.close_menu(); - } - - if ui.button("Split Vertical |").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); - ui.close_menu(); - } - - ui.separator(); - - if ui.button("< Join Left").clicked() { - let mut path_keep_right = path.clone(); - path_keep_right.push(1); // Remove left, keep right child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_right)); - ui.close_menu(); - } - - if ui.button("Join Right >").clicked() { - let mut path_keep_left = path.clone(); - path_keep_left.push(0); // Remove right, keep left child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_left)); - ui.close_menu(); - } - - }); - - // Visual feedback - let divider_color = if response.hovered() || response.dragged() { - egui::Color32::from_gray(120) - } else { - egui::Color32::from_gray(60) - }; - - ui.painter().vline( - split_x, - rect.y_range(), - egui::Stroke::new(2.0, divider_color), - ); - } - LayoutNode::VerticalGrid { percent, children } => { - // Handle dragging - if drag_state.is_dragging && drag_state.node_path == *path { - if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { - // Calculate new percentage based on pointer position - let new_percent = ((pointer_pos.y - rect.top()) / rect.height() * 100.0) - .clamp(10.0, 90.0); // Clamp to prevent too small panes - *percent = new_percent; - } - } - - // Split vertically (top / bottom) - let split_y = rect.top() + (rect.height() * *percent / 100.0); - - let top_rect = egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y)); - - let bottom_rect = - egui::Rect::from_min_max(egui::pos2(rect.min.x, split_y), rect.max); - - // Render children - let mut top_path = path.clone(); - top_path.push(0); - render_layout_node( - ui, - &mut children[0], - top_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &top_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - let mut bottom_path = path.clone(); - bottom_path.push(1); - render_layout_node( - ui, - &mut children[1], - bottom_rect, - drag_state, - hovered_divider, - selected_pane, - layout_action, - split_preview_mode, - icon_cache, - tool_icon_cache, - selected_tool, - fill_color, - stroke_color, - pane_instances, - &bottom_path, - pending_view_action, - fallback_pane_priority, - pending_handlers, - theme, - document, - selection, - active_layer_id, - ); - - // Draw divider with interaction - let divider_height = 8.0; - let divider_rect = egui::Rect::from_min_max( - egui::pos2(rect.min.x, split_y - divider_height / 2.0), - egui::pos2(rect.max.x, split_y + divider_height / 2.0), - ); - - let divider_id = ui.id().with(("divider", path)); - let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); - - // Check if pointer is over divider - if response.hovered() { - *hovered_divider = Some((path.clone(), false)); - } - - // Handle drag start - if response.drag_started() { - drag_state.is_dragging = true; - drag_state.node_path = path.clone(); - drag_state.is_horizontal = false; - } - - // Handle drag end - if response.drag_stopped() { - drag_state.is_dragging = false; - } - - // Context menu on right-click - response.context_menu(|ui| { - ui.set_min_width(180.0); - - if ui.button("Split Horizontal ->").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); - ui.close_menu(); - } - - if ui.button("Split Vertical |").clicked() { - *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); - ui.close_menu(); - } - - ui.separator(); - - if ui.button("^ Join Up").clicked() { - let mut path_keep_bottom = path.clone(); - path_keep_bottom.push(1); // Remove top, keep bottom child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_bottom)); - ui.close_menu(); - } - - if ui.button("Join Down v").clicked() { - let mut path_keep_top = path.clone(); - path_keep_top.push(0); // Remove bottom, keep top child - *layout_action = Some(LayoutAction::RemoveSplit(path_keep_top)); - ui.close_menu(); - } - - }); - - // Visual feedback - let divider_color = if response.hovered() || response.dragged() { - egui::Color32::from_gray(120) - } else { - egui::Color32::from_gray(60) - }; - - ui.painter().hline( - rect.x_range(), - split_y, - egui::Stroke::new(2.0, divider_color), - ); - } - } -} - -/// Render a single pane with its content -fn render_pane( - ui: &mut egui::Ui, - pane_name: &mut String, - rect: egui::Rect, - selected_pane: &mut Option, - layout_action: &mut Option, - split_preview_mode: &mut SplitPreviewMode, - icon_cache: &mut IconCache, - tool_icon_cache: &mut ToolIconCache, - selected_tool: &mut Tool, - fill_color: &mut egui::Color32, - stroke_color: &mut egui::Color32, - pane_instances: &mut HashMap, - path: &NodePath, - pending_view_action: &mut Option, - fallback_pane_priority: &mut Option, - pending_handlers: &mut Vec, - theme: &Theme, - document: &lightningbeam_core::document::Document, - selection: &mut lightningbeam_core::selection::Selection, - active_layer_id: &Option, -) { - let pane_type = PaneType::from_name(pane_name); - - // Define header and content areas - let header_height = 40.0; - let header_rect = egui::Rect::from_min_size( - rect.min, - egui::vec2(rect.width(), header_height), - ); - let content_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(0.0, header_height), - egui::vec2(rect.width(), rect.height() - header_height), - ); - - // Draw header background - ui.painter().rect_filled( - header_rect, - 0.0, - egui::Color32::from_rgb(35, 35, 35), - ); - - // Draw content background - let bg_color = if let Some(pane_type) = pane_type { - pane_color(pane_type) - } else { - egui::Color32::from_rgb(40, 40, 40) - }; - ui.painter().rect_filled(content_rect, 0.0, bg_color); - - // Draw border around entire pane - let border_color = egui::Color32::from_gray(80); - let border_width = 1.0; - ui.painter().rect_stroke( - rect, - 0.0, - egui::Stroke::new(border_width, border_color), - ); - - // Draw header separator line - ui.painter().hline( - rect.x_range(), - header_rect.max.y, - egui::Stroke::new(1.0, egui::Color32::from_gray(50)), - ); - - // Render icon button in header (left side) - let icon_size = 24.0; - let icon_padding = 8.0; - let icon_button_rect = egui::Rect::from_min_size( - header_rect.min + egui::vec2(icon_padding, icon_padding), - egui::vec2(icon_size, icon_size), - ); - - // Draw icon button background - ui.painter().rect_filled( - icon_button_rect, - 4.0, - egui::Color32::from_rgba_premultiplied(50, 50, 50, 200), - ); - - // Load and render icon if available - if let Some(pane_type) = pane_type { - if let Some(icon) = icon_cache.get_or_load(pane_type) { - let icon_texture_id = icon.texture_id(ui.ctx()); - let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button - ui.painter().image( - icon_texture_id, - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - } - - // Make icon button interactive (show pane type menu on click) - let icon_button_id = ui.id().with(("icon_button", path)); - let icon_response = ui.interact(icon_button_rect, icon_button_id, egui::Sense::click()); - - if icon_response.hovered() { - ui.painter().rect_stroke( - icon_button_rect, - 4.0, - egui::Stroke::new(1.0, egui::Color32::from_gray(180)), - ); - } - - // Show pane type selector menu on left click - let menu_id = ui.id().with(("pane_type_menu", path)); - if icon_response.clicked() { - ui.memory_mut(|mem| mem.toggle_popup(menu_id)); - } - - egui::popup::popup_below_widget(ui, menu_id, &icon_response, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| { - ui.set_min_width(200.0); - ui.label("Select Pane Type:"); - ui.separator(); - - for pane_type_option in PaneType::all() { - // Load icon for this pane type - if let Some(icon) = icon_cache.get_or_load(*pane_type_option) { - ui.horizontal(|ui| { - // Show icon - let icon_texture_id = icon.texture_id(ui.ctx()); - let icon_size = egui::vec2(16.0, 16.0); - ui.add(egui::Image::new((icon_texture_id, icon_size))); - - // Show label with selection - if ui.selectable_label( - pane_type == Some(*pane_type_option), - pane_type_option.display_name() - ).clicked() { - *pane_name = pane_type_option.to_name().to_string(); - ui.memory_mut(|mem| mem.close_popup()); - } - }); - } else { - // Fallback if icon fails to load - if ui.selectable_label( - pane_type == Some(*pane_type_option), - pane_type_option.display_name() - ).clicked() { - *pane_name = pane_type_option.to_name().to_string(); - ui.memory_mut(|mem| mem.close_popup()); - } - } - } - }); - - // Draw pane title in header - let title_text = if let Some(pane_type) = pane_type { - pane_type.display_name() - } else { - pane_name.as_str() - }; - let title_pos = header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0, header_height / 2.0); - ui.painter().text( - title_pos, - egui::Align2::LEFT_CENTER, - title_text, - egui::FontId::proportional(14.0), - egui::Color32::from_gray(220), - ); - - // Create header controls area (positioned after title) - let title_width = 150.0; // Approximate width for title - let header_controls_rect = egui::Rect::from_min_size( - header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0 + title_width, 0.0), - egui::vec2(header_rect.width() - (icon_padding * 2.0 + icon_size + 8.0 + title_width), header_height), - ); - - // Render pane-specific header controls (if pane has them) - if let Some(pane_type) = pane_type { - // Get or create pane instance for header rendering - let needs_new_instance = pane_instances - .get(path) - .map(|instance| instance.pane_type() != pane_type) - .unwrap_or(true); - - if needs_new_instance { - pane_instances.insert(path.clone(), panes::PaneInstance::new(pane_type)); - } - - if let Some(pane_instance) = pane_instances.get_mut(path) { - let mut header_ui = ui.new_child(egui::UiBuilder::new().max_rect(header_controls_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); - let mut shared = panes::SharedPaneState { - tool_icon_cache, - icon_cache, - selected_tool, - fill_color, - stroke_color, - pending_view_action, - fallback_pane_priority, - theme, - pending_handlers, - document, - selection, - active_layer_id, - }; - pane_instance.render_header(&mut header_ui, &mut shared); - } - } - - // Make pane content clickable (use full rect for split preview interaction) - let pane_id = ui.id().with(("pane", path)); - let response = ui.interact(rect, pane_id, egui::Sense::click()); - - // Render pane-specific content using trait-based system - if let Some(pane_type) = pane_type { - // Get or create pane instance for this path - // Check if we need a new instance (either doesn't exist or type changed) - let needs_new_instance = pane_instances - .get(path) - .map(|instance| instance.pane_type() != pane_type) - .unwrap_or(true); - - if needs_new_instance { - pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); - } - - // Get the pane instance and render its content - if let Some(pane_instance) = pane_instances.get_mut(path) { - // Create shared state - let mut shared = SharedPaneState { - tool_icon_cache, - icon_cache, - selected_tool, - fill_color, - stroke_color, - pending_view_action, - fallback_pane_priority, - theme, - pending_handlers, - document, - selection, - active_layer_id, - }; - - // Render pane content (header was already rendered above) - pane_instance.render_content(ui, content_rect, path, &mut shared); - } - } else { - // Unknown pane type - draw placeholder - let content_text = "Unknown pane type"; - let text_pos = content_rect.center(); - ui.painter().text( - text_pos, - egui::Align2::CENTER_CENTER, - content_text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), - ); - } - - // Handle split preview mode (rendered AFTER pane content for proper z-ordering) - if let SplitPreviewMode::Active { - is_horizontal, - hovered_pane, - split_percent, - } = split_preview_mode - { - // Check if mouse is over this pane - if let Some(pointer_pos) = ui.input(|i| i.pointer.hover_pos()) { - if rect.contains(pointer_pos) { - // Update hovered pane - *hovered_pane = Some(path.clone()); - - // Calculate split percentage based on mouse position - *split_percent = if *is_horizontal { - ((pointer_pos.x - rect.left()) / rect.width() * 100.0).clamp(10.0, 90.0) - } else { - ((pointer_pos.y - rect.top()) / rect.height() * 100.0).clamp(10.0, 90.0) - }; - - // Render split preview overlay - let grey_overlay = egui::Color32::from_rgba_premultiplied(128, 128, 128, 30); - - if *is_horizontal { - let split_x = rect.left() + (rect.width() * *split_percent / 100.0); - - // First half - let first_rect = egui::Rect::from_min_max( - rect.min, - egui::pos2(split_x, rect.max.y), - ); - ui.painter().rect_filled(first_rect, 0.0, grey_overlay); - - // Second half - let second_rect = egui::Rect::from_min_max( - egui::pos2(split_x, rect.min.y), - rect.max, - ); - ui.painter().rect_filled(second_rect, 0.0, grey_overlay); - - // Divider line - ui.painter().vline( - split_x, - rect.y_range(), - egui::Stroke::new(2.0, egui::Color32::BLACK), - ); - } else { - let split_y = rect.top() + (rect.height() * *split_percent / 100.0); - - // First half - let first_rect = egui::Rect::from_min_max( - rect.min, - egui::pos2(rect.max.x, split_y), - ); - ui.painter().rect_filled(first_rect, 0.0, grey_overlay); - - // Second half - let second_rect = egui::Rect::from_min_max( - egui::pos2(rect.min.x, split_y), - rect.max, - ); - ui.painter().rect_filled(second_rect, 0.0, grey_overlay); - - // Divider line - ui.painter().hline( - rect.x_range(), - split_y, - egui::Stroke::new(2.0, egui::Color32::BLACK), - ); - } - - // Create a high-priority interaction for split preview (rendered last = highest priority) - let split_preview_id = ui.id().with(("split_preview", path)); - let split_response = ui.interact(rect, split_preview_id, egui::Sense::click()); - - // If clicked, perform the split - if split_response.clicked() { - if *is_horizontal { - *layout_action = Some(LayoutAction::SplitHorizontal(path.clone(), *split_percent)); - } else { - *layout_action = Some(LayoutAction::SplitVertical(path.clone(), *split_percent)); - } - // Exit preview mode - *split_preview_mode = SplitPreviewMode::None; - } - } - } - } else if response.clicked() { - *selected_pane = Some(path.clone()); - } -} - -/// Render toolbar with tool buttons -fn render_toolbar( - ui: &mut egui::Ui, - rect: egui::Rect, - tool_icon_cache: &mut ToolIconCache, - selected_tool: &mut Tool, - path: &NodePath, -) { - let button_size = 60.0; // 50% bigger (was 40.0) - let button_padding = 8.0; - let button_spacing = 4.0; - - // Calculate how many columns we can fit - let available_width = rect.width() - (button_padding * 2.0); - let columns = ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; - let columns = columns.max(1); // At least 1 column - - let mut x = rect.left() + button_padding; - let mut y = rect.top() + button_padding; - let mut col = 0; - - for tool in Tool::all() { - let button_rect = egui::Rect::from_min_size( - egui::pos2(x, y), - egui::vec2(button_size, button_size), - ); - - // Check if this is the selected tool - let is_selected = *selected_tool == *tool; - - // Button background - let bg_color = if is_selected { - egui::Color32::from_rgb(70, 100, 150) // Highlighted blue - } else { - egui::Color32::from_rgb(50, 50, 50) - }; - ui.painter().rect_filled(button_rect, 4.0, bg_color); - - // Load and render tool icon - if let Some(icon) = tool_icon_cache.get_or_load(*tool, ui.ctx()) { - let icon_rect = button_rect.shrink(8.0); // Padding inside button - ui.painter().image( - icon.id(), - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - - // Make button interactive (include path to ensure unique IDs across panes) - let button_id = ui.id().with(("tool_button", path, *tool as usize)); - let response = ui.interact(button_rect, button_id, egui::Sense::click()); - - // Check for click first - if response.clicked() { - *selected_tool = *tool; - } - - if response.hovered() { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, egui::Color32::from_gray(180)), - ); - } - - // Show tooltip with tool name and shortcut (consumes response) - response.on_hover_text(format!("{} ({})", tool.display_name(), tool.shortcut_hint())); - - // Draw selection border - if is_selected { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)), - ); - } - - // Move to next position in grid - col += 1; - if col >= columns { - // Move to next row - col = 0; - x = rect.left() + button_padding; - y += button_size + button_spacing; - } else { - // Move to next column - x += button_size + button_spacing; - } - } -} - -/// Get a color for each pane type for visualization -fn pane_color(pane_type: PaneType) -> egui::Color32 { - match pane_type { - PaneType::Stage => egui::Color32::from_rgb(30, 40, 50), - PaneType::Timeline => egui::Color32::from_rgb(40, 30, 50), - PaneType::Toolbar => egui::Color32::from_rgb(50, 40, 30), - PaneType::Infopanel => egui::Color32::from_rgb(30, 50, 40), - PaneType::Outliner => egui::Color32::from_rgb(40, 50, 30), - PaneType::PianoRoll => egui::Color32::from_rgb(55, 35, 45), - PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), - PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), - } -} - -/// Split a pane node into a horizontal or vertical grid with two copies of the pane -fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) { - if path.is_empty() { - // Split the root node - if let LayoutNode::Pane { name } = root { - let pane_name = name.clone(); - let new_node = if is_horizontal { - LayoutNode::HorizontalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - } else { - LayoutNode::VerticalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - }; - *root = new_node; - } - } else { - // Navigate to parent and split the child - navigate_to_node(root, &path[..path.len() - 1], &mut |node| { - let child_index = path[path.len() - 1]; - match node { - LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } => { - if let LayoutNode::Pane { name } = &*children[child_index] { - let pane_name = name.clone(); - let new_node = if is_horizontal { - LayoutNode::HorizontalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - } else { - LayoutNode::VerticalGrid { - percent, - children: [ - Box::new(LayoutNode::Pane { name: pane_name.clone() }), - Box::new(LayoutNode::Pane { name: pane_name }), - ], - } - }; - children[child_index] = Box::new(new_node); - } - } - _ => {} - } - }); - } -} - -/// Remove a split by replacing it with one of its children -/// The path includes the split node path plus which child to keep (0 or 1 as last element) -fn remove_split(root: &mut LayoutNode, path: &NodePath) { - if path.is_empty() { - return; // Can't remove if path is empty - } - - // Last element indicates which child to keep (0 or 1) - let child_to_keep = path[path.len() - 1]; - - // Path to the split node is everything except the last element - let split_path = &path[..path.len() - 1]; - - if split_path.is_empty() { - // Removing root split - replace root with the chosen child - if let LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } = root - { - *root = (*children[child_to_keep]).clone(); - } - } else { - // Navigate to parent of the split node and replace it - let parent_path = &split_path[..split_path.len() - 1]; - let split_index = split_path[split_path.len() - 1]; - - navigate_to_node(root, parent_path, &mut |node| { - match node { - LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } => { - // Get the split node's chosen child - if let LayoutNode::HorizontalGrid { children: split_children, .. } - | LayoutNode::VerticalGrid { children: split_children, .. } = - &*children[split_index] - { - // Replace the split node with the chosen child - children[split_index] = split_children[child_to_keep].clone(); - } - } - _ => {} - } - }); - } -} - -/// Navigate to a node at the given path and apply a function to it -fn navigate_to_node(node: &mut LayoutNode, path: &[usize], f: &mut F) -where - F: FnMut(&mut LayoutNode), -{ - if path.is_empty() { - f(node); - } else { - match node { - LayoutNode::HorizontalGrid { children, .. } - | LayoutNode::VerticalGrid { children, .. } => { - navigate_to_node(&mut children[path[0]], &path[1..], f); - } - _ => {} - } - } -} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs new file mode 100644 index 0000000..979abd5 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -0,0 +1,83 @@ +//! Lucide icon font (ISC license — see `assets/fonts/LICENSE-lucide.txt`). +#![allow(dead_code)] // a forward-looking icon palette; not all are wired up yet +//! +//! The font is embedded and registered as a named egui font family ("lucide"). Icons are drawn as +//! text using [`font`] for the `FontId`, with the codepoint constants below (Lucide's private-use +//! area). Run [`install`] once at startup. + +use std::sync::Arc; + +use eframe::egui; + +/// The egui font-family name the Lucide glyphs live under. +pub const FAMILY: &str = "lucide"; + +/// Embed + register the Lucide icon font. Call once with the egui context at startup. +pub fn install(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + fonts.font_data.insert( + "lucide".to_owned(), + Arc::new(egui::FontData::from_static(include_bytes!( + "../../assets/fonts/lucide.ttf" + ))), + ); + fonts + .families + .insert(egui::FontFamily::Name(FAMILY.into()), vec!["lucide".to_owned()]); + ctx.set_fonts(fonts); +} + +/// A `FontId` in the Lucide family at `size`. +pub fn font(size: f32) -> egui::FontId { + egui::FontId::new(size, egui::FontFamily::Name(FAMILY.into())) +} + +// --- Codepoints (Lucide PUA). Add more as the mobile UI needs them. --- +pub const MAXIMIZE: &str = "\u{e112}"; +pub const MINIMIZE: &str = "\u{e11a}"; +pub const ARROW_LEFT_RIGHT: &str = "\u{e24a}"; +pub const ARROW_RIGHT_FROM_LINE: &str = "\u{e458}"; // output port +pub const ARROW_RIGHT_TO_LINE: &str = "\u{e459}"; // input port +pub const GRIP_HORIZONTAL: &str = "\u{e0ea}"; +pub const CHEVRONS_UP: &str = "\u{e074}"; +pub const PLAY: &str = "\u{e13c}"; +pub const PAUSE: &str = "\u{e12e}"; +pub const SETTINGS: &str = "\u{e154}"; +pub const SEARCH: &str = "\u{e151}"; +pub const PLUS: &str = "\u{e13d}"; +pub const X: &str = "\u{e1b2}"; +pub const MENU: &str = "\u{e115}"; +// Intent-picker icons. +pub const BRUSH: &str = "\u{e1d3}"; +pub const FILM: &str = "\u{e0d0}"; +pub const MUSIC: &str = "\u{e122}"; +pub const MIC: &str = "\u{e118}"; +pub const CLAPPERBOARD: &str = "\u{e29b}"; +pub const SQUARE_DASHED: &str = "\u{e1cb}"; +pub const FOLDER_OPEN: &str = "\u{e247}"; +// Tool icons (omnibutton). +pub const MOUSE_POINTER_2: &str = "\u{e1c3}"; +pub const PENCIL: &str = "\u{e1f9}"; +pub const MOVE: &str = "\u{e121}"; +pub const VECTOR_SQUARE: &str = "\u{e67c}"; +pub const SQUARE: &str = "\u{e167}"; +pub const CIRCLE: &str = "\u{e076}"; +pub const MINUS: &str = "\u{e11c}"; +pub const HEXAGON: &str = "\u{e0f3}"; +pub const PAINT_BUCKET: &str = "\u{e2e6}"; +pub const PIPETTE: &str = "\u{e13b}"; +pub const TYPE: &str = "\u{e198}"; +pub const ERASER: &str = "\u{e28f}"; +pub const PEN_TOOL: &str = "\u{e131}"; +pub const BLEND: &str = "\u{e59c}"; +pub const WAND_SPARKLES: &str = "\u{e357}"; +pub const LASSO_SELECT: &str = "\u{e1cf}"; +pub const SCISSORS: &str = "\u{e14e}"; +// Create-menu icons. +pub const AUDIO_WAVEFORM: &str = "\u{e55b}"; +pub const PIANO: &str = "\u{e561}"; +pub const FOLDER: &str = "\u{e0d7}"; +pub const FILE_PLUS: &str = "\u{e0c9}"; +pub const COPY: &str = "\u{e09e}"; +pub const LAYERS: &str = "\u{e529}"; +pub const ELLIPSIS: &str = "\u{e0b6}"; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs new file mode 100644 index 0000000..5a3e5ca --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -0,0 +1,210 @@ +//! Selection inspector bottom sheet. When something is selected/focused it rises above the +//! transport, showing the focused object's properties by reusing `InfopanelPane` full-bleed. +//! Jump-to chips slide the stack window to the related surface; the ✕ deselects. + +use eframe::egui; +use lightningbeam_core::pane::PaneType; +use lightningbeam_core::selection::FocusSelection; + +use super::{surface, MobileState, Palette, MOBILE_NS}; +use crate::panes::{NodePath, SharedPaneState}; +use crate::RenderContext; + +const GRAB_H: f32 = 16.0; +const HEAD_H: f32 = 44.0; + +fn inspector_path() -> NodePath { + vec![MOBILE_NS, 200] +} + +/// Whether anything is selected/focused (i.e. the inspector should be shown). +pub fn is_active(shared: &SharedPaneState) -> bool { + !shared.focus.is_none() || !shared.selection.is_empty() +} + +/// A cheap content-sensitive signature of the current selection, so the shell can detect when the +/// selection *changes* (to re-show a manually-dismissed inspector for the new thing). +pub fn selection_sig(shared: &SharedPaneState) -> u64 { + let mut h: u64 = 0; + for id in shared.selection.clip_instances() { + h ^= (id.as_u128() as u64).wrapping_mul(0x9E3779B97F4A7C15); + } + for f in shared.selection.selected_fills() { + h ^= (f.idx() as u64).wrapping_mul(0xD1B54A32D192ED03); + } + for e in shared.selection.selected_edges() { + h ^= (e.idx() as u64).wrapping_mul(0xA24BAED4963EE407); + } + h +} + +/// The stack slot (see `super::STACK`) where the current selection lives, so we can tell if the +/// sheet would cover it. Geometry/selection lives on the Stage; clips/layers on the Timeline; etc. +#[allow(dead_code)] // selection→pane mapping; kept for reflow/jump heuristics +pub fn target_slot(shared: &SharedPaneState) -> usize { + match &*shared.focus { + FocusSelection::Notes { .. } => 4, // PianoRoll + FocusSelection::Nodes(_) => 5, // Node/Instrument + FocusSelection::Assets(_) => 1, // Asset Library + FocusSelection::ClipInstances(_) | FocusSelection::Layers(_) => 3, // Timeline + FocusSelection::Geometry { .. } | FocusSelection::None => 2, // Stage + } +} + +/// A short title describing what's selected. +fn title(shared: &SharedPaneState) -> String { + use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; + let plural = |n: usize, s: &str| { + if n == 1 { + format!("1 {s}") + } else { + format!("{n} {s}s") + } + }; + match &*shared.focus { + FocusSelection::Layers(ids) => { + let doc = shared.action_executor.document(); + match ids.len() { + 0 => "Layer".to_string(), + 1 => { + if let Some(l) = doc.get_layer(&ids[0]) { + let ty = match l { + AnyLayer::Vector(_) => "Vector", + AnyLayer::Audio(a) => match a.audio_layer_type { + AudioLayerType::Midi => "MIDI", + AudioLayerType::Sampled => "Audio", + }, + AnyLayer::Video(_) => "Video", + AnyLayer::Effect(_) => "Effect", + AnyLayer::Group(_) => "Group", + AnyLayer::Raster(_) => "Raster", + AnyLayer::Text(_) => "Text", + }; + format!("{} · {} layer", l.name(), ty) + } else { + "Layer".to_string() + } + } + n => plural(n, "layer"), + } + } + FocusSelection::ClipInstances(ids) => plural(ids.len(), "clip"), + FocusSelection::Notes { indices, .. } => plural(indices.len().max(1), "note"), + FocusSelection::Nodes(ids) => plural(ids.len(), "node"), + FocusSelection::Assets(ids) => plural(ids.len(), "asset"), + FocusSelection::Geometry { .. } | FocusSelection::None => "Selection".to_string(), + } +} + +/// A jump-to chip: its label and the stack window it brings into view (top, count). These reframe +/// to a related surface with the object still selected. +struct Chip { + label: &'static str, + window: (usize, usize), +} + +const CHIPS: [Chip; 2] = [ + Chip { label: "Timeline", window: (3, 1) }, // Timeline = STACK index 3 + Chip { label: "Nodes", window: (5, 1) }, // Node/Instrument = STACK index 5 +]; + +pub fn render( + ui: &mut egui::Ui, + rect: egui::Rect, + region: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, + pal: &Palette, + landscape: bool, +) { + // Panel background + border, with rounded corners on the edge that faces the content (top in + // portrait, left in landscape). + let radius = if landscape { + egui::CornerRadius { nw: 14, ne: 0, sw: 14, se: 0 } + } else { + egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 } + }; + ui.painter().rect_filled(rect, radius, pal.surface_alt); + ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + + // Grab handle — top strip (portrait, drags height) or left strip (landscape, drags width). + // `content_area` is the panel minus the grab strip. + let content_area = if landscape { + let grab = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.left() + GRAB_H, rect.bottom())); + let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag()); + let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(4.0, 34.0)); + ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line }); + if gresp.dragged() && region.width() > 1.0 { + state.inspector_width_frac = (state.inspector_width_frac - gresp.drag_delta().x / region.width()).clamp(0.2, 0.7); + } + if gresp.hovered() || gresp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + egui::Rect::from_min_max(egui::pos2(grab.right(), rect.top()), rect.max) + } else { + let grab = egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + GRAB_H)); + let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag()); + let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0)); + ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line }); + if gresp.dragged() && region.height() > 1.0 { + state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region.height()).clamp(0.2, 0.85); + } + if gresp.hovered() || gresp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + egui::Rect::from_min_max(egui::pos2(rect.left(), grab.bottom()), rect.max) + }; + + // Single header row (egui widgets — they inherit the mobile touch sizing + theme visuals): + // title on the left, [Timeline] [Nodes] jump buttons + ✕ close on the right. + let head = egui::Rect::from_min_max( + content_area.min, + egui::pos2(content_area.right(), content_area.top() + HEAD_H), + ); + let title_text = title(&rc.shared); + let mut jump: Option<(usize, usize)> = None; + let mut do_close = false; + let mut hui = ui.new_child( + egui::UiBuilder::new() + .max_rect(head.shrink2(egui::vec2(12.0, 5.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + hui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new(egui::RichText::new(super::icons::X).font(super::icons::font(18.0))).frame(false)) + .clicked() + { + do_close = true; + } + ui.add_space(4.0); + // Reversed so the visual order stays Timeline, then Nodes. + for chip in CHIPS.iter().rev() { + if ui.button(chip.label).clicked() { + jump = Some(chip.window); + } + } + // Title fills the remaining space on the left. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(title_text).color(pal.text)).truncate()); + }); + }); + if do_close { + rc.shared.selection.clear(); + *rc.shared.focus = FocusSelection::None; + } + if let Some((top, count)) = jump { + state.window_top = top; + state.window_count = count; + state.weights = [1.0, 1.0, 1.0]; + state.anim = None; + } + + // Properties content — reuse the Infopanel full-bleed. + let content = egui::Rect::from_min_max( + egui::pos2(content_area.left(), head.bottom()), + content_area.max, + ); + if content.height() > 1.0 { + surface::render_surface_fullbleed(ui, content, &inspector_path(), PaneType::Infopanel, rc); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs new file mode 100644 index 0000000..86f948a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs @@ -0,0 +1,184 @@ +//! Mobile new-file intent picker — the phone equivalent of the desktop start screen. +//! +//! Mirrors the desktop "new project" flow: each intent calls +//! [`EditorApp::create_new_project_with_focus`] (which builds the document, picks a default layer, +//! and switches to editor mode) and then sets the initial mobile stack window so the relevant panes +//! are in view. + +use eframe::egui; + +use super::{icons, Palette}; +use crate::EditorApp; + +/// One intent card. +struct Intent { + label: &'static str, + icon: &'static str, + accent: egui::Color32, + /// Argument to `create_new_project_with_focus` (0=Animation, 1=Video, 2=Music, 5=Painting). + focus: usize, + /// Initial mobile stack window: (window_top, window_count). + window: (usize, usize), + /// Initial pane weights (relative heights) for the window. + weights: [f32; 3], +} + +fn intents(pal: &Palette) -> [Intent; 6] { + let [coral, cyan, amber, pink, violet] = pal.accents; + let even = [1.0, 1.0, 1.0]; + // Compose/Record: compressed Timeline ribbon on top; the tall Piano Roll (which now hosts the + // instrument header + keyboard as one surface) fills the rest. + let music = [0.4, 1.6, 1.0]; + [ + // Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5. + Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1), weights: even }, + Intent { label: "Animate", icon: icons::FILM, accent: cyan, focus: 0, window: (2, 2), weights: even }, + Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 2), weights: music }, + Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 2), weights: music }, + Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2), weights: even }, + Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2), weights: even }, + ] +} + +pub fn render(app: &mut EditorApp, ctx: &egui::Context) { + let pal = Palette::from_theme(&app.theme, ctx); + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(pal.bg)) + .show(ctx, |ui| { + let rect = ui.available_rect_before_wrap(); + let margin = 16.0; + let left = rect.left() + margin; + let right = rect.right() - margin; + + // Header. + ui.painter().text( + egui::pos2(left, rect.top() + 22.0), + egui::Align2::LEFT_TOP, + "Start something", + egui::FontId::proportional(22.0), + pal.text, + ); + + let landscape = rect.width() > rect.height(); + let content_top = rect.top() + 62.0; + let content_bottom = rect.bottom() - margin; + let gap = 10.0; + + // Portrait: 3 rows × 2 cols of intent cards up top, recent list below. + // Landscape: 2 rows × 3 cols on the left, recent list to the right. + let (cols, rows) = if landscape { (3usize, 2usize) } else { (2usize, 3usize) }; + let (grid_rect, recent_rect) = if landscape { + let split = left + (right - left) * 0.62; + ( + egui::Rect::from_min_max(egui::pos2(left, content_top), egui::pos2(split - gap, content_bottom)), + egui::Rect::from_min_max(egui::pos2(split + gap, content_top), egui::pos2(right, content_bottom)), + ) + } else { + let grid_h = (content_bottom - content_top) * 0.66; + ( + egui::Rect::from_min_max(egui::pos2(left, content_top), egui::pos2(right, content_top + grid_h)), + egui::Rect::from_min_max(egui::pos2(left, content_top + grid_h + 14.0), egui::pos2(right, content_bottom)), + ) + }; + + // Floor at 0 so a very small window can't produce negative/inverted card rects. + let col_w = ((grid_rect.width() - (cols as f32 - 1.0) * gap) / cols as f32).max(0.0); + let card_h = ((grid_rect.height() - (rows as f32 - 1.0) * gap) / rows as f32).max(0.0); + for (i, intent) in intents(&pal).iter().enumerate() { + let col = (i % cols) as f32; + let row = (i / cols) as f32; + let cx = grid_rect.left() + col * (col_w + gap); + let cy = grid_rect.top() + row * (card_h + gap); + let card = egui::Rect::from_min_size(egui::pos2(cx, cy), egui::vec2(col_w, card_h)); + + let resp = ui.interact(card, ui.id().with(("mobile_intent", i)), egui::Sense::click()); + let p = ui.painter(); + p.rect_filled(card, 12.0, if resp.hovered() { pal.surface_alt } else { pal.surface }); + p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + p.text( + egui::pos2(card.center().x, card.top() + card.height() * 0.40), + egui::Align2::CENTER_CENTER, + intent.icon, + icons::font(30.0), + intent.accent, + ); + p.text( + egui::pos2(card.center().x, card.bottom() - 18.0), + egui::Align2::CENTER_CENTER, + intent.label, + egui::FontId::proportional(15.0), + pal.text, + ); + + if resp.clicked() { + app.create_new_project_with_focus(intent.focus); + app.mobile_state.window_top = intent.window.0; + app.mobile_state.window_count = intent.window.1; + app.mobile_state.weights = intent.weights; + } + } + + // Recent projects list (below the grid in portrait, beside it in landscape). + let rleft = recent_rect.left(); + let rright = recent_rect.right(); + ui.painter().text( + egui::pos2(rleft, recent_rect.top()), + egui::Align2::LEFT_TOP, + "Recent", + egui::FontId::proportional(13.0), + pal.text_dim, + ); + let list_top = recent_rect.top() + 22.0; + let recents = app.config.get_recent_files(); + if recents.is_empty() { + ui.painter().text( + egui::pos2(rleft, list_top + 8.0), + egui::Align2::LEFT_TOP, + "No recent projects", + egui::FontId::proportional(12.0), + pal.text_dim, + ); + } else { + let row_h = 38.0; + let row_gap = 6.0; + let avail = recent_rect.bottom() - list_top; + let max_rows = ((avail + row_gap) / (row_h + row_gap)).floor().max(0.0) as usize; + let mut chosen: Option = None; + for (j, path) in recents.iter().take(max_rows).enumerate() { + let ry = list_top + j as f32 * (row_h + row_gap); + let row_rect = + egui::Rect::from_min_max(egui::pos2(rleft, ry), egui::pos2(rright, ry + row_h)); + let resp = + ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click()); + let p = ui.painter(); + p.rect_filled(row_rect, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface }); + p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + p.text( + egui::pos2(row_rect.left() + 12.0, row_rect.center().y), + egui::Align2::LEFT_CENTER, + icons::FOLDER_OPEN, + icons::font(15.0), + pal.text_dim, + ); + let name = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + p.text( + egui::pos2(row_rect.left() + 36.0, row_rect.center().y), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(13.0), + pal.text, + ); + if resp.clicked() { + chosen = Some(path.clone()); + } + } + if let Some(path) = chosen { + app.load_from_file(path); + app.app_mode = crate::AppMode::Editor; + } + } + }); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs new file mode 100644 index 0000000..31385ea --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -0,0 +1,488 @@ +//! Mobile / phone UI shell. +//! +//! Developed on desktop behind the `LB_MOBILE_UI` env var (see [`is_mobile_env`]). +//! +//! The shell is a single **vertical sliding-window stack** of the existing panes: a fixed +//! top→bottom order, with a window of 2 or 3 consecutive panes visible at once. Dragging the +//! dividers between panes and the top/bottom screen edges grows, shrinks, or slides the window +//! (the R1–R6 state machine in `stack.rs`). A transport bar is pinned at the bottom as the floor. +//! +//! Each visible pane reuses the existing `PaneInstance::render_content` full-bleed (see +//! `surface.rs`). Per the wireframe, the Toolbar becomes the omnibutton and the Infopanel becomes +//! the selection inspector (later phases), so neither is in the stack. + +use eframe::egui; +use lightningbeam_core::pane::PaneType; + +use crate::panes::NodePath; +use crate::RenderContext; + +pub mod icons; +mod inspector; +pub mod intent; +mod omni; +mod palette; +mod stack; +mod surface; +mod topbar; +mod transport; + +use palette::Palette; + +/// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are built +/// from small child indices, so prefixing with `usize::MAX` guarantees mobile slots never alias a +/// real layout path in the shared `pane_instances` map. +pub const MOBILE_NS: usize = usize::MAX; + +const TRANSPORT_H: f32 = 60.0; +const TOPBAR_H: f32 = 40.0; + +/// Clamp a desktop dialog width to fit the current screen (with side margins). A no-op on wide +/// desktop screens (`min` keeps the desired width); on a phone-aspect window it shrinks to fit. +pub fn dialog_width(ctx: &egui::Context, desired: f32) -> f32 { + let avail = ctx.content_rect().width() - 24.0; + desired.min(avail.max(200.0)) +} + +/// Enlarge egui's spacing/sizing so the standard widgets (buttons, dropdowns, sliders, text fields) +/// in panes and dialogs are touch-friendly. Applied every frame after the theme visuals when the +/// mobile shell is active. The mobile chrome (transport, omnibutton, headers) is hand-sized already; +/// this targets the egui-widget content inside panes. +pub fn apply_touch_style(ctx: &egui::Context) { + use egui::{FontId, TextStyle}; + ctx.style_mut(|s| { + let sp = &mut s.spacing; + // Minimum touch target height for buttons/sliders/checkboxes. + sp.interact_size = egui::vec2(sp.interact_size.x.max(44.0), 38.0); + sp.button_padding = egui::vec2(12.0, 9.0); + sp.item_spacing = egui::vec2(10.0, 10.0); + sp.slider_width = 200.0; + sp.slider_rail_height = 10.0; + sp.combo_width = 200.0; + sp.combo_height = 360.0; + sp.text_edit_width = 220.0; + sp.icon_width = 24.0; + sp.icon_width_inner = 14.0; + sp.icon_spacing = 8.0; + sp.scroll.bar_width = 16.0; + sp.menu_margin = egui::Margin::same(8); + // Larger text for touch legibility. + s.text_styles.insert(TextStyle::Body, FontId::proportional(15.0)); + s.text_styles.insert(TextStyle::Button, FontId::proportional(15.0)); + s.text_styles.insert(TextStyle::Monospace, FontId::monospace(13.0)); + s.text_styles.insert(TextStyle::Small, FontId::proportional(12.0)); + }); +} + +/// Returns true if the mobile UI is requested via the `LB_MOBILE_UI` env var. +/// Any non-empty value other than "0" enables it. +pub fn is_mobile_env() -> bool { + std::env::var("LB_MOBILE_UI") + .map(|v| !v.is_empty() && v != "0") + .unwrap_or(false) +} + +/// `LB_MOBILE_UI=2` develops the mobile shell in landscape (opens a landscape phone window). +/// Orientation itself is aspect-based at render time; this only picks the initial window aspect. +pub fn is_mobile_landscape_env() -> bool { + std::env::var("LB_MOBILE_UI").map(|v| v == "2").unwrap_or(false) +} + +/// The panes that make up the vertical stack, in fixed top→bottom order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackPane { + Outliner, + AssetLibrary, + Stage, + Timeline, + /// The instrument surface: keyboard-primary on mobile, hosting the falling-notes roll above the + /// keys (the standalone Virtual Piano is embedded here, so it isn't a separate stack slot). + PianoRoll, + /// Node editor, or (toggled) the instrument/preset browser. + NodeInstrument, + ScriptEditor, +} + +/// The stack order. Index into this is a pane's stable slot id. +pub const STACK: [StackPane; 7] = [ + StackPane::Outliner, + StackPane::AssetLibrary, + StackPane::Stage, + StackPane::Timeline, + StackPane::PianoRoll, + StackPane::NodeInstrument, + StackPane::ScriptEditor, +]; + +impl StackPane { + /// The backing pane type. The Node/Instrument slot picks NodeEditor or PresetBrowser based on + /// the toggle. + pub fn pane_type(self, show_instruments: bool) -> PaneType { + match self { + StackPane::Outliner => PaneType::Outliner, + StackPane::AssetLibrary => PaneType::AssetLibrary, + StackPane::Stage => PaneType::Stage, + StackPane::Timeline => PaneType::Timeline, + StackPane::PianoRoll => PaneType::PianoRoll, + StackPane::NodeInstrument => { + if show_instruments { + PaneType::PresetBrowser + } else { + PaneType::NodeEditor + } + } + StackPane::ScriptEditor => PaneType::ScriptEditor, + } + } + + pub fn label(self, show_instruments: bool) -> &'static str { + match self { + StackPane::Outliner => "Outliner", + StackPane::AssetLibrary => "Assets", + StackPane::Stage => "Stage", + StackPane::Timeline => "Timeline", + StackPane::PianoRoll => "Instrument", + StackPane::NodeInstrument => { + if show_instruments { + "Instruments" + } else { + "Nodes" + } + } + StackPane::ScriptEditor => "Script", + } + } +} + +/// `pane_instances` key for a stack slot. +fn slot_path(slot: usize) -> NodePath { + vec![MOBILE_NS, slot] +} + +/// An in-progress drag of a stack handle. +#[derive(Debug, Clone, Copy)] +pub struct StackDrag { + pub handle: stack::Handle, + /// Accumulated pointer offset (px) since the drag began (downward positive). + pub offset: f32, +} + +/// A short ease between two stack layouts (resize snap, membership change, or fullscreen toggle). +/// Both endpoints are full configs so the same animation covers panes resizing AND panes +/// entering/leaving the window. +#[derive(Debug, Clone, Copy)] +pub struct LayoutAnim { + pub from_top: usize, + pub from_count: usize, + pub from_w: [f32; 3], + pub to_top: usize, + pub to_count: usize, + pub to_w: [f32; 3], + /// egui time (seconds) when the animation started (may be back-dated to continue a drag). + pub start: f64, +} + +/// Persistent mobile-shell state, cached on `EditorApp`. +pub struct MobileState { + /// Index into `STACK` of the topmost visible pane. + pub window_top: usize, + /// Number of visible panes (1, 2, or 3; 1 = a single pane filling the stack). + pub window_count: usize, + /// Relative heights of the visible panes (first `window_count` entries are used; normalized on + /// use). Reset to even on any membership change; adjusted by intermediate divider snapping. + pub weights: [f32; 3], + /// Node/Instrument band: false = node editor, true = instrument/preset browser. + pub show_instruments: bool, + /// Inspector sheet height as a fraction of the region above the transport (portrait). + pub inspector_frac: f32, + /// Inspector side-panel width as a fraction of the region (landscape). + pub inspector_width_frac: f32, + /// Last frame's orientation (landscape?), to reset orientation-specific cached state on rotation. + pub was_landscape: bool, + /// Whether the inspector sheet is currently shown. Gated to appear on pointer *release* (a tap), + /// not on press, so press+drag interactions aren't interrupted by the sheet popping up. + pub inspector_visible: bool, + /// Screen-y of the tap that opened the inspector — used to decide whether the tapped thing would + /// be hidden by the sheet (only then do we reflow the stack). + pub inspector_anchor_y: f32, + /// Set when the user taps outside the sheet to dismiss it; suppresses re-showing until the + /// selection changes. Reset when the selection signature below changes. + pub inspector_dismissed: bool, + /// Cheap signature of the current selection, to detect when it changes (re-show the inspector). + pub inspector_sel_sig: u64, + /// Whether the omnibutton radial tool menu is open. + pub omni_open: bool, + /// Whether the omnibutton "more" grid (all tools) is open. + pub omni_grid_open: bool, + /// Whether the omnibutton "+New" create grid is open. + pub omni_create_open: bool, + /// Top-bar overflow (⋯ commands) sheet open. + pub overflow_open: bool, + /// Top-bar command palette (⌕) open, and its search query. + pub palette_open: bool, + pub palette_query: String, + /// Active handle drag (transient). + pub drag: Option, + /// In-flight layout ease (transient). + pub anim: Option, +} + +impl Default for MobileState { + fn default() -> Self { + Self { + // Launch on {Stage, Timeline}. + window_top: 2, + window_count: 2, + weights: [1.0, 1.0, 1.0], + show_instruments: false, + inspector_frac: 0.45, + inspector_width_frac: 0.4, + was_landscape: false, + inspector_visible: false, + inspector_anchor_y: 0.0, + inspector_dismissed: false, + inspector_sel_sig: 0, + omni_open: false, + omni_grid_open: false, + omni_create_open: false, + overflow_open: false, + palette_open: false, + palette_query: String::new(), + drag: None, + anim: None, + } + } +} + +/// Render the whole mobile shell into `available_rect`. +pub fn render_mobile_shell( + ui: &mut egui::Ui, + available_rect: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, +) { + let pal = Palette::from_theme(rc.shared.theme, ui.ctx()); + + // Orientation (aspect-based; rotation = a resize). Published to panes before anything renders. + let landscape = available_rect.width() > available_rect.height(); + rc.shared.is_portrait = !landscape; + // Rotating while 3 panes are open drops to 2 (landscape caps the stack at 2). + if landscape && state.window_count > 2 { + state.window_count = 2; + state.window_top = state.window_top.min(STACK.len().saturating_sub(2)); + } + // On an orientation change, the cached inspector tap-anchor is in the old layout's coordinate + // space; drop it to a non-covering default so the portrait reflow doesn't mis-decide until the + // next tap re-anchors. + if landscape != state.was_landscape { + state.was_landscape = landscape; + state.inspector_anchor_y = available_rect.top(); + } + + // Background (device color; bands paint over most of it). + ui.painter().rect_filled(available_rect, 0.0, pal.bg); + + // In landscape the top bar is folded into the top pane header (no separate band), reclaiming its + // height for the stack. + let topbar_h = if landscape { 0.0 } else { TOPBAR_H }; + let topbar_rect = egui::Rect::from_min_max( + available_rect.min, + egui::pos2(available_rect.right(), available_rect.top() + topbar_h), + ); + let transport_rect = egui::Rect::from_min_max( + egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H), + available_rect.max, + ); + // Region between the top bar and the transport, shared between the stack and (when something is + // selected) the inspector sheet that rises above the transport. + let region = egui::Rect::from_min_max( + egui::pos2(available_rect.left(), topbar_rect.bottom()), + egui::pos2(available_rect.right(), transport_rect.top()), + ); + // The inspector sheet appears on pointer *release* (a tap), not on press — so press+drag on the + // canvas isn't interrupted by the sheet popping up. Track visibility: hide when nothing's + // selected; show once the pointer is up with a selection; keep it shown while it's being + // interacted with (pointer down but still selected). + let active = inspector::is_active(&rc.shared); + let any_down = ui.input(|i| i.pointer.any_down()); + let sel_sig = inspector::selection_sig(&rc.shared); + if sel_sig != state.inspector_sel_sig { + // Selection changed → a fresh thing to inspect; clear any prior manual dismissal. + state.inspector_dismissed = false; + state.inspector_sel_sig = sel_sig; + } + if !active { + state.inspector_visible = false; + state.inspector_dismissed = false; + } else if !state.inspector_dismissed && !any_down { + if !state.inspector_visible { + // Just opened (tap released): anchor to the release position for the reflow test below. + state.inspector_anchor_y = ui + .input(|i| i.pointer.interact_pos()) + .map(|p| p.y) + .unwrap_or(region.bottom()); + } + state.inspector_visible = true; + } + let mut inspector_shown = state.inspector_visible; + + // Inspector geometry: a bottom sheet in portrait, a right-side vertical column in landscape. + // Clamp defensively: on a very small region the desired minimum can exceed the available space, + // and `f32::clamp` panics if `min > max`, so keep `min <= max`. + let inspector_rect = if landscape { + let lo = 180.0_f32.min(region.width()); + let hi = (region.width() - 140.0).max(lo); + let w = (region.width() * state.inspector_width_frac).clamp(lo, hi); + egui::Rect::from_min_max(egui::pos2(region.right() - w, region.top()), region.max) + } else { + let lo = 120.0_f32.min(region.height()); + let hi = (region.height() - 60.0).max(lo); + let h = (region.height() * state.inspector_frac).clamp(lo, hi); + egui::Rect::from_min_max(egui::pos2(region.left(), region.bottom() - h), region.max) + }; + + // Tapping outside the inspector (but not on the transport) dismisses (hides) it. We only hide — + // NOT clear the selection — so actions dispatched from overlays (context menu, "+New" grid) still + // see the selection. It stays dismissed until the selection changes (sig above). + if inspector_shown { + let dismiss = ui.input(|i| { + i.pointer.primary_pressed() + && i.pointer.press_origin().map_or(false, |p| { + !inspector_rect.contains(p) && !transport_rect.contains(p) + }) + }); + if dismiss { + state.inspector_visible = false; + state.inspector_dismissed = true; + inspector_shown = false; + } + } + + // Reflow: shrink the stack to make room for the inspector. Landscape always carves horizontally + // (side-by-side); portrait only carves when the tapped thing sits below the sheet (else overlay). + // Restoring on dismiss is automatic — reflow only changes the render rect. + let stack_rect = if !inspector_shown { + region + } else if landscape { + egui::Rect::from_min_max(region.min, egui::pos2(inspector_rect.left(), region.bottom())) + } else if state.inspector_anchor_y > inspector_rect.top() { + egui::Rect::from_min_max(region.min, egui::pos2(region.right(), inspector_rect.top())) + } else { + region + }; + + // Decide whether the instrument pane (PianoRoll = STACK index 4) reveals the roll, from the + // *committed* (snapped) window weights — so the keyboard↔roll transition lands on a stack snap + // rather than at an arbitrary mid-drag height. Roll shows once the pane is past the smallest snap. + rc.shared.instrument_show_roll = { + let idx = 4i32 - state.window_top as i32; + if idx >= 0 && (idx as usize) < state.window_count { + let sum: f32 = state.weights[..state.window_count].iter().map(|w| w.max(0.0)).sum(); + let w = if sum > 0.0 { state.weights[idx as usize].max(0.0) / sum } else { 0.0 }; + w > 0.30 // 0.25 preset ⇒ keyboard only; 0.33/0.5/0.75 ⇒ keyboard + roll + } else { + true + } + }; + + stack::render(ui, stack_rect, rc, state, &pal); + + if inspector_shown { + inspector::render(ui, inspector_rect, region, rc, state, &pal, landscape); + } + + // Transport floor: drawn last = always on top, the persistent spine. + transport::render(ui, transport_rect, &mut rc.shared, &pal); + + // Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else. + omni::render(ui, region, rc, state, &pal); + + // Instrument-browser request from the music pane's header → show the Preset Browser fullscreen. + if *rc.shared.open_instrument_browser { + *rc.shared.open_instrument_browser = false; + state.show_instruments = true; + state.window_top = 5; // Node/Instrument band (PresetBrowser when show_instruments) + state.window_count = 1; + state.weights = [1.0, 1.0, 1.0]; + state.anim = None; + } + + // Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last. + // Portrait: its own band at the top. Landscape: folded into the middle of the top pane header. + if landscape { + let hh = stack::header_height(false); + let top_header = egui::Rect::from_min_max( + egui::pos2(stack_rect.left(), region.top()), + egui::pos2(stack_rect.right(), region.top() + hh), + ); + topbar::render_inline(ui, top_header, available_rect, rc, state, &pal); + } else { + topbar::render(ui, topbar_rect, available_rect, rc, state, &pal); + } + + // Long-press context menu (populated by whichever pane was long-pressed). Persistent popup, + // dispatched via pending_menu_actions. + render_context_menu(ui, available_rect, rc); +} + +/// Render the pane-populated long-press context menu (`shared.mobile_context_menu`) as a persistent +/// popup: it stays open until an item is chosen or the user taps outside it. Styled to match the +/// timeline's context menu (default themed popup frame + full-width, touch-height items). +fn render_context_menu(ui: &mut egui::Ui, available: egui::Rect, rc: &mut RenderContext) { + let Some(menu) = rc.shared.mobile_context_menu.clone() else { + return; + }; + // Keep the popup on screen (rough clamp against its estimated size). + let est = egui::vec2(200.0, menu.items.len() as f32 * ui.spacing().interact_size.y + 16.0); + let pos = egui::pos2( + menu.pos.x.min(available.right() - est.x - 8.0).max(available.left() + 8.0), + menu.pos.y.min(available.bottom() - est.y - 8.0).max(available.top() + 8.0), + ); + + let mut chosen: Option = None; + let area = egui::Area::new(ui.id().with("mobile_ctx_menu")) + .order(egui::Order::Foreground) + .fixed_pos(pos) + .interactable(true) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(180.0); + for (label, action) in &menu.items { + let w = ui.available_width(); + let (rect, resp) = + ui.allocate_exact_size(egui::vec2(w, ui.spacing().interact_size.y), egui::Sense::click()); + if ui.is_rect_visible(rect) { + if resp.hovered() { + ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill); + } + let tc = if resp.hovered() { + ui.visuals().widgets.hovered.text_color() + } else { + ui.visuals().widgets.inactive.text_color() + }; + ui.painter().text( + rect.min + egui::vec2(10.0, (rect.height() - 14.0) / 2.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(14.0), + tc, + ); + } + if resp.clicked() { + chosen = Some(*action); + } + } + }); + }); + + // Dismiss on item choice or a primary click outside the popup. (The secondary click that opened + // it won't dismiss, so there's no just-opened race.) + let primary_click = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Primary)); + let dismiss = chosen.is_some() || (primary_click && !area.response.contains_pointer()); + if let Some(action) = chosen { + rc.shared.pending_menu_actions.push(action); + } + if dismiss { + *rc.shared.mobile_context_menu = None; + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs new file mode 100644 index 0000000..7158071 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs @@ -0,0 +1,337 @@ +//! Omnibutton — a floating action button whose radial holds the active layer's drawing tools plus +//! a "+New" petal (create layers / import / clip ops) and, when there are extra tools, a "⋯ more" +//! petal that opens a full-tools grid. Tools drive `shared.selected_tool`; create actions dispatch +//! `MenuAction`s. The radial fans up and to the left, away from the thumb. Pane-internal adds +//! (nodes/notes/instruments) are handled inside their panes (P6), not here. + +use eframe::egui; +use lightningbeam_core::layer::{AnyLayer, LayerType}; +use lightningbeam_core::selection::FocusSelection; +use lightningbeam_core::tool::Tool; + +use super::{icons, MobileState, Palette}; +use crate::menu::MenuAction; +use crate::RenderContext; + +const FAB_R: f32 = 26.0; +const IR: f32 = 20.0; +const PRIMARY: [Tool; 14] = [ + Tool::Select, + Tool::Draw, + Tool::Transform, + Tool::Rectangle, + Tool::Ellipse, + Tool::Line, + Tool::Polygon, + Tool::PaintBucket, + Tool::Gradient, + Tool::Eyedropper, + Tool::Text, + Tool::Erase, + Tool::BezierEdit, + Tool::Split, +]; +const RING_CAP: usize = 10; + +#[derive(Clone, Copy)] +enum Special { + More, + New, +} + +fn tool_icon(t: Tool) -> &'static str { + match t { + Tool::Select | Tool::RegionSelect => icons::MOUSE_POINTER_2, + Tool::SelectLasso => icons::LASSO_SELECT, + Tool::Draw | Tool::Pencil | Tool::Pen => icons::PENCIL, + Tool::Transform => icons::VECTOR_SQUARE, + Tool::Rectangle => icons::SQUARE, + Tool::Ellipse => icons::CIRCLE, + Tool::Line => icons::MINUS, + Tool::Polygon => icons::HEXAGON, + Tool::PaintBucket => icons::PAINT_BUCKET, + Tool::Gradient => icons::BLEND, + Tool::Eyedropper => icons::PIPETTE, + Tool::Text => icons::TYPE, + Tool::Erase => icons::ERASER, + Tool::BezierEdit => icons::PEN_TOOL, + Tool::MagicWand | Tool::QuickSelect => icons::WAND_SPARKLES, + Tool::Split => icons::SCISSORS, + _ => icons::BRUSH, + } +} + +fn active_layer_type(rc: &RenderContext) -> Option { + rc.shared + .active_layer_id + .and_then(|id| rc.shared.action_executor.document().get_layer(&id)) + .map(|layer| match layer { + AnyLayer::Vector(_) => LayerType::Vector, + AnyLayer::Audio(_) => LayerType::Audio, + AnyLayer::Video(_) => LayerType::Video, + AnyLayer::Effect(_) => LayerType::Effect, + AnyLayer::Group(_) => LayerType::Group, + AnyLayer::Raster(_) => LayerType::Raster, + AnyLayer::Text(_) => LayerType::Text, + }) +} + +/// The create-menu items: (label, icon, action, enabled). Split/Duplicate need a selected clip. +fn create_items(rc: &RenderContext) -> Vec<(&'static str, &'static str, MenuAction, bool)> { + let clip_sel = matches!(&*rc.shared.focus, FocusSelection::ClipInstances(ids) if !ids.is_empty()); + vec![ + ("Vector", icons::PEN_TOOL, MenuAction::AddLayer, true), + ("Audio", icons::AUDIO_WAVEFORM, MenuAction::AddAudioTrack, true), + ("MIDI", icons::PIANO, MenuAction::AddMidiTrack, true), + ("Raster", icons::BRUSH, MenuAction::AddRasterLayer, true), + ("Video", icons::CLAPPERBOARD, MenuAction::AddVideoLayer, true), + ("Group", icons::FOLDER, MenuAction::Group, true), + ("Import", icons::FILE_PLUS, MenuAction::Import, true), + ("Split", icons::SCISSORS, MenuAction::SplitClip, clip_sel), + ("Duplicate", icons::COPY, MenuAction::DuplicateClip, clip_sel), + ] +} + +fn close_all(state: &mut MobileState) { + state.omni_open = false; + state.omni_grid_open = false; + state.omni_create_open = false; +} + +pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { + let ctx_tools: Vec = Tool::for_layer_type(active_layer_type(rc)).to_vec(); + let primary: Vec = PRIMARY + .iter() + .copied() + .filter(|t| ctx_tools.contains(t)) + .take(RING_CAP) + .collect(); + let has_more = ctx_tools.len() > primary.len(); + + // Tools are only relevant when the Stage is on screen. Off-Stage the omnibutton is purely a + // "+New" create button. + let stage_slot = super::STACK + .iter() + .position(|p| *p == super::StackPane::Stage) + .unwrap_or(2); + let stage_visible = (state.window_top..state.window_top + state.window_count).contains(&stage_slot); + if !stage_visible { + state.omni_open = false; // no tool radial off-Stage + } + + let fab_center = egui::pos2(region.right() - 18.0 - FAB_R, region.bottom() - 18.0 - FAB_R); + + if state.omni_grid_open { + // Full-tools grid. + let cells: Vec = ctx_tools + .iter() + .map(|t| Cell { + icon: tool_icon(*t), + label: t.display_name(), + selected: *rc.shared.selected_tool == *t, + enabled: true, + }) + .collect(); + let (close, clicked) = draw_grid(ui, region, "Tools", &cells, pal); + if let Some(i) = clicked { + *rc.shared.selected_tool = ctx_tools[i]; + close_all(state); + } else if close { + close_all(state); + } + } else if state.omni_create_open { + // Create grid. + let items = create_items(rc); + let cells: Vec = items + .iter() + .map(|it| Cell { + icon: it.1, + label: it.0, + selected: false, + enabled: it.3, + }) + .collect(); + let (close, clicked) = draw_grid(ui, region, "New", &cells, pal); + if let Some(i) = clicked { + rc.shared.pending_menu_actions.push(items[i].2); + close_all(state); + } else if close { + close_all(state); + } + } else if state.omni_open { + // Radial: scrim, then tool petals + special petals (more / new). + let scrim = ui.interact(region, ui.id().with("mobile_omni_scrim"), egui::Sense::click()); + ui.painter().rect_filled(region, 0.0, pal.scrim); + if scrim.clicked() { + state.omni_open = false; + } + + let mut specials: Vec = Vec::new(); + if has_more { + specials.push(Special::More); + } + specials.push(Special::New); + let n = primary.len() + specials.len(); + + for k in 0..n { + let frac = if n > 1 { k as f32 / (n as f32 - 1.0) } else { 0.5 }; + let deg = 74.0 + frac * 126.0; // 74°..200°, up and to the left + let a = deg.to_radians(); + let radius = if k % 2 == 0 { 114.0 } else { 170.0 }; + let c = fab_center + egui::vec2(radius * a.cos(), -radius * a.sin()); + let rect = egui::Rect::from_center_size(c, egui::vec2(IR * 2.0, IR * 2.0)); + + if k < primary.len() { + let t = primary[k]; + let resp = ui.interact(rect, ui.id().with(("mobile_omni_tool", k)), egui::Sense::click()); + let selected = *rc.shared.selected_tool == t; + petal(ui, c, if selected { pal.accent } else { petal_bg(&resp, pal) }, tool_icon(t), if selected { pal.on_accent } else { pal.text }, pal); + if resp.clicked() { + *rc.shared.selected_tool = t; + state.omni_open = false; + } + } else { + match specials[k - primary.len()] { + Special::More => { + let resp = ui.interact(rect, ui.id().with("mobile_omni_more"), egui::Sense::click()); + petal(ui, c, petal_bg(&resp, pal), icons::MENU, pal.text, pal); + if resp.clicked() { + state.omni_grid_open = true; + } + } + Special::New => { + let resp = ui.interact(rect, ui.id().with("mobile_omni_new"), egui::Sense::click()); + petal(ui, c, pal.accent, icons::PLUS, pal.on_accent, pal); + if resp.hovered() { + ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.5, pal.text)); + } + if resp.clicked() { + state.omni_create_open = true; + } + } + } + } + } + } + + // The FAB itself (on top). Closed → current tool icon; open → ✕. + let fab_rect = egui::Rect::from_center_size(fab_center, egui::vec2(FAB_R * 2.0, FAB_R * 2.0)); + let fresp = ui.interact(fab_rect, ui.id().with("mobile_omni_fab"), egui::Sense::click()); + ui.painter().circle_filled(fab_center, FAB_R, pal.accent); + let open = state.omni_open || state.omni_grid_open || state.omni_create_open; + let glyph = if open { + icons::X + } else if stage_visible { + tool_icon(*rc.shared.selected_tool) + } else { + icons::PLUS // off-Stage: a create button + }; + ui.painter() + .text(fab_center, egui::Align2::CENTER_CENTER, glyph, icons::font(22.0), pal.on_accent); + if fresp.clicked() { + if open { + close_all(state); + } else if stage_visible { + state.omni_open = true; + } else { + state.omni_create_open = true; + } + } +} + +fn petal_bg(resp: &egui::Response, pal: &Palette) -> egui::Color32 { + if resp.hovered() { + pal.line + } else { + pal.surface_alt + } +} + +fn petal(ui: &egui::Ui, c: egui::Pos2, bg: egui::Color32, glyph: &str, fg: egui::Color32, pal: &Palette) { + ui.painter().circle_filled(c, IR, bg); + ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.0, pal.line)); + ui.painter().text(c, egui::Align2::CENTER_CENTER, glyph, icons::font(18.0), fg); +} + +struct Cell { + icon: &'static str, + label: &'static str, + selected: bool, + enabled: bool, +} + +/// A modal grid sheet of icon+label cells. Returns (backdrop-tapped, Some(clicked-enabled-index)). +fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell], pal: &Palette) -> (bool, Option) { + let scrim = ui.interact(region, ui.id().with(("mobile_omni_gridscrim", title)), egui::Sense::click()); + ui.painter().rect_filled(region, 0.0, pal.scrim); + + let panel = region.shrink2(egui::vec2(16.0, 40.0)); + ui.painter().rect_filled(panel, 14.0, pal.surface); + ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + ui.painter().text( + egui::pos2(panel.left() + 16.0, panel.top() + 18.0), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(14.0), + pal.text, + ); + + let cols = 4usize; + let n = cells.len(); + let rows = n.div_ceil(cols).max(1); + let grid = egui::Rect::from_min_max( + egui::pos2(panel.left() + 10.0, panel.top() + 36.0), + egui::pos2(panel.right() - 10.0, panel.bottom() - 10.0), + ); + let cw = grid.width() / cols as f32; + let chh = (grid.height() / rows as f32).min(96.0); + let mut clicked = None; + for (i, cell) in cells.iter().enumerate() { + let col = (i % cols) as f32; + let row = (i / cols) as f32; + let r = egui::Rect::from_min_size( + egui::pos2(grid.left() + col * cw, grid.top() + row * chh), + egui::vec2(cw, chh), + ) + .shrink(5.0); + let resp = if cell.enabled { + ui.interact(r, ui.id().with(("mobile_omni_cell", title, i)), egui::Sense::click()) + } else { + ui.interact(r, ui.id().with(("mobile_omni_cell_off", title, i)), egui::Sense::hover()) + }; + let bg = if cell.selected { + pal.accent + } else if cell.enabled && resp.hovered() { + pal.surface_alt + } else { + pal.surface + }; + ui.painter().rect_filled(r, 10.0, bg); + ui.painter().rect_stroke(r, 10.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + let fg = if cell.selected { + pal.on_accent + } else if cell.enabled { + pal.text + } else { + pal.line + }; + ui.painter().text( + egui::pos2(r.center().x, r.top() + r.height() * 0.38), + egui::Align2::CENTER_CENTER, + cell.icon, + icons::font(22.0), + fg, + ); + ui.painter().text( + egui::pos2(r.center().x, r.bottom() - 12.0), + egui::Align2::CENTER_CENTER, + cell.label, + egui::FontId::proportional(9.5), + if cell.selected { pal.on_accent } else if cell.enabled { pal.text_dim } else { pal.line }, + ); + if cell.enabled && resp.clicked() { + clicked = Some(i); + } + } + (scrim.clicked() && clicked.is_none(), clicked) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs new file mode 100644 index 0000000..cb479be --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs @@ -0,0 +1,59 @@ +//! The mobile UI's semantic color palette, sourced from the shared theme CSS variables so it +//! matches the main UI and responds to light/dark/user themes. Built once per frame from the active +//! [`Theme`](crate::theme::Theme) and passed (by `Copy`) into the mobile render functions, replacing +//! the per-file hardcoded color constants. + +use eframe::egui::{Color32, Context}; + +use crate::theme::Theme; + +#[derive(Clone, Copy)] +pub struct Palette { + /// App/device background behind everything. + pub bg: Color32, + /// Panels, cards, bands, sheets. + pub surface: Color32, + /// Raised / hovered surface. + pub surface_alt: Color32, + /// Band headers, the top bar. + pub header: Color32, + /// Borders and dividers. + pub line: Color32, + /// Primary text / icons. + pub text: Color32, + /// Secondary (dim) text / icons. + pub text_dim: Color32, + /// Selection / active accent. + pub accent: Color32, + /// Text / icons drawn on top of `accent`. + pub on_accent: Color32, + /// Modal backdrop (translucent). + pub scrim: Color32, + /// Decorative category accents (intent picker, chips): coral, cyan, amber, pink, violet. + pub accents: [Color32; 5], +} + +impl Palette { + pub fn from_theme(theme: &Theme, ctx: &Context) -> Self { + let c = |name: &str, fb: Color32| theme.var(name, ctx).unwrap_or(fb); + Self { + bg: c("bg-app", Color32::from_rgb(0x2a, 0x2a, 0x2a)), + surface: c("bg-panel", Color32::from_rgb(0x22, 0x22, 0x22)), + surface_alt: c("bg-surface-raised", Color32::from_rgb(0x3f, 0x3f, 0x3f)), + header: c("bg-header", Color32::from_rgb(0x35, 0x35, 0x35)), + line: c("border-default", Color32::from_rgb(0x44, 0x44, 0x44)), + text: c("text-primary", Color32::from_rgb(0xf6, 0xf6, 0xf6)), + text_dim: c("text-secondary", Color32::from_rgb(0xaa, 0xaa, 0xaa)), + accent: c("accent", Color32::from_rgb(0x39, 0x6c, 0xd8)), + on_accent: c("text-on-accent", Color32::WHITE), + scrim: c("scrim", Color32::from_rgba_unmultiplied(0x10, 0x14, 0x1a, 0xb0)), + accents: [ + c("accent-coral", Color32::from_rgb(0xe8, 0x82, 0x6b)), + c("accent-cyan", Color32::from_rgb(0x54, 0xc3, 0xe8)), + c("accent-amber", Color32::from_rgb(0xf4, 0xa3, 0x40)), + c("accent-pink", Color32::from_rgb(0xc7, 0x5b, 0x8a)), + c("accent-violet", Color32::from_rgb(0x8a, 0x6e, 0xc0)), + ], + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs new file mode 100644 index 0000000..ab703a1 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -0,0 +1,862 @@ +//! The vertical sliding-window stack engine. +//! +//! A window of 2–3 consecutive panes (from [`super::STACK`]) is visible. Dragging the dividers +//! between visible panes and the top/bottom screen edges grows, shrinks, or slides the window via +//! the operations below (verified against the user's transition table): +//! +//! ```text +//! R1 upper divider, UP -> slide window DOWN one (top+=1) +//! R4 upper divider, DOWN -> drop BOTTOM pane (count 3->2) +//! R3 lower divider, UP -> drop TOP pane (top+=1, count 3->2) +//! R2 lower divider, DOWN -> slide window UP one (top-=1) +//! R5 bottom edge, UP -> grow at bottom; if already 3, slide down +//! R6 top edge, DOWN -> grow at top; if already 3, slide up +//! ``` +//! +//! At count==2 the single divider is "upper" when dragged up (R1) and "lower" when dragged +//! down (R2). The window content interpolates continuously during a drag and snaps on release. + +use eframe::egui; + +use super::{icons, slot_path, LayoutAnim, MobileState, Palette, StackDrag, StackPane, STACK}; +use crate::RenderContext; + +const N: usize = STACK.len(); + +/// Height of each band's drag header (and the bottom-edge footer). The whole header is the grab +/// target — there's no thin divider bar. +const HEADER_H: f32 = 52.0; +/// Landscape headers are shorter — vertical space is scarce, and the top one hosts the app bar. +const HEADER_H_LANDSCAPE: f32 = 34.0; +/// Header height for the current orientation. +pub fn header_height(is_portrait: bool) -> f32 { + if is_portrait { HEADER_H } else { HEADER_H_LANDSCAPE } +} +/// Corner radius (px) for the rounded top of each header. +const HEADER_RADIUS: u8 = 9; +const FOOTER_H: f32 = 28.0; +/// Width of a header right-side button (fullscreen, node toggle). +const BTN_W: f32 = 44.0; +/// Max pixels of drag to complete a window transition (so you don't drag half the screen). +const TRIGGER_MAX: f32 = 150.0; + +/// A draggable boundary of the stack. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Handle { + TopEdge, + /// Divider below visible pane `k` (between visible pane `k` and `k+1`). + Divider(usize), + BottomEdge, +} + +// --- R1..R6 as pure ops on (top, count) -> Option<(top, count)> --- + +fn op_r1(top: usize, count: usize) -> Option<(usize, usize)> { + (top + count < N).then(|| (top + 1, count)) // slide down +} +fn op_r2(top: usize, count: usize) -> Option<(usize, usize)> { + (top > 0).then(|| (top - 1, count)) // slide up +} +fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> { + (count == 3).then(|| (top + 1, 2)) // drop top +} +fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> { + (count == 3).then(|| (top, 2)) // drop bottom +} +fn op_r5(top: usize, count: usize, max: usize) -> Option<(usize, usize)> { + if count < max && top + count < N { + Some((top, count + 1)) // grow bottom + } else if count == max && top + count < N { + Some((top + 1, count)) // slide down + } else { + None + } +} +fn op_r6(top: usize, count: usize, max: usize) -> Option<(usize, usize)> { + if count < max && top > 0 { + Some((top - 1, count + 1)) // grow top + } else if count == max && top > 0 { + Some((top - 1, count)) // slide up + } else { + None + } +} + +/// Given a handle and signed drag offset, resolve the target window config and progress `t`. +/// Returns None if the drag direction has no valid operation (e.g. at a list boundary). +fn resolve( + handle: Handle, + offset: f32, + top: usize, + count: usize, + trigger: f32, + max: usize, +) -> Option<(usize, usize, f32)> { + let going_up = offset < 0.0; + let t = (offset.abs() / trigger.max(1.0)).clamp(0.0, 1.0); + let target = match handle { + Handle::TopEdge => (!going_up).then(|| op_r6(top, count, max)).flatten(), + Handle::BottomEdge => going_up.then(|| op_r5(top, count, max)).flatten(), + Handle::Divider(k) => { + if count == 2 { + if going_up { op_r1(top, count) } else { op_r2(top, count) } + } else if k == 0 { + // upper divider + if going_up { op_r1(top, count) } else { op_r4(top, count) } + } else { + // lower divider + if going_up { op_r3(top, count) } else { op_r2(top, count) } + } + } + }; + target.map(|(tt, tc)| (tt, tc, t)) +} + +// --- rect layout --- + +/// Below/above these boundary positions, a divider release collapses a group → membership change. +const COLLAPSE_LO: f32 = 0.12; +const COLLAPSE_HI: f32 = 0.88; + +// The only allowed pane-weight distributions per window size. Snapping to these keeps the minimum +// pane height manageable (no pane below ~1/4). +const PRESETS_1: [[f32; 3]; 1] = [[1.0, 0.0, 0.0]]; +const PRESETS_2: [[f32; 3]; 3] = [[0.25, 0.75, 0.0], [0.5, 0.5, 0.0], [0.75, 0.25, 0.0]]; +const PRESETS_3: [[f32; 3]; 3] = [ + [0.5, 0.25, 0.25], + [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0], + [0.25, 0.25, 0.5], +]; + +fn presets(count: usize) -> &'static [[f32; 3]] { + match count { + 2 => &PRESETS_2, + 3 => &PRESETS_3, + _ => &PRESETS_1, + } +} + +/// The allowed preset nearest (L2) to the current weights. +fn nearest_preset(cur: &[f32], count: usize) -> [f32; 3] { + let dist = |p: &[f32; 3]| (0..count).map(|i| (p[i] - cur[i]).powi(2)).sum::(); + presets(count) + .iter() + .min_by(|a, b| dist(a).total_cmp(&dist(b))) + .copied() + .unwrap_or([1.0, 0.0, 0.0]) +} + +// 3-pane snapping rides a single path parameter s in [0, 2] through the three presets, so the two +// dividers move together and reach their snaps simultaneously. The dragged divider's boundary +// position (pane0 for the upper divider; pane0+pane1 for the lower) maps onto s. +const PATH3_UPPER: [f32; 3] = [0.5, 1.0 / 3.0, 0.25]; // pane0 at s = 0, 1, 2 +const PATH3_LOWER: [f32; 3] = [0.75, 2.0 / 3.0, 0.5]; // pane0+pane1 at s = 0, 1, 2 +/// A pane grown beyond this collapses the 3-pane window to 2 panes on release. +const COLLAPSE_GROW: f32 = 0.66; + +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + lerp_f(a[0], b[0], t), + lerp_f(a[1], b[1], t), + lerp_f(a[2], b[2], t), + ] +} + +/// Weights along the 3-pane preset path at parameter `s` ∈ [0, 2]. +fn path3(s: f32) -> [f32; 3] { + let s = s.clamp(0.0, 2.0); + if s <= 1.0 { + lerp3(PRESETS_3[0], PRESETS_3[1], s) + } else { + lerp3(PRESETS_3[1], PRESETS_3[2], s - 1.0) + } +} + +/// Path parameter for divider `k` whose boundary position is `b` (within the path's range). +fn s_from_boundary(k: usize, b: f32) -> f32 { + let pts = if k == 0 { PATH3_UPPER } else { PATH3_LOWER }; + if b >= pts[1] { + (pts[0] - b) / (pts[0] - pts[1]) // 0 at pts[0] … 1 at pts[1] + } else { + 1.0 + (pts[1] - b) / (pts[1] - pts[2]) // 1 at pts[1] … 2 at pts[2] + } +} + +/// The 3-pane path boundary range for divider `k`: (min, max) of its position across the presets. +fn path3_range(k: usize) -> (f32, f32) { + let pts = if k == 0 { PATH3_UPPER } else { PATH3_LOWER }; + (pts[2], pts[0]) +} + +/// Live weights while dragging divider `k` to boundary `b` in a 3-pane window: follow the linked +/// path within range, and beyond it group-resize from the nearer path endpoint (so the motion is +/// continuous across the extreme presets — no jump in the other divider). +fn live_weights3(k: usize, b_unclamped: f32) -> [f32; 3] { + let (lo, hi) = path3_range(k); + if b_unclamped >= lo && b_unclamped <= hi { + path3(s_from_boundary(k, b_unclamped)) + } else { + let anchor = if b_unclamped > hi { path3(0.0) } else { path3(2.0) }; + let (bmin, bmax) = boundary_bounds(3, k); + to_arr(&weights_for_boundary(&anchor, k, b_unclamped.clamp(bmin, bmax))) + } +} +/// Minimum normalized size a pane may be squeezed to during a live divider drag. +const MIN_FRAC: f32 = 0.05; +/// Duration (seconds) of the ease into a snapped divider position. +const SNAP_ANIM_SECS: f64 = 0.10; + +fn ease_out(p: f32) -> f32 { + 1.0 - (1.0 - p).powi(3) +} + +/// Normalized pane weights (the first `count` stored weights, summing to 1). +fn nweights(weights: &[f32; 3], count: usize) -> Vec { + let mut w: Vec = weights[..count].iter().map(|x| x.max(0.0001)).collect(); + let s: f32 = w.iter().sum(); + if s > 0.0 { + for x in &mut w { + *x /= s; + } + } else { + let e = 1.0 / count as f32; + w.iter_mut().for_each(|x| *x = e); + } + w +} + +/// The bottom Y of the given stack slot's band within `region` (using the current window + +/// weights), or None if that slot isn't in the visible window. Used to decide whether the +/// inspector sheet would cover the selected pane. +#[allow(dead_code)] // kept for pane-coverage heuristics +pub fn pane_bottom_in(state: &MobileState, region: egui::Rect, slot: usize) -> Option { + let (top, count) = (state.window_top, state.window_count); + if slot < top || slot >= top + count { + return None; + } + let nw = nweights(&state.weights, count); + let cum: f32 = nw[..=(slot - top)].iter().sum(); + Some(region.top() + cum * region.height()) +} + +/// Lay out `count` panes in `rect` using normalized weights `nw`. +fn config_rects(top: usize, count: usize, rect: egui::Rect, nw: &[f32]) -> Vec<(usize, egui::Rect)> { + let h = rect.height(); + let mut out = Vec::with_capacity(count); + let mut y = rect.top(); + for i in 0..count { + let ph = nw[i] * h; + out.push(( + top + i, + egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y + ph)), + )); + y += ph; + } + out +} + +fn lerp_f(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} +fn lerp_rect(a: egui::Rect, b: egui::Rect, t: f32) -> egui::Rect { + egui::Rect::from_min_max( + egui::pos2(lerp_f(a.min.x, b.min.x, t), lerp_f(a.min.y, b.min.y, t)), + egui::pos2(lerp_f(a.max.x, b.max.x, t), lerp_f(a.max.y, b.max.y, t)), + ) +} +fn collapsed(rect: egui::Rect, at_top: bool) -> egui::Rect { + let y = if at_top { rect.top() } else { rect.bottom() }; + egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)) +} + +/// Allowed range of divider `k`'s boundary position (normalized) given MIN_FRAC per pane. +/// The boundary splits the window into the group above (panes 0..=k) and below (panes k+1..). +fn boundary_bounds(count: usize, k: usize) -> (f32, f32) { + let above = (k + 1) as f32; + let below = (count - 1 - k) as f32; + (above * MIN_FRAC, 1.0 - below * MIN_FRAC) +} + +/// New normalized weights when divider `k` is moved to boundary position `b`: the panes above the +/// divider are scaled to fill `[0, b]` and those below to fill `[b, 1]`, each group keeping its +/// internal proportions. This makes the *other* dividers in the group move with the dragged one. +fn weights_for_boundary(nw: &[f32], k: usize, b: f32) -> Vec { + let count = nw.len(); + let above_sum: f32 = nw[..=k].iter().sum(); + let below_sum: f32 = nw[k + 1..].iter().sum(); + let mut w = nw.to_vec(); + if above_sum > 0.0 { + let s = b / above_sum; + for x in &mut w[..=k] { + *x *= s; + } + } + if below_sum > 0.0 { + let s = (1.0 - b) / below_sum; + for x in &mut w[k + 1..count] { + *x *= s; + } + } + w +} + +fn to_arr(v: &[f32]) -> [f32; 3] { + let mut a = [0.0; 3]; + for (i, x) in v.iter().take(3).enumerate() { + a[i] = *x; + } + a +} + +fn even_arr(count: usize) -> [f32; 3] { + let mut a = [0.0; 3]; + let e = 1.0 / count as f32; + for x in a.iter_mut().take(count) { + *x = e; + } + a +} + +/// Interpolate between two precomputed rect lists by `t` (used for edge membership transitions). +fn interp_layout( + c: &[(usize, egui::Rect)], + tt: &[(usize, egui::Rect)], + top_c: usize, + top_t: usize, + t: f32, + rect: egui::Rect, +) -> Vec<(usize, egui::Rect)> { + let find = |v: &[(usize, egui::Rect)], slot: usize| v.iter().find(|(s, _)| *s == slot).map(|(_, r)| *r); + let lo = c.first().map(|(s, _)| *s).unwrap_or(0).min(tt.first().map(|(s, _)| *s).unwrap_or(0)); + let hi = c.last().map(|(s, _)| *s + 1).unwrap_or(0).max(tt.last().map(|(s, _)| *s + 1).unwrap_or(0)); + let mut out = Vec::new(); + for slot in lo..hi { + let r = match (find(c, slot), find(tt, slot)) { + (Some(rc), Some(rt)) => lerp_rect(rc, rt, t), + (Some(rc), None) => lerp_rect(rc, collapsed(rect, slot < top_t), t), // exiting + (None, Some(rt)) => lerp_rect(collapsed(rect, slot < top_c), rt, t), // entering + (None, None) => continue, + }; + out.push((slot, r)); + } + out +} + +// --- rendering --- + +/// The interaction (drag) target for a band's boundary is its full-width header bar. Bands are +/// laid out in the area above a reserved bottom footer (the BottomEdge handle). Band 0's header is +/// the TopEdge handle; band i's header (i≥1) is the divider above it; the footer is the BottomEdge. +pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { + let top = state.window_top; + let count = state.window_count; + // Landscape caps the window at 2 panes; portrait allows 3. + let max_panes = if rc.shared.is_portrait { 3 } else { 2 }; + let header_h = header_height(rc.shared.is_portrait); + + // Reserve a footer bar at the very bottom for the BottomEdge handle. + let footer_rect = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.bottom() - FOOTER_H), + rect.max, + ); + let content_area = egui::Rect::from_min_max( + rect.min, + egui::pos2(rect.right(), footer_rect.top()), + ); + let pane_h = content_area.height() / count as f32; + let trigger = pane_h.min(TRIGGER_MAX); + + let now = ui.input(|i| i.time); + // Resting band rects from the committed weights — used for interaction so handles sit at their + // final positions even while the visuals animate. + let nw = nweights(&state.weights, count); + let rest_bands = config_rects(top, count, content_area, &nw); + + // The draw layout: a live drag, an in-flight layout ease, or the resting config. + let draw_layout = if let Some(d) = state.drag { + match d.handle { + Handle::Divider(k) if k + 1 < count => { + // Live resize: move divider k's boundary. In 3-pane the dividers are linked along the + // preset path; in 2-pane it's a simple group resize. + let off_frac = d.offset / content_area.height().max(1.0); + let b0: f32 = nw[..=k].iter().sum(); + let b = b0 + off_frac; + let w = if count == 3 { + live_weights3(k, b) + } else { + let (bmin, bmax) = boundary_bounds(count, k); + to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax))) + }; + config_rects(top, count, content_area, &nweights(&w, count)) + } + Handle::BottomEdge if count == 1 && top + 1 < N => { + // Continuous reveal: dragging the bottom edge up grows the pane below out of the + // bottom, its divider tracking the finger across the FULL height (not just `trigger`), + // so one drag sweeps the current pane → split → revealed-pane-fullscreen. + let frac = (-d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + config_rects(top, 2, content_area, &nweights(&[1.0 - frac, frac, 0.0], 2)) + } + Handle::TopEdge if count == 1 && top > 0 => { + // Symmetric reveal of the pane above: dragging the top edge down grows it from the top. + let frac = (d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + config_rects(top - 1, 2, content_area, &nweights(&[frac, 1.0 - frac, 0.0], 2)) + } + Handle::BottomEdge if count == 2 && max_panes == 2 => { + // 2-pane (landscape) → 1-pane reveal: dragging the bottom edge up first slides the + // window down to reveal the pane below, then collapses onto it — the whole sweep + // mapped over the FULL height so the dragged boundary reaches the top (rather than + // stalling at the even split after the slide). + let frac = (-d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + let even2 = nweights(&even_arr(2), 2); + let even1 = nweights(&even_arr(1), 1); + if top + 2 < N { + if frac <= 0.5 { + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top + 1, 2, content_area, &even2); + interp_layout(&from, &to, top, top + 1, frac / 0.5, content_area) + } else { + let from = config_rects(top + 1, 2, content_area, &even2); + let to = config_rects(top + 2, 1, content_area, &even1); + interp_layout(&from, &to, top + 1, top + 2, (frac - 0.5) / 0.5, content_area) + } + } else { + // No pane below → just collapse onto the bottom pane. + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top + 1, 1, content_area, &even1); + interp_layout(&from, &to, top, top + 1, frac, content_area) + } + } + Handle::TopEdge if count == 2 && max_panes == 2 => { + // Symmetric 2→1 reveal from the top: dragging the top edge down slides the window up + // to reveal the pane above, then collapses onto it. + let frac = (d.offset / content_area.height().max(1.0)).clamp(0.0, 1.0); + let even2 = nweights(&even_arr(2), 2); + let even1 = nweights(&even_arr(1), 1); + if top > 0 { + if frac <= 0.5 { + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top - 1, 2, content_area, &even2); + interp_layout(&from, &to, top, top - 1, frac / 0.5, content_area) + } else { + let from = config_rects(top - 1, 2, content_area, &even2); + let to = config_rects(top - 1, 1, content_area, &even1); + interp_layout(&from, &to, top - 1, top - 1, (frac - 0.5) / 0.5, content_area) + } + } else { + // No pane above → collapse onto the top pane (drop the bottom). + let from = config_rects(top, 2, content_area, &even2); + let to = config_rects(top, 1, content_area, &even1); + interp_layout(&from, &to, top, top, frac, content_area) + } + } + _ => resolve(d.handle, d.offset, top, count, trigger, max_panes) + .map(|(tt, tc, t)| { + let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc)); + interp_layout(&rest_bands, &target, top, tt, t, content_area) + }) + .unwrap_or_else(|| rest_bands.clone()), + } + } else if let Some(a) = state.anim { + let p = (((now - a.start) / SNAP_ANIM_SECS) as f32).clamp(0.0, 1.0); + if p >= 1.0 { + state.anim = None; + rest_bands.clone() + } else { + ui.ctx().request_repaint(); + let e = ease_out(p); + let from = config_rects(a.from_top, a.from_count, content_area, &nweights(&a.from_w, a.from_count)); + let to = config_rects(a.to_top, a.to_count, content_area, &nweights(&a.to_w, a.to_count)); + interp_layout(&from, &to, a.from_top, a.to_top, e, content_area) + } + } else { + rest_bands.clone() + }; + + // 1) Pane content, carving the header off the top of each band. + for (slot, brect) in &draw_layout { + let hh = header_h.min(brect.height()); + let content_rect = egui::Rect::from_min_max( + egui::pos2(brect.left(), brect.top() + hh), + brect.max, + ); + if content_rect.height() > 1.0 { + let sp = STACK[*slot]; + super::surface::render_surface_fullbleed( + ui, + content_rect, + &slot_path(*slot), + sp.pane_type(state.show_instruments), + rc, + ); + } + } + + // 2) Header visuals (animated positions). The fullscreen icon shows "restore" when a single + // pane fills the stack. + let fullscreen = draw_layout.len() == 1; + for (slot, brect) in &draw_layout { + if brect.height() < 6.0 { + continue; + } + let hr = egui::Rect::from_min_max( + brect.left_top(), + egui::pos2(brect.right(), brect.top() + header_h.min(brect.height())), + ); + draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal); + } + draw_footer(ui, footer_rect, top + count >= N, pal); + + // 3) Interactions on the resting header/footer rects (added last → they win the press). + handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state, max_panes, header_h); +} + +fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool, pal: &Palette) { + let p = ui.painter(); + // Rounded top corners so the header reads as a tab atop the pane; square at the bottom where + // it meets the pane content. + p.rect_filled( + hr, + egui::CornerRadius { nw: HEADER_RADIUS, ne: HEADER_RADIUS, sw: 0, se: 0 }, + pal.header, + ); + p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, pal.line)); + let cy = hr.center().y; + // Grip glyph on the left (Lucide). + p.text( + egui::pos2(hr.left() + 15.0, cy), + egui::Align2::CENTER_CENTER, + icons::GRIP_HORIZONTAL, + icons::font(16.0), + pal.text_dim, + ); + p.text( + egui::pos2(hr.left() + 32.0, cy), + egui::Align2::LEFT_CENTER, + sp.label(show_instruments), + egui::FontId::proportional(15.0), + pal.text, + ); + // Right-side buttons: fullscreen / restore rightmost, Node/Instrument toggle just left of it. + p.text( + egui::pos2(hr.right() - BTN_W * 0.5, cy), + egui::Align2::CENTER_CENTER, + if fullscreen { icons::MINIMIZE } else { icons::MAXIMIZE }, + icons::font(17.0), + pal.text_dim, + ); + if sp == StackPane::NodeInstrument { + p.text( + egui::pos2(hr.right() - BTN_W * 1.5, cy), + egui::Align2::CENTER_CENTER, + icons::ARROW_LEFT_RIGHT, + icons::font(17.0), + pal.accent, + ); + } +} + +fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool, pal: &Palette) { + let p = ui.painter(); + p.rect_filled(fr, 0.0, pal.header); + p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, pal.line)); + let col = if at_end { pal.line } else { pal.text_dim }; + let cy = fr.center().y; + let galley = p.layout_no_wrap( + "pull up for more".to_string(), + egui::FontId::proportional(11.0), + col, + ); + let total_w = 22.0 + galley.size().x; + let start_x = fr.center().x - total_w * 0.5; + p.text( + egui::pos2(start_x + 8.0, cy), + egui::Align2::CENTER_CENTER, + icons::CHEVRONS_UP, + icons::font(14.0), + col, + ); + let gy = cy - galley.size().y * 0.5; + p.galley(egui::pos2(start_x + 22.0, gy), galley, col); +} + +fn handle_key(h: Handle) -> (usize, usize) { + match h { + Handle::TopEdge => (0, 0), + Handle::Divider(k) => (1, k + 1), + Handle::BottomEdge => (2, 0), + } +} + +/// Toggle a slot between filling the stack (count==1) and a 2-pane split with an adjacent pane. +fn toggle_fullscreen(state: &mut MobileState, slot: usize, now: f64, max: usize) { + if state.window_count == 1 && state.window_top == slot { + // Restore: split with the pane below if possible, else above. + if let Some((t, c)) = op_r5(slot, 1, max).or_else(|| op_r6(slot, 1, max)) { + set_window(state, t, c, now); + } + } else { + set_window(state, slot, 1, now); + } +} + +fn handle_interactions( + ui: &mut egui::Ui, + rest_bands: &[(usize, egui::Rect)], + content_area: egui::Rect, + footer_rect: egui::Rect, + trigger: f32, + now: f64, + state: &mut MobileState, + max: usize, + header_h: f32, +) { + // (handle, header_rect, slot) — slot is Some for band headers, None for the footer. + let mut handles: Vec<(Handle, egui::Rect, Option)> = Vec::new(); + for (i, (slot, brect)) in rest_bands.iter().enumerate() { + let hr = egui::Rect::from_min_max( + brect.left_top(), + egui::pos2(brect.right(), brect.top() + header_h.min(brect.height())), + ); + let handle = if i == 0 { Handle::TopEdge } else { Handle::Divider(i - 1) }; + handles.push((handle, hr, Some(*slot))); + } + handles.push((Handle::BottomEdge, footer_rect, None)); + + for (handle, hrect, slot_opt) in handles { + let id = ui.id().with(("mobile_stack_handle", handle_key(handle))); + let resp = ui.interact(hrect, id, egui::Sense::click_and_drag()); + + // Right-side header buttons are interacted AFTER the header (so they're on top and win the + // press there); the rest of the header drives the drag. + if let Some(slot) = slot_opt { + let fs = egui::Rect::from_min_max( + egui::pos2(hrect.right() - BTN_W, hrect.top()), + hrect.max, + ); + let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click()); + if fsresp.clicked() { + toggle_fullscreen(state, slot, now, max); + } + if STACK[slot] == StackPane::NodeInstrument { + let nt = egui::Rect::from_min_max( + egui::pos2(hrect.right() - 2.0 * BTN_W, hrect.top()), + egui::pos2(hrect.right() - BTN_W, hrect.bottom()), + ); + let ntresp = ui.interact(nt, ui.id().with("mobile_node_toggle"), egui::Sense::click()); + if ntresp.clicked() { + state.show_instruments = !state.show_instruments; + } + } + } + + if resp.drag_started() { + state.drag = Some(StackDrag { handle, offset: 0.0 }); + state.anim = None; // a fresh drag cancels any in-flight ease + } + if resp.dragged() { + if let Some(d) = &mut state.drag { + if d.handle == handle { + d.offset += resp.drag_delta().y; + } + } + } + if resp.drag_stopped() { + if let Some(d) = state.drag.take() { + commit_drag(d, state, content_area, trigger, now, max); + } + } + if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + } +} + +fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64, max: usize) { + let h = content_area.height().max(1.0); + match d.handle { + Handle::Divider(k) if k + 1 < state.window_count => { + commit_divider(state, k, d.offset / h, now); + } + Handle::BottomEdge if state.window_count == 1 && state.window_top + 1 < N => { + // Snap the continuous reveal: near the top → revealed pane fullscreen; barely moved → + // back to the current pane; in between → a 2-pane split at the nearest preset. + let top = state.window_top; + let frac = (-d.offset / h).clamp(0.0, 1.0); + let from_w = [1.0 - frac, frac, 0.0]; + if frac >= COLLAPSE_HI { + begin_anim(state, top, 2, from_w, top + 1, 1, even_arr(1), 0.0, now); + } else if frac <= COLLAPSE_LO { + begin_anim(state, top, 2, from_w, top, 1, even_arr(1), 0.0, now); + } else { + begin_anim(state, top, 2, from_w, top, 2, nearest_preset(&from_w, 2), 0.0, now); + } + } + Handle::TopEdge if state.window_count == 1 && state.window_top > 0 => { + let top = state.window_top; + let frac = (d.offset / h).clamp(0.0, 1.0); + let from_w = [frac, 1.0 - frac, 0.0]; + if frac >= COLLAPSE_HI { + begin_anim(state, top - 1, 2, from_w, top - 1, 1, even_arr(1), 0.0, now); + } else if frac <= COLLAPSE_LO { + begin_anim(state, top - 1, 2, from_w, top, 1, even_arr(1), 0.0, now); + } else { + begin_anim(state, top - 1, 2, from_w, top - 1, 2, nearest_preset(&from_w, 2), 0.0, now); + } + } + Handle::BottomEdge if state.window_count == 2 && max == 2 => { + // Snap the two-phase 2→1 reveal to match the preview's phases: past the midpoint (phase 2, + // collapsing) → revealed pane fullscreen; a middling drag (phase 1, sliding) → the next + // 2-pane split; barely moved → stay put. + let top = state.window_top; + let frac = (-d.offset / h).clamp(0.0, 1.0); + let even2 = even_arr(2); + if top + 2 < N { + if frac >= 0.5 { + let t = ((frac - 0.5) / 0.5).clamp(0.0, 1.0); + begin_anim(state, top + 1, 2, even2, top + 2, 1, even_arr(1), t, now); + } else if frac > COLLAPSE_LO { + let t = (frac / 0.5).clamp(0.0, 1.0); + begin_anim(state, top, 2, even2, top + 1, 2, even2, t, now); + } + // else: barely moved → stay on the current [top, top+1] split. + } else if frac >= 0.5 { + begin_anim(state, top, 2, even2, top + 1, 1, even_arr(1), frac, now); + } + } + Handle::TopEdge if state.window_count == 2 && max == 2 => { + // Symmetric snap for the top-edge 2→1 reveal. + let top = state.window_top; + let frac = (d.offset / h).clamp(0.0, 1.0); + let even2 = even_arr(2); + if top > 0 { + if frac >= 0.5 { + let t = ((frac - 0.5) / 0.5).clamp(0.0, 1.0); + begin_anim(state, top - 1, 2, even2, top - 1, 1, even_arr(1), t, now); + } else if frac > COLLAPSE_LO { + let t = (frac / 0.5).clamp(0.0, 1.0); + begin_anim(state, top, 2, even2, top - 1, 2, even2, t, now); + } + // else: barely moved → stay on the current split. + } else if frac >= 0.5 { + begin_anim(state, top, 2, even2, top, 1, even_arr(1), frac, now); + } + } + _ => { + // Edges (and degenerate dividers): membership transition. Animate from the current + // config to the new one, continuing from where the drag's interp left off (~progress t). + let top = state.window_top; + let count = state.window_count; + if let Some((tt, tc, t)) = resolve(d.handle, d.offset, top, count, trigger, max) { + if t >= 0.5 { + let from_w = to_arr(&nweights(&state.weights, count)); + begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), t, now); + } + } + } + } +} + +/// Apply a divider release. 3-pane and 2-pane have different snap rules (see below). Either way the +/// result is eased in from whatever was on screen at release. +fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64) { + let top = state.window_top; + let count = state.window_count; + let nw = nweights(&state.weights, count); + let b0: f32 = nw[..=k].iter().sum(); + let b = b0 + offset_frac; // unclamped boundary position of divider k + + if count == 3 { + // `from` = what's on screen at release. + let from_w = live_weights3(k, b); + // Sizes of the two extreme panes after this drag, to test the "grown past 66%" rule. + let pane_top = from_w[0]; // grown when dragging the upper divider down + let pane_bot = from_w[2]; // grown when dragging the lower divider up + + if k == 0 && pane_top >= COLLAPSE_GROW { + // Upper pane grown out → drop the bottom pane; the surviving {0,1} divider snaps to + // where it was dropped. + if let Some((tt, tc)) = op_r4(top, 3) { + let to_w = collapse_2pane(from_w[0], from_w[1]); + begin_anim(state, top, 3, from_w, tt, tc, to_w, 0.0, now); + } + } else if k == 1 && pane_bot >= COLLAPSE_GROW { + // Bottom pane grown out → drop the top pane; surviving {1,2} divider snaps to drop. + if let Some((tt, tc)) = op_r3(top, 3) { + let to_w = collapse_2pane(from_w[1], from_w[2]); + begin_anim(state, top, 3, from_w, tt, tc, to_w, 0.0, now); + } + } else if b <= COLLAPSE_LO { + if let Some((tt, tc)) = op_r1(top, 3) { + begin_anim(state, top, 3, from_w, tt, tc, even_arr(tc), 0.0, now); + } + } else if b >= COLLAPSE_HI { + if let Some((tt, tc)) = op_r2(top, 3) { + begin_anim(state, top, 3, from_w, tt, tc, even_arr(tc), 0.0, now); + } + } else { + // Snap both dividers together: round the path parameter to the nearest preset. + let (lo, hi) = path3_range(k); + let s = s_from_boundary(k, b.clamp(lo, hi)).round().clamp(0.0, 2.0); + let to_w = PRESETS_3[s as usize]; + begin_anim(state, top, 3, from_w, top, 3, to_w, 0.0, now); + } + return; + } + + // 2-pane: group resize snapping to the nearest 2-pane preset, but releasing near an edge + // collapses to a single fullscreen pane (the one that grew). It does NOT slide in the next pane — + // that's the job of the top/bottom edge handles. + let (bmin, bmax) = boundary_bounds(count, k); + let from_w = to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax))); + if b <= COLLAPSE_LO { + // Divider dragged to the top → the top pane is squeezed out; the bottom pane goes fullscreen. + begin_anim(state, top, count, from_w, top + 1, 1, even_arr(1), 0.0, now); + } else if b >= COLLAPSE_HI { + // Divider dragged to the bottom → the bottom pane is squeezed out; the top pane goes fullscreen. + begin_anim(state, top, count, from_w, top, 1, even_arr(1), 0.0, now); + } else { + let to_w = nearest_preset(&from_w, count); + begin_anim(state, top, count, from_w, top, count, to_w, 0.0, now); + } +} + +/// Given the two surviving panes' (unnormalized) sizes after a 3→2 collapse, snap to the nearest +/// 2-pane preset by the first pane's proportion. +fn collapse_2pane(a: f32, b: f32) -> [f32; 3] { + let total = (a + b).max(1e-4); + let p0 = a / total; + nearest_preset(&[p0, 1.0 - p0, 0.0], 2) +} + +/// Switch to a new window config, easing from `from_*` to the new state over the snap duration. +/// `start_p` back-dates the animation so it can continue an in-progress drag. +fn begin_anim( + state: &mut MobileState, + from_top: usize, + from_count: usize, + from_w: [f32; 3], + to_top: usize, + to_count: usize, + to_w: [f32; 3], + start_p: f32, + now: f64, +) { + state.window_top = to_top; + state.window_count = to_count; + state.weights = to_w; + state.anim = Some(LayoutAnim { + from_top, + from_count, + from_w, + to_top, + to_count, + to_w, + start: now - start_p as f64 * SNAP_ANIM_SECS, + }); +} + +/// Switch the visible window and reset to even pane sizing, easing the transition. +fn set_window(state: &mut MobileState, top: usize, count: usize, now: f64) { + let from_w = to_arr(&nweights(&state.weights, state.window_count)); + begin_anim(state, state.window_top, state.window_count, from_w, top, count, even_arr(count), 0.0, now); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/surface.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/surface.rs new file mode 100644 index 0000000..7f2fbba --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/surface.rs @@ -0,0 +1,33 @@ +//! Full-bleed hero surface rendering: maps a mobile surface to an existing pane and renders +//! its content into a rect with no header/divider chrome. This is the de-chromed core of +//! `render_pane` (main.rs) — see the get-or-create + `render_content` pattern there. + +use eframe::egui; +use lightningbeam_core::pane::PaneType; + +use crate::panes::{NodePath, PaneInstance, PaneRenderer}; +use crate::RenderContext; + +/// Render `pane_type` full-bleed into `rect`, reusing (or creating) the cached pane instance +/// keyed by `path`. The pane clips its own drawing to `rect`, so no extra clipping is needed. +pub fn render_surface_fullbleed( + ui: &mut egui::Ui, + rect: egui::Rect, + path: &NodePath, + pane_type: PaneType, + rc: &mut RenderContext, +) { + // Get-or-create the instance for this slot (recreate if the type changed). + let needs_new = rc + .pane_instances + .get(path) + .map(|inst| inst.pane_type() != pane_type) + .unwrap_or(true); + if needs_new { + rc.pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); + } + + if let Some(inst) = rc.pane_instances.get_mut(path) { + inst.render_content(ui, rect, path, &mut rc.shared); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs new file mode 100644 index 0000000..5fd2815 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs @@ -0,0 +1,271 @@ +//! The mobile top bar: the project filename on the left, and on the right ⌕ (command palette) and +//! ⋯ (overflow commands). Both open a modal sheet whose items dispatch `MenuAction`s (which +//! `main.rs::handle_menu_action` already implements). Per the wireframe these are the +//! "commands/destinations", as opposed to the omnibutton's "tools/objects". + +use eframe::egui; + +use super::{icons, MobileState, Palette}; +use crate::menu::{MenuAction, MenuDef, MenuItemDef}; +use crate::RenderContext; + +const BTN: f32 = 40.0; + +/// Curated overflow (⋯) commands. +fn overflow_items() -> [(&'static str, MenuAction); 9] { + [ + ("Save", MenuAction::Save), + ("Save As…", MenuAction::SaveAs), + ("Open File…", MenuAction::OpenFile), + ("New file…", MenuAction::NewFile), + ("Import…", MenuAction::Import), + ("Export…", MenuAction::Export), + ("Undo", MenuAction::Undo), + ("Redo", MenuAction::Redo), + ("Preferences", MenuAction::Preferences), + ] +} + +/// Flatten the whole menu tree into (path-label, action) for the command palette. +fn flatten(defs: &'static [MenuDef], prefix: &str, out: &mut Vec<(String, MenuAction)>) { + for d in defs { + match d { + MenuDef::Item(item) => out.push((format!("{prefix}{}", item.label), item.action)), + MenuDef::Submenu { label, children } => { + flatten(children, &format!("{prefix}{label} › "), out); + } + MenuDef::Separator => {} + } + } +} + +fn all_commands() -> Vec<(String, MenuAction)> { + let mut v = Vec::new(); + flatten(MenuItemDef::menu_structure(), "", &mut v); + v +} + +pub fn render( + ui: &mut egui::Ui, + bar: egui::Rect, + full: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, + pal: &Palette, +) { + ui.painter().rect_filled(bar, 0.0, pal.header); + ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, pal.line)); + + // Filename (or app name when unsaved). + let name = rc + .shared + .container_path + .as_ref() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Lightningbeam".to_string()); + ui.painter().text( + egui::pos2(bar.left() + 14.0, bar.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(14.0), + pal.text, + ); + + // Right cluster: ⌕ (palette) then ⋯ (overflow). + let overflow_rect = egui::Rect::from_min_size(egui::pos2(bar.right() - BTN, bar.top()), egui::vec2(BTN, bar.height())); + let palette_rect = egui::Rect::from_min_size(egui::pos2(bar.right() - 2.0 * BTN, bar.top()), egui::vec2(BTN, bar.height())); + + let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click()); + ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0), + if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim }); + if sresp.clicked() { + state.palette_open = !state.palette_open; + state.overflow_open = false; + state.palette_query.clear(); + } + + let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click()); + ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0), + if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim }); + if oresp.clicked() { + state.overflow_open = !state.overflow_open; + state.palette_open = false; + } + + if state.overflow_open { + render_overflow(ui, full, rc, state, pal); + } else if state.palette_open { + render_palette(ui, full, rc, state, pal); + } +} + +/// Landscape: the app bar folded into the middle of the top pane header — filename + ⌕ + ⋯ as a +/// centered cluster (the pane's own grip/label sits to the left, its buttons to the right). No bar +/// background or divider line; the header already drew them. +pub fn render_inline( + ui: &mut egui::Ui, + header: egui::Rect, + full: egui::Rect, + rc: &mut RenderContext, + state: &mut MobileState, + pal: &Palette, +) { + let name = rc + .shared + .container_path + .as_ref() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Lightningbeam".to_string()); + let btn = header.height().min(BTN); + let gap = 6.0; + // Reserve gutters so the cluster never overlaps the pane header's own grip/label (left) or its + // fullscreen / node-toggle buttons (right). + let left_gutter = 150.0; + let right_gutter = 2.0 * BTN + 16.0; + let span_left = header.left() + left_gutter; + let span_right = (header.right() - right_gutter).max(span_left); + let span_w = span_right - span_left; + + // Filename elided to fit whatever's left after the two buttons. + let max_name_w = (span_w - gap - 2.0 * btn).max(0.0); + let galley = { + let mut job = + egui::text::LayoutJob::simple_singleline(name, egui::FontId::proportional(14.0), pal.text); + job.wrap.max_width = max_name_w; + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + ui.painter().layout_job(job) + }; + let name_w = galley.rect.width(); + let name_h = galley.rect.height(); + let cluster_w = name_w + gap + 2.0 * btn; + // Center within the reserved span, then clamp so the right edge stays inside it. + let x0 = (span_left + (span_w - cluster_w) / 2.0).clamp(span_left, (span_right - cluster_w).max(span_left)); + let cy = header.center().y; + let bt = header.top() + (header.height() - btn) / 2.0; + + ui.painter().galley(egui::pos2(x0, cy - name_h / 2.0), galley, pal.text); + + let palette_rect = egui::Rect::from_min_size(egui::pos2(x0 + name_w + gap, bt), egui::vec2(btn, btn)); + let overflow_rect = egui::Rect::from_min_size(egui::pos2(palette_rect.right(), bt), egui::vec2(btn, btn)); + + let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click()); + ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0), + if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim }); + if sresp.clicked() { + state.palette_open = !state.palette_open; + state.overflow_open = false; + state.palette_query.clear(); + } + + let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click()); + ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0), + if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim }); + if oresp.clicked() { + state.overflow_open = !state.overflow_open; + state.palette_open = false; + } + + if state.overflow_open { + render_overflow(ui, full, rc, state, pal); + } else if state.palette_open { + render_palette(ui, full, rc, state, pal); + } +} + +/// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect). +fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str, pal: &Palette) -> (bool, egui::Rect) { + let scrim = ui.interact(full, ui.id().with(("mobile_topbar_scrim", id)), egui::Sense::click()); + ui.painter().rect_filled(full, 0.0, pal.scrim); + let panel = egui::Rect::from_min_max( + egui::pos2(full.left() + 16.0, full.top() + 44.0), + egui::pos2(full.right() - 16.0, full.bottom() - 60.0), + ); + ui.painter().rect_filled(panel, 14.0, pal.surface); + ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + (scrim.clicked(), panel) +} + +fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize), pal: &Palette) -> bool { + let resp = ui.interact(r, ui.id().with(("mobile_cmd", key.0, key.1)), egui::Sense::click()); + ui.painter().rect_filled(r, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface }); + ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, pal.line)); + ui.painter().text( + egui::pos2(r.left() + 14.0, r.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + pal.text, + ); + resp.clicked() +} + +fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { + let (backdrop, panel) = open_panel(ui, full, "overflow", pal); + let mut close = backdrop; + let items = overflow_items(); + let row_h = 44.0; + let inner = panel.shrink(8.0); + for (i, (label, action)) in items.iter().enumerate() { + let r = egui::Rect::from_min_size( + egui::pos2(inner.left(), inner.top() + i as f32 * row_h), + egui::vec2(inner.width(), row_h), + ); + if r.bottom() > inner.bottom() { + break; + } + if command_button(ui, r, label, ("of", i), pal) { + rc.shared.pending_menu_actions.push(*action); + close = true; + } + } + if close { + state.overflow_open = false; + } +} + +fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { + let (backdrop, panel) = open_panel(ui, full, "palette", pal); + let mut close = backdrop; + let inner = panel.shrink(8.0); + + // Search field (real egui widget). + let field = egui::Rect::from_min_size(inner.min, egui::vec2(inner.width(), 30.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::left_to_right(egui::Align::Center))); + let te = child.add( + egui::TextEdit::singleline(&mut state.palette_query) + .hint_text("Search commands…") + .desired_width(inner.width()), + ); + // Focus the field only when it isn't already focused — re-requesting every frame would stomp + // focus and prevent anything else in the panel from taking it. + if !te.has_focus() { + te.request_focus(); + } + + // Filtered list. + let q = state.palette_query.to_lowercase(); + let cmds = all_commands(); + let row_h = 38.0; + let list_top = inner.top() + 38.0; + let mut y = list_top; + for (i, (label, action)) in cmds.iter().enumerate() { + if !q.is_empty() && !label.to_lowercase().contains(&q) { + continue; + } + let r = egui::Rect::from_min_size(egui::pos2(inner.left(), y), egui::vec2(inner.width(), row_h)); + if r.bottom() > inner.bottom() { + break; + } + if command_button(ui, r, label, ("pal", i), pal) { + rc.shared.pending_menu_actions.push(*action); + close = true; + } + y += row_h; + } + if close { + state.palette_open = false; + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs new file mode 100644 index 0000000..8b4bc1b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs @@ -0,0 +1,116 @@ +//! The transport "floor": the always-present bottom bar with play/pause, the timecode, and a +//! project scrub. Wired to the audio controller exactly like `TimelinePane`'s header. + +use eframe::egui; + +use super::{icons, Palette}; +use crate::panes::SharedPaneState; + +pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState, pal: &Palette) { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, pal.surface); + painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line)); + + let cy = rect.center().y; + + // --- Play / pause button (circle on the left) --- + let btn_r = 18.0; + let btn_center = egui::pos2(rect.left() + 20.0 + btn_r, cy); + let btn_rect = egui::Rect::from_center_size(btn_center, egui::vec2(btn_r * 2.0, btn_r * 2.0)); + let btn_resp = ui.interact(btn_rect, ui.id().with("mobile_transport_play"), egui::Sense::click()); + painter.circle_filled(btn_center, btn_r, pal.accent); + let glyph = if *shared.is_playing { icons::PAUSE } else { icons::PLAY }; + painter.text( + btn_center, + egui::Align2::CENTER_CENTER, + glyph, + icons::font(16.0), + pal.on_accent, + ); + if btn_resp.clicked() { + *shared.is_playing = !*shared.is_playing; + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + if *shared.is_playing { + controller.seek(*shared.playback_time); + controller.play(); + } else { + controller.pause(); + } + } + } + + // --- Playhead readout: format follows the project's TimelineMode (set at creation by type). --- + let t = *shared.playback_time; + let tc = { + use lightningbeam_core::document::TimelineMode; + let doc = shared.action_executor.document(); + match doc.timeline_mode { + TimelineMode::Measures => { + let pos = lightningbeam_core::beat_time::time_to_measure(t, doc.tempo_map(), &doc.time_signature); + format!("{}.{}.{:02}", pos.measure, pos.beat, pos.tick / 10) + } + TimelineMode::Frames => format_timecode(t, doc.framerate.max(1.0)), + TimelineMode::Seconds => { + let total = t.max(0.0); + let m = (total / 60.0).floor() as u32; + format!("{:02}:{:06.3}", m, total % 60.0) + } + } + }; + let tc_left = btn_rect.right() + 12.0; + painter.text( + egui::pos2(tc_left, cy), + egui::Align2::LEFT_CENTER, + &tc, + egui::FontId::monospace(13.0), + pal.text, + ); + let tc_width = 78.0; + + // --- Project scrub (fills the remaining width) --- + let duration = shared.action_executor.document().duration.max(1.0); + let scrub_left = tc_left + tc_width; + let scrub_rect = egui::Rect::from_min_max( + egui::pos2(scrub_left, cy - 3.0), + egui::pos2(rect.right() - 14.0, cy + 3.0), + ); + let scrub_resp = ui.interact( + scrub_rect.expand2(egui::vec2(0.0, 12.0)), + ui.id().with("mobile_transport_scrub"), + egui::Sense::click_and_drag(), + ); + painter.rect_filled(scrub_rect, 3.0, pal.line); + let frac = (*shared.playback_time / duration).clamp(0.0, 1.0) as f32; + let filled = egui::Rect::from_min_max( + scrub_rect.min, + egui::pos2(scrub_rect.left() + scrub_rect.width() * frac, scrub_rect.bottom()), + ); + painter.rect_filled(filled, 3.0, pal.accent.gamma_multiply(0.5)); + let head_x = scrub_rect.left() + scrub_rect.width() * frac; + painter.vline( + head_x, + (scrub_rect.top() - 4.0)..=(scrub_rect.bottom() + 4.0), + egui::Stroke::new(2.0, pal.accent), + ); + + if (scrub_resp.dragged() || scrub_resp.clicked()) && scrub_rect.width() > 0.0 { + if let Some(pos) = scrub_resp.interact_pointer_pos() { + let f = ((pos.x - scrub_rect.left()) / scrub_rect.width()).clamp(0.0, 1.0) as f64; + let new_time = f * duration; + *shared.playback_time = new_time; + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.seek(new_time); + } + } + } +} + +fn format_timecode(seconds: f64, fps: f64) -> String { + let total = seconds.max(0.0); + let minutes = (total / 60.0).floor() as u32; + let secs = (total % 60.0).floor() as u32; + let frames = ((total - total.floor()) * fps).floor() as u32; + format!("{:02}:{:02}:{:02}", minutes, secs, frames) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 0132c8f..cc39985 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1292,6 +1292,9 @@ impl AssetLibraryPane { lightningbeam_core::layer::AnyLayer::Raster(_) => { // Raster layers don't have their own clip instances } + lightningbeam_core::layer::AnyLayer::Text(_) => { + // Text layers don't have their own clip instances + } } } false diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 0cb524a..f776dcf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -44,12 +44,23 @@ pub struct InfopanelPane { selected_tool_gradient_stop: Option, /// FPS value captured when a drag/focus-in starts (for single-undo-action on commit) fps_drag_start: Option, + /// In-progress layer-name edit buffer, keyed by the layer being edited. + layer_name_edit: Option<(Uuid, String)>, + /// Layer opacity/volume captured when a slider drag starts (single-undo on commit). + layer_opacity_drag_start: Option, + layer_volume_drag_start: Option, /// Resize mode for the active raster layer's "to document size" action (scale vs canvas). raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode, + /// Font families already registered into egui for the family-picker previews + /// (rendered each entry in its own font), so we only load each once. + registered_preview_fonts: std::collections::HashSet, } impl InfopanelPane { pub fn new() -> Self { + // Kick off background loading of the font-picker fonts at startup so the dropdown + // is ready without a hitch by the time the user opens it. + lightningbeam_core::fonts::start_preload(); let presets = bundled_brushes(); let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { @@ -63,9 +74,30 @@ impl InfopanelPane { selected_shape_gradient_stop: None, selected_tool_gradient_stop: None, fps_drag_start: None, + layer_name_edit: None, + layer_opacity_drag_start: None, + layer_volume_drag_start: None, raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale, + registered_preview_fonts: std::collections::HashSet::new(), } } + + /// Register pre-loaded `bytes` for `family` into egui under `FontFamily::Name(family)` + /// so a label can be rendered in that font. Cheap (no font IO); the add is idempotent + /// and batched into one atlas rebuild at frame end, so the preview appears next frame. + fn register_font_bytes(&mut self, ctx: &egui::Context, family: &str, bytes: Vec) { + if family.is_empty() || !self.registered_preview_fonts.insert(family.to_string()) { + return; + } + ctx.add_font(egui::epaint::text::FontInsert::new( + family, + egui::FontData::from_owned(bytes), + vec![egui::epaint::text::InsertFontFamily { + family: egui::FontFamily::Name(family.into()), + priority: egui::epaint::text::FontPriority::Highest, + }], + )); + } } /// Aggregated info about the current DCEL selection @@ -1141,8 +1173,9 @@ impl InfopanelPane { } /// Render layer info section - fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) { - let document = shared.action_executor.document(); + fn render_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_ids: &[Uuid]) { + use lightningbeam_core::actions::{set_layer_properties::LayerProperty, SetLayerPropertiesAction}; + use lightningbeam_core::layer::AudioLayerType; egui::CollapsingHeader::new("Layer") .id_salt(("layer_info", path)) @@ -1150,52 +1183,153 @@ impl InfopanelPane { .show(ui, |ui| { ui.add_space(4.0); - if layer_ids.len() == 1 { - if let Some(layer) = document.get_layer(&layer_ids[0]) { - ui.horizontal(|ui| { - ui.label("Name:"); - ui.label(layer.name()); - }); + if layer_ids.len() != 1 { + ui.label(format!("{} layers selected", layer_ids.len())); + ui.add_space(4.0); + return; + } + let layer_id = layer_ids[0]; + // Snapshot the current values, then drop the document borrow before mutating shared. + let Some((type_name, is_audio, name, opacity, volume, muted, soloed, locked)) = ({ + let document = shared.action_executor.document(); + document.get_layer(&layer_id).map(|layer| { let type_name = match layer { AnyLayer::Vector(_) => "Vector", AnyLayer::Audio(a) => match a.audio_layer_type { - lightningbeam_core::layer::AudioLayerType::Midi => "MIDI", - lightningbeam_core::layer::AudioLayerType::Sampled => "Audio", + AudioLayerType::Midi => "MIDI", + AudioLayerType::Sampled => "Audio", }, AnyLayer::Video(_) => "Video", AnyLayer::Effect(_) => "Effect", AnyLayer::Group(_) => "Group", AnyLayer::Raster(_) => "Raster", + AnyLayer::Text(_) => "Text", }; - ui.horizontal(|ui| { - ui.label("Type:"); - ui.label(type_name); - }); + ( + type_name, + matches!(layer, AnyLayer::Audio(_)), + layer.name().to_string(), + layer.opacity(), + layer.volume(), + layer.muted(), + layer.soloed(), + layer.locked(), + ) + }) + }) else { + return; + }; - ui.horizontal(|ui| { - ui.label("Opacity:"); - ui.label(format!("{:.0}%", layer.opacity() * 100.0)); - }); - - if matches!(layer, AnyLayer::Audio(_)) { - ui.horizontal(|ui| { - ui.label("Volume:"); - ui.label(format!("{:.0}%", layer.volume() * 100.0)); - }); - } - - if layer.muted() { - ui.label("Muted"); - } - if layer.locked() { - ui.label("Locked"); + // Name (editable; commits on Enter / focus loss). + if self.layer_name_edit.as_ref().map(|(id, _)| *id) != Some(layer_id) { + self.layer_name_edit = Some((layer_id, name.clone())); + } + ui.horizontal(|ui| { + ui.label("Name:"); + let buf = &mut self.layer_name_edit.as_mut().unwrap().1; + let resp = ui.text_edit_singleline(buf); + let commit = resp.lost_focus() + && (ui.input(|i| i.key_pressed(egui::Key::Enter)) || !resp.has_focus()); + if commit { + let new_name = buf.trim().to_string(); + if !new_name.is_empty() && new_name != name { + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Name(new_name), + ))); } } - } else { - ui.label(format!("{} layers selected", layer_ids.len())); + }); + + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label(type_name); + }); + + // Opacity slider (single-undo: live-preview while dragging, commit on release). + ui.horizontal(|ui| { + ui.label("Opacity:"); + let mut op = opacity; + let r = ui.add(egui::Slider::new(&mut op, 0.0..=1.0).show_value(false)); + ui.label(format!("{:.0}%", op * 100.0)); + if (r.drag_started() || r.gained_focus()) && self.layer_opacity_drag_start.is_none() { + self.layer_opacity_drag_start = Some(opacity); + } + if r.changed() { + if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { + l.set_opacity(op); + } + } + if r.drag_stopped() || r.lost_focus() { + if let Some(start) = self.layer_opacity_drag_start.take() { + if (start - op).abs() > 1e-6 { + if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { + l.set_opacity(start); // revert so the action owns the change + } + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Opacity(op), + ))); + } + } + } + }); + + if is_audio { + ui.horizontal(|ui| { + ui.label("Volume:"); + let mut vol = volume; + let r = ui.add(egui::Slider::new(&mut vol, 0.0..=1.0).show_value(false)); + ui.label(format!("{:.0}%", vol * 100.0)); + if (r.drag_started() || r.gained_focus()) && self.layer_volume_drag_start.is_none() { + self.layer_volume_drag_start = Some(volume); + } + if r.changed() { + if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { + l.set_volume(vol); + } + } + if r.drag_stopped() || r.lost_focus() { + if let Some(start) = self.layer_volume_drag_start.take() { + if (start - vol).abs() > 1e-6 { + if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { + l.set_volume(start); + } + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Volume(vol), + ))); + } + } + } + }); } + // Toggles: mute / solo (audio) / lock. + ui.horizontal(|ui| { + if is_audio { + if ui.selectable_label(muted, "Mute").clicked() { + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Muted(!muted), + ))); + } + if ui.selectable_label(soloed, "Solo").clicked() { + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Soloed(!soloed), + ))); + } + } + if ui.selectable_label(locked, "Lock").clicked() { + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Locked(!locked), + ))); + } + }); + ui.add_space(4.0); }); } @@ -1247,6 +1381,186 @@ impl InfopanelPane { }); } + /// Render a text-layer section: edit the text content, font, size, color, + /// alignment, and box size of the active text layer. Driven by the *active* + /// layer so it appears whenever a text layer is selected. + fn render_text_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) { + use lightningbeam_core::text_layer::TextAlign; + use lightningbeam_core::actions::{ResizeTextBoxAction, SetTextContentAction}; + + // Snapshot current values, then drop the document borrow before mutating shared. + let snapshot = { + let document = shared.action_executor.document(); + match document.get_layer(&layer_id) { + Some(AnyLayer::Text(tl)) => { + Some((tl.content.clone(), tl.box_origin, tl.box_width, tl.box_height)) + } + _ => None, + } + }; + let Some((content0, origin, w0, h0)) = snapshot else { return }; + + // Families egui has actually bound (added fonts take effect next frame). Using an + // unbound `FontFamily::Name` panics, so only preview-render families in this set. + let bound_families: std::collections::HashSet = + ui.ctx().fonts(|f| f.families().into_iter().collect()); + // Set when the font dropdown popup is open this frame (its closure only runs then), + // so we can synchronously finish loading any not-yet-preloaded fonts the user sees. + let mut dropdown_open = false; + + egui::CollapsingHeader::new("Text") + .id_salt(("text_layer", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + let mut content = content0.clone(); + let mut content_changed = false; + + ui.label("Text:"); + if ui + .add(egui::TextEdit::multiline(&mut content.text).desired_rows(2).desired_width(f32::INFINITY)) + .changed() + { + content_changed = true; + } + + ui.horizontal(|ui| { + ui.label("Size:"); + if ui + .add(egui::DragValue::new(&mut content.font_size).range(1.0..=2000.0).speed(0.5)) + .changed() + { + content_changed = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Color:"); + // content.color is sRGB-encoded RGBA in 0..1 (matches peniko Color::new). + let mut col = egui::Color32::from_rgba_unmultiplied( + (content.color[0] * 255.0).round() as u8, + (content.color[1] * 255.0).round() as u8, + (content.color[2] * 255.0).round() as u8, + (content.color[3] * 255.0).round() as u8, + ); + if ui.color_edit_button_srgba(&mut col).changed() { + content.color = [ + col.r() as f32 / 255.0, + col.g() as f32 / 255.0, + col.b() as f32 / 255.0, + col.a() as f32 / 255.0, + ]; + content_changed = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Align:"); + for (label, a) in [("L", TextAlign::Left), ("C", TextAlign::Center), ("R", TextAlign::Right), ("J", TextAlign::Justify)] { + if ui.selectable_label(content.align == a, label).clicked() { + content.align = a; + content_changed = true; + } + } + }); + + // Font family picker (bundled families first, then system; embedded fonts + // register under their own names on load). + ui.horizontal(|ui| { + ui.label("Font:"); + let current_label = if content.font_family.is_empty() { + "(Default)".to_string() + } else { + content.font_family.clone() + }; + egui::ComboBox::from_id_salt(("text_font", path)) + .selected_text(current_label) + .show_ui(ui, |ui| { + dropdown_open = true; + if ui.selectable_label(content.font_family.is_empty(), "(Default)").clicked() { + content.font_family.clear(); + content_changed = true; + } + // The popup closure only runs while the dropdown is open, so each + // entry is rendered in its own font and queued for registration. + for fam in lightningbeam_core::fonts::families() { + let named = egui::FontFamily::Name(fam.as_str().into()); + // Render in its own font only once egui has bound it; otherwise + // plain (and queue it for registration so it previews next frame). + let label = if bound_families.contains(&named) { + egui::RichText::new(&fam).family(named) + } else { + egui::RichText::new(&fam) + }; + if ui.selectable_label(content.font_family == fam, label).clicked() { + content.font_family = fam.clone(); + content_changed = true; + } + } + }); + // Missing-font indicator. + if !content.font_family.is_empty() + && !lightningbeam_core::fonts::family_available(&content.font_family) + { + ui.colored_label(egui::Color32::from_rgb(230, 160, 60), "⚠ missing"); + } + }); + + // Box size. + let mut bw = w0; + let mut bh = h0; + let mut box_changed = false; + ui.horizontal(|ui| { + ui.label("Box:"); + if ui.add(egui::DragValue::new(&mut bw).range(1.0..=100000.0).speed(1.0)).changed() { + box_changed = true; + } + ui.label("×"); + if ui.add(egui::DragValue::new(&mut bh).range(1.0..=100000.0).speed(1.0)).changed() { + box_changed = true; + } + }); + + ui.add_space(4.0); + + if content_changed { + shared.pending_actions.push(Box::new(SetTextContentAction::new(layer_id, content))); + } + if box_changed { + shared.pending_actions.push(Box::new(ResizeTextBoxAction::new(layer_id, origin, bw, bh))); + } + }); + + // Register fonts the background preloader has finished (cheap: bytes already loaded), + // a few per frame so each atlas rebuild stays small. Only fall back to synchronous + // loading for families the user is actively viewing in the open dropdown that the + // background hasn't reached yet. Repaint while there's more to do or the preloader + // is still running, so registration keeps flowing without input. + let ctx = ui.ctx().clone(); + const PER_FRAME: usize = 4; + let mut budget = PER_FRAME; + let mut more = false; + for fam in lightningbeam_core::fonts::families() { + if self.registered_preview_fonts.contains(&fam) { + continue; + } + let bytes = lightningbeam_core::fonts::take_preloaded(&fam).or_else(|| { + if dropdown_open { lightningbeam_core::fonts::family_font_bytes(&fam) } else { None } + }); + if let Some(bytes) = bytes { + if budget == 0 { + more = true; + break; + } + self.register_font_bytes(&ctx, &fam, bytes); + budget -= 1; + } + } + if more || !lightningbeam_core::fonts::preload_done() { + ctx.request_repaint(); + } + } + /// Render clip instance info section fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) { let document = shared.action_executor.document(); @@ -1270,6 +1584,7 @@ impl InfopanelPane { AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(ci) = instances.iter().find(|c| c.id == ci_id) { found = true; @@ -1601,6 +1916,7 @@ impl PaneRenderer for InfopanelPane { // active (independent of selection focus, since painting doesn't focus the layer). if let Some(active_id) = *shared.active_layer_id { self.render_raster_layer_section(ui, path, shared, active_id); + self.render_text_layer_section(ui, path, shared, active_id); } // Onion-skinning view settings — always available, regardless of selection. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/keyboard_layout.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/keyboard_layout.rs new file mode 100644 index 0000000..038665d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/keyboard_layout.rs @@ -0,0 +1,108 @@ +//! Shared horizontal keyboard geometry (pitch → x), used by both the Virtual Piano and the mobile +//! portrait "Synthesia" Piano Roll so their columns line up with the keys. It is **width-driven** +//! (key width from the pane width, not its height) and supports a smooth horizontal **pan** (pixels) +//! so the roll and keyboard scroll together and stay aligned. + +/// Number of white keys strictly before each pitch-class within its octave. +const WHITES_BEFORE: [i32; 12] = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6]; +/// White pitch-classes, indexed by white-key-within-octave. +const WHITE_PC: [i32; 7] = [0, 2, 4, 5, 7, 9, 11]; +/// Target on-screen white-key width (px); the visible white-key count approximates it. +const TARGET_WHITE_KEY_W: f32 = 40.0; + +/// Absolute white-key index of a note, counting white keys from MIDI 0. +fn awi(note: u8) -> i32 { + (note as i32 / 12) * 7 + WHITES_BEFORE[(note % 12) as usize] +} + +/// The white note at a given absolute white-key index. +fn white_note_from_awi(a: i32) -> u8 { + let oct = a.div_euclid(7); + let rem = a.rem_euclid(7) as usize; + (oct * 12 + WHITE_PC[rem]).clamp(0, 127) as u8 +} + +/// A horizontal piano key layout for a pane width, octave center, and horizontal pan. +#[derive(Clone, Copy)] +pub struct KeyboardLayout { + pub white_key_width: f32, + pub black_key_width: f32, + /// `note_x(white n) == base_x + awi(n) * white_key_width`. + base_x: f32, + rect_left: f32, + rect_width: f32, +} + +impl KeyboardLayout { + pub fn is_black_key(note: u8) -> bool { + matches!(note % 12, 1 | 3 | 6 | 8 | 10) + } + + /// Build a layout. `pan_x` shifts everything right (drag-right reveals lower keys); at `pan_x == 0` + /// the octave-center key sits in the middle of the pane. + pub fn from_width(origin_x: f32, width: f32, octave: i8, pan_x: f32) -> Self { + let vis = ((width / TARGET_WHITE_KEY_W).round() as u32).clamp(7, 24) as f32; + let white_key_width = width / vis; + let black_key_width = white_key_width * 0.6; + let center = (60 + octave as i32 * 12).clamp(0, 127) as u8; + let base_x = origin_x + width / 2.0 - (awi(center) as f32 + 0.5) * white_key_width + pan_x; + Self { white_key_width, black_key_width, base_x, rect_left: origin_x, rect_width: width } + } + + /// Snap a pan offset to the nearest whole key (for release-snap). + pub fn snap_pan(&self, pan_x: f32) -> f32 { + (pan_x / self.white_key_width).round() * self.white_key_width + } + + /// Left x of a key's rect (black keys straddle the white boundary). + pub fn note_x(&self, note: u8) -> f32 { + let x = self.base_x + awi(note) as f32 * self.white_key_width; + if Self::is_black_key(note) { + x - self.black_key_width / 2.0 + } else { + x + } + } + + pub fn note_width(&self, note: u8) -> f32 { + if Self::is_black_key(note) { + self.black_key_width + } else { + self.white_key_width + } + } + + pub fn note_center_x(&self, note: u8) -> f32 { + self.note_x(note) + self.note_width(note) / 2.0 + } + + /// Leftmost visible white key (with one key of margin). + pub fn first_visible_white(&self) -> u8 { + let a = ((self.rect_left - self.base_x) / self.white_key_width).floor() as i32 - 1; + white_note_from_awi(a.max(0)) + } + + /// Rightmost visible note (with one key of margin). + pub fn last_visible_note(&self) -> u8 { + let a = ((self.rect_left + self.rect_width - self.base_x) / self.white_key_width).ceil() as i32 + 1; + white_note_from_awi(a).min(127) + } + + pub fn visible_notes(&self) -> std::ops::RangeInclusive { + self.first_visible_white()..=self.last_visible_note() + } + + /// Which key is at screen x (black keys, drawn on top, take precedence). + pub fn x_to_note(&self, x: f32) -> u8 { + for note in self.visible_notes() { + if Self::is_black_key(note) { + let c = self.note_center_x(note); + if (x - c).abs() <= self.black_key_width / 2.0 { + return note; + } + } + } + let a = ((x - self.base_x) / self.white_key_width).floor() as i32; + white_note_from_awi(a.max(0)).min(127) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 7d1acb8..d79eebe 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -72,6 +72,7 @@ pub mod gradient_editor; pub mod timeline; pub mod infopanel; pub mod outliner; +pub mod keyboard_layout; pub mod piano_roll; pub mod virtual_piano; pub mod node_editor; @@ -212,10 +213,15 @@ pub struct SharedPaneState<'a> { pub editing_instance_id: Option, /// The parent layer ID containing the clip instance being edited pub editing_parent_layer_id: Option, + /// The full clip_id path being edited, outermost → current (for breadcrumbs). Empty at root. + pub editing_clip_path: Vec, /// Request to enter a movie clip for editing: (clip_id, instance_id, parent_layer_id) pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>, /// Request to exit the current movie clip pub pending_exit_clip: &'a mut bool, + /// Request to exit up to a specific editing depth (number of clips to keep); e.g. a breadcrumb + /// click. `Some(0)` exits all the way to the document root. + pub pending_exit_to_depth: &'a mut Option, /// Currently active layer ID pub active_layer_id: &'a mut Option, /// Current tool interaction state (mutable for tools to modify) @@ -356,6 +362,37 @@ pub struct SharedPaneState<'a> { /// on the first frame; panes (e.g. infopanel) convert the pixel data to egui /// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. pub brush_preview_pixels: &'a std::sync::Arc)>>>, + /// True when rendering the phone/mobile shell (panes can render more compactly). + pub is_mobile: bool, + /// Device orientation for the mobile shell: portrait (tall) vs landscape (wide). Defaults to + /// `true`; the mobile shell sets it from the available rect. Panes that reflow (e.g. the Piano + /// Roll's vertical vs conventional layout) key off this. + pub is_portrait: bool, + /// Shared keyboard octave offset (C4-relative), so the mobile Virtual Piano and the portrait + /// Piano Roll agree on which keys are visible and stay column-aligned. + pub keyboard_octave: &'a mut i8, + /// Shared horizontal keyboard pan (px) for smooth left/right scroll of the mobile keyboard+roll. + pub keyboard_pan_x: &'a mut f32, + /// Whether the mobile instrument pane should show the falling-notes roll above the keys. Driven + /// by the shell from the *snapped* pane size-class so the reveal happens at a stack snap point. + pub instrument_show_roll: bool, + /// Set by the mobile instrument header's "Presets" button; the shell opens the Preset Browser. + pub open_instrument_browser: &'a mut bool, + /// Set by the mobile instrument header's REC button; the Timeline pane picks it up and toggles + /// recording (reusing its full count-in / clip-creation flow). + pub pending_record_toggle: &'a mut bool, + /// Mobile long-press context menu request. A pane sets this on `response.secondary_clicked()` + /// (which fires on long-press) with the items relevant to what was pressed; the mobile shell + /// renders one persistent popup and dispatches the chosen `MenuAction`. `None` = no menu. + pub mobile_context_menu: &'a mut Option, +} + +/// A mobile long-press context menu: a screen position and a list of `(label, action)` items. +/// Rendered by the mobile shell; each item dispatches its `MenuAction` via `pending_menu_actions`. +#[derive(Clone)] +pub struct MobileContextMenu { + pub pos: egui::Pos2, + pub items: Vec<(String, crate::menu::MenuAction)>, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs new file mode 100644 index 0000000..824f49d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mobile.rs @@ -0,0 +1,738 @@ +//! Mobile (touch) node editor — Focus & Patch views (wireframe Plate 07). +//! +//! Swapped in for the desktop `draw_graph_editor` canvas when `shared.is_mobile`. Focus shows one +//! module's parameters as big touch controls plus navigation; Patch does tap-to-cable wiring. All +//! edits reuse the existing dispatch: mutating a param's `ValueType` in place is picked up by +//! `check_parameter_changes`, and add/connect/disconnect go through `NodeGraphAction`. + +use super::graph_data::{DataType, NodeTemplate, ValueType}; +use super::{actions, NodeGraphPane}; +use crate::mobile::icons; +use eframe::egui; +use egui_node_graph2::{InputId, InputParamKind, NodeDataTrait, NodeId, NodeTemplateTrait, OutputId}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum NodeViewMode { + Focus, + Patch, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PortDir { + In, + Out, +} + +/// An armed port awaiting a compatible endpoint to cable to. +#[derive(Clone, Copy)] +pub struct PatchPick { + node: NodeId, + port: usize, + dir: PortDir, + typ: DataType, +} + +pub struct MobileNodeState { + pub mode: NodeViewMode, + /// The module currently shown in Focus (and centred in Patch). + pub focus_node: Option, + /// Whether the add-node picker overlay is open. + pub show_add: bool, + /// Search filter in the add-node picker. + pub add_search: String, + /// Armed port in Patch awaiting a compatible endpoint. + patch_pick: Option, +} + +impl Default for MobileNodeState { + fn default() -> Self { + Self { + mode: NodeViewMode::Focus, + focus_node: None, + show_add: false, + add_search: String::new(), + patch_pick: None, + } + } +} + +impl NodeGraphPane { + pub(super) fn render_mobile( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &mut crate::panes::SharedPaneState, + ) { + let bg = shared.theme.bg_color(&["#node-editor", ".pane-content"], ui.ctx(), egui::Color32::from_gray(28)); + ui.painter().rect_filled(rect, 0.0, bg); + + // Resolve the focus node: keep the current one if it still exists, else the selected node, + // else the first node in the graph. + let focus_valid = self + .mobile + .focus_node + .map_or(false, |id| self.state.graph.nodes.get(id).is_some()); + if !focus_valid { + self.mobile.focus_node = self + .state + .selected_nodes + .iter() + .next() + .copied() + .or_else(|| self.state.graph.iter_nodes().next()); + } + + // Header: Focus/Patch toggle + focused node name. + let header_h = 44.0; + let header = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), rect.top() + header_h)); + let body = egui::Rect::from_min_max(egui::pos2(rect.left(), header.bottom()), rect.max); + + let name = self + .mobile + .focus_node + .and_then(|id| self.state.graph.nodes.get(id)) + .map(|n| n.label.clone()) + .unwrap_or_else(|| "—".to_string()); + ui.painter().line_segment( + [egui::pos2(header.left(), header.bottom()), egui::pos2(header.right(), header.bottom())], + egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + ); + let mut mode = self.mobile.mode; + ui.scope_builder( + egui::UiBuilder::new().max_rect(header.shrink(8.0)).layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + if ui.selectable_label(mode == NodeViewMode::Focus, "Focus").clicked() { + mode = NodeViewMode::Focus; + } + if ui.selectable_label(mode == NodeViewMode::Patch, "Patch").clicked() { + mode = NodeViewMode::Patch; + } + ui.add_space(8.0); + ui.label(egui::RichText::new(name).strong()); + }, + ); + self.mobile.mode = mode; + + // Keep backend-id map current so embedded `bottom_ui` (sampler/script/etc.) targets the + // right backend node (mirrors the desktop pre-draw sync). + self.user_state.node_backend_ids = self + .node_id_map + .iter() + .map(|(&nid, bid)| (nid, bid.index())) + .collect(); + + match self.mobile.mode { + NodeViewMode::Focus => self.render_mobile_focus(ui, body, shared), + NodeViewMode::Patch => self.render_mobile_patch(ui, body, shared), + } + + // Same dispatch tail as the desktop path: apply param edits and run any queued action. + self.check_parameter_changes(shared); + self.execute_pending_action(shared); + + // Drain the custom-UI loads that have self-contained handlers (sampler + script sample). + // Other bespoke interactions (sequencer grid, NAM model, script canvas) are a case-by-case + // follow-up; clear their queues so they don't accumulate. + if let Some(load) = self.user_state.pending_sampler_load.take() { + self.handle_pending_sampler_load(load, shared); + } + if let Some(load) = self.user_state.pending_script_sample_load.take() { + self.handle_pending_script_sample_load(load, shared); + } + self.user_state.pending_sequencer_changes.clear(); + self.user_state.pending_draw_param_changes.clear(); + self.user_state.pending_root_note_changes.clear(); + } + + fn render_mobile_focus( + &mut self, + ui: &mut egui::Ui, + body: egui::Rect, + shared: &mut crate::panes::SharedPaneState, + ) { + let Some(focus) = self.mobile.focus_node else { + return; + }; + + // Full-width minimap strip across the top; everything else below it. + let mm_h = 96.0; + let mm = egui::Rect::from_min_max(body.min, egui::pos2(body.right(), body.top() + mm_h)); + // Reserve an add-node button strip at the bottom. + let add_h = 46.0; + let add_rect = egui::Rect::from_min_max(egui::pos2(body.left(), body.bottom() - add_h), body.max); + let content = egui::Rect::from_min_max(egui::pos2(body.left(), mm.bottom()), egui::pos2(body.right(), add_rect.top())); + + // Minimap first (tap a node to focus it). + let mut jump: Option = self.render_minimap(ui, mm, focus); + + // Connection travel chips (owned) + params. + let (in_chips, out_chips) = self.focus_chips(focus); + let inputs: Vec<(String, InputId)> = self + .state + .graph + .nodes + .get(focus) + .map(|n| n.inputs.clone()) + .unwrap_or_default(); + + ui.scope_builder( + egui::UiBuilder::new().max_rect(content.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + if !in_chips.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("in").weak()); + for (label, node) in &in_chips { + if ui.button(label.as_str()).clicked() { + jump = Some(*node); + } + } + }); + } + if !out_chips.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("out").weak()); + for (label, node) in &out_chips { + if ui.button(label.as_str()).clicked() { + jump = Some(*node); + } + } + }); + } + if !in_chips.is_empty() || !out_chips.is_empty() { + ui.separator(); + } + egui::ScrollArea::vertical().show(ui, |ui| { + let mut any = false; + for (name, input_id) in &inputs { + let Some(param) = self.state.graph.inputs.get_mut(*input_id) else { + continue; + }; + if matches!(param.kind, InputParamKind::ConnectionOnly) { + continue; + } + any = true; + render_param_row(ui, name, &mut param.value); + ui.add_space(6.0); + } + if !any { + ui.label(egui::RichText::new("No editable parameters").weak()); + } + // Embedded desktop custom UI (sampler picker, sequencer grid, script UI, …). + // Standard nodes render nothing here. + if let Some(node) = self.state.graph.nodes.get(focus) { + let _ = node.user_data.bottom_ui(ui, focus, &self.state.graph, &mut self.user_state); + } + }); + }, + ); + + // Add-node button. + ui.scope_builder( + egui::UiBuilder::new().max_rect(add_rect.shrink(8.0)).layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + if ui.button(egui::RichText::new("+ Add node").size(15.0)).clicked() { + self.mobile.show_add = true; + } + }, + ); + + if let Some(nid) = jump { + self.mobile.focus_node = Some(nid); + } + + if self.mobile.show_add { + self.render_add_picker(ui, body, shared); + } + } + + /// Connection chips for the focus node: (label, remote node) for upstream inputs and downstream + /// outputs. Label names the remote endpoint (`node ▸ port`). + fn focus_chips(&self, focus: NodeId) -> (Vec<(String, NodeId)>, Vec<(String, NodeId)>) { + let mut ins = Vec::new(); + let mut outs = Vec::new(); + for (input_id, outputs) in self.state.graph.iter_connection_groups() { + let in_node = self.state.graph.inputs.get(input_id).map(|p| p.node); + for output_id in outputs { + let out_node = self.state.graph.outputs.get(output_id).map(|p| p.node); + if in_node == Some(focus) { + if let Some(src) = out_node { + ins.push((format!("{} · {}", self.node_label(src), self.output_port_name(src, output_id)), src)); + } + } + if out_node == Some(focus) { + if let Some(dst) = in_node { + outs.push((format!("{} · {}", self.node_label(dst), self.input_port_name(dst, input_id)), dst)); + } + } + } + } + (ins, outs) + } + + fn node_label(&self, n: NodeId) -> String { + self.state.graph.nodes.get(n).map(|x| x.label.clone()).unwrap_or_default() + } + fn output_port_name(&self, n: NodeId, oid: OutputId) -> String { + self.state + .graph + .nodes + .get(n) + .and_then(|node| node.outputs.iter().find(|(_, id)| *id == oid).map(|(nm, _)| nm.clone())) + .unwrap_or_default() + } + fn input_port_name(&self, n: NodeId, iid: InputId) -> String { + self.state + .graph + .nodes + .get(n) + .and_then(|node| node.inputs.iter().find(|(_, id)| *id == iid).map(|(nm, _)| nm.clone())) + .unwrap_or_default() + } + + /// Draw a minimap of the graph; returns a node id if the user tapped one. + fn render_minimap(&self, ui: &mut egui::Ui, rect: egui::Rect, focus: NodeId) -> Option { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 4.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120)); + painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.0, egui::Color32::from_gray(70)), egui::StrokeKind::Inside); + + let nodes: Vec<(NodeId, egui::Pos2)> = self + .state + .graph + .iter_nodes() + .filter_map(|n| self.state.node_positions.get(n).map(|p| (n, *p))) + .collect(); + if nodes.is_empty() { + return None; + } + let (mut mnx, mut mny, mut mxx, mut mxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for (_, p) in &nodes { + mnx = mnx.min(p.x); + mny = mny.min(p.y); + mxx = mxx.max(p.x); + mxy = mxy.max(p.y); + } + let pad = 10.0; + let inner = rect.shrink(pad); + let span = egui::vec2((mxx - mnx).max(1.0), (mxy - mny).max(1.0)); + let map = |p: egui::Pos2| { + egui::pos2( + inner.left() + (p.x - mnx) / span.x * inner.width(), + inner.top() + (p.y - mny) / span.y * inner.height(), + ) + }; + + // Connection lines. + for (input_id, outputs) in self.state.graph.iter_connection_groups() { + let in_pos = self.state.graph.inputs.get(input_id).and_then(|p| self.state.node_positions.get(p.node)).copied(); + for output_id in outputs { + let out_pos = self.state.graph.outputs.get(output_id).and_then(|p| self.state.node_positions.get(p.node)).copied(); + if let (Some(a), Some(b)) = (out_pos, in_pos) { + painter.line_segment([map(a), map(b)], egui::Stroke::new(1.0, egui::Color32::from_gray(90))); + } + } + } + + let resp = ui.interact(rect, ui.id().with("node_minimap"), egui::Sense::click()); + let click = if resp.clicked() { resp.interact_pointer_pos() } else { None }; + let mut hit = None; + let mut best_d = f32::MAX; + for (nid, p) in &nodes { + let c = map(*p); + let focused = *nid == focus; + let color = if focused { egui::Color32::from_rgb(0x4a, 0xa3, 0xff) } else { egui::Color32::from_gray(200) }; + painter.circle_filled(c, if focused { 7.0 } else { 5.0 }, color); + if focused { + painter.circle_stroke(c, 9.0, egui::Stroke::new(1.5, color)); + } + // Tap selects the nearest node (dots are small, so don't require a precise hit). + if let Some(cp) = click { + let d = (cp - c).length(); + if d < best_d { + best_d = d; + hit = Some(*nid); + } + } + } + hit + } + + /// The add-node picker overlay: a searchable list of templates. + fn render_add_picker(&mut self, ui: &mut egui::Ui, body: egui::Rect, shared: &mut crate::panes::SharedPaneState) { + let panel = egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(320.0), body.height().min(420.0))); + let mut chosen: Option = None; + let mut close = false; + ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Add node").strong()); + if ui.button("✕").clicked() { + close = true; + } + }); + ui.add(egui::TextEdit::singleline(&mut self.mobile.add_search).hint_text("Search…")); + let q = self.mobile.add_search.to_lowercase(); + egui::ScrollArea::vertical().show(ui, |ui| { + for template in NodeTemplate::all_finder_kinds() { + let label = template.node_finder_label(&mut self.user_state).to_string(); + if !q.is_empty() && !label.to_lowercase().contains(&q) { + continue; + } + if ui.button(&label).clicked() { + chosen = Some(template); + } + } + }); + }); + }); + if let Some(t) = chosen { + self.mobile_add_node(t, shared); + self.mobile.show_add = false; + self.mobile.add_search.clear(); + } + if close { + self.mobile.show_add = false; + } + } + + /// Create a node (frontend + backend action) and focus it, mirroring the desktop CreatedNode path. + fn mobile_add_node(&mut self, template: NodeTemplate, _shared: &mut crate::panes::SharedPaneState) { + let Some(track_id) = self.track_id else { + return; + }; + // Place near the current focus node, else the origin. + let base = self + .mobile + .focus_node + .and_then(|id| self.state.node_positions.get(id).copied()) + .unwrap_or(egui::Pos2::ZERO); + let pos = base + egui::vec2(160.0, 0.0); + + let label = template.node_graph_label(&mut self.user_state); + let user_data = template.user_data(&mut self.user_state); + let new_id = self + .state + .graph + .add_node(label, user_data, |graph, id| template.build_node(graph, &mut self.user_state, id)); + self.state.node_positions.insert(new_id, pos); + + let node_type = template.backend_type_name().to_string(); + self.pending_action = Some(Box::new(actions::NodeGraphAction::AddNode( + actions::AddNodeAction::new(track_id, node_type.clone(), (pos.x, pos.y)), + ))); + self.pending_node_addition = Some((new_id, node_type, (pos.x, pos.y))); + self.mobile.focus_node = Some(new_id); + } + + fn render_mobile_patch( + &mut self, + ui: &mut egui::Ui, + body: egui::Rect, + _shared: &mut crate::panes::SharedPaneState, + ) { + let Some(focus) = self.mobile.focus_node else { + return; + }; + + // Build owned row data (immutable reads) so rendering can freely queue ops. + let inputs: Vec<(String, InputId)> = self.state.graph.nodes.get(focus).map(|n| n.inputs.clone()).unwrap_or_default(); + let outputs: Vec<(String, OutputId)> = self.state.graph.nodes.get(focus).map(|n| n.outputs.clone()).unwrap_or_default(); + + // Rows: (idx, name, type, cables[(remote_node, remote_port, other_node, other_port)]). + let mut in_rows: Vec<(usize, String, DataType, Vec<(String, String, NodeId, usize)>)> = Vec::new(); + for (idx, (name, input_id)) in inputs.iter().enumerate() { + let Some(typ) = self.state.graph.inputs.get(*input_id).map(|p| p.typ) else { continue }; + let mut cables = Vec::new(); + if let Some(outs) = self.state.graph.connections.get(*input_id) { + for oid in outs { + if let Some(src) = self.state.graph.outputs.get(*oid).map(|o| o.node) { + let sp = self.output_port_index(src, *oid); + cables.push((self.node_label(src), self.output_port_name(src, *oid), src, sp)); + } + } + } + in_rows.push((idx, name.clone(), typ, cables)); + } + let mut out_rows: Vec<(usize, String, DataType, Vec<(String, String, NodeId, usize)>)> = Vec::new(); + for (idx, (name, output_id)) in outputs.iter().enumerate() { + let Some(typ) = self.state.graph.outputs.get(*output_id).map(|o| o.typ) else { continue }; + let mut cables = Vec::new(); + for (input_id, outs) in self.state.graph.iter_connection_groups() { + if outs.iter().any(|o| o == output_id) { + if let Some(dst) = self.state.graph.inputs.get(input_id).map(|i| i.node) { + let dp = self.input_port_index(dst, input_id); + cables.push((self.node_label(dst), self.input_port_name(dst, input_id), dst, dp)); + } + } + } + out_rows.push((idx, name.clone(), typ, cables)); + } + + // Queue ops during render, apply after (avoids borrowing self mid-render). + enum Op { + Disconnect(NodeId, usize, NodeId, usize), // (out_node, out_port, in_node, in_port) + Connect(NodeId, usize, NodeId, usize), + Pick(PatchPick), + ClearPick, + } + let mut ops: Vec = Vec::new(); + let pick = self.mobile.patch_pick; + + ui.scope_builder( + egui::UiBuilder::new().max_rect(body.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + // A Grid aligns each section's port arrows into a column. Tapping a port arrow + // arms a cable from it (also for unconnected ports); tapping a cable chip removes + // it. Inputs read `[cables] ⥓ name`; outputs read `name ↦ [cables]`. + ui.label(egui::RichText::new("Inputs").weak()); + egui::Grid::new("patch_inputs").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + for (idx, name, typ, cables) in &in_rows { + ui.horizontal(|ui| { + for (rnode, rport, sn, sp) in cables { + if cable_chip(ui, rnode, rport, *typ).clicked() { + ops.push(Op::Disconnect(*sn, *sp, focus, *idx)); + } + } + }); + if arrow_button(ui, icons::ARROW_RIGHT_TO_LINE, *typ).clicked() { + ops.push(Op::Pick(PatchPick { node: focus, port: *idx, dir: PortDir::In, typ: *typ })); + } + ui.label(name.as_str()); + ui.end_row(); + } + }); + ui.add_space(10.0); + ui.label(egui::RichText::new("Outputs").weak()); + egui::Grid::new("patch_outputs").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + for (idx, name, typ, cables) in &out_rows { + ui.label(name.as_str()); + if arrow_button(ui, icons::ARROW_RIGHT_FROM_LINE, *typ).clicked() { + ops.push(Op::Pick(PatchPick { node: focus, port: *idx, dir: PortDir::Out, typ: *typ })); + } + ui.horizontal(|ui| { + for (rnode, rport, dn, dp) in cables { + if cable_chip(ui, rnode, rport, *typ).clicked() { + ops.push(Op::Disconnect(focus, *idx, *dn, *dp)); + } + } + }); + ui.end_row(); + } + }); + }); + }, + ); + + // Compatible-endpoint picker overlay when a port is armed. + if let Some(p) = pick { + let candidates = self.patch_candidates(p); + let panel = egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(320.0), body.height().min(360.0))); + ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + port_marker(ui, p.typ, p.dir); + ui.label(egui::RichText::new("Connect to…").strong()); + if ui.button("✕").clicked() { + ops.push(Op::ClearPick); + } + }); + egui::ScrollArea::vertical().show(ui, |ui| { + if candidates.is_empty() { + ui.label(egui::RichText::new("No compatible ports").weak()); + } + for (label, other, other_port) in &candidates { + if ui.button(egui::RichText::new(label.as_str()).color(type_color(p.typ))).clicked() { + // Orient the cable: armed In needs an Out source; armed Out needs an In target. + match p.dir { + PortDir::In => ops.push(Op::Connect(*other, *other_port, p.node, p.port)), + PortDir::Out => ops.push(Op::Connect(p.node, p.port, *other, *other_port)), + } + ops.push(Op::ClearPick); + } + } + }); + }); + }); + } + + for op in ops { + match op { + Op::Disconnect(on, op_, inn, ip) => self.mobile_disconnect(on, op_, inn, ip), + Op::Connect(on, op_, inn, ip) => self.mobile_connect(on, op_, inn, ip), + Op::Pick(p) => self.mobile.patch_pick = Some(p), + Op::ClearPick => self.mobile.patch_pick = None, + } + } + } + + /// Compatible endpoints on OTHER nodes for an armed port (opposite direction, same type). + fn patch_candidates(&self, p: PatchPick) -> Vec<(String, NodeId, usize)> { + let mut out = Vec::new(); + for node in self.state.graph.iter_nodes() { + if node == p.node { + continue; + } + let Some(n) = self.state.graph.nodes.get(node) else { continue }; + match p.dir { + // Armed input → list outputs of matching type. + PortDir::In => { + for (i, (name, oid)) in n.outputs.iter().enumerate() { + if self.state.graph.outputs.get(*oid).map(|o| o.typ) == Some(p.typ) { + out.push((format!("{} · {}", self.node_label(node), name), node, i)); + } + } + } + // Armed output → list inputs of matching type. + PortDir::Out => { + for (i, (name, iid)) in n.inputs.iter().enumerate() { + if self.state.graph.inputs.get(*iid).map(|x| x.typ) == Some(p.typ) { + out.push((format!("{} · {}", self.node_label(node), name), node, i)); + } + } + } + } + } + out + } + + fn output_port_index(&self, n: NodeId, oid: OutputId) -> usize { + self.state.graph.nodes.get(n).and_then(|node| node.outputs.iter().position(|(_, id)| *id == oid)).unwrap_or(0) + } + fn input_port_index(&self, n: NodeId, iid: InputId) -> usize { + self.state.graph.nodes.get(n).and_then(|node| node.inputs.iter().position(|(_, id)| *id == iid)).unwrap_or(0) + } + + /// Cable an output port → input port: update the frontend graph + dispatch `Connect`. + fn mobile_connect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) { + // Mobile has no subgraph navigation yet, so cables always target the track-level graph. If + // subgraph nav is added, route through `va_context()` / `graph_connect_in_template` like desktop. + debug_assert!(self.subgraph_stack.is_empty(), "mobile node connect assumes the top-level graph"); + let Some(track_id) = self.track_id else { return }; + let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id); + let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id); + let (Some(output_id), Some(input_id)) = (output_id, input_id) else { return }; + if let Some(conns) = self.state.graph.connections.get_mut(input_id) { + if !conns.contains(&output_id) { + conns.push(output_id); + } + } else { + self.state.graph.connections.insert(input_id, vec![output_id]); + } + if let (Some(&from_id), Some(&to_id)) = (self.node_id_map.get(&out_node), self.node_id_map.get(&in_node)) { + self.pending_action = Some(Box::new(actions::NodeGraphAction::Connect( + actions::ConnectAction::new(track_id, from_id, out_port, to_id, in_port), + ))); + } + } + + /// Remove a cable: update the frontend graph + dispatch `Disconnect`. Like `mobile_connect`, this + /// assumes the track-level graph (mobile has no subgraph navigation). + fn mobile_disconnect(&mut self, out_node: NodeId, out_port: usize, in_node: NodeId, in_port: usize) { + debug_assert!(self.subgraph_stack.is_empty(), "mobile node disconnect assumes the top-level graph"); + let Some(track_id) = self.track_id else { return }; + let output_id = self.state.graph.nodes.get(out_node).and_then(|n| n.outputs.get(out_port)).map(|(_, id)| *id); + let input_id = self.state.graph.nodes.get(in_node).and_then(|n| n.inputs.get(in_port)).map(|(_, id)| *id); + let (Some(output_id), Some(input_id)) = (output_id, input_id) else { return }; + if let Some(conns) = self.state.graph.connections.get_mut(input_id) { + conns.retain(|o| *o != output_id); + } + if let (Some(&from_id), Some(&to_id)) = (self.node_id_map.get(&out_node), self.node_id_map.get(&in_node)) { + self.pending_action = Some(Box::new(actions::NodeGraphAction::Disconnect( + actions::DisconnectAction::new(track_id, from_id, out_port, to_id, in_port), + ))); + } + } +} + +/// Desktop port color per signal type (matches `DataType::data_type_color`). +fn type_color(t: DataType) -> egui::Color32 { + match t { + DataType::Audio => egui::Color32::from_rgb(100, 150, 255), // blue + DataType::Midi => egui::Color32::from_rgb(100, 255, 100), // green + DataType::CV => egui::Color32::from_rgb(255, 150, 100), // orange + } +} + +/// A single Lucide direction arrow glyph, tinted by signal type. +fn arrow(ui: &mut egui::Ui, glyph: &str, t: DataType) { + ui.label( + egui::RichText::new(glyph) + .color(type_color(t)) + .family(egui::FontFamily::Name(icons::FAMILY.into())) + .size(15.0), + ); +} + +/// A port marker: a Lucide direction arrow (out = from-line, in = to-line) tinted by signal type. +fn port_marker(ui: &mut egui::Ui, t: DataType, dir: PortDir) { + let glyph = match dir { + PortDir::In => icons::ARROW_RIGHT_TO_LINE, + PortDir::Out => icons::ARROW_RIGHT_FROM_LINE, + }; + arrow(ui, glyph, t); +} + +/// A clickable, aligned port arrow (out = from-line, in = to-line) tinted by signal type. Tapping it +/// arms a cable from the port. Frameless so it reads like the desktop port dot. +fn arrow_button(ui: &mut egui::Ui, glyph: &str, t: DataType) -> egui::Response { + ui.add( + egui::Button::new( + egui::RichText::new(glyph) + .color(type_color(t)) + .family(egui::FontFamily::Name(icons::FAMILY.into())) + .size(16.0), + ) + .frame(false), + ) +} + +/// A cable badge naming the remote endpoint (`remote-node · remote-port`), framed and tinted by +/// signal type. Returns a click response (used to disconnect the cable). +fn cable_chip(ui: &mut egui::Ui, remote_node: &str, remote_port: &str, t: DataType) -> egui::Response { + egui::Frame::group(ui.style()) + .inner_margin(egui::Margin::symmetric(6, 1)) + .show(ui, |ui| { + ui.label(egui::RichText::new(format!("{remote_node} · {remote_port}")).color(type_color(t))); + }) + .response + .interact(egui::Sense::click()) +} + +/// One parameter row: a touch control matching the desktop widget rule (enum → dropdown, ranged → +/// slider, plain → stepper, string → field). Mutates the value in place; dispatch happens later via +/// `check_parameter_changes`. +fn render_param_row(ui: &mut egui::Ui, name: &str, value: &mut ValueType) { + match value { + ValueType::Float { value, min, max, unit, enum_labels, .. } => { + ui.label(name); + if let Some(labels) = enum_labels { + let mut sel = (*value as usize).min(labels.len().saturating_sub(1)); + egui::ComboBox::from_id_salt(name) + .width(ui.available_width().min(240.0)) + .selected_text(labels.get(sel).copied().unwrap_or("?")) + .show_ui(ui, |ui| { + for (i, label) in labels.iter().enumerate() { + ui.selectable_value(&mut sel, i, *label); + } + }); + *value = sel as f32; + } else if *max > *min { + // Give the rail a visible color so the full track length reads (the theme's default + // inactive fill can be near-transparent); trailing_fill shows progress along it. + ui.scope(|ui| { + let rail = egui::Color32::from_gray(90); + ui.visuals_mut().widgets.inactive.bg_fill = rail; + ui.visuals_mut().widgets.inactive.weak_bg_fill = rail; + ui.visuals_mut().widgets.hovered.bg_fill = rail; + ui.add(egui::Slider::new(value, *min..=*max).suffix(*unit).trailing_fill(true)); + }); + } else { + ui.add(egui::DragValue::new(value).speed(0.1)); + } + } + ValueType::String { value } => { + ui.label(name); + ui.text_edit_singleline(value); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index 7b6bca1..0b36b85 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -6,6 +6,7 @@ pub mod actions; pub mod audio_backend; pub mod backend; pub mod graph_data; +mod mobile; use backend::{BackendNodeId, GraphBackend}; use graph_data::{AllNodeTemplates, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NamModelInfo, NodeData, NodeTemplate, PendingAmpSimLoad, ValueType}; @@ -142,6 +143,9 @@ pub struct NodeGraphPane { last_oscilloscope_poll: std::time::Instant, /// Backend track ID (u32) for oscilloscope queries backend_track_id: Option, + + /// Mobile (touch) Focus/Patch view state. + mobile: mobile::MobileNodeState, } impl NodeGraphPane { @@ -171,6 +175,7 @@ impl NodeGraphPane { pending_script_resolutions: Vec::new(), last_oscilloscope_poll: std::time::Instant::now(), backend_track_id: None, + mobile: mobile::MobileNodeState::default(), } } @@ -2365,6 +2370,13 @@ impl crate::panes::PaneRenderer for NodeGraphPane { painter.galley(text_pos, galley, text_color); return; } + + // Mobile: touch-native Focus/Patch views instead of the desktop canvas. + if shared.is_mobile { + self.render_mobile(ui, rect, shared); + return; + } + // Poll oscilloscope data at ~20 FPS let has_oscilloscopes; if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 64e5390..ad47424 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -169,6 +169,20 @@ pub struct PianoRollPane { snap_value: SnapValue, last_snap_selection: HashSet, snap_user_changed: bool, // set in render_header, consumed before handle_input + + // Mobile portrait "Synthesia" mode: the playable keyboard is drawn as a strip at the bottom of + // this pane (one unified surface), reusing the Virtual Piano's rendering + MIDI logic. + keyboard: super::virtual_piano::VirtualPianoPane, + // Landscape mobile: the Keys/Notes toggle — true shows the full keyboard, false the roll. + landscape_keys: bool, + // Manual long-press detection for the vertical roll (works with mouse-hold and touch): time the + // press started, and where. `None` = disarmed (no fresh press, or the press became a pan). + lp_press_time: Option, + lp_press_pos: egui::Pos2, + // When the long-press landed on an existing note, we resize it instead of creating: its index in + // the resolved-note list and its original duration (to compute the resize delta on commit). + editing_index: Option, + editing_orig_dur: f64, } impl PianoRollPane { @@ -205,6 +219,12 @@ impl PianoRollPane { snap_value: SnapValue::None, last_snap_selection: HashSet::new(), snap_user_changed: false, + keyboard: super::virtual_piano::VirtualPianoPane::new(), + landscape_keys: false, + lp_press_time: None, + lp_press_pos: egui::Pos2::ZERO, + editing_index: None, + editing_orig_dur: 0.0, } } @@ -368,6 +388,22 @@ impl PianoRollPane { rect: Rect, shared: &mut SharedPaneState, ) { + // Landscape mobile: a Keys/Notes toggle bar switches this pane between the full keyboard and + // the conventional piano roll. (Portrait uses the keyboard-primary "Synthesia" view below.) + let mut rect = rect; + if shared.is_mobile && !shared.is_portrait { + // Clamp the bar height so a very short pane can't invert the remaining content rect. + let bar_h = 30.0_f32.min(rect.height()); + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), rect.min.y + bar_h)); + rect = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + self.render_keys_notes_toggle(ui, bar, shared); + if self.landscape_keys { + let kb_path: NodePath = Vec::new(); + self.keyboard.render_content(ui, rect, &kb_path, shared); + return; + } + } + let keyboard_rect = Rect::from_min_size(rect.min, vec2(KEYBOARD_WIDTH, rect.height())); let grid_rect = Rect::from_min_max( pos2(rect.min.x + KEYBOARD_WIDTH, rect.min.y), @@ -444,6 +480,13 @@ impl PianoRollPane { } } + // Mobile portrait: keyboard-primary "Synthesia" instrument surface (keys + falling-notes + // roll). Landscape falls through to the conventional horizontal roll (pitch↕ / time▸). + if shared.is_mobile && shared.is_portrait { + self.render_vertical_mode(ui, rect, shared, &clip_data); + return; + } + // 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 { @@ -556,6 +599,372 @@ impl PianoRollPane { self.render_keyboard(&painter, keyboard_rect); } + /// Portrait mobile "Synthesia" view — ONE unified instrument surface: an instrument header on + /// top, then falling notes as vertical columns (pitch on X, aligned to the keys via the shared + /// `KeyboardLayout`, time falling toward the "now" line), then the playable keyboard strip at the + /// bottom (reusing the Virtual Piano). Tapping a column adds a note. + fn render_vertical_mode( + &mut self, + ui: &mut egui::Ui, + rect: Rect, + shared: &mut SharedPaneState, + clip_data: &[(u32, f64, f64, f64, Uuid)], + ) { + use super::keyboard_layout::KeyboardLayout; + + // Keyboard-primary layout (portrait, per wireframe Plate 08): the playable keyboard is the + // base surface, capped at an ergonomic height. Whether the falling-notes roll appears above + // is decided by the shell from the *snapped* pane size-class, so the reveal lands on a stack + // snap point (not mid-drag). Short/smallest snap ⇒ keyboard only. + const KEY_CAP: f32 = 170.0; + let header_h = 32.0; + let header_rect = Rect::from_min_max(rect.min, pos2(rect.right(), rect.top() + header_h)); + let content_top = header_rect.bottom(); + let content_h = (rect.bottom() - content_top).max(0.0); + let show_roll = shared.instrument_show_roll && content_h > KEY_CAP + 40.0; + let kb_h = if show_roll { content_h.min(KEY_CAP) } else { content_h }; + let kb_rect = Rect::from_min_max(pos2(rect.left(), rect.bottom() - kb_h), rect.max); + let roll_rect = Rect::from_min_max(pos2(rect.left(), content_top), pos2(rect.right(), kb_rect.top())); + + let pps = self.pixels_per_second; // pixels per second (vertical waterfall) + let now_y = roll_rect.bottom(); // a note reaches the keys at its onset time + let now = ui.input(|i| i.time); + + // Auto-release a long-press-added preview note once its short audition elapses (the normal + // release check lives in handle_input, which the vertical mode bypasses). + if let Some(dur) = self.preview_duration { + if self.preview_note_sounding && now - self.preview_start_time >= dur { + self.preview_note_off(shared); + } + } + + // Handle roll gestures BEFORE building the layout / drawing, so the roll and the keyboard both + // use the updated pan + playhead this same frame (no 1-frame follow lag between them). + let resp = if show_roll { + let r = ui.interact(roll_rect, ui.id().with("pr_vertical"), egui::Sense::click_and_drag()); + if self.creating_note.is_none() && r.dragged() { + let d = r.drag_delta(); + if d.y.abs() > 0.0 { + // Drag down ⇒ advance time (content follows the finger). + let nt = (*shared.playback_time + (d.y / pps) as f64).max(0.0); + *shared.playback_time = nt; + if let Some(ctrl) = shared.audio_controller.as_ref() { + if let Ok(mut c) = ctrl.lock() { + c.seek(nt); + } + } + } + *shared.keyboard_pan_x += d.x; + } + Some(r) + } else { + None + }; + + let layout = KeyboardLayout::from_width(rect.min.x, rect.width(), *shared.keyboard_octave, *shared.keyboard_pan_x); + // Note times are in beats; the playhead is in seconds — convert via the tempo map so the + // waterfall lines up with real playback (onset crosses the now line exactly when it sounds). + let tempo_map = shared.action_executor.document().tempo_map().clone(); + let playhead = *shared.playback_time; // seconds + + if let Some(resp) = resp { + let painter = ui.painter_at(roll_rect); + let bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(30, 30, 35)); + painter.rect_filled(roll_rect, 0.0, bg); + + // Column guides aligned to the keys: black-key columns tinted, white-key boundaries lined. + for note in layout.visible_notes() { + let x = layout.note_x(note); + if KeyboardLayout::is_black_key(note) { + let w = layout.note_width(note); + painter.rect_filled( + Rect::from_min_size(pos2(x, roll_rect.min.y), vec2(w, roll_rect.height())), + 0.0, + Color32::from_rgba_unmultiplied(0, 0, 0, 40), + ); + } else { + painter.line_segment([pos2(x, roll_rect.min.y), pos2(x, roll_rect.max.y)], Stroke::new(1.0, Color32::from_gray(55))); + } + } + + // Notes of the selected clip, falling toward the now line. + if let Some(clip_id) = self.selected_clip_id { + if let Some(&(_, timeline_start, trim_start, duration, _)) = + clip_data.iter().find(|c| c.0 == clip_id) + { + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + let resolved = Self::resolve_notes(events); + for (ni, note) in resolved.iter().enumerate() { + // The note being resized is drawn separately (live) as the temp note. + if Some(ni) == self.editing_index { + continue; + } + if note.start_time + note.duration <= trim_start + || note.start_time >= trim_start + duration + { + continue; + } + let global = timeline_start + (note.start_time - trim_start); + let y_on = now_y - ((tempo_map.transform(global) - playhead) as f32) * pps; + let y_off = now_y - ((tempo_map.transform(global + note.duration) - playhead) as f32) * pps; + let (top, bot) = (y_on.min(y_off), y_on.max(y_off)); + if bot < roll_rect.min.y || top > roll_rect.max.y { + continue; + } + let x = layout.note_x(note.note); + let w = layout.note_width(note.note).max(3.0); + let bright = 0.35 + (note.velocity as f32 / 127.0) * 0.65; + let col = Color32::from_rgb( + (111.0 * bright) as u8, + (220.0 * bright) as u8, + (111.0 * bright) as u8, + ); + painter.rect_filled( + Rect::from_min_max( + pos2(x + 1.0, top.max(roll_rect.min.y)), + pos2(x + w - 1.0, bot.min(roll_rect.max.y)), + ), + 2.0, + col, + ); + } + } + } + } + + // The note currently being created (long-press to start, drag along Y to size). + if let Some(temp) = self.creating_note.as_ref() { + if let Some(&(_, ts, trim, _d, _)) = + clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) + { + let g = ts + (temp.start_time - trim); + let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps; + let y_off = now_y - ((tempo_map.transform(g + temp.duration) - playhead) as f32) * pps; + let x = layout.note_x(temp.note); + let w = layout.note_width(temp.note).max(3.0); + painter.rect_filled( + Rect::from_min_max(pos2(x + 1.0, y_on.min(y_off)), pos2(x + w - 1.0, y_on.max(y_off))), + 2.0, + Color32::from_rgb(150, 255, 150), + ); + } + } + + // The amber "now" line at the boundary with the keyboard. + painter.line_segment( + [pos2(roll_rect.min.x, now_y), pos2(roll_rect.max.x, now_y)], + Stroke::new(2.0, Color32::from_rgb(0xf4, 0xa3, 0x40)), + ); + + const LONG_PRESS_SECS: f64 = 0.4; + let pressed = ui.input(|i| i.pointer.primary_pressed()); + let down = ui.input(|i| i.pointer.any_down()); + let ptr = ui.input(|i| i.pointer.latest_pos()); + + if self.creating_note.is_some() { + // Sizing the note (new or existing): drag along Y sets its duration; release commits. + if down { + if let (Some(p), Some(&(_, ts, trim, _d, _))) = ( + ptr, + clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id), + ) { + let end_sec = playhead + ((now_y - p.y) / pps) as f64; + let end_beats = tempo_map.inverse_transform(end_sec).max(0.0); + let end_local = trim + (end_beats - ts); + if let Some(t) = self.creating_note.as_mut() { + t.duration = (end_local - t.start_time).max(0.25); + } + } + } else if let (Some(temp), Some(clip_id)) = + (self.creating_note.take(), self.selected_clip_id) + { + if let Some(idx) = self.editing_index.take() { + let dt = temp.duration - self.editing_orig_dur; + self.commit_resize_note(clip_id, idx, dt, shared, clip_data); + } else { + self.commit_create_note(clip_id, temp, shared, clip_data); + } + } + } else { + // Snap the pan to the nearest key on release. + if resp.drag_stopped() { + *shared.keyboard_pan_x = layout.snap_pan(*shared.keyboard_pan_x); + } + // Manual long-press (mouse-hold or touch): armed only on a *fresh* press, and cancelled + // for the rest of that press once it moves (→ pan), so pausing mid-pan won't fire. + if pressed { + match ptr { + Some(p) if roll_rect.contains(p) => { + self.lp_press_time = Some(now); + self.lp_press_pos = p; + } + _ => self.lp_press_time = None, + } + } + if !down { + self.lp_press_time = None; + } + if let Some(t0) = self.lp_press_time { + let moved = ptr.map_or(0.0, |p| (p - self.lp_press_pos).length()); + if moved > 8.0 { + self.lp_press_time = None; // became a pan → suppress for this press + } else if now - t0 >= LONG_PRESS_SECS { + self.lp_press_time = None; + let pos = self.lp_press_pos; + if let Some(clip_id) = self.selected_clip_id { + if let Some(&(_, timeline_start, trim_start, clip_dur, _)) = + clip_data.iter().find(|c| c.0 == clip_id) + { + let pitch = layout.x_to_note(pos.x); + // Hit-test an existing note in this column → resize it; else create. + let mut hit = None; + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + for (i, n) in Self::resolve_notes(events).iter().enumerate() { + if n.note != pitch + || n.start_time < trim_start + || n.start_time >= trim_start + clip_dur + { + continue; + } + let g = timeline_start + (n.start_time - trim_start); + let y_on = now_y - ((tempo_map.transform(g) - playhead) as f32) * pps; + let y_off = now_y - ((tempo_map.transform(g + n.duration) - playhead) as f32) * pps; + if pos.y >= y_on.min(y_off) && pos.y <= y_on.max(y_off) { + hit = Some((i, n.start_time, n.duration, n.velocity)); + break; + } + } + } + if let Some((idx, onset, dur, vel)) = hit { + self.editing_index = Some(idx); + self.editing_orig_dur = dur; + self.creating_note = Some(TempNote { note: pitch, start_time: onset, duration: dur, velocity: vel }); + } else { + let onset_sec = playhead + ((now_y - pos.y) / pps) as f64; + let onset_beats = tempo_map.inverse_transform(onset_sec).max(0.0); + let onset_local = snap_to_value((trim_start + (onset_beats - timeline_start)).max(0.0), self.snap_value, &tempo_map); + self.editing_index = None; + self.creating_note = Some(TempNote { note: pitch, start_time: onset_local, duration: 0.5, velocity: DEFAULT_VELOCITY }); + } + self.preview_note_on(pitch, DEFAULT_VELOCITY, Some(0.4), now, shared); + } + } + } + } + } + } + + // Notes sounding at the playhead → drive the keyboard's highlights so it colors them exactly + // like pressed keys (full key, correct white/black layering) rather than an after-overlay. + let mut sounding = std::collections::HashSet::new(); + if let Some(clip_id) = self.selected_clip_id { + if let Some(&(_, timeline_start, trim_start, duration, _)) = + clip_data.iter().find(|c| c.0 == clip_id) + { + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + for note in Self::resolve_notes(events) { + if note.start_time < trim_start || note.start_time >= trim_start + duration { + continue; + } + let global = timeline_start + (note.start_time - trim_start); + let s = tempo_map.transform(global); + let e = tempo_map.transform(global + note.duration); + if playhead >= s && playhead < e { + sounding.insert(note.note); + } + } + } + } + } + self.keyboard.playback_notes = sounding; + + // Instrument header (in this pane's own header) + the always-present playable keyboard strip. + self.render_instrument_header(ui, header_rect, shared); + let kb_path: NodePath = Vec::new(); + self.keyboard.render_content(ui, kb_rect, &kb_path, shared); + } + + /// The in-pane instrument header: active track name + Presets + REC. + fn render_instrument_header(&mut self, ui: &mut egui::Ui, rect: Rect, shared: &mut SharedPaneState) { + let painter = ui.painter_at(rect); + let hdr = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28)); + painter.rect_filled(rect, 0.0, hdr); + painter.line_segment( + [pos2(rect.left(), rect.bottom()), pos2(rect.right(), rect.bottom())], + Stroke::new(1.0, Color32::from_gray(60)), + ); + + let name = shared + .active_layer_id + .and_then(|id| shared.action_executor.document().get_layer(&id)) + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "No instrument".to_string()); + painter.text( + pos2(rect.left() + 12.0, rect.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(14.0), + Color32::from_gray(220), + ); + + // REC (rightmost). + let rec = Rect::from_min_max(pos2(rect.right() - 64.0, rect.top() + 4.0), pos2(rect.right() - 8.0, rect.bottom() - 4.0)); + let rec_resp = ui.interact(rec, ui.id().with("pr_rec"), egui::Sense::click()); + let recording = *shared.is_recording; + let rec_bg = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) } else { Color32::from_gray(66) }; + painter.rect_filled(rec, 6.0, rec_bg); + painter.circle_filled(pos2(rec.left() + 13.0, rec.center().y), 4.0, Color32::from_rgb(255, 96, 96)); + painter.text(pos2(rec.left() + 23.0, rec.center().y), egui::Align2::LEFT_CENTER, "REC", egui::FontId::proportional(12.0), Color32::WHITE); + if rec_resp.clicked() { + *shared.pending_record_toggle = true; + } + + // Presets (left of REC). + let pre = Rect::from_min_max(pos2(rec.left() - 84.0, rect.top() + 4.0), pos2(rec.left() - 8.0, rect.bottom() - 4.0)); + let pre_resp = ui.interact(pre, ui.id().with("pr_presets"), egui::Sense::click()); + painter.rect_filled(pre, 6.0, if pre_resp.hovered() { Color32::from_gray(56) } else { Color32::from_gray(42) }); + painter.text(pre.center(), egui::Align2::CENTER_CENTER, "Presets", egui::FontId::proportional(12.0), Color32::from_gray(220)); + if pre_resp.clicked() { + *shared.open_instrument_browser = true; + } + } + + /// Landscape Keys/Notes segmented toggle drawn in `bar` (updates `self.landscape_keys`). + fn render_keys_notes_toggle(&mut self, ui: &mut egui::Ui, bar: Rect, shared: &mut SharedPaneState) { + let painter = ui.painter_at(bar); + let bg = shared.theme.bg_color(&[".pane-header"], ui.ctx(), Color32::from_rgb(24, 24, 28)); + painter.rect_filled(bar, 0.0, bg); + painter.line_segment( + [pos2(bar.left(), bar.bottom()), pos2(bar.right(), bar.bottom())], + Stroke::new(1.0, Color32::from_gray(60)), + ); + + let seg_w = 74.0; + let x0 = bar.center().x - seg_w; + let keys_rect = Rect::from_min_size(pos2(x0, bar.top() + 4.0), vec2(seg_w, bar.height() - 8.0)); + let notes_rect = Rect::from_min_size(pos2(x0 + seg_w, bar.top() + 4.0), vec2(seg_w, bar.height() - 8.0)); + if ui.interact(keys_rect, ui.id().with("pr_kn_keys"), egui::Sense::click()).clicked() { + self.landscape_keys = true; + } + if ui.interact(notes_rect, ui.id().with("pr_kn_notes"), egui::Sense::click()).clicked() { + self.landscape_keys = false; + } + let accent = Color32::from_rgb(0x4a, 0xa3, 0xff); + for (r, label, on) in [ + (keys_rect, "Keys", self.landscape_keys), + (notes_rect, "Notes", !self.landscape_keys), + ] { + painter.rect_filled(r, 6.0, if on { accent } else { Color32::from_gray(46) }); + painter.text( + r.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(13.0), + if on { Color32::WHITE } else { Color32::from_gray(200) }, + ); + } + } + fn render_keyboard(&self, painter: &egui::Painter, rect: Rect) { // Background painter.rect_filled(rect, 0.0, Color32::from_rgb(40, 40, 45)); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 92d85be..5c2f15b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -575,6 +575,9 @@ struct VelloRenderContext { is_playing: bool, /// Active layer for tool operations active_layer_id: Option, + /// Text layer being edited in place, with the caret + selection as byte offsets + /// into its text (start, end). Drives the Vello-drawn caret/selection. + text_edit: Option<(uuid::Uuid, usize, usize)>, /// Delta for drag preview (world space) drag_delta: Option, /// Current selection state @@ -2109,6 +2112,74 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Active text-layer border: the same black/yellow marching-ants outline as + // raster, around the text box bounds (so its size is visible while selected). + if let Some(active_id) = self.ctx.active_layer_id { + if let Some(lightningbeam_core::layer::AnyLayer::Text(tl)) = self.ctx.document.get_layer(&active_id) { + let rect = vello::kurbo::Rect::from_origin_size( + tl.box_origin, + (tl.box_width, tl.box_height), + ); + let inv_zoom = 1.0 / (self.ctx.zoom as f64).max(1e-3); + let stroke_w = 1.5 * inv_zoom; + let dash = 6.0 * inv_zoom; + let pattern = [dash, dash]; + scene.stroke( + &vello::kurbo::Stroke::new(stroke_w).with_dashes(0.0, pattern), + overlay_transform, + vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]), + None, + &rect, + ); + scene.stroke( + &vello::kurbo::Stroke::new(stroke_w).with_dashes(dash, pattern), + overlay_transform, + vello::peniko::Color::new([1.0, 0.85, 0.0, 1.0]), + None, + &rect, + ); + + // 8 resize handles (corners + edge midpoints), constant screen size. + let (ox, oy, bw, bh) = (tl.box_origin.x, tl.box_origin.y, tl.box_width, tl.box_height); + let hs = 4.0 * inv_zoom; + let handle_pts = [ + (ox, oy), (ox + bw, oy), (ox + bw, oy + bh), (ox, oy + bh), + (ox + bw * 0.5, oy), (ox + bw, oy + bh * 0.5), + (ox + bw * 0.5, oy + bh), (ox, oy + bh * 0.5), + ]; + for (hx, hy) in handle_pts { + let hr = vello::kurbo::Rect::new(hx - hs, hy - hs, hx + hs, hy + hs); + scene.fill(vello::peniko::Fill::NonZero, overlay_transform, + vello::peniko::Color::new([1.0, 1.0, 1.0, 1.0]), None, &hr); + scene.stroke(&vello::kurbo::Stroke::new(stroke_w), overlay_transform, + vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]), None, &hr); + } + } + } + + // In-place text editing: Vello-drawn selection highlight + caret for the layer + // being edited (egui drives the input; we render the caret ourselves so it tracks + // any clip-instance transform). Byte offsets come from the egui field's cursor. + if let Some((edit_id, sel_start, sel_end)) = self.ctx.text_edit { + if let Some(lightningbeam_core::layer::AnyLayer::Text(tl)) = self.ctx.document.get_layer(&edit_id) { + use vello::peniko::{Color, Fill}; + let box_xf = overlay_transform * vello::kurbo::Affine::translate((tl.box_origin.x, tl.box_origin.y)); + let content = tl.content_at(self.ctx.playback_time); + let max_w = tl.box_width as f32; + // Selection highlight (behind would be ideal; semi-transparent so glyphs show through). + let (lo, hi) = (sel_start.min(sel_end), sel_start.max(sel_end)); + for (x0, y0, x1, y1) in lightningbeam_core::fonts::selection_geometry(content, max_w, lo, hi) { + let r = vello::kurbo::Rect::new(x0, y0, x1, y1); + scene.fill(Fill::NonZero, box_xf, Color::new([0.30, 0.55, 1.0, 0.35]), None, &r); + } + // Caret at the primary cursor (sel_end). + if let Some((x0, y0, x1, y1)) = lightningbeam_core::fonts::caret_geometry(content, max_w, sel_end) { + let r = vello::kurbo::Rect::new(x0, y0, x1.max(x0 + 1.0), y1); + scene.fill(Fill::NonZero, box_xf, Color::new([0.05, 0.05, 0.05, 1.0]), None, &r); + } + } + } + // Render drag preview objects with transparency if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) { if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) { @@ -3184,6 +3255,14 @@ pub struct StagePane { // Interaction state is_panning: bool, last_pan_pos: Option, + // Gesture state (P5): double-tap-drag marquee. `last_tap` = (time, canvas pos) of the last + // primary press for double-tap detection; `marquee_gesture_armed` = a double-tap landed on empty + // space and a drag will start a transient marquee regardless of the active tool. + last_tap: Option<(f64, egui::Pos2)>, + marquee_gesture_armed: bool, + /// True once an armed double-tap has actually begun dragging (so we suppress the active tool for + /// the duration of the marquee but NOT for a plain double-tap, which still zooms-to-fit). + marquee_gesture_active: bool, // Unique ID for this stage instance (for Vello resources) instance_id: u64, // Eyedropper state @@ -3267,6 +3346,28 @@ pub struct StagePane { /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, + + // --- Text layer in-place editing --- + /// The text layer currently being edited in place (None = not editing). + text_edit_layer: Option, + /// Working buffer mirrored to/from the hidden egui TextEdit while editing. + text_edit_buffer: String, + /// Set the frame editing begins so the egui field grabs focus exactly once. + text_edit_request_focus: bool, + /// Caret + selection as BYTE offsets into the text (anchor, focus), for the Vello caret. + text_edit_cursor: Option<(usize, usize)>, + /// The text content captured when editing began, for a single clean undo step. + text_edit_original: Option, + /// True when the current edit is of a freshly-created layer whose creation is still the + /// top undo entry — so committing it empty can remove it by rolling that creation back. + /// Cleared if another action (e.g. a resize) is pushed during the edit. + text_edit_is_new: bool, + /// If the current edit created a new clip (vector branch), its id — so an empty commit + /// also exits that clip after removing it. + text_edit_created_clip: Option, + /// Active drag of a text box resize handle: + /// (layer_id, handle_index, start_origin_x, start_origin_y, start_w, start_h). + text_box_drag: Option<(uuid::Uuid, usize, f64, f64, f64, f64)>, } /// Synthetic drag/click state injected during test mode replay @@ -3620,6 +3721,27 @@ fn selection_stipple_brush() -> &'static vello::peniko::ImageBrush { }) } +/// Convert a character index (egui caret) to a byte offset (parley/`str`). +fn char_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices().nth(char_idx).map(|(b, _)| b).unwrap_or(s.len()) +} + +/// Diagonal resize cursor for an axis-aligned box corner (0=TL, 1=TR, 2=BR, 3=BL). +fn corner_resize_cursor(corner: usize) -> egui::CursorIcon { + match corner % 4 { + 0 | 2 => egui::CursorIcon::ResizeNwSe, // TL / BR + _ => egui::CursorIcon::ResizeNeSw, // TR / BL + } +} + +/// Axis resize cursor for an axis-aligned box edge (0=Top, 1=Right, 2=Bottom, 3=Left). +fn edge_resize_cursor(edge: usize) -> egui::CursorIcon { + match edge % 4 { + 0 | 2 => egui::CursorIcon::ResizeVertical, // Top / Bottom + _ => egui::CursorIcon::ResizeHorizontal, // Right / Left + } +} + impl StagePane { pub fn new() -> Self { let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -3629,6 +3751,9 @@ impl StagePane { needs_initial_center: true, is_panning: false, last_pan_pos: None, + last_tap: None, + marquee_gesture_armed: false, + marquee_gesture_active: false, instance_id, pending_eyedropper_sample: None, last_viewport_rect: None, @@ -3661,6 +3786,14 @@ impl StagePane { pending_tool_readback_b: None, #[cfg(debug_assertions)] replay_override: None, + text_edit_layer: None, + text_edit_buffer: String::new(), + text_edit_request_focus: false, + text_edit_cursor: None, + text_edit_original: None, + text_edit_is_new: false, + text_edit_created_clip: None, + text_box_drag: None, } } @@ -3876,8 +4009,9 @@ impl StagePane { // geometry selection/editing there; clip-instance interaction stays available. let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time); - // Double-click: enter/exit movie clip editing - if response.double_clicked() { + // Double-click: enter/exit movie clip editing (desktop; mobile handles double-tap as a + // single tool-independent gesture in handle_input). + if !shared.is_mobile && response.double_clicked() { // Hit test clip instances at the click position let document = shared.action_executor.document(); let clip_hit = hit_test::hit_test_clip_instances( @@ -3908,6 +4042,18 @@ impl StagePane { // Double-click on empty space while inside a clip: exit *shared.pending_exit_clip = true; return; + } else { + // Double-tap / double-click on truly empty space (no clip and no shape under the + // pointer, at the top level) → zoom to fit the artboard. (Gesture P5-2.) + let graph_hit = if inbetween { + None + } else { + hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY) + }; + if graph_hit.is_none() { + self.zoom_to_fit(shared); + return; + } } } @@ -4836,6 +4982,364 @@ impl StagePane { } } + /// Text tool: click the stage to create a text layer. + /// - Active layer is Vector → create a clip (VectorClip + instance) containing a + /// text layer, then enter that clip so the text is directly editable. + /// - Otherwise (raster/video/none) → create a new top-level text layer. + fn handle_text_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::text_layer::{TextLayer, TextContent}; + use lightningbeam_core::actions::{AddLayerAction, CreateTextClipAction}; + use vello::kurbo::Point; + + // Allow dragging the active text layer's resize handles with the Text tool too + // (runs on drag events, so it must precede the click-only guard below). + if self.handle_text_box_resize(ui, response, world_pos, shared) { + return; + } + // Only act on a click (ignore drags / hovers). + if !self.rsp_clicked(response) { + return; + } + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // Click on an existing text box → edit it (don't spawn a duplicate). + if let Some(hit_id) = self.text_layer_at(point, shared) { + if self.text_edit_layer != Some(hit_id) { + if let Some(cur) = self.text_edit_layer { + self.commit_text_edit(shared, cur); + } + *shared.active_layer_id = Some(hit_id); + self.begin_text_edit(hit_id); + } + return; + } + + // Empty space → commit any in-progress edit, then create a new text layer. + if let Some(cur) = self.text_edit_layer { + self.commit_text_edit(shared, cur); + } + + let fc = *shared.fill_color; + let color = [ + fc.r() as f32 / 255.0, + fc.g() as f32 / 255.0, + fc.b() as f32 / 255.0, + fc.a() as f32 / 255.0, + ]; + + let is_vector = shared + .active_layer_id + .and_then(|id| shared.action_executor.document().get_layer(&id)) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + + if is_vector { + let parent_id = shared.active_layer_id.unwrap(); + // The text layer sits at the clip's origin; the instance is placed at the click. + let mut tl = TextLayer::new("Text", Point::ZERO); + tl.content = TextContent { color, ..TextContent::default() }; + let text_layer_id = tl.layer.id; + let clip_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let action = CreateTextClipAction::new(parent_id, tl, (point.x, point.y)) + .with_ids(clip_id, instance_id); + if shared.action_executor.execute(Box::new(action)).is_ok() { + // Enter the new clip; main.rs activates the clip's first layer (the text layer). + *shared.pending_enter_clip = Some((clip_id, instance_id, parent_id)); + self.begin_text_edit(text_layer_id); + self.text_edit_is_new = true; + self.text_edit_created_clip = Some(clip_id); + } + } else { + // Raster / video / group / no layer → new top-level text layer, created in + // the current editing context (root, or the clip currently being edited). + let mut tl = TextLayer::new("Text", point); + tl.content = TextContent { color, ..TextContent::default() }; + let text_layer_id = tl.layer.id; + let action = AddLayerAction::new(AnyLayer::Text(tl)) + .with_target_clip(shared.editing_clip_id); + if shared.action_executor.execute(Box::new(action)).is_ok() { + *shared.active_layer_id = Some(text_layer_id); + self.begin_text_edit(text_layer_id); + self.text_edit_is_new = true; + } + } + } + + /// Handle dragging a text box resize handle. Returns true if the interaction was + /// consumed (so the normal select/create logic should be skipped this frame). + /// `world_pos` is in the active layer's local space (clip-local when editing a clip), + /// which matches the box coordinates, so hit-testing is axis-aligned here. + /// Index (0..8) of the text-box resize handle under `(mx,my)`, if any. + /// 0=TL 1=TR 2=BR 3=BL (corners), 4=Top 5=Right 6=Bottom 7=Left (edges). + fn text_handle_at(ox: f64, oy: f64, bw: f64, bh: f64, mx: f64, my: f64, tol: f64) -> Option { + let pts = [ + (ox, oy), (ox + bw, oy), (ox + bw, oy + bh), (ox, oy + bh), + (ox + bw * 0.5, oy), (ox + bw, oy + bh * 0.5), + (ox + bw * 0.5, oy + bh), (ox, oy + bh * 0.5), + ]; + pts.iter().position(|(hx, hy)| (hx - mx).abs() <= tol && (hy - my).abs() <= tol) + } + + /// Resize cursor for a text-box handle index (reuses the shared corner/edge mapping). + fn text_handle_cursor(idx: usize) -> egui::CursorIcon { + if idx < 4 { corner_resize_cursor(idx) } else { edge_resize_cursor(idx - 4) } + } + + fn handle_text_box_resize( + &mut self, + ui: &egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) -> bool { + use lightningbeam_core::layer::AnyLayer; + + let Some(layer_id) = *shared.active_layer_id else { return false }; + let Some((ox, oy, bw, bh)) = (match shared.action_executor.document().get_layer(&layer_id) { + Some(AnyLayer::Text(tl)) => Some((tl.box_origin.x, tl.box_origin.y, tl.box_width, tl.box_height)), + _ => None, + }) else { return false }; + + let (mx, my) = (world_pos.x as f64, world_pos.y as f64); + let tol = 6.0 / (self.zoom as f64).max(1e-3); + + // Hover feedback: show a resize cursor over a handle when not already dragging. + if self.text_box_drag.is_none() { + if let Some(idx) = Self::text_handle_at(ox, oy, bw, bh, mx, my, tol) { + ui.ctx().set_cursor_icon(Self::text_handle_cursor(idx)); + } + } + + // Begin a handle drag: hit-test the 8 handles. + if self.rsp_drag_started(response) && self.text_box_drag.is_none() { + if let Some(idx) = Self::text_handle_at(ox, oy, bw, bh, mx, my, tol) { + self.text_box_drag = Some((layer_id, idx, ox, oy, bw, bh)); + return true; + } + } + + let Some((drag_id, idx, sox, soy, sbw, sbh)) = self.text_box_drag else { return false }; + if drag_id != layer_id { return false; } + + if self.rsp_dragged(response) { + let (no, nw, nh) = Self::resize_text_box(idx, sox, soy, sbw, sbh, mx, my); + // Live preview (no undo entry). + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.box_origin = no; + tl.box_width = nw; + tl.box_height = nh; + } + return true; + } + + if self.rsp_drag_stopped(response) { + let (no, nw, nh) = Self::resize_text_box(idx, sox, soy, sbw, sbh, mx, my); + // Restore the start box, then record one undoable resize. + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.box_origin = vello::kurbo::Point::new(sox, soy); + tl.box_width = sbw; + tl.box_height = sbh; + } + } + let action = lightningbeam_core::actions::ResizeTextBoxAction::new(layer_id, no, nw, nh); + let _ = shared.action_executor.execute(Box::new(action)); + self.text_box_drag = None; + // A resize is now the top undo entry, so the creation can no longer be + // rolled back by an empty-commit; keep the layer even if left empty. + self.text_edit_is_new = false; + return true; + } + + true + } + + /// Compute a new (origin, width, height) for a text box given a dragged handle. + /// Handles: 0=TL 1=TR 2=BR 3=BL (corners), 4=T 5=R 6=B 7=L (edges). + fn resize_text_box(idx: usize, ox: f64, oy: f64, w: f64, h: f64, mx: f64, my: f64) + -> (vello::kurbo::Point, f64, f64) + { + const MIN: f64 = 8.0; + let (mut l, mut r, mut t, mut b) = (ox, ox + w, oy, oy + h); + match idx { + 0 => { l = mx; t = my; } + 1 => { r = mx; t = my; } + 2 => { r = mx; b = my; } + 3 => { l = mx; b = my; } + 4 => { t = my; } + 5 => { r = mx; } + 6 => { b = my; } + 7 => { l = mx; } + _ => {} + } + if r - l < MIN { + if matches!(idx, 0 | 3 | 7) { l = r - MIN; } else { r = l + MIN; } + } + if b - t < MIN { + if matches!(idx, 0 | 1 | 4) { t = b - MIN; } else { b = t + MIN; } + } + (vello::kurbo::Point::new(l, t), r - l, b - t) + } + + /// Begin in-place editing of an existing text layer (not a fresh creation). + fn begin_text_edit(&mut self, layer_id: uuid::Uuid) { + self.text_edit_layer = Some(layer_id); + self.text_edit_buffer.clear(); + self.text_edit_request_focus = true; + self.text_edit_cursor = None; + self.text_edit_original = None; // captured on the first update frame + self.text_edit_is_new = false; + self.text_edit_created_clip = None; + } + + /// Clear all in-place text editing state. + fn finish_text_edit_state(&mut self) { + self.text_edit_layer = None; + self.text_edit_buffer.clear(); + self.text_edit_cursor = None; + self.text_edit_original = None; + self.text_edit_request_focus = false; + self.text_edit_is_new = false; + self.text_edit_created_clip = None; + } + + /// Commit the current in-place edit as a single undoable step. + fn commit_text_edit(&mut self, shared: &mut SharedPaneState, layer_id: uuid::Uuid) { + use lightningbeam_core::actions::SetTextContentAction; + + // A freshly-created layer left empty is removed by rolling back its creation + // (the top undo entry), which cleanly reverses both the root and clip branches. + if self.text_edit_is_new && self.text_edit_buffer.trim().is_empty() { + let _ = shared.action_executor.undo(); + if *shared.active_layer_id == Some(layer_id) { + *shared.active_layer_id = None; + } + // If creating it entered a new clip, leave that clip (it no longer exists). + if self.text_edit_created_clip.is_some() { + *shared.pending_exit_clip = true; + } + self.finish_text_edit_state(); + return; + } + + if let Some(orig) = self.text_edit_original.take() { + let mut final_content = orig.clone(); + final_content.text = self.text_edit_buffer.clone(); + if final_content != orig { + // The document already holds `final_content` (live preview); with_old + // records the pre-edit value so undo restores it in one step. + let action = SetTextContentAction::with_old(layer_id, orig, final_content); + let _ = shared.action_executor.execute(Box::new(action)); + } + } + self.finish_text_edit_state(); + } + + /// Find the topmost text layer in the current editing context whose box contains + /// `point` (in the context's local space, which matches `world_pos`). + fn text_layer_at(&self, point: vello::kurbo::Point, shared: &SharedPaneState) -> Option { + use lightningbeam_core::layer::AnyLayer; + let doc = shared.action_executor.document(); + let layers = doc.context_layers(shared.editing_clip_id.as_ref()); + for layer in layers.into_iter().rev() { + if let AnyLayer::Text(tl) = layer { + let r = vello::kurbo::Rect::from_origin_size( + tl.box_origin, (tl.box_width, tl.box_height)); + if r.contains(point) { + return Some(tl.layer.id); + } + } + } + None + } + + /// Drive the hybrid in-place text editor: a hidden, focused egui `TextEdit` captures + /// keystrokes/IME and the caret position; the text + caret are rendered in Vello. + /// The field is parked far offscreen (`constrain(false)` so egui doesn't clamp it back + /// on-screen) — invisible and not intercepting pointer events over the text box. + fn update_text_editing(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) { + use lightningbeam_core::layer::AnyLayer; + let Some(layer_id) = self.text_edit_layer else { return }; + + // Current text, or bail (and clear) if the layer is gone / not text anymore. + let cur_text = { + let doc = shared.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(AnyLayer::Text(tl)) => { + if self.text_edit_original.is_none() { + self.text_edit_original = Some(tl.content.clone()); + self.text_edit_buffer = tl.content.text.clone(); + } + tl.content.text.clone() + } + _ => { self.finish_text_edit_state(); return; } + } + }; + + // Escape cancels: restore the pre-edit content and exit. + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(orig) = self.text_edit_original.take() { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.content = orig; + } + } + self.finish_text_edit_state(); + return; + } + + // Hidden, focused TextEdit parked offscreen (constrain(false) keeps egui from + // clamping it back into view). Captures keyboard/IME + caret only. + let te_id = egui::Id::new(("lb_text_edit", layer_id)); + let output = egui::Area::new(egui::Id::new(("lb_text_edit_area", layer_id))) + .constrain(false) + .fixed_pos(egui::pos2(-100_000.0, -100_000.0)) + .show(ui.ctx(), |ui| { + egui::TextEdit::multiline(&mut self.text_edit_buffer) + .id(te_id) + .desired_width(1.0) + .show(ui) + }) + .inner; + + // Acquire and hold keyboard focus; once acquired, losing it commits the edit. + if self.text_edit_request_focus { + if output.response.has_focus() { + self.text_edit_request_focus = false; + } else { + output.response.request_focus(); + } + } else if !output.response.has_focus() { + self.commit_text_edit(shared, layer_id); + return; + } + + // Caret/selection → byte offsets for the Vello caret. + if let Some(cr) = output.cursor_range { + let anchor = char_to_byte(&self.text_edit_buffer, cr.secondary.index); + let focus = char_to_byte(&self.text_edit_buffer, cr.primary.index); + self.text_edit_cursor = Some((anchor, focus)); + } + + // Live preview: push the buffer into the document text (no undo entry). + if self.text_edit_buffer != cur_text { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.content.text = self.text_edit_buffer.clone(); + } + } + } + fn handle_ellipse_tool( &mut self, ui: &mut egui::Ui, @@ -9928,29 +10432,17 @@ impl StagePane { // Check corner handles with correct diagonal cursors for (idx, corner) in world_corners.iter().enumerate() { if point.distance(*corner) < tolerance { - // Top-left (0) and bottom-right (2): NW-SE diagonal (\) - // Top-right (1) and bottom-left (3): NE-SW diagonal (/) - let cursor = match idx { - 0 | 2 => egui::CursorIcon::ResizeNwSe, // Top-left, Bottom-right - 1 | 3 => egui::CursorIcon::ResizeNeSw, // Top-right, Bottom-left - _ => egui::CursorIcon::Default, - }; - ui.ctx().set_cursor_icon(cursor); + ui.ctx().set_cursor_icon(corner_resize_cursor(idx)); hovering_handle = true; break; } } - // Check edge handles + // Check edge handles (midpoints are ordered top, right, bottom, left) if !hovering_handle { for (idx, edge_pos) in edge_midpoints.iter().enumerate() { if point.distance(*edge_pos) < tolerance { - let cursor = match idx { - 0 | 2 => egui::CursorIcon::ResizeVertical, // Top/Bottom - 1 | 3 => egui::CursorIcon::ResizeHorizontal, // Right/Left - _ => egui::CursorIcon::Default, - }; - ui.ctx().set_cursor_icon(cursor); + ui.ctx().set_cursor_icon(edge_resize_cursor(idx)); hovering_handle = true; break; } @@ -10580,6 +11072,75 @@ impl StagePane { } } + /// True if a clip instance or vector geometry is under `point` (clip-local) on the active layer. + /// Used by the double-tap-drag marquee gesture to decide "empty vs object". + fn point_hits_object(&self, point: vello::kurbo::Point, shared: &SharedPaneState) -> bool { + use lightningbeam_core::hit_test; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Affine; + let Some(active_layer_id) = *shared.active_layer_id else { + return false; + }; + let document = shared.action_executor.document(); + let Some(AnyLayer::Vector(vl)) = document.get_layer(&active_layer_id) else { + return false; + }; + if hit_test::hit_test_clip_instances(&vl.clip_instances, document, point, Affine::IDENTITY, *shared.playback_time).is_some() { + return true; + } + hit_test::hit_test_layer(vl, *shared.playback_time, point, 5.0, Affine::IDENTITY).is_some() + } + + /// Delete the currently-selected DCEL edges on the active vector layer (snapshot-based undo). + /// Shared by the Delete/Backspace key and the long-press context menu. + fn delete_selected_geometry(&self, shared: &mut SharedPaneState) { + let Some(active_layer_id) = *shared.active_layer_id else { + return; + }; + let time = *shared.playback_time; + let selected_edges: Vec = + shared.selection.selected_edges().iter().copied().collect(); + let selected_fills: Vec = + shared.selection.selected_fills().iter().copied().collect(); + if selected_edges.is_empty() && selected_fills.is_empty() { + return; + } + let graph_before = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(), + _ => None, + }; + let Some(graph_before) = graph_before else { + return; + }; + { + let document = shared.action_executor.document_mut(); + if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { + if let Some(graph) = vl.graph_at_time_mut(time) { + // Free fills first so their boundary edges become unreferenced and are GC'd. + for fid in &selected_fills { + if !graph.fill(*fid).deleted { + graph.free_fill(*fid); + } + } + for eid in &selected_edges { + graph.remove_edge(*eid); + } + graph.gc_isolated_vertices(); + } + } + } + let graph_after = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(), + _ => None, + }; + if let Some(graph_after) = graph_after { + use lightningbeam_core::actions::ModifyGraphAction; + let action = ModifyGraphAction::new(active_layer_id, time, graph_before, graph_after, "Delete"); + shared.pending_actions.push(Box::new(action)); + } + shared.selection.clear_geometry_selection(); + } + fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { let response = ui.allocate_rect(rect, egui::Sense::click_and_drag()); @@ -10846,8 +11407,165 @@ impl StagePane { } } - // Handle tool input (only if not using Alt modifier for panning) - if !alt_held { + // P5 gesture (mobile): double-tap-drag on empty space → transient marquee, regardless of the + // active tool. The second tap arms it; a drag then drives ToolState::MarqueeSelecting (resolved + // by the release handler above). While armed we suppress the normal tool dispatch so a + // brush/etc. doesn't paint during the gesture. A double-tap without a drag falls through + // (Select tool handles zoom-to-fit). Mobile-only so it can't hijack a desktop Brush/Draw drag. + if shared.is_mobile && !is_replaying && !alt_held { + use vello::kurbo::Point; + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let press_pos = mouse_canvas_pos.to_pos2(); + if self.rsp_primary_pressed(ui) { + let now = ui.input(|i| i.time); + let is_double = self + .last_tap + .map_or(false, |(t, p)| now - t < 0.3 && p.distance(press_pos) < 12.0); + let hits = self.point_hits_object(point, shared); + self.marquee_gesture_armed = is_double && !hits; + self.last_tap = Some((now, press_pos)); + } + if self.marquee_gesture_armed && self.rsp_dragged(&response) { + self.marquee_gesture_active = true; + match shared.tool_state { + ToolState::MarqueeSelecting { start, .. } => { + *shared.tool_state = ToolState::MarqueeSelecting { start: *start, current: point }; + } + _ => { + if !shift_held { + shared.selection.clear(); + *shared.focus = lightningbeam_core::selection::FocusSelection::None; + } + *shared.tool_state = ToolState::MarqueeSelecting { start: point, current: point }; + } + } + } + if ui.input(|i| i.pointer.any_released()) { + self.marquee_gesture_armed = false; + self.marquee_gesture_active = false; + } + } + + // P5 gesture (mobile): double-tap semantics, in priority order and regardless of the active + // tool. (double_clicked() fires on double-tap and, on desktop, double-click — so testable.) + // 1. object under the tap → enter it (movie clip or group — go a level deeper) + // 2. empty, inside a clip → exit one level + // 3. empty, at the top level → zoom to fit + // The desktop equivalent lives in handle_select_tool (gated to !is_mobile below). + if shared.is_mobile && response.double_clicked() { + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::{Affine, Point}; + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let hit = (*shared.active_layer_id).and_then(|layer_id| { + let document = shared.action_executor.document(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + lightningbeam_core::hit_test::hit_test_clip_instances( + &vl.clip_instances, document, point, Affine::IDENTITY, *shared.playback_time, + ) + .and_then(|iid| vl.clip_instances.iter().find(|c| c.id == iid).map(|c| (c.clip_id, iid, layer_id))) + } else { + None + } + }); + if let Some((clip_id, instance_id, layer_id)) = hit { + *shared.pending_enter_clip = Some((clip_id, instance_id, layer_id)); + } else if shared.editing_clip_id.is_some() { + *shared.pending_exit_clip = true; + } else { + self.zoom_to_fit(shared); + } + } + + // P5 gesture (mobile only): long-press on an object opens its actions menu. In the egui fork + // `secondary_clicked()` fires on long-press (and right-click) and `context_menu()` opens on + // the same — so the menu must be attached UNCONDITIONALLY for egui to register it. Desktop + // (flag off) has no Stage context menu. + if shared.is_mobile { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::selection::FocusSelection; + use vello::kurbo::{Affine, Point}; + // Long-press selects the object under the press (clip instance first, else geometry). + if response.secondary_clicked() { + if let Some(layer_id) = *shared.active_layer_id { + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let time = *shared.playback_time; + let clip_hit = { + let document = shared.action_executor.document(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + lightningbeam_core::hit_test::hit_test_clip_instances(&vl.clip_instances, document, point, Affine::IDENTITY, time) + } else { + None + } + }; + if let Some(instance_id) = clip_hit { + if !shared.selection.contains_clip_instance(&instance_id) { + shared.selection.select_only_clip_instance(instance_id); + *shared.focus = FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); + } + } else { + // No clip — try vector geometry (edge/fill). + let graph_hit = { + let document = shared.action_executor.document(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + lightningbeam_core::hit_test::hit_test_layer(vl, time, point, 5.0, Affine::IDENTITY) + } else { + None + } + }; + if let Some(gh) = graph_hit { + if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) { + if let Some(graph) = vl.graph_at_time(time) { + shared.selection.clear_geometry_selection(); + match gh { + lightningbeam_core::hit_test::GraphHitResult::Edge(eid) => shared.selection.select_edge(eid, graph), + lightningbeam_core::hit_test::GraphHitResult::Fill(fid) => shared.selection.select_fill(fid, graph), + } + } + } + *shared.focus = FocusSelection::Geometry { layer_id, time }; + } + } + } + } + + // Build the menu items for the current selection and hand them to the shell to render + // as a persistent popup (stays open until an item or the backdrop is tapped). + if response.secondary_clicked() { + use crate::menu::MenuAction; + let mut items: Vec<(String, MenuAction)> = Vec::new(); + if !shared.selection.clip_instances().is_empty() { + // A clip instance is already a clip — no "convert" here. + items.push(("Cut".into(), MenuAction::Cut)); + items.push(("Copy".into(), MenuAction::Copy)); + items.push(("Paste".into(), MenuAction::Paste)); + items.push(("Duplicate".into(), MenuAction::DuplicateClip)); + items.push(("Delete".into(), MenuAction::Delete)); + items.push(("Send to back".into(), MenuAction::SendToBack)); + items.push(("Bring to front".into(), MenuAction::BringToFront)); + } else if shared.selection.has_geometry_selection() { + // Geometry can be gathered up into a movie clip. + items.push(("Cut".into(), MenuAction::Cut)); + items.push(("Copy".into(), MenuAction::Copy)); + items.push(("Paste".into(), MenuAction::Paste)); + items.push(("Convert to movie clip".into(), MenuAction::ConvertToMovieClip)); + items.push(("Delete".into(), MenuAction::Delete)); + items.push(("Send to back".into(), MenuAction::SendToBack)); + items.push(("Bring to front".into(), MenuAction::BringToFront)); + } else { + // Nothing selected — offer paste onto the stage. + items.push(("Paste".into(), MenuAction::Paste)); + } + if !items.is_empty() { + if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { + *shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items }); + } + } + } + } + + // Handle tool input (only if not using Alt modifier for panning, and not while a + // double-tap-drag marquee is actively dragging so the tool doesn't act during the gesture). + if !alt_held && !self.marquee_gesture_active { use lightningbeam_core::tool::Tool; // On a shape-tween in-between frame the active vector layer's geometry is an @@ -10863,10 +11581,19 @@ impl StagePane { match *shared.selected_tool { Tool::Select => { - let is_raster = shared.active_layer_id.and_then(|id| { + let active = shared.active_layer_id.and_then(|id| { shared.action_executor.document().get_layer(&id) - }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); - if is_raster { + }); + let is_raster = matches!(active, Some(lightningbeam_core::layer::AnyLayer::Raster(_))); + let is_text = matches!(active, Some(lightningbeam_core::layer::AnyLayer::Text(_))); + if is_text && self.handle_text_box_resize(ui, &response, world_pos, shared) { + // Consumed by a resize-handle drag. + } else if is_text && response.double_clicked() { + // Double-click a selected text layer to edit it in place. + if let Some(id) = *shared.active_layer_id { + self.begin_text_edit(id); + } + } else if is_raster { self.handle_raster_select_tool(ui, &response, world_pos, shared); } else { self.handle_select_tool(ui, &response, world_pos, shift_held, shared); @@ -10932,6 +11659,9 @@ impl StagePane { Tool::Gradient => { self.handle_raster_gradient_tool(ui, &response, world_pos, shared); } + Tool::Text => { + self.handle_text_tool(ui, &response, world_pos, shared); + } _ => { // Other tools not implemented yet } @@ -10945,70 +11675,26 @@ impl StagePane { // Delete/Backspace: remove selected DCEL elements if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) { if shared.selection.has_geometry_selection() { - if let Some(active_layer_id) = *shared.active_layer_id { - let time = *shared.playback_time; - - // Collect selected edge IDs before mutating - let selected_edges: Vec = - shared.selection.selected_edges().iter().copied().collect(); - - if !selected_edges.is_empty() { - // Snapshot before - let graph_before = { - let document = shared.action_executor.document(); - match document.get_layer(&active_layer_id) { - Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { - vl.graph_at_time(time).cloned() - } - _ => None, - } - }; - - if let Some(graph_before) = graph_before { - // Remove selected edges - { - let document = shared.action_executor.document_mut(); - if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { - if let Some(graph) = vl.graph_at_time_mut(time) { - for eid in &selected_edges { - graph.remove_edge(*eid); - } - } - } - } - - // Snapshot after - let graph_after = { - let document = shared.action_executor.document(); - match document.get_layer(&active_layer_id) { - Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { - vl.graph_at_time(time).cloned() - } - _ => None, - } - }; - - if let Some(graph_after) = graph_after { - use lightningbeam_core::actions::ModifyGraphAction; - let action = ModifyGraphAction::new( - active_layer_id, - time, - graph_before, - graph_after, - "Delete", - ); - shared.pending_actions.push(Box::new(action)); - } - - shared.selection.clear_geometry_selection(); - } - } - } + self.delete_selected_geometry(shared); } } // Skip real scroll/zoom/pan input during replay if !is_replaying { + // Unified pinch / Ctrl+scroll zoom: `zoom_delta()` folds in touch-pinch (on device) AND + // Ctrl+wheel/trackpad (on desktop). It's a multiplicative factor (1.0 = no change); + // `apply_zoom_at_point` wants an additive delta (new = old * (1 + delta)). This is the + // single path for factor-zoom — the raw MouseWheel branch below only handles plain + // (non-Ctrl) mouse-wheel zoom and non-Ctrl trackpad pan. + let zoom_factor = ui.input(|i| i.zoom_delta()); + if (zoom_factor - 1.0).abs() > f32::EPSILON { + let center = ui + .input(|i| i.multi_touch().map(|m| m.center_pos)) + .map(|p| p - rect.min) + .unwrap_or(mouse_canvas_pos); + self.apply_zoom_at_point(zoom_factor - 1.0, center); + } + // Distinguish between mouse wheel (discrete) and trackpad (smooth) let mut handled = false; ui.input(|i| { @@ -11016,23 +11702,20 @@ impl StagePane { if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { match unit { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { - // Real mouse wheel (discrete clicks) -> always zoom - let zoom_delta = if ctrl_held || modifiers.ctrl { - delta.y * 0.01 // Ctrl+wheel: faster zoom - } else { - delta.y * 0.005 // Normal zoom - }; - self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); + // Plain mouse wheel zooms; Ctrl+wheel was already handled above via + // zoom_delta(). Consume the event either way so the pan fallback + // below doesn't also fire. + if !(ctrl_held || modifiers.ctrl) { + self.apply_zoom_at_point(delta.y * 0.005, mouse_canvas_pos); + } handled = true; } egui::MouseWheelUnit::Point => { - // Trackpad (smooth scrolling) -> only zoom if Ctrl held + // Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls + // through to scroll_delta panning. if ctrl_held || modifiers.ctrl { - let zoom_delta = delta.y * 0.005; - self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); handled = true; } - // Otherwise let scroll_delta handle panning } } } @@ -11765,6 +12448,9 @@ impl PaneRenderer for StagePane { // Handle input for pan/zoom and tool controls self.handle_input(ui, rect, shared); + // Drive the in-place text editor (hidden egui field → Vello caret). + self.update_text_editing(ui, shared); + // Handle asset drag-and-drop from Asset Library if let Some(dragging) = shared.dragging_asset.clone() { if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { @@ -12143,6 +12829,9 @@ impl PaneRenderer for StagePane { container_path: shared.container_path.clone(), is_playing: *shared.is_playing, active_layer_id: *shared.active_layer_id, + text_edit: self.text_edit_layer.and_then(|id| { + self.text_edit_cursor.map(|(s, e)| (id, s, e)) + }), drag_delta, selection: shared.selection.clone(), fill_color: *shared.fill_color, @@ -12216,72 +12905,72 @@ impl PaneRenderer for StagePane { } } - // Show camera info overlay - let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); - ui.painter().text( - rect.min + egui::vec2(10.0, 10.0), - egui::Align2::LEFT_TOP, - format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})", - self.zoom, self.pan_offset.x, self.pan_offset.y), - egui::FontId::proportional(14.0), - info_color, - ); - - // Render breadcrumb navigation when inside a movie clip - if shared.editing_clip_id.is_some() { - let document = shared.action_executor.document(); - // Build breadcrumb names from the editing context - // We only have the current clip_id, so show "Scene 1 > ClipName" - let clip_name = shared.editing_clip_id - .and_then(|id| document.get_vector_clip(&id)) - .map(|c| c.name.clone()) - .unwrap_or_else(|| "Unknown".to_string()); - - let breadcrumb_y = rect.min.y + 30.0; - let breadcrumb_x = rect.min.x + 10.0; - - // Background pill - let scene_text = "Scene 1"; - let separator = " > "; - let full_text = format!("{}{}{}", scene_text, separator, clip_name); + // Render breadcrumb navigation showing the full editing path (root → … → current clip). + if !shared.editing_clip_path.is_empty() { let font = egui::FontId::proportional(13.0); - let galley = ui.painter().layout_no_wrap(full_text.clone(), font.clone(), egui::Color32::WHITE); - let text_rect = egui::Rect::from_min_size( - egui::pos2(breadcrumb_x, breadcrumb_y), - galley.size() + egui::vec2(16.0, 8.0), - ); - ui.painter().rect_filled( - text_rect, - 4.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180), - ); + let sep = " > "; + // Segments: "Scene 1" (root) + each clip name. `target` = depth to exit to when clicked + // (root → 0; clip at path index k → k+1); the current (last) level isn't clickable. + let segments: Vec<(String, Option)> = { + let document = shared.action_executor.document(); + let n = shared.editing_clip_path.len(); + let mut segs: Vec<(String, Option)> = vec![("Scene 1".to_string(), Some(0))]; + for (k, cid) in shared.editing_clip_path.iter().enumerate() { + let name = document + .get_vector_clip(cid) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + segs.push((name, if k + 1 == n { None } else { Some(k + 1) })); + } + segs + }; - // "Scene 1" as clickable (exit clip) - let scene_galley = ui.painter().layout_no_wrap( - scene_text.to_string(), font.clone(), egui::Color32::from_rgb(120, 180, 255), - ); - let scene_rect = egui::Rect::from_min_size( - egui::pos2(breadcrumb_x + 8.0, breadcrumb_y + 4.0), - scene_galley.size(), - ); - let scene_response = ui.allocate_rect(scene_rect, egui::Sense::click()); - ui.painter().galley(scene_rect.min, scene_galley, egui::Color32::WHITE); - if scene_response.clicked() { - *shared.pending_exit_clip = true; - } - if scene_response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } + let bx = rect.min.x + 10.0; + let by = rect.min.y + 10.0; - // Separator + clip name (not clickable, it's the current level) - let rest_text = format!("{}{}", separator, clip_name); - ui.painter().text( - egui::pos2(scene_rect.max.x, breadcrumb_y + 4.0), - egui::Align2::LEFT_TOP, - rest_text, - font, - egui::Color32::WHITE, - ); + // Background pill sized to the whole path. + let full: String = segments + .iter() + .enumerate() + .map(|(i, (s, _))| if i == 0 { s.clone() } else { format!("{sep}{s}") }) + .collect(); + let full_galley = ui.painter().layout_no_wrap(full, font.clone(), egui::Color32::WHITE); + let pill = egui::Rect::from_min_size(egui::pos2(bx, by), full_galley.size() + egui::vec2(16.0, 8.0)); + ui.painter().rect_filled(pill, 4.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180)); + + // Draw segments left → right; clickable ancestors in blue. + let mut x = bx + 8.0; + let y = by + 4.0; + let mut clicked_target: Option = None; + for (i, (name, target)) in segments.iter().enumerate() { + if i > 0 { + let sg = ui.painter().layout_no_wrap(sep.to_string(), font.clone(), egui::Color32::from_gray(160)); + let sw = sg.size().x; + ui.painter().galley(egui::pos2(x, y), sg, egui::Color32::from_gray(160)); + x += sw; + } + let color = if target.is_some() { + egui::Color32::from_rgb(120, 180, 255) + } else { + egui::Color32::WHITE + }; + let g = ui.painter().layout_no_wrap(name.clone(), font.clone(), color); + let seg_rect = egui::Rect::from_min_size(egui::pos2(x, y), g.size()); + if let Some(depth) = target { + let resp = ui.allocate_rect(seg_rect, egui::Sense::click()); + if resp.clicked() { + clicked_target = Some(*depth); + } + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + ui.painter().galley(seg_rect.min, g, color); + x += seg_rect.width(); + } + if let Some(depth) = clicked_target { + *shared.pending_exit_to_depth = Some(depth); + } } // Render vector editing overlays (vertices, control points, etc.) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 5cfdca3..793835b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -17,6 +17,8 @@ use std::collections::HashMap; const RULER_HEIGHT: f32 = 30.0; const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEADER_WIDTH: f32 = 200.0; +/// On mobile the header collapses to a minimal color swatch with a 1:2 width:height aspect. +const MOBILE_LAYER_HEADER_WIDTH: f32 = LAYER_HEIGHT * 0.5; const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes const MAX_PIXELS_PER_SECOND: f32 = 500.0; const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles @@ -212,6 +214,7 @@ fn effective_clip_duration( AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION), AnyLayer::Group(_) => None, AnyLayer::Raster(_) => None, + AnyLayer::Text(_) => None, } } @@ -265,6 +268,9 @@ pub struct TimelinePane { /// Is the user panning the timeline? is_panning: bool, last_pan_pos: Option, + /// Manual long-press tracking for the mobile context menu: press start time + position. + lp_time: Option, + lp_pos: egui::Pos2, /// Clip drag state (None if not dragging) clip_drag_state: Option, @@ -685,6 +691,7 @@ fn layer_clips<'a>( AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], } } @@ -718,6 +725,7 @@ fn collect_clip_instances<'a>( } } AnyLayer::Raster(_) => {} + AnyLayer::Text(_) => {} } } @@ -768,6 +776,7 @@ fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) { AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), + AnyLayer::Text(_) => ("Text", egui::Color32::from_rgb(220, 200, 120)), } } @@ -782,6 +791,8 @@ impl TimelinePane { is_scrubbing: false, is_panning: false, last_pan_pos: None, + lp_time: None, + lp_pos: egui::Pos2::ZERO, clip_drag_state: None, drag_offset: 0.0, drag_anchor_start: 0.0, @@ -945,7 +956,7 @@ impl TimelinePane { /// Toggle recording on/off /// In Auto mode, records to the active layer (audio or video with camera) - fn toggle_recording(&mut self, shared: &mut SharedPaneState) { + pub(crate) fn toggle_recording(&mut self, shared: &mut SharedPaneState) { if *shared.is_recording { // Stop recording self.stop_recording(shared); @@ -1206,7 +1217,7 @@ impl TimelinePane { /// Stop all active recordings /// Called every frame; fires deferred recording commands once the count-in pre-roll ends. - fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) { + pub(crate) fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) { let Some(ref pending) = self.pending_recording_start else { return }; if !*shared.is_playing || *shared.playback_time < pending.trigger_time { return; @@ -1226,7 +1237,7 @@ impl TimelinePane { *shared.count_in_enabled = saved_count_in; } - fn stop_recording(&mut self, shared: &mut SharedPaneState) { + pub(crate) fn stop_recording(&mut self, shared: &mut SharedPaneState) { // Cancel any in-progress count-in self.pending_recording_start = None; @@ -1475,6 +1486,15 @@ impl TimelinePane { self.pixels_per_second = 100.0; } + /// Fit the whole project duration into the given content width, scrolled to the start. + /// (Gesture P5-2: double-tap empty → zoom-to-fit.) + pub fn fit_to_project(&mut self, viewport_width: f32) { + let dur = self.duration.max(0.01); + self.pixels_per_second = + (viewport_width / dur as f32).clamp(MIN_PIXELS_PER_SECOND, MAX_PIXELS_PER_SECOND); + self.viewport_start_time = 0.0; + } + /// Apply zoom while keeping the time under the cursor stationary fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) { let old_zoom = self.pixels_per_second; @@ -1933,6 +1953,8 @@ impl TimelinePane { track_levels: &std::collections::HashMap, input_level: f32, playback_time: f64, + header_width: f32, + is_mobile: bool, ) { // Background for header column let header_style = theme.style(".timeline-header", ui.ctx()); @@ -1995,7 +2017,7 @@ impl TimelinePane { let header_rect = egui::Rect::from_min_size( egui::pos2(rect.min.x, y), - egui::vec2(LAYER_HEADER_WIDTH, LAYER_HEIGHT), + egui::vec2(header_width, LAYER_HEIGHT), ); // Determine the AnyLayer or GroupLayer for this row @@ -2029,6 +2051,27 @@ impl TimelinePane { ui.painter().rect_filled(header_rect, 0.0, bg_color); + // Mobile: collapse the header to a minimal color swatch (1:2 w:h). Selection is handled + // by handle_input via the (narrow) header rect, so we only draw here. + if is_mobile { + let sw = header_rect.shrink(2.0); + let col = if is_active || is_selected { + type_color + } else { + type_color.gamma_multiply(0.55) + }; + ui.painter().rect_filled(sw, 3.0, col); + if is_active || is_selected { + ui.painter().rect_stroke( + sw, + 3.0, + egui::Stroke::new(1.5, egui::Color32::WHITE), + egui::StrokeKind::Inside, + ); + } + continue; + } + // Gutter area (left of indicator) — solid group color, with collapse chevron if indent > 0.0 { let gutter_rect = egui::Rect::from_min_size( @@ -4935,29 +4978,40 @@ impl TimelinePane { // Only handle scroll when mouse is over the timeline area let mut handled = false; let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect); + + // Unified pinch / Ctrl+scroll time-zoom: `zoom_delta()` folds in touch-pinch (on device) AND + // Ctrl+wheel/trackpad (on desktop). Multiplicative factor (1.0 = no change); apply_zoom_at_point + // wants an additive delta. This is the single path for factor-zoom — the raw MouseWheel branch + // below only handles plain (non-Ctrl) mouse-wheel zoom and non-Ctrl trackpad pan. + if pointer_over_timeline { + let zoom_factor = ui.input(|i| i.zoom_delta()); + if (zoom_factor - 1.0).abs() > f32::EPSILON { + let center_x = ui + .input(|i| i.multi_touch().map(|m| m.center_pos.x)) + .map(|x| x - content_rect.min.x) + .unwrap_or(mouse_x); + self.apply_zoom_at_point(zoom_factor - 1.0, center_x); + } + } + if pointer_over_timeline { ui.input(|i| { for event in &i.raw.events { if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { match unit { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { - // Real mouse wheel (discrete clicks) -> always zoom horizontally - let zoom_delta = if ctrl_held || modifiers.ctrl { - delta.y * 0.01 // Ctrl+wheel: faster zoom - } else { - delta.y * 0.005 // Normal zoom - }; - self.apply_zoom_at_point(zoom_delta, mouse_x); + // Plain mouse wheel zooms; Ctrl+wheel handled above via zoom_delta(). + // Consume the event either way so the pan fallback doesn't also fire. + if !(ctrl_held || modifiers.ctrl) { + self.apply_zoom_at_point(delta.y * 0.005, mouse_x); + } handled = true; } egui::MouseWheelUnit::Point => { - // Trackpad (smooth scrolling) + // Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls through + // to scroll_delta panning below. if ctrl_held || modifiers.ctrl { - // Ctrl held: zoom - let zoom_delta = delta.y * 0.005; - self.apply_zoom_at_point(zoom_delta, mouse_x); handled = true; } - // Otherwise let scroll_delta handle panning (below) } } } @@ -5000,6 +5054,18 @@ impl TimelinePane { } } + // Double-tap / double-click on empty space → fit the whole project into view. (Gesture P5-2.) + if response.double_clicked() { + if let Some(pos) = response.interact_pointer_pos() { + let over_clip = self + .detect_clip_at_pointer(pos, document, content_rect, header_rect, editing_clip_id, &audio_cache) + .is_some(); + if !over_clip { + self.fit_to_project(content_rect.width()); + } + } + } + // Update cursor based on hover position (only if not scrubbing or panning) if !self.is_scrubbing && !self.is_panning { // If dragging a clip with trim/loop, keep the appropriate cursor @@ -5452,15 +5518,21 @@ impl PaneRenderer for TimelinePane { } self.duration = max_endpoint; - // Split into layer header column (left) and timeline content (right) + // Split into layer header column (left) and timeline content (right). On mobile the header + // column collapses to a minimal color-swatch width. + let header_width = if shared.is_mobile { + MOBILE_LAYER_HEADER_WIDTH + } else { + LAYER_HEADER_WIDTH + }; let header_column_rect = egui::Rect::from_min_size( rect.min, - egui::vec2(LAYER_HEADER_WIDTH, rect.height()), + egui::vec2(header_width, rect.height()), ); let timeline_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(LAYER_HEADER_WIDTH, 0.0), - egui::vec2(rect.width() - LAYER_HEADER_WIDTH, rect.height()), + rect.min + egui::vec2(header_width, 0.0), + egui::vec2(rect.width() - header_width, rect.height()), ); // Split timeline into ruler and content areas @@ -5477,12 +5549,12 @@ impl PaneRenderer for TimelinePane { // Split header column into ruler area (top) and layer headers (bottom) let header_ruler_spacer = egui::Rect::from_min_size( header_column_rect.min, - egui::vec2(LAYER_HEADER_WIDTH, RULER_HEIGHT), + egui::vec2(header_width, RULER_HEIGHT), ); let layer_headers_rect = egui::Rect::from_min_size( header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT), - egui::vec2(LAYER_HEADER_WIDTH, header_column_rect.height() - RULER_HEIGHT), + egui::vec2(header_width, header_column_rect.height() - RULER_HEIGHT), ); // Save original clip rect to restore at the end @@ -5499,7 +5571,7 @@ impl PaneRenderer for TimelinePane { // Render layer header column with clipping ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); - self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time, header_width, shared.is_mobile); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); @@ -5770,9 +5842,10 @@ impl PaneRenderer for TimelinePane { } ui.set_clip_rect(original_clip_rect); - // Context menu: detect right-click on clips or empty timeline space + // Context menu: detect right-click on clips or empty timeline space (desktop only — mobile + // uses the long-press menu below). let mut just_opened_menu = false; - let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); + let secondary_clicked = !shared.is_mobile && ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); if secondary_clicked { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if content_rect.contains(pos) { @@ -5792,6 +5865,67 @@ impl PaneRenderer for TimelinePane { } } + // Mobile: a manual long-press (hold in place ~0.4s) builds a persistent context menu rendered + // by the shell — clip actions on a clip, animation (keyframe/tween) actions on an empty lane. + let mut long_press: Option = None; + if shared.is_mobile { + let now = ui.input(|i| i.time); + let down = ui.input(|i| i.pointer.any_down()); + let ptr = ui.input(|i| i.pointer.latest_pos()); + if ui.input(|i| i.pointer.primary_pressed()) { + match ptr { + Some(p) if content_rect.contains(p) => { + self.lp_time = Some(now); + self.lp_pos = p; + } + _ => self.lp_time = None, + } + } + if !down { + self.lp_time = None; + } + if let Some(t0) = self.lp_time { + let moved = ptr.map_or(0.0, |p| (p - self.lp_pos).length()); + if moved > 8.0 { + self.lp_time = None; + } else if now - t0 >= 0.4 { + self.lp_time = None; + long_press = Some(self.lp_pos); + } else { + // Still counting down — request a repaint so the threshold is re-evaluated even if + // the finger is perfectly still and egui would otherwise go idle (no input events). + let remaining = (0.4 - (now - t0)).max(0.0); + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(remaining)); + } + } + } + if let Some(pos) = long_press { + use crate::menu::MenuAction; + let mut items: Vec<(String, MenuAction)> = Vec::new(); + if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref(), &audio_cache) { + if !shared.selection.contains_clip_instance(&clip_id) { + shared.selection.select_only_clip_instance(clip_id); + } + *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); + items.push(("Split clip".into(), MenuAction::SplitClip)); + items.push(("Duplicate clip".into(), MenuAction::DuplicateClip)); + items.push(("Cut".into(), MenuAction::Cut)); + items.push(("Copy".into(), MenuAction::Copy)); + items.push(("Paste".into(), MenuAction::Paste)); + items.push(("Delete".into(), MenuAction::Delete)); + } else { + items.push(("New keyframe".into(), MenuAction::NewKeyframe)); + items.push(("New blank keyframe".into(), MenuAction::NewBlankKeyframe)); + items.push(("Add keyframe at playhead".into(), MenuAction::AddKeyframeAtPlayhead)); + items.push(("Duplicate keyframe".into(), MenuAction::DuplicateKeyframe)); + items.push(("Delete frame".into(), MenuAction::DeleteFrame)); + items.push(("Add motion tween".into(), MenuAction::AddMotionTween)); + items.push(("Add shape tween".into(), MenuAction::AddShapeTween)); + items.push(("Paste".into(), MenuAction::Paste)); + } + *shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items }); + } + // Render context menu if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip { let has_clip = ctx_clip_id.is_some(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 019df99..28315bd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -43,6 +43,7 @@ impl PaneRenderer for ToolbarPane { AnyLayer::Effect(_) => LayerType::Effect, AnyLayer::Group(_) => LayerType::Group, AnyLayer::Raster(_) => LayerType::Raster, + AnyLayer::Text(_) => LayerType::Text, }); // Auto-switch to Select if the current tool isn't available for this layer type diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index 3ab982c..1bcba15 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -33,6 +33,8 @@ pub struct VirtualPianoPane { sustain_active: bool, /// Notes being held by sustain pedal (not by active key/mouse press) sustained_notes: HashSet, + /// Externally-driven highlights (e.g. notes sounding during playback), colored like pressed keys. + pub playback_notes: HashSet, } impl Default for VirtualPianoPane { @@ -83,6 +85,7 @@ impl VirtualPianoPane { note_to_key_map, sustain_active: false, sustained_notes: HashSet::new(), + playback_notes: HashSet::new(), } } @@ -216,9 +219,17 @@ impl VirtualPianoPane { /// Render the piano keyboard fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { - // Calculate visible range and key dimensions based on pane size - let (visible_start, visible_end, white_key_width, offset_x) = - self.calculate_visible_range(rect.width(), rect.height()); + // Calculate visible range and key dimensions based on pane size. On mobile use the shared + // width-driven layout (so the portrait Piano Roll's columns line up with these keys). + let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile { + let layout = super::keyboard_layout::KeyboardLayout::from_width( + rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x, + ); + let vs = layout.first_visible_white(); + (vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x) + } else { + self.calculate_visible_range(rect.width(), rect.height()) + }; let white_key_height = rect.height(); let black_key_width = white_key_width * self.black_key_width_ratio; @@ -238,6 +249,11 @@ impl VirtualPianoPane { return; } + // Only play notes when the press STARTED on the keyboard, so dragging in from the roll above + // (mobile) doesn't trigger notes. On desktop the whole pane is the keyboard, so a click here + // always qualifies. + let started_here = ui.input(|i| i.pointer.press_origin().map_or(false, |p| rect.contains(p))); + // Count white keys before each note for positioning let mut white_key_positions: std::collections::HashMap = std::collections::HashMap::new(); let mut white_count = 0; @@ -303,7 +319,8 @@ impl VirtualPianoPane { }); let pointer_down = ui.input(|i| i.pointer.primary_down()); let is_pressed = self.pressed_notes.contains(¬e) || - (!black_key_interacted && pointer_over_key && pointer_down); + self.playback_notes.contains(¬e) || + (started_here && !black_key_interacted && pointer_over_key && pointer_down); let color = if is_pressed { shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255)) } else { @@ -320,8 +337,9 @@ impl VirtualPianoPane { ); if !black_key_interacted { - // Mouse down starts note (detect primary button pressed on this key) - if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { + // Mouse down starts note (detect primary button pressed on this key). Gated on + // `started_here` so a drag that began on the roll above doesn't start notes. + if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { // Calculate velocity based on mouse Y position let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); @@ -330,7 +348,8 @@ impl VirtualPianoPane { self.dragging_note = Some(note); } - // Mouse up stops note (detect primary button released) + // Mouse up stops note (detect primary button released). NEVER gated — a held note + // must always release. if ui.input(|i| i.pointer.primary_released()) { if self.dragging_note == Some(note) { self.send_note_off(note, shared); @@ -339,7 +358,7 @@ impl VirtualPianoPane { } // Dragging over a new key (pointer is down and over a different key) - if pointer_over_key && pointer_down { + if started_here && pointer_over_key && pointer_down { if self.dragging_note != Some(note) { // Stop previous note if let Some(prev_note) = self.dragging_note { @@ -387,7 +406,8 @@ impl VirtualPianoPane { }); let pointer_down = ui.input(|i| i.pointer.primary_down()); let is_pressed = self.pressed_notes.contains(¬e) || - (pointer_over_key && pointer_down); + self.playback_notes.contains(¬e) || + (started_here && pointer_over_key && pointer_down); let color = if is_pressed { shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200)) } else { @@ -397,7 +417,7 @@ impl VirtualPianoPane { ui.painter().rect_filled(key_rect, 2.0, color); // Mouse down starts note - if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { + if started_here && pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { // Calculate velocity based on mouse Y position let mouse_y = ui.input(|i| i.pointer.hover_pos()).unwrap().y; let velocity = self.calculate_velocity_from_mouse_y(mouse_y, key_rect); @@ -415,7 +435,7 @@ impl VirtualPianoPane { } // Dragging over a new key - if pointer_over_key && pointer_down { + if started_here && pointer_over_key && pointer_down { if self.dragging_note != Some(note) { if let Some(prev_note) = self.dragging_note { self.send_note_off(prev_note, shared); @@ -824,6 +844,11 @@ impl PaneRenderer for VirtualPianoPane { _path: &NodePath, shared: &mut SharedPaneState, ) { + // On mobile, the octave is shared with the Piano Roll so they stay aligned. + if shared.is_mobile { + self.octave_offset = *shared.keyboard_octave; + } + // Check if there's an active MIDI layer let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id { shared.layer_to_track_map.contains_key(&active_layer_id) @@ -869,14 +894,28 @@ impl PaneRenderer for VirtualPianoPane { } // Calculate visible range (needed for both rendering and labels) - let (visible_start, visible_end, white_key_width, offset_x) = - self.calculate_visible_range(rect.width(), rect.height()); + let (visible_start, visible_end, white_key_width, offset_x) = if shared.is_mobile { + let layout = super::keyboard_layout::KeyboardLayout::from_width( + rect.min.x, rect.width(), self.octave_offset, *shared.keyboard_pan_x, + ); + let vs = layout.first_visible_white(); + (vs, layout.last_visible_note(), layout.white_key_width, layout.note_x(vs) - rect.min.x) + } else { + self.calculate_visible_range(rect.width(), rect.height()) + }; // Render the keyboard self.render_keyboard(ui, rect, shared); - // Render keyboard labels on top - self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x); + // Render keyboard labels on top — but not on mobile (no physical keyboard to hint at). + if !shared.is_mobile { + self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x); + } + + // Publish the octave so the portrait Piano Roll aligns to these keys. + if shared.is_mobile { + *shared.keyboard_octave = self.octave_offset; + } } fn name(&self) -> &str { diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index e0f0989..7ef5391 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -151,6 +151,7 @@ impl PreferencesDialog { ctx: &egui::Context, config: &mut AppConfig, theme: &mut Theme, + mobile: bool, ) -> Option { if !self.open { return None; @@ -160,68 +161,32 @@ impl PreferencesDialog { let mut should_cancel = false; let mut open = self.open; - egui::Window::new("Preferences") - .open(&mut open) - .resizable(false) - .collapsible(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .show(ctx, |ui| { - ui.set_width(550.0); + // On mobile, render as a screen-fitting modal sheet (dim backdrop, centered) like the other + // mobile modals; on desktop, the familiar draggable window. + let width = crate::mobile::dialog_width(ctx, 550.0); + let scroll_h = if mobile { + (ctx.content_rect().height() - 220.0).clamp(160.0, 400.0) + } else { + 400.0 + }; - // Error message - if let Some(error) = &self.error_message { - ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error); - ui.add_space(8.0); - } - - // Tab bar - ui.horizontal(|ui| { - ui.selectable_value(&mut self.tab, PreferencesTab::General, "General"); - ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts"); - }); - ui.separator(); - - // Tab content - match self.tab { - PreferencesTab::General => { - egui::ScrollArea::vertical() - .max_height(400.0) - .show(ui, |ui| { - self.render_general_section(ui); - ui.add_space(8.0); - self.render_audio_section(ui); - ui.add_space(8.0); - self.render_appearance_section(ui); - ui.add_space(8.0); - self.render_startup_section(ui); - ui.add_space(8.0); - self.render_advanced_section(ui); - }); - } - PreferencesTab::Shortcuts => { - self.render_shortcuts_tab(ui); - } - } - - ui.add_space(16.0); - - // Buttons - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - should_cancel = true; - } - - if ui.button("Reset to Defaults").clicked() { - self.reset_to_defaults(); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Save").clicked() { - should_save = true; - } - }); - }); + if mobile { + let resp = egui::Modal::new(egui::Id::new("preferences_modal")).show(ctx, |ui| { + self.render_body(ui, width, scroll_h, &mut should_save, &mut should_cancel); }); + if resp.backdrop_response.clicked() { + should_cancel = true; + } + } else { + egui::Window::new("Preferences") + .open(&mut open) + .resizable(false) + .collapsible(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + self.render_body(ui, width, scroll_h, &mut should_save, &mut should_cancel); + }); + } // Update open state self.open = open; @@ -238,6 +203,70 @@ impl PreferencesDialog { None } + #[allow(clippy::too_many_arguments)] + fn render_body( + &mut self, + ui: &mut egui::Ui, + width: f32, + scroll_h: f32, + should_save: &mut bool, + should_cancel: &mut bool, + ) { + ui.set_width(width); + + // Error message + if let Some(error) = &self.error_message { + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error); + ui.add_space(8.0); + } + + // Tab bar + ui.horizontal(|ui| { + ui.selectable_value(&mut self.tab, PreferencesTab::General, "General"); + ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts"); + }); + ui.separator(); + + // Tab content + match self.tab { + PreferencesTab::General => { + egui::ScrollArea::vertical().max_height(scroll_h).show(ui, |ui| { + self.render_general_section(ui); + ui.add_space(8.0); + self.render_audio_section(ui); + ui.add_space(8.0); + self.render_appearance_section(ui); + ui.add_space(8.0); + self.render_startup_section(ui); + ui.add_space(8.0); + self.render_advanced_section(ui); + }); + } + PreferencesTab::Shortcuts => { + self.render_shortcuts_tab(ui); + } + } + + ui.add_space(16.0); + + // Buttons + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + *should_cancel = true; + } + + if ui.button("Reset to Defaults").clicked() { + self.reset_to_defaults(); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Save").clicked() { + *should_save = true; + } + }); + }); + } + fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) { // Capture key events for rebinding BEFORE rendering the rest if let Some(rebind_action) = self.rebinding { diff --git a/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs index 42b7150..10b5aac 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs @@ -58,7 +58,8 @@ impl SampleImportDialog { .resizable(true) .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .default_width(700.0) + .default_width(crate::mobile::dialog_width(ctx, 700.0)) + .max_width(crate::mobile::dialog_width(ctx, 700.0)) .default_height(500.0) .show(ctx, |ui| { // Folder info diff --git a/lightningbeam-ui/lightningbeam-editor/src/theme.rs b/lightningbeam-ui/lightningbeam-editor/src/theme.rs index 1b6f847..6624b7d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/theme.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/theme.rs @@ -519,6 +519,73 @@ impl Theme { self.resolve(context, ctx).border_color.unwrap_or(fallback) } + /// Look up a CSS custom property (variable) as a color, respecting the active light/dark mode. + /// `name` is given without the leading `--` (e.g. `"bg-surface"`). Dark overrides light. + pub fn var(&self, name: &str, ctx: &egui::Context) -> Option { + let mut vars = self.light_variables.clone(); + if self.is_dark(ctx) { + vars.extend(self.dark_variables.clone()); + } + let raw = vars.get(name)?.clone(); + parse_color_value(&raw, &vars) + } + + /// Apply the theme's core palette variables to egui's global `Visuals`, so the standard egui + /// widgets (buttons, text fields, dialogs, pane chrome) share the same colors as the mobile UI. + /// Cheap enough to call every frame; respects the active light/dark mode. + pub fn apply_to_egui(&self, ctx: &egui::Context) { + let is_dark = self.is_dark(ctx); + let v = |name: &str, fb: egui::Color32| self.var(name, ctx).unwrap_or(fb); + let g = |n: u8| egui::Color32::from_gray(n); + + let text = v("text-primary", if is_dark { g(230) } else { g(20) }); + let bg_app = v("bg-app", if is_dark { g(42) } else { g(224) }); + let panel = v("bg-panel", bg_app); + let surface = v("bg-surface", panel); + let raised = v("bg-surface-raised", surface); + let sunken = v("bg-surface-sunken", bg_app); + let border = v("border-default", if is_dark { g(68) } else { g(153) }); + let accent = v("accent", egui::Color32::from_rgb(0x39, 0x6c, 0xd8)); + let on_accent = v("text-on-accent", egui::Color32::WHITE); + let stroke = |c: egui::Color32| egui::Stroke::new(1.0, c); + + let mut visuals = if is_dark { egui::Visuals::dark() } else { egui::Visuals::light() }; + visuals.panel_fill = panel; + visuals.window_fill = panel; + visuals.window_stroke = stroke(border); + visuals.extreme_bg_color = sunken; + visuals.faint_bg_color = surface; + visuals.override_text_color = Some(text); + visuals.hyperlink_color = accent; + + let w = &mut visuals.widgets; + w.noninteractive.bg_fill = panel; + w.noninteractive.weak_bg_fill = panel; + w.noninteractive.bg_stroke = stroke(border); + w.noninteractive.fg_stroke = stroke(text); + w.inactive.bg_fill = surface; + w.inactive.weak_bg_fill = surface; + w.inactive.bg_stroke = stroke(border); + w.inactive.fg_stroke = stroke(text); + w.hovered.bg_fill = raised; + w.hovered.weak_bg_fill = raised; + w.hovered.bg_stroke = stroke(border); + w.hovered.fg_stroke = stroke(text); + w.active.bg_fill = accent; + w.active.weak_bg_fill = accent; + w.active.bg_stroke = stroke(accent); + w.active.fg_stroke = stroke(on_accent); + w.open.bg_fill = surface; + w.open.weak_bg_fill = surface; + w.open.bg_stroke = stroke(border); + w.open.fg_stroke = stroke(text); + + visuals.selection.bg_fill = accent.linear_multiply(0.4); + visuals.selection.stroke = stroke(on_accent); + + ctx.set_visuals(visuals); + } + /// Convenience: resolve and extract a dimension with fallback #[allow(dead_code)] pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 { diff --git a/phone-ui-sketches.html b/phone-ui-sketches.html new file mode 100644 index 0000000..313a8fc --- /dev/null +++ b/phone-ui-sketches.html @@ -0,0 +1,842 @@ + + + + + +Phone UI — Wireframe Plates + + + + +
+

Mobile port · concept plates

+
+
+

Unified-timeline studio — phone layout

+

One hero surface at a time, surface tabs along the top, and time controls grouped at the bottom: a fixed transport bar with the resizable timeline ribbon riding above it. Every desktop control stays reachable; almost none stay visible.

+
+
+
CONSTRAINTPhone · single-pane
+
TABSTop · surfaces
+
SPINEBottom · time
+
+
+
+ +
+ Time / playhead / transport + Selection + Annotation +
+ + +
+
+ PLATE 01 +

The spine & the resizable ribbon

+ Tabs top · time bottom · 3 snaps +
+

Surface tabs sit along the top. At the bottom, the transport bar is fixed — the irreducible spine — and the timeline ribbon expands upward above it through three snaps, trading stage for tracks by degrees. It never hits zero on its own; the transport is always the floor.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
V1
+
00:12:04
+ 1 + 2 + 3 +
+
Peek — ribbon collapsed to a sliver; transport still anchors the bottom.
+
+ +
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
+
+
RASTER
+
VEC
+
AUD
+
AUD
+
+
00:12:04
+
+
Half — arrange & trim across tracks while the stage stays in view.
+
+ +
+
1
+

Tabs own the top

+

Stage / Time / Nodes / Mixer / Tree. The active surface is home; the rest are one tap away. Moving them up clears the bottom for the controls your thumb actually lives on.

+
+
2
+

Ribbon expands upward

+

Drag the grabber up for more tracks, down for more stage. A continuous reveal, not a modal toggle — there's always a sliver left.

+
+
3
+

Transport = the fixed floor

+

Play, playhead time and a zoomed-out project scrub never move and never disappear. It's the irreducible spine; the ribbon is detail layered on top of it.

+
+
+
+
+ + +
+
+ PLATE 02 +

Tweak mode — the pull model

+ Selection → inspector +
+

You arrive with a full project and a vague target, so controls come to what you point at. Tap anything and its properties rise in a sheet above the transport. The header carries jump-to chips that reframe to another surface with the same object still selected.

+ +
+
+
+
9:41⌕ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
+
+
+
+
+
+
Ellipse · Vector layer
+
PropertiesTimelineNodesAutomation
+
Fill
#9A6BC0
+
Opacity
+
Position
x 108
y 54
+
Z-order
Front ▾
+
+
00:12:04
+ 1 + 2 + 3 +
+
Half tier shown — drag up for every advanced parameter, down to a 3-control peek. Transport stays pinned below.
+
+ +
+
1
+

Selection-driven

+

Tap selects and summons the sheet. Once selected, dragging the object moves it; dragging empty space pans. One chain: tap → inspect → move → long-press.

+
+
2
+

Jump-to chips = parity move

+

Timeline · Nodes · Automation reframe to that surface with the object still held — replacing the desktop trick of seeing panes side-by-side.

+
+
3
+

Sheet floats over the floor

+

The inspector rises above the transport, never under it — so even mid-tweak you can scrub and see the playhead. Object actions like send to back live on the .

+
+
+
+

Two ways to find the thing

+

For "I don't know what I want to tweak": the command palette and an outliner surface (the Tree tab) — a browsable map of the whole project.

+
+
+
+
+ + +
+
+ PLATE 03 +

Sketch mode — the push model

+ New file · intent seeds +
+

An empty project with a clearish intent, so a tool defines the surface and gets out of the way. "New" offers intents — the same non-binding menu as desktop. Picking one sets the hero surface and a minimal toolset; switching intent mid-session just swaps the hero while a real project quietly accretes underneath.

+ +
+
+
+
9:41●●●
+
+
Start something
+
Sets your first surface — nothing is locked.
+
+
DrawFull canvas · stylus · radial brushes
+
AnimateStage + keyframe scrub strip
+
ComposeInstrument + arrangement ribbon
+
RecordBig button · waveform fills screen
+
Edit videoClip bin + trim timeline
+
BlankEmpty unified timeline
+
Open recent…
+
+
+ 1 +
+
Intent picker — mirrors the desktop new-file flow; the seed steers nothing once content exists.
+
+ +
+
1
+

Intent seeds, state steers

+

At creation there's nothing else to go on, so intent is the right call. The moment content exists, the original type stops driving anything.

+
+
2
+

Home-on-open ≠ birth type

+

Re-opening lands on the file's last-focused surface + last selection — not the type it was born as. A music file that grew an animation layer shouldn't open music-shaped.

+
+
3
+

Lossless underneath

+

Every intent writes to the same document model. Whatever you rough out on the train reconstitutes as a full pane layout back on desktop.

+
+
+
+
+ + +
+
+ PLATE 04 +

Music surface — the GarageBand fix

+ Expression + context, together +
+

GarageBand denies you the when while you play. Here the instrument lives in the thumb zone with a compressed arrangement ribbon pinned above it — loop boundaries, the playhead sweeping toward the wrap, and squashed lanes of what you're playing against. The transport anchors the bottom as everywhere else.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
DRUMS
+
BASS
+
KEYS ●
+
+
Grand Piano ▾Loop ⟳ 2 bars● REC
+
+ LIVE TAKE · KEYS +
+
+
+
+
+
+
+
+
+
+
+
2.1.03
120bpm
+ 1 + 2 + 3 +
+
Record-against-context — see the loop wrap and the other tracks while your thumbs are on the keys.
+
+ +
+
1
+

The loop boundary is visible

+

Green markers show where the 2-bar loop wraps; the amber playhead sweeps toward it — the exact when GarageBand's modal switch hides.

+
+
2
+

Playing against the mix

+

Squashed drum/bass lanes stay on screen so you hear and see what your take lands over. The live KEYS lane fills as you record.

+
+
3
+

Same spine, same floor

+

Transport with bars-beats + tempo anchors the bottom. Drag the ribbon down for full arrangement; the Mixer is a portrait-native surface one tab away.

+
+
+
+
+ + +
+
+ PLATE 05 +

Orientation is a per-surface reflow

+ Not an app-global mode +
+

No surface forces a rotation — that's the user's wrist, not the app's call. But the same timeline data emphasises a different axis per orientation: landscape spends pixels on time resolution, portrait spends them on track count. Rotation preserves selection, playhead and zoom-center; it only reflows.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
V1
+
V2
+
RAS
+
A1
+
A2
+
A3
+
A4
+
FX
+
+
00:12:04
+
+
Portrait — vertical room buys more tracks. Best for arranging, reordering, seeing structure.
+
+ +
+
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
V1
+
A1
+
A2
+
+
00:12:04
+
+
Landscape — wide axis buys time resolution. Best for scrubbing, precise trims.
+
+ +
+
+

Reflow, don't reset

+

Same selection, playhead and zoom-center — just re-emphasised. A rotate that throws away where you were is its own GarageBand-tier annoyance.

+
+
+

Per-surface character

+

Stage follows the project's aspect; instrument prefers landscape for key width; mixer is portrait-native. The ribbon absorbs whatever the hero leaves over.

+
+
🔒
+

Manual lock

+

People work lying down and propped at angles. The device suggests; the user can pin. Auto-rotate never fights a drawing gesture.

+
+
+
+
+ + +
+
+ PLATE 06 +

Omnibutton & the gesture map

+ Tools vs commands · 7 + 1 meanings +
+

The omnibutton produces tools/objects; the global menu holds commands/destinations — a clean tool-vs-command line. Below, the resolved stage gestures: seven meanings, no collisions, each reinforcing a desktop idiom rather than inventing a phone-only convention.

+ +
+
+
+
9:41⌕ ⋯ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+
+
+
+
+
Shape
+
+
+
+
V1
+
00:12:04
+ 1 + 2 +
+
Radial fans away from the finger to dodge occlusion. Global commands live in the top .
+
+ +
+
1
+

Omnibutton = tools/objects

+

Brushes, shapes, instruments, nodes — things you place or use. Long-press on empty space can summon this same create menu at the point you pressed.

+
+
2
+

Global menu = commands

+

Export, render, project settings, and the command palette itself live in the top /. If an item feels like either, it's usually a selection action in disguise → inspector.

+
+
+

Palette is the safety net

+

Every menu-bar verb has one contextual home plus the palette. That's what lets contextual homes hide rare commands without breaking parity.

+
+
+
+ +
+
+
tap object
select  + summon inspector
+
tap empty
deselect
+
drag selected
move the object / clip
+
drag empty
pan the view (matches timeline scroll)
+
two-finger drag
pan — always, everywhere (escape valve)
+
pinch
zoom — time-zoom on timeline, spatial on stage
+
long-press object
actions menu for that object
+
long-press empty
create-here radial (proposed)
+
double-tap object
enter / edit — go a level deeper
+
double-tap empty
zoom-to-fit
+
double-tap-drag empty
marquee select — fast transient path
+
+
+
+

Two tiers for marquee

+

The double-tap-drag gesture is the fast, transient path. A sustained marquee mode covers heavy vector multi-select — and with the gesture in hand, it may not even need a dedicated button.

+
+
+

Origin disambiguates

+

Marquee is strictly empty-space-initiated. Double-tap-drag starting on an object never marquees — same spatial-zones logic as the ruler vs lanes.

+
+
+

Crisp double-tap window

+

Empty space is where both zoom-to-fit and marquee branch off the same tap-tap, so tighten the detection window there specifically.

+
+
+
+
+ + +
+
+ PLATE 07 +

Node editor — focus & patch

+ Audio synthesis · port-to-port +
+

For synthesis the graph is patch-heavy: several cables of one type running between modules. So ports are identified by name — gate, velocity, pitch — not just type; type only decides what may connect, the name decides which is which. Connections are wire-level, parallel cables between a single pair are normal, and one output can fan out. Focus mode tunes a module's parameters; patch mode runs the cables.

+ +
+
+
+
9:41⌕ ⋯ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
+ + + + + + +
+
+
+
+
◂ gateMIDI→CV.gate
+
◂ velocityMIDI→CV.vel
+
+
+
ADSRENV · CV
+
+
Attack
+
Decay
120 ms
+
Sustain
+
+
+
env ▸VCA.in
+
+
+ Add node  ·  ⌕ Search
+
+
+
00:12:04
+ 1 + 2 +
+
Focus — one module's parameters; its named ports are travel chips. Input chips name the remote port, so two cables from the same node never collide.
+
+ +
+
+
9:41⌕ ⋯ ●●●
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
PatchMIDI→CV ▸ ADSRCV
+
pitch
→ Osc 1
+
velocity
velocity
+
gate
gate
+
tap a source port, then a target — both are CV, so the name decides the match
+
+
+
00:12:04
+ 3 +
+
Patch — two modules as port lists, each cable a row. The CV pair (gate, velocity) is just two rows; pitch fans off to the oscillator.
+
+ +
+
1
+

Ports are named, not just typed

+

Input chips read the remote endpoint as node.portMIDI→CV.gate, MIDI→CV.vel — so two cables from one node stay distinct even though both are CV. Type gates compatibility; the name carries identity.

+
+
2
+

Focus still travels & renders live

+

Tap a chip to jump to that endpoint; the minimap keeps you placed. The audio graph evaluates at the playhead, so transport stays — scrub and the envelope previews where you are.

+
+
3
+

Patch mode for cabling

+

A pair (or small cluster) shown as two port lists; each cable is a row. Parallel cables between the same pair are normal, and an output fans out (pitch → Osc). Tap a source port then a target — compatible ports light up, incompatible dim. This is the modular workflow, not one-node-at-a-time.

+
+
!
+

Honest scope

+

Phone is tune-a-module and patch-a-few; large graph restructuring stays a desktop job. Reachable for parity via the palette and these two views — without faking a tiny pannable canvas of boxes.

+
+
+
+
+ + +
+
+ PLATE 08 +

Keyboard cap & the reclaimed space

+ Portrait · keys ≤ ⅓ +
+

Key width governs playability, and width is set by the screen's short axis — so past about a third of a portrait screen, taller keys add nothing. The resize catches at that cap; pulling further reveals an instrument workspace above the keys instead. By default it's a note view of the current part, pitch-aligned to the keys; toggle it to performance controllers. Landscape has no room for this, so it's a portrait-only affordance.

+ +
+
+
+
9:41●●●
+
Stage
Time
Nodes
Mixer
Tree
+ +
+
+
DRUMS
+
BASS
+
+ +
+ Grand Piano ▾ +
NotesControllers
+ ● REC +
+ +
+ CURRENT PART · KEYS +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
2.1.03
120bpm
+ 1 + 2 + 3 +
+
Macro → micro → input: arrangement context up top, note workspace in the middle, capped keys in the thumb zone.
+
+ +
+
+
Stage
Time
Nodes
Mixer
Tree
+
+
+
DRUMS
+
+
Grand Piano ▾
KeysNotes
● REC
+
+ PIANO ROLL · time ▸ +
+
+
+
+
+
+
+
+
2.1.03
120bpm
+ 4 +
+
Landscape · Notes — standalone, the editor flips to a conventional piano roll (pitch ↕, time ▸) to spend the wide axis on time. The context ribbon still rides on top.
+
+ +
+
1
+

The cap, and what fills it

+

Keys grow to ~⅓ then stop; continued upward drag reveals the workspace. The keyboard holds its ergonomic height — the new room opens above it. Same continuous-reveal as the timeline ribbon.

+
+
2
+

Default: see what you play

+

A note view of the current part, each column sitting over its key below (a waterfall toward the amber now line). The see-what-you're-doing principle the GarageBand port breaks — and editable in place.

+
+
3
+

Or perform: Controllers

+

Toggle the zone to pitch/mod strips, an XY pad, an arpeggiator — the other natural use of space above keys. The same drag, a different occupant.

+
+
4
+

Landscape splits them — and reflows

+

No room to stack, so keys and notes become a Keys / Notes toggle, with the context ribbon persisting in both — you lose seeing keys-and-notes together, never the when. And the note view itself reflows: Synthesia-style (pitch ↔, aligned to keys) when it sits above the keyboard in portrait; a conventional piano roll (pitch ↕, time ▸) when it's standalone in landscape and the wide axis is better spent on time. Rotate back and your part, playhead and selection carry across.

+
+
+
+
+ +
+ UNIFIED-TIMELINE STUDIO · PHONE CONCEPT PLATES 01–08 + WIREFRAME — LAYOUT & GESTURE INTENT, NOT FINAL VISUAL +
+ + +