From 8cd44f205b1f581f3c384a3f323a4725d0d5bf02 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 12:33:52 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E3=83=AD=E3=83=83=E3=82=AF=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=83=88=E3=83=A9=E3=83=83=E3=82=AF=E3=81=AE=E6=96=9C?= =?UTF-8?q?=E7=B7=9A=E8=A1=A8=E7=A4=BA=E3=81=A8=E3=83=89=E3=83=A9=E3=83=83?= =?UTF-8?q?=E3=82=B0=E4=BF=9D=E8=AD=B7=E3=82=92=E4=BF=AE=E6=AD=A3=20(#1191?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/timeline.rs | 108 ++++++++++++++++++----- crates/ui-egui/tests/timeline_trim_ui.rs | 82 +++++++++++++++++ 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ac4caf53..83bc4bdb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -575,6 +575,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("timeline.transition.{}.in", trn.id.0), Rect::from_min_max(tr_rect.min, pos2(x0 + zone, tr_rect.max.y)), "start"); app.auto.add(&format!("timeline.transition.{}.out", trn.id.0), Rect::from_min_max(pos2(x1 - zone, tr_rect.min.y), tr_rect.max), "end"); } + if tr.locked { + let color = Color32::from_rgba_unmultiplied(t.text.r(), t.text.g(), t.text.b(), 64); + let mut x = content.min.x - row.height(); + while x < content.max.x { + p.line_segment([pos2(x, row.max.y), pos2(x + row.height(), row.min.y)], Stroke::new(1.0, color)); + x += 8.0; + } + } } // ---- track headers @@ -1849,6 +1857,26 @@ fn marquee_clips(seq: &Sequence, layout: &Layout, r: Rect) -> Vec { .collect() } +/// A move previews the same track-lock restrictions as its eventual edit. +fn drag_is_locked(session: &filmcraft_engine::Session, seq: &Sequence, drag: &Drag) -> bool { + let clips = match drag { + Drag::Move { clips, track_delta, .. } => return move_is_locked(seq, clips, *track_delta), + Drag::Trim { clip, .. } | Drag::Stretch { clip, .. } | Drag::Remix { clip, .. } | Drag::Slip { clip, .. } | Drag::Slide { clip, .. } => vec![*clip], + Drag::Roll { left, right, .. } => vec![*left, *right], + Drag::Transition { id, .. } => return seq.all_tracks().any(|t| t.locked && t.transitions.iter().any(|tr| tr.id == *id)), + _ => return false, + }; + move_is_locked(seq, &filmcraft_engine::commands::with_links(session, &clips), 0) +} + +fn move_is_locked(seq: &Sequence, clips: &[ClipId], delta: i32) -> bool { + clips.iter().any(|clip| { + seq.find_item(*clip).is_some_and(|(track, _)| { + seq.track(track).is_some_and(|t| t.locked) || shift_track(seq, track, delta).and_then(|id| seq.track(id)).is_some_and(|t| t.locked) + }) + }) +} + fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option { if delta == 0 { return Some(tid); @@ -2188,17 +2216,35 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && area.contains(p) { let g = grab_at(seq, layout, p, tool, mods, roll_ripple); - let cur = match (tool, g) { - (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, - (Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn, - (Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab, - (_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair, - (_, Grab::Volume { .. }) => CursorIcon::ResizeVertical, - (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, - (Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal, - (Tool::Hand, _) => CursorIcon::Grab, - (Tool::Zoom, _) => CursorIcon::ZoomIn, - _ => CursorIcon::Default, + let blocked = tool == Tool::Selection + && match g.hit() { + Hit::Clip { clip, .. } => { + let clips = if app.session.state.selection.contains(&clip) { + app.session.state.selection.clone() + } else if mods.alt { + vec![clip] + } else { + filmcraft_engine::commands::with_links(&app.session, &[clip]) + }; + move_is_locked(seq, &clips, 0) + } + _ => false, + }; + let cur = if blocked { + CursorIcon::NotAllowed + } else { + match (tool, g) { + (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, + (Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn, + (Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab, + (_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair, + (_, Grab::Volume { .. }) => CursorIcon::ResizeVertical, + (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, + (Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal, + (Tool::Hand, _) => CursorIcon::Grab, + (Tool::Zoom, _) => CursorIcon::ZoomIn, + _ => CursorIcon::Default, + } }; ctx.set_cursor_icon(cur); // the edge a press here would grab, and the trim it starts (#259) @@ -2378,6 +2424,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } (_, Grab::Other(Hit::Empty { .. } | Hit::None)) => None, }; + let started = started.filter(|drag| { + let blocked = drag_is_locked(&app.session, seq, drag); + if blocked { + ctx.set_cursor_icon(CursorIcon::NotAllowed); + } + !blocked + }); if let Some(d) = started { if matches!(d, Drag::Scrub { .. }) { scrub_playhead(app, seq, layout, p, mods.shift); @@ -2456,17 +2509,22 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & (Some(c), Some(s)) if c.kind == s.kind => c.index as i32 - s.index as i32, _ => 0, }; - // #1104: past the last track of the clips' kind, a new track (only for clips of one track) - let new_track = start_row.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(t, _)| t == start_track))).and_then(|s| { - let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h); - new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind) - }); - if let Some((_, band)) = new_track { - let (x0, x1) = (layout.x_of((first + offset).max(Tick::ZERO)), layout.x_of((last + offset).max(Tick::ZERO))); - let r = Rect::from_min_max(pos2(x0, band.min.y + 1.0), pos2(x1, band.max.y - 1.0)); - ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + if move_is_locked(seq, &clips, track_delta) { + ctx.set_cursor_icon(CursorIcon::NotAllowed); + Some(Drag::Move { clips, grab_tick, start_track, offset: Tick::ZERO, track_delta: 0, new_track: None, copy: mods.alt }) + } else { + // #1104: past the last track of the clips' kind, a new track (only for clips of one track) + let new_track = start_row.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(t, _)| t == start_track))).and_then(|s| { + let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h); + new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind) + }); + if let Some((_, band)) = new_track { + let (x0, x1) = (layout.x_of((first + offset).max(Tick::ZERO)), layout.x_of((last + offset).max(Tick::ZERO))); + let r = Rect::from_min_max(pos2(x0, band.min.y + 1.0), pos2(x1, band.max.y - 1.0)); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + } + Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, new_track, copy: mods.alt }) } - Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, new_track, copy: mods.alt }) } Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; @@ -2523,7 +2581,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } other => Some(other), }; - app.tl.drag = new; + app.tl.drag = new.filter(|drag| { + let blocked = drag_is_locked(&app.session, seq, drag); + if blocked { + ctx.set_cursor_icon(CursorIcon::NotAllowed); + } + !blocked + }); // auto-scroll when dragging near the edges if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. }) | Some(Drag::Volume(_))) { let v = &mut app.ui.timeline; diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index c68d5612..816bc5c0 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -390,3 +390,85 @@ fn dragging_in_the_trim_monitor_trims_without_hanging() { assert!(e1 < e0, "the Out edge moved in by {} frames", frames.abs()); assert!((6..=14).contains(&-frames), "about 11 frames expected for 60 px at 6 px/frame, moved {}", frames.abs()); } + +fn locked_move_keeps_the_original_preview(audio: bool, destination: bool, linked_partner: bool, edge: Option<&str>) { + let mut d = Driver::demo(); + d.exec("sequence.linkedSelection", json!({"on": linked_partner})); + let seq = d.app().session.active_sequence().unwrap(); + let tracks = if audio { &seq.audio_tracks } else { &seq.video_tracks }; + let clip = tracks[0].items[0].id; + let locked = if linked_partner { seq.audio_tracks[0].id } else { tracks[usize::from(destination)].id }; + d.exec("timeline.setTrack", json!({"track": locked.0, "locked": true})); + d.frames(3); + let prefix = format!("timeline.clip.{}", clip.0); + let original = d.ok("ui.elements", json!({"prefix": prefix}))[0]["rect"].clone(); + let r = original.as_array().unwrap(); + let from = pos2(r[0].as_f64().unwrap() as f32 + r[2].as_f64().unwrap() as f32 * 0.5, r[1].as_f64().unwrap() as f32 + 8.0); + let from = if let Some(edge) = edge { + let point = d.ok("ui.timeline.locate", json!({"clip": clip.0, "edge": edge})); + pos2(point["x"].as_f64().unwrap() as f32, from.y) + } else { + from + }; + let mut to = from + egui::vec2(60.0, 0.0); + if destination { + let label = if audio { "A2" } else { "V2" }; + let rows = d.ok("ui.elements", json!({"prefix": format!("timeline.track.{label}.target")})); + let y = &rows[0]["rect"]; + to.y = (y[1].as_f64().unwrap() + y[3].as_f64().unwrap() * 0.5) as f32; + } + let before = d.app().session.project.clone(); + let undo = d.app().session.history.undo.len(); + d.harness.input_mut().events.push(egui::Event::PointerMoved(from)); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }); + d.frames(1); + for i in 1..=8 { + d.harness.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + d.frames(1); + } + d.frames(2); + let elements = d.ok("ui.elements", json!({"prefix": prefix})); + assert_eq!(elements[0]["rect"], original, "a prohibited move never previews a displaced clip"); + assert_eq!(d.cursor(), CursorIcon::NotAllowed); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + d.frames(2); + assert_eq!(serde_json::to_value(&*d.app().session.project).unwrap(), serde_json::to_value(&*before).unwrap()); + assert_eq!(d.app().session.history.undo.len(), undo); +} + +#[test] +fn locked_sources_do_not_preview_a_move() { + for audio in [false, true] { + locked_move_keeps_the_original_preview(audio, false, false, None); + } +} +#[test] +fn locked_destinations_do_not_preview_a_move() { + for audio in [false, true] { + locked_move_keeps_the_original_preview(audio, true, false, None); + } +} +#[test] +fn a_locked_linked_partner_prevents_the_move_preview() { + locked_move_keeps_the_original_preview(false, false, true, None); +} + +#[test] +fn locked_edges_do_not_preview_a_trim() { + for audio in [false, true] { + for edge in ["in", "out"] { + locked_move_keeps_the_original_preview(audio, false, false, Some(edge)); + } + } +}