use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::time::{Duration, Instant};
use trace_stream::render::RenderOptions;
use turbo_debug_console::cmd;
use turbo_debug_console::registry::{Server, ServerEvent, SessionId};
use turbo_debug_console::session::{Sessions, SharedStreamView, format_title};
use turbo_debug_console::streamview::StreamView;
use turbo_vision::app::Application;
use turbo_vision::core::command::{CM_CASCADE, CM_CLOSE, CM_NEXT, CM_QUIT, CM_TILE};
use turbo_vision::core::event::{EventType, KB_ALT_X, KB_F6, KB_F10};
use turbo_vision::core::geometry::Rect;
use turbo_vision::core::menu_data::{Menu, MenuItem};
use turbo_vision::core::state::{SF_CLOSED, shadow_size};
use turbo_vision::views::file_dialog::FileDialog;
use turbo_vision::views::menu_bar::{MenuBar, SubMenu};
use turbo_vision::views::msgbox;
use turbo_vision::views::status_line::{StatusItem, StatusLine};
use turbo_vision::views::view::ViewId;
use turbo_vision::views::window::{Window, WindowBuilder};
const MAX_EVENTS_PER_TICK: usize = 64;
const CONTROL_PORT: u16 = 7878;
fn main() -> turbo_vision::core::error::Result<()> {
let mut app = Application::new()?;
let (width, height) = app.terminal.size();
app.set_menu_bar(build_menu_bar(width));
app.set_status_line(build_status_line(width, height, 0));
let mut server = match Server::bind(CONTROL_PORT) {
Ok(s) => s,
Err(e) => {
drop(app);
eprintln!("turbo-debug-console: cannot bind 127.0.0.1:{CONTROL_PORT}: {e}");
std::process::exit(1);
}
};
let mut console = Console::default();
app.draw();
let _ = app.terminal.flush();
let mut last_reap = Instant::now();
let mut last_status_tick = Instant::now();
while app.running {
let mut dirty = false;
if let Some(mut event) = app.get_event() {
app.handle_event(&mut event);
dirty = true;
if event.what == EventType::Command {
console.handle_command(&mut app, event.command);
}
}
for _ in 0..MAX_EVENTS_PER_TICK {
let Ok(ev) = server.events().try_recv() else {
break;
};
dirty = true;
console.handle_server_event(&mut app, ev);
}
if last_reap.elapsed() > Duration::from_secs(60) {
last_reap = Instant::now();
server.reap(Duration::from_mins(30));
}
if last_status_tick.elapsed() >= Duration::from_secs(1) {
last_status_tick = Instant::now();
let (w, h) = app.terminal.size();
app.set_status_line(build_status_line(w, h, server.live_count()));
dirty = true;
}
if dirty {
app.draw();
let _ = app.terminal.flush();
}
app.desktop.handle_moved_windows(&mut app.terminal);
if app.desktop.remove_closed_windows() {
console.forget_closed_windows(&app, &mut server);
app.draw();
let _ = app.terminal.flush();
}
}
Ok(())
}
#[derive(Default)]
struct Console {
sessions: Sessions,
window_ids: HashMap<ViewId, SessionId>,
session_windows: HashMap<SessionId, ViewId>,
opts: RenderOptionsState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ConsoleIntent {
CreateWindow {
id: SessionId,
name: String,
port: u16,
},
Retitle {
view_id: ViewId,
title: String,
},
CloseWindow {
view_id: ViewId,
},
}
struct RenderOptionsState(RenderOptions);
impl Default for RenderOptionsState {
fn default() -> Self {
Self(RenderOptions {
use_color: true,
format_thinking: true,
format_markdown: true,
})
}
}
impl Console {
fn handle_command(
&mut self,
app: &mut Application,
command: turbo_vision::core::command::CommandId,
) {
let focused_id = app
.desktop
.top_view_id()
.and_then(|id| self.window_ids.get(&id).copied());
match command {
cmd::CM_SHOW_THINKING => {
self.opts.0.format_thinking = !self.opts.0.format_thinking;
self.sessions.set_options(self.opts.0);
}
cmd::CM_SHOW_MARKDOWN => {
self.opts.0.format_markdown = !self.opts.0.format_markdown;
self.sessions.set_options(self.opts.0);
}
cmd::CM_CLEAR_WINDOW => {
if let Some(id) = focused_id {
self.sessions.clear(id);
}
}
cmd::CM_SAVE_AS => {
if let Some(id) = focused_id {
self.save_as(app, id);
}
}
cmd::CM_OPEN_CAPTURE => self.open_capture(app),
_ => {}
}
}
fn handle_server_event(&mut self, app: &mut Application, ev: ServerEvent) {
let Some(intent) = self.decide_server_event(ev) else {
return;
};
self.apply_intent(app, intent);
}
fn decide_server_event(&mut self, ev: ServerEvent) -> Option<ConsoleIntent> {
match ev {
ServerEvent::Opened { id, name, port } => {
Some(ConsoleIntent::CreateWindow { id, name, port })
}
ServerEvent::Attached { id, reattached } => {
self.sessions.mark_attached(id, reattached);
self.retitle_intent(id)
}
ServerEvent::Bytes { id, data } => {
self.sessions.feed(id, &data);
None
}
ServerEvent::Disconnected { id } => {
self.sessions.mark_disconnected(id);
self.retitle_intent(id)
}
ServerEvent::Closed { id } => {
self.sessions.remove(id);
let view_id = self.session_windows.remove(&id)?;
self.window_ids.remove(&view_id);
Some(ConsoleIntent::CloseWindow { view_id })
}
}
}
fn retitle_intent(&self, id: SessionId) -> Option<ConsoleIntent> {
let view_id = *self.session_windows.get(&id)?;
let title = self.sessions.window_title(id)?;
Some(ConsoleIntent::Retitle { view_id, title })
}
fn apply_intent(&mut self, app: &mut Application, intent: ConsoleIntent) {
match intent {
ConsoleIntent::CreateWindow { id, name, port } => {
let window_bounds = tile_window_bounds(app);
let view = Rc::new(RefCell::new(StreamView::new(session_view_bounds(
window_bounds,
))));
self.sessions
.insert(id, name.clone(), port, Rc::clone(&view), self.opts.0);
let mut window = WindowBuilder::new()
.bounds(window_bounds)
.title(format_title(&name, port))
.build();
apply_session_window_palette(&mut window);
window.add(Box::new(SharedStreamView(view)));
let view_id = app.desktop.add(Box::new(window));
self.window_ids.insert(view_id, id);
self.session_windows.insert(id, view_id);
}
ConsoleIntent::Retitle { view_id, title } => {
if let Some(view) = app.desktop.child_by_id_mut(view_id)
&& let Some(window) = view.as_any_mut().downcast_mut::<Window>()
{
window.set_title(&title);
}
}
ConsoleIntent::CloseWindow { view_id } => {
if let Some(view) = app.desktop.child_by_id_mut(view_id) {
view.set_state_flag(SF_CLOSED, true);
}
}
}
}
fn forget_closed_windows(&mut self, app: &Application, server: &mut Server) {
let closed: Vec<SessionId> = self
.window_ids
.iter()
.filter(|(view_id, _)| !app.desktop.contains_id(**view_id))
.map(|(_, id)| *id)
.collect();
self.window_ids
.retain(|view_id, _| app.desktop.contains_id(*view_id));
self.session_windows
.retain(|_, view_id| app.desktop.contains_id(*view_id));
for id in closed {
self.sessions.remove(id);
server.close_session(id);
}
}
fn save_as(&self, app: &mut Application, id: SessionId) {
let Some(text) = self.sessions.plain_text(id) else {
return;
};
let mut dialog = build_file_dialog(app, "Save As")
.with_button_label("~S~ave")
.build();
if let Some(path) = dialog.execute(app)
&& let Err(e) = std::fs::write(&path, text)
{
msgbox::message_box_error(app, &format!("Cannot write {}: {e}", path.display()));
}
}
fn open_capture(&mut self, app: &mut Application) {
let mut dialog = build_file_dialog(app, "Open Capture").build();
let Some(path) = dialog.execute(app) else {
return;
};
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
msgbox::message_box_error(app, &format!("Cannot read {}: {e}", path.display()));
return;
}
};
let name = basename(&path);
let window_bounds = tile_window_bounds(app);
let view = Rc::new(RefCell::new(StreamView::new(session_view_bounds(
window_bounds,
))));
let id = NEXT_CAPTURE_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.sessions
.insert(id, name.clone(), 0, Rc::clone(&view), self.opts.0);
if let Some(state) = self.sessions.get_mut(id) {
state.connected = true;
state.pipeline.feed(&bytes, &mut view.borrow_mut());
state.pipeline.finish(&mut view.borrow_mut());
}
let mut window = WindowBuilder::new()
.bounds(window_bounds)
.title(name)
.build();
apply_session_window_palette(&mut window);
window.add(Box::new(SharedStreamView(view)));
let view_id = app.desktop.add(Box::new(window));
self.window_ids.insert(view_id, id);
}
}
fn tile_window_bounds(app: &Application) -> Rect {
let mut bounds = app.get_tile_rect();
let (shadow_x, shadow_y) = shadow_size();
bounds.b.x -= shadow_x;
bounds.b.y -= shadow_y;
bounds
}
fn session_view_bounds(window_bounds: Rect) -> Rect {
Rect::new(0, 0, window_bounds.width() - 2, window_bounds.height() - 2)
}
fn apply_session_window_palette(window: &mut Window) {
window.set_custom_palette(vec![97, 98, 99, 100, 101, 102, 103, 104]);
}
fn build_menu_bar(width: i16) -> MenuBar {
let mut menu_bar = MenuBar::new(Rect::new(0, 0, width, 1));
menu_bar.add_submenu(SubMenu::new(
"~F~ile",
Menu::from_items(vec![
MenuItem::new("~O~pen capture...", cmd::CM_OPEN_CAPTURE, 0, 0),
MenuItem::new("~S~ave As...", cmd::CM_SAVE_AS, 0, 0),
MenuItem::separator(),
MenuItem::new("E~x~it", CM_QUIT, 0, 0),
]),
));
menu_bar.add_submenu(SubMenu::new(
"~V~iew",
Menu::from_items(vec![
MenuItem::new("Show ~t~hinking", cmd::CM_SHOW_THINKING, 0, 0),
MenuItem::new("~M~arkdown", cmd::CM_SHOW_MARKDOWN, 0, 0),
MenuItem::new("~C~lear window", cmd::CM_CLEAR_WINDOW, 0, 0),
]),
));
menu_bar.add_submenu(SubMenu::new(
"~W~indow",
Menu::from_items(vec![
MenuItem::new("~N~ext", CM_NEXT, 0, 0),
MenuItem::new("~T~ile", CM_TILE, 0, 0),
MenuItem::new("C~a~scade", CM_CASCADE, 0, 0),
MenuItem::new("~C~lose", CM_CLOSE, 0, 0),
]),
));
menu_bar
}
fn build_status_line(width: i16, height: i16, live: usize) -> StatusLine {
StatusLine::new(
Rect::new(0, height - 1, width, height),
vec![
StatusItem::new("~F6~ Next", KB_F6, CM_NEXT),
StatusItem::new("~F10~ Menu", KB_F10, 0),
StatusItem::new("~Alt-X~ Exit", KB_ALT_X, CM_QUIT),
StatusItem::new(&format!("{live} conn"), 0, 0),
],
)
}
fn build_file_dialog(app: &Application, title: &str) -> FileDialog {
let (width, height) = app.terminal.size();
let dialog_width = 62.min(width);
let dialog_height = 20.min(height);
let dialog_x = (width - dialog_width) / 2;
let dialog_y = (height - dialog_height) / 2;
FileDialog::new(
Rect::new(
dialog_x,
dialog_y,
dialog_x + dialog_width,
dialog_y + dialog_height,
),
title,
"*",
None,
)
}
fn basename(path: &std::path::Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
)
}
static NEXT_CAPTURE_ID: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(u64::MAX / 2);
#[cfg(test)]
mod console_decision_tests {
use super::*;
use turbo_debug_console::registry::ServerEvent;
#[test]
fn opened_decides_to_create_a_window() {
let mut console = Console::default();
let intent = console.decide_server_event(ServerEvent::Opened {
id: 1,
name: "demo".into(),
port: 61278,
});
assert_eq!(
intent,
Some(ConsoleIntent::CreateWindow {
id: 1,
name: "demo".into(),
port: 61278,
})
);
}
#[test]
fn bytes_decides_nothing_but_still_feeds_the_session() {
let mut console = Console::default();
console
.sessions
.insert(1, "demo".into(), 4242, test_view(), console.opts.0);
let intent = console.decide_server_event(ServerEvent::Bytes {
id: 1,
data: b"hello\n".to_vec(),
});
assert_eq!(intent, None);
assert!(console.sessions.plain_text(1).unwrap().contains("hello"));
}
#[test]
fn attached_reattach_decides_to_retitle_the_mapped_window() {
let mut console = Console::default();
console
.sessions
.insert(1, "demo".into(), 4242, test_view(), console.opts.0);
let view_id = ViewId::from_u16(7);
console.session_windows.insert(1, view_id);
console.window_ids.insert(view_id, 1);
let intent = console.decide_server_event(ServerEvent::Attached {
id: 1,
reattached: true,
});
assert_eq!(
intent,
Some(ConsoleIntent::Retitle {
view_id,
title: "demo :4242".into(),
})
);
}
#[test]
fn attached_first_attach_also_decides_to_retitle_the_mapped_window() {
let mut console = Console::default();
console
.sessions
.insert(1, "demo".into(), 4242, test_view(), console.opts.0);
let view_id = ViewId::from_u16(8);
console.session_windows.insert(1, view_id);
console.window_ids.insert(view_id, 1);
let intent = console.decide_server_event(ServerEvent::Attached {
id: 1,
reattached: false,
});
assert_eq!(
intent,
Some(ConsoleIntent::Retitle {
view_id,
title: "demo :4242".into(),
})
);
}
#[test]
fn closed_decides_to_close_the_mapped_window_and_forgets_it() {
let mut console = Console::default();
console
.sessions
.insert(1, "demo".into(), 4242, test_view(), console.opts.0);
let view_id = ViewId::from_u16(9);
console.session_windows.insert(1, view_id);
console.window_ids.insert(view_id, 1);
let intent = console.decide_server_event(ServerEvent::Closed { id: 1 });
assert_eq!(intent, Some(ConsoleIntent::CloseWindow { view_id }));
assert!(!console.session_windows.contains_key(&1));
assert!(!console.window_ids.contains_key(&view_id));
assert!(console.sessions.plain_text(1).is_none());
}
#[test]
fn closed_with_no_mapped_window_decides_nothing() {
let mut console = Console::default();
console
.sessions
.insert(1, "demo".into(), 4242, test_view(), console.opts.0);
let intent = console.decide_server_event(ServerEvent::Closed { id: 1 });
assert_eq!(intent, None);
}
fn test_view() -> turbo_debug_console::session::SharedView {
std::rc::Rc::new(std::cell::RefCell::new(StreamView::new(Rect::new(
0, 0, 80, 24,
))))
}
}
#[cfg(test)]
mod title_render_tests {
use std::io;
use std::time::Duration;
use turbo_vision::core::event::Event;
use turbo_vision::core::geometry::Rect;
use turbo_vision::terminal::{Backend, Terminal};
use turbo_vision::views::desktop::Desktop;
use turbo_vision::views::view::View;
use turbo_vision::views::window::WindowBuilder;
struct NullBackend;
impl Backend for NullBackend {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn init(&mut self) -> io::Result<()> {
Ok(())
}
fn cleanup(&mut self) -> io::Result<()> {
Ok(())
}
fn size(&self) -> io::Result<(u16, u16)> {
Ok((80, 25))
}
fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
Ok(None)
}
fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn shadow_shrunk_bounds_keep_the_title_bar_on_screen() {
let mut terminal = Terminal::with_backend(Box::new(NullBackend)).unwrap();
let mut desktop = Desktop::new(Rect::new(0, 0, 80, 25));
let mut bounds = desktop.bounds();
let (shadow_x, shadow_y) = turbo_vision::core::state::shadow_size();
bounds.b.x -= shadow_x;
bounds.b.y -= shadow_y;
let window = WindowBuilder::new()
.bounds(bounds)
.title("demo :61278")
.build();
desktop.add(Box::new(window));
desktop.draw(&mut terminal);
let row0: String = terminal.buffer()[0].iter().map(|c| c.ch).collect();
assert!(row0.contains("demo :61278"), "title not on row 0: {row0:?}");
}
#[test]
fn unshrunk_bounds_push_the_title_bar_off_screen() {
let mut terminal = Terminal::with_backend(Box::new(NullBackend)).unwrap();
let mut desktop = Desktop::new(Rect::new(0, 0, 80, 25));
let window = WindowBuilder::new()
.bounds(desktop.bounds())
.title("demo :61278")
.build();
desktop.add(Box::new(window));
desktop.draw(&mut terminal);
let row0: String = terminal.buffer()[0].iter().map(|c| c.ch).collect();
assert!(
!row0.contains("demo :61278"),
"expected the unshrunk-bounds title to be scrolled off row 0, but found it: {row0:?}"
);
}
#[test]
fn stream_view_bounds_land_inside_the_frame_not_over_it() {
use turbo_debug_console::streamview::StreamView;
let window_bounds = Rect::new(0, 0, 40, 20);
let view_bounds = super::session_view_bounds(window_bounds);
assert_eq!(view_bounds, Rect::new(0, 0, 38, 18));
let mut window = WindowBuilder::new()
.bounds(window_bounds)
.title("demo")
.build();
window.add(Box::new(StreamView::new(view_bounds)));
let absolute = window.child_at(0).bounds();
assert_eq!(absolute, Rect::new(1, 1, 39, 19));
}
#[test]
fn outer_bounds_overflow_the_interior_by_the_frame_width() {
use turbo_debug_console::streamview::StreamView;
let window_bounds = Rect::new(0, 0, 40, 20);
let mut window = WindowBuilder::new()
.bounds(window_bounds)
.title("demo")
.build();
window.add(Box::new(StreamView::new(window_bounds)));
let absolute = window.child_at(0).bounds();
assert_eq!(absolute, Rect::new(1, 1, 41, 21));
assert!(
absolute.b.x > 39 && absolute.b.y > 19,
"expected the unfixed bounds to overflow the interior (1,1,39,19), got {absolute:?}"
);
}
}