ass_editor/commands/
style_commands.rs

1//! Style management commands for ASS documents
2//!
3//! Provides commands for creating, editing, deleting, cloning, and applying styles
4//! with proper validation and delta tracking.
5
6use super::{CommandResult, EditorCommand};
7use crate::core::{EditorDocument, EditorError, Position, Range, Result, StyleBuilder};
8
9#[cfg(not(feature = "std"))]
10use alloc::{
11    format,
12    string::{String, ToString},
13    vec::Vec,
14};
15
16#[cfg(feature = "std")]
17use std::collections::HashMap;
18
19#[cfg(not(feature = "std"))]
20use alloc::collections::BTreeMap as HashMap;
21
22/// Command to create a new style
23#[derive(Debug, Clone)]
24pub struct CreateStyleCommand {
25    pub style_name: String,
26    pub style_builder: StyleBuilder,
27    pub description: Option<String>,
28}
29
30impl CreateStyleCommand {
31    /// Create a new style creation command
32    pub fn new(style_name: String, style_builder: StyleBuilder) -> Self {
33        Self {
34            style_name,
35            style_builder,
36            description: None,
37        }
38    }
39
40    /// Set a custom description for this command
41    #[must_use]
42    pub fn with_description(mut self, description: String) -> Self {
43        self.description = Some(description);
44        self
45    }
46}
47
48impl EditorCommand for CreateStyleCommand {
49    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
50        // Build the style line with the provided name
51        let mut builder = self.style_builder.clone();
52        builder = builder.name(&self.style_name);
53        let style_line = builder.build()?;
54
55        // Find the styles section or create one
56        let content = document.text();
57        let styles_section_pos = content
58            .find("[V4+ Styles]")
59            .or_else(|| content.find("[V4 Styles]"))
60            .or_else(|| content.find("[Styles]"));
61
62        if let Some(section_start) = styles_section_pos {
63            // Find the end of the format line to insert after it
64            let section_content = &content[section_start..];
65            if let Some(format_line_end) = section_content.find('\n') {
66                let format_end_pos = section_start + format_line_end + 1;
67
68                // Find next format line or end of section
69                let insert_pos = if let Some(next_line_start) = content[format_end_pos..].find('\n')
70                {
71                    format_end_pos + next_line_start + 1
72                } else {
73                    format_end_pos
74                };
75
76                // Insert the new style
77                let insert_text = format!("{style_line}\n");
78                document.insert(Position::new(insert_pos), &insert_text)?;
79
80                let end_pos = Position::new(insert_pos + insert_text.len());
81                return Ok(CommandResult::success_with_change(
82                    Range::new(Position::new(insert_pos), end_pos),
83                    end_pos,
84                )
85                .with_message(format!("Created style '{}'", self.style_name)));
86            }
87        }
88
89        // No styles section found, create one
90        let styles_section = format!(
91            "\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n{style_line}\n"
92        );
93
94        // Insert before [Events] section if it exists, otherwise at the end
95        let insert_pos = if let Some(events_pos) = content.find("[Events]") {
96            events_pos
97        } else {
98            content.len()
99        };
100
101        document.insert(Position::new(insert_pos), &styles_section)?;
102
103        let end_pos = Position::new(insert_pos + styles_section.len());
104        Ok(CommandResult::success_with_change(
105            Range::new(Position::new(insert_pos), end_pos),
106            end_pos,
107        )
108        .with_message(format!(
109            "Created styles section and style '{}'",
110            self.style_name
111        )))
112    }
113
114    fn description(&self) -> &str {
115        self.description.as_deref().unwrap_or("Create style")
116    }
117
118    fn memory_usage(&self) -> usize {
119        core::mem::size_of::<Self>()
120            + self.style_name.len()
121            + self.description.as_ref().map_or(0, |d| d.len())
122            + 200 // Estimated StyleBuilder size
123    }
124}
125
126/// Command to edit an existing style
127#[derive(Debug, Clone)]
128pub struct EditStyleCommand {
129    pub style_name: String,
130    pub field_updates: HashMap<String, String>,
131    pub description: Option<String>,
132}
133
134impl EditStyleCommand {
135    /// Create a new style edit command
136    pub fn new(style_name: String) -> Self {
137        Self {
138            style_name,
139            field_updates: HashMap::new(),
140            description: None,
141        }
142    }
143
144    /// Set a field value
145    pub fn set_field(mut self, field: &str, value: String) -> Self {
146        self.field_updates.insert(field.to_string(), value);
147        self
148    }
149
150    /// Set font name
151    pub fn set_font(self, font: &str) -> Self {
152        self.set_field("Fontname", font.to_string())
153    }
154
155    /// Set font size
156    pub fn set_size(self, size: u32) -> Self {
157        self.set_field("Fontsize", size.to_string())
158    }
159
160    /// Set primary color
161    pub fn set_color(self, color: &str) -> Self {
162        self.set_field("PrimaryColour", color.to_string())
163    }
164
165    /// Set bold
166    pub fn set_bold(self, bold: bool) -> Self {
167        self.set_field("Bold", if bold { "-1" } else { "0" }.to_string())
168    }
169
170    /// Set italic
171    pub fn set_italic(self, italic: bool) -> Self {
172        self.set_field("Italic", if italic { "-1" } else { "0" }.to_string())
173    }
174
175    /// Set alignment
176    pub fn set_alignment(self, alignment: u32) -> Self {
177        self.set_field("Alignment", alignment.to_string())
178    }
179
180    /// Set custom description
181    #[must_use]
182    pub fn with_description(mut self, description: String) -> Self {
183        self.description = Some(description);
184        self
185    }
186}
187
188impl EditorCommand for EditStyleCommand {
189    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
190        let content = document.text();
191        let style_pattern = format!("Style: {}", self.style_name);
192
193        if let Some(style_start) = content.find(&style_pattern) {
194            // Find the end of the style line
195            let line_start = content[..style_start]
196                .rfind('\n')
197                .map(|pos| pos + 1)
198                .unwrap_or(0);
199            let line_end = content[style_start..]
200                .find('\n')
201                .map(|pos| style_start + pos)
202                .unwrap_or(content.len());
203
204            let style_line = &content[line_start..line_end];
205            let fields: Vec<&str> = style_line.split(',').collect();
206
207            if fields.len() < 2 {
208                return Err(EditorError::command_failed("Invalid style format"));
209            }
210
211            // Find format line to determine field order
212            let styles_section_start = content[..line_start]
213                .rfind("[V4+ Styles]")
214                .or_else(|| content[..line_start].rfind("[V4 Styles]"))
215                .or_else(|| content[..line_start].rfind("[Styles]"))
216                .ok_or_else(|| EditorError::command_failed("Could not find styles section"))?;
217
218            let format_line_start = content[styles_section_start..]
219                .find("Format:")
220                .map(|pos| styles_section_start + pos)
221                .ok_or_else(|| EditorError::command_failed("Could not find format line"))?;
222
223            let format_line_end = content[format_line_start..]
224                .find('\n')
225                .map(|pos| format_line_start + pos)
226                .unwrap_or(content.len());
227
228            let format_line = &content[format_line_start..format_line_end];
229            let format_fields: Vec<&str> = format_line
230                .strip_prefix("Format: ")
231                .unwrap_or(format_line)
232                .split(", ")
233                .collect();
234
235            // Build updated style line
236            let mut updated_fields = fields
237                .iter()
238                .map(|f| f.to_string())
239                .collect::<Vec<String>>();
240
241            for (field_name, new_value) in &self.field_updates {
242                if let Some(field_index) = format_fields.iter().position(|f| f == field_name) {
243                    if field_index < updated_fields.len() {
244                        updated_fields[field_index] = new_value.clone();
245                    }
246                }
247            }
248
249            let new_style_line = updated_fields.join(",");
250            let range = Range::new(Position::new(line_start), Position::new(line_end));
251
252            document.replace(range, &new_style_line)?;
253
254            let end_pos = Position::new(line_start + new_style_line.len());
255            Ok(CommandResult::success_with_change(
256                Range::new(Position::new(line_start), end_pos),
257                end_pos,
258            )
259            .with_message(format!("Updated style '{}'", self.style_name)))
260        } else {
261            Err(EditorError::command_failed(format!(
262                "Style '{}' not found",
263                self.style_name
264            )))
265        }
266    }
267
268    fn description(&self) -> &str {
269        self.description.as_deref().unwrap_or("Edit style")
270    }
271
272    fn memory_usage(&self) -> usize {
273        core::mem::size_of::<Self>()
274            + self.style_name.len()
275            + self
276                .field_updates
277                .iter()
278                .map(|(k, v)| k.len() + v.len())
279                .sum::<usize>()
280            + self.description.as_ref().map_or(0, |d| d.len())
281    }
282}
283
284/// Command to delete a style
285#[derive(Debug, Clone)]
286pub struct DeleteStyleCommand {
287    pub style_name: String,
288    pub description: Option<String>,
289}
290
291impl DeleteStyleCommand {
292    /// Create a new style deletion command
293    pub fn new(style_name: String) -> Self {
294        Self {
295            style_name,
296            description: None,
297        }
298    }
299
300    /// Set custom description
301    #[must_use]
302    pub fn with_description(mut self, description: String) -> Self {
303        self.description = Some(description);
304        self
305    }
306}
307
308impl EditorCommand for DeleteStyleCommand {
309    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
310        let content = document.text();
311        let style_pattern = format!("Style: {}", self.style_name);
312
313        if let Some(style_start) = content.find(&style_pattern) {
314            // Find the complete line including the newline
315            let line_start = content[..style_start]
316                .rfind('\n')
317                .map(|pos| pos + 1)
318                .unwrap_or(0);
319            let line_end = content[style_start..]
320                .find('\n')
321                .map(|pos| style_start + pos + 1) // Include the newline
322                .unwrap_or(content.len());
323
324            let range = Range::new(Position::new(line_start), Position::new(line_end));
325            document.delete(range)?;
326
327            Ok(CommandResult::success_with_change(
328                Range::new(Position::new(line_start), Position::new(line_start)),
329                Position::new(line_start),
330            )
331            .with_message(format!("Deleted style '{}'", self.style_name)))
332        } else {
333            Err(EditorError::command_failed(format!(
334                "Style '{}' not found",
335                self.style_name
336            )))
337        }
338    }
339
340    fn description(&self) -> &str {
341        self.description.as_deref().unwrap_or("Delete style")
342    }
343
344    fn memory_usage(&self) -> usize {
345        core::mem::size_of::<Self>()
346            + self.style_name.len()
347            + self.description.as_ref().map_or(0, |d| d.len())
348    }
349}
350
351/// Command to clone an existing style with a new name
352#[derive(Debug, Clone)]
353pub struct CloneStyleCommand {
354    pub source_style: String,
355    pub target_style: String,
356    pub description: Option<String>,
357}
358
359impl CloneStyleCommand {
360    /// Create a new style cloning command
361    pub fn new(source_style: String, target_style: String) -> Self {
362        Self {
363            source_style,
364            target_style,
365            description: None,
366        }
367    }
368
369    /// Set custom description
370    #[must_use]
371    pub fn with_description(mut self, description: String) -> Self {
372        self.description = Some(description);
373        self
374    }
375}
376
377impl EditorCommand for CloneStyleCommand {
378    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
379        let content = document.text();
380        let source_pattern = format!("Style: {}", self.source_style);
381
382        // Check if target style already exists
383        let target_pattern = format!("Style: {}", self.target_style);
384        if content.contains(&target_pattern) {
385            return Err(EditorError::command_failed(format!(
386                "Style '{}' already exists",
387                self.target_style
388            )));
389        }
390
391        if let Some(source_start) = content.find(&source_pattern) {
392            // Find the complete source style line
393            let line_start = content[..source_start]
394                .rfind('\n')
395                .map(|pos| pos + 1)
396                .unwrap_or(0);
397            let line_end = content[source_start..]
398                .find('\n')
399                .map(|pos| source_start + pos)
400                .unwrap_or(content.len());
401
402            let source_line = &content[line_start..line_end];
403
404            // Replace the style name in the cloned line
405            let cloned_line = source_line.replace(
406                &format!("Style: {}", self.source_style),
407                &format!("Style: {}", self.target_style),
408            );
409
410            // Find where to insert the new style (after the source style)
411            let insert_pos = line_end;
412            let insert_text = format!("\n{cloned_line}");
413
414            document.insert(Position::new(insert_pos), &insert_text)?;
415
416            let end_pos = Position::new(insert_pos + insert_text.len());
417            Ok(CommandResult::success_with_change(
418                Range::new(Position::new(insert_pos), end_pos),
419                end_pos,
420            )
421            .with_message(format!(
422                "Cloned style '{}' to '{}'",
423                self.source_style, self.target_style
424            )))
425        } else {
426            Err(EditorError::command_failed(format!(
427                "Source style '{}' not found",
428                self.source_style
429            )))
430        }
431    }
432
433    fn description(&self) -> &str {
434        self.description.as_deref().unwrap_or("Clone style")
435    }
436
437    fn memory_usage(&self) -> usize {
438        core::mem::size_of::<Self>()
439            + self.source_style.len()
440            + self.target_style.len()
441            + self.description.as_ref().map_or(0, |d| d.len())
442    }
443}
444
445/// Command to apply a style to events (change all events using one style to another)
446#[derive(Debug, Clone)]
447pub struct ApplyStyleCommand {
448    pub old_style: String,
449    pub new_style: String,
450    pub event_filter: Option<String>, // Optional filter for event text
451    pub description: Option<String>,
452}
453
454impl ApplyStyleCommand {
455    /// Create a new style application command
456    pub fn new(old_style: String, new_style: String) -> Self {
457        Self {
458            old_style,
459            new_style,
460            event_filter: None,
461            description: None,
462        }
463    }
464
465    /// Only apply to events containing specific text
466    pub fn with_filter(mut self, filter: String) -> Self {
467        self.event_filter = Some(filter);
468        self
469    }
470
471    /// Set custom description
472    #[must_use]
473    pub fn with_description(mut self, description: String) -> Self {
474        self.description = Some(description);
475        self
476    }
477}
478
479impl EditorCommand for ApplyStyleCommand {
480    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
481        let mut content = document.text();
482        let mut changes_made = 0;
483        let mut total_range: Option<Range> = None;
484
485        // Find events section
486        let events_start = content
487            .find("[Events]")
488            .ok_or_else(|| EditorError::command_failed("Events section not found"))?;
489
490        // Skip format line
491        let events_content_start = content[events_start..]
492            .find('\n')
493            .map(|pos| events_start + pos + 1)
494            .ok_or_else(|| EditorError::command_failed("Invalid events section format"))?;
495
496        // Skip format line again (Format: ...)
497        let first_event_start = content[events_content_start..]
498            .find('\n')
499            .map(|pos| events_content_start + pos + 1)
500            .unwrap_or(events_content_start);
501
502        let mut search_pos = first_event_start;
503
504        while search_pos < content.len() {
505            // Find next event line
506            let line_start = search_pos;
507            let line_end = content[line_start..]
508                .find('\n')
509                .map(|pos| line_start + pos)
510                .unwrap_or(content.len());
511
512            if line_start >= line_end {
513                break;
514            }
515
516            let line = &content[line_start..line_end];
517
518            // Check if this is an event line
519            if line.starts_with("Dialogue:") || line.starts_with("Comment:") {
520                let parts: Vec<&str> = line.split(',').collect();
521
522                // Check if this event uses the old style (typically 4th field after event type)
523                if parts.len() > 4 && parts[3].trim() == self.old_style {
524                    // Apply filter if specified
525                    let should_update = if let Some(ref filter) = self.event_filter {
526                        line.contains(filter)
527                    } else {
528                        true
529                    };
530
531                    if should_update {
532                        // Replace the old style with new style
533                        let updated_line = line.replace(
534                            &format!(",{},", self.old_style),
535                            &format!(",{},", self.new_style),
536                        );
537
538                        // Update the document
539                        let range = Range::new(Position::new(line_start), Position::new(line_end));
540                        document.replace(range, &updated_line)?;
541
542                        // Update content for next iteration (this is inefficient but correct)
543                        content = document.text();
544
545                        // Track overall range
546                        let change_range = Range::new(
547                            Position::new(line_start),
548                            Position::new(line_start + updated_line.len()),
549                        );
550                        total_range = Some(match total_range {
551                            Some(existing) => existing.union(&change_range),
552                            None => change_range,
553                        });
554
555                        changes_made += 1;
556                    }
557                }
558            } else if line.starts_with('[') && line != "[Events]" {
559                // Stop at next section
560                break;
561            }
562
563            search_pos = line_end + 1;
564        }
565
566        if changes_made > 0 {
567            Ok(CommandResult::success_with_change(
568                total_range.unwrap_or(Range::new(Position::new(0), Position::new(0))),
569                Position::new(content.len()),
570            )
571            .with_message(format!(
572                "Applied style '{}' to {} events",
573                self.new_style, changes_made
574            )))
575        } else {
576            Ok(CommandResult::success()
577                .with_message("No events found matching the criteria".to_string()))
578        }
579    }
580
581    fn description(&self) -> &str {
582        self.description
583            .as_deref()
584            .unwrap_or("Apply style to events")
585    }
586
587    fn memory_usage(&self) -> usize {
588        core::mem::size_of::<Self>()
589            + self.old_style.len()
590            + self.new_style.len()
591            + self.event_filter.as_ref().map_or(0, |f| f.len())
592            + self.description.as_ref().map_or(0, |d| d.len())
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use crate::core::EditorDocument;
600    #[cfg(not(feature = "std"))]
601    use alloc::string::ToString;
602    const TEST_CONTENT: &str = r#"[Script Info]
603Title: Test Script
604
605[V4+ Styles]
606Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
607Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
608
609[Events]
610Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
611Dialogue: 0,0:00:01.00,0:00:05.00,Default,Speaker,0,0,0,,Hello world!
612"#;
613
614    #[test]
615    fn test_create_style_command() {
616        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
617
618        let style_builder = StyleBuilder::new()
619            .font("Comic Sans MS")
620            .size(24)
621            .bold(true);
622
623        let command = CreateStyleCommand::new("NewStyle".to_string(), style_builder);
624        let result = command.execute(&mut doc).unwrap();
625
626        assert!(result.success);
627        assert!(result.content_changed);
628        assert!(doc.text().contains("Style: NewStyle"));
629        assert!(doc.text().contains("Comic Sans MS"));
630    }
631
632    #[test]
633    fn test_edit_style_command() {
634        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
635
636        let command = EditStyleCommand::new("Default".to_string())
637            .set_font("Helvetica")
638            .set_size(24)
639            .set_bold(true);
640
641        let result = command.execute(&mut doc).unwrap();
642
643        assert!(result.success);
644        assert!(result.content_changed);
645        assert!(doc.text().contains("Helvetica"));
646        assert!(doc.text().contains("24"));
647        assert!(doc.text().contains("-1")); // Bold = true
648    }
649
650    #[test]
651    fn test_delete_style_command() {
652        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
653
654        let command = DeleteStyleCommand::new("Default".to_string());
655        let result = command.execute(&mut doc).unwrap();
656
657        assert!(result.success);
658        assert!(result.content_changed);
659        assert!(!doc.text().contains("Style: Default"));
660    }
661
662    #[test]
663    fn test_clone_style_command() {
664        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
665
666        let command = CloneStyleCommand::new("Default".to_string(), "DefaultCopy".to_string());
667        let result = command.execute(&mut doc).unwrap();
668
669        assert!(result.success);
670        assert!(result.content_changed);
671        assert!(doc.text().contains("Style: Default")); // Original should still exist
672        assert!(doc.text().contains("Style: DefaultCopy")); // Clone should exist
673    }
674
675    #[test]
676    fn test_apply_style_command() {
677        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
678
679        // First create a new style to apply
680        let create_cmd = CreateStyleCommand::new(
681            "NewStyle".to_string(),
682            StyleBuilder::new().font("Verdana").size(18),
683        );
684        create_cmd.execute(&mut doc).unwrap();
685
686        // Now apply the new style to events
687        let command = ApplyStyleCommand::new("Default".to_string(), "NewStyle".to_string());
688        let result = command.execute(&mut doc).unwrap();
689
690        assert!(result.success);
691        assert!(result.content_changed);
692        assert!(doc.text().contains("NewStyle")); // Event should now use NewStyle
693    }
694
695    #[test]
696    fn test_apply_style_with_filter() {
697        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
698
699        // Create a new style
700        let create_cmd = CreateStyleCommand::new(
701            "FilteredStyle".to_string(),
702            StyleBuilder::new().font("Times").size(22),
703        );
704        create_cmd.execute(&mut doc).unwrap();
705
706        // Apply style only to events containing "Hello"
707        let command = ApplyStyleCommand::new("Default".to_string(), "FilteredStyle".to_string())
708            .with_filter("Hello".to_string());
709
710        let result = command.execute(&mut doc).unwrap();
711
712        assert!(result.success);
713        assert!(result.content_changed);
714        assert!(doc.text().contains("FilteredStyle"));
715    }
716
717    #[test]
718    fn test_edit_nonexistent_style() {
719        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
720
721        let command = EditStyleCommand::new("NonExistent".to_string()).set_font("Arial");
722
723        let result = command.execute(&mut doc);
724        assert!(result.is_err());
725    }
726
727    #[test]
728    fn test_clone_to_existing_style() {
729        let mut doc = EditorDocument::from_content(TEST_CONTENT).unwrap();
730
731        let command = CloneStyleCommand::new("Default".to_string(), "Default".to_string());
732        let result = command.execute(&mut doc);
733
734        assert!(result.is_err());
735    }
736}