1use 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 pub add_to_timeline: Vec<Id>,
32}
33
34fn 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
48fn 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
60fn 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
72fn 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
87fn 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
101fn 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
109enum 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 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
146fn 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 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 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 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 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
341fn 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 assert!(move_item(&mut items, 4, -1));
445 assert_eq!(items[1].id, 4);
446 assert!(move_item(&mut items, 1, -1));
448 assert_eq!(items[0].id, 1);
449 assert!(move_item(&mut items, 3, 1));
451 assert!(move_item(&mut items, 4, 1)); assert!(indent_item(&mut items, 4));
454 assert_eq!(items.len(), 2);
455 assert_eq!(items[1].children.last().unwrap().id, 4);
456 assert!(outdent_item(&mut items, 3));
458 assert_eq!(items[2].id, 3);
459 assert!(!outdent_item(&mut items, 1));
461 assert!(indent_item(&mut items, 1));
463 assert_eq!(items[0].id, 1);
464 }
465
466 #[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}