Merge pull request #1225 from haranishi/fix/1191-locked-track-feedback

fix: トラックロックの斜線表示とドラッグ保護を修正 (#1191)
This commit is contained in:
Brandon Thomas committed 2026-10-13 02:34:34 -04:00
commit 13355a77a9
2 files changed
+166 -20

No files matched your search

+84 -20
View File
@@ -601,6 +601,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
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;
}
}
}
// An existing clip dragged past the outer row previews its new destination (#1187).
@@ -1920,6 +1928,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);
@@ -2260,17 +2288,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)
@@ -2457,6 +2503,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);
@@ -2535,14 +2588,19 @@ 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,
};
// A linked or multitrack selection gets one destination per source track.
let new_track = start_row
.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(tid, _)| seq.track(tid).is_some_and(|t| !t.locked))))
.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)
});
Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, new_track, copy: mods.alt })
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 {
// A linked or multitrack selection gets one destination per source track.
let new_track = start_row
.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(tid, _)| seq.track(tid).is_some_and(|t| !t.locked))))
.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)
});
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() };
@@ -2601,7 +2659,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;
+82
View File
@@ -391,6 +391,88 @@ fn dragging_in_the_trim_monitor_trims_without_hanging() {
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));
}
}
}
fn move_existing_clip_to_a_new_track(audio: bool, linked: bool) {
let mut d = Driver::demo();
d.exec("sequence.linkedSelection", json!({"on": linked}));