Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ crossterm = "0.27"
futures = "0.3"

[dev-dependencies]
rusty-testkit = { version = "0.1.1", path = "../rusty-testkit" }
tokio-test = "0.4"
rusty-bubbles = { version = "2.1.0", path = "../rusty-bubbles" }

[target.'cfg(unix)'.dev-dependencies]
rusty-testkit = { version = "0.1.1", path = "../rusty-testkit" }

[lib]
name = "rusty_bubbletea"
path = "src/lib.rs"
12 changes: 9 additions & 3 deletions UPSTREAM_MAPPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ upstream tag `v2.0.8`, checked out locally in `upstream-go/` (gitignored).
| `termios_other.go` | `src/termios_other.rs` | Non-POSIX fallback |
| `termios_unix.go` | `src/termios_unix.rs` | POSIX termios helper |
| `termios_windows.go` | `src/termios_windows.rs` | Windows console mode helper |
| `tty.go` | `src/tty.rs` | `init_terminal` / `restore_terminal` |
| `tty_unix.go` | `src/tty_unix.rs` | Unix TTY reader + raw mode |
| `tty_windows.go` | `src/tty_windows.rs` | Windows VT console helper |
| `tty.go` | `src/tty.rs` | Public terminal facade; dispatches raw mode and restore operations to the target platform while sharing window-size queries |
| `tty_unix.go` | `src/tty_unix.rs` | Unix-only TTY state, termios, and raw-file-descriptor implementation |
| `tty_windows.go` | `src/tty_windows.rs` | Windows-only safe crossterm console-mode implementation |
| `xterm.go` | `src/xterm.rs` | XTVERSION query, `TerminalVersionMsg` |

## Test Files (`*_test.go` -> `tests/`)
Expand All @@ -66,6 +66,7 @@ upstream tag `v2.0.8`, checked out locally in `upstream-go/` (gitignored).
| `mouse_test.go` | `tests/mouse_test.rs` | Mouse event suite |
| `options_test.go` | `tests/commands_test.rs` | Program option tests |
| `screen_test.go` | `tests/commands_test.rs` | Screen buffer tests |
| Rust Windows regression | `tests/windows_tty.rs` | Native Windows compile and public terminal-surface regression for the platform split |

Golden files under `testdata/` are accounted for by the corresponding Rust test
assertions (values verified against upstream output): `testdata/TestClearMsg/*.golden`
Expand Down Expand Up @@ -160,6 +161,11 @@ byte-for-byte.
| `tutorials/basics/main.go` | `examples/tutorial_basics.rs` | Tutorial: counter; quits on 'q' |
| `tutorials/commands/main.go` | `examples/tutorial_commands.rs` | Tutorial: commands; quits on 'q' |

Rust-only portability regression coverage is maintained in `tests/windows_platform.rs`; it
exercises the Windows raw-mode and terminal-restoration boundary that has no standalone
upstream Go test file. The PTY-driven `tests/interactive.rs` suite remains Unix-gated because
its real pseudo-terminal dependency is intentionally not claimed as native Windows support.

Example support files (`examples/go.mod`, `examples/go.sum`, `examples/table/demo.tape`,
per-example `README.md`/`.gif` assets, `tutorials/go.mod`, `tutorials/go.sum`) are documented
in the Support Files section.
Expand Down
2 changes: 1 addition & 1 deletion coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion src/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,9 +921,10 @@ fn uv_mouse_to_mouse(m: rusty_ultraviolet::Mouse) -> crate::mouse::Mouse {
/// CheckOptimizedMovements reads the stdin termios and reports whether hard
/// tabs (TABDLY==TAB0) and backspace (BSDLY==BS0) optimizations are enabled.
fn check_optimized_movements() -> (bool, bool) {
use std::os::fd::AsRawFd;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;

let fd = std::io::stdin().as_raw_fd();
let mut t: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut t) } != 0 {
Expand Down
92 changes: 35 additions & 57 deletions src/tty.rs
Original file line number Diff line number Diff line change
@@ -1,82 +1,60 @@
//! Cleanroom Rust port of upstream Go source file: `tty.go`
//! Upstream Target Tag / Version: `v2.0.8`
//!
//! <public-docs>
//! <user-docs>
//! # TTY Terminal Management
//!
//! TTY initialization, input stream setup, raw mode toggles, and window dimension queries for Bubble Tea v2.0.8.
//! </public-docs>
//! TTY initialization, raw mode toggles, and window dimension queries for
//! Bubble Tea v2.0.8. Unix targets use the upstream-compatible termios and
//! raw-file-descriptor implementation. Windows targets use crossterm's safe
//! console-mode API for enable, disable, initialize, and restore operations.
//! </user-docs>

use crossterm::terminal::size as term_size;
use std::sync::{Mutex, OnceLock};

/// Saved terminal state for the raw mode toggle, mirroring the upstream
/// `p.previousTtyInputState` (x/term `MakeRaw`/`Restore`).
static SAVED_TERMIOS: OnceLock<Mutex<Option<libc::termios>>> = OnceLock::new();
#[cfg(unix)]
use crate::tty_unix as platform;
#[cfg(windows)]
use crate::tty_windows as platform;
#[cfg(not(any(unix, windows)))]
use unsupported as platform;

/// Initializes terminal raw mode, mirroring the upstream `initInput` ->
/// `term.MakeRaw` path (`tty_unix.go`). Unlike a fully-zeroed `cfmakeraw`,
/// only `OPOST` is cleared from the output flags, so `TABDLY` (and thus the
/// hard-tab cursor optimization) behaves exactly as it does upstream.
pub fn enable_raw_mode() -> std::io::Result<()> {
use std::os::fd::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
let mut t: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut t) } != 0 {
return Err(std::io::Error::last_os_error());
#[cfg(not(any(unix, windows)))]
mod unsupported {
/// Reports that raw mode is unavailable on an unsupported target.
pub(super) fn enable_raw_mode() -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"raw terminal mode is unsupported on this target",
))
}
*SAVED_TERMIOS
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap() = Some(t);

