mirror of
https://github.com/openai/codex.git
synced 2026-05-06 06:12:59 +03:00
Compare commits
2 Commits
dev/friel/
...
fcoury/ter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359328c66a | ||
|
|
7ffad7057b |
@@ -189,16 +189,43 @@ where
|
||||
tracing::warn!("failed to read initial cursor position; defaulting to origin: {err}");
|
||||
Position { x: 0, y: 0 }
|
||||
});
|
||||
Ok(Self {
|
||||
Ok(Self::with_screen_size_and_cursor_position(
|
||||
backend,
|
||||
screen_size,
|
||||
cursor_pos,
|
||||
))
|
||||
}
|
||||
|
||||
/// Creates a new [`Terminal`] when the caller has already determined the initial cursor.
|
||||
pub fn with_options_and_cursor_position(backend: B, cursor_pos: Position) -> io::Result<Self> {
|
||||
let screen_size = backend.size()?;
|
||||
Ok(Self::with_screen_size_and_cursor_position(
|
||||
backend,
|
||||
screen_size,
|
||||
cursor_pos,
|
||||
))
|
||||
}
|
||||
|
||||
fn with_screen_size_and_cursor_position(
|
||||
backend: B,
|
||||
screen_size: Size,
|
||||
cursor_pos: Position,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)],
|
||||
current: 0,
|
||||
hidden_cursor: false,
|
||||
viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
|
||||
viewport_area: Rect::new(
|
||||
/*x*/ 0,
|
||||
cursor_pos.y,
|
||||
/*width*/ 0,
|
||||
/*height*/ 0,
|
||||
),
|
||||
last_known_screen_size: screen_size,
|
||||
last_known_cursor_pos: cursor_pos,
|
||||
visible_history_rows: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
|
||||
|
||||
@@ -164,6 +164,7 @@ mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod style;
|
||||
mod terminal_palette;
|
||||
mod terminal_probe;
|
||||
mod terminal_title;
|
||||
mod text_formatting;
|
||||
mod theme_picker;
|
||||
|
||||
@@ -76,6 +76,7 @@ mod imp {
|
||||
use crossterm::style::query_foreground_color;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
struct Cache<T> {
|
||||
attempted: bool,
|
||||
@@ -115,7 +116,7 @@ mod imp {
|
||||
pub(super) fn default_colors() -> Option<DefaultColors> {
|
||||
let cache = default_colors_cache();
|
||||
let mut cache = cache.lock().ok()?;
|
||||
cache.get_or_init_with(|| query_default_colors().unwrap_or_default())
|
||||
cache.get_or_init_with(query_default_colors)
|
||||
}
|
||||
|
||||
pub(super) fn requery_default_colors() {
|
||||
@@ -124,14 +125,71 @@ mod imp {
|
||||
if cache.attempted && cache.value.is_none() {
|
||||
return;
|
||||
}
|
||||
cache.refresh_with(|| query_default_colors().unwrap_or_default());
|
||||
cache.refresh_with(query_default_colors);
|
||||
}
|
||||
}
|
||||
|
||||
fn query_default_colors() -> std::io::Result<Option<DefaultColors>> {
|
||||
let fg = query_foreground_color()?.and_then(color_to_tuple);
|
||||
let bg = query_background_color()?.and_then(color_to_tuple);
|
||||
Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg }))
|
||||
fn query_default_colors() -> Option<DefaultColors> {
|
||||
match crate::terminal_probe::selected_probe_mode() {
|
||||
crate::terminal_probe::ProbeMode::Bounded => query_default_colors_bounded(),
|
||||
crate::terminal_probe::ProbeMode::Crossterm => query_default_colors_crossterm(),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_default_colors_bounded() -> Option<DefaultColors> {
|
||||
let start = Instant::now();
|
||||
let result = crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT);
|
||||
let elapsed = start.elapsed();
|
||||
let outcome = match &result {
|
||||
Ok(Some(_)) => "ok",
|
||||
Ok(None) => "no_response",
|
||||
Err(_) => "error",
|
||||
};
|
||||
crate::terminal_probe::record_probe_timing(
|
||||
"default_colors",
|
||||
crate::terminal_probe::ProbeMode::Bounded,
|
||||
elapsed,
|
||||
outcome,
|
||||
result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|err| err as &dyn std::fmt::Display),
|
||||
);
|
||||
result.ok().flatten().map(|colors| DefaultColors {
|
||||
fg: colors.fg,
|
||||
bg: colors.bg,
|
||||
})
|
||||
}
|
||||
|
||||
fn query_default_colors_crossterm() -> Option<DefaultColors> {
|
||||
let fg = query_default_color_crossterm("default_foreground", query_foreground_color);
|
||||
let bg = query_default_color_crossterm("default_background", query_background_color);
|
||||
fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg })
|
||||
}
|
||||
|
||||
fn query_default_color_crossterm(
|
||||
probe: &'static str,
|
||||
query: fn() -> std::io::Result<Option<CrosstermColor>>,
|
||||
) -> Option<(u8, u8, u8)> {
|
||||
let start = Instant::now();
|
||||
let result = query();
|
||||
let elapsed = start.elapsed();
|
||||
let outcome = match &result {
|
||||
Ok(Some(_)) => "ok",
|
||||
Ok(None) => "no_response",
|
||||
Err(_) => "error",
|
||||
};
|
||||
crate::terminal_probe::record_probe_timing(
|
||||
probe,
|
||||
crate::terminal_probe::ProbeMode::Crossterm,
|
||||
elapsed,
|
||||
outcome,
|
||||
result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|err| err as &dyn std::fmt::Display),
|
||||
);
|
||||
result.ok().flatten().and_then(color_to_tuple)
|
||||
}
|
||||
|
||||
fn color_to_tuple(color: CrosstermColor) -> Option<(u8, u8, u8)> {
|
||||
|
||||
473
codex-rs/tui/src/terminal_probe.rs
Normal file
473
codex-rs/tui/src/terminal_probe.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
//! Short, best-effort terminal response probes.
|
||||
//!
|
||||
//! Crossterm's public helpers wait up to two seconds for terminal responses. That is too long for
|
||||
//! TUI startup, where unsupported terminals should simply fall back to conservative defaults.
|
||||
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
const PROBE_MODE_ENV_VAR: &str = "CODEX_TUI_TERMINAL_PROBE_MODE";
|
||||
const TRACE_PROBES_ENV_VAR: &str = "CODEX_TUI_TRACE_TERMINAL_PROBES";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ProbeMode {
|
||||
Bounded,
|
||||
Crossterm,
|
||||
}
|
||||
|
||||
impl ProbeMode {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bounded => "bounded",
|
||||
Self::Crossterm => "crossterm",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProbeMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn selected_probe_mode() -> ProbeMode {
|
||||
probe_mode_for(std::env::var(PROBE_MODE_ENV_VAR).ok().as_deref())
|
||||
}
|
||||
|
||||
fn probe_mode_for(value: Option<&str>) -> ProbeMode {
|
||||
match value.map(str::trim) {
|
||||
Some(value)
|
||||
if value.eq_ignore_ascii_case("crossterm")
|
||||
|| value.eq_ignore_ascii_case("legacy")
|
||||
|| value.eq_ignore_ascii_case("blocking") =>
|
||||
{
|
||||
ProbeMode::Crossterm
|
||||
}
|
||||
Some(value)
|
||||
if value.eq_ignore_ascii_case("bounded")
|
||||
|| value.eq_ignore_ascii_case("fast")
|
||||
|| value.eq_ignore_ascii_case("opportunistic") =>
|
||||
{
|
||||
ProbeMode::Bounded
|
||||
}
|
||||
_ => ProbeMode::Bounded,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trace_probes_enabled() -> bool {
|
||||
parse_bool_env(std::env::var(TRACE_PROBES_ENV_VAR).ok().as_deref())
|
||||
.unwrap_or(/*default*/ false)
|
||||
}
|
||||
|
||||
fn parse_bool_env(value: Option<&str>) -> Option<bool> {
|
||||
match value.map(str::trim) {
|
||||
Some("1") => Some(true),
|
||||
Some(value) if value.eq_ignore_ascii_case("true") => Some(true),
|
||||
Some(value) if value.eq_ignore_ascii_case("yes") => Some(true),
|
||||
Some(value) if value.eq_ignore_ascii_case("on") => Some(true),
|
||||
Some("0") => Some(false),
|
||||
Some(value) if value.eq_ignore_ascii_case("false") => Some(false),
|
||||
Some(value) if value.eq_ignore_ascii_case("no") => Some(false),
|
||||
Some(value) if value.eq_ignore_ascii_case("off") => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_probe_timing(
|
||||
probe: &'static str,
|
||||
mode: ProbeMode,
|
||||
elapsed: Duration,
|
||||
outcome: &'static str,
|
||||
error: Option<&dyn fmt::Display>,
|
||||
) {
|
||||
if !trace_probes_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed_ms = elapsed.as_secs_f64() * 1000.0;
|
||||
match error {
|
||||
Some(error) => tracing::info!(
|
||||
target: "codex_tui::terminal_probe",
|
||||
probe,
|
||||
mode = %mode,
|
||||
elapsed_ms,
|
||||
outcome,
|
||||
error = %error,
|
||||
"terminal capability probe"
|
||||
),
|
||||
None => tracing::info!(
|
||||
target: "codex_tui::terminal_probe",
|
||||
probe,
|
||||
mode = %mode,
|
||||
elapsed_ms,
|
||||
outcome,
|
||||
"terminal capability probe"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
mod imp {
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
use ratatui::layout::Position;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) struct DefaultColors {
|
||||
pub(crate) fg: (u8, u8, u8),
|
||||
pub(crate) bg: (u8, u8, u8),
|
||||
}
|
||||
|
||||
struct Tty {
|
||||
file: File,
|
||||
original_flags: libc::c_int,
|
||||
}
|
||||
|
||||
impl Tty {
|
||||
fn open() -> io::Result<Self> {
|
||||
let file = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
|
||||
let fd = file.as_raw_fd();
|
||||
let original_flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if original_flags == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if unsafe { libc::fcntl(fd, libc::F_SETFL, original_flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Self {
|
||||
file,
|
||||
original_flags,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
|
||||
self.file.write_all(bytes)?;
|
||||
self.file.flush()
|
||||
}
|
||||
|
||||
fn read_available(&mut self, buffer: &mut Vec<u8>) -> io::Result<()> {
|
||||
let mut chunk = [0_u8; 256];
|
||||
loop {
|
||||
let count = unsafe {
|
||||
libc::read(
|
||||
self.file.as_raw_fd(),
|
||||
chunk.as_mut_ptr().cast::<libc::c_void>(),
|
||||
chunk.len(),
|
||||
)
|
||||
};
|
||||
if count > 0 {
|
||||
buffer.extend_from_slice(&chunk[..count as usize]);
|
||||
continue;
|
||||
}
|
||||
if count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
if matches!(
|
||||
err.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_readable(&self, timeout: Duration) -> io::Result<bool> {
|
||||
let mut fd = libc::pollfd {
|
||||
fd: self.file.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let timeout_ms = timeout.as_millis().min(libc::c_int::MAX as u128) as libc::c_int;
|
||||
loop {
|
||||
let result = unsafe {
|
||||
libc::poll(&mut fd, /*nfds*/ 1, timeout_ms)
|
||||
};
|
||||
if result > 0 {
|
||||
return Ok((fd.revents & libc::POLLIN) != 0);
|
||||
}
|
||||
if result == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
if err.kind() != io::ErrorKind::Interrupted {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tty {
|
||||
fn drop(&mut self) {
|
||||
let _ =
|
||||
unsafe { libc::fcntl(self.file.as_raw_fd(), libc::F_SETFL, self.original_flags) };
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cursor_position(timeout: Duration) -> io::Result<Option<Position>> {
|
||||
let mut tty = Tty::open()?;
|
||||
tty.write_all(b"\x1B[6n")?;
|
||||
let Some(response) = read_until(&mut tty, timeout, parse_cursor_position)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
pub(crate) fn default_colors(timeout: Duration) -> io::Result<Option<DefaultColors>> {
|
||||
let mut tty = Tty::open()?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
let Some(fg) = query_color_slot(&mut tty, /*slot*/ 10, remaining(deadline))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(bg) = query_color_slot(&mut tty, /*slot*/ 11, remaining(deadline))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(DefaultColors { fg, bg }))
|
||||
}
|
||||
|
||||
pub(crate) fn keyboard_enhancement_supported(timeout: Duration) -> io::Result<Option<bool>> {
|
||||
let mut tty = Tty::open()?;
|
||||
tty.write_all(b"\x1B[?u\x1B[c")?;
|
||||
read_until(&mut tty, timeout, parse_keyboard_enhancement_support)
|
||||
}
|
||||
|
||||
fn query_color_slot(
|
||||
tty: &mut Tty,
|
||||
slot: u8,
|
||||
timeout: Duration,
|
||||
) -> io::Result<Option<(u8, u8, u8)>> {
|
||||
write!(tty.file, "\x1B]{slot};?\x1B\\")?;
|
||||
tty.file.flush()?;
|
||||
read_until(tty, timeout, |buffer| parse_osc_color(buffer, slot))
|
||||
}
|
||||
|
||||
fn read_until<T>(
|
||||
tty: &mut Tty,
|
||||
timeout: Duration,
|
||||
mut parse: impl FnMut(&[u8]) -> Option<T>,
|
||||
) -> io::Result<Option<T>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut buffer = Vec::new();
|
||||
loop {
|
||||
tty.read_available(&mut buffer)?;
|
||||
if let Some(value) = parse(&buffer) {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
if !tty.poll_readable(deadline.saturating_duration_since(now))? {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining(deadline: Instant) -> Duration {
|
||||
deadline.saturating_duration_since(Instant::now())
|
||||
}
|
||||
|
||||
fn parse_cursor_position(buffer: &[u8]) -> Option<Position> {
|
||||
for start in find_all_subslices(buffer, b"\x1B[") {
|
||||
let rest = &buffer[start + 2..];
|
||||
let Some(end) = rest.iter().position(|b| *b == b'R') else {
|
||||
continue;
|
||||
};
|
||||
let payload = std::str::from_utf8(&rest[..end]).ok()?;
|
||||
let (row, col) = payload.split_once(';')?;
|
||||
let row = row.parse::<u16>().ok()?.saturating_sub(1);
|
||||
let col = col.parse::<u16>().ok()?.saturating_sub(1);
|
||||
return Some(Position { x: col, y: row });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> {
|
||||
let prefix = format!("\x1B]{slot};");
|
||||
let start = find_subslice(buffer, prefix.as_bytes())?;
|
||||
let payload_start = start + prefix.len();
|
||||
let rest = &buffer[payload_start..];
|
||||
let (payload_end, _terminator_len) = osc_payload_end(rest)?;
|
||||
let payload = std::str::from_utf8(&rest[..payload_end]).ok()?;
|
||||
parse_osc_rgb(payload)
|
||||
}
|
||||
|
||||
fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> {
|
||||
let mut idx = 0;
|
||||
while idx < buffer.len() {
|
||||
match buffer[idx] {
|
||||
0x07 => return Some((idx, 1)),
|
||||
0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)),
|
||||
_ => idx += 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> {
|
||||
let (prefix, values) = payload.trim().split_once(':')?;
|
||||
if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = values.split('/');
|
||||
let r = parse_osc_component(parts.next()?)?;
|
||||
let g = parse_osc_component(parts.next()?)?;
|
||||
let b = parse_osc_component(parts.next()?)?;
|
||||
if prefix.eq_ignore_ascii_case("rgba") {
|
||||
parse_osc_component(parts.next()?)?;
|
||||
}
|
||||
parts.next().is_none().then_some((r, g, b))
|
||||
}
|
||||
|
||||
fn parse_osc_component(component: &str) -> Option<u8> {
|
||||
match component.len() {
|
||||
2 => u8::from_str_radix(component, 16).ok(),
|
||||
4 => u16::from_str_radix(component, 16)
|
||||
.ok()
|
||||
.map(|value| (value / 257) as u8),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_keyboard_enhancement_support(buffer: &[u8]) -> Option<bool> {
|
||||
if find_keyboard_flags(buffer).is_some() {
|
||||
return Some(true);
|
||||
}
|
||||
find_primary_device_attributes(buffer).map(|_| false)
|
||||
}
|
||||
|
||||
fn find_keyboard_flags(buffer: &[u8]) -> Option<KeyboardEnhancementFlags> {
|
||||
for start in find_all_subslices(buffer, b"\x1B[?") {
|
||||
let rest = &buffer[start + 3..];
|
||||
let Some(end) = rest.iter().position(|b| *b == b'u') else {
|
||||
continue;
|
||||
};
|
||||
if end == 0 {
|
||||
continue;
|
||||
}
|
||||
let bits = std::str::from_utf8(&rest[..end]).ok()?.parse::<u8>().ok()?;
|
||||
let mut flags = KeyboardEnhancementFlags::empty();
|
||||
if bits & 1 != 0 {
|
||||
flags |= KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
|
||||
}
|
||||
if bits & 2 != 0 {
|
||||
flags |= KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
|
||||
}
|
||||
if bits & 4 != 0 {
|
||||
flags |= KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
|
||||
}
|
||||
if bits & 8 != 0 {
|
||||
flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
||||
}
|
||||
return Some(flags);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_primary_device_attributes(buffer: &[u8]) -> Option<()> {
|
||||
for start in find_all_subslices(buffer, b"\x1B[?") {
|
||||
let rest = &buffer[start + 3..];
|
||||
let Some(end) = rest.iter().position(|b| *b == b'c') else {
|
||||
continue;
|
||||
};
|
||||
if end > 0 && rest[..end].iter().all(|b| b.is_ascii_digit() || *b == b';') {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
fn find_all_subslices<'a>(
|
||||
haystack: &'a [u8],
|
||||
needle: &'a [u8],
|
||||
) -> impl Iterator<Item = usize> + 'a {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.enumerate()
|
||||
.filter_map(move |(idx, window)| (window == needle).then_some(idx))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_cursor_position_as_zero_based() {
|
||||
assert_eq!(
|
||||
parse_cursor_position(b"\x1B[20;10R"),
|
||||
Some(Position { x: 9, y: 19 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_osc_colors_with_bel_and_st() {
|
||||
assert_eq!(
|
||||
parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10),
|
||||
Some((255, 127, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11),
|
||||
Some((0, 128, 255))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_keyboard_enhancement_flags_and_pda_fallback() {
|
||||
assert_eq!(parse_keyboard_enhancement_support(b"\x1B[?7u"), Some(true));
|
||||
assert_eq!(
|
||||
parse_keyboard_enhancement_support(b"\x1B[?64;1;2c"),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(parse_keyboard_enhancement_support(b""), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) use imp::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod env_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn probe_mode_parses_known_values() {
|
||||
assert_eq!(probe_mode_for(Some("crossterm")), ProbeMode::Crossterm);
|
||||
assert_eq!(probe_mode_for(Some("legacy")), ProbeMode::Crossterm);
|
||||
assert_eq!(probe_mode_for(Some("blocking")), ProbeMode::Crossterm);
|
||||
assert_eq!(probe_mode_for(Some("bounded")), ProbeMode::Bounded);
|
||||
assert_eq!(probe_mode_for(Some("fast")), ProbeMode::Bounded);
|
||||
assert_eq!(probe_mode_for(/*value*/ None), ProbeMode::Bounded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_env_parses_common_values() {
|
||||
assert_eq!(parse_bool_env(Some("1")), Some(true));
|
||||
assert_eq!(parse_bool_env(Some("true")), Some(true));
|
||||
assert_eq!(parse_bool_env(Some("yes")), Some(true));
|
||||
assert_eq!(parse_bool_env(Some("on")), Some(true));
|
||||
assert_eq!(parse_bool_env(Some("0")), Some(false));
|
||||
assert_eq!(parse_bool_env(Some("false")), Some(false));
|
||||
assert_eq!(parse_bool_env(Some("no")), Some(false));
|
||||
assert_eq!(parse_bool_env(Some("off")), Some(false));
|
||||
assert_eq!(parse_bool_env(Some("bogus")), None);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::Command;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
@@ -282,11 +283,138 @@ pub fn init() -> Result<Terminal> {
|
||||
|
||||
set_panic_hook();
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let tui = CustomTerminal::with_options(backend)?;
|
||||
let mut backend = CrosstermBackend::new(stdout());
|
||||
|
||||
#[cfg(unix)]
|
||||
let cursor_pos = match crate::terminal_probe::selected_probe_mode() {
|
||||
crate::terminal_probe::ProbeMode::Bounded => {
|
||||
let start = Instant::now();
|
||||
let result =
|
||||
crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT);
|
||||
let elapsed = start.elapsed();
|
||||
let outcome = match &result {
|
||||
Ok(Some(_)) => "ok",
|
||||
Ok(None) => "no_response",
|
||||
Err(_) => "error",
|
||||
};
|
||||
crate::terminal_probe::record_probe_timing(
|
||||
"cursor_position",
|
||||
crate::terminal_probe::ProbeMode::Bounded,
|
||||
elapsed,
|
||||
outcome,
|
||||
result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|err| err as &dyn std::fmt::Display),
|
||||
);
|
||||
match result {
|
||||
Ok(Some(pos)) => pos,
|
||||
Ok(None) => {
|
||||
tracing::warn!("initial cursor position probe timed out; defaulting to origin");
|
||||
Position { x: 0, y: 0 }
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to read initial cursor position; defaulting to origin: {err}"
|
||||
);
|
||||
Position { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::terminal_probe::ProbeMode::Crossterm => {
|
||||
cursor_position_with_crossterm_timing(&mut backend)
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let cursor_pos = cursor_position_with_crossterm_timing(&mut backend);
|
||||
|
||||
let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?;
|
||||
Ok(tui)
|
||||
}
|
||||
|
||||
fn cursor_position_with_crossterm_timing(backend: &mut CrosstermBackend<Stdout>) -> Position {
|
||||
let start = Instant::now();
|
||||
let result = backend.get_cursor_position();
|
||||
let elapsed = start.elapsed();
|
||||
crate::terminal_probe::record_probe_timing(
|
||||
"cursor_position",
|
||||
crate::terminal_probe::ProbeMode::Crossterm,
|
||||
elapsed,
|
||||
if result.is_ok() { "ok" } else { "error" },
|
||||
result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|err| err as &dyn std::fmt::Display),
|
||||
);
|
||||
result.unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to read initial cursor position; defaulting to origin: {err}");
|
||||
Position { x: 0, y: 0 }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn keyboard_enhancement_supported_with_timing() -> bool {
|
||||
match crate::terminal_probe::selected_probe_mode() {
|
||||
crate::terminal_probe::ProbeMode::Bounded => {
|
||||
let start = Instant::now();
|
||||
let result = crate::terminal_probe::keyboard_enhancement_supported(
|
||||
crate::terminal_probe::DEFAULT_TIMEOUT,
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
let outcome = match &result {
|
||||
Ok(Some(true)) => "supported",
|
||||
Ok(Some(false)) => "unsupported",
|
||||
Ok(None) => "no_response",
|
||||
Err(_) => "error",
|
||||
};
|
||||
crate::terminal_probe::record_probe_timing(
|
||||
"keyboard_enhancement",
|
||||
crate::terminal_probe::ProbeMode::Bounded,
|
||||
elapsed,
|
||||
outcome,
|
||||
result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|err| err as &dyn std::fmt::Display),
|
||||
);
|
||||
result
|
||||
.unwrap_or(/*default*/ None)
|
||||
.unwrap_or(/*default*/ false)
|
||||
}
|
||||
crate::terminal_probe::ProbeMode::Crossterm => {
|
||||
keyboard_enhancement_supported_with_crossterm_timing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn keyboard_enhancement_supported_with_timing() -> bool {
|
||||
keyboard_enhancement_supported_with_crossterm_timing()
|
||||
}
|
||||
|
||||
fn keyboard_enhancement_supported_with_crossterm_timing() -> bool {
|
||||
let start = Instant::now();
|
||||
let result = supports_keyboard_enhancement();
|
||||
let elapsed = start.elapsed();
|
||||
let outcome = match &result {
|
||||
Ok(true) => "supported",
|
||||
Ok(false) => "unsupported",
|
||||
Err(_) => "error",
|
||||
};
|
||||
crate::terminal_probe::record_probe_timing(
|
||||
"keyboard_enhancement",
|
||||
crate::terminal_probe::ProbeMode::Crossterm,
|
||||
elapsed,
|
||||
outcome,
|
||||
result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|err| err as &dyn std::fmt::Display),
|
||||
);
|
||||
result.unwrap_or(/*default*/ false)
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
let hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
@@ -339,7 +467,7 @@ impl Tui {
|
||||
// Detect keyboard enhancement support before any EventStream is created so the
|
||||
// crossterm poller can acquire its lock without contention.
|
||||
let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled()
|
||||
&& supports_keyboard_enhancement().unwrap_or(false);
|
||||
&& keyboard_enhancement_supported_with_timing();
|
||||
// Cache this to avoid contention with the event reader.
|
||||
supports_color::on_cached(supports_color::Stream::Stdout);
|
||||
let _ = crate::terminal_palette::default_colors();
|
||||
|
||||
@@ -248,7 +248,9 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
|
||||
Event::FocusGained => {
|
||||
self.terminal_focused.store(true, Ordering::Relaxed);
|
||||
self.broker.pause_events();
|
||||
crate::terminal_palette::requery_default_colors();
|
||||
self.broker.resume_events();
|
||||
Some(TuiEvent::Draw)
|
||||
}
|
||||
Event::FocusLost => {
|
||||
|
||||
Reference in New Issue
Block a user