simple_editor\ui/
tracking_ui.rs

1//! Tracking pane (non-blocking): follow a point or a rectangular area through a clip and keep the
2//! result as a reusable project path.
3//!
4//! Pick the clip (the selection by default), place the tracker box — the Preview draws it and drags it
5//! while this pane is on screen — set its size and the search radius, then "Track forward" /
6//! "Track backward". `engine::tracking` does the matching on its own thread and this pane shows a
7//! progress bar and repaints; "Cancel" just drops the job. The result saves into `Project.paths`
8//! ("Save as path") and can be dropped straight onto the clip's X/Y keyframes ("Apply to clip"),
9//! which is the manual-tracking workflow the paths exist for.
10
11use crate::engine::tracking::TrackJob;
12use crate::media::Backend;
13use crate::model::{Id, Project};
14use crate::theme::Palette;
15use crate::ui::tools::{glyph_text_button, Glyph};
16use eframe::egui::{self, Button, DragValue};
17
18pub struct TrackState {
19    /// Clip to track; None follows the selection.
20    pub clip: Option<Id>,
21    /// Tracker box in project px relative to the canvas centre (the Preview draws and drags it).
22    pub cx: f32,
23    pub cy: f32,
24    pub hw: f32,
25    pub hh: f32,
26    /// How far from the last position the template is looked for, in px.
27    pub search: f32,
28    /// Re-grab the template every N frames (0 = never; a rigid feature drifts less without it).
29    pub refresh: u32,
30    pub name: String,
31    pub status: String,
32    job: Option<TrackJob>,
33    points: Vec<(f32, f32, f32)>,
34}
35
36impl Default for TrackState {
37    fn default() -> Self {
38        Self {
39            clip: None,
40            cx: 0.0,
41            cy: 0.0,
42            hw: 32.0,
43            hh: 32.0,
44            search: 24.0,
45            refresh: 10,
46            name: String::new(),
47            status: String::new(),
48            job: None,
49            points: Vec::new(),
50        }
51    }
52}
53
54impl TrackState {
55    /// The box the Preview should draw, in project px relative to the canvas centre.
56    pub fn box_rect(&self) -> (f32, f32, f32, f32) {
57        (self.cx, self.cy, self.hw, self.hh)
58    }
59}
60
61/// The clip being tracked: the explicit pick if it still exists, else the first visual clip selected.
62fn target(project: &Project, state: &TrackState, selection: &[Id]) -> Option<Id> {
63    state
64        .clip
65        .filter(|&id| project.clip(id).is_some())
66        .or_else(|| selection.iter().copied().find(|&id| project.clip(id).is_some_and(|c| c.is_visual())))
67}
68
69pub fn show(
70    ui: &mut egui::Ui,
71    state: &mut TrackState,
72    project: &mut Project,
73    selection: &[Id],
74    backend: Backend,
75    _palette: &Palette,
76    undo: &mut dyn FnMut(&Project),
77) -> bool {
78    let mut changed = false;
79    // drain the worker first, so the bar and the point count are this frame's truth
80    if state.job.as_mut().is_some_and(|j| j.poll()) {
81        ui.ctx().request_repaint();
82    } else if let Some(j) = state.job.take() {
83        state.status = format!("tracked {} points", j.points.len());
84        state.points = j.points;
85    }
86    let running = state.job.is_some();
87    let target = target(project, state, selection);
88
89    ui.strong("Tracking");
90    ui.horizontal(|ui| {
91        ui.label("Clip");
92        let name = target.and_then(|id| project.clip(id)).map(|c| c.name.clone()).unwrap_or_else(|| "—".into());
93        ui.monospace(name);
94        if ui.add_enabled(!running, Button::new("Use selected")).clicked() {
95            state.clip = selection.first().copied();
96        }
97    });
98    egui::Grid::new("track_params").num_columns(2).show(ui, |ui| {
99        let rows: [(&str, f32, f32); 5] = [
100            ("X", -10000.0, 10000.0),
101            ("Y", -10000.0, 10000.0),
102            ("Width", 4.0, 2000.0),
103            ("Height", 4.0, 2000.0),
104            ("Search radius", 1.0, 512.0),
105        ];
106        for (label, lo, hi) in rows {
107            ui.label(label);
108            // width/height are shown whole; the tracker keeps them as half-extents
109            let (v, half) = match label {
110                "X" => (&mut state.cx, false),
111                "Y" => (&mut state.cy, false),
112                "Width" => (&mut state.hw, true),
113                "Height" => (&mut state.hh, true),
114                _ => (&mut state.search, false),
115            };
116            let mut shown = if half { *v * 2.0 } else { *v };
117            if ui.add_enabled(!running, DragValue::new(&mut shown).range(lo..=hi).speed(1.0).suffix(" px")).changed() {
118                *v = if half { shown / 2.0 } else { shown };
119            }
120            ui.end_row();
121        }
122        ui.label("Refresh every");
123        ui.add_enabled(!running, DragValue::new(&mut state.refresh).range(0..=240).suffix(" frames"))
124            .on_hover_text("Re-grab the template that often (0 = never, best for a rigid feature)");
125        ui.end_row();
126    });
127
128    let mut go = None;
129    ui.horizontal(|ui| {
130        ui.add_enabled_ui(!running && target.is_some(), |ui| {
131            if glyph_text_button(ui, Glyph::Target, "Track forward").clicked() {
132                go = Some(false);
133            }
134            if glyph_text_button(ui, Glyph::Target, "Track backward").clicked() {
135                go = Some(true);
136            }
137        });
138        if running && ui.button("Cancel").clicked() {
139            state.job = None; // hanging up the channel stops the worker
140            state.status = "cancelled".into();
141        }
142    });
143    if let (Some(backward), Some(id)) = (go, target) {
144        state.points.clear();
145        state.status.clear();
146        match TrackJob::start(project, id, state.box_rect(), state.search, state.refresh, backward, backend) {
147            Ok(j) => state.job = Some(j),
148            Err(e) => state.status = e,
149        }
150    }
151    if let Some(j) = &state.job {
152        ui.add(egui::ProgressBar::new(j.progress).show_percentage());
153    }
154
155    let have = state.points.len() >= 2;
156    ui.separator();
157    ui.horizontal(|ui| {
158        ui.label("Name");
159        ui.add(egui::TextEdit::singleline(&mut state.name).desired_width(110.0).hint_text("Path"));
160        if ui.add_enabled(have, Button::new("Save as path")).clicked() {
161            undo(project);
162            project.add_path(state.name.clone(), state.points.clone());
163            state.status = format!("saved {} points to the project paths", state.points.len());
164            changed = true;
165        }
166        if ui.add_enabled(have && target.is_some(), Button::new("Apply to clip")).clicked() {
167            undo(project);
168            if let Some(id) = target {
169                changed = project.apply_path(id, &state.points);
170                state.status =
171                    if changed { "keyframed the clip's position".into() } else { "the track is too short".to_string() };
172            }
173        }
174    });
175    if !state.status.is_empty() {
176        ui.weak(&state.status);
177    }
178    changed
179}