1use 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 pub filter_label: u8,
17}
18
19#[derive(Default)]
20pub struct MarkersResponse {
21 pub edited: bool,
22 pub seek: Option<f64>,
23}
24
25pub(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#[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
45struct Row {
47 id: Id,
48 abs_t: f64,
50 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
71fn 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 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 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 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 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 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}