fix: ロックしたトラックの斜線表示とドラッグ保護を修正 (#1191)

This commit is contained in:
Ikki Nishihara 2026-10-12 12:33:52 +09:00
parent 78fe0102bd
commit 8cd44f205b
2 changed files with 168 additions and 22 deletions

View File

@ -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<ClipId> {
.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<TrackId> {
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;

View File

@ -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));
}
}
}