simple_editor\ui/
markers_ui.rs

1//! Markers pane: every marker in timeline order (project markers and clip markers together, via
2//! `Project::markers_in_timeline`). Rows: colour label dot (menu), time (editable DragValue, seconds),
3//! duration for ranges, name, note (multiline when the row is selected), "Go" (seek) and X (delete).
4//! Toolbar: "Add at playhead" (M), "Add on selected clip", filter by label, and "Copy as list" (markdown,
5//! handy for the AI tools). Undo once per gesture; returns what changed.
6
7use crate::model::{Id, Project};
8use crate::theme::Palette;
9use crate::ui::{edit_start, timecode};
10use eframe::egui::{self, DragValue, Response, RichText};
11
12#[derive(Default)]
13pub struct MarkersState {
14    pub selected: Option<Id>,
15    /// 0 = every label.
16    pub filter_label: u8,
17}
18
19#[derive(Default)]
20pub struct MarkersResponse {
21    pub edited: bool,
22    pub seek: Option<f64>,
23}
24
25/// The delete button used everywhere (planner, inspector, effects, mixer, …): a painted cross that
26/// turns red on hover.
27pub(crate) fn x_button(ui: &mut egui::Ui) -> Response {
28    let r = crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::Cross, "");
29    if r.hovered() {
30        let c = egui::Color32::from_rgb(220, 70, 70);
31        ui.painter().rect_stroke(r.rect, 2.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside);
32        crate::ui::tools::draw_glyph(ui.painter(), r.rect, crate::ui::tools::Glyph::Cross, c);
33    }
34    r
35}
36
37/// Test-only: remember a widget rect so headless tests can click the real button.
38#[cfg(test)]
39fn mark(ui: &egui::Ui, name: impl std::fmt::Display, r: &Response) {
40    ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("mk", name.to_string())), r.rect));
41}
42#[cfg(not(test))]
43fn mark(_ui: &egui::Ui, _name: impl std::fmt::Display, _r: &Response) {}
44
45/// Where a marker lives: the project timeline, or a clip (with its start offset).
46struct Row {
47    id: Id,
48    /// Timeline seconds.
49    abs_t: f64,
50    /// 0 for project markers.
51    offset: f64,
52    clip: Option<Id>,
53}
54
55fn rows(project: &Project, filter: u8) -> Vec<Row> {
56    let mut v: Vec<Row> = project
57        .markers
58        .iter()
59        .filter(|m| filter == 0 || m.label == filter)
60        .map(|m| Row { id: m.id, abs_t: m.t, offset: 0.0, clip: None })
61        .collect();
62    for (_, c) in project.all_clips() {
63        for m in c.markers.iter().filter(|m| filter == 0 || m.label == filter) {
64            v.push(Row { id: m.id, abs_t: c.start + m.t, offset: c.start, clip: Some(c.id) });
65        }
66    }
67    v.sort_by(|a, b| a.abs_t.total_cmp(&b.abs_t));
68    v
69}
70
71/// Markdown list of every marker in timeline order (for notes / the AI tools).
72fn as_markdown(project: &Project, fps: f64) -> String {
73    let mut s = String::new();
74    for (_, t, dur, name, _) in project.markers_in_timeline() {
75        let name = if name.is_empty() { "(marker)".to_string() } else { name };
76        if dur > 0.0 {
77            s.push_str(&format!("- {} → {}  {name}\n", timecode(t, fps), timecode(t + dur, fps)));
78        } else {
79            s.push_str(&format!("- {}  {name}\n", timecode(t, fps)));
80        }
81    }
82    s
83}
84
85pub fn show(
86    ui: &mut egui::Ui,
87    state: &mut MarkersState,
88    project: &mut Project,
89    selection: &[Id],
90    playhead: f64,
91    palette: &Palette,
92    undo: &mut dyn FnMut(&Project),
93) -> MarkersResponse {
94    let mut out = MarkersResponse::default();
95    let fps = project.fps;
96    let labels: Vec<(String, [u8; 3])> = project.labels.iter().map(|l| (l.name.clone(), l.color)).collect();
97
98    // ---- toolbar ----
99    let sel_clip = selection.iter().copied().find(|&id| project.clip(id).is_some());
100    ui.horizontal_wrapped(|ui| {
101        let r = ui.button("Add at playhead").on_hover_text("M");
102        mark(ui, "add", &r);
103        if r.clicked() {
104            undo(project);
105            state.selected = Some(project.add_marker(playhead, ""));
106            out.edited = true;
107        }
108        let on_clip = sel_clip.is_some();
109        if ui
110            .add_enabled(on_clip, egui::Button::new("Add on selected clip"))
111            .on_disabled_hover_text("Select a clip first")
112            .clicked()
113        {
114            if let Some(cid) = sel_clip {
115                let local = project.clip(cid).map(|c| (playhead - c.start).clamp(0.0, c.duration)).unwrap_or(0.0);
116                undo(project);
117                state.selected = project.add_clip_marker(cid, local, "");
118                out.edited = true;
119            }
120        }
121        egui::ComboBox::from_id_salt("marker_filter")
122            .selected_text(if state.filter_label == 0 {
123                "All labels".to_string()
124            } else {
125                project.label_name(state.filter_label).to_string()
126            })
127            .width(110.0)
128            .show_ui(ui, |ui| {
129                ui.selectable_value(&mut state.filter_label, 0, "All labels");
130                for (i, (name, [r, g, b])) in labels.iter().enumerate() {
131                    // the name is tinted with its own colour — no swatch character needed
132                    let t = RichText::new(name.clone()).color(egui::Color32::from_rgb(*r, *g, *b));
133                    ui.selectable_value(&mut state.filter_label, i as u8 + 1, t);
134                }
135            });
136        if ui.button("Copy as list").on_hover_text("Markdown, for notes and the AI tools").clicked() {
137            ui.ctx().copy_text(as_markdown(project, fps));
138        }
139    });
140    ui.separator();
141
142    let rows = rows(project, state.filter_label);
143    if rows.is_empty() {
144        ui.weak("No markers — press M to add one at the playhead");
145        return out;
146    }
147
148    let mut remove: Option<Id> = None;
149    egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
150        for row in &rows {
151            // ponytail: edit a clone of the one marker and write it back — lets `undo` snapshot the
152            // untouched project without holding a mutable borrow across the widgets.
153            let Some(orig) = project.marker_mut(row.id).map(|m| m.clone()) else { continue };
154            let mut m = orig.clone();
155            let selected = state.selected == Some(row.id);
156            let (mut start, mut changed) = (false, false);
157            ui.horizontal(|ui| {
158                // label dot + menu
159                let color = match project.label_color(m.label) {
160                    Some([r, g, b]) => egui::Color32::from_rgb(r, g, b),
161                    None => palette.text_dim,
162                };
163                let dot = ui.add(crate::ui::tools::color_chip(color, false, palette));
164                dot.context_menu(|ui| {
165                    if ui.selectable_label(m.label == 0, "None").clicked() {
166                        m.label = 0;
167                        start = true;
168                        changed = true;
169                        ui.close();
170                    }
171                    for (i, (name, [r, g, b])) in labels.iter().enumerate() {
172                        let t = RichText::new(name.clone()).color(egui::Color32::from_rgb(*r, *g, *b));
173                        if ui.selectable_label(m.label == i as u8 + 1, t).clicked() {
174                            m.label = i as u8 + 1;
175                            start = true;
176                            changed = true;
177                            ui.close();
178                        }
179                    }
180                });
181                let mut t = m.t;
182                let r = ui.add(DragValue::new(&mut t).speed(0.05).range(0.0..=1e6).suffix(" s").fixed_decimals(2));
183                if r.changed() {
184                    m.t = t;
185                }
186                start |= edit_start(&r);
187                changed |= r.changed();
188                if m.duration > 0.0 {
189                    let r = ui.add(DragValue::new(&mut m.duration).speed(0.05).range(0.0..=1e6).prefix("+"));
190                    start |= edit_start(&r);
191                    changed |= r.changed();
192                }
193                let w = (ui.available_width() - 70.0).max(60.0);
194                let r = ui.add(egui::TextEdit::singleline(&mut m.name).desired_width(w).hint_text("marker"));
195                if r.has_focus() || r.clicked() {
196                    state.selected = Some(row.id);
197                }
198                start |= r.gained_focus();
199                changed |= r.changed();
200                let go = ui.small_button("Go").on_hover_text(timecode(row.abs_t, fps));
201                mark(ui, format_args!("go{}", row.id), &go);
202                if go.clicked() {
203                    state.selected = Some(row.id);
204                    out.seek = Some(m.t + row.offset);
205                }
206                let del = x_button(ui).on_hover_text("Delete marker");
207                mark(ui, format_args!("del{}", row.id), &del);
208                if del.clicked() {
209                    remove = Some(row.id);
210                }
211            });
212            if selected {
213                let r = ui.add(
214                    egui::TextEdit::multiline(&mut m.note)
215                        .desired_rows(2)
216                        .desired_width(f32::INFINITY)
217                        .hint_text("note"),
218                );
219                start |= r.gained_focus();
220                changed |= r.changed();
221            }
222            if start {
223                undo(project);
224            }
225            if changed {
226                if row.clip.is_some() {
227                    // clip markers stay inside their clip
228                    if let Some(c) = row.clip.and_then(|cid| project.clip(cid)) {
229                        m.t = m.t.clamp(0.0, c.duration);
230                    }
231                }
232                if let Some(dst) = project.marker_mut(row.id) {
233                    *dst = m;
234                }
235                out.edited = true;
236            }
237        }
238    });
239
240    if let Some(id) = remove {
241        undo(project);
242        project.remove_marker(id);
243        if state.selected == Some(id) {
244            state.selected = None;
245        }
246        out.edited = true;
247    }
248    out
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::model::{Clip, ClipKind};
255    use eframe::egui::{Color32, Event, Modifiers, PointerButton, Pos2, RawInput, Rect, Vec2};
256
257    struct H {
258        ctx: egui::Context,
259        project: Project,
260        state: MarkersState,
261        selection: Vec<Id>,
262        undos: usize,
263        time: f64,
264    }
265
266    impl H {
267        fn new() -> Self {
268            let mut project = Project::new();
269            project.tracks[0].clips.push(Clip::new(9, ClipKind::Video, "v", 2.0, 4.0));
270            Self {
271                ctx: egui::Context::default(),
272                project,
273                state: MarkersState::default(),
274                selection: vec![9],
275                undos: 0,
276                time: 0.0,
277            }
278        }
279        fn frame(&mut self, events: Vec<Event>) -> MarkersResponse {
280            self.time += 0.05;
281            let input = RawInput {
282                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(520.0, 600.0))),
283                time: Some(self.time),
284                events,
285                ..Default::default()
286            };
287            let pal = Palette::new(true, Color32::WHITE);
288            let H { ctx, project, state, selection, undos, .. } = self;
289            let mut out = MarkersResponse::default();
290            let _ = ctx.run(input, |ctx| {
291                egui::CentralPanel::default().show(ctx, |ui| {
292                    let mut undo = |_: &Project| *undos += 1;
293                    out = show(ui, state, project, selection, 3.0, &pal, &mut undo);
294                });
295            });
296            out
297        }
298        fn rect(&self, name: &str) -> Rect {
299            self.ctx
300                .data(|d| d.get_temp::<Rect>(egui::Id::new(("mk", name.to_string()))))
301                .unwrap_or_else(|| panic!("no widget rect recorded for {name}"))
302        }
303        fn click(&mut self, pos: Pos2) -> MarkersResponse {
304            self.frame(vec![Event::PointerMoved(pos)]);
305            self.frame(vec![Event::PointerButton {
306                pos,
307                button: PointerButton::Primary,
308                pressed: true,
309                modifiers: Modifiers::NONE,
310            }]);
311            let out = self.frame(vec![Event::PointerButton {
312                pos,
313                button: PointerButton::Primary,
314                pressed: false,
315                modifiers: Modifiers::NONE,
316            }]);
317            self.frame(vec![]);
318            out
319        }
320    }
321
322    #[test]
323    fn add_seek_and_delete() {
324        let mut h = H::new();
325        h.frame(vec![]);
326        let add = h.rect("add");
327        h.click(add.center());
328        assert_eq!(h.project.markers.len(), 1, "Add at playhead adds a marker");
329        assert_eq!(h.project.markers[0].t, 3.0);
330        assert_eq!(h.undos, 1, "one undo per gesture");
331
332        let id = h.project.markers[0].id;
333        h.frame(vec![]);
334        let go = h.rect(&format!("go{id}"));
335        let out = h.click(go.center());
336        assert_eq!(out.seek, Some(3.0), "Go seeks to the marker time");
337
338        h.frame(vec![]);
339        let del = h.rect(&format!("del{id}"));
340        h.click(del.center());
341        assert!(h.project.markers.is_empty(), "the X removes the marker");
342        assert_eq!(h.undos, 2, "delete is one more undo");
343    }
344
345    #[test]
346    fn clip_markers_are_offset_and_filtered() {
347        let mut p = Project::new();
348        p.tracks[0].clips.push(Clip::new(9, ClipKind::Video, "v", 2.0, 4.0));
349        p.add_marker(3.0, "project");
350        p.add_clip_marker(9, 1.0, "on clip");
351        let r = rows(&p, 0);
352        assert_eq!(r.len(), 2);
353        assert!(r.iter().all(|x| x.abs_t == 3.0), "both land at 3 s");
354        let clip_row = r.iter().find(|x| x.clip.is_some()).expect("clip marker");
355        assert_eq!(clip_row.offset, 2.0, "clip markers carry their clip start");
356        p.markers[0].label = 2;
357        assert_eq!(rows(&p, 2).len(), 1);
358        assert_eq!(rows(&p, 1).len(), 0);
359        assert_eq!(rows(&p, 0).len(), 2);
360    }
361
362    #[test]
363    fn markdown_list_has_a_line_per_marker() {
364        let mut p = Project::new();
365        p.add_marker(1.0, "a");
366        p.add_marker(2.5, "");
367        let md = as_markdown(&p, 30.0);
368        assert_eq!(md.lines().count(), 2);
369        assert!(md.contains(" a"), "{md}");
370        assert!(md.contains("(marker)"), "{md}");
371    }
372
373    #[test]
374    fn empty_pane_is_harmless() {
375        let mut h = H::new();
376        let out = h.frame(vec![]);
377        assert!(!out.edited && out.seek.is_none());
378        assert_eq!(h.undos, 0);
379    }
380}