1#![allow(dead_code)]
8
9use std::collections::{HashMap, HashSet};
10use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct GroupId(pub u64);
19
20impl fmt::Display for GroupId {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 write!(f, "group-{}", self.0)
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum GroupBehavior {
29 Locked,
31 Relative,
33 Loose,
35}
36
37impl fmt::Display for GroupBehavior {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 Self::Locked => write!(f, "locked"),
41 Self::Relative => write!(f, "relative"),
42 Self::Loose => write!(f, "loose"),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct EditGroup {
50 pub id: GroupId,
52 pub name: String,
54 pub members: HashSet<u64>,
56 pub behavior: GroupBehavior,
58 pub locked: bool,
60 pub color: Option<u32>,
62}
63
64impl EditGroup {
65 pub fn new(id: GroupId, name: impl Into<String>) -> Self {
67 Self {
68 id,
69 name: name.into(),
70 members: HashSet::new(),
71 behavior: GroupBehavior::Locked,
72 locked: false,
73 color: None,
74 }
75 }
76
77 #[must_use]
79 pub fn with_behavior(mut self, behavior: GroupBehavior) -> Self {
80 self.behavior = behavior;
81 self
82 }
83
84 #[must_use]
86 pub fn with_color(mut self, rgba: u32) -> Self {
87 self.color = Some(rgba);
88 self
89 }
90
91 pub fn add_member(&mut self, clip_id: u64) -> bool {
93 self.members.insert(clip_id)
94 }
95
96 pub fn remove_member(&mut self, clip_id: u64) -> bool {
98 self.members.remove(&clip_id)
99 }
100
101 #[must_use]
103 pub fn contains(&self, clip_id: u64) -> bool {
104 self.members.contains(&clip_id)
105 }
106
107 #[must_use]
109 pub fn member_count(&self) -> usize {
110 self.members.len()
111 }
112
113 #[must_use]
115 pub fn is_empty(&self) -> bool {
116 self.members.is_empty()
117 }
118}
119
120#[derive(Debug, Clone, PartialEq)]
126pub enum BatchOp {
127 MoveBy(i64),
129 ScaleDuration(f64),
131 SetOpacity(f64),
133 TrimInBy(i64),
135 TrimOutBy(i64),
137 SetMute(bool),
139 Delete,
141}
142
143impl fmt::Display for BatchOp {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Self::MoveBy(d) => write!(f, "move by {d}"),
147 Self::ScaleDuration(s) => write!(f, "scale duration x{s}"),
148 Self::SetOpacity(o) => write!(f, "opacity {o}"),
149 Self::TrimInBy(d) => write!(f, "trim in by {d}"),
150 Self::TrimOutBy(d) => write!(f, "trim out by {d}"),
151 Self::SetMute(m) => write!(f, "mute={m}"),
152 Self::Delete => write!(f, "delete"),
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
159pub struct BatchResult {
160 pub affected: usize,
162 pub skipped: usize,
164 pub errors: HashMap<u64, String>,
166}
167
168impl BatchResult {
169 #[must_use]
171 pub fn new() -> Self {
172 Self {
173 affected: 0,
174 skipped: 0,
175 errors: HashMap::new(),
176 }
177 }
178
179 #[must_use]
181 pub fn is_ok(&self) -> bool {
182 self.errors.is_empty()
183 }
184
185 #[must_use]
187 pub fn has_errors(&self) -> bool {
188 !self.errors.is_empty()
189 }
190}
191
192impl Default for BatchResult {
193 fn default() -> Self {
194 Self::new()
195 }
196}
197
198fn next_group_id() -> GroupId {
204 use std::sync::atomic::{AtomicU64, Ordering};
205 static CTR: AtomicU64 = AtomicU64::new(1);
206 GroupId(CTR.fetch_add(1, Ordering::Relaxed))
207}
208
209#[derive(Debug, Clone)]
211pub struct GroupEditRegistry {
212 groups: HashMap<GroupId, EditGroup>,
214 clip_to_groups: HashMap<u64, HashSet<GroupId>>,
216}
217
218impl GroupEditRegistry {
219 #[must_use]
221 pub fn new() -> Self {
222 Self {
223 groups: HashMap::new(),
224 clip_to_groups: HashMap::new(),
225 }
226 }
227
228 pub fn create_group(&mut self, name: impl Into<String>) -> GroupId {
230 let id = next_group_id();
231 let group = EditGroup::new(id, name);
232 self.groups.insert(id, group);
233 id
234 }
235
236 pub fn delete_group(&mut self, id: GroupId) -> Option<EditGroup> {
238 if let Some(group) = self.groups.remove(&id) {
239 for &clip_id in &group.members {
240 if let Some(set) = self.clip_to_groups.get_mut(&clip_id) {
241 set.remove(&id);
242 }
243 }
244 Some(group)
245 } else {
246 None
247 }
248 }
249
250 #[must_use]
252 pub fn get(&self, id: GroupId) -> Option<&EditGroup> {
253 self.groups.get(&id)
254 }
255
256 pub fn get_mut(&mut self, id: GroupId) -> Option<&mut EditGroup> {
258 self.groups.get_mut(&id)
259 }
260
261 pub fn add_clip_to_group(&mut self, group_id: GroupId, clip_id: u64) -> bool {
263 let added = self
264 .groups
265 .get_mut(&group_id)
266 .is_some_and(|g| g.add_member(clip_id));
267 if added {
268 self.clip_to_groups
269 .entry(clip_id)
270 .or_default()
271 .insert(group_id);
272 }
273 added
274 }
275
276 pub fn remove_clip_from_group(&mut self, group_id: GroupId, clip_id: u64) -> bool {
278 let removed = self
279 .groups
280 .get_mut(&group_id)
281 .is_some_and(|g| g.remove_member(clip_id));
282 if removed {
283 if let Some(set) = self.clip_to_groups.get_mut(&clip_id) {
284 set.remove(&group_id);
285 }
286 }
287 removed
288 }
289
290 pub fn groups_for_clip(&self, clip_id: u64) -> Vec<GroupId> {
292 self.clip_to_groups
293 .get(&clip_id)
294 .map_or_else(Vec::new, |set| set.iter().copied().collect())
295 }
296
297 #[must_use]
299 pub fn group_count(&self) -> usize {
300 self.groups.len()
301 }
302
303 #[must_use]
305 pub fn is_empty(&self) -> bool {
306 self.groups.is_empty()
307 }
308
309 #[must_use]
315 pub fn plan_batch_op(&self, group_id: GroupId, _op: &BatchOp) -> BatchResult {
316 let mut result = BatchResult::new();
317 let group = match self.groups.get(&group_id) {
318 Some(g) => g,
319 None => return result,
320 };
321 if group.locked {
322 result.skipped = group.member_count();
323 return result;
324 }
325 result.affected = group.member_count();
326 result
327 }
328}
329
330impl Default for GroupEditRegistry {
331 fn default() -> Self {
332 Self::new()
333 }
334}
335
336#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn test_group_id_display() {
346 assert_eq!(GroupId(42).to_string(), "group-42");
347 }
348
349 #[test]
350 fn test_group_behavior_display() {
351 assert_eq!(GroupBehavior::Locked.to_string(), "locked");
352 assert_eq!(GroupBehavior::Relative.to_string(), "relative");
353 assert_eq!(GroupBehavior::Loose.to_string(), "loose");
354 }
355
356 #[test]
357 fn test_edit_group_new() {
358 let g = EditGroup::new(GroupId(1), "My Group");
359 assert_eq!(g.name, "My Group");
360 assert!(g.is_empty());
361 assert_eq!(g.behavior, GroupBehavior::Locked);
362 }
363
364 #[test]
365 fn test_edit_group_add_remove_member() {
366 let mut g = EditGroup::new(GroupId(1), "g");
367 assert!(g.add_member(10));
368 assert!(!g.add_member(10)); assert_eq!(g.member_count(), 1);
370 assert!(g.contains(10));
371 assert!(g.remove_member(10));
372 assert!(!g.remove_member(10)); assert!(g.is_empty());
374 }
375
376 #[test]
377 fn test_edit_group_builders() {
378 let g = EditGroup::new(GroupId(1), "g")
379 .with_behavior(GroupBehavior::Loose)
380 .with_color(0xFF0000FF);
381 assert_eq!(g.behavior, GroupBehavior::Loose);
382 assert_eq!(g.color, Some(0xFF0000FF));
383 }
384
385 #[test]
386 fn test_batch_op_display() {
387 assert_eq!(BatchOp::MoveBy(-100).to_string(), "move by -100");
388 assert_eq!(BatchOp::Delete.to_string(), "delete");
389 assert_eq!(BatchOp::SetMute(true).to_string(), "mute=true");
390 }
391
392 #[test]
393 fn test_batch_result_default() {
394 let r = BatchResult::default();
395 assert!(r.is_ok());
396 assert!(!r.has_errors());
397 assert_eq!(r.affected, 0);
398 }
399
400 #[test]
401 fn test_batch_result_with_errors() {
402 let mut r = BatchResult::new();
403 r.errors.insert(1, "locked".to_string());
404 assert!(r.has_errors());
405 assert!(!r.is_ok());
406 }
407
408 #[test]
409 fn test_registry_create_delete() {
410 let mut reg = GroupEditRegistry::new();
411 let gid = reg.create_group("Test");
412 assert_eq!(reg.group_count(), 1);
413 assert!(reg.delete_group(gid).is_some());
414 assert!(reg.is_empty());
415 }
416
417 #[test]
418 fn test_registry_add_clip_to_group() {
419 let mut reg = GroupEditRegistry::new();
420 let gid = reg.create_group("G1");
421 assert!(reg.add_clip_to_group(gid, 100));
422 assert!(reg.get(gid).expect("get should succeed").contains(100));
423 }
424
425 #[test]
426 fn test_registry_remove_clip_from_group() {
427 let mut reg = GroupEditRegistry::new();
428 let gid = reg.create_group("G1");
429 reg.add_clip_to_group(gid, 100);
430 assert!(reg.remove_clip_from_group(gid, 100));
431 assert!(!reg.get(gid).expect("get should succeed").contains(100));
432 }
433
434 #[test]
435 fn test_registry_groups_for_clip() {
436 let mut reg = GroupEditRegistry::new();
437 let g1 = reg.create_group("A");
438 let g2 = reg.create_group("B");
439 reg.add_clip_to_group(g1, 5);
440 reg.add_clip_to_group(g2, 5);
441 let groups = reg.groups_for_clip(5);
442 assert_eq!(groups.len(), 2);
443 }
444
445 #[test]
446 fn test_registry_plan_batch_op() {
447 let mut reg = GroupEditRegistry::new();
448 let gid = reg.create_group("G");
449 reg.add_clip_to_group(gid, 1);
450 reg.add_clip_to_group(gid, 2);
451 let result = reg.plan_batch_op(gid, &BatchOp::MoveBy(50));
452 assert_eq!(result.affected, 2);
453 assert!(result.is_ok());
454 }
455
456 #[test]
457 fn test_registry_plan_batch_op_locked() {
458 let mut reg = GroupEditRegistry::new();
459 let gid = reg.create_group("Locked");
460 reg.add_clip_to_group(gid, 1);
461 reg.get_mut(gid).expect("get_mut should succeed").locked = true;
462 let result = reg.plan_batch_op(gid, &BatchOp::Delete);
463 assert_eq!(result.affected, 0);
464 assert_eq!(result.skipped, 1);
465 }
466
467 #[test]
468 fn test_registry_default() {
469 let reg = GroupEditRegistry::default();
470 assert!(reg.is_empty());
471 }
472
473 #[test]
474 fn test_delete_group_cleans_reverse_index() {
475 let mut reg = GroupEditRegistry::new();
476 let gid = reg.create_group("X");
477 reg.add_clip_to_group(gid, 42);
478 reg.delete_group(gid);
479 assert!(reg.groups_for_clip(42).is_empty());
480 }
481}