Skip to main content

inline_playback/
inline-playback.rs

1// TUI Inline Demo - Interactive frame description collection using unified key menu system
2// This demonstrates the same playback workflow as playback-demo but using a reusable key menu framework
3
4use anyhow::Result;
5use cactui::inline_prompt::{InlineInputOpts, InlinePrompt};
6use crossterm::{event::KeyCode, style::Color};
7
8use std::collections::HashMap;
9
10/// Simulated frame data with content and timing
11#[derive(Debug, Clone)]
12struct DemoFrame {
13    content: String,
14}
15
16/// Channel state for frame display
17#[derive(Debug, Clone)]
18struct ChannelState {
19    fg_red: bool,
20    fg_green: bool,
21    fg_blue: bool,
22    bg_red: bool,
23    bg_green: bool,
24    bg_blue: bool,
25    modifiers: bool,
26}
27
28impl ChannelState {
29    fn new() -> Self {
30        Self {
31            fg_red: false,
32            fg_green: false,
33            fg_blue: false,
34            bg_red: false,
35            bg_green: false,
36            bg_blue: false,
37            modifiers: false,
38        }
39    }
40
41    fn format_channels(&self) -> String {
42        if !self.fg_red
43            && !self.fg_green
44            && !self.fg_blue
45            && !self.bg_red
46            && !self.bg_green
47            && !self.bg_blue
48            && !self.modifiers
49        {
50            return "(none)".to_string();
51        }
52
53        let mut result = String::new();
54
55        // Foreground RGB
56        let mut fg_parts = Vec::new();
57        if self.fg_red {
58            fg_parts.push("R");
59        }
60        if self.fg_green {
61            fg_parts.push("G");
62        }
63        if self.fg_blue {
64            fg_parts.push("B");
65        }
66        result.push_str(&fg_parts.join(""));
67
68        // Background RGB (only if any background colors are set)
69        if self.bg_red || self.bg_green || self.bg_blue {
70            result.push('/');
71            let mut bg_parts = Vec::new();
72            if self.bg_red {
73                bg_parts.push("R");
74            }
75            if self.bg_green {
76                bg_parts.push("G");
77            }
78            if self.bg_blue {
79                bg_parts.push("B");
80            }
81            result.push_str(&bg_parts.join(""));
82        }
83
84        // Modifiers
85        if self.modifiers {
86            result.push('+');
87        }
88
89        if result.is_empty() {
90            "(none)".to_string()
91        } else {
92            result
93        }
94    }
95}
96
97/// Collected descriptions for the demo
98#[derive(Debug, Clone)]
99struct PlaybackSession {
100    recording_description: Option<String>,
101    frame_descriptions: HashMap<usize, String>, // frame num -> desc
102    frame_channels: HashMap<usize, ChannelState>, // frame num -> channel state
103    frames: Vec<DemoFrame>,
104    current_frame_num: usize,
105}
106
107impl PlaybackSession {
108    fn new() -> Self {
109        Self {
110            recording_description: None,
111            frame_descriptions: HashMap::new(),
112            frame_channels: HashMap::new(),
113            frames: create_demo_frames(),
114            current_frame_num: 0,
115        }
116    }
117
118    fn get_current_channels(&self) -> &ChannelState {
119        static DEFAULT_CHANNELS: ChannelState = ChannelState {
120            fg_red: false,
121            fg_green: false,
122            fg_blue: false,
123            bg_red: false,
124            bg_green: false,
125            bg_blue: false,
126            modifiers: false,
127        };
128        self.frame_channels
129            .get(&self.current_frame_num)
130            .unwrap_or(&DEFAULT_CHANNELS)
131    }
132
133    fn get_current_channels_mut(&mut self) -> &mut ChannelState {
134        self.frame_channels
135            .entry(self.current_frame_num)
136            .or_insert_with(ChannelState::new)
137    }
138
139    /// Collect overall recording description
140    fn collect_recording_description(&mut self) -> Result<()> {
141        println!("Interactive Recording Playback");
142        println!("═══════════════════════════════════");
143        println!();
144
145        let description = InlinePrompt::input("Recording description:", None)?;
146        if !description.is_empty() {
147            self.recording_description = Some(description);
148        }
149
150        Ok(())
151    }
152
153    /// Run the interactive playback with frame descriptions
154    fn run_interactive_playback(&mut self) -> Result<()> {
155        let collect_descriptions =
156            InlinePrompt::confirm("Collect descriptions for each frame?", true)?;
157
158        if !collect_descriptions {
159            println!("⏭️  Skipping frame descriptions");
160            return Ok(());
161        }
162
163        println!();
164        println!("🎥 Starting frame-by-frame playback...");
165        println!("Use keys to navigate, Enter to continue");
166        println!();
167        println!();
168
169        let inline_menu = create_inline_frame_menu();
170
171        for frame_num in 0..self.frames.len() {
172            // Set current frame number in session
173            self.current_frame_num = frame_num;
174
175            inline_menu.run_menu(self)?;
176
177            println!();
178            println!();
179        }
180
181        println!("✅ Playback complete!");
182        Ok(())
183    }
184}
185
186/// Calculate how many lines a description will take when displayed
187fn calculate_description_lines(description: &str) -> Result<usize> {
188    let full_text = format!("Description: {}", description);
189    cactui::text_wrapping::calculate_text_lines(&full_text)
190}
191
192/// Get frame header lines with current state
193fn get_frame_header_lines(session: &PlaybackSession) -> Vec<String> {
194    let mut lines = Vec::new();
195
196    // Add the frame content (split on newlines)
197    if let Some(current_frame) = session.frames.get(session.current_frame_num) {
198        for line in current_frame.content.lines() {
199            lines.push(line.to_string());
200        }
201
202        let channels = session.get_current_channels();
203
204        // Add channel debug grids if any channels are active
205        if channels.fg_red
206            || channels.fg_green
207            || channels.fg_blue
208            || channels.bg_red
209            || channels.bg_green
210            || channels.bg_blue
211            || channels.modifiers
212        {
213            lines.push("=== FRAME CHANNELS ===".to_string());
214
215            if channels.fg_red {
216                lines.push("--- Foreground Red ---".to_string());
217                lines.push("1│255..............".to_string());
218                lines.push("2│255,128............,255".to_string());
219                lines.push("3│255,128............,255".to_string());
220                lines.push("4│255,128............,255".to_string());
221                lines.push("5│255,128............,255".to_string());
222                lines.push("6│255,128............,255".to_string());
223                lines.push("7│255..............".to_string());
224                lines.push("8│128..............".to_string());
225            }
226
227            if channels.fg_green {
228                lines.push("--- Foreground Green ---".to_string());
229                lines.push("1│0,255,0..............".to_string());
230                lines.push("2│0,255,0..............".to_string());
231                lines.push("3│0,255,0..............".to_string());
232                lines.push("4│0,255,0..............".to_string());
233                lines.push("5│0,255,0..............".to_string());
234                lines.push("6│0,255,0..............".to_string());
235                lines.push("7│0,255,0..............".to_string());
236                lines.push("8│0,255,0..............".to_string());
237            }
238
239            if channels.fg_blue {
240                lines.push("--- Foreground Blue ---".to_string());
241                lines.push("1│0,0,255..............".to_string());
242                lines.push("2│0,0,255..............".to_string());
243                lines.push("3│0,0,255..............".to_string());
244                lines.push("4│0,0,255..............".to_string());
245                lines.push("5│0,0,255..............".to_string());
246                lines.push("6│0,0,255..............".to_string());
247                lines.push("7│0,0,255..............".to_string());
248                lines.push("8│0,0,255..............".to_string());
249            }
250
251            if channels.bg_red {
252                lines.push("--- Background Red ---".to_string());
253                lines.push("1│255,0,0..............".to_string());
254                lines.push("2│255,0,0..............".to_string());
255                lines.push("3│255,0,0..............".to_string());
256                lines.push("4│255,0,0..............".to_string());
257                lines.push("5│255,0,0..............".to_string());
258                lines.push("6│255,0,0..............".to_string());
259                lines.push("7│255,0,0..............".to_string());
260                lines.push("8│255,0,0..............".to_string());
261            }
262
263            if channels.bg_green {
264                lines.push("--- Background Green ---".to_string());
265                lines.push("1│0,255,0..............".to_string());
266                lines.push("2│0,255,0..............".to_string());
267                lines.push("3│0,255,0..............".to_string());
268                lines.push("4│0,255,0..............".to_string());
269                lines.push("5│0,255,0..............".to_string());
270                lines.push("6│0,255,0..............".to_string());
271                lines.push("7│0,255,0..............".to_string());
272                lines.push("8│0,255,0..............".to_string());
273            }
274
275            if channels.bg_blue {
276                lines.push("--- Background Blue ---".to_string());
277                lines.push("1│0..............".to_string());
278                lines.push("2│0..............".to_string());
279                lines.push("3│0..............".to_string());
280                lines.push("4│0..............".to_string());
281                lines.push("5│0..............".to_string());
282                lines.push("6│0..............".to_string());
283                lines.push("7│0..............".to_string());
284                lines.push("8│0..............".to_string());
285            }
286
287            lines.push("=== END FRAME 0 ===".to_string());
288        }
289    }
290
291    // Show current state - Channels FIRST, then Description LAST
292    lines.push(format!(
293        "Channels: {}",
294        session.get_current_channels().format_channels()
295    ));
296
297    let desc = session
298        .frame_descriptions
299        .get(&session.current_frame_num)
300        .map(|s| s.clone())
301        .unwrap_or_else(|| "(none)".to_string());
302
303    lines.push(format!("Description: {}", desc));
304
305    lines
306}
307
308/// Create the main frame menu configuration
309fn create_inline_frame_menu() -> cactui::KeyMenuConfig<PlaybackSession> {
310    cactui::KeyMenuConfig {
311        header_lines: Some(Box::new(|session: &PlaybackSession| {
312            get_frame_header_lines(session)
313        })),
314        items: vec![
315            cactui::KeyMenuItem {
316                key: KeyCode::Char('e'),
317                description: "edit description".to_string(),
318                color: Some(Color::Cyan),
319                action: cactui::MenuAction::Callback {
320                    callback: Box::new(|session, line_counts| {
321                        // Get current description for placeholder
322                        let current_desc = session
323                            .frame_descriptions
324                            .get(&session.current_frame_num)
325                            .map(|s| s.as_str());
326
327                        // Calculate how many lines the current description takes up
328                        let current_description = session
329                            .frame_descriptions
330                            .get(&session.current_frame_num)
331                            .map(|s| s.as_str())
332                            .unwrap_or("(none)");
333                        let current_lines_to_clear =
334                            calculate_description_lines(current_description)?;
335
336                        // Clear the description lines so we can draw over them with our InlinePrompt
337                        cactui::inline_keymenu::clear_lines(current_lines_to_clear)?;
338                        // Call the inline input with escape-to-exit mode for multi-line descriptions
339                        let mut opts = InlineInputOpts::new().escape_to_exit();
340                        if current_desc.is_some() {
341                            opts = opts.placeholder(current_desc.unwrap());
342                        };
343                        let new_desc = InlinePrompt::input("Description:", Some(opts))?;
344                        // Update line_counts.header to reflect the new header size
345                        let new_lines_to_clear = calculate_description_lines(&new_desc)?;
346                        if new_lines_to_clear > current_lines_to_clear {
347                            line_counts.header += new_lines_to_clear - current_lines_to_clear
348                        } else if new_lines_to_clear < current_lines_to_clear {
349                            line_counts.header = line_counts
350                                .header
351                                .saturating_sub(current_lines_to_clear - new_lines_to_clear)
352                        }
353
354                        // Update description in session
355                        if !new_desc.is_empty() {
356                            session
357                                .frame_descriptions
358                                .insert(session.current_frame_num, new_desc);
359                        } else {
360                            session
361                                .frame_descriptions
362                                .remove(&session.current_frame_num);
363                        }
364
365                        Ok(cactui::MenuResult::Stay)
366                    }),
367                    clear_menu: true,
368                },
369            },
370            cactui::KeyMenuItem {
371                key: KeyCode::Char('c'),
372                description: "channels".to_string(),
373                color: Some(Color::Cyan),
374                action: cactui::MenuAction::Submenu(cactui::KeyMenuConfig {
375                    header_lines: Some(Box::new(|session: &PlaybackSession| {
376                        get_frame_header_lines(session)
377                    })),
378                    items: vec![
379                        cactui::KeyMenuItem {
380                            key: KeyCode::Char('1'),
381                            description: "fg red".to_string(),
382                            color: Some(Color::Red),
383                            action: cactui::MenuAction::Callback {
384                                callback: Box::new(|session, _| {
385                                    session.get_current_channels_mut().fg_red =
386                                        !session.get_current_channels().fg_red;
387                                    Ok(cactui::MenuResult::Stay)
388                                }),
389                                clear_menu: false,
390                            },
391                        },
392                        cactui::KeyMenuItem {
393                            key: KeyCode::Char('2'),
394                            description: "fg green".to_string(),
395                            color: Some(Color::Green),
396                            action: cactui::MenuAction::Callback {
397                                callback: Box::new(|session, _| {
398                                    session.get_current_channels_mut().fg_green =
399                                        !session.get_current_channels().fg_green;
400                                    Ok(cactui::MenuResult::Stay)
401                                }),
402                                clear_menu: false,
403                            },
404                        },
405                        cactui::KeyMenuItem {
406                            key: KeyCode::Char('3'),
407                            description: "fg blue".to_string(),
408                            color: Some(Color::Blue),
409                            action: cactui::MenuAction::Callback {
410                                callback: Box::new(|session, _| {
411                                    session.get_current_channels_mut().fg_blue =
412                                        !session.get_current_channels().fg_blue;
413                                    Ok(cactui::MenuResult::Stay)
414                                }),
415                                clear_menu: false,
416                            },
417                        },
418                        cactui::KeyMenuItem {
419                            key: KeyCode::Char('4'),
420                            description: "bg red".to_string(),
421                            color: Some(Color::Red),
422                            action: cactui::MenuAction::Callback {
423                                callback: Box::new(|session, _| {
424                                    session.get_current_channels_mut().bg_red =
425                                        !session.get_current_channels().bg_red;
426                                    Ok(cactui::MenuResult::Stay)
427                                }),
428                                clear_menu: false,
429                            },
430                        },
431                        cactui::KeyMenuItem {
432                            key: KeyCode::Char('5'),
433                            description: "bg green".to_string(),
434                            color: Some(Color::Green),
435                            action: cactui::MenuAction::Callback {
436                                callback: Box::new(|session, _| {
437                                    session.get_current_channels_mut().bg_green =
438                                        !session.get_current_channels().bg_green;
439                                    Ok(cactui::MenuResult::Stay)
440                                }),
441                                clear_menu: false,
442                            },
443                        },
444                        cactui::KeyMenuItem {
445                            key: KeyCode::Char('6'),
446                            description: "bg blue".to_string(),
447                            color: Some(Color::Blue),
448                            action: cactui::MenuAction::Callback {
449                                callback: Box::new(|session, _| {
450                                    session.get_current_channels_mut().bg_blue =
451                                        !session.get_current_channels().bg_blue;
452                                    Ok(cactui::MenuResult::Stay)
453                                }),
454                                clear_menu: false,
455                            },
456                        },
457                        cactui::KeyMenuItem {
458                            key: KeyCode::Char('7'),
459                            description: "mods".to_string(),
460                            color: Some(Color::White),
461                            action: cactui::MenuAction::Callback {
462                                callback: Box::new(|session, _| {
463                                    session.get_current_channels_mut().modifiers =
464                                        !session.get_current_channels().modifiers;
465                                    Ok(cactui::MenuResult::Stay)
466                                }),
467                                clear_menu: false,
468                            },
469                        },
470                        cactui::KeyMenuItem {
471                            key: KeyCode::Esc,
472                            description: "return".to_string(),
473                            color: None,
474                            action: cactui::MenuAction::Exit,
475                        },
476                    ],
477                    should_loop: true,
478                }),
479            },
480            cactui::KeyMenuItem {
481                key: KeyCode::Enter,
482                description: "next frame".to_string(),
483                color: Some(Color::Cyan),
484                action: cactui::MenuAction::ExitKeepHeader,
485            },
486        ],
487        should_loop: true,
488    }
489}
490
491/// Create demo frames with placeholder content
492fn create_demo_frames() -> Vec<DemoFrame> {
493    vec![
494        DemoFrame {
495            content: format!(
496                "{}\n{}",
497                "=== DEBUGTERM FRAME 0 (22ms) ===",
498                " 1│┌ 2 (FOCUSED)────────────────┐\n \
499                  2││>> WIDGET 1 <<              │\n \
500                  3││Welcome to i3-style TUI!    │\n \
501                  4││                            │\n \
502                  5││Keys:                       │\n \
503                  6││- Space: New widget         │\n \
504                  7││- Alt+h/Alt+v: Split horizon│\n \
505                  8││- Alt+e: Toggle layout      │\n \
506                  9││- Alt-Arrows: Navigate      │\n\
507                 10││- Alt+Shift+Arrows: Move    │\n\
508                 11││- Tab: Cycle focus          │\n\
509                 12││- s: Seal/unseal container  │\n\
510                 13││- u: Update content         │\n\
511                 14││- r: Reset content          │\n\
512                 15││- x: Remove widget          │\n\
513                 16││- q: Quit                   │\n\
514                 17││                            │\n\
515                 18││                            │\n\
516                 19│└────────────────────────────┘\n\
517                 20│-- INPUT --\n\
518                === END FRAME 0 ==="
519            ),
520        },
521        DemoFrame {
522            content: format!(
523                "{}\n{}",
524                "=== DEBUGTERM FRAME 5 (122ms) ===",
525                " 1│┌ 2──────────────────────────┐\n \
526                  2││>> WIDGET 1 <<              │\n \
527                  3││Welcome to i3-style TUI!    │\n \
528                  4││                            │\n \
529                  5││Keys:                       │\n \
530                  6││- Space: New widget         │\n \
531                  7││- Alt+h/Alt+v: Split horizon│\n \
532                  8││- Alt+e: Toggle layout      │\n \
533                  9││- Alt-Arrows: Navigate      │\n\
534                 10│└────────────────────────────┘\n\
535                 11│┌ 3 (FOCUSED)────────────────┐\n\
536                 12││Widget 3                    │\n\
537                 13││                            │\n\
538                 14││This is widget number 3     │\n\
539                 15││                            │\n\
540                 16││                            │\n\
541                 17││                            │\n\
542                 18││                            │\n\
543                 19│└────────────────────────────┘\n\
544                 20│-- INPUT --\n\
545                === END FRAME 5 ==="
546            ),
547        },
548        DemoFrame {
549            content: format!(
550                "{}\n{}",
551                "=== DEBUGTERM FRAME 10 (324ms) ===",
552                " 1│┌ 2─────────────┐┌ 4 (FOCUSED)──┐\n \
553                  2││>> WIDGET 1 << ││Widget 4      │\n \
554                  3││Welcome to i3- ││              │\n \
555                  4││               ││This is widget│\n \
556                  5││Keys:          ││number 4      │\n \
557                  6││- Space: New w ││              │\n \
558                  7││- Alt+h/Alt+v: ││              │\n \
559                  8││- Alt+e: Toggl ││              │\n \
560                  9││- Alt-Arrows:  ││              │\n\
561                 10│└───────────────┘└──────────────┘\n\
562                 11│┌ 3──────────────────────────────┐\n\
563                 12││Widget 3                        │\n\
564                 13││                                │\n\
565                 14││This is widget number 3         │\n\
566                 15││                                │\n\
567                 16││                                │\n\
568                 17││                                │\n\
569                 18││                                │\n\
570                 19│└────────────────────────────────┘\n\
571                 20│-- INPUT --\n\
572                === END FRAME 10 ==="
573            ),
574        },
575    ]
576}
577
578fn main() -> Result<()> {
579    let mut session = PlaybackSession::new();
580
581    // Step 1: Collect overall recording description
582    session.collect_recording_description()?;
583
584    // Step 2: Run interactive playback with frame descriptions
585    session.run_interactive_playback()?;
586
587    Ok(())
588}