parent
d559eb10ef
commit
1a1b134ffa
@ -0,0 +1,75 @@ |
||||
-- Showcase of the tui terminal-control functions and markup action tags: |
||||
-- inline redraws, the alternate screen, absolute cursor addressing, scroll |
||||
-- regions. Needs a real terminal — piped, everything degrades to no-ops. |
||||
-- |
||||
-- Run with: cargo run -- lua/tui_term.lua |
||||
|
||||
tui.setTitle("tui terminal demo") |
||||
|
||||
---------------------------------------------------------------------- |
||||
-- 1. Inline progress: redraw one line with the <clear=line> action tag |
||||
---------------------------------------------------------------------- |
||||
for i = 1, 30 do |
||||
tui.raw(tui.styled(("<clear=line>working <info>%d/30</info> %s"):format(i, ("#"):rep(i)))) |
||||
os.sleep(0.03) |
||||
end |
||||
tui.raw(tui.styled("<clear=line>")) |
||||
tui.success("progress loop finished") |
||||
|
||||
---------------------------------------------------------------------- |
||||
-- 2. Full-screen: alternate screen + absolute cursor addressing |
||||
---------------------------------------------------------------------- |
||||
tui.altScreen(true) |
||||
tui.clearScreen() |
||||
tui.showCursor(false) |
||||
|
||||
local info = tui.screenInfo() |
||||
local w, h = info.width or 80, info.height or 24 |
||||
tui.moveTo(2, 3) |
||||
tui.raw(tui.styled(("<info;b>alternate screen</> — %dx%d cells"):format(w, h))) |
||||
|
||||
-- A little box drawn with moveTo. |
||||
for row = 4, 8 do |
||||
tui.moveTo(row, 3) |
||||
if row == 4 or row == 8 then |
||||
tui.raw("+" .. ("-"):rep(20) .. "+") |
||||
else |
||||
tui.raw("|" .. (" "):rep(20) .. "|") |
||||
end |
||||
end |
||||
tui.moveTo(6, 6) |
||||
tui.raw(tui.styled("<yellow>boxed content</>")) |
||||
|
||||
tui.moveTo(10, 3) |
||||
tui.raw("where is the cursor? ") |
||||
local row, col = tui.cursorPos() |
||||
tui.raw(tui.styled(("at <b>row %s, col %s</b>"):format(tostring(row), tostring(col)))) |
||||
|
||||
tui.moveTo(12, 3) |
||||
tui.raw("back to the normal screen in 2 seconds...") |
||||
os.sleep(2) |
||||
|
||||
tui.altScreen(false) |
||||
tui.showCursor(true) |
||||
print("back — scrollback intact.") |
||||
|
||||
---------------------------------------------------------------------- |
||||
-- 3. Scroll region: log lines scrolling under a fixed header |
||||
---------------------------------------------------------------------- |
||||
print() |
||||
print(tui.styled("<b>FIXED HEADER</b> (lines below scroll, this one stays)")) |
||||
local top = select(1, tui.cursorPos()) |
||||
if top then |
||||
tui.scrollRegion(top, top + 5) |
||||
for i = 1, 15 do |
||||
tui.moveTo(top + 5, 1) |
||||
tui.raw(("\nlog line %d"):format(i)) |
||||
os.sleep(0.1) |
||||
end |
||||
tui.scrollRegion() |
||||
tui.moveTo(top + 6, 1) |
||||
end |
||||
print("scroll region reset.") |
||||
|
||||
-- Deliberately no title restore: the runtime pops the pushed title (and |
||||
-- would also leave the alt screen / re-show the cursor) on exit. |
||||
@ -0,0 +1,586 @@ |
||||
//! Terminal control bindings: cursor movement, line/screen clearing, the
|
||||
//! alternate screen, scroll regions, window title, clipboard and soft reset —
|
||||
//! the xterm-style escape sequences behind rich TUIs, exposed as plain
|
||||
//! functions (`tui.moveTo`, `tui.clearLine`, `tui.altScreen`, …).
|
||||
//!
|
||||
//! Two rules hold for every function here:
|
||||
//!
|
||||
//! - **Arguments are validated first, unconditionally** — a typo fails loudly
|
||||
//! even when nothing would be emitted (same contract as the prompts).
|
||||
//! - **Sequences go only to a real terminal.** When stdout is not a tty the
|
||||
//! functions validate and then do nothing: piped output must never contain
|
||||
//! control bytes. This is a plain tty check, deliberately not the color
|
||||
//! heuristics — `NO_COLOR` turns off colors, not cursor control. (Markup
|
||||
//! *action tags* on the other hand ride inside `tui.styled` strings and
|
||||
//! follow its color detection; see [`super::markup`].)
|
||||
//!
|
||||
//! Stateful operations (alternate screen, hidden cursor, cursor style, scroll
|
||||
//! region, pushed title) are tracked in a process-wide [`TermState`];
|
||||
//! [`cleanup`] undoes whatever is still active and runs on every exit path —
|
||||
//! normal return, script error, and panic (hooked in `main`). A script that
|
||||
//! forgets `tui.altScreen(false)` cannot wreck the user's terminal.
|
||||
|
||||
use std::io::IsTerminal; |
||||
use std::sync::{Mutex, MutexGuard}; |
||||
|
||||
use base64::Engine; |
||||
use crossterm::{cursor, terminal}; |
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
use super::opts::{int_of, lua_value_brief, Opts}; |
||||
use super::output::write_stdout; |
||||
use super::{ext_err, prompts}; |
||||
|
||||
/// Terminal modes that outlive the call that set them. One instance per
|
||||
/// process (not per Lua state): there is one terminal, and the cleanup runs
|
||||
/// from `main` / the panic hook where no Lua state may exist anymore.
|
||||
struct TermState { |
||||
alt_screen: bool, |
||||
cursor_hidden: bool, |
||||
cursor_styled: bool, |
||||
scroll_region: bool, |
||||
title_pushed: bool, |
||||
} |
||||
|
||||
const CLEAN: TermState = TermState { |
||||
alt_screen: false, |
||||
cursor_hidden: false, |
||||
cursor_styled: false, |
||||
scroll_region: false, |
||||
title_pushed: false, |
||||
}; |
||||
|
||||
static STATE: Mutex<TermState> = Mutex::new(CLEAN); |
||||
|
||||
fn state() -> MutexGuard<'static, TermState> { |
||||
STATE.lock().unwrap_or_else(|e| e.into_inner()) |
||||
} |
||||
|
||||
/// Everything needed to undo `st`, in an order that works from any state:
|
||||
/// scroll region and cursor first (they may belong to the alternate screen),
|
||||
/// then leave the alternate screen, then pop the title.
|
||||
fn restore_sequence(st: &TermState) -> String { |
||||
let mut out = String::new(); |
||||
if st.scroll_region { |
||||
out.push_str("\x1b[r"); |
||||
} |
||||
if st.cursor_styled { |
||||
out.push_str("\x1b[0 q"); |
||||
} |
||||
if st.cursor_hidden { |
||||
out.push_str(&seq(cursor::Show)); |
||||
} |
||||
if st.alt_screen { |
||||
out.push_str(&seq(terminal::LeaveAlternateScreen)); |
||||
} |
||||
if st.title_pushed { |
||||
out.push_str("\x1b[23;2t"); |
||||
} |
||||
out |
||||
} |
||||
|
||||
/// Restore any terminal state a script left behind. Called by `main` on every
|
||||
/// exit path (normal, error, panic hook); idempotent — the tracked state is
|
||||
/// cleared, so a second call emits nothing.
|
||||
pub(crate) fn cleanup() { |
||||
let mut st = state(); |
||||
let out = restore_sequence(&st); |
||||
*st = CLEAN; |
||||
drop(st); |
||||
if !out.is_empty() { |
||||
write_stdout(out.as_bytes()); |
||||
} |
||||
} |
||||
|
||||
/// The ANSI bytes of a crossterm command (writing to a `String` cannot fail).
|
||||
fn seq(cmd: impl crossterm::Command) -> String { |
||||
let mut s = String::new(); |
||||
cmd.write_ansi(&mut s).expect("write_ansi to String"); |
||||
s |
||||
} |
||||
|
||||
/// Whether control sequences are emitted at all — see the module docs.
|
||||
fn active() -> bool { |
||||
std::io::stdout().is_terminal() |
||||
} |
||||
|
||||
/// Send a fire-and-forget sequence, or drop it when stdout is not a terminal.
|
||||
fn emit(s: &str) { |
||||
if active() { |
||||
write_stdout(s.as_bytes()); |
||||
} |
||||
} |
||||
|
||||
/// `lua_value_brief`, but integral values print their value (a rejected `0`
|
||||
/// or `2` should say so, not "integer").
|
||||
fn brief(v: &LuaValue) -> String { |
||||
match int_of(v) { |
||||
Some(n) => n.to_string(), |
||||
None => lua_value_brief(v), |
||||
} |
||||
} |
||||
|
||||
/// A 1-based screen coordinate, or nil. Values beyond u16 are clamped — the
|
||||
/// terminal clamps to the screen edge anyway.
|
||||
fn opt_coord(fname: &str, what: &str, v: &LuaValue) -> LuaResult<Option<u16>> { |
||||
if v.is_nil() { |
||||
return Ok(None); |
||||
} |
||||
match int_of(v) { |
||||
Some(n) if n >= 1 => Ok(Some(n.min(u16::MAX as i64) as u16)), |
||||
_ => Err(ext_err!("{fname}: {what} must be a positive integer (got {})", brief(v))), |
||||
} |
||||
} |
||||
|
||||
/// A relative-move distance; nil counts as 0 (no movement on that axis).
|
||||
fn opt_delta(fname: &str, what: &str, v: &LuaValue) -> LuaResult<i64> { |
||||
if v.is_nil() { |
||||
return Ok(0); |
||||
} |
||||
int_of(v) |
||||
.ok_or_else(|| ext_err!("{fname}: {what} must be an integer (got {})", lua_value_brief(v))) |
||||
} |
||||
|
||||
/// Magnitude of a delta as the u16 a CSI parameter can carry.
|
||||
fn magnitude(n: i64) -> u16 { |
||||
n.unsigned_abs().min(u16::MAX as u64) as u16 |
||||
} |
||||
|
||||
/// The shared clear-mode argument: nil/0 = whole, -1 = to start, 1 = to end.
|
||||
fn clear_mode(fname: &str, v: &LuaValue) -> LuaResult<i64> { |
||||
if v.is_nil() { |
||||
return Ok(0); |
||||
} |
||||
match int_of(v) { |
||||
Some(m @ -1..=1) => Ok(m), |
||||
_ => Err(ext_err!( |
||||
"{fname}: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got {}", |
||||
brief(v) |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// A required string argument that is valid UTF-8.
|
||||
fn string_arg(fname: &str, what: &str, v: LuaValue) -> LuaResult<String> { |
||||
match v { |
||||
LuaValue::String(s) => Ok(s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{fname}: {what} must be valid UTF-8"))? |
||||
.to_string()), |
||||
other => Err(ext_err!("{fname}: {what} must be a string (got {})", other.type_name())), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> { |
||||
// tui.moveTo(row, col): absolute cursor position, 1-based like the
|
||||
// terminal itself; nil keeps that axis (CUP / VPA / CHA).
|
||||
tui.raw_set( |
||||
"moveTo", |
||||
lua.create_function(|_, (row, col): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.moveTo"; |
||||
let row = opt_coord(F, "row", &row)?; |
||||
let col = opt_coord(F, "col", &col)?; |
||||
let s = match (row, col) { |
||||
(Some(r), Some(c)) => seq(cursor::MoveTo(c - 1, r - 1)), |
||||
(Some(r), None) => seq(cursor::MoveToRow(r - 1)), |
||||
(None, Some(c)) => seq(cursor::MoveToColumn(c - 1)), |
||||
(None, None) => return Err(ext_err!("{F}: row and col cannot both be nil")), |
||||
}; |
||||
emit(&s); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.move(dx, dy): relative move — positive dx right, positive dy down
|
||||
// (screen coordinates); nil is 0. The terminal clamps at the edges.
|
||||
tui.raw_set( |
||||
"move", |
||||
lua.create_function(|_, (dx, dy): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.move"; |
||||
let dx = opt_delta(F, "dx", &dx)?; |
||||
let dy = opt_delta(F, "dy", &dy)?; |
||||
let mut s = String::new(); |
||||
match dx { |
||||
1.. => s.push_str(&seq(cursor::MoveRight(magnitude(dx)))), |
||||
..0 => s.push_str(&seq(cursor::MoveLeft(magnitude(dx)))), |
||||
0 => {} |
||||
} |
||||
match dy { |
||||
1.. => s.push_str(&seq(cursor::MoveDown(magnitude(dy)))), |
||||
..0 => s.push_str(&seq(cursor::MoveUp(magnitude(dy)))), |
||||
0 => {} |
||||
} |
||||
emit(&s); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.saveCursor() / tui.restoreCursor(): DECSC/DECRC — position, but
|
||||
// also the SGR state, one slot deep.
|
||||
tui.raw_set( |
||||
"saveCursor", |
||||
lua.create_function(|_, ()| { |
||||
emit(&seq(cursor::SavePosition)); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
tui.raw_set( |
||||
"restoreCursor", |
||||
lua.create_function(|_, ()| { |
||||
emit(&seq(cursor::RestorePosition)); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.cursorPos() -> row, col (1-based), or nil off-tty. The DSR-CPR
|
||||
// round trip reads the terminal's reply from its input, so it runs under
|
||||
// the prompt lock: a concurrent coroutine's prompt must not eat the reply.
|
||||
tui.raw_set( |
||||
"cursorPos", |
||||
lua.create_async_function(|_, ()| async { |
||||
if !active() { |
||||
return Ok((None, None)); |
||||
} |
||||
let pos = tokio::task::spawn_blocking(|| { |
||||
let _guard = prompts::PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); |
||||
cursor::position().ok() |
||||
}) |
||||
.await |
||||
.ok() |
||||
.flatten(); |
||||
Ok(match pos { |
||||
Some((col, row)) => (Some(row as u32 + 1), Some(col as u32 + 1)), |
||||
None => (None, None), |
||||
}) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.showCursor(visible): DECTCEM; no argument means show. A hidden
|
||||
// cursor is tracked and re-shown on exit.
|
||||
tui.raw_set( |
||||
"showCursor", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.showCursor"; |
||||
let show = match v { |
||||
LuaValue::Nil => true, |
||||
LuaValue::Boolean(b) => b, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: expected a boolean or nil (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
if active() { |
||||
write_stdout(seq_of_visibility(show).as_bytes()); |
||||
state().cursor_hidden = !show; |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.cursorStyle("block"|"underline"|"bar", {blink=}): DECSCUSR; no
|
||||
// style resets to the terminal default. A changed style is restored on
|
||||
// exit.
|
||||
tui.raw_set( |
||||
"cursorStyle", |
||||
lua.create_function(|_, (style, opts): (LuaValue, Option<LuaTable>)| { |
||||
const F: &str = "tui.cursorStyle"; |
||||
let opts = Opts::parse(F, opts, &["blink"])?; |
||||
let blink = opts.boolean("blink")?.unwrap_or(false); |
||||
let style = match style { |
||||
LuaValue::Nil => None, |
||||
LuaValue::String(s) => Some( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))? |
||||
.to_string(), |
||||
), |
||||
other => { |
||||
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name())) |
||||
} |
||||
}; |
||||
use cursor::SetCursorStyle::*; |
||||
let cmd = match (style.as_deref(), blink) { |
||||
(None, false) => DefaultUserShape, |
||||
(None, true) => { |
||||
return Err(ext_err!("{F}: a style is required when 'blink' is set")) |
||||
} |
||||
(Some("block"), false) => SteadyBlock, |
||||
(Some("block"), true) => BlinkingBlock, |
||||
(Some("underline"), false) => SteadyUnderScore, |
||||
(Some("underline"), true) => BlinkingUnderScore, |
||||
(Some("bar"), false) => SteadyBar, |
||||
(Some("bar"), true) => BlinkingBar, |
||||
(Some(other), _) => { |
||||
return Err(ext_err!( |
||||
"{F}: style must be 'block', 'underline' or 'bar' (got '{other}')" |
||||
)) |
||||
} |
||||
}; |
||||
if active() { |
||||
write_stdout(seq(cmd).as_bytes()); |
||||
state().cursor_styled = style.is_some(); |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.clearLine(mode): EL. Whole-line and to-start clears also return the
|
||||
// cursor to column 1 (the natural spot to redraw from); to-end does not
|
||||
// move it.
|
||||
tui.raw_set( |
||||
"clearLine", |
||||
lua.create_function(|_, mode: LuaValue| { |
||||
emit(match clear_mode("tui.clearLine", &mode)? { |
||||
-1 => "\x1b[1K\r", |
||||
1 => "\x1b[0K", |
||||
_ => "\x1b[2K\r", |
||||
}); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.clearScreen(mode): ED, same modes; whole-screen and to-start clears
|
||||
// home the cursor.
|
||||
tui.raw_set( |
||||
"clearScreen", |
||||
lua.create_function(|_, mode: LuaValue| { |
||||
emit(match clear_mode("tui.clearScreen", &mode)? { |
||||
-1 => "\x1b[1J\x1b[H", |
||||
1 => "\x1b[0J", |
||||
_ => "\x1b[2J\x1b[H", |
||||
}); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.altScreen(on): the 1049 alternate screen (saves/restores the cursor
|
||||
// too). Idempotent — re-entering is ignored — and tracked, so an exit or
|
||||
// error always lands back on the normal screen.
|
||||
tui.raw_set( |
||||
"altScreen", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
let on = match v { |
||||
LuaValue::Boolean(b) => b, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"tui.altScreen: expected a boolean (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
if active() { |
||||
let mut st = state(); |
||||
if on != st.alt_screen { |
||||
let s = if on { |
||||
seq(terminal::EnterAlternateScreen) |
||||
} else { |
||||
seq(terminal::LeaveAlternateScreen) |
||||
}; |
||||
write_stdout(s.as_bytes()); |
||||
st.alt_screen = on; |
||||
} |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.scrollRegion(top, bottom): DECSTBM, 1-based inclusive; no arguments
|
||||
// reset it to the full screen. Setting a region moves the cursor home. A
|
||||
// set region is tracked and reset on exit.
|
||||
tui.raw_set( |
||||
"scrollRegion", |
||||
lua.create_function(|_, (top, bottom): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.scrollRegion"; |
||||
let top = opt_coord(F, "top", &top)?; |
||||
let bottom = opt_coord(F, "bottom", &bottom)?; |
||||
match (top, bottom) { |
||||
(None, None) => { |
||||
if active() { |
||||
write_stdout(b"\x1b[r"); |
||||
state().scroll_region = false; |
||||
} |
||||
} |
||||
(Some(t), Some(b)) => { |
||||
if t >= b { |
||||
return Err(ext_err!("{F}: top ({t}) must be less than bottom ({b})")); |
||||
} |
||||
if active() { |
||||
write_stdout(format!("\x1b[{t};{b}r").as_bytes()); |
||||
state().scroll_region = true; |
||||
} |
||||
} |
||||
_ => { |
||||
return Err(ext_err!( |
||||
"{F}: top and bottom must both be given (or both nil to reset)" |
||||
)) |
||||
} |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.scroll(n): scroll the scroll region up by n lines (content moves
|
||||
// up); negative scrolls down, 0 does nothing.
|
||||
tui.raw_set( |
||||
"scroll", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.scroll"; |
||||
let n = int_of(&v) |
||||
.ok_or_else(|| ext_err!("{F}: expected an integer (got {})", lua_value_brief(&v)))?; |
||||
match n { |
||||
1.. => emit(&seq(terminal::ScrollUp(magnitude(n)))), |
||||
..0 => emit(&seq(terminal::ScrollDown(magnitude(n)))), |
||||
0 => {} |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.setTitle(text): OSC 2. The first call pushes the current title on
|
||||
// the terminal's title stack (XTWINOPS 22) so exit can pop it back.
|
||||
tui.raw_set( |
||||
"setTitle", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.setTitle"; |
||||
let title = string_arg(F, "title", v)?; |
||||
// A control byte would terminate the OSC string early and leak
|
||||
// the rest as terminal input — reject rather than sanitize.
|
||||
if title.chars().any(char::is_control) { |
||||
return Err(ext_err!("{F}: title must not contain control characters")); |
||||
} |
||||
if active() { |
||||
let mut st = state(); |
||||
let mut out = String::new(); |
||||
if !st.title_pushed { |
||||
out.push_str("\x1b[22;2t"); |
||||
st.title_pushed = true; |
||||
} |
||||
out.push_str(&format!("\x1b]2;{title}\x07")); |
||||
write_stdout(out.as_bytes()); |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.setClipboard(text): OSC 52 write (any bytes; base64 on the wire).
|
||||
// Write-only — querying the clipboard is disabled by most terminals.
|
||||
tui.raw_set( |
||||
"setClipboard", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.setClipboard"; |
||||
let bytes = match v { |
||||
LuaValue::String(s) => s.as_bytes().to_vec(), |
||||
other => { |
||||
return Err(ext_err!("{F}: text must be a string (got {})", other.type_name())) |
||||
} |
||||
}; |
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); |
||||
emit(&format!("\x1b]52;c;{b64}\x07")); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.reset(): DECSTR soft reset — un-sticks a garbled terminal (wrong
|
||||
// charset, stuck attributes) without clearing the screen.
|
||||
tui.raw_set( |
||||
"reset", |
||||
lua.create_function(|_, ()| { |
||||
if active() { |
||||
write_stdout(b"\x1b[!p"); |
||||
// DECSTR itself makes the cursor visible and resets the
|
||||
// scroll region; keep the tracking in sync. It does not leave
|
||||
// the alternate screen or touch the title stack.
|
||||
let mut st = state(); |
||||
st.cursor_hidden = false; |
||||
st.scroll_region = false; |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
fn seq_of_visibility(show: bool) -> String { |
||||
if show { |
||||
seq(cursor::Show) |
||||
} else { |
||||
seq(cursor::Hide) |
||||
} |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
#[test] |
||||
fn restore_sequence_bytes() { |
||||
let all = TermState { |
||||
alt_screen: true, |
||||
cursor_hidden: true, |
||||
cursor_styled: true, |
||||
scroll_region: true, |
||||
title_pushed: true, |
||||
}; |
||||
assert_eq!(restore_sequence(&all), "\x1b[r\x1b[0 q\x1b[?25h\x1b[?1049l\x1b[23;2t"); |
||||
assert_eq!(restore_sequence(&CLEAN), ""); |
||||
assert_eq!( |
||||
restore_sequence(&TermState { cursor_hidden: true, ..CLEAN }), |
||||
"\x1b[?25h" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn cleanup_clears_tracked_state_and_is_idempotent() { |
||||
*state() = TermState { |
||||
alt_screen: true, |
||||
cursor_hidden: true, |
||||
cursor_styled: true, |
||||
scroll_region: true, |
||||
title_pushed: true, |
||||
}; |
||||
cleanup(); |
||||
{ |
||||
let st = state(); |
||||
assert!( |
||||
!st.alt_screen |
||||
&& !st.cursor_hidden |
||||
&& !st.cursor_styled |
||||
&& !st.scroll_region |
||||
&& !st.title_pushed |
||||
); |
||||
} |
||||
cleanup(); // second call must be a no-op (nothing tracked)
|
||||
} |
||||
|
||||
#[test] |
||||
fn coord_and_mode_parsing() { |
||||
use LuaValue::{Boolean, Integer, Nil, Number}; |
||||
|
||||
assert_eq!(opt_coord("f", "row", &Nil).unwrap(), None); |
||||
assert_eq!(opt_coord("f", "row", &Integer(3)).unwrap(), Some(3)); |
||||
assert_eq!(opt_coord("f", "row", &Number(3.0)).unwrap(), Some(3)); |
||||
assert_eq!(opt_coord("f", "row", &Integer(1 << 20)).unwrap(), Some(u16::MAX)); |
||||
for bad in [Integer(0), Integer(-2), Number(1.5), Boolean(true)] { |
||||
assert!(opt_coord("f", "row", &bad).is_err(), "{bad:?}"); |
||||
} |
||||
let err = opt_coord("tui.moveTo", "row", &Integer(0)).unwrap_err(); |
||||
assert!(err.to_string().contains("row must be a positive integer (got 0)"), "{err}"); |
||||
|
||||
assert_eq!(clear_mode("f", &Nil).unwrap(), 0); |
||||
for m in [-1, 0, 1] { |
||||
assert_eq!(clear_mode("f", &Integer(m)).unwrap(), m); |
||||
} |
||||
for bad in [Integer(2), Integer(-2), Number(0.5)] { |
||||
assert!(clear_mode("f", &bad).is_err(), "{bad:?}"); |
||||
} |
||||
|
||||
assert_eq!(opt_delta("f", "dx", &Nil).unwrap(), 0); |
||||
assert_eq!(opt_delta("f", "dx", &Integer(-7)).unwrap(), -7); |
||||
assert!(opt_delta("f", "dx", &Boolean(false)).is_err()); |
||||
assert_eq!(magnitude(-7), 7); |
||||
assert_eq!(magnitude(i64::MIN), u16::MAX); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue