use anyhow::Result;
use cactui::inline_prompt::{InlineInputOpts, InlinePrompt};
use crossterm::{event::KeyCode, style::Color};
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct DemoFrame {
content: String,
}
#[derive(Debug, Clone)]
struct ChannelState {
fg_red: bool,
fg_green: bool,
fg_blue: bool,
bg_red: bool,
bg_green: bool,
bg_blue: bool,
modifiers: bool,
}
impl ChannelState {
fn new() -> Self {
Self {
fg_red: false,
fg_green: false,
fg_blue: false,
bg_red: false,
bg_green: false,
bg_blue: false,
modifiers: false,
}
}
fn format_channels(&self) -> String {
if !self.fg_red
&& !self.fg_green
&& !self.fg_blue
&& !self.bg_red
&& !self.bg_green
&& !self.bg_blue
&& !self.modifiers
{
return "(none)".to_string();
}
let mut result = String::new();
let mut fg_parts = Vec::new();
if self.fg_red {
fg_parts.push("R");
}
if self.fg_green {
fg_parts.push("G");
}
if self.fg_blue {
fg_parts.push("B");
}
result.push_str(&fg_parts.join(""));
if self.bg_red || self.bg_green || self.bg_blue {
result.push('/');
let mut bg_parts = Vec::new();
if self.bg_red {
bg_parts.push("R");
}
if self.bg_green {
bg_parts.push("G");
}
if self.bg_blue {
bg_parts.push("B");
}
result.push_str(&bg_parts.join(""));
}
if self.modifiers {
result.push('+');
}
if result.is_empty() {
"(none)".to_string()
} else {
result
}
}
}
#[derive(Debug, Clone)]
struct PlaybackSession {
recording_description: Option<String>,
frame_descriptions: HashMap<usize, String>, frame_channels: HashMap<usize, ChannelState>, frames: Vec<DemoFrame>,
current_frame_num: usize,
}
impl PlaybackSession {
fn new() -> Self {
Self {
recording_description: None,
frame_descriptions: HashMap::new(),
frame_channels: HashMap::new(),
frames: create_demo_frames(),
current_frame_num: 0,
}
}
fn get_current_channels(&self) -> &ChannelState {
static DEFAULT_CHANNELS: ChannelState = ChannelState {
fg_red: false,
fg_green: false,
fg_blue: false,
bg_red: false,
bg_green: false,
bg_blue: false,
modifiers: false,
};
self.frame_channels
.get(&self.current_frame_num)
.unwrap_or(&DEFAULT_CHANNELS)
}
fn get_current_channels_mut(&mut self) -> &mut ChannelState {
self.frame_channels
.entry(self.current_frame_num)
.or_insert_with(ChannelState::new)
}
fn collect_recording_description(&mut self) -> Result<()> {
println!("Interactive Recording Playback");
println!("═══════════════════════════════════");
println!();
let description = InlinePrompt::input("Recording description:", None)?;
if !description.is_empty() {
self.recording_description = Some(description);
}
Ok(())
}
fn run_interactive_playback(&mut self) -> Result<()> {
let collect_descriptions =
InlinePrompt::confirm("Collect descriptions for each frame?", true)?;
if !collect_descriptions {
println!("⏭️ Skipping frame descriptions");
return Ok(());
}
println!();
println!("🎥 Starting frame-by-frame playback...");
println!("Use keys to navigate, Enter to continue");
println!();
println!();
let inline_menu = create_inline_frame_menu();
for frame_num in 0..self.frames.len() {
self.current_frame_num = frame_num;
inline_menu.run_menu(self)?;
println!();
println!();
}
println!("✅ Playback complete!");
Ok(())
}
}
fn calculate_description_lines(description: &str) -> Result<usize> {
let full_text = format!("Description: {}", description);
cactui::text_wrapping::calculate_text_lines(&full_text)
}
fn get_frame_header_lines(session: &PlaybackSession) -> Vec<String> {
let mut lines = Vec::new();
if let Some(current_frame) = session.frames.get(session.current_frame_num) {
for line in current_frame.content.lines() {
lines.push(line.to_string());
}
let channels = session.get_current_channels();
if channels.fg_red
|| channels.fg_green
|| channels.fg_blue
|| channels.bg_red
|| channels.bg_green
|| channels.bg_blue
|| channels.modifiers
{
lines.push("=== FRAME CHANNELS ===".to_string());
if channels.fg_red {
lines.push("--- Foreground Red ---".to_string());
lines.push("1│255..............".to_string());
lines.push("2│255,128............,255".to_string());
lines.push("3│255,128............,255".to_string());
lines.push("4│255,128............,255".to_string());
lines.push("5│255,128............,255".to_string());
lines.push("6│255,128............,255".to_string());
lines.push("7│255..............".to_string());
lines.push("8│128..............".to_string());
}
if channels.fg_green {
lines.push("--- Foreground Green ---".to_string());
lines.push("1│0,255,0..............".to_string());
lines.push("2│0,255,0..............".to_string());
lines.push("3│0,255,0..............".to_string());
lines.push("4│0,255,0..............".to_string());
lines.push("5│0,255,0..............".to_string());
lines.push("6│0,255,0..............".to_string());
lines.push("7│0,255,0..............".to_string());
lines.push("8│0,255,0..............".to_string());
}
if channels.fg_blue {
lines.push("--- Foreground Blue ---".to_string());
lines.push("1│0,0,255..............".to_string());
lines.push("2│0,0,255..............".to_string());
lines.push("3│0,0,255..............".to_string());
lines.push("4│0,0,255..............".to_string());
lines.push("5│0,0,255..............".to_string());
lines.push("6│0,0,255..............".to_string());
lines.push("7│0,0,255..............".to_string());
lines.push("8│0,0,255..............".to_string());
}
if channels.bg_red {
lines.push("--- Background Red ---".to_string());
lines.push("1│255,0,0..............".to_string());
lines.push("2│255,0,0..............".to_string());
lines.push("3│255,0,0..............".to_string());
lines.push("4│255,0,0..............".to_string());
lines.push("5│255,0,0..............".to_string());
lines.push("6│255,0,0..............".to_string());
lines.push("7│255,0,0..............".to_string());
lines.push("8│255,0,0..............".to_string());
}
if channels.bg_green {
lines.push("--- Background Green ---".to_string());
lines.push("1│0,255,0..............".to_string());
lines.push("2│0,255,0..............".to_string());
lines.push("3│0,255,0..............".to_string());
lines.push("4│0,255,0..............".to_string());
lines.push("5│0,255,0..............".to_string());
lines.push("6│0,255,0..............".to_string());
lines.push("7│0,255,0..............".to_string());
lines.push("8│0,255,0..............".to_string());
}
if channels.bg_blue {
lines.push("--- Background Blue ---".to_string());
lines.push("1│0..............".to_string());
lines.push("2│0..............".to_string());
lines.push("3│0..............".to_string());
lines.push("4│0..............".to_string());
lines.push("5│0..............".to_string());
lines.push("6│0..............".to_string());
lines.push("7│0..............".to_string());
lines.push("8│0..............".to_string());
}
lines.push("=== END FRAME 0 ===".to_string());
}
}
lines.push(format!(
"Channels: {}",
session.get_current_channels().format_channels()
));
let desc = session
.frame_descriptions
.get(&session.current_frame_num)
.map(|s| s.clone())
.unwrap_or_else(|| "(none)".to_string());
lines.push(format!("Description: {}", desc));
lines
}
fn create_inline_frame_menu() -> cactui::KeyMenuConfig<PlaybackSession> {
cactui::KeyMenuConfig {
header_lines: Some(Box::new(|session: &PlaybackSession| {
get_frame_header_lines(session)
})),
items: vec![
cactui::KeyMenuItem {
key: KeyCode::Char('e'),
description: "edit description".to_string(),
color: Some(Color::Cyan),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, line_counts| {
let current_desc = session
.frame_descriptions
.get(&session.current_frame_num)
.map(|s| s.as_str());
let current_description = session
.frame_descriptions
.get(&session.current_frame_num)
.map(|s| s.as_str())
.unwrap_or("(none)");
let current_lines_to_clear =
calculate_description_lines(current_description)?;
cactui::inline_keymenu::clear_lines(current_lines_to_clear)?;
let mut opts = InlineInputOpts::new().escape_to_exit();
if current_desc.is_some() {
opts = opts.placeholder(current_desc.unwrap());
};
let new_desc = InlinePrompt::input("Description:", Some(opts))?;
let new_lines_to_clear = calculate_description_lines(&new_desc)?;
if new_lines_to_clear > current_lines_to_clear {
line_counts.header += new_lines_to_clear - current_lines_to_clear
} else if new_lines_to_clear < current_lines_to_clear {
line_counts.header = line_counts
.header
.saturating_sub(current_lines_to_clear - new_lines_to_clear)
}
if !new_desc.is_empty() {
session
.frame_descriptions
.insert(session.current_frame_num, new_desc);
} else {
session
.frame_descriptions
.remove(&session.current_frame_num);
}
Ok(cactui::MenuResult::Stay)
}),
clear_menu: true,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('c'),
description: "channels".to_string(),
color: Some(Color::Cyan),
action: cactui::MenuAction::Submenu(cactui::KeyMenuConfig {
header_lines: Some(Box::new(|session: &PlaybackSession| {
get_frame_header_lines(session)
})),
items: vec![
cactui::KeyMenuItem {
key: KeyCode::Char('1'),
description: "fg red".to_string(),
color: Some(Color::Red),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().fg_red =
!session.get_current_channels().fg_red;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('2'),
description: "fg green".to_string(),
color: Some(Color::Green),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().fg_green =
!session.get_current_channels().fg_green;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('3'),
description: "fg blue".to_string(),
color: Some(Color::Blue),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().fg_blue =
!session.get_current_channels().fg_blue;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('4'),
description: "bg red".to_string(),
color: Some(Color::Red),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().bg_red =
!session.get_current_channels().bg_red;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('5'),
description: "bg green".to_string(),
color: Some(Color::Green),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().bg_green =
!session.get_current_channels().bg_green;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('6'),
description: "bg blue".to_string(),
color: Some(Color::Blue),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().bg_blue =
!session.get_current_channels().bg_blue;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Char('7'),
description: "mods".to_string(),
color: Some(Color::White),
action: cactui::MenuAction::Callback {
callback: Box::new(|session, _| {
session.get_current_channels_mut().modifiers =
!session.get_current_channels().modifiers;
Ok(cactui::MenuResult::Stay)
}),
clear_menu: false,
},
},
cactui::KeyMenuItem {
key: KeyCode::Esc,
description: "return".to_string(),
color: None,
action: cactui::MenuAction::Exit,
},
],
should_loop: true,
}),
},
cactui::KeyMenuItem {
key: KeyCode::Enter,
description: "next frame".to_string(),
color: Some(Color::Cyan),
action: cactui::MenuAction::ExitKeepHeader,
},
],
should_loop: true,
}
}
fn create_demo_frames() -> Vec<DemoFrame> {
vec![
DemoFrame {
content: format!(
"{}\n{}",
"=== DEBUGTERM FRAME 0 (22ms) ===",
" 1│┌ 2 (FOCUSED)────────────────┐\n \
2││>> WIDGET 1 << │\n \
3││Welcome to i3-style TUI! │\n \
4││ │\n \
5││Keys: │\n \
6││- Space: New widget │\n \
7││- Alt+h/Alt+v: Split horizon│\n \
8││- Alt+e: Toggle layout │\n \
9││- Alt-Arrows: Navigate │\n\
10││- Alt+Shift+Arrows: Move │\n\
11││- Tab: Cycle focus │\n\
12││- s: Seal/unseal container │\n\
13││- u: Update content │\n\
14││- r: Reset content │\n\
15││- x: Remove widget │\n\
16││- q: Quit │\n\
17││ │\n\
18││ │\n\
19│└────────────────────────────┘\n\
20│-- INPUT --\n\
=== END FRAME 0 ==="
),
},
DemoFrame {
content: format!(
"{}\n{}",
"=== DEBUGTERM FRAME 5 (122ms) ===",
" 1│┌ 2──────────────────────────┐\n \
2││>> WIDGET 1 << │\n \
3││Welcome to i3-style TUI! │\n \
4││ │\n \
5││Keys: │\n \
6││- Space: New widget │\n \
7││- Alt+h/Alt+v: Split horizon│\n \
8││- Alt+e: Toggle layout │\n \
9││- Alt-Arrows: Navigate │\n\
10│└────────────────────────────┘\n\
11│┌ 3 (FOCUSED)────────────────┐\n\
12││Widget 3 │\n\
13││ │\n\
14││This is widget number 3 │\n\
15││ │\n\
16││ │\n\
17││ │\n\
18││ │\n\
19│└────────────────────────────┘\n\
20│-- INPUT --\n\
=== END FRAME 5 ==="
),
},
DemoFrame {
content: format!(
"{}\n{}",
"=== DEBUGTERM FRAME 10 (324ms) ===",
" 1│┌ 2─────────────┐┌ 4 (FOCUSED)──┐\n \
2││>> WIDGET 1 << ││Widget 4 │\n \
3││Welcome to i3- ││ │\n \
4││ ││This is widget│\n \
5││Keys: ││number 4 │\n \
6││- Space: New w ││ │\n \
7││- Alt+h/Alt+v: ││ │\n \
8││- Alt+e: Toggl ││ │\n \
9││- Alt-Arrows: ││ │\n\
10│└───────────────┘└──────────────┘\n\
11│┌ 3──────────────────────────────┐\n\
12││Widget 3 │\n\
13││ │\n\
14││This is widget number 3 │\n\
15││ │\n\
16││ │\n\
17││ │\n\
18││ │\n\
19│└────────────────────────────────┘\n\
20│-- INPUT --\n\
=== END FRAME 10 ==="
),
},
]
}
fn main() -> Result<()> {
let mut session = PlaybackSession::new();
session.collect_recording_description()?;
session.run_interactive_playback()?;
Ok(())
}