1use edit::{EditOp, EditProject, History};
15use leptos::prelude::*;
16
17use crate::editor_ipc;
18
19#[must_use]
23pub fn resolve_history(existing: Option<History>, current: &EditProject) -> History {
24 match existing {
29 Some(history) if history.project().source.path == current.source.path => history,
30 _ => History::new(current.clone()),
31 }
32}
33
34fn run(
38 project: RwSignal<Option<EditProject>>,
39 history: StoredValue<Option<History>>,
40 edit: impl FnOnce(&mut History),
41) {
42 let Some(current) = project.get_untracked() else {
43 return;
44 };
45 let mut hist = resolve_history(history.get_value(), ¤t);
46 edit(&mut hist);
47 let edited = hist.project().clone();
48 let duration = edited.project_duration();
49 project.set(Some(edited));
50 history.set_value(Some(hist));
51 editor_ipc::editor_transport(&editor_ipc::TransportAction::SetDuration { frames: duration });
54}
55
56fn apply_logged(history: &mut History, op: &EditOp) {
62 if let Err(err) = history.apply(op) {
63 leptos::logging::warn!("edit op rejected: {err}");
64 }
65}
66
67pub fn split_at(
69 project: RwSignal<Option<EditProject>>,
70 history: StoredValue<Option<History>>,
71 at: u64,
72) {
73 run(project, history, |hist| {
74 apply_logged(hist, &EditOp::Split { at });
75 });
76}
77
78pub fn ripple_delete_selected(
81 project: RwSignal<Option<EditProject>>,
82 history: StoredValue<Option<History>>,
83 selected: Option<usize>,
84) {
85 let Some(index) = selected else {
86 return;
87 };
88 let Some(current) = project.get_untracked() else {
89 return;
90 };
91 let Some((start, end)) = current.segment_project_range(index) else {
92 return;
93 };
94 run(project, history, |hist| {
95 apply_logged(hist, &EditOp::RippleDelete { start, end });
96 });
97}
98
99pub fn add_zoom_default(
103 project: RwSignal<Option<EditProject>>,
104 history: StoredValue<Option<History>>,
105 at: u64,
106) {
107 let Some(current) = project.get_untracked() else {
108 return;
109 };
110 let duration = current.project_duration();
111 if duration == 0 {
112 return;
113 }
114 let start = at.min(duration.saturating_sub(1));
115 let window = (u64::from(current.project_fps) * 3 / 2).max(1); let end = (start + window).min(duration);
117 if end <= start {
118 return;
119 }
120 let zoom = edit::zoom::ZoomSegment::manual(edit::zoom::ZoomId(0), start, end, 1.6);
121 run(project, history, |hist| {
122 apply_logged(hist, &EditOp::AddZoom { zoom });
123 });
124}
125
126pub fn add_zoom_at_cursor(
132 project: RwSignal<Option<EditProject>>,
133 history: StoredValue<Option<History>>,
134 at: u64,
135) {
136 let Some(current) = project.get_untracked() else {
137 return;
138 };
139 let duration = current.project_duration();
140 if duration == 0 {
141 return;
142 }
143 let start = at.min(duration.saturating_sub(1));
144 let window = (u64::from(current.project_fps) * 3 / 2).max(1); let end = (start + window).min(duration);
146 if end <= start {
147 return;
148 }
149 let (x, y) = current.zoom_cursor_target(start);
150 let mut zoom = edit::zoom::ZoomSegment::manual(edit::zoom::ZoomId(0), start, end, 1.6);
151 zoom.mode = edit::zoom::ZoomMode::Manual { x, y };
152 run(project, history, |hist| {
153 apply_logged(hist, &EditOp::AddZoom { zoom });
154 });
155}
156
157pub fn remove_zoom(
159 project: RwSignal<Option<EditProject>>,
160 history: StoredValue<Option<History>>,
161 id: edit::zoom::ZoomId,
162) {
163 run(project, history, |hist| {
164 apply_logged(hist, &EditOp::RemoveZoom { id });
165 });
166}
167
168pub fn set_speed(
171 project: RwSignal<Option<EditProject>>,
172 history: StoredValue<Option<History>>,
173 index: usize,
174 timescale: f64,
175) {
176 run(project, history, |hist| {
177 apply_logged(hist, &EditOp::SetSpeed { index, timescale });
178 });
179}
180
181pub fn set_crop(
184 project: RwSignal<Option<EditProject>>,
185 history: StoredValue<Option<History>>,
186 rect: edit::style::CropRect,
187) {
188 run(project, history, |hist| {
189 apply_logged(hist, &EditOp::SetCrop { rect });
190 });
191}
192
193pub fn set_aspect(
195 project: RwSignal<Option<EditProject>>,
196 history: StoredValue<Option<History>>,
197 ratio: edit::style::AspectRatio,
198) {
199 run(project, history, |hist| {
200 apply_logged(hist, &EditOp::SetAspect { ratio });
201 });
202}
203
204pub fn set_background(
206 project: RwSignal<Option<EditProject>>,
207 history: StoredValue<Option<History>>,
208 config: edit::style::BackgroundConfig,
209) {
210 run(project, history, move |hist| {
211 apply_logged(hist, &EditOp::SetBackground { config });
212 });
213}
214
215pub fn set_cursor(
217 project: RwSignal<Option<EditProject>>,
218 history: StoredValue<Option<History>>,
219 cursor: edit::style::CursorConfig,
220) {
221 run(project, history, move |hist| {
222 apply_logged(hist, &EditOp::SetCursor { cursor });
223 });
224}
225
226pub fn set_zoom_ease(
228 project: RwSignal<Option<EditProject>>,
229 history: StoredValue<Option<History>>,
230 id: edit::zoom::ZoomId,
231 ease: edit::zoom::EditEase,
232) {
233 run(project, history, |hist| {
234 apply_logged(hist, &EditOp::SetZoomEase { id, ease });
235 });
236}
237
238pub fn undo(project: RwSignal<Option<EditProject>>, history: StoredValue<Option<History>>) {
240 run(project, history, |hist| {
241 hist.undo();
242 });
243}
244
245pub fn redo(project: RwSignal<Option<EditProject>>, history: StoredValue<Option<History>>) {
247 run(project, history, |hist| {
248 hist.redo();
249 });
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use edit::ClipRef;
256 use std::path::PathBuf;
257
258 fn project(path: &str) -> EditProject {
259 EditProject::from_recording(ClipRef::new(PathBuf::from(path), 1920, 1080, 30, 900))
260 }
261
262 #[test]
263 fn resolve_reuses_same_clip_history_preserving_undo() {
264 let p = project("/tmp/a.mp4");
265 let mut h = History::new(p.clone());
266 h.apply(&EditOp::Split { at: 300 }).unwrap();
267 assert!(h.can_undo());
268 let resolved = resolve_history(Some(h), &p);
270 assert!(resolved.can_undo());
271 }
272
273 #[test]
274 fn resolve_starts_fresh_for_a_different_clip() {
275 let mut h = History::new(project("/tmp/a.mp4"));
276 h.apply(&EditOp::Split { at: 300 }).unwrap();
277 let b = project("/tmp/b.mp4");
278 let resolved = resolve_history(Some(h), &b);
279 assert!(!resolved.can_undo(), "different clip → fresh history");
280 assert_eq!(resolved.project().source.path, b.source.path);
281 }
282
283 #[test]
284 fn resolve_none_starts_fresh() {
285 let p = project("/tmp/a.mp4");
286 let resolved = resolve_history(None, &p);
287 assert!(!resolved.can_undo());
288 assert_eq!(resolved.project().segments.len(), 1);
289 }
290}