cryptic_tui/
formatting.rs

1//! Text formatting for emoji and markdown processing.
2//!
3//! This module provides transformation of ASCII emoticons to Unicode emoji
4//! and basic markdown formatting (*bold*, _italic_, `code`) with Ratatui styling.
5//!
6//! ## Supported ASCII Emoticons
7//!
8//! The following ASCII emoticons are automatically converted to Unicode emoji:
9//!
10//! | ASCII | Emoji | Description |
11//! |-------|-------|-------------|
12//! | `(y)` | 👍 | Thumbs up |
13//! | `:-)` | 😊 | Smile |
14//! | `:)` | 🙂 | Simple smile |
15//! | `:-D` | 😄 | Grin |
16//! | `:D` | 😄 | Grin |
17//! | `;-)` | 😉 | Wink |
18//! | `;)` | 😉 | Wink |
19//! | `:-P` | 😛 | Tongue |
20//! | `:P` | 😛 | Tongue |
21//! | `:-(` | 😞 | Sad |
22//! | `:(` | 😞 | Sad |
23//! | `:/` | 😕 | Unsure |
24//! | `:o` | 😮 | Surprised |
25//! | `:O` | 😮 | Surprised |
26//! | `:*` | 😘 | Kiss |
27//! | `:\|` | 😐 | Neutral |
28//! | `:Q` | 👶 | Baby face |
29//!
30//! ## Markdown Formatting
31//!
32//! - `*bold text*` - Renders in bold
33//! - `_italic text_` - Renders in italic  
34//! - `` `code` `` - Renders with code styling (reversed video)
35//!
36//! **Important**: Content within backticks is treated as **verbatim text**.
37//! No emoji replacement, bold, or italic formatting will be applied inside
38//! backtick pairs. For example:
39//! - `` `*not bold*` `` - The asterisks remain literal
40//! - `` `:) no emoji` `` - The emoticon remains as `:)`
41//!
42//! Processing order ensures backticks are handled first, protecting their
43//! content from all subsequent transformations.
44
45use ratatui::{
46    style::{Modifier, Style},
47    text::{Line, Span},
48};
49
50/// Emoji replacement mappings (ASCII -> Unicode)
51const EMOJI_MAP: &[(&str, &str)] = &[
52    ("(y)", "👍"), // Thumbs up
53    (":-)", "😊"), // Smile
54    (":)", "🙂"),  // Simple smile
55    (":-D", "😄"), // Grin
56    (":D", "😄"),  // Grin
57    (";-)", "😉"), // Wink
58    (";)", "😉"),  // Wink
59    (":-P", "😛"), // Tongue
60    (":P", "😛"),  // Tongue
61    (":-(", "😞"), // Sad
62    (":(", "😞"),  // Sad
63    (":/", "😕"),  // Unsure
64    (":o", "😮"),  // Surprised
65    (":O", "😮"),  // Surprised
66    (":*", "😘"),  // Kiss
67    (":|", "😐"),  // Neutral
68    (":Q", "👶"),  // Baby face
69];
70
71/// Text segment type after processing backticks
72#[derive(Debug, Clone)]
73enum Segment {
74    Text(String),
75    Code(String),
76}
77
78/// Process text with emoji and markdown formatting.
79///
80/// Processing order:
81/// 1. Backticks (code) - processed first, content protected from further formatting
82/// 2. Emoji replacement - applied to text segments only
83/// 3. Bold (*text*) and italic (_text_) - applied to text segments only
84///
85/// Returns a Line containing styled Spans for Ratatui rendering.
86///
87/// # Examples
88///
89/// ```
90/// let line = process_text("This is *bold* and :) `code`");
91/// // Returns: Line with styled spans: "This is ", bold("bold"), " and 🙂 ", code("code")
92/// ```
93pub fn process_text(input: &str) -> Line<'static> {
94    // Step 1: Process backticks first, protecting content
95    let segments = process_backticks(input);
96
97    // Step 2 & 3: Process emoji, bold, and italic on text segments only
98    let mut spans = Vec::new();
99
100    for segment in segments {
101        match segment {
102            Segment::Code(text) => {
103                // Code segments are rendered in reverse video (no further processing)
104                spans.push(Span::styled(
105                    text,
106                    Style::default().add_modifier(Modifier::REVERSED),
107                ));
108            }
109            Segment::Text(text) => {
110                // Text segments get emoji + markdown processing
111                let emoji_text = replace_emoji(&text);
112                let formatted_spans = process_markdown(&emoji_text);
113                spans.extend(formatted_spans);
114            }
115        }
116    }
117
118    Line::from(spans)
119}
120
121/// Replace ASCII emoticons with Unicode emoji.
122fn replace_emoji(text: &str) -> String {
123    let mut result = text.to_string();
124
125    // Process in order (longer patterns first to avoid ambiguity)
126    for (ascii, emoji) in EMOJI_MAP {
127        result = result.replace(ascii, emoji);
128    }
129
130    result
131}
132
133/// Process backticks and return segments.
134///
135/// Returns a list of Text or Code segments. Code segments are already
136/// identified and should not receive further markdown processing.
137fn process_backticks(text: &str) -> Vec<Segment> {
138    let mut segments = Vec::new();
139    let chars: Vec<char> = text.chars().collect();
140    let mut i = 0;
141    let mut current_text = String::new();
142
143    while i < chars.len() {
144        if chars[i] == '`' {
145            // Found opening backtick
146            // Save accumulated text
147            if !current_text.is_empty() {
148                segments.push(Segment::Text(current_text.clone()));
149                current_text.clear();
150            }
151
152            // Look for closing backtick
153            let mut code_content = String::new();
154            let mut j = i + 1;
155            let mut found_closing = false;
156
157            while j < chars.len() {
158                if chars[j] == '`' {
159                    found_closing = true;
160                    break;
161                }
162                code_content.push(chars[j]);
163                j += 1;
164            }
165
166            if found_closing {
167                if code_content.is_empty() {
168                    // Empty backticks `` - treat as literal
169                    current_text.push_str("``");
170                } else {
171                    // Valid code segment
172                    segments.push(Segment::Code(code_content));
173                }
174                i = j + 1; // Skip past closing backtick
175            } else {
176                // Unpaired backtick - treat as literal
177                current_text.push('`');
178                i += 1;
179            }
180        } else {
181            current_text.push(chars[i]);
182            i += 1;
183        }
184    }
185
186    // Add remaining text
187    if !current_text.is_empty() {
188        segments.push(Segment::Text(current_text));
189    }
190
191    // Handle case where input was empty or only whitespace
192    if segments.is_empty() {
193        segments.push(Segment::Text(String::new()));
194    }
195
196    segments
197}
198
199/// Process markdown formatting (*bold* and _italic_) in text.
200///
201/// Returns a vector of styled Spans. This function handles:
202/// - *text* -> Bold
203/// - _text_ -> Italic (dim)
204///
205/// Processing is done in sequence: asterisks first, then underscores.
206fn process_markdown(text: &str) -> Vec<Span<'static>> {
207    // First process asterisks (bold)
208    let bold_spans = process_delimiter(text, '*', |s| {
209        Span::styled(s, Style::default().add_modifier(Modifier::BOLD))
210    });
211
212    // Then process underscores (italic/dim) on the result
213    let mut result = Vec::new();
214    for span in bold_spans {
215        if span.style.add_modifier.is_empty() {
216            // Plain text span - check for underscores
217            let italic_spans = process_delimiter(&span.content, '_', |s| {
218                Span::styled(s, Style::default().add_modifier(Modifier::DIM))
219            });
220            result.extend(italic_spans);
221        } else {
222            // Already styled (bold) - keep as is
223            result.push(span);
224        }
225    }
226
227    result
228}
229
230/// Process a single delimiter pattern (like * or _) in text.
231///
232/// Returns a vector of Spans with some styled according to formatter_fn.
233fn process_delimiter<F>(text: &str, delimiter: char, formatter_fn: F) -> Vec<Span<'static>>
234where
235    F: Fn(String) -> Span<'static>,
236{
237    let mut spans = Vec::new();
238    let chars: Vec<char> = text.chars().collect();
239    let mut i = 0;
240    let mut current_text = String::new();
241    let mut inside_delimiter = false;
242    let mut delimited_text = String::new();
243
244    while i < chars.len() {
245        if chars[i] == delimiter {
246            if inside_delimiter {
247                // End of delimited section
248                if delimited_text.is_empty() {
249                    // Empty delimiter pair (e.g., ** or __) - treat as literal
250                    current_text.push(delimiter);
251                    current_text.push(delimiter);
252                } else {
253                    // Format the delimited text
254                    if !current_text.is_empty() {
255                        spans.push(Span::raw(current_text.clone()));
256                        current_text.clear();
257                    }
258                    spans.push(formatter_fn(delimited_text.clone()));
259                    delimited_text.clear();
260                }
261                inside_delimiter = false;
262            } else {
263                // Start of delimited section
264                if !current_text.is_empty() {
265                    spans.push(Span::raw(current_text.clone()));
266                    current_text.clear();
267                }
268                inside_delimiter = true;
269            }
270            i += 1;
271        } else {
272            if inside_delimiter {
273                delimited_text.push(chars[i]);
274            } else {
275                current_text.push(chars[i]);
276            }
277            i += 1;
278        }
279    }
280
281    // Handle unpaired delimiter at end
282    if inside_delimiter {
283        // Restore the opening delimiter and accumulated text as literal
284        current_text.push(delimiter);
285        current_text.push_str(&delimited_text);
286    }
287
288    // Add remaining text
289    if !current_text.is_empty() {
290        spans.push(Span::raw(current_text));
291    }
292
293    // Ensure we always return at least one span
294    if spans.is_empty() {
295        spans.push(Span::raw(""));
296    }
297
298    spans
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_emoji_replacement() {
307        assert_eq!(replace_emoji("Hello :-)"), "Hello 😊");
308        assert_eq!(replace_emoji("Thanks (y)"), "Thanks 👍");
309        assert_eq!(replace_emoji(":D :P"), "😄 😛");
310    }
311
312    #[test]
313    fn test_backticks() {
314        let segments = process_backticks("normal `code` text");
315        assert_eq!(segments.len(), 3);
316        matches!(&segments[0], Segment::Text(t) if t == "normal ");
317        matches!(&segments[1], Segment::Code(c) if c == "code");
318        matches!(&segments[2], Segment::Text(t) if t == " text");
319    }
320
321    #[test]
322    fn test_empty_backticks() {
323        let segments = process_backticks("empty `` here");
324        // Empty backticks are treated as literal text
325        assert!(segments
326            .iter()
327            .any(|s| matches!(s, Segment::Text(t) if t.contains("``"))));
328    }
329
330    #[test]
331    fn test_unpaired_backtick() {
332        let segments = process_backticks("unpaired ` here");
333        // Unpaired backtick is treated as literal text
334        assert!(segments
335            .iter()
336            .any(|s| matches!(s, Segment::Text(t) if t.contains("`"))));
337    }
338
339    #[test]
340    fn test_markdown_bold() {
341        let spans = process_markdown("This is *bold* text");
342        assert!(spans.len() >= 2);
343        // Should have plain text, bold styled, and more plain text
344    }
345
346    #[test]
347    fn test_markdown_italic() {
348        let spans = process_markdown("This is _italic_ text");
349        assert!(spans.len() >= 2);
350    }
351
352    #[test]
353    fn test_backticks_protect_from_emoji() {
354        // Emoji outside backticks should be replaced
355        let line = process_text("Hello :) and `:-) no emoji` here");
356        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
357
358        // The :) outside should become 🙂, but the :-) inside backticks should stay literal
359        assert!(
360            text.contains("🙂"),
361            "Expected emoji replacement outside backticks"
362        );
363        assert!(
364            text.contains(":-)"),
365            "Expected literal emoticon inside backticks"
366        );
367    }
368
369    #[test]
370    fn test_backticks_protect_from_markdown() {
371        // Bold/italic outside backticks should be processed
372        let line = process_text("*bold* and `*not bold*` text");
373
374        // We should have at least 3 spans: bold text, code text, plain text
375        assert!(line.spans.len() >= 2, "Expected multiple spans");
376
377        // The code span should contain the literal asterisks
378        let has_literal_asterisks = line
379            .spans
380            .iter()
381            .any(|span| span.content.contains("*not bold*"));
382        assert!(
383            has_literal_asterisks,
384            "Expected literal asterisks in code segment"
385        );
386    }
387
388    #[test]
389    fn test_backticks_protect_both_emoji_and_markdown() {
390        let line = process_text(":) *test* and `:-) *protected*` end");
391        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
392
393        // Outside backticks: emoji and markdown should be processed
394        assert!(text.contains("🙂"), "Expected emoji outside backticks");
395
396        // Inside backticks: both should be literal
397        assert!(text.contains(":-)"), "Expected literal emoticon in code");
398        assert!(
399            text.contains("*protected*"),
400            "Expected literal asterisks in code"
401        );
402    }
403
404    #[test]
405    fn test_empty_delimiters() {
406        let spans = process_markdown("Empty ** and __ here");
407        // Empty pairs should be treated as literals
408        assert!(spans.iter().any(|s| s.content.contains("**")));
409    }
410}