// This attempts to replicate the behaviour documented for cfmakeraw in
// the termios(3) manpage, as x/term's `makeRaw` does.
t.c_iflag &= !(libc::IGNBRK
| libc::BRKINT
| libc::PARMRK
| libc::ISTRIP
| libc::INLCR
| libc::IGNCR
| libc::ICRNL
| libc::IXON);
t.c_oflag &= !libc::OPOST;
t.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
t.c_cflag &= !(libc::CSIZE | libc::PARENB);
t.c_cflag |= libc::CS8;
t.c_cc[libc::VMIN] = 1;
t.c_cc[libc::VTIME] = 0;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &t) } != 0 {
return Err(std::io::Error::last_os_error());
/// Reports that raw mode is unavailable on an unsupported target.
pub(super) fn disable_raw_mode() -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"raw terminal mode is unsupported on this target",
))
}
Ok(())
}

/// Restores the terminal state saved by [`enable_raw_mode`], mirroring the
/// upstream `term.Restore` path.
/// Enables terminal raw mode through the target platform implementation.
pub fn enable_raw_mode() -> std::io::Result<()> {
platform::enable_raw_mode()
}

/// Disables terminal raw mode through the target platform implementation.
pub fn disable_raw_mode() -> std::io::Result<()> {
use std::os::fd::AsRawFd;
let saved = SAVED_TERMIOS
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap()
.take();
if let Some(t) = saved {
let fd = std::io::stdin().as_raw_fd();
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &t) } != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
platform::disable_raw_mode()
}

/// Initializes terminal raw mode.
/// Initializes terminal raw mode through the target platform implementation.
pub fn init_terminal() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
Ok(())
}

/// Restores terminal raw mode.
/// Restores terminal raw mode through the target platform implementation.
pub fn restore_terminal() -> Result<(), Box<dyn std::error::Error>> {
disable_raw_mode()?;
Ok(())
Expand Down
81 changes: 77 additions & 4 deletions src/tty_unix.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,86 @@
//! Cleanroom Rust port of upstream Go source file: `tty_unix.go`
//! Upstream Target Tag / Version: `v2.0.8`
//!
//! <public-docs>
//! <user-docs>
//! # TTY (Unix)
//!
//! POSIX Unix TTY handle and raw mode initialization.
//! </public-docs>
//! POSIX Unix TTY handle and raw mode initialization. The implementation is
//! compiled only for Unix targets so Windows never type-checks its termios or
//! raw-file-descriptor operations.
//! </user-docs>

/// Unix TTY initialization check.
pub fn is_unix_tty() -> bool {
!cfg!(target_os = "windows")
cfg!(unix)
}

#[cfg(unix)]
mod unix {
use std::io;
use std::sync::{Mutex, OnceLock};

/// Saved terminal state for the raw mode toggle, mirroring the upstream
/// `p.previousTtyInputState` (`term.MakeRaw`/`term.Restore`).
static SAVED_TERMIOS: OnceLock<Mutex<Option<libc::termios>>> = OnceLock::new();

fn saved_termios() -> &'static Mutex<Option<libc::termios>> {
SAVED_TERMIOS.get_or_init(|| Mutex::new(None))
}

/// Enables POSIX raw mode while preserving the upstream output behavior.
pub(crate) fn enable_raw_mode() -> io::Result<()> {
use std::os::fd::AsRawFd;

let fd = io::stdin().as_raw_fd();
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut termios) } != 0 {
return Err(io::Error::last_os_error());
}
saved_termios()
.lock()
.map_err(|_| io::Error::other("saved terminal state lock poisoned"))?
.replace(termios);

