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::proto::{PROTOCOL_VERSION, StreamKind};
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_NEXT, CM_QUIT, CM_TOGGLE_BLOCK_MODE};
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, SF_SHADOW};
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::{View, ViewId};
use turbo_vision::views::window::{Window, WindowBuilder};
const MAX_EVENTS_PER_TICK: usize = 64;
const CONTROL_PORT: u16 = 7878;
const RENDER_OPTIONS: RenderOptions = RenderOptions {
use_color: true,
format_thinking: true,
format_markdown: true,
};
fn handle_cli_flags() {
for arg in std::env::args().skip(1) {
match arg.as_str() {
"-V" | "--version" => {
println!("turbo-debug-console {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
"-h" | "--help" => {
println!(
"turbo-debug-console {}\n\
{}\n\
\n\
USAGE:\n turbo-debug-console\n\
\n\
Takes no options: it listens on the fixed control port {CONTROL_PORT}.\n\
\n\
Name a session and get a port to stream at:\n\
\n printf 'HELLO {PROTOCOL_VERSION} tokens build\\n' | nc 127.0.0.1 {CONTROL_PORT}\n\
\n\
<kind> is 'tokens' (a model token stream) or 'trace' (JSON-lines\n\
tracing-subscriber records):\n\
\n printf 'HELLO {PROTOCOL_VERSION} trace myapp\\n' | nc 127.0.0.1 {CONTROL_PORT}\n\
\n\
Or skip the handshake -- anything that is not a HELLO is\n\
rendered as a raw token stream in its own window:\n\
\n cat capture.txt | nc 127.0.0.1 {CONTROL_PORT}\n\
\n\
KEYS\n \
F10 menu F6 next window Alt-X quit\n \
PgUp/PgDn/Home/End, the mouse wheel or the scrollbar scroll a window\n\
\n\
{}",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_DESCRIPTION"),
env!("CARGO_PKG_REPOSITORY"),
);
std::process::exit(0);
}
_ => {}
}
}
}
fn set_terminal_title(title: &str) {
use std::io::Write;
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]2;{title}\x07");
let _ = out.flush();
}
fn main() -> turbo_vision::core::error::Result<()> {
handle_cli_flags();
set_terminal_title("Turbo Debug Console");
let mut app = Application::new()?;
let (width, height) = app.terminal.size();
app.set_menu_bar(build_menu_bar(width, false));
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();
for command in [
cmd::CM_COPY,
cmd::CM_SAVE_AS,
cmd::CM_SELECT_ALL,
cmd::CM_CLEAR_WINDOW,
cmd::CM_CLEANUP,
] {
app.disable_command(command);
}
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() {
console.sync_command_state(&mut app);
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 {
console.sync_command_state(&mut app);
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>,
auto_cleanup: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ConsoleIntent {
CreateWindow {
id: SessionId,
name: String,
port: u16,
kind: StreamKind,
},
Retitle {
view_id: ViewId,
title: String,
},
CloseWindow {
view_id: ViewId,
},
}
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_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_SELECT_ALL => {
if let Some(id) = focused_id {
self.sessions.select_all(id);
}
}
cmd::CM_COPY => {
if let Some(id) = focused_id {
self.copy_selection(app, id);
}
}
cmd::CM_OPEN_CAPTURE => self.open_capture(app),
cmd::CM_CLEANUP => self.cleanup_windows(app),
cmd::CM_AUTO_CLEANUP => {
self.auto_cleanup = !self.auto_cleanup;
let (width, _) = app.terminal.size();
app.set_menu_bar(build_menu_bar(width, self.auto_cleanup));
if self.auto_cleanup {
self.cleanup_windows(app);
}
}
cmd::CM_TILE_WINDOWS => app.tile(),
cmd::CM_CASCADE_WINDOWS => app.cascade(),
_ => {}
}
}
fn cleanup_windows(&mut self, app: &mut Application) {
let stale: Vec<ViewId> = self
.window_ids
.iter()
.filter(|(_, id)| !self.sessions.is_connected(**id))
.map(|(view_id, _)| *view_id)
.collect();
for view_id in stale {
if let Some(view) = app.desktop.child_by_id_mut(view_id) {
view.set_state_flag(SF_CLOSED, true);
}
}
}
fn copy_selection(&self, app: &mut Application, id: SessionId) {
let Some(text) = self.sessions.selected_text(id).filter(|t| !t.is_empty()) else {
return;
};
let lines = text.lines().count();
turbo_vision::core::clipboard::set_clipboard(&text);
msgbox::message_box_ok(
app,
&format!(
"Copied {lines} line{} to the clipboard.",
if lines == 1 { "" } else { "s" }
),
);
}
fn can_copy(&self, focused_id: Option<SessionId>) -> bool {
focused_id
.and_then(|id| self.sessions.selected_text(id))
.is_some_and(|t| !t.is_empty())
}
fn has_stale_windows(&self) -> bool {
self.window_ids
.values()
.any(|id| !self.sessions.is_connected(*id))
}
fn sync_command_state(&self, app: &mut Application) {
let focused_id = app
.desktop
.top_view_id()
.and_then(|id| self.window_ids.get(&id).copied());
if self.can_copy(focused_id) {
app.enable_command(cmd::CM_COPY);
} else {
app.disable_command(cmd::CM_COPY);
}
let multiple_windows = app.desktop.count_tileable_windows() > 1;
for command in [CM_NEXT, cmd::CM_TILE_WINDOWS, cmd::CM_CASCADE_WINDOWS] {
if multiple_windows {
app.enable_command(command);
} else {
app.disable_command(command);
}
}
for command in [cmd::CM_SAVE_AS, cmd::CM_SELECT_ALL, cmd::CM_CLEAR_WINDOW] {
if focused_id.is_some() {
app.enable_command(command);
} else {
app.disable_command(command);
}
}
if self.has_stale_windows() {
app.enable_command(cmd::CM_CLEANUP);
} else {
app.disable_command(cmd::CM_CLEANUP);
}
}
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,
kind,
} => Some(ConsoleIntent::CreateWindow {
id,
name,
port,
kind,
}),
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);
if self.auto_cleanup
&& let Some(&view_id) = self.session_windows.get(&id)
{
return Some(ConsoleIntent::CloseWindow { view_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,
kind,
} => {
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,
kind,
Rc::clone(&view),
RENDER_OPTIONS,
);
let title = self
.sessions
.window_title(id)
.unwrap_or_else(|| format_title(&name, port));
let mut window = WindowBuilder::new()
.bounds(window_bounds)
.title(title)
.build();
apply_session_window_palette(&mut window);
drop_window_shadow(&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,
StreamKind::Tokens,
Rc::clone(&view),
RENDER_OPTIONS,
);
if let Some(state) = self.sessions.get_mut(id) {
state.connected = true;
state.feed(&bytes);
state.finish();
}
let mut window = WindowBuilder::new()
.bounds(window_bounds)
.title(name)
.build();
apply_session_window_palette(&mut window);
drop_window_shadow(&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 {
app.get_tile_rect()
}
fn drop_window_shadow(window: &mut Window) {
let state = window.state();
window.set_state(state & !SF_SHADOW);
}
fn session_view_bounds(window_bounds: Rect) -> Rect {
Rect::new(0, 0, window_bounds.width() - 1, 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 auto_cleanup_item(enabled: bool) -> MenuItem {
let mark = if enabled { '√' } else { ' ' };
MenuItem::new(
&format!("{mark} Auto-cleanu~p~"),
cmd::CM_AUTO_CLEANUP,
0,
0,
)
}
fn build_menu_bar(width: i16, auto_cleanup: bool) -> 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(
"~E~dit",
Menu::from_items(vec![
MenuItem::new("~C~opy", cmd::CM_COPY, 0, 0),
MenuItem::new("Select ~A~ll", cmd::CM_SELECT_ALL, 0, 0),
MenuItem::separator(),
MenuItem::flag(
"Bloc~k~ mode",
CM_TOGGLE_BLOCK_MODE,
0,
0,
turbo_vision::core::state::block_edit_mode,
),
MenuItem::separator(),
MenuItem::new("C~l~ear 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", cmd::CM_TILE_WINDOWS, 0, 0),
MenuItem::new("C~a~scade", cmd::CM_CASCADE_WINDOWS, 0, 0),
MenuItem::separator(),
MenuItem::new("Clean~u~p", cmd::CM_CLEANUP, 0, 0),
auto_cleanup_item(auto_cleanup),
]),
));
menu_bar
}
fn build_status_line(width: i16, height: i16, live: usize) -> StatusLine {
let mut status_line = 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),
],
);
status_line.set_right_indicator(|| {
turbo_vision::core::state::block_edit_mode().then(|| "▭ Block".to_string())
});
status_line
}
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 auto_cleanup_turns_a_disconnect_into_a_window_close() {
let mut console = Console::default();
console.sessions.insert(
1,
"demo".into(),
61278,
StreamKind::Tokens,
Rc::new(RefCell::new(StreamView::new(Rect::new(0, 0, 20, 5)))),
RENDER_OPTIONS,
);
let view_id = ViewId::from_u16(7);
console.window_ids.insert(view_id, 1);
console.session_windows.insert(1, view_id);
assert!(
matches!(
console.decide_server_event(ServerEvent::Disconnected { id: 1 }),
Some(ConsoleIntent::Retitle { .. })
),
"with the flag off a disconnect must only mark the title"
);
console.auto_cleanup = true;
assert_eq!(
console.decide_server_event(ServerEvent::Disconnected { id: 1 }),
Some(ConsoleIntent::CloseWindow { view_id }),
"with the flag on a disconnect must close the window"
);
}
#[test]
fn cleanup_availability_follows_the_session_connect_state() {
let mut console = Console::default();
console.sessions.insert(
1,
"demo".into(),
61278,
StreamKind::Tokens,
Rc::new(RefCell::new(StreamView::new(Rect::new(0, 0, 20, 5)))),
RENDER_OPTIONS,
);
assert!(
!console.sessions.is_connected(1),
"a session whose client has not attached yet is a cleanup candidate"
);
console.decide_server_event(ServerEvent::Attached {
id: 1,
reattached: false,
});
assert!(
console.sessions.is_connected(1),
"an attached session is not a cleanup candidate"
);
console.decide_server_event(ServerEvent::Disconnected { id: 1 });
assert!(
!console.sessions.is_connected(1),
"a session whose client went away is a cleanup candidate again"
);
}
#[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,
kind: StreamKind::Tokens,
});
assert_eq!(
intent,
Some(ConsoleIntent::CreateWindow {
id: 1,
name: "demo".into(),
port: 61278,
kind: StreamKind::Tokens,
})
);
}
#[test]
fn opened_carries_the_trace_kind_through() {
let mut console = Console::default();
let intent = console.decide_server_event(ServerEvent::Opened {
id: 1,
name: "myapp".into(),
port: 61279,
kind: StreamKind::Trace,
});
assert_eq!(
intent,
Some(ConsoleIntent::CreateWindow {
id: 1,
name: "myapp".into(),
port: 61279,
kind: StreamKind::Trace,
})
);
}
#[test]
fn bytes_decides_nothing_but_still_feeds_the_session() {
let mut console = Console::default();
console.sessions.insert(
1,
"demo".into(),
4242,
StreamKind::Tokens,
test_view(),
RENDER_OPTIONS,
);
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,
StreamKind::Tokens,
test_view(),
RENDER_OPTIONS,
);
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,
StreamKind::Tokens,
test_view(),
RENDER_OPTIONS,
);
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,
StreamKind::Tokens,
test_view(),
RENDER_OPTIONS,
);
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,
StreamKind::Tokens,
test_view(),
RENDER_OPTIONS,
);
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,
))))
}
#[test]
fn copy_is_available_only_with_a_nonempty_selection() {
let mut console = Console::default();
console.sessions.insert(
1,
"demo".into(),
0,
StreamKind::Tokens,
test_view(),
RENDER_OPTIONS,
);
assert!(!console.can_copy(None), "no focused window");
assert!(!console.can_copy(Some(1)), "nothing selected yet");
console.sessions.feed(1, b"hello\n");
console.sessions.select_all(1);
assert!(
console.can_copy(Some(1)),
"select-all over content is copyable"
);
}
}
#[cfg(test)]
pub 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;
pub(super) 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 shadowless_full_bounds_reach_the_right_edge_with_the_title_on_screen() {
let mut terminal = Terminal::with_backend(Box::new(NullBackend)).unwrap();
let desktop_bounds = Rect::new(0, 0, 80, 25);
let mut desktop = Desktop::new(desktop_bounds);
let mut window = WindowBuilder::new()
.bounds(desktop_bounds)
.title("demo :61278")
.build();
super::drop_window_shadow(&mut window);
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:?}");
let last = usize::try_from(desktop_bounds.b.x).unwrap() - 1;
let corner = terminal.buffer()[0][last].ch;
assert!(
corner != ' ' && corner != '\0',
"expected the window frame to reach the last column, found {corner:?} in {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, 39, 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, 40, 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:?}"
);
}
}
#[cfg(test)]
mod window_overlap_tests {
use std::cell::RefCell;
use std::io;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use turbo_debug_console::session::SharedStreamView;
use turbo_debug_console::streamview::StreamView;
use turbo_vision::core::draw::Cell;
use turbo_vision::core::event::Event;
use turbo_vision::core::geometry::Rect;
use turbo_vision::core::palette::{Attr, TvColor};
use turbo_vision::terminal::{Backend, Terminal};
use turbo_vision::views::desktop::Desktop;
use turbo_vision::views::view::View;
use turbo_vision::views::window::WindowBuilder;
const WRENCH: &str = "\u{1F6E0}\u{FE0F}";
fn line(s: &str) -> Vec<Cell> {
s.chars()
.map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
.collect()
}
#[derive(Clone, Default)]
struct RecordingBackend {
width: u16,
height: u16,
output: Arc<Mutex<Vec<u8>>>,
}
impl Backend for RecordingBackend {
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((self.width, self.height))
}
fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
Ok(None)
}
fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
self.output.lock().unwrap().extend_from_slice(data);
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(())
}
}
fn replay_onto_grid(bytes: &[u8], grid: &mut [Vec<char>]) {
use unicode_width::UnicodeWidthChar;
let text = std::str::from_utf8(bytes).expect("flush emits valid UTF-8");
let mut chars = text.chars().peekable();
let mut row = 0usize;
let mut col = 0usize;
while let Some(c) = chars.next() {
if c == '\u{1b}' && chars.peek() == Some(&'[') {
chars.next();
let mut params = String::new();
let mut final_byte = ' ';
for pc in chars.by_ref() {
if pc.is_ascii_digit() || pc == ';' {
params.push(pc);
} else {
final_byte = pc;
break;
}
}
if final_byte == 'H' {
let mut parts = params.split(';');
let r: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
let cix: usize = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1);
row = r.saturating_sub(1);
col = cix.saturating_sub(1);
}
continue;
}
let width = c.width().unwrap_or(0);
if row < grid.len() && col < grid[row].len() {
grid[row][col] = c;
}
col += width;
}
}
#[test]
fn a_new_window_s_flush_fully_covers_a_row_that_held_a_wide_character_underneath() {
let output: Arc<Mutex<Vec<u8>>> = Arc::default();
let backend = RecordingBackend {
width: 40,
height: 12,
output: output.clone(),
};
let mut terminal = Terminal::with_backend(Box::new(backend)).unwrap();
let mut desktop = Desktop::new(Rect::new(0, 0, 40, 12));
let mut grid = vec![vec![' '; 40]; 12];
let window_bounds = Rect::new(0, 0, 30, 8);
let mut window_a = WindowBuilder::new()
.bounds(window_bounds)
.title("a")
.build();
let view_a = Rc::new(RefCell::new(StreamView::new(super::session_view_bounds(
window_bounds,
))));
view_a.borrow_mut().push_line(&line(""));
view_a.borrow_mut().push_line(&line(""));
view_a
.borrow_mut()
.push_line(&line(&format!("{WRENCH} Reading src/dsml.rs 1:500...")));
window_a.add(Box::new(SharedStreamView(view_a)));
desktop.add(Box::new(window_a));
desktop.draw(&mut terminal);
terminal.flush().unwrap();
replay_onto_grid(&output.lock().unwrap(), &mut grid);
output.lock().unwrap().clear();
let mut window_b = WindowBuilder::new()
.bounds(window_bounds)
.title("b")
.build();
let view_b = Rc::new(RefCell::new(StreamView::new(super::session_view_bounds(
window_bounds,
))));
view_b.borrow_mut().push_line(&line("hi"));
view_b.borrow_mut().push_line(&line("there"));
window_b.add(Box::new(SharedStreamView(view_b)));
desktop.add(Box::new(window_b));
desktop.draw(&mut terminal);
terminal.flush().unwrap();
replay_onto_grid(&output.lock().unwrap(), &mut grid);
let absolute_row = usize::try_from(window_bounds.a.y).unwrap() + 1 + 2;
let interior_x0 = usize::try_from(window_bounds.a.x).unwrap() + 1;
let interior_x1 = usize::try_from(window_bounds.b.x).unwrap() - 1;
for col in interior_x0..interior_x1 {
assert_eq!(
grid[absolute_row][col], ' ',
"row {absolute_row} column {col} still shows a leftover \
character from window A: {:?}",
grid[absolute_row]
);
}
}
}
#[cfg(test)]
mod desktop_background_tests {
use super::title_render_tests::NullBackend;
use turbo_vision::core::geometry::Rect;
use turbo_vision::terminal::Terminal;
use turbo_vision::views::desktop::Desktop;
use turbo_vision::views::view::View;
#[test]
fn background_covers_every_column_after_a_widening_resize() {
let mut terminal = Terminal::with_backend(Box::new(NullBackend)).unwrap();
let mut desktop = Desktop::new(Rect::new(0, 0, 78, 25));
desktop.set_bounds(Rect::new(0, 1, 80, 24));
desktop.draw(&mut terminal);
for y in 1..24usize {
let row: String = terminal.buffer()[y].iter().map(|c| c.ch).collect();
assert_eq!(
row.chars().filter(|c| *c == '\u{2591}').count(),
80,
"row {y}: {row:?}"
);
}
}
#[test]
fn background_covers_every_desktop_column() {
let mut terminal = Terminal::with_backend(Box::new(NullBackend)).unwrap();
let mut desktop = Desktop::new(Rect::new(0, 0, 80, 25));
desktop.set_bounds(Rect::new(0, 1, 80, 24));
desktop.draw(&mut terminal);
for y in 1..24usize {
let row: String = terminal.buffer()[y].iter().map(|c| c.ch).collect();
assert_eq!(
row.chars().filter(|c| *c == '░').count(),
80,
"row {y}: {row:?}"
);
}
}
}