use std::cell::RefCell;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use crate::backend::{
Backend, BackendError, Clipboard, Display, FileDialogOptions, MessageDialogChoice,
MessageDialogOptions, Notification, PlatformServices, ServiceResult, SystemTheme,
};
use crate::compose::{
FilePickerController, FilePickerEvent, FilePickerMode, MessageDialogController,
MessageDialogEvent,
};
use crate::event::{Point, Rect, UiEvent};
use crate::tui::backend::TuiBackend;
use crate::{Key, Modifiers, NamedKey};
pub(crate) trait DialogSurface {
fn draw_dialog_frame(&mut self, paint: &mut dyn FnMut(&mut ratatui::Frame<'_>));
fn dialog_size(&self) -> Option<ratatui::layout::Size>;
}
impl<B: ratatui::backend::Backend> DialogSurface for ratatui::Terminal<B> {
fn draw_dialog_frame(&mut self, paint: &mut dyn FnMut(&mut ratatui::Frame<'_>)) {
let _ = self.draw(|frame| paint(frame));
}
fn dialog_size(&self) -> Option<ratatui::layout::Size> {
ratatui::Terminal::size(self).ok()
}
}
enum NestedDialogStep<T> {
Resolved(T),
Cancelled,
Continue,
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
let mut i = 0;
while i < data.len() {
let b0 = data[i] as u32;
let b1 = if i + 1 < data.len() {
data[i + 1] as u32
} else {
0
};
let b2 = if i + 2 < data.len() {
data[i + 2] as u32
} else {
0
};
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((n >> 18) & 0x3f) as usize] as char);
out.push(CHARS[((n >> 12) & 0x3f) as usize] as char);
if i + 1 < data.len() {
out.push(CHARS[((n >> 6) & 0x3f) as usize] as char);
} else {
out.push('=');
}
if i + 2 < data.len() {
out.push(CHARS[(n & 0x3f) as usize] as char);
} else {
out.push('=');
}
i += 3;
}
out
}
fn osc52_sequence(text: &str) -> String {
format!("\x1b]52;c;{}\x07", base64_encode(text.as_bytes()))
}
fn tmux_passthrough_wrap(seq: &str) -> String {
let escaped = seq.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{}\x1b\\", escaped)
}
pub(crate) fn emit_osc52_with(text: &str, in_tmux: bool, writer: &mut dyn std::io::Write) {
let seq = osc52_sequence(text);
let _ = writer.write_all(seq.as_bytes());
if in_tmux {
let _ = writer.write_all(tmux_passthrough_wrap(&seq).as_bytes());
}
let _ = writer.flush();
}
pub(crate) fn emit_osc52_to(text: &str, writer: &mut dyn std::io::Write) {
emit_osc52_with(text, std::env::var_os("TMUX").is_some(), writer);
}
fn osc8_hyperlink_sequence(url: &str) -> String {
format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
}
fn emit_osc8_hyperlink_to(url: &str, writer: &mut dyn std::io::Write) -> bool {
let seq = osc8_hyperlink_sequence(url);
writer
.write_all(seq.as_bytes())
.and_then(|()| writer.flush())
.is_ok()
}
fn emit_osc8_hyperlink(url: &str) -> bool {
let stdout_wrote = emit_osc8_hyperlink_to(url, &mut std::io::stdout());
let tty_wrote = emit_osc8_hyperlink_to_tty(url);
stdout_wrote | tty_wrote
}
#[cfg(unix)]
fn emit_osc8_hyperlink_to_tty(url: &str) -> bool {
match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(mut tty) => emit_osc8_hyperlink_to(url, &mut tty),
Err(_) => false,
}
}
#[cfg(not(unix))]
fn emit_osc8_hyperlink_to_tty(_url: &str) -> bool {
false
}
fn open_url_via(url: &str, try_opener: impl FnOnce(&str) -> bool) -> ServiceResult<()> {
if try_opener(url) {
return Ok(());
}
let osc8_safe = !url.contains(|c: char| c.is_control());
if osc8_safe && emit_osc8_hyperlink(url) {
return Ok(());
}
Err(BackendError::Unsupported)
}
fn strip_control_chars(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn osc9_notification_sequence(title: &str, body: &str) -> String {
let message = if title.is_empty() {
body.to_string()
} else {
format!("{title}: {body}")
};
format!("\x1b]9;{message}\x07")
}
fn osc777_notification_sequence(title: &str, body: &str) -> String {
format!("\x1b]777;notify;{title};{body}\x07")
}
fn emit_notification_osc_to(title: &str, body: &str, writer: &mut dyn std::io::Write) -> bool {
let seq = format!(
"{}{}",
osc9_notification_sequence(title, body),
osc777_notification_sequence(title, body)
);
writer
.write_all(seq.as_bytes())
.and_then(|()| writer.flush())
.is_ok()
}
fn emit_notification_osc(n: &Notification) {
let title = strip_control_chars(&n.title);
let body = strip_control_chars(&n.body);
let _ = emit_notification_osc_to(&title, &body, &mut std::io::stdout());
emit_notification_osc_to_tty(&title, &body);
}
#[cfg(unix)]
fn emit_notification_osc_to_tty(title: &str, body: &str) {
if let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
let _ = emit_notification_osc_to(title, body, &mut tty);
}
}
#[cfg(not(unix))]
fn emit_notification_osc_to_tty(_title: &str, _body: &str) {}
#[cfg_attr(not(unix), allow(dead_code))]
fn native_clipboard_candidates(wayland: bool) -> [(&'static str, &'static [&'static str]); 3] {
let wl_copy: (&'static str, &'static [&'static str]) = ("wl-copy", &[]);
let xclip: (&'static str, &'static [&'static str]) = ("xclip", &["-selection", "clipboard"]);
let xsel: (&'static str, &'static [&'static str]) = ("xsel", &["--clipboard", "--input"]);
if wayland {
[wl_copy, xclip, xsel]
} else {
[xclip, xsel, wl_copy]
}
}
#[cfg_attr(not(unix), allow(dead_code))]
fn write_clipboard_via_native_tool(text: &str) {
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
for (program, args) in native_clipboard_candidates(wayland) {
let mut child = match std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(_) => continue, };
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(text.as_bytes());
}
match child.wait() {
Ok(status) if status.success() => return,
_ => continue,
}
}
}
pub struct TuiClipboard {
inner: RefCell<Option<arboard::Clipboard>>,
}
impl TuiClipboard {
fn new() -> Self {
Self {
inner: RefCell::new(arboard::Clipboard::new().ok()),
}
}
}
impl Clipboard for TuiClipboard {
fn read_text(&self) -> Option<String> {
self.inner.borrow_mut().as_mut()?.get_text().ok()
}
fn write_text(&self, text: &str) {
if let Some(cb) = self.inner.borrow_mut().as_mut() {
let _ = cb.set_text(text);
}
emit_osc52_to(text, &mut std::io::stdout());
#[cfg(unix)]
if let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
emit_osc52_to(text, &mut tty);
}
#[cfg(unix)]
{
let owned = text.to_string();
let _ = std::thread::spawn(move || write_clipboard_via_native_tool(&owned));
}
}
fn clear(&self) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: "arboard::Clipboard::new (no local clipboard available)".to_string(),
})?;
cb.clear().map_err(|e| BackendError::PlatformFailure {
context: format!("arboard::clear: {e}"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_empty() {
assert_eq!(base64_encode(b""), "");
}
#[test]
fn base64_three_bytes_no_padding() {
assert_eq!(base64_encode(b"Man"), "TWFu");
}
#[test]
fn base64_two_bytes_one_pad() {
assert_eq!(base64_encode(b"Ma"), "TWE=");
}
#[test]
fn base64_one_byte_two_pads() {
assert_eq!(base64_encode(b"M"), "TQ==");
}
#[test]
fn base64_hello() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
}
#[test]
fn osc52_sequence_correct() {
let mut out = Vec::new();
emit_osc52_with("hello", false, &mut out);
assert_eq!(String::from_utf8(out).unwrap(), "\x1b]52;c;aGVsbG8=\x07");
}
#[test]
fn osc52_empty_text() {
let mut out = Vec::new();
emit_osc52_with("", false, &mut out);
assert_eq!(String::from_utf8(out).unwrap(), "\x1b]52;c;\x07");
}
#[test]
fn osc52_tmux_emits_raw_then_passthrough() {
let mut out = Vec::new();
emit_osc52_with("hello", true, &mut out);
assert_eq!(
String::from_utf8(out).unwrap(),
"\x1b]52;c;aGVsbG8=\x07\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07\x1b\\"
);
}
#[test]
fn tmux_passthrough_doubles_every_esc() {
let wrapped = tmux_passthrough_wrap("\x1bA\x1bB");
assert_eq!(wrapped, "\x1bPtmux;\x1b\x1bA\x1b\x1bB\x1b\\");
}
#[test]
fn native_candidates_wayland_first_when_wayland() {
let candidates = native_clipboard_candidates(true);
assert_eq!(candidates[0].0, "wl-copy");
assert_eq!(candidates[1].0, "xclip");
assert_eq!(candidates[2].0, "xsel");
}
#[test]
fn native_candidates_x11_first_when_not_wayland() {
let candidates = native_clipboard_candidates(false);
assert_eq!(candidates[0].0, "xclip");
assert_eq!(candidates[1].0, "xsel");
assert_eq!(candidates[2].0, "wl-copy");
}
#[test]
fn native_candidates_always_list_all_three_tools_regardless_of_order() {
for wayland in [true, false] {
let names: Vec<&str> = native_clipboard_candidates(wayland)
.iter()
.map(|(program, _)| *program)
.collect();
assert!(names.contains(&"wl-copy"), "{names:?}");
assert!(names.contains(&"xclip"), "{names:?}");
assert!(names.contains(&"xsel"), "{names:?}");
}
}
#[test]
fn native_candidate_args_target_the_system_clipboard_selection() {
let candidates = native_clipboard_candidates(false);
let xclip = candidates.iter().find(|(p, _)| *p == "xclip").unwrap();
assert_eq!(xclip.1, &["-selection", "clipboard"]);
let xsel = candidates.iter().find(|(p, _)| *p == "xsel").unwrap();
assert_eq!(xsel.1, &["--clipboard", "--input"]);
}
#[test]
fn image_html_and_file_list_are_unsupported_by_design() {
let cb = TuiClipboard::new();
assert_eq!(cb.read_image(), Err(BackendError::Unsupported));
assert_eq!(
cb.write_image(&crate::backend::RgbaImage {
width: 1,
height: 1,
pixels: vec![0, 0, 0, 255],
}),
Err(BackendError::Unsupported)
);
assert_eq!(
cb.write_html("<b>hi</b>", "hi"),
Err(BackendError::Unsupported)
);
assert_eq!(cb.read_file_list(), Err(BackendError::Unsupported));
}
const CLEAR_TEST_SENTINEL: &str = "some text #954 (tui clear test)";
#[allow(clippy::print_stderr)]
#[test]
fn clear_empties_the_local_clipboard() {
let cb = TuiClipboard::new();
{
let mut inner = cb.inner.borrow_mut();
let Some(handle) = inner.as_mut() else {
eprintln!("skipping: no local desktop clipboard in this environment");
return;
};
if let Err(e) = handle.set_text(CLEAR_TEST_SENTINEL) {
eprintln!("skipping: local clipboard rejected a write ({e})");
return;
}
}
match cb.read_text() {
None => {
eprintln!(
"skipping: the sentinel wasn't readable back — no usable desktop \
clipboard here, or another writer wiped it first"
);
return;
}
Some(text) if text != CLEAR_TEST_SENTINEL => {
eprintln!(
"skipping: another writer claimed the systemwide clipboard before \
clear() could run (read back {text:?}) — nothing to assert about clear()"
);
return;
}
Some(_) => {}
}
cb.clear().expect(
"clear should succeed once set_text/read_text already proved a local clipboard is live",
);
let after = cb.read_text();
assert!(
!sentinel_survived_clear(after.as_deref()),
"clear() must drop the text it was given; the sentinel is still on the clipboard"
);
if let Some(text) = after {
eprintln!(
"note: another writer re-populated the clipboard after clear() ({text:?}); \
clear() still dropped this test's own text"
);
}
}
fn sentinel_survived_clear(after: Option<&str>) -> bool {
after == Some(CLEAR_TEST_SENTINEL)
}
#[test]
fn only_the_sentinel_surviving_clear_counts_as_a_clear_failure() {
assert!(
sentinel_survived_clear(Some(CLEAR_TEST_SENTINEL)),
"a no-op clear() leaves the sentinel behind — that must still fail loudly"
);
assert!(
!sentinel_survived_clear(None),
"an empty clipboard is exactly what clear() promises"
);
assert!(
!sentinel_survived_clear(Some("\n\n\n\n")),
"the row separators of a blank selection copied by a concurrent TuiDriver \
test — the literal value that made #1129's macOS run red — is interference, \
not a clear() failure"
);
}
}
#[cfg(test)]
mod open_url_tests {
use super::*;
#[test]
fn osc8_hyperlink_sequence_wraps_url_as_its_own_link_text() {
assert_eq!(
osc8_hyperlink_sequence("https://example.com"),
"\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\"
);
}
#[test]
fn emit_osc8_hyperlink_to_writes_the_sequence_and_reports_success() {
let mut out = Vec::new();
assert!(emit_osc8_hyperlink_to("https://example.com", &mut out));
assert_eq!(
String::from_utf8(out).unwrap(),
osc8_hyperlink_sequence("https://example.com")
);
}
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("simulated write failure (#969 test)"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn emit_osc8_hyperlink_to_reports_failure_when_the_writer_fails() {
let mut w = FailingWriter;
assert!(!emit_osc8_hyperlink_to("https://example.com", &mut w));
}
#[cfg(not(unix))]
#[test]
fn emit_osc8_hyperlink_to_tty_reports_false_without_a_dev_tty() {
assert!(!emit_osc8_hyperlink_to_tty("https://example.com/969"));
}
#[cfg(unix)]
#[test]
fn emit_osc8_hyperlink_to_tty_is_total_on_unix() {
let _ = emit_osc8_hyperlink_to_tty("https://example.com/969");
}
#[test]
fn emit_osc8_hyperlink_succeeds_on_the_stdout_leg_alone() {
assert!(emit_osc8_hyperlink("https://example.com/969"));
}
#[test]
fn open_url_via_returns_ok_when_the_opener_spawns() {
let result = open_url_via("https://example.com/969", |_url| {
#[cfg(unix)]
{
std::process::Command::new("true").spawn().is_ok()
}
#[cfg(windows)]
{
std::process::Command::new("cmd")
.args(["/c", "exit", "0"])
.spawn()
.is_ok()
}
});
assert_eq!(result, Ok(()));
}
#[test]
fn open_url_via_falls_back_to_osc8_when_the_opener_is_missing() {
let result = open_url_via("https://example.com/969", |_url| false);
assert_eq!(result, Ok(()));
}
#[test]
fn open_url_via_rejects_control_characters_in_the_osc8_fallback() {
let malicious_url = "https://example.com/\x1b]0;pwned\x07";
let result = open_url_via(malicious_url, |_url| false);
assert_eq!(result, Err(BackendError::Unsupported));
}
#[test]
fn unsupported_is_only_reachable_when_both_legs_fail() {
let opener_failed = std::process::Command::new("quadraui-969-this-binary-does-not-exist")
.spawn()
.is_err();
assert!(
opener_failed,
"expected a nonexistent binary to fail to spawn"
);
let mut w = FailingWriter;
let osc8_failed = !emit_osc8_hyperlink_to("https://example.com/969", &mut w);
assert!(osc8_failed, "expected the failing writer to fail the write");
}
#[cfg(unix)]
#[test]
fn open_url_result_returns_ok_through_the_real_delegation() {
let _guard = crate::desktop::PATH_OVERRIDE_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
#[cfg(target_os = "macos")]
const OPENER_NAME: &str = "open";
#[cfg(all(unix, not(target_os = "macos")))]
const OPENER_NAME: &str = "xdg-open";
let tmp = tempfile::tempdir().expect("tempdir");
let stub_path = tmp.path().join(OPENER_NAME);
std::fs::write(&stub_path, b"#!/bin/sh\nexit 0\n").expect("write stub opener");
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stub_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod +x stub opener");
}
let original_path = std::env::var_os("PATH");
struct RestorePath(Option<std::ffi::OsString>);
impl Drop for RestorePath {
fn drop(&mut self) {
match self.0.take() {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
}
let _restore = RestorePath(original_path.clone());
let mut new_path = std::ffi::OsString::from(tmp.path());
if let Some(existing) = &original_path {
new_path.push(":");
new_path.push(existing);
}
std::env::set_var("PATH", new_path);
let services = TuiPlatformServices::new();
let result = services.open_url_result("https://example.com/969");
assert_eq!(result, Ok(()));
}
#[cfg(unix)]
#[test]
fn open_path_returns_ok_through_the_real_delegation() {
let _guard = crate::desktop::PATH_OVERRIDE_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
#[cfg(target_os = "macos")]
const OPENER_NAME: &str = "open";
#[cfg(all(unix, not(target_os = "macos")))]
const OPENER_NAME: &str = "xdg-open";
let tmp = tempfile::tempdir().expect("tempdir");
let stub_path = tmp.path().join(OPENER_NAME);
std::fs::write(&stub_path, b"#!/bin/sh\nexit 0\n").expect("write stub opener");
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stub_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod +x stub opener");
}
let original_path = std::env::var_os("PATH");
struct RestorePath(Option<std::ffi::OsString>);
impl Drop for RestorePath {
fn drop(&mut self) {
match self.0.take() {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
}
let _restore = RestorePath(original_path.clone());
let mut new_path = std::ffi::OsString::from(tmp.path());
if let Some(existing) = &original_path {
new_path.push(":");
new_path.push(existing);
}
std::env::set_var("PATH", new_path);
let services = TuiPlatformServices::new();
let scratch = std::path::Path::new("/tmp/quadraui-1087-open-path-scratch");
assert_eq!(services.open_path(scratch), Ok(()));
}
#[cfg(unix)]
#[test]
fn open_path_reports_err_when_the_opener_is_missing() {
let _guard = crate::desktop::PATH_OVERRIDE_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let original_path = std::env::var_os("PATH");
struct RestorePath(Option<std::ffi::OsString>);
impl Drop for RestorePath {
fn drop(&mut self) {
match self.0.take() {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
}
let _restore = RestorePath(original_path);
let empty_dir = tempfile::tempdir().expect("tempdir");
std::env::set_var("PATH", empty_dir.path());
let services = TuiPlatformServices::new();
let scratch = std::path::Path::new("/tmp/quadraui-1087-open-path-scratch");
assert!(services.open_path(scratch).is_err());
}
}
#[cfg(test)]
mod notification_osc_tests {
use super::*;
#[test]
fn strip_control_chars_removes_escape_and_other_controls_but_keeps_the_rest() {
assert_eq!(strip_control_chars("plain text"), "plain text");
assert_eq!(strip_control_chars("a\x1b]0;pwned\x07b"), "a]0;pwnedb");
assert_eq!(strip_control_chars("tab\tnewline\n"), "tabnewline");
}
#[test]
fn osc9_notification_sequence_joins_title_and_body() {
assert_eq!(
osc9_notification_sequence("Build", "Finished"),
"\x1b]9;Build: Finished\x07"
);
}
#[test]
fn osc9_notification_sequence_drops_a_dangling_prefix_for_an_empty_title() {
assert_eq!(
osc9_notification_sequence("", "Finished"),
"\x1b]9;Finished\x07"
);
}
#[test]
fn osc777_notification_sequence_carries_title_and_body_as_distinct_fields() {
assert_eq!(
osc777_notification_sequence("Build", "Finished"),
"\x1b]777;notify;Build;Finished\x07"
);
}
#[test]
fn emit_notification_osc_to_writes_both_sequences_and_reports_success() {
let mut out = Vec::new();
assert!(emit_notification_osc_to("Build", "Finished", &mut out));
assert_eq!(
String::from_utf8(out).unwrap(),
format!(
"{}{}",
osc9_notification_sequence("Build", "Finished"),
osc777_notification_sequence("Build", "Finished")
)
);
}
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other(
"simulated write failure (#1092 test)",
))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn emit_notification_osc_to_reports_failure_when_the_writer_fails() {
let mut w = FailingWriter;
assert!(!emit_notification_osc_to("Build", "Finished", &mut w));
}
#[test]
fn stripped_title_and_body_never_carry_a_raw_escape_into_the_sequence() {
let title = strip_control_chars("t\x1b]0;pwned\x07itle");
let body = strip_control_chars("b\x1body");
assert!(!title.contains('\x1b') && !title.contains('\x07'));
assert!(!body.contains('\x1b') && !body.contains('\x07'));
let osc9 = osc9_notification_sequence(&title, &body);
assert_eq!(osc9.matches('\x1b').count(), 1, "{osc9:?}");
let osc777 = osc777_notification_sequence(&title, &body);
assert_eq!(osc777.matches('\x1b').count(), 1, "{osc777:?}");
}
#[test]
fn send_notification_does_not_panic() {
let services = TuiPlatformServices::new();
services.send_notification(Notification::new(
"quadraui#1092",
"send_notification smoke test",
));
}
}
pub struct TuiPlatformServices {
clipboard: TuiClipboard,
dialog_surface: RefCell<Option<Rc<RefCell<dyn DialogSurface>>>>,
scripted_dialog_events: RefCell<Option<VecDeque<UiEvent>>>,
}
impl TuiPlatformServices {
pub fn new() -> Self {
Self {
clipboard: TuiClipboard::new(),
dialog_surface: RefCell::new(None),
scripted_dialog_events: RefCell::new(None),
}
}
pub(crate) fn set_dialog_surface(&self, surface: Rc<RefCell<dyn DialogSurface>>) {
*self.dialog_surface.borrow_mut() = Some(surface);
}
pub(crate) fn queue_dialog_events(&self, events: impl IntoIterator<Item = UiEvent>) {
self.scripted_dialog_events
.borrow_mut()
.get_or_insert_with(VecDeque::new)
.extend(events);
}
fn next_dialog_events(&self) -> Vec<UiEvent> {
{
let mut scripted = self.scripted_dialog_events.borrow_mut();
if let Some(queue) = scripted.as_mut() {
return match queue.pop_front() {
Some(ev) => vec![ev],
None => vec![UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
}],
};
}
}
loop {
let deadline =
std::time::Instant::now().checked_add(std::time::Duration::from_millis(250));
match super::backend::wait_for_stdin_ready(deadline) {
super::backend::StdinReadiness::HungUp => {
std::thread::sleep(std::time::Duration::from_millis(250));
return Vec::new();
}
super::backend::StdinReadiness::TimedOut => continue,
super::backend::StdinReadiness::Ready => {
return match ratatui::crossterm::event::poll(std::time::Duration::ZERO) {
Ok(true) => match ratatui::crossterm::event::read() {
Ok(ev) => super::events::crossterm_to_uievents(ev),
Err(_) => Vec::new(),
},
Ok(false) => continue,
Err(_) => Vec::new(),
};
}
super::backend::StdinReadiness::Unguarded => {
match ratatui::crossterm::event::poll(std::time::Duration::from_millis(250)) {
Ok(true) => {
return match ratatui::crossterm::event::read() {
Ok(ev) => super::events::crossterm_to_uievents(ev),
Err(_) => Vec::new(),
};
}
Ok(false) => continue,
Err(_) => return Vec::new(),
}
}
}
}
}
fn run_nested_dialog_loop<T>(
&self,
mut draw: impl FnMut(&mut dyn Backend, Rect),
mut handle_event: impl FnMut(&UiEvent, &dyn Backend, Rect) -> NestedDialogStep<T>,
) -> Option<T> {
let surface = self.dialog_surface.borrow().clone()?;
let mut scratch = TuiBackend::new();
loop {
let size = surface.borrow().dialog_size()?;
let viewport = Rect::new(0.0, 0.0, size.width as f32, size.height as f32);
scratch.begin_frame(crate::Viewport::new(
size.width as f32,
size.height as f32,
1.0,
));
surface.borrow_mut().draw_dialog_frame(&mut |frame| {
scratch.enter_frame_scope(frame, |b| draw(b, viewport));
});
scratch.end_frame();
for event in self.next_dialog_events() {
match handle_event(&event, &scratch, viewport) {
NestedDialogStep::Resolved(v) => return Some(v),
NestedDialogStep::Cancelled => return None,
NestedDialogStep::Continue => {}
}
}
}
}
}
impl Default for TuiPlatformServices {
fn default() -> Self {
Self::new()
}
}
impl PlatformServices for TuiPlatformServices {
fn clipboard(&self) -> &dyn Clipboard {
&self.clipboard
}
fn show_file_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let root = opts
.initial_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let picker = RefCell::new(FilePickerController::new(
FilePickerMode::Open,
root,
opts.filters,
));
self.run_nested_dialog_loop(
|backend, viewport| picker.borrow().render(viewport, backend),
|event, _backend, viewport| {
let visible_rows = (viewport.height as usize)
.saturating_sub(crate::compose::folder_picker::PALETTE_CHROME_ROWS);
match picker.borrow_mut().handle(event, visible_rows) {
FilePickerEvent::Confirmed { path } => NestedDialogStep::Resolved(path),
FilePickerEvent::Cancelled => NestedDialogStep::Cancelled,
FilePickerEvent::Consumed | FilePickerEvent::Ignored => {
NestedDialogStep::Continue
}
}
},
)
}
fn show_file_save_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let root = opts
.initial_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let mut picker = FilePickerController::new(FilePickerMode::Save, root, opts.filters);
if let Some(name) = opts.initial_filename {
picker = picker.with_initial_filename(name);
}
let picker = RefCell::new(picker);
self.run_nested_dialog_loop(
|backend, viewport| picker.borrow().render(viewport, backend),
|event, _backend, viewport| {
let visible_rows = (viewport.height as usize)
.saturating_sub(crate::compose::folder_picker::PALETTE_CHROME_ROWS);
match picker.borrow_mut().handle(event, visible_rows) {
FilePickerEvent::Confirmed { path } => NestedDialogStep::Resolved(path),
FilePickerEvent::Cancelled => NestedDialogStep::Cancelled,
FilePickerEvent::Consumed | FilePickerEvent::Ignored => {
NestedDialogStep::Continue
}
}
},
)
}
fn show_folder_open_dialog(&self, _opts: FileDialogOptions) -> Option<PathBuf> {
None
}
fn show_message_dialog(&self, opts: MessageDialogOptions) -> Option<MessageDialogChoice> {
let controller = RefCell::new(MessageDialogController::new(opts));
self.run_nested_dialog_loop(
|backend, _viewport| controller.borrow().render(backend),
|event, backend, _viewport| match controller.borrow_mut().handle(event, backend) {
MessageDialogEvent::Resolved(id) => NestedDialogStep::Resolved(id),
MessageDialogEvent::Consumed | MessageDialogEvent::Ignored => {
NestedDialogStep::Continue
}
},
)
}
fn send_notification(&self, n: Notification) {
emit_notification_osc(&n);
#[cfg(target_os = "macos")]
{
let _ =
crate::desktop::tui_macos_notify_command(&n.title, &n.body, n.is_silent()).spawn();
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = crate::desktop::tui_notify_send_command(&n.title, &n.body).spawn();
}
}
fn open_url(&self, url: &str) {
let _ = self.open_url_result(url);
}
fn open_url_result(&self, url: &str) -> ServiceResult<()> {
open_url_via(url, |u| {
crate::desktop::try_open_with_default(std::ffi::OsStr::new(u))
})
}
fn reveal_in_file_manager(&self, path: &Path) -> ServiceResult<()> {
crate::desktop::reveal_in_file_manager(path)
}
fn open_path(&self, path: &Path) -> ServiceResult<()> {
crate::desktop::open_with_default(path.as_os_str())
}
fn move_to_trash(&self, path: &Path) -> ServiceResult<()> {
crate::desktop::move_to_trash(path)
}
fn beep(&self) -> ServiceResult<()> {
use std::io::Write;
let _ = std::io::stdout().write_all(b"\x07");
let _ = std::io::stdout().flush();
#[cfg(unix)]
if let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
let _ = tty.write_all(b"\x07");
let _ = tty.flush();
}
Ok(())
}
fn system_theme(&self) -> ServiceResult<SystemTheme> {
super::caps::detect_system_theme().ok_or(BackendError::Unsupported)
}
fn displays(&self) -> ServiceResult<Vec<Display>> {
let (width, height) =
ratatui::crossterm::terminal::size().map_err(|e| BackendError::PlatformFailure {
context: format!("crossterm::terminal::size: {e}"),
})?;
let bounds = Rect::new(0.0, 0.0, width as f32, height as f32);
Ok(vec![Display {
bounds,
work_area: bounds,
scale: 1.0,
primary: true,
}])
}
fn cursor_screen_point(&self) -> ServiceResult<Point> {
Err(BackendError::Unsupported)
}
fn platform_name(&self) -> &'static str {
"tui"
}
}
#[cfg(test)]
mod message_dialog_tests {
use super::*;
use crate::backend::MessageDialogOptions;
#[test]
fn show_message_dialog_returns_none_when_no_surface_wired() {
let services = TuiPlatformServices::new();
let opts = MessageDialogOptions {
title: "Unsaved Changes".to_string(),
body: "Do you want to save?".to_string(),
buttons: Vec::new(),
severity: None,
};
assert!(services.show_message_dialog(opts).is_none());
}
#[test]
fn show_folder_open_dialog_always_returns_none() {
let services = TuiPlatformServices::new();
assert!(services
.show_folder_open_dialog(FileDialogOptions::default())
.is_none());
}
#[test]
fn beep_reports_success() {
let services = TuiPlatformServices::new();
assert_eq!(services.beep(), Ok(()));
}
#[test]
fn cursor_screen_point_always_reports_unsupported_on_tui() {
let services = TuiPlatformServices::new();
assert_eq!(
services.cursor_screen_point(),
Err(BackendError::Unsupported)
);
}
#[test]
#[allow(clippy::print_stderr)]
fn displays_reports_one_cell_grid_display_or_skips_headless() {
let services = TuiPlatformServices::new();
match services.displays() {
Ok(displays) => {
assert_eq!(displays.len(), 1);
let d = displays[0];
assert_eq!(d.work_area, d.bounds);
assert_eq!(d.scale, 1.0);
assert!(d.primary);
}
Err(e) => {
eprintln!(
"skipping: displays() failed in this environment ({e:?}) — no controlling \
terminal attached (piped/captured output, no pty)"
);
}
}
}
}
#[cfg(test)]
mod dialog_tests {
use super::*;
use crate::backend::{MessageDialogButton, MessageDialogOptions};
use crate::types::WidgetId;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn wired_services(width: u16, height: u16) -> TuiPlatformServices {
let services = TuiPlatformServices::new();
let terminal = Terminal::new(TestBackend::new(width, height)).expect("TestBackend");
let surface: Rc<RefCell<dyn DialogSurface>> = Rc::new(RefCell::new(terminal));
services.set_dialog_surface(surface);
services
}
fn key_char(c: char) -> UiEvent {
UiEvent::KeyPressed {
key: Key::Char(c),
modifiers: Modifiers::default(),
repeat: false,
}
}
fn key_named(k: NamedKey) -> UiEvent {
UiEvent::KeyPressed {
key: Key::Named(k),
modifiers: Modifiers::default(),
repeat: false,
}
}
#[test]
fn show_file_open_dialog_confirms_a_selected_path() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("target.txt"), b"").expect("write file");
let services = wired_services(60, 20);
services.queue_dialog_events(vec![
key_char('t'),
key_char('a'),
key_char('r'),
key_char('g'),
key_named(NamedKey::Enter),
]);
let opts = FileDialogOptions {
initial_dir: Some(tmp.path().to_path_buf()),
..Default::default()
};
let result = services.show_file_open_dialog(opts);
assert_eq!(result, Some(tmp.path().join("target.txt")));
}
#[test]
fn show_file_open_dialog_cancelled_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("target.txt"), b"").expect("write file");
let services = wired_services(60, 20);
services.queue_dialog_events(vec![key_named(NamedKey::Escape)]);
let opts = FileDialogOptions {
initial_dir: Some(tmp.path().to_path_buf()),
..Default::default()
};
assert_eq!(services.show_file_open_dialog(opts), None);
}
#[test]
fn show_file_save_dialog_confirms_a_typed_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let services = wired_services(60, 20);
services.queue_dialog_events(vec![
key_char('n'),
key_char('e'),
key_char('w'),
key_char('.'),
key_char('t'),
key_char('x'),
key_char('t'),
key_named(NamedKey::Enter),
]);
let opts = FileDialogOptions {
initial_dir: Some(tmp.path().to_path_buf()),
..Default::default()
};
let result = services.show_file_save_dialog(opts);
assert_eq!(result, Some(tmp.path().join("new.txt")));
}
#[test]
fn show_file_save_dialog_seeds_initial_filename() {
let tmp = tempfile::tempdir().expect("tempdir");
let services = wired_services(60, 20);
services.queue_dialog_events(vec![key_named(NamedKey::Enter)]);
let opts = FileDialogOptions {
initial_dir: Some(tmp.path().to_path_buf()),
initial_filename: Some("untitled.txt".to_string()),
..Default::default()
};
let result = services.show_file_save_dialog(opts);
assert_eq!(result, Some(tmp.path().join("untitled.txt")));
}
#[test]
fn show_message_dialog_resolves_a_pressed_button() {
let services = wired_services(60, 20);
services.queue_dialog_events(vec![key_named(NamedKey::Right), key_named(NamedKey::Enter)]);
let opts = MessageDialogOptions {
title: "Unsaved Changes".to_string(),
body: "Do you want to save?".to_string(),
buttons: vec![
MessageDialogButton {
id: WidgetId::new("save"),
label: "Save".into(),
is_default: true,
is_cancel: false,
},
MessageDialogButton {
id: WidgetId::new("cancel"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
},
],
severity: None,
};
let result = services.show_message_dialog(opts);
assert_eq!(result, Some(WidgetId::new("cancel")));
}
#[test]
fn show_message_dialog_escape_resolves_cancel_button() {
let services = wired_services(60, 20);
services.queue_dialog_events(vec![key_named(NamedKey::Escape)]);
let opts = MessageDialogOptions {
title: "Heads up".to_string(),
body: "Something happened.".to_string(),
buttons: vec![
MessageDialogButton {
id: WidgetId::new("ok"),
label: "OK".into(),
is_default: true,
is_cancel: false,
},
MessageDialogButton {
id: WidgetId::new("cancel"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
},
],
severity: None,
};
assert_eq!(
services.show_message_dialog(opts),
Some(WidgetId::new("cancel"))
);
}
#[test]
fn scripted_queue_exhaustion_synthesizes_escape_instead_of_hanging() {
let services = wired_services(60, 20);
services.queue_dialog_events(Vec::<UiEvent>::new());
let opts = MessageDialogOptions {
title: "T".to_string(),
body: "B".to_string(),
buttons: vec![MessageDialogButton {
id: WidgetId::new("cancel"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
}],
severity: None,
};
assert_eq!(
services.show_message_dialog(opts),
Some(WidgetId::new("cancel"))
);
}
}