// This replicates the behavior documented for cfmakeraw in the
// termios(3) manpage, as x/term's makeRaw does. Only OPOST is cleared
// from output flags so TABDLY retains the upstream hard-tab behavior.
termios.c_iflag &= !(libc::IGNBRK
| libc::BRKINT
| libc::PARMRK
| libc::ISTRIP
| libc::INLCR
| libc::IGNCR
| libc::ICRNL
| libc::IXON);
termios.c_oflag &= !libc::OPOST;
termios.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
termios.c_cflag &= !(libc::CSIZE | libc::PARENB);
termios.c_cflag |= libc::CS8;
termios.c_cc[libc::VMIN] = 1;
termios.c_cc[libc::VTIME] = 0;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}

/// Restores the terminal state saved by [`enable_raw_mode`].
pub(crate) fn disable_raw_mode() -> io::Result<()> {
use std::os::fd::AsRawFd;

let saved = saved_termios()
.lock()
.map_err(|_| io::Error::other("saved terminal state lock poisoned"))?
.take();
if let Some(termios) = saved {
let fd = io::stdin().as_raw_fd();
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) } != 0 {
return Err(io::Error::last_os_error());
}
}
Ok(())
}
}

#[cfg(unix)]
pub(crate) use unix::{disable_raw_mode, enable_raw_mode};
22 changes: 18 additions & 4 deletions src/tty_windows.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
//! Cleanroom Rust port of upstream Go source file: `tty_windows.go`
//! Upstream Target Tag / Version: `v2.0.8`
//!
//! <public-docs>
//! <user-docs>
//! # TTY (Windows)
//!
//! Windows VT console mode handle and raw mode initialization.
//! </public-docs>
//! Windows VT console mode handle and raw mode initialization. Windows uses
//! crossterm's safe console-mode operations; no Unix termios or handwritten
//! raw-file-descriptor code is compiled for this target.
//! </user-docs>

/// Windows TTY initialization check.
pub fn is_windows_tty() -> bool {
cfg!(target_os = "windows")
cfg!(windows)
}

#[cfg(windows)]
/// Enables Windows console raw mode through crossterm's safe API.
pub(crate) fn enable_raw_mode() -> std::io::Result<()> {
crossterm::terminal::enable_raw_mode()
}

#[cfg(windows)]
/// Disables Windows console raw mode through crossterm's safe API.
pub(crate) fn disable_raw_mode() -> std::io::Result<()> {
crossterm::terminal::disable_raw_mode()
}
4 changes: 4 additions & 0 deletions tests/interactive.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![cfg(unix)]

//! Interactive integration tests for the Bubble Tea examples, driven through
//! a real pseudo-terminal (Playwright-style): keys, typing, mouse, resizing,
//! and assertions on the reconstructed on-screen state.
Expand All @@ -7,6 +9,8 @@
//! interactive behavior (typing, navigating, mouse clicks) that a byte-level
//! key-sweep cannot.

#![cfg(unix)]

use rusty_testkit::PtySession;

/// The package's Cargo target directory. `cargo metadata` is authoritative:
Expand Down
10 changes: 7 additions & 3 deletions tests/tea_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,9 @@ fn test_clipboard_and_color_commands() {

// TTY helpers
let _ = rusty_bubbletea::tty::get_window_size();
let (tx_sig, _rx_sig) = std::sync::mpsc::channel();
let (_tx_sig, _rx_sig) = std::sync::mpsc::channel::<Box<dyn Msg>>();
#[cfg(unix)]
rusty_bubbletea::signals_unix::listen_for_resize(&tx_sig);
rusty_bubbletea::signals_unix::listen_for_resize(&_tx_sig);
}

#[test]
Expand Down Expand Up @@ -596,7 +596,11 @@ fn test_keyboard_and_mouse_and_env_and_logging() {
nil.reset();
assert!(nil.close().is_ok());

let mut logger = logging::log_to_file("/tmp/test_bubbletea.log", "test").unwrap();
let log_path = std::env::temp_dir()
.join("rusty-bubbletea-test.log")
.to_string_lossy()
.into_owned();
let mut logger = logging::log_to_file(&log_path, "test").unwrap();
logger.log("test message");
}

Expand Down
17 changes: 17 additions & 0 deletions tests/windows_platform.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#![cfg(windows)]

//! Windows regression coverage for the compile-safe terminal boundary.

use rusty_bubbletea::tty::{disable_raw_mode, enable_raw_mode, init_terminal, restore_terminal};

#[test]
fn raw_mode_operations_are_safe_no_ops_on_windows() {
enable_raw_mode().expect("Windows raw-mode fallback should succeed");
disable_raw_mode().expect("Windows raw-mode restoration fallback should succeed");
}

#[test]
fn terminal_lifecycle_uses_the_same_windows_boundary() {
init_terminal().expect("Windows terminal initialization fallback should succeed");
restore_terminal().expect("Windows terminal restoration fallback should succeed");
}
Loading
Loading