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}