simple_editor\ui/
planner.rs

1//! Planner pane: tabs "Plan" and "Notes".
2//! Plan = a nested to-do tree (Project.plan): each row = checkbox (done), editable title, colour label dot
3//! (click → LABEL_COLORS menu), a collapse triangle, "+" add sub-task, delete, reorder among siblings,
4//! indent/outdent; a selected item shows its notes (multiline) and its moodboard: the asset ids in
5//! `assets` as rows (name, kind tag, thumbnail via ThumbCache if available, per-asset note in `asset_notes`,
6//! "Add to timeline", delete); assets are added by dropping library items (DragPayload::Asset) onto the item or
7//! onto the moodboard area. Progress bar "done/total" at the top; "Add task" at the top level;
8//! "Clear completed". Notes tab = Project.notes as a large multiline TextEdit ("describe your process,
9//! ideas, style while you edit — the style summary / AI tools read this"). Undo once per gesture.
10//! Returns what changed.
11
12use crate::media::thumbs::ThumbCache;
13use crate::model::{ClipKind, Id, PlanItem, Project, LABEL_COLORS};
14use crate::theme::Palette;
15use crate::ui::markers_ui::x_button;
16use crate::ui::tools::{glyph_text_button, Dir, Glyph};
17use crate::ui::{edit_start, label_color, once, DragPayload};
18use eframe::egui::{self, Response, RichText, TextEdit};
19
20#[derive(Default)]
21pub struct PlannerState {
22    pub tab: usize,
23    pub selected: Option<Id>,
24    pub collapsed: Vec<Id>,
25}
26
27#[derive(Default)]
28pub struct PlannerResponse {
29    pub edited: bool,
30    /// Asset ids the user asked to place on the timeline at the playhead.
31    pub add_to_timeline: Vec<Id>,
32}
33
34// ---------- pure tree helpers ----------
35
36fn find_mut(items: &mut [PlanItem], id: Id) -> Option<&mut PlanItem> {
37    for it in items {
38        if it.id == id {
39            return Some(it);
40        }
41        if let Some(f) = find_mut(&mut it.children, id) {
42            return Some(f);
43        }
44    }
45    None
46}
47
48/// Swap the item with its previous/next sibling. Returns true when the id was found (even at an edge).
49fn move_item(items: &mut Vec<PlanItem>, id: Id, delta: i32) -> bool {
50    if let Some(i) = items.iter().position(|x| x.id == id) {
51        let j = i as i32 + delta;
52        if j >= 0 && (j as usize) < items.len() {
53            items.swap(i, j as usize);
54        }
55        return true;
56    }
57    items.iter_mut().any(|it| move_item(&mut it.children, id, delta))
58}
59
60/// Make the item a child of its previous sibling.
61fn indent_item(items: &mut Vec<PlanItem>, id: Id) -> bool {
62    if let Some(i) = items.iter().position(|x| x.id == id) {
63        if i > 0 {
64            let it = items.remove(i);
65            items[i - 1].children.push(it);
66        }
67        return true;
68    }
69    items.iter_mut().any(|it| indent_item(&mut it.children, id))
70}
71
72/// Move the item next to its parent (top-level items stay).
73fn outdent_item(items: &mut Vec<PlanItem>, id: Id) -> bool {
74    for i in 0..items.len() {
75        if let Some(j) = items[i].children.iter().position(|c| c.id == id) {
76            let it = items[i].children.remove(j);
77            items.insert(i + 1, it);
78            return true;
79        }
80        if outdent_item(&mut items[i].children, id) {
81            return true;
82        }
83    }
84    false
85}
86
87/// (done, total) across the whole tree.
88fn count_items(items: &[PlanItem]) -> (usize, usize) {
89    let mut done = 0;
90    let mut total = 0;
91    for it in items {
92        total += 1;
93        done += it.done as usize;
94        let (d, t) = count_items(&it.children);
95        done += d;
96        total += t;
97    }
98    (done, total)
99}
100
101/// Remove done items (their children go with them).
102fn retain_incomplete(items: &mut Vec<PlanItem>) {
103    items.retain(|i| !i.done);
104    for i in items {
105        retain_incomplete(&mut i.children);
106    }
107}
108
109// ---------- UI ----------
110
111enum Op {
112    Add(Option<Id>),
113    Remove(Id),
114    Up(Id),
115    Down(Id),
116    In(Id),
117    Out(Id),
118    ClearDone,
119}
120
121struct TreeUi<'a> {
122    collapsed: &'a mut Vec<Id>,
123    selected: &'a mut Option<Id>,
124    op: &'a mut Option<Op>,
125    start: bool,
126    changed: bool,
127    palette: &'a Palette,
128}
129
130impl TreeUi<'_> {
131    fn note(&mut self, r: &Response) {
132        self.start |= edit_start(r);
133        self.changed |= r.changed();
134    }
135    /// Text fields: one undo entry per visit to the field, not per keystroke.
136    fn note_text(&mut self, r: &Response) {
137        self.start |= r.gained_focus();
138        self.changed |= r.changed();
139    }
140    fn click(&mut self) {
141        self.start = true;
142        self.changed = true;
143    }
144}
145
146/// Colour-dot button opening a LABEL_COLORS menu; returns the picked value.
147fn color_menu(ui: &mut egui::Ui, current: u8, palette: &Palette) -> Option<u8> {
148    let mut picked = None;
149    let chip = crate::ui::tools::color_chip(label_color(current, palette), false, palette);
150    egui::containers::menu::MenuButton::from_button(chip).ui(ui, |ui| {
151        if ui.button("None").clicked() {
152            picked = Some(0);
153            ui.close();
154        }
155        for (i, (name, [r, g, b])) in LABEL_COLORS.iter().enumerate() {
156            // the name carries its own colour — no swatch character needed
157            let t = RichText::new(*name).color(egui::Color32::from_rgb(*r, *g, *b));
158            if ui.button(t).clicked() {
159                picked = Some(i as u8 + 1);
160                ui.close();
161            }
162        }
163    });
164    picked
165}
166
167fn tree_rows(ui: &mut egui::Ui, items: &mut [PlanItem], depth: usize, t: &mut TreeUi) {
168    for it in items {
169        let closed = t.collapsed.contains(&it.id);
170        let row = ui
171            .horizontal(|ui| {
172                ui.add_space(depth as f32 * 14.0);
173                if it.children.is_empty() {
174                    ui.add_space(18.0);
175                } else if glyph_text_button(ui, Glyph::Tri(if closed { Dir::Right } else { Dir::Down }), "").clicked() {
176                    if closed {
177                        t.collapsed.retain(|&c| c != it.id);
178                    } else {
179                        t.collapsed.push(it.id);
180                    }
181                }
182                let r = ui.checkbox(&mut it.done, "");
183                t.note(&r);
184                if let Some(c) = color_menu(ui, it.color, t.palette) {
185                    it.color = c;
186                    t.click();
187                }
188                let w = (ui.available_width() - 150.0).max(60.0);
189                let r = ui.add(TextEdit::singleline(&mut it.title).desired_width(w).hint_text("task"));
190                if r.has_focus() {
191                    *t.selected = Some(it.id);
192                }
193                t.note_text(&r);
194                let mut op = |o: Op| *t.op = Some(o);
195                if ui.small_button("+").on_hover_text("Add sub-task").clicked() {
196                    op(Op::Add(Some(it.id)));
197                }
198                if glyph_text_button(ui, Glyph::Tri(Dir::Up), "").on_hover_text("Move up").clicked() {
199                    op(Op::Up(it.id));
200                }
201                if glyph_text_button(ui, Glyph::Tri(Dir::Down), "").on_hover_text("Move down").clicked() {
202                    op(Op::Down(it.id));
203                }
204                if glyph_text_button(ui, Glyph::Indent(false), "").on_hover_text("Outdent").clicked() {
205                    op(Op::Out(it.id));
206                }
207                if glyph_text_button(ui, Glyph::Indent(true), "").on_hover_text("Indent").clicked() {
208                    op(Op::In(it.id));
209                }
210                if x_button(ui).on_hover_text("Delete task").clicked() {
211                    op(Op::Remove(it.id));
212                }
213            })
214            .response;
215        // drop a library asset onto the row → moodboard
216        if let Some(p) = row.dnd_release_payload::<DragPayload>() {
217            if let DragPayload::Asset(aid) = *p {
218                if !it.assets.contains(&aid) {
219                    it.assets.push(aid);
220                    t.click();
221                }
222                *t.selected = Some(it.id);
223            }
224        }
225        if !closed {
226            tree_rows(ui, &mut it.children, depth + 1, t);
227        }
228    }
229}
230
231pub fn show(
232    ui: &mut egui::Ui,
233    state: &mut PlannerState,
234    project: &mut Project,
235    thumbs: &mut ThumbCache,
236    palette: &Palette,
237    undo: &mut dyn FnMut(&Project),
238) -> PlannerResponse {
239    let mut resp = PlannerResponse::default();
240    let mut undone = false;
241    ui.horizontal(|ui| {
242        ui.selectable_value(&mut state.tab, 0, "Plan");
243        ui.selectable_value(&mut state.tab, 1, "Notes");
244    });
245    ui.separator();
246
247    if state.tab == 1 {
248        let mut notes = project.notes.clone();
249        let r = ui.add_sized(
250            ui.available_size(),
251            TextEdit::multiline(&mut notes).hint_text(
252                "Describe your process, ideas and style while you edit — the style summary / AI tools read this.",
253            ),
254        );
255        // one undo entry per visit to the notes field, not per keystroke
256        if r.gained_focus() {
257            once(&mut undone, undo, project);
258        }
259        if r.changed() {
260            project.notes = notes;
261            resp.edited = true;
262        }
263        return resp;
264    }
265
266    let (done, total) = count_items(&project.plan);
267    ui.horizontal(|ui| {
268        if total > 0 {
269            ui.add(
270                egui::ProgressBar::new(done as f32 / total as f32).desired_width(120.0).text(format!("{done}/{total}")),
271            );
272        }
273        ui.label("");
274    });
275
276    // ponytail: per-frame deep clone of the task tree, written back on change — keeps `undo` able to
277    // snapshot the untouched project. Upgrade: drive tree_rows from &mut project.plan if a big plan shows.
278    let mut plan = project.plan.clone();
279    let mut op: Option<Op> = None;
280    let mut t = TreeUi {
281        collapsed: &mut state.collapsed,
282        selected: &mut state.selected,
283        op: &mut op,
284        start: false,
285        changed: false,
286        palette,
287    };
288    ui.horizontal(|ui| {
289        if ui.button("Add task").clicked() {
290            *t.op = Some(Op::Add(None));
291        }
292        if done > 0 && ui.button("Clear completed").clicked() {
293            *t.op = Some(Op::ClearDone);
294        }
295    });
296    egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
297        tree_rows(ui, &mut plan, 0, &mut t);
298        if let Some(sel) = *t.selected {
299            let (start, changed) = details(ui, &mut plan, sel, project, thumbs, &mut resp);
300            t.start |= start;
301            t.changed |= changed;
302        }
303    });
304
305    if t.start {
306        once(&mut undone, undo, project);
307    }
308    if t.changed {
309        project.plan = plan;
310        resp.edited = true;
311    }
312    if let Some(op) = op {
313        once(&mut undone, undo, project);
314        match op {
315            Op::Add(parent) => state.selected = Some(project.plan_add(parent, "Task")),
316            Op::Remove(id) => {
317                project.plan_remove(id);
318                if state.selected == Some(id) {
319                    state.selected = None;
320                }
321            }
322            Op::Up(id) => {
323                move_item(&mut project.plan, id, -1);
324            }
325            Op::Down(id) => {
326                move_item(&mut project.plan, id, 1);
327            }
328            Op::In(id) => {
329                indent_item(&mut project.plan, id);
330            }
331            Op::Out(id) => {
332                outdent_item(&mut project.plan, id);
333            }
334            Op::ClearDone => retain_incomplete(&mut project.plan),
335        }
336        resp.edited = true;
337    }
338    resp
339}
340
341/// Notes + moodboard of the selected item (edits the plan clone). Returns (gesture start, changed).
342fn details(
343    ui: &mut egui::Ui,
344    plan: &mut [PlanItem],
345    sel: Id,
346    project: &Project,
347    thumbs: &mut ThumbCache,
348    resp: &mut PlannerResponse,
349) -> (bool, bool) {
350    let Some(it) = find_mut(plan, sel) else { return (false, false) };
351    let mut start = false;
352    let mut changed = false;
353    ui.separator();
354    let r = ui.add(TextEdit::multiline(&mut it.notes).desired_rows(2).desired_width(f32::INFINITY).hint_text("notes"));
355    start |= r.gained_focus();
356    changed |= r.changed();
357    while it.asset_notes.len() < it.assets.len() {
358        it.asset_notes.push(String::new());
359    }
360    let mut rm: Option<usize> = None;
361    let (frame_r, payload) = ui.dnd_drop_zone::<DragPayload, ()>(egui::Frame::group(ui.style()), |ui| {
362        ui.weak("Moodboard — drop library assets here");
363        for i in 0..it.assets.len() {
364            let aid = it.assets[i];
365            ui.horizontal(|ui| {
366                let Some(a) = project.asset(aid) else {
367                    ui.weak("(missing asset)");
368                    return;
369                };
370                if a.has_video() {
371                    if let Some((tex, [w, h])) = thumbs.texture(ui.ctx(), &a.path, 0.0, 40) {
372                        let size = egui::vec2(w as f32, h as f32);
373                        ui.add(egui::Image::new(egui::load::SizedTexture::new(tex, size)));
374                    }
375                }
376                ui.label(a.name());
377                ui.weak(match a.kind {
378                    ClipKind::Video => "V",
379                    ClipKind::Audio => "A",
380                    ClipKind::Image => "I",
381                    _ => "?",
382                });
383                if ui.small_button("Add to timeline").clicked() {
384                    resp.add_to_timeline.push(aid);
385                }
386                if x_button(ui).on_hover_text("Remove from moodboard").clicked() {
387                    rm = Some(i);
388                }
389            });
390            let r = ui.add(TextEdit::singleline(&mut it.asset_notes[i]).desired_width(f32::INFINITY).hint_text("note"));
391            start |= r.gained_focus();
392            changed |= r.changed();
393        }
394    });
395    let _ = frame_r;
396    if let Some(p) = payload {
397        if let DragPayload::Asset(aid) = *p {
398            if !it.assets.contains(&aid) {
399                it.assets.push(aid);
400                it.asset_notes.push(String::new());
401                start = true;
402                changed = true;
403            }
404        }
405    }
406    if let Some(i) = rm {
407        it.assets.remove(i);
408        if i < it.asset_notes.len() {
409            it.asset_notes.remove(i);
410        }
411        start = true;
412        changed = true;
413    }
414    (start, changed)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn item(id: Id, done: bool, children: Vec<PlanItem>) -> PlanItem {
422        PlanItem { id, title: format!("t{id}"), done, children, ..Default::default() }
423    }
424
425    #[test]
426    fn add_check_remove_via_project() {
427        let mut p = Project::new();
428        let a = p.plan_add(None, "Intro");
429        let b = p.plan_add(Some(a), "Hook");
430        assert_eq!(count_items(&p.plan), (0, 2));
431        p.plan_item_mut(b).unwrap().done = true;
432        assert_eq!(count_items(&p.plan), (1, 2));
433        retain_incomplete(&mut p.plan);
434        assert_eq!(count_items(&p.plan), (0, 1));
435        p.plan_remove(a);
436        assert!(p.plan.is_empty());
437    }
438
439    #[test]
440    fn reorder_indent_outdent() {
441        let mut items =
442            vec![item(1, false, vec![]), item(2, false, vec![item(3, false, vec![])]), item(4, false, vec![])];
443        // move 4 up: [1, 4, 2]
444        assert!(move_item(&mut items, 4, -1));
445        assert_eq!(items[1].id, 4);
446        // edges stay put
447        assert!(move_item(&mut items, 1, -1));
448        assert_eq!(items[0].id, 1);
449        // nested move found
450        assert!(move_item(&mut items, 3, 1));
451        // indent 4 under 1
452        assert!(move_item(&mut items, 4, 1)); // back to [1, 2, 4]
453        assert!(indent_item(&mut items, 4));
454        assert_eq!(items.len(), 2);
455        assert_eq!(items[1].children.last().unwrap().id, 4);
456        // outdent 3 next to its parent 2
457        assert!(outdent_item(&mut items, 3));
458        assert_eq!(items[2].id, 3);
459        // outdent a top-level item: not found in any parent
460        assert!(!outdent_item(&mut items, 1));
461        // first sibling can't indent
462        assert!(indent_item(&mut items, 1));
463        assert_eq!(items[0].id, 1);
464    }
465
466    /// Headless: both tabs lay out; no edits reported without interaction.
467    #[test]
468    fn show_headless() {
469        let mut p = Project::new();
470        let a = p.plan_add(None, "Intro");
471        p.plan_add(Some(a), "Hook");
472        let palette = Palette::new(true, egui::Color32::WHITE);
473        let ctx = egui::Context::default();
474        let mut thumbs = ThumbCache::new(ctx.clone(), crate::media::Backend::Auto);
475        for tab in [0, 1] {
476            let mut state = PlannerState { tab, ..Default::default() };
477            for _ in 0..2 {
478                let _ = ctx.run(egui::RawInput::default(), |ctx| {
479                    egui::CentralPanel::default().show(ctx, |ui| {
480                        let mut undo = |_: &Project| panic!("no undo without edits");
481                        let r = show(ui, &mut state, &mut p, &mut thumbs, &palette, &mut undo);
482                        assert!(!r.edited && r.add_to_timeline.is_empty());
483                    });
484                });
485            }
486        }
487        assert_eq!(count_items(&p.plan), (0, 2));
488    }
489}