Compare commits
44 Commits
609b80336b
...
0eff5f7f33
| Author | SHA1 | Date |
|---|---|---|
|
|
0eff5f7f33 | |
|
|
b6f43d2e72 | |
|
|
8fbb6d65c0 | |
|
|
6aece26a8b | |
|
|
ef2b0822bd | |
|
|
5f09222f3f | |
|
|
b0e965b1c2 | |
|
|
6596acb3db | |
|
|
d6b86a14b1 | |
|
|
a2839f80b1 | |
|
|
6e6feaddf5 | |
|
|
15bdf80ec1 | |
|
|
6b8a1f1386 | |
|
|
53ffb7d528 | |
|
|
c373af461e | |
|
|
6e361aa30c | |
|
|
bb6b6fa9e3 | |
|
|
9646eef487 | |
|
|
6f67ddb709 | |
|
|
2cbc35f181 | |
|
|
4f66ddb515 | |
|
|
b14972f657 | |
|
|
77eb2c2d33 | |
|
|
4f58edf436 | |
|
|
01b20165cc | |
|
|
4b2802076b | |
|
|
943ff7c15f | |
|
|
f13a127c9d | |
|
|
a0ac4f2d35 | |
|
|
b8419778a5 | |
|
|
08b6aa0139 | |
|
|
87263489c0 | |
|
|
f2cb516e8a | |
|
|
b25e639a1c | |
|
|
01fe14f197 | |
|
|
3b39e80e40 | |
|
|
0e3c437d50 | |
|
|
76e7963f03 | |
|
|
597697d0a6 | |
|
|
0c59ed8b4b | |
|
|
79f063d00c | |
|
|
5ee2df5db7 | |
|
|
da0b39fef0 | |
|
|
fd582828c2 |
20
Changelog.md
20
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)
|
||||
|
|
|
|||
|
|
@ -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<P: AsRef<Path>>(
|
|||
// 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<P: AsRef<Path>>(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Write FLAC file using hound (FLAC is essentially lossless WAV)
|
||||
fn write_flac<P: AsRef<Path>>(
|
||||
/// 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<P: AsRef<Path>>(
|
||||
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());
|
||||
}
|
||||
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))?;
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)),
|
||||
}
|
||||
|
||||
writer.finalize()
|
||||
.map_err(|e| format!("Failed to finalize FLAC file: {}", 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;
|
||||
}
|
||||
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<u8> = 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
|
||||
}
|
||||
|
||||
let mut list: Vec<u8> = 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<P: AsRef<Path>>(
|
|||
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<P: AsRef<Path>>(
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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).
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
instance_id: Option<Uuid>,
|
||||
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<Uuid> {
|
||||
self.clip_id
|
||||
}
|
||||
|
||||
/// The clip instance id placed in the parent layer (after execute).
|
||||
pub fn instance_id(&self) -> Option<Uuid> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
/// `true` = bring to front (top), `false` = send to back (bottom).
|
||||
to_front: bool,
|
||||
/// Full instance order captured on `execute`, for `rollback`.
|
||||
old_order: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
impl ReorderClipInstancesAction {
|
||||
pub fn new(layer_id: Uuid, instance_ids: Vec<Uuid>, 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<ClipInstance>> {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TextContent>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Uuid, Uuid>) -> 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
/// Output height in pixels (None = use document height).
|
||||
pub height: Option<u32>,
|
||||
/// 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 };
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<LoadedProject, String> {
|
|||
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))?;
|
||||
|
|
|
|||
|
|
@ -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<NoBrush>,
|
||||
/// Registered family names of the three bundled fonts (sans, serif, mono).
|
||||
bundled: Vec<String>,
|
||||
/// 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<dyn AsRef<[u8]> + 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<FontStore> = 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<String> {
|
||||
STORE.with(|s| {
|
||||
let s = &mut *s.borrow_mut();
|
||||
|
||||
let mut system: Vec<String> =
|
||||
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<u8>) -> Vec<String> {
|
||||
STORE.with(|s| {
|
||||
let s = &mut *s.borrow_mut();
|
||||
let blob = Blob::new(Arc::new(bytes) as Arc<dyn AsRef<[u8]> + 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<Vec<u8>> {
|
||||
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<String, Vec<u8>>,
|
||||
started: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
fn preload() -> &'static Mutex<PreloadState> {
|
||||
static P: OnceLock<Mutex<PreloadState>> = 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<u8>` (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<Vec<u8>> {
|
||||
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<R>(
|
||||
content: &TextContent,
|
||||
max_width: f32,
|
||||
f: impl FnOnce(&Layout<NoBrush>) -> 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<image>`). 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 `<image>`). 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("</g>");
|
||||
}
|
||||
}
|
||||
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 `<path>` 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<skrifa::instance::NormalizedCoord> = 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#"<path fill="rgb({},{},{})" fill-opacity="{:.4}" fill-rule="nonzero" d="{}"/>"#,
|
||||
to_u8(r), to_u8(g), to_u8(b), fill_opacity, d
|
||||
));
|
||||
}
|
||||
|
||||
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) 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 <path> elements.
|
||||
assert_eq!(body.matches("<path").count(), 4, "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_pen_maps_yflip_and_skew() {
|
||||
use skrifa::outline::OutlinePen;
|
||||
let mut d = String::new();
|
||||
{
|
||||
let mut pen = SvgOutlinePen { gx: 10.0, gy: 100.0, skew: 0.0, d: &mut d };
|
||||
pen.move_to(0.0, 0.0); // baseline origin → (10, 100)
|
||||
pen.line_to(5.0, 20.0); // 20 up → y = 100 − 20 = 80
|
||||
pen.close();
|
||||
}
|
||||
assert!(d.contains("M10.00 100.00"), "d={d}");
|
||||
assert!(d.contains("L15.00 80.00"), "d={d}");
|
||||
assert!(d.ends_with('Z'));
|
||||
|
||||
// Synthetic-italic skew shifts x right in proportion to height.
|
||||
let mut d2 = String::new();
|
||||
{
|
||||
let mut pen = SvgOutlinePen { gx: 0.0, gy: 0.0, skew: 0.5, d: &mut d2 };
|
||||
pen.move_to(0.0, 10.0); // x = 0 + 0.5·10 = 5, y = −10
|
||||
}
|
||||
assert!(d2.contains("M5.00 -10.00"), "d={d2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_layer_emits_real_glyph_outlines() {
|
||||
use crate::text_layer::TextLayer;
|
||||
|
||||
let mut tl = TextLayer::new("t", Point::new(20.0, 60.0));
|
||||
tl.content.text = "Hi".to_string();
|
||||
tl.content.font_size = 48.0;
|
||||
tl.content.color = [1.0, 0.0, 0.0, 1.0];
|
||||
|
||||
let mut body = String::new();
|
||||
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
|
||||
|
||||
// Bundled fonts guarantee glyphs → a filled path with actual outline segments.
|
||||
assert!(body.contains("<path"), "no path emitted: {body}");
|
||||
assert!(body.contains(r#"fill="rgb(255,0,0)""#), "wrong fill: {body}");
|
||||
assert!(
|
||||
body.contains('C') || body.contains('Q') || body.contains('L'),
|
||||
"path has no outline segments: {body}"
|
||||
);
|
||||
assert!(body.len() > 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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TextKeyframe>` 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<H: Hasher>(&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<String>, 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; }
|
||||
}
|
||||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<VertexId> = (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
|
||||
// -------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<P: AsRef<Path>>(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
midi_pool: &MidiClipPool,
|
||||
settings: &AudioExportSettings,
|
||||
output_path: P,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> 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<P: AsRef<Path>>(
|
||||
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<P: AsRef<Path>>(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
_midi_pool: &MidiClipPool,
|
||||
settings: &AudioExportSettings,
|
||||
output_path: P,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> 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::<i16> 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::<i16>(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<Vec<i16>> {
|
||||
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<Vec<f32>> {
|
||||
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<P: AsRef<Path>>(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
_midi_pool: &MidiClipPool,
|
||||
settings: &AudioExportSettings,
|
||||
output_path: P,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> 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::<f32>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self, String> {
|
||||
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<u8>, Vec<u8>, Vec<u8>), 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() {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8> },
|
||||
/// 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<GifFrameMessage>,
|
||||
output_path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
total_frames: usize,
|
||||
delay_ms: u32,
|
||||
loop_forever: bool,
|
||||
transparency: bool,
|
||||
progress_tx: Sender<ExportProgress>,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
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<Sender<(usize, Vec<u8>)>> = 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<u8>)>();
|
||||
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<usize, gif::Frame<'static>> = 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<String> = 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
// `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<u8> = if allow_transparency {
|
||||
pixels.to_vec()
|
||||
} else {
|
||||
let flat = flatten_alpha(img);
|
||||
flat.save(path).map_err(|e| format!("WebP save failed: {e}"))
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<readback_pipeline::ReadbackPipeline>,
|
||||
/// CPU YUV converter for RGBA→YUV420p conversion
|
||||
cpu_yuv_converter: Option<cpu_yuv_converter::CpuYuvConverter>,
|
||||
/// 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<cpu_yuv_converter::CpuYuv422P10Converter>,
|
||||
/// 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<ImageExportState>,
|
||||
|
||||
/// Animated GIF export state (frames rendered on the UI thread, encoded on `thread_handle`).
|
||||
gif_state: Option<GifExportState>,
|
||||
}
|
||||
|
||||
/// 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<video_exporter::ExportGpuResources>,
|
||||
/// Output RGBA texture (kept separate from gpu_resources to avoid split-borrow issues).
|
||||
output_texture: Option<wgpu::Texture>,
|
||||
output_texture_view: Option<wgpu::TextureView>,
|
||||
/// Staging buffer for synchronous GPU→CPU readback (reused each frame).
|
||||
staging_buffer: Option<wgpu::Buffer>,
|
||||
/// Sender to the encoder thread; dropped after the final frame to signal completion.
|
||||
frame_tx: Option<Sender<GifFrameMessage>>,
|
||||
}
|
||||
|
||||
/// 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<std::sync::Mutex<VideoManager>>,
|
||||
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
|
||||
) -> Result<bool, String> {
|
||||
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<u8> = {
|
||||
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));
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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}";
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<std::path::PathBuf> = 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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<StackDrag>,
|
||||
/// In-flight layout ease (transient).
|
||||
pub anim: Option<LayoutAnim>,
|
||||
}
|
||||
|
||||
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<crate::menu::MenuAction> = 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LayerType> {
|
||||
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> = Tool::for_layer_type(active_layer_type(rc)).to_vec();
|
||||
let primary: Vec<Tool> = 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<Cell> = 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<Cell> = 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<Special> = 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<usize>) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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)),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<f32>();
|
||||
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<f32> {
|
||||
let mut w: Vec<f32> = 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<f32> {
|
||||
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<f32> {
|
||||
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<usize>)> = 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,12 +44,23 @@ pub struct InfopanelPane {
|
|||
selected_tool_gradient_stop: Option<usize>,
|
||||
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||||
fps_drag_start: Option<f64>,
|
||||
/// 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<f64>,
|
||||
layer_volume_drag_start: Option<f64>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<u8>) {
|
||||
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,51 +1183,152 @@ 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",
|
||||
};
|
||||
(
|
||||
type_name,
|
||||
matches!(layer, AnyLayer::Audio(_)),
|
||||
layer.name().to_string(),
|
||||
layer.opacity(),
|
||||
layer.volume(),
|
||||
layer.muted(),
|
||||
layer.soloed(),
|
||||
layer.locked(),
|
||||
)
|
||||
})
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 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),
|
||||
)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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:");
|
||||
ui.label(format!("{:.0}%", layer.opacity() * 100.0));
|
||||
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 matches!(layer, AnyLayer::Audio(_)) {
|
||||
if is_audio {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Volume:");
|
||||
ui.label(format!("{:.0}%", layer.volume() * 100.0));
|
||||
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),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if layer.muted() {
|
||||
ui.label("Muted");
|
||||
// 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 layer.locked() {
|
||||
ui.label("Locked");
|
||||
if ui.selectable_label(soloed, "Solo").clicked() {
|
||||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||
layer_id,
|
||||
LayerProperty::Soloed(!soloed),
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.label(format!("{} layers selected", layer_ids.len()));
|
||||
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<egui::FontFamily> =
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<uuid::Uuid>,
|
||||
/// The parent layer ID containing the clip instance being edited
|
||||
pub editing_parent_layer_id: Option<uuid::Uuid>,
|
||||
/// The full clip_id path being edited, outermost → current (for breadcrumbs). Empty at root.
|
||||
pub editing_clip_path: Vec<uuid::Uuid>,
|
||||
/// 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<usize>,
|
||||
/// Currently active layer ID
|
||||
pub active_layer_id: &'a mut Option<uuid::Uuid>,
|
||||
/// 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<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
|
||||
/// 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<MobileContextMenu>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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<NodeId>,
|
||||
/// 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<PatchPick>,
|
||||
}
|
||||
|
||||
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<NodeId> = 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<NodeId> {
|
||||
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<NodeTemplate> = 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<Op> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u32>,
|
||||
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,20 @@ pub struct PianoRollPane {
|
|||
snap_value: SnapValue,
|
||||
last_snap_selection: HashSet<usize>,
|
||||
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<f64>,
|
||||
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<usize>,
|
||||
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));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<egui::Pos2>,
|
||||
/// Manual long-press tracking for the mobile context menu: press start time + position.
|
||||
lp_time: Option<f64>,
|
||||
lp_pos: egui::Pos2,
|
||||
|
||||
/// Clip drag state (None if not dragging)
|
||||
clip_drag_state: Option<ClipDragType>,
|
||||
|
|
@ -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<daw_backend::TrackId, f32>,
|
||||
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<egui::Pos2> = 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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
/// Externally-driven highlights (e.g. notes sounding during playback), colored like pressed keys.
|
||||
pub playback_notes: HashSet<u8>,
|
||||
}
|
||||
|
||||
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<u8, f32> = 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,16 +894,30 @@ 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
|
||||
// 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 {
|
||||
"Virtual Piano"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ impl PreferencesDialog {
|
|||
ctx: &egui::Context,
|
||||
config: &mut AppConfig,
|
||||
theme: &mut Theme,
|
||||
mobile: bool,
|
||||
) -> Option<PreferencesSaveResult> {
|
||||
if !self.open {
|
||||
return None;
|
||||
|
|
@ -160,13 +161,58 @@ impl PreferencesDialog {
|
|||
let mut should_cancel = false;
|
||||
let mut open = self.open;
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
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| {
|
||||
ui.set_width(550.0);
|
||||
self.render_body(ui, width, scroll_h, &mut should_save, &mut should_cancel);
|
||||
});
|
||||
}
|
||||
|
||||
// Update open state
|
||||
self.open = open;
|
||||
|
||||
if should_cancel {
|
||||
self.close();
|
||||
return None;
|
||||
}
|
||||
|
||||
if should_save {
|
||||
return self.handle_save(config, theme);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -184,9 +230,7 @@ impl PreferencesDialog {
|
|||
// Tab content
|
||||
match self.tab {
|
||||
PreferencesTab::General => {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(400.0)
|
||||
.show(ui, |ui| {
|
||||
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);
|
||||
|
|
@ -208,7 +252,7 @@ impl PreferencesDialog {
|
|||
// Buttons
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
should_cancel = true;
|
||||
*should_cancel = true;
|
||||
}
|
||||
|
||||
if ui.button("Reset to Defaults").clicked() {
|
||||
|
|
@ -217,25 +261,10 @@ impl PreferencesDialog {
|
|||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
should_save = true;
|
||||
*should_save = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update open state
|
||||
self.open = open;
|
||||
|
||||
if should_cancel {
|
||||
self.close();
|
||||
return None;
|
||||
}
|
||||
|
||||
if should_save {
|
||||
return self.handle_save(config, theme);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<egui::Color32> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,842 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Phone UI — Wireframe Plates</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0E1014;
|
||||
--ink:#D7DDE4;
|
||||
--ink-soft:#8B95A1;
|
||||
--rule:#23282F;
|
||||
--frame:#3A414D;
|
||||
--device:#16191F;
|
||||
--panel:#1F242C;
|
||||
--panel-2:#272D37;
|
||||
--line:#363D49;
|
||||
--dim:#7C8693;
|
||||
--bright:#EAEEF3;
|
||||
--amber:#F4A340; /* time / playhead / transport — the spine */
|
||||
--cyan:#54C3E8; /* selection */
|
||||
--coral:#E8826B; /* annotation */
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html{-webkit-text-size-adjust:100%;}
|
||||
body{
|
||||
margin:0; font-family:var(--sans); color:var(--ink);
|
||||
background:
|
||||
radial-gradient(circle, rgba(255,255,255,.022) 1px, transparent 1.4px) 0 0/26px 26px,
|
||||
var(--bg);
|
||||
line-height:1.5;
|
||||
}
|
||||
|
||||
/* ---- title block ---- */
|
||||
.sheet-head{ max-width:1120px; margin:0 auto; padding:48px 24px 8px;}
|
||||
.titleblock{ border:1.5px solid var(--frame); display:grid; grid-template-columns:1fr auto; background:rgba(255,255,255,.012);}
|
||||
.titleblock .tl-main{ padding:20px 22px;}
|
||||
.titleblock .tl-meta{ border-left:1.5px solid var(--frame); display:grid; grid-template-rows:repeat(3,1fr); font-family:var(--mono); font-size:11px; min-width:200px;}
|
||||
.tl-meta div{ padding:8px 14px; display:flex; flex-direction:column; justify-content:center;}
|
||||
.tl-meta div + div{ border-top:1px solid var(--rule);}
|
||||
.tl-meta span{ color:var(--ink-soft); letter-spacing:.04em;}
|
||||
.tl-meta strong{ font-weight:600; font-size:12px; color:var(--bright);}
|
||||
.eyebrow{ font-family:var(--mono); font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--coral); margin:0 0 8px;}
|
||||
.titleblock h1{ margin:0; font-size:clamp(22px,3.6vw,34px); letter-spacing:-0.01em; line-height:1.05; color:var(--bright);}
|
||||
.titleblock p{ margin:10px 0 0; max-width:60ch; color:var(--ink-soft); font-size:14px;}
|
||||
|
||||
.legend-key{ max-width:1120px; margin:14px auto 0; padding:0 24px; display:flex; flex-wrap:wrap; gap:18px; font-family:var(--mono); font-size:11px; color:var(--ink-soft);}
|
||||
.legend-key b{ display:inline-flex; align-items:center; gap:6px; font-weight:500; color:var(--ink);}
|
||||
.swatch{ width:11px; height:11px; border-radius:2px; display:inline-block;}
|
||||
|
||||
/* ---- plates ---- */
|
||||
.plate{ max-width:1120px; margin:0 auto; padding:40px 24px; border-top:1px solid var(--rule);}
|
||||
.plate:first-of-type{ border-top:0;}
|
||||
.plate-head{ display:flex; align-items:baseline; gap:16px; margin-bottom:8px; flex-wrap:wrap;}
|
||||
.plate-num{ font-family:var(--mono); font-size:13px; font-weight:600; color:var(--coral); letter-spacing:.04em;}
|
||||
.plate-head h2{ margin:0; font-size:clamp(18px,2.6vw,24px); letter-spacing:-0.01em; color:var(--bright);}
|
||||
.plate-head .tag{ font-family:var(--mono); font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--ink-soft); border:1px solid var(--line); padding:3px 8px; border-radius:3px;}
|
||||
.plate > p.lede{ margin:0 0 26px; max-width:74ch; color:var(--ink-soft); font-size:14px;}
|
||||
|
||||
.stage-row{ display:flex; gap:34px; align-items:flex-start; flex-wrap:wrap;}
|
||||
.figcol{ display:flex; flex-direction:column; gap:10px; align-items:center;}
|
||||
.figcap{ font-family:var(--mono); font-size:11px; color:var(--ink-soft); text-align:center; max-width:300px; letter-spacing:.02em;}
|
||||
.figcap b{ color:var(--bright); font-weight:600;}
|
||||
|
||||
/* ---- annotation callouts ---- */
|
||||
.notes{ flex:1; min-width:240px; display:flex; flex-direction:column; gap:16px; padding-top:6px;}
|
||||
.note{ display:grid; grid-template-columns:24px 1fr; gap:12px;}
|
||||
.note .dot{ width:22px; height:22px; border-radius:50%; border:1.5px solid var(--frame); font-family:var(--mono); font-size:11px; font-weight:600; display:flex; align-items:center; justify-content:center; background:var(--panel); color:var(--ink);}
|
||||
.note h4{ margin:0 0 3px; font-size:14px; letter-spacing:-0.01em; color:var(--bright);}
|
||||
.note p{ margin:0; font-size:13px; color:var(--ink-soft);}
|
||||
.note code{ font-family:var(--mono); font-size:12px; background:var(--panel); border:1px solid var(--line); padding:1px 5px; border-radius:3px; color:var(--bright);}
|
||||
|
||||
/* ================= PHONE FRAME ================= */
|
||||
.phone{ width:266px; flex:0 0 auto; background:#04060A; border-radius:34px; padding:7px; box-shadow:0 0 0 1px rgba(255,255,255,.04), 0 22px 46px -22px rgba(0,0,0,.85); position:relative;}
|
||||
.phone.lg{ width:300px;}
|
||||
.phone .screen{ position:relative; border-radius:27px; overflow:hidden; background:var(--device); height:540px; display:flex; flex-direction:column; color:var(--bright); font-family:var(--mono);}
|
||||
.phone.lg .screen{ height:610px;}
|
||||
.phone.land{ width:520px;} .phone.land .screen{ height:266px;}
|
||||
|
||||
.hs{ position:absolute; width:20px; height:20px; border-radius:50%; background:var(--coral); color:#1a0f0a; font-family:var(--mono); font-size:11px; font-weight:700; display:flex; align-items:center; justify-content:center; z-index:9; box-shadow:0 0 0 3px rgba(232,130,107,.22);}
|
||||
|
||||
.statusbar{ height:20px; display:flex; align-items:center; justify-content:space-between; padding:0 14px; font-size:9px; color:var(--dim); flex:0 0 auto;}
|
||||
|
||||
/* surface tabs — now at TOP */
|
||||
.tabs{ flex:0 0 auto; height:30px; background:var(--device); border-bottom:1px solid var(--line); display:flex;}
|
||||
.tabs .tab{ flex:1; display:flex; align-items:center; justify-content:center; gap:5px; font-size:9px; color:var(--dim); border-right:1px solid #20242c; letter-spacing:.02em;}
|
||||
.tabs .tab:last-child{ border-right:0;}
|
||||
.tabs .tab .g{ width:10px; height:10px; border-radius:2.5px; border:1.4px solid var(--dim);}
|
||||
.tabs .tab.on{ color:var(--amber); box-shadow:inset 0 -2px 0 var(--amber);}
|
||||
.tabs .tab.on .g{ border-color:var(--amber); background:rgba(244,163,64,.16);}
|
||||
|
||||
/* transport — now at BOTTOM, with the timeline */
|
||||
.transport{ flex:0 0 auto; height:34px; background:var(--panel); border-top:1px solid var(--line); display:flex; align-items:center; gap:8px; padding:0 10px;}
|
||||
.transport .play{ width:20px; height:20px; border-radius:50%; background:var(--amber); display:flex; align-items:center; justify-content:center; flex:0 0 auto;}
|
||||
.transport .play::after{ content:""; border-left:7px solid #1b130a; border-top:5px solid transparent; border-bottom:5px solid transparent; margin-left:2px;}
|
||||
.transport .tc{ font-size:11px; color:var(--bright); letter-spacing:.02em;}
|
||||
.transport .scrub{ flex:1; height:4px; border-radius:2px; background:var(--line); position:relative;}
|
||||
.transport .scrub::before{ content:""; position:absolute; left:0; top:0; bottom:0; width:38%; background:var(--amber); border-radius:2px; opacity:.45;}
|
||||
.transport .scrub::after{ content:""; position:absolute; left:38%; top:-3px; width:2px; height:10px; background:var(--amber);}
|
||||
.transport .mini{ font-size:9px; color:var(--dim);}
|
||||
|
||||
.hero{ flex:1; position:relative; overflow:hidden; display:flex; align-items:center; justify-content:center; background:radial-gradient(120% 120% at 50% 0%, #1b1f27 0%, #14161B 70%);}
|
||||
.hero .canvas-frame{ position:absolute; border:1px dashed var(--line);}
|
||||
.obj{ position:absolute; border-radius:6px;}
|
||||
.obj.sel{ box-shadow:0 0 0 1.5px var(--cyan), 0 0 0 4px rgba(84,195,232,.18);}
|
||||
.handle{ position:absolute; width:7px; height:7px; background:var(--cyan); border:1px solid #0c2a33; border-radius:2px; z-index:3;}
|
||||
|
||||
/* timeline ribbon — sits above the transport */
|
||||
.ribbon{ flex:0 0 auto; background:var(--panel); display:flex; flex-direction:column; overflow:hidden; position:relative;}
|
||||
.ribbon .grab{ height:14px; display:flex; align-items:center; justify-content:center; flex:0 0 auto;}
|
||||
.ribbon .grab::before{ content:""; width:34px; height:4px; border-radius:2px; background:var(--line);}
|
||||
.ribbon .ruler{ height:14px; border-bottom:1px solid var(--line); border-top:1px solid var(--line); position:relative; background:repeating-linear-gradient(90deg,transparent 0 27px,var(--line) 27px 28px); flex:0 0 auto;}
|
||||
.ribbon .ruler::after{ content:""; position:absolute; left:38%; top:0; height:600px; width:1.5px; background:var(--amber); z-index:4;}
|
||||
.lane{ height:22px; display:flex; align-items:center; border-bottom:1px solid #20242c; padding-left:42px; position:relative; flex:0 0 auto;}
|
||||
.lane .gut{ position:absolute; left:0; top:0; bottom:0; width:42px; border-right:1px solid var(--line); display:flex; align-items:center; padding-left:7px; font-size:8.5px; color:var(--dim); background:var(--panel-2);}
|
||||
.clip{ position:absolute; height:13px; border-radius:3px; top:50%; transform:translateY(-50%);}
|
||||
.clip.a{ background:linear-gradient(180deg,#3a6ea8,#2c5482);}
|
||||
.clip.v{ background:linear-gradient(180deg,#8a5fb0,#6a4690);}
|
||||
.clip.raster{ background:linear-gradient(180deg,#3f9d8f,#2c7568);}
|
||||
.clip.sel{ box-shadow:0 0 0 1.5px var(--cyan);}
|
||||
.clip .wav{ position:absolute; inset:2px 3px; opacity:.5; background:repeating-linear-gradient(90deg,#cfe3ff 0 1px, transparent 1px 3px);}
|
||||
|
||||
/* bottom sheet (inspector) — rises above the transport */
|
||||
.sheet-ui{ position:absolute; left:0; right:0; bottom:34px; background:var(--panel-2); border-top:1px solid var(--line); border-radius:14px 14px 0 0; z-index:6; box-shadow:0 -10px 30px -12px rgba(0,0,0,.7);}
|
||||
.sheet-ui .grab{ height:16px; display:flex; align-items:center; justify-content:center;}
|
||||
.sheet-ui .grab::before{ content:""; width:34px; height:4px; border-radius:2px; background:var(--line);}
|
||||
.sheet-ui .sh-head{ display:flex; align-items:center; gap:8px; padding:2px 12px 8px;}
|
||||
.sheet-ui .sh-head .ico{ width:18px; height:18px; border-radius:4px; background:var(--cyan);}
|
||||
.sheet-ui .sh-head .ttl{ font-size:11px; color:var(--bright);}
|
||||
.sheet-ui .sh-head .ovf{ margin-left:auto; color:var(--dim); font-size:14px; letter-spacing:1px;}
|
||||
.chips{ display:flex; gap:6px; padding:0 12px 10px; flex-wrap:wrap;}
|
||||
.chip{ font-size:9.5px; padding:4px 8px; border-radius:11px; border:1px solid var(--line); color:var(--dim);}
|
||||
.chip.on{ background:var(--cyan); color:#06222b; border-color:var(--cyan);}
|
||||
.prop{ display:flex; align-items:center; gap:8px; padding:7px 12px; border-top:1px solid #20242c;}
|
||||
.prop .k{ font-size:10px; color:var(--dim); width:62px;}
|
||||
.prop .field{ flex:1; height:18px; border-radius:4px; background:var(--panel); border:1px solid var(--line); display:flex; align-items:center; padding:0 7px; font-size:10px; color:var(--bright);}
|
||||
.prop .field.slider{ position:relative; background:var(--line);}
|
||||
.prop .field.slider::after{ content:""; position:absolute; left:0;top:0;bottom:0;width:55%; background:var(--cyan); opacity:.55; border-radius:4px 0 0 4px;}
|
||||
|
||||
.omni{ position:absolute; right:14px; bottom:120px; width:46px; height:46px; border-radius:50%; background:var(--amber); display:flex; align-items:center; justify-content:center; z-index:7; box-shadow:0 6px 16px -4px rgba(244,163,64,.55);}
|
||||
.omni::before,.omni::after{ content:""; position:absolute; background:#1b130a;}
|
||||
.omni::before{ width:16px; height:2.5px; border-radius:2px;}
|
||||
.omni::after{ width:2.5px; height:16px; border-radius:2px;}
|
||||
|
||||
.radial-wrap{ position:absolute; inset:0; z-index:8;}
|
||||
.radial-wrap .scrim{ position:absolute; inset:0; background:rgba(8,10,14,.55);}
|
||||
.rad-btn{ position:absolute; width:42px; height:42px; border-radius:50%; background:var(--panel-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size:9px; color:var(--bright); text-align:center; line-height:1.1;}
|
||||
.rad-btn .g{ width:16px;height:16px;border-radius:4px;}
|
||||
|
||||
/* intent grid */
|
||||
.intent-screen{ flex:1; display:flex; flex-direction:column; padding:18px 16px; gap:14px; background:var(--device);}
|
||||
.intent-screen .new-h{ font-size:13px; color:var(--bright); letter-spacing:.02em;}
|
||||
.intent-screen .new-sub{ font-size:10px; color:var(--dim); margin-top:-8px;}
|
||||
.intent-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:4px;}
|
||||
.intent{ aspect-ratio:1/.92; border:1px solid var(--line); border-radius:12px; background:var(--panel); display:flex; flex-direction:column; justify-content:flex-end; padding:10px; gap:4px; position:relative; overflow:hidden;}
|
||||
.intent .gico{ position:absolute; top:10px; left:10px; width:22px; height:22px; border-radius:6px;}
|
||||
.intent .nm{ font-size:11px; color:var(--bright);}
|
||||
.intent .de{ font-size:8.5px; color:var(--dim); line-height:1.25;}
|
||||
.intent.draw .gico{ background:var(--coral);}
|
||||
.intent.animate .gico{ background:var(--cyan);}
|
||||
.intent.compose .gico{ background:var(--amber);}
|
||||
.intent.record .gico{ background:#c75b8a;}
|
||||
.intent.video .gico{ background:#6a4690;}
|
||||
.intent.open{ grid-column:1/3; aspect-ratio:auto; flex-direction:row; align-items:center; gap:10px; padding:12px;}
|
||||
.intent.open .gico{ position:static; background:var(--line);}
|
||||
|
||||
.keys{ flex:1; background:#0c0e12; display:flex; position:relative; padding:6px 6px 8px;}
|
||||
.wkey{ flex:1; background:linear-gradient(180deg,#f3f4f6,#d9dde2); border-radius:0 0 4px 4px; margin:0 1px; box-shadow:inset 0 -6px 8px -6px rgba(0,0,0,.3);}
|
||||
.bkey{ position:absolute; top:6px; width:5.4%; height:54%; background:#1b1e24; border-radius:0 0 3px 3px; z-index:2; box-shadow:0 2px 3px rgba(0,0,0,.5);}
|
||||
.inst-bar{ flex:0 0 auto; height:26px; background:var(--panel); border-top:1px solid var(--line); border-bottom:1px solid var(--line); display:flex; align-items:center; gap:8px; padding:0 10px; font-size:9.5px; color:var(--dim);}
|
||||
.inst-bar .pill{ padding:3px 7px; border:1px solid var(--line); border-radius:10px; color:var(--bright);}
|
||||
|
||||
.gmap{ display:flex; gap:30px; flex-wrap:wrap; align-items:flex-start;}
|
||||
.gtable{ flex:1; min-width:280px; border:1px solid var(--frame); border-radius:8px; overflow:hidden; background:var(--device);}
|
||||
.grow{ display:grid; grid-template-columns:150px 1fr; border-top:1px solid var(--rule);}
|
||||
.grow:first-child{ border-top:0;}
|
||||
.grow .gg{ font-family:var(--mono); font-size:12px; padding:9px 12px; background:var(--panel); color:var(--bright); border-right:1px solid var(--rule); display:flex; align-items:center;}
|
||||
.grow .gm{ padding:9px 12px; font-size:13px; color:var(--ink-soft); display:flex; align-items:center;}
|
||||
.grow .gm b{ color:var(--bright); font-weight:600;}
|
||||
.grow.free .gg{ color:var(--coral);}
|
||||
.grow.free .gm b{ color:var(--coral);}
|
||||
|
||||
.foot{ max-width:1120px; margin:0 auto; padding:30px 24px 70px; border-top:1.5px solid var(--frame); font-family:var(--mono); font-size:11px; color:var(--ink-soft); display:flex; justify-content:space-between; flex-wrap:wrap; gap:12px;}
|
||||
|
||||
@media (max-width:560px){
|
||||
.phone{ width:240px;} .phone .screen{ height:500px;}
|
||||
.phone.land{ width:300px;} .phone.land .screen{ height:200px;}
|
||||
.titleblock{ grid-template-columns:1fr;} .titleblock .tl-meta{ border-left:0; border-top:1.5px solid var(--frame); grid-template-rows:none; grid-template-columns:repeat(3,1fr);}
|
||||
.tl-meta div + div{ border-top:0; border-left:1px solid var(--rule);}
|
||||
}
|
||||
/* ---- node focus mode ---- */
|
||||
.nodewrap{ position:absolute; inset:0;}
|
||||
.minimap{ position:absolute; top:8px; right:8px; width:80px; height:54px; background:rgba(8,10,14,.72); border:1px solid var(--line); border-radius:6px; z-index:5;}
|
||||
.minimap .mn{ position:absolute; width:11px; height:8px; border-radius:2px; background:var(--line);}
|
||||
.minimap .mn.on{ background:var(--cyan); box-shadow:0 0 0 2px rgba(84,195,232,.3);}
|
||||
.minimap .mw{ position:absolute; height:1.5px; background:var(--line);}
|
||||
.node-card{ position:absolute; left:50%; top:48%; transform:translate(-50%,-50%); width:158px; background:var(--panel-2); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 30px -16px rgba(0,0,0,.85); overflow:hidden; z-index:3;}
|
||||
.node-card .nh{ padding:8px 10px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:7px;}
|
||||
.node-card .nh .ndot{ width:8px; height:8px; border-radius:2px; background:#9a6bc0;}
|
||||
.node-card .nh .nt{ font-size:11px; color:var(--bright);}
|
||||
.node-card .nh .ny{ margin-left:auto; font-size:7.5px; color:var(--dim); border:1px solid var(--line); border-radius:8px; padding:2px 6px;}
|
||||
.node-card .nprev{ height:42px; background:linear-gradient(135deg,#3a2c52,#243042);}
|
||||
.node-card .np{ display:flex; align-items:center; gap:7px; padding:6px 10px; border-top:1px solid #20242c;}
|
||||
.node-card .np .k{ font-size:9px; color:var(--dim); width:42px;}
|
||||
.node-card .np .field{ flex:1; height:15px; border-radius:4px; background:var(--panel); border:1px solid var(--line); font-size:9px; color:var(--bright); display:flex; align-items:center; padding:0 6px;}
|
||||
.node-card .np .field.slider{ background:var(--line); position:relative;}
|
||||
.node-card .np .field.slider::after{ content:""; position:absolute; left:0;top:0;bottom:0; width:48%; background:var(--cyan); opacity:.5; border-radius:4px;}
|
||||
.wire-line{ position:absolute; top:48%; height:1.5px; background:var(--line); z-index:1;}
|
||||
.wire-line.l{ left:0; width:50px;} .wire-line.r{ right:0; width:50px; background:var(--cyan); opacity:.6;}
|
||||
.port-col{ position:absolute; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:9px; z-index:4;}
|
||||
.port-col.in{ left:8px;} .port-col.out{ right:8px;}
|
||||
.pchip{ width:54px; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:5px 6px; font-size:8px; color:var(--bright); line-height:1.25;}
|
||||
.pchip .pl{ color:var(--dim); font-size:7px; display:block; letter-spacing:.04em;}
|
||||
.pchip.wire{ border-color:var(--cyan); box-shadow:0 0 0 2px rgba(84,195,232,.18);}
|
||||
.swipehint{ position:absolute; top:50%; transform:translateY(-50%); font-size:8px; color:var(--dim); z-index:4;}
|
||||
.addnode{ position:absolute; left:50%; bottom:8px; transform:translateX(-50%); background:var(--panel-2); border:1px solid var(--line); border-radius:14px; padding:5px 12px; font-size:9px; color:var(--bright); z-index:6; white-space:nowrap;}
|
||||
.wirebanner{ position:absolute; top:8px; left:8px; right:96px; background:rgba(84,195,232,.14); border:1px solid var(--cyan); border-radius:8px; padding:5px 8px; font-size:8px; color:#bfe9f7; z-index:6; line-height:1.25;}
|
||||
|
||||
/* ---- patch (pair) view ---- */
|
||||
.patch{ position:absolute; left:12px; right:12px; top:50%; transform:translateY(-50%); background:var(--panel-2); border:1px solid var(--line); border-radius:12px; overflow:hidden; z-index:2; box-shadow:0 14px 30px -16px rgba(0,0,0,.85);}
|
||||
.patch .pthead{ display:flex; align-items:center; gap:6px; padding:8px 10px; border-bottom:1px solid var(--line); font-size:10px; color:var(--bright);}
|
||||
.patch .pthead .ty{ margin-left:auto; font-size:7.5px; color:var(--dim); border:1px solid var(--line); border-radius:8px; padding:2px 6px;}
|
||||
.ptrow{ display:grid; grid-template-columns:1fr 64px 1fr; align-items:center; padding:7px 10px; border-top:1px solid #20242c;}
|
||||
.ptrow .sp{ display:flex; align-items:center; gap:6px; justify-content:flex-end; font-size:9px; color:var(--bright);}
|
||||
.ptrow .dp{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--bright);}
|
||||
.ptrow .dp.muted, .ptrow .sp.muted{ color:var(--dim);}
|
||||
.ptrow .cable{ height:12px; position:relative;}
|
||||
.ptrow .cable.on::before{ content:""; position:absolute; left:0; right:0; top:50%; height:2px; background:var(--cyan); transform:translateY(-50%);}
|
||||
.dotport{ width:8px; height:8px; border-radius:50%; background:var(--cyan); border:1px solid #0c2a33; flex:0 0 auto;}
|
||||
.dotport.empty{ background:var(--line); border-color:var(--dim);}
|
||||
.patch .pthint{ padding:7px 10px; border-top:1px solid var(--line); font-size:8px; color:var(--dim);}
|
||||
|
||||
/* ---- conventional piano roll (landscape) ---- */
|
||||
.proll{ flex:1; position:relative; overflow:hidden; border-bottom:1px solid var(--line); background:repeating-linear-gradient(0deg,#13161c 0 13px,#181c23 13px 26px);}
|
||||
.proll .glab{ position:absolute; top:5px; left:8px; font-size:8px; color:var(--dim); z-index:2;}
|
||||
.proll .pn{ position:absolute; height:10px; border-radius:2px; background:linear-gradient(180deg,#caa15a,#a07c32);}
|
||||
.proll .ph{ position:absolute; top:0; bottom:0; width:1.5px; background:var(--amber); opacity:.7; z-index:2;}
|
||||
|
||||
/* ---- instrument workspace ---- */
|
||||
.workspace{ flex:1; position:relative; overflow:hidden; border-bottom:1px solid var(--line);
|
||||
background:repeating-linear-gradient(90deg,#13161c 0 7.14%, #181c23 7.14% 14.28%);}
|
||||
.workspace .now{ position:absolute; left:0; right:0; bottom:0; height:1.5px; background:var(--amber); opacity:.7; z-index:3;}
|
||||
.workspace .glab{ position:absolute; top:6px; left:8px; font-size:8px; color:var(--dim);}
|
||||
.nbar{ position:absolute; width:5.2%; border-radius:2px; background:linear-gradient(180deg,#caa15a,#a07c32); z-index:2;}
|
||||
.ws-toggle{ display:flex; gap:5px;}
|
||||
.ws-toggle .seg{ font-size:8.5px; padding:3px 8px; border:1px solid var(--line); border-radius:9px; color:var(--dim);}
|
||||
.ws-toggle .seg.on{ background:var(--cyan); color:#06222b; border-color:var(--cyan);}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){ *{ transition:none!important; animation:none!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sheet-head">
|
||||
<p class="eyebrow">Mobile port · concept plates</p>
|
||||
<div class="titleblock">
|
||||
<div class="tl-main">
|
||||
<h1>Unified-timeline studio — phone layout</h1>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
<div><span>CONSTRAINT</span><strong>Phone · single-pane</strong></div>
|
||||
<div><span>TABS</span><strong>Top · surfaces</strong></div>
|
||||
<div><span>SPINE</span><strong>Bottom · time</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-key">
|
||||
<b><span class="swatch" style="background:var(--amber)"></span>Time / playhead / transport</b>
|
||||
<b><span class="swatch" style="background:var(--cyan)"></span>Selection</b>
|
||||
<b><span class="swatch" style="background:var(--coral)"></span>Annotation</b>
|
||||
</div>
|
||||
|
||||
<!-- ============ PLATE 01 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 01</span>
|
||||
<h2>The spine & the resizable ribbon</h2>
|
||||
<span class="tag">Tabs top · time bottom · 3 snaps</span>
|
||||
</div>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="canvas-frame" style="inset:24px 30px;"></div>
|
||||
<div class="obj sel" style="left:96px; top:150px; width:74px; height:54px; background:#2c5482;"></div>
|
||||
</div>
|
||||
<div class="ribbon"><div class="grab"></div><div class="ruler"></div><div class="lane"><span class="gut">V1</span></div></div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:120px; top:42px;">1</span>
|
||||
<span class="hs" style="left:6px; bottom:48px;">2</span>
|
||||
<span class="hs" style="left:6px; bottom:8px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Peek</b> — ribbon collapsed to a sliver; transport still anchors the bottom.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero" style="flex:0 0 168px;">
|
||||
<div class="canvas-frame" style="inset:22px;"></div>
|
||||
<div class="obj sel" style="left:80px; top:78px; width:66px; height:46px; background:#2c5482;"></div>
|
||||
</div>
|
||||
<div class="ribbon" style="flex:1;">
|
||||
<div class="grab"></div><div class="ruler"></div>
|
||||
<div class="lane"><span class="gut">RASTER</span><div class="clip raster" style="left:50px; width:80px;"></div></div>
|
||||
<div class="lane"><span class="gut">VEC</span><div class="clip v sel" style="left:96px; width:64px;"></div></div>
|
||||
<div class="lane"><span class="gut">AUD</span><div class="clip a" style="left:60px; width:120px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">AUD</span><div class="clip a" style="left:130px; width:70px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Half</b> — arrange & trim across tracks while the stage stays in view.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Tabs own the top</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Ribbon expands upward</h4>
|
||||
<p>Drag the grabber up for more tracks, down for more stage. A continuous reveal, not a modal toggle — there's always a sliver left.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Transport = the fixed floor</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 02 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 02</span>
|
||||
<h2>Tweak mode — the pull model</h2>
|
||||
<span class="tag">Selection → inspector</span>
|
||||
</div>
|
||||
<p class="lede">You arrive with a full project and a vague target, so controls come <em>to</em> 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.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="canvas-frame" style="inset:18px 22px 0;"></div>
|
||||
<div class="obj sel" style="left:108px; top:54px; width:78px; height:58px; background:#7a4f9c;"></div>
|
||||
<div class="handle" style="left:104px; top:50px;"></div>
|
||||
<div class="handle" style="left:182px; top:50px;"></div>
|
||||
<div class="handle" style="left:104px; top:108px;"></div>
|
||||
<div class="handle" style="left:182px; top:108px;"></div>
|
||||
</div>
|
||||
<div class="sheet-ui" style="height:236px;">
|
||||
<div class="grab"></div>
|
||||
<div class="sh-head"><span class="ico" style="background:#9a6bc0;"></span><span class="ttl">Ellipse · Vector layer</span><span class="ovf">⋯</span></div>
|
||||
<div class="chips"><span class="chip on">Properties</span><span class="chip">Timeline</span><span class="chip">Nodes</span><span class="chip">Automation</span></div>
|
||||
<div class="prop"><span class="k">Fill</span><div class="field"><span style="width:9px;height:9px;background:#9a6bc0;border-radius:2px;margin-right:6px;"></span>#9A6BC0</div></div>
|
||||
<div class="prop"><span class="k">Opacity</span><div class="field slider"></div></div>
|
||||
<div class="prop"><span class="k">Position</span><div class="field">x 108</div><div class="field">y 54</div></div>
|
||||
<div class="prop"><span class="k">Z-order</span><div class="field">Front ▾</div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:170px; top:74px;">1</span>
|
||||
<span class="hs" style="left:120px; bottom:250px;">2</span>
|
||||
<span class="hs" style="left:6px; bottom:8px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Half tier</b> shown — drag up for every advanced parameter, down to a 3-control peek. Transport stays pinned below.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Selection-driven</h4>
|
||||
<p>Tap selects and summons the sheet. Once selected, dragging the object <em>moves</em> it; dragging empty space pans. One chain: tap → inspect → move → long-press.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Jump-to chips = parity move</h4>
|
||||
<p><code>Timeline</code> · <code>Nodes</code> · <code>Automation</code> reframe to that surface with the object still held — replacing the desktop trick of seeing panes side-by-side.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Sheet floats over the floor</h4>
|
||||
<p>The inspector rises above the transport, never under it — so even mid-tweak you can scrub and see the playhead. Object actions like <code>send to back</code> live on the <code>⋯</code>.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot" style="border-style:dashed;">+</div><div>
|
||||
<h4>Two ways to find the thing</h4>
|
||||
<p>For "I don't know what I want to tweak": the <code>⌕</code> command palette and an outliner surface (the <code>Tree</code> tab) — a browsable map of the whole project.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 03 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 03</span>
|
||||
<h2>Sketch mode — the push model</h2>
|
||||
<span class="tag">New file · intent seeds</span>
|
||||
</div>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="intent-screen">
|
||||
<div class="new-h">Start something</div>
|
||||
<div class="new-sub">Sets your first surface — nothing is locked.</div>
|
||||
<div class="intent-grid">
|
||||
<div class="intent draw"><span class="gico"></span><span class="nm">Draw</span><span class="de">Full canvas · stylus · radial brushes</span></div>
|
||||
<div class="intent animate"><span class="gico"></span><span class="nm">Animate</span><span class="de">Stage + keyframe scrub strip</span></div>
|
||||
<div class="intent compose"><span class="gico"></span><span class="nm">Compose</span><span class="de">Instrument + arrangement ribbon</span></div>
|
||||
<div class="intent record"><span class="gico"></span><span class="nm">Record</span><span class="de">Big button · waveform fills screen</span></div>
|
||||
<div class="intent video"><span class="gico"></span><span class="nm">Edit video</span><span class="de">Clip bin + trim timeline</span></div>
|
||||
<div class="intent draw" style="opacity:.5"><span class="gico" style="background:var(--line)"></span><span class="nm">Blank</span><span class="de">Empty unified timeline</span></div>
|
||||
<div class="intent open"><span class="gico"></span><span class="nm">Open recent…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hs" style="left:120px; top:160px;">1</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Intent picker</b> — mirrors the desktop new-file flow; the seed steers nothing once content exists.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Intent seeds, state steers</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Home-on-open ≠ birth type</h4>
|
||||
<p>Re-opening lands on the file's <b>last-focused surface</b> + last selection — not the type it was born as. A music file that grew an animation layer shouldn't open music-shaped.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Lossless underneath</h4>
|
||||
<p>Every intent writes to the same document model. Whatever you rough out on the train reconstitutes as a full pane layout back on desktop.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 04 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 04</span>
|
||||
<h2>Music surface — the GarageBand fix</h2>
|
||||
<span class="tag">Expression + context, together</span>
|
||||
</div>
|
||||
<p class="lede">GarageBand denies you the <em>when</em> 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.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:0 0 auto;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 64px,#2a8a5a 64px 65px,transparent 65px 128px,var(--line) 128px 129px);"></div>
|
||||
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:64px;"><div class="wav"></div></div><div class="clip a" style="left:106px; width:64px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">BASS</span><div class="clip a" style="left:42px; width:128px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut" style="color:var(--amber);">KEYS ●</span><div class="clip a sel" style="left:42px; width:42px; background:linear-gradient(180deg,#caa15a,#a07c32);"></div></div>
|
||||
</div>
|
||||
<div class="inst-bar"><span class="pill">Grand Piano ▾</span><span>Loop ⟳ 2 bars</span><span style="margin-left:auto;color:var(--coral);">● REC</span></div>
|
||||
<div class="workspace">
|
||||
<span class="glab">LIVE TAKE · KEYS</span>
|
||||
<div class="nbar" style="left:7.1%; bottom:0; height:30px;"></div>
|
||||
<div class="nbar" style="left:21.4%; bottom:16px; height:24px;"></div>
|
||||
<div class="nbar" style="left:35.7%; bottom:4px; height:40px;"></div>
|
||||
<div class="now"></div>
|
||||
</div>
|
||||
<div class="keys" style="flex:0 0 150px;">
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="bkey" style="left:9.5%"></div><div class="bkey" style="left:22%"></div>
|
||||
<div class="bkey" style="left:47%"></div><div class="bkey" style="left:59.5%"></div><div class="bkey" style="left:72%"></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
|
||||
<span class="hs" style="left:120px; top:52px;">1</span>
|
||||
<span class="hs" style="left:200px; top:96px;">2</span>
|
||||
<span class="hs" style="left:6px; bottom:8px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Record-against-context</b> — see the loop wrap and the other tracks while your thumbs are on the keys.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>The loop boundary is visible</h4>
|
||||
<p>Green markers show where the 2-bar loop wraps; the amber playhead sweeps toward it — the exact <em>when</em> GarageBand's modal switch hides.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Playing against the mix</h4>
|
||||
<p>Squashed drum/bass lanes stay on screen so you hear <em>and see</em> what your take lands over. The live KEYS lane fills as you record.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Same spine, same floor</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 05 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 05</span>
|
||||
<h2>Orientation is a per-surface reflow</h2>
|
||||
<span class="tag">Not an app-global mode</span>
|
||||
</div>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div class="stage-row" style="align-items:flex-start;">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:1;">
|
||||
<div class="ruler"></div>
|
||||
<div class="lane"><span class="gut">V1</span><div class="clip v" style="left:50px;width:60px;"></div></div>
|
||||
<div class="lane"><span class="gut">V2</span><div class="clip raster" style="left:70px;width:90px;"></div></div>
|
||||
<div class="lane"><span class="gut">RAS</span><div class="clip raster" style="left:50px;width:50px;"></div></div>
|
||||
<div class="lane"><span class="gut">A1</span><div class="clip a" style="left:48px;width:130px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A2</span><div class="clip a" style="left:90px;width:80px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A3</span><div class="clip a" style="left:60px;width:60px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A4</span><div class="clip a" style="left:120px;width:70px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">FX</span><div class="clip v" style="left:42px;width:40px;"></div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Portrait</b> — vertical room buys <b>more tracks</b>. Best for arranging, reordering, seeing structure.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone land"><div class="screen">
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:1;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 48px,var(--line) 48px 49px);"></div>
|
||||
<div class="lane"><span class="gut">V1</span><div class="clip v" style="left:60px;width:140px;"></div><div class="clip raster" style="left:210px;width:90px;"></div></div>
|
||||
<div class="lane"><span class="gut">A1</span><div class="clip a" style="left:50px;width:300px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">A2</span><div class="clip a" style="left:120px;width:200px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div><span class="mini">⌕</span><span class="mini">⋯</span></div>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Landscape</b> — wide axis buys <b>time resolution</b>. Best for scrubbing, precise trims.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="min-width:200px;">
|
||||
<div class="note"><div class="dot">↻</div><div>
|
||||
<h4>Reflow, don't reset</h4>
|
||||
<p>Same selection, playhead and zoom-center — just re-emphasised. A rotate that throws away where you were is its own GarageBand-tier annoyance.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">⊞</div><div>
|
||||
<h4>Per-surface character</h4>
|
||||
<p>Stage follows the <em>project's</em> aspect; instrument prefers landscape for key width; mixer is portrait-native. The ribbon absorbs whatever the hero leaves over.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">🔒</div><div>
|
||||
<h4>Manual lock</h4>
|
||||
<p>People work lying down and propped at angles. The device <em>suggests</em>; the user can <em>pin</em>. Auto-rotate never fights a drawing gesture.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 06 ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 06</span>
|
||||
<h2>Omnibutton & the gesture map</h2>
|
||||
<span class="tag">Tools vs commands · 7 + 1 meanings</span>
|
||||
</div>
|
||||
<p class="lede">The omnibutton produces <em>tools/objects</em>; the global menu holds <em>commands/destinations</em> — 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.</p>
|
||||
|
||||
<div class="stage-row" style="margin-bottom:30px;">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab on"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="canvas-frame" style="inset:20px;"></div>
|
||||
<div class="radial-wrap">
|
||||
<div class="scrim"></div>
|
||||
<div class="rad-btn" style="right:18px; bottom:190px;"><span class="g" style="background:var(--coral);"></span></div>
|
||||
<div class="rad-btn" style="right:64px; bottom:174px;"><span class="g" style="background:var(--cyan);"></span></div>
|
||||
<div class="rad-btn" style="right:96px; bottom:140px;"><span class="g" style="background:var(--amber);"></span></div>
|
||||
<div class="rad-btn" style="right:110px; bottom:98px;">Shape</div>
|
||||
<div class="omni"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ribbon"><div class="grab"></div><div class="ruler"></div><div class="lane"><span class="gut">V1</span></div></div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="right:6px; bottom:150px;">1</span>
|
||||
<span class="hs" style="right:30px; top:42px;">2</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Radial fans away from the finger</b> to dodge occlusion. Global commands live in the top <code>⋯</code>.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Omnibutton = tools/objects</h4>
|
||||
<p>Brushes, shapes, instruments, nodes — things you place or use. Long-press on empty space can summon this same create menu <em>at the point you pressed</em>.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Global menu = commands</h4>
|
||||
<p>Export, render, project settings, and the command palette itself live in the top <code>⋯</code>/<code>⌕</code>. If an item feels like either, it's usually a selection action in disguise → inspector.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot" style="border-style:dashed;">▤</div><div>
|
||||
<h4>Palette is the safety net</h4>
|
||||
<p>Every menu-bar verb has one contextual home <em>plus</em> the palette. That's what lets contextual homes hide rare commands without breaking parity.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gmap">
|
||||
<div class="gtable">
|
||||
<div class="grow"><div class="gg">tap object</div><div class="gm"><b>select</b> + summon inspector</div></div>
|
||||
<div class="grow"><div class="gg">tap empty</div><div class="gm">deselect</div></div>
|
||||
<div class="grow"><div class="gg">drag selected</div><div class="gm"><b>move</b> the object / clip</div></div>
|
||||
<div class="grow"><div class="gg">drag empty</div><div class="gm"><b>pan</b> the view (matches timeline scroll)</div></div>
|
||||
<div class="grow"><div class="gg">two-finger drag</div><div class="gm"><b>pan</b> — always, everywhere (escape valve)</div></div>
|
||||
<div class="grow"><div class="gg">pinch</div><div class="gm"><b>zoom</b> — time-zoom on timeline, spatial on stage</div></div>
|
||||
<div class="grow"><div class="gg">long-press object</div><div class="gm"><b>actions menu</b> for that object</div></div>
|
||||
<div class="grow"><div class="gg">long-press empty</div><div class="gm">create-here radial <span style="color:var(--coral)">(proposed)</span></div></div>
|
||||
<div class="grow"><div class="gg">double-tap object</div><div class="gm"><b>enter / edit</b> — go a level deeper</div></div>
|
||||
<div class="grow"><div class="gg">double-tap empty</div><div class="gm">zoom-to-fit</div></div>
|
||||
<div class="grow free"><div class="gg">double-tap-drag empty</div><div class="gm"><b>marquee select</b> — fast transient path</div></div>
|
||||
</div>
|
||||
<div class="notes" style="min-width:240px;">
|
||||
<div class="note"><div class="dot">▦</div><div>
|
||||
<h4>Two tiers for marquee</h4>
|
||||
<p>The <b>double-tap-drag</b> gesture is the fast, transient path. A sustained <b>marquee mode</b> covers heavy vector multi-select — and with the gesture in hand, it may not even need a dedicated button.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">◎</div><div>
|
||||
<h4>Origin disambiguates</h4>
|
||||
<p>Marquee is strictly empty-space-initiated. Double-tap-drag starting <em>on</em> an object never marquees — same spatial-zones logic as the ruler vs lanes.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">⊙</div><div>
|
||||
<h4>Crisp double-tap window</h4>
|
||||
<p>Empty space is where both <code>zoom-to-fit</code> and <code>marquee</code> branch off the same tap-tap, so tighten the detection window there specifically.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 07 — NODE EDITOR: FOCUS & PATCH ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 07</span>
|
||||
<h2>Node editor — focus & patch</h2>
|
||||
<span class="tag">Audio synthesis · port-to-port</span>
|
||||
</div>
|
||||
<p class="lede">For synthesis the graph is patch-heavy: several cables of one type running between modules. So ports are identified by <em>name</em> — gate, velocity, pitch — not just type; type only decides what <em>may</em> 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.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab on"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="nodewrap">
|
||||
<div class="minimap">
|
||||
<span class="mw" style="left:14px; top:16px; width:18px; transform:rotate(20deg);"></span>
|
||||
<span class="mw" style="left:40px; top:26px; width:20px;"></span>
|
||||
<span class="mn" style="left:8px; top:10px;"></span>
|
||||
<span class="mn on" style="left:30px; top:22px;"></span>
|
||||
<span class="mn" style="left:56px; top:30px;"></span>
|
||||
<span class="mn" style="left:34px; top:40px;"></span>
|
||||
</div>
|
||||
<div class="wire-line l"></div>
|
||||
<div class="wire-line r"></div>
|
||||
<div class="port-col in">
|
||||
<div class="pchip"><span class="pl">◂ gate</span>MIDI→CV.gate</div>
|
||||
<div class="pchip"><span class="pl">◂ velocity</span>MIDI→CV.vel</div>
|
||||
</div>
|
||||
<div class="node-card">
|
||||
<div class="nh"><span class="ndot" style="background:#5aa0c2;"></span><span class="nt">ADSR</span><span class="ny">ENV · CV</span></div>
|
||||
<div class="nprev" style="background:linear-gradient(135deg,#243042,#2c3a26);"></div>
|
||||
<div class="np"><span class="k">Attack</span><div class="field slider"></div></div>
|
||||
<div class="np"><span class="k">Decay</span><div class="field">120 ms</div></div>
|
||||
<div class="np"><span class="k">Sustain</span><div class="field slider"></div></div>
|
||||
</div>
|
||||
<div class="port-col out">
|
||||
<div class="pchip"><span class="pl">env ▸</span>VCA.in</div>
|
||||
</div>
|
||||
<div class="addnode">+ Add node · ⌕ Search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:6px; top:232px;">1</span>
|
||||
<span class="hs" style="right:34px; top:54px;">2</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Focus</b> — one module's parameters; its named ports are travel chips. Input chips name the <em>remote port</em>, so two cables from the same node never collide.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>⌕ ⋯ ●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab"><span class="g"></span>Time</div><div class="tab on"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="hero">
|
||||
<div class="patch">
|
||||
<div class="pthead"><span>Patch</span><span style="color:var(--dim);">MIDI→CV ▸ ADSR</span><span class="ty">CV</span></div>
|
||||
<div class="ptrow"><div class="sp">pitch <span class="dotport"></span></div><div class="cable"></div><div class="dp muted"><span class="dotport empty"></span>→ Osc 1</div></div>
|
||||
<div class="ptrow"><div class="sp">velocity <span class="dotport"></span></div><div class="cable on"></div><div class="dp"><span class="dotport"></span>velocity</div></div>
|
||||
<div class="ptrow"><div class="sp">gate <span class="dotport"></span></div><div class="cable on"></div><div class="dp"><span class="dotport"></span>gate</div></div>
|
||||
<div class="pthint">tap a source port, then a target — both are CV, so the <b>name</b> decides the match</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">00:12:04</span><div class="scrub"></div></div>
|
||||
<span class="hs" style="left:122px; top:250px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Patch</b> — two modules as port lists, each cable a row. The CV pair (gate, velocity) is just two rows; pitch fans off to the oscillator.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>Ports are named, not just typed</h4>
|
||||
<p>Input chips read the remote endpoint as <code>node.port</code> — <code>MIDI→CV.gate</code>, <code>MIDI→CV.vel</code> — so two cables from one node stay distinct even though both are CV. Type gates compatibility; the name carries identity.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Focus still travels & renders live</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Patch mode for cabling</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot" style="border-style:dashed;">!</div><div>
|
||||
<h4>Honest scope</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PLATE 08 — KEYBOARD CAP & RECLAIMED SPACE ============ -->
|
||||
<section class="plate">
|
||||
<div class="plate-head">
|
||||
<span class="plate-num">PLATE 08</span>
|
||||
<h2>Keyboard cap & the reclaimed space</h2>
|
||||
<span class="tag">Portrait · keys ≤ ⅓</span>
|
||||
</div>
|
||||
<p class="lede">Key <em>width</em> 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.</p>
|
||||
|
||||
<div class="stage-row">
|
||||
<div class="figcol">
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span>●●●</span></div>
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<!-- macro context -->
|
||||
<div class="ribbon" style="flex:0 0 auto;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 64px,#2a8a5a 64px 65px,transparent 65px 128px,var(--line) 128px 129px);"></div>
|
||||
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:64px;"><div class="wav"></div></div><div class="clip a" style="left:106px; width:64px;"><div class="wav"></div></div></div>
|
||||
<div class="lane"><span class="gut">BASS</span><div class="clip a" style="left:42px; width:128px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<!-- instrument bar at the top of the surface (above the workspace, not between notes/keys) -->
|
||||
<div class="inst-bar">
|
||||
<span class="pill">Grand Piano ▾</span>
|
||||
<div class="ws-toggle"><span class="seg on">Notes</span><span class="seg">Controllers</span></div>
|
||||
<span style="margin-left:auto;color:var(--coral);">● REC</span>
|
||||
</div>
|
||||
<!-- reclaimed workspace: note view, aligned to keys -->
|
||||
<div class="workspace">
|
||||
<span class="glab">CURRENT PART · KEYS</span>
|
||||
<div class="nbar" style="left:7.1%; bottom:0; height:34px;"></div>
|
||||
<div class="nbar" style="left:21.4%; bottom:18px; height:26px;"></div>
|
||||
<div class="nbar" style="left:35.7%; bottom:6px; height:48px;"></div>
|
||||
<div class="nbar" style="left:50%; bottom:28px; height:22px;"></div>
|
||||
<div class="nbar" style="left:64.2%; bottom:0; height:40px;"></div>
|
||||
<div class="nbar" style="left:78.5%; bottom:14px; height:30px;"></div>
|
||||
<div class="now"></div>
|
||||
</div>
|
||||
<!-- keys capped ~1/3 -->
|
||||
<div class="keys" style="flex:0 0 150px;">
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="wkey"></div><div class="wkey"></div><div class="wkey"></div><div class="wkey"></div>
|
||||
<div class="bkey" style="left:9.5%"></div><div class="bkey" style="left:22%"></div>
|
||||
<div class="bkey" style="left:47%"></div><div class="bkey" style="left:59.5%"></div><div class="bkey" style="left:72%"></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
|
||||
<span class="hs" style="left:120px; top:150px;">1</span>
|
||||
<span class="hs" style="left:120px; bottom:188px;">2</span>
|
||||
<span class="hs" style="left:96px; bottom:158px;">3</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Macro → micro → input</b>: arrangement context up top, note workspace in the middle, capped keys in the thumb zone.</div>
|
||||
</div>
|
||||
|
||||
<div class="figcol">
|
||||
<div class="phone land"><div class="screen">
|
||||
<div class="tabs"><div class="tab"><span class="g"></span>Stage</div><div class="tab on"><span class="g"></span>Time</div><div class="tab"><span class="g"></span>Nodes</div><div class="tab"><span class="g"></span>Mixer</div><div class="tab"><span class="g"></span>Tree</div></div>
|
||||
<div class="ribbon" style="flex:0 0 auto;">
|
||||
<div class="ruler" style="background:repeating-linear-gradient(90deg,transparent 0 80px,#2a8a5a 80px 81px,transparent 81px 160px,var(--line) 160px 161px);"></div>
|
||||
<div class="lane"><span class="gut">DRUMS</span><div class="clip a" style="left:42px; width:130px;"><div class="wav"></div></div><div class="clip a" style="left:180px; width:130px;"><div class="wav"></div></div></div>
|
||||
</div>
|
||||
<div class="inst-bar"><span class="pill">Grand Piano ▾</span><div class="ws-toggle"><span class="seg">Keys</span><span class="seg on">Notes</span></div><span style="margin-left:auto;color:var(--coral);">● REC</span></div>
|
||||
<div class="proll">
|
||||
<span class="glab">PIANO ROLL · time ▸</span>
|
||||
<div class="pn" style="left:12%; top:30px; width:14%;"></div>
|
||||
<div class="pn" style="left:30%; top:56px; width:10%;"></div>
|
||||
<div class="pn" style="left:44%; top:17px; width:18%;"></div>
|
||||
<div class="pn" style="left:46%; top:82px; width:8%;"></div>
|
||||
<div class="pn" style="left:66%; top:43px; width:12%;"></div>
|
||||
<div class="pn" style="left:80%; top:69px; width:10%;"></div>
|
||||
<div class="ph" style="left:44%;"></div>
|
||||
</div>
|
||||
<div class="transport"><div class="play"></div><span class="tc">2.1.03</span><div class="scrub"></div><span class="mini">120bpm</span></div>
|
||||
<span class="hs" style="left:200px; top:42px;">4</span>
|
||||
</div></div>
|
||||
<div class="figcap"><b>Landscape · Notes</b> — 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.</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><div>
|
||||
<h4>The cap, and what fills it</h4>
|
||||
<p>Keys grow to ~⅓ then stop; continued upward drag reveals the workspace. The keyboard holds its ergonomic height — the new room opens <em>above</em> it. Same continuous-reveal as the timeline ribbon.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">2</div><div>
|
||||
<h4>Default: see what you play</h4>
|
||||
<p>A note view of the current part, each column sitting over its key below (a waterfall toward the amber <em>now</em> line). The see-what-you're-doing principle the GarageBand port breaks — and editable in place.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">3</div><div>
|
||||
<h4>Or perform: Controllers</h4>
|
||||
<p>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.</p>
|
||||
</div></div>
|
||||
<div class="note"><div class="dot">4</div><div>
|
||||
<h4>Landscape splits them — and reflows</h4>
|
||||
<p>No room to stack, so keys and notes become a <b>Keys / Notes</b> toggle, with the context ribbon persisting in both — you lose seeing keys-and-notes together, never the <em>when</em>. 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.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="foot">
|
||||
<span>UNIFIED-TIMELINE STUDIO · PHONE CONCEPT PLATES 01–08</span>
|
||||
<span>WIREFRAME — LAYOUT & GESTURE INTENT, NOT FINAL VISUAL</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue