use std::path::PathBuf;
use std::time::Instant;
use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
use alacritty_terminal::grid::Scroll;
use alacritty_terminal::term::TermMode;
use anyhow::Result;
use crossbeam_channel::Sender;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::agent::AgentTracker;
use crate::input::PaneInputModes;
use crate::layout::PaneId;
use crate::preview::Preview;
use crate::server::Event;
mod commands;
mod guard;
mod osc;
mod pty;
mod screen;
pub use commands::{Finished, short_duration};
use osc::sanitize_title;
use osc::{Osc, OscScanner};
pub use osc::{osc_notify, osc52};
use pty::Size;
pub use pty::{ProcessHold, TermPane};
#[cfg(test)]
pub use screen::render_term;
pub use screen::set_outer_colors;
use screen::{SearchState, default_color};
const RESET_MODES: &[u8] = b"\x1b[?1049l\x1b[?1049h\x1b[<4096u\x1b[=0;1u\x1b(B\x1b)B\x1b*B\x1b+B\x0f\x1b[0m\x1b7\
\x1b[?1049l\x1b[<4096u\x1b[=0;1u\
\x1b[?6l\x1b7\x1b[r\x1b8\x1b[?1l\x1b>\x1b[?7h\x1b[4l\x1b[20l\
\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[?1007h\
\x1b[?25h\x1b[0 q";
fn reset_sequence(cols: u16) -> Vec<u8> {
let mut out = RESET_MODES.to_vec();
out.extend_from_slice(b"\x1b7\x1b[3g");
for col in (9..=cols).step_by(8) {
out.extend_from_slice(format!("\x1b[{col}G\x1bH").as_bytes());
}
out.extend_from_slice(b"\x1b8\x1b(B\x1b)B\x1b*B\x1b+B\x0f\x1b[0m\r\n\x1b7");
out
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PaneSpec {
pub command: Vec<String>,
pub cwd: Option<PathBuf>,
pub close_on_exit: bool,
pub preview: Option<PathBuf>,
pub startup_input: Option<String>,
pub server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection: Option<crate::servers::Server>,
pub close_on_success: bool,
pub keep_on_failure: bool,
pub files: Option<[String; 2]>,
pub env: Vec<(String, String)>,
pub env_remove: Vec<String>,
pub account: Option<String>,
pub session: Option<String>,
pub worktree: Option<crate::git::Worktree>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_agent: Option<PendingAgent>,
#[serde(skip)]
pub secret_env: SecretEnv,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PendingAgent {
pub agent: String,
pub args: Vec<String>,
pub workspace: Option<String>,
pub suspended: bool,
pub close_on_exit: bool,
}
#[derive(Clone, Default)]
pub struct SecretEnv(pub Vec<(String, String)>);
impl std::fmt::Debug for SecretEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list().entries(self.0.iter().map(|(k, _)| k)).finish()
}
}
#[derive(Debug, Clone)]
pub enum PaneSignal {
Notification {
title: String,
body: String,
},
Clipboard(String),
Bell,
TitleChanged,
CommandDone(Finished),
}
pub enum Content {
Term(Box<TermPane>),
Preview(Box<Preview>),
Files(Box<crate::files::FilesPane>),
}
pub struct Pane {
pub id: PaneId,
pub name: Option<String>,
pub title: String,
pub spec: PaneSpec,
pub content: Content,
pub cols: u16,
pub rows: u16,
pub agent: AgentTracker,
pub exit_code: Option<Option<i32>>,
pub last_output: Instant,
pub bell: bool,
pub reported_cwd: Option<PathBuf>,
pub reported_host: String,
pub ssh: Option<crate::servers::Server>,
pub seen: crate::context::Seen,
pub close_at: Option<Instant>,
pub restart_with: Option<PaneSpec>,
pub suspended: bool,
search: Option<SearchState>,
}
pub struct PaneEnv {
pub socket: PathBuf,
pub session: String,
pub shell: String,
pub scrollback: usize,
pub graphics: std::sync::Arc<crate::graphics::Shared>,
}
impl Pane {
pub fn spawn(
id: PaneId,
name: Option<String>,
spec: PaneSpec,
cols: u16,
rows: u16,
env: &PaneEnv,
tx: &Sender<Event>,
) -> Result<Pane> {
let cols = cols.max(2);
let rows = rows.max(1);
let content = if let Some(path) = &spec.preview {
Content::Preview(Box::new(Preview::open(path)))
} else {
Content::Term(Box::new(TermPane::spawn(id, &spec, cols, rows, env, tx)?))
};
Ok(Pane {
id,
name,
title: String::new(),
spec,
content,
cols,
rows,
agent: AgentTracker::default(),
exit_code: None,
last_output: Instant::now(),
bell: false,
reported_cwd: None,
reported_host: String::new(),
seen: Default::default(),
ssh: None,
close_at: None,
restart_with: None,
suspended: false,
search: None,
})
}
pub fn files(id: PaneId, name: Option<String>, spec: PaneSpec, files: crate::files::FilesPane) -> Pane {
Pane {
id,
name,
title: String::new(),
spec,
content: Content::Files(Box::new(files)),
cols: 80,
rows: 24,
agent: AgentTracker::default(),
exit_code: None,
last_output: Instant::now(),
bell: false,
reported_cwd: None,
reported_host: String::new(),
seen: Default::default(),
ssh: None,
close_at: None,
restart_with: None,
suspended: false,
search: None,
}
}
pub fn respawn(&mut self, env: &PaneEnv, tx: &Sender<Event>) -> Result<()> {
let Content::Term(old) = &mut self.content else { return Ok(()) };
let mut fresh = TermPane::spawn(self.id, &self.spec, self.cols, self.rows, env, tx)?;
std::mem::swap(&mut fresh.term, &mut old.term);
std::mem::swap(&mut fresh.events, &mut old.events);
std::mem::swap(&mut fresh.commands, &mut old.commands);
std::mem::swap(&mut fresh.graphics, &mut old.graphics);
let reset = reset_sequence(self.cols);
fresh.marked(|t| t.parser.advance(&mut t.term, &reset));
**old = fresh;
self.exit_code = None;
self.suspended = false;
self.close_at = None;
let session = self.agent.session_id.take();
self.agent = AgentTracker { session_id: session, ..AgentTracker::default() };
Ok(())
}
pub fn display_name(&self) -> String {
if let Some(n) = &self.name {
return n.clone();
}
match &self.content {
Content::Preview(p) => p.title(),
Content::Files(f) => f.title(),
Content::Term(_) if !self.title.is_empty() => self.title.clone(),
Content::Term(_) => self
.spec
.command
.first()
.map(|c| c.rsplit('/').next().unwrap_or(c).to_string())
.unwrap_or_else(|| "shell".into()),
}
}
pub fn is_preview(&self) -> bool {
matches!(self.content, Content::Preview(_))
}
pub fn preview_of(&mut self, path: &std::path::Path) -> Option<&mut Preview> {
match &mut self.content {
Content::Preview(p) if p.path() == path => Some(p),
_ => None,
}
}
pub fn is_terminal(&self) -> bool {
matches!(self.content, Content::Term(_))
}
pub fn term(&self) -> Option<&TermPane> {
match &self.content {
Content::Term(t) => Some(t),
_ => None,
}
}
pub fn term_mut(&mut self) -> Option<&mut TermPane> {
match &mut self.content {
Content::Term(t) => Some(t),
_ => None,
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
let cols = cols.max(2);
let rows = rows.max(1);
if (cols, rows) == (self.cols, self.rows) {
return;
}
self.cols = cols;
self.rows = rows;
if let Content::Term(t) = &mut self.content {
let _ = t.master.resize(pty::pty_size(cols, rows, t.graphics.shared()));
t.resize_marked(Size { cols, rows });
}
}
pub fn feed(&mut self, bytes: &[u8]) -> Vec<PaneSignal> {
self.last_output = Instant::now();
let Content::Term(t) = &mut self.content else { return Vec::new() };
let mut signals = Vec::new();
let mut start = 0;
for (end, osc) in t.osc.scan(bytes) {
match osc {
Osc::Notify { title, body } => signals.push(PaneSignal::Notification { title, body }),
Osc::Cwd { host, path } => {
self.reported_cwd = Some(path);
self.reported_host = host;
}
Osc::Mark(mark) => {
t.advance(&bytes[start..end]);
start = end;
signals.extend(t.mark(mark).map(PaneSignal::CommandDone));
}
}
}
t.advance(&bytes[start..]);
self.drain_events(&mut signals);
signals
}
pub fn refresh_pixel_size(&mut self) {
if let Content::Term(t) = &mut self.content {
let _ = t.master.resize(pty::pty_size(self.cols, self.rows, t.graphics.shared()));
}
}
pub fn show_images(&self, area: Rect, buf: &mut Buffer, can_show: bool) {
if let Content::Term(t) = &self.content {
t.graphics.show_cells(area, buf, can_show);
}
}
pub fn take_output(&self) -> Option<Vec<u8>> {
self.term()?.output.try_recv().ok()
}
pub fn has_output(&self) -> bool {
self.term().is_some_and(|t| !t.output.is_empty())
}
pub fn poll_sync_timeout(&mut self) -> Option<Vec<PaneSignal>> {
let Content::Term(t) = &mut self.content else { return None };
match t.parser.sync_timeout().sync_timeout() {
Some(deadline) if deadline <= Instant::now() => {
t.marked(|t| t.parser.stop_sync(&mut t.term));
let mut signals = Vec::new();
self.drain_events(&mut signals);
Some(signals)
}
_ => None,
}
}
fn drain_events(&mut self, signals: &mut Vec<PaneSignal>) {
let Content::Term(t) = &mut self.content else { return };
let events: Vec<TermEvent> = t.events.borrow_mut().drain(..).collect();
for ev in events {
match ev {
TermEvent::PtyWrite(s) => t.write(t.graphics.device_attributes(&s).into_bytes()),
TermEvent::Title(s) => {
self.title = sanitize_title(&s);
signals.push(PaneSignal::TitleChanged);
}
TermEvent::ResetTitle => {
self.title.clear();
signals.push(PaneSignal::TitleChanged);
}
TermEvent::Bell => {
self.bell = true;
signals.push(PaneSignal::Bell);
}
TermEvent::ClipboardStore(_, text) => signals.push(PaneSignal::Clipboard(text)),
TermEvent::ColorRequest(index, format) => {
let rgb = t.term.colors()[index].unwrap_or_else(|| default_color(index));
t.write(format(rgb).into_bytes());
}
TermEvent::TextAreaSizeRequest(format) => {
let (cell_width, cell_height) = t.graphics.shared().cell();
let size = WindowSize { num_lines: self.rows, num_cols: self.cols, cell_width, cell_height };
t.write(format(size).into_bytes());
}
_ => {}
}
}
}
pub fn write_input(&mut self, bytes: Vec<u8>) {
if let Content::Term(t) = &mut self.content {
t.term.scroll_display(Scroll::Bottom);
t.write(bytes);
}
}
pub fn input_modes(&self) -> PaneInputModes {
let Some(t) = self.term() else { return PaneInputModes::default() };
let m = *t.term.mode();
PaneInputModes {
app_cursor: m.contains(TermMode::APP_CURSOR),
app_keypad: m.contains(TermMode::APP_KEYPAD),
bracketed_paste: m.contains(TermMode::BRACKETED_PASTE),
mouse_click: m.contains(TermMode::MOUSE_REPORT_CLICK),
mouse_drag: m.contains(TermMode::MOUSE_DRAG),
mouse_motion: m.contains(TermMode::MOUSE_MOTION),
sgr_mouse: m.contains(TermMode::SGR_MOUSE),
utf8_mouse: m.contains(TermMode::UTF8_MOUSE),
focus_events: m.contains(TermMode::FOCUS_IN_OUT),
alternate_scroll: m.contains(TermMode::ALTERNATE_SCROLL),
alt_screen: m.contains(TermMode::ALT_SCREEN),
kitty_flags: [
(TermMode::DISAMBIGUATE_ESC_CODES, 1),
(TermMode::REPORT_EVENT_TYPES, 2),
(TermMode::REPORT_ALTERNATE_KEYS, 4),
(TermMode::REPORT_ALL_KEYS_AS_ESC, 8),
(TermMode::REPORT_ASSOCIATED_TEXT, 16),
]
.iter()
.filter(|(f, _)| m.contains(*f))
.map(|(_, b)| b)
.sum(),
}
}
pub fn wants_mouse(&self) -> bool {
self.term().is_some_and(|t| t.term.mode().intersects(TermMode::MOUSE_MODE))
}
pub fn kill(&mut self) {
if let Content::Term(t) = &mut self.content {
t.hang_up();
}
}
pub fn signal(&self, signal: i32) {
let Some(t) = self.term() else { return };
t.process.signal_group(signal);
#[cfg(unix)]
if let Some(fg) = self.foreground_pid().filter(|fg| Some(*fg) != t.pid) {
unsafe {
libc::kill(-(fg as libc::pid_t), signal);
}
}
}
pub fn pid(&self) -> Option<u32> {
self.term().and_then(|t| t.pid)
}
pub fn process_hold(&self) -> Option<ProcessHold> {
self.term().map(|t| ProcessHold::new(&t.process))
}
pub fn foreground_argv(&self) -> Option<Vec<String>> {
let t = self.term()?;
let pgid = t.master.process_group_leader()?;
crate::process::argv(pgid as u32)
}
pub fn has_foreground_job(&self) -> bool {
let Some(t) = self.term() else { return false };
match (t.master.process_group_leader(), t.pid) {
(Some(pg), Some(pid)) => pg as u32 != pid,
_ => false,
}
}
pub fn shell_waiting(&self) -> bool {
if self.has_foreground_job() {
return false;
}
let program = self.pid().and_then(crate::process::argv).and_then(|a| a.into_iter().next());
program.is_none_or(|p| is_shell(&p))
}
pub fn current_cwd(&self) -> Option<PathBuf> {
if let Some(p) = &self.reported_cwd
&& !self.reported_remotely()
{
return Some(p.clone());
}
crate::process::cwd(self.pid()?)
}
pub fn remote_cwd(&self) -> Option<PathBuf> {
if self.reported_remotely() { self.reported_cwd.clone() } else { None }
}
fn reported_remotely(&self) -> bool {
let host = self.reported_host.as_str();
if host.is_empty() || host == "localhost" {
return false;
}
static LOCAL: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let local = LOCAL.get_or_init(crate::context::hostname);
local.as_deref().is_some_and(|l| {
let short = |s: &str| s.split('.').next().unwrap_or(s).to_ascii_lowercase();
short(l) != short(host)
})
}
pub fn foreground_pid(&self) -> Option<u32> {
let t = self.term()?;
t.master.process_group_leader().map(|p| p as u32)
}
}
pub fn open_url(url: &str) -> std::io::Result<()> {
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
std::process::Command::new(opener)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(crate::exec::reap_later)
}
pub fn is_shell(program: &str) -> bool {
let name = program.rsplit('/').next().unwrap_or(program).trim_start_matches('-');
matches!(
name,
"sh" | "bash"
| "zsh"
| "fish"
| "dash"
| "ash"
| "ksh"
| "mksh"
| "yash"
| "tcsh"
| "csh"
| "nu"
| "xonsh"
| "elvish"
| "pwsh"
)
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use alacritty_terminal::term::{Config as TermConfig, Term};
use alacritty_terminal::vte::ansi::{Processor, Rgb};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use super::pty::Listener;
use super::screen::{parse_xcolor, render_term, url_in_text};
use super::*;
use super::{pty, reset_sequence};
#[test]
fn a_restarted_pane_starts_with_fresh_modes() {
let size = pty::Size { cols: 20, rows: 6 };
let config = TermConfig { kitty_keyboard: true, ..TermConfig::default() };
let mut term = Term::new(config, &size, Listener::default());
let mut parser: Processor = Processor::new();
parser.advance(
&mut term,
b"\x1b[?1049h\x1b[>1u\x1b[?1049l\x1b[>3u\x1b[?1h\x1b=\x1b[?6h\x1b[2;4r\x1b[?1003h\x1b[?1004h\
\x1b[?2004h\x1b[4h\x1b[?25l\x1b(0\x1b7\x1b[?1049h",
);
parser.advance(&mut term, b"\x1b)0\x0e\x1b[3g\x1b[3G\x1bH\r\x1b(0\x1b7");
assert_ne!(*term.mode(), TermMode::default());
parser.advance(&mut term, &reset_sequence(20));
assert_eq!(*term.mode(), TermMode::default());
parser.advance(&mut term, b"\rq\tx");
let line = term.grid().cursor.point.line;
let row = &term.grid()[line];
assert_eq!(row[alacritty_terminal::index::Column(0)].c, 'q');
assert_eq!(row[alacritty_terminal::index::Column(8)].c, 'x');
let restored_q = |term: &mut Term<Listener>, parser: &mut Processor| {
parser.advance(term, b"\x1b8q");
let at = term.grid().cursor.point;
term.grid()[at.line][at.column - 1].c
};
assert_eq!(restored_q(&mut term, &mut parser), 'q');
parser.advance(&mut term, b"\x1b[?1049h");
assert_eq!(*term.mode() & !TermMode::ALT_SCREEN, TermMode::default());
assert_eq!(restored_q(&mut term, &mut parser), 'q');
}
#[test]
fn replies_held_in_a_synchronized_update_go_out_when_it_times_out() {
let (tx, _rx) = crossbeam_channel::bounded(64);
let env = PaneEnv {
socket: "/nonexistent".into(),
session: "t".into(),
shell: "sh".into(),
scrollback: 100,
graphics: Default::default(),
};
let spec = PaneSpec {
command: vec![
"sh".into(),
"-c".into(),
"stty raw -echo; printf '\\033[?2026h\\033[6n'; r=$(dd bs=1 count=6 2>/dev/null); \
printf 'got%s' \"$r\"; sleep 1"
.into(),
],
..Default::default()
};
let mut pane = Pane::spawn(1, None, spec, 40, 5, &env, &tx).unwrap();
let start = Instant::now();
let mut seen = String::new();
while !seen.contains('R') && start.elapsed() < std::time::Duration::from_secs(5) {
while let Some(data) = pane.take_output() {
seen.push_str(&String::from_utf8_lossy(&data));
pane.feed(&data);
}
pane.poll_sync_timeout();
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(seen.contains("got\x1b[1;1R"), "the reply never reached the program: {seen:?}");
}
#[test]
fn a_command_never_runs_in_another_directory_than_its_own() {
let (tx, _rx) = crossbeam_channel::bounded(64);
let env = PaneEnv {
socket: "/nonexistent".into(),
session: "t".into(),
shell: "sh".into(),
scrollback: 100,
graphics: Default::default(),
};
let gone = Some(PathBuf::from("/nonexistent/kraal-test-dir"));
let command = PaneSpec { command: vec!["true".into()], cwd: gone.clone(), ..Default::default() };
let err = Pane::spawn(1, None, command, 20, 5, &env, &tx).err().expect("refused");
assert!(format!("{err:#}").contains("kraal-test-dir"), "{err:#}");
let typed = PaneSpec { startup_input: Some("rm -rf build\r".into()), cwd: gone.clone(), ..Default::default() };
assert!(Pane::spawn(2, None, typed, 20, 5, &env, &tx).is_err());
let mut shell = Pane::spawn(3, None, PaneSpec { cwd: gone, ..Default::default() }, 20, 5, &env, &tx).unwrap();
shell.kill();
}
#[test]
fn shells_are_told_from_other_programs() {
for shell in ["zsh", "-zsh", "/bin/bash", "/usr/bin/fish", "sh"] {
assert!(is_shell(shell), "{shell}");
}
for other in ["vim", "/usr/bin/nvim", "ssh", "python3", "zshell"] {
assert!(!is_shell(other), "{other}");
}
}
#[test]
fn osc_scanner_finds_notifications_across_chunks() {
let mut s = OscScanner::default();
let data = b"hello\x1b]777;notify;Claude;needs you\x07 and \x1b]9;done!\x1b\\ \x1b]9;4;1;50\x07";
let mut found = Vec::new();
for chunk in data.chunks(3) {
found.extend(s.scan(chunk).into_iter().map(|(_, o)| o));
}
assert_eq!(
found,
vec![
Osc::Notify { title: "Claude".into(), body: "needs you".into() },
Osc::Notify { title: String::new(), body: "done!".into() },
]
);
}
#[test]
fn osc7_cwd() {
let mut s = OscScanner::default();
let found: Vec<Osc> =
s.scan(b"\x1b]7;file://laptop/home/me/My%20Dir\x07").into_iter().map(|(_, o)| o).collect();
assert_eq!(found, vec![Osc::Cwd { host: "laptop".into(), path: PathBuf::from("/home/me/My Dir") }]);
}
#[test]
fn render_copies_cells_and_styles() {
let events = Rc::new(RefCell::new(Vec::new()));
let mut term = Term::new(TermConfig::default(), &Size { cols: 10, rows: 3 }, Listener(events));
let mut parser: Processor = Processor::new();
parser.advance(&mut term, b"\x1b[1;31mhi\x1b[0m \xe6\x97\xa5x");
let area = Rect::new(2, 1, 10, 3);
let mut buf = Buffer::empty(Rect::new(0, 0, 14, 5));
render_term(&term, area, &mut buf, None);
assert_eq!(buf[(2, 1)].symbol(), "h");
assert_eq!(buf[(2, 1)].fg, Color::Red);
assert!(buf[(2, 1)].modifier.contains(Modifier::BOLD));
assert_eq!(buf[(5, 1)].symbol(), "日");
assert_eq!(buf[(7, 1)].symbol(), "x");
}
#[test]
fn osc8_links_are_collected() {
let events = Rc::new(RefCell::new(Vec::new()));
let mut term = Term::new(TermConfig::default(), &Size { cols: 20, rows: 2 }, Listener(events));
let mut parser: Processor = Processor::new();
parser.advance(&mut term, b"see \x1b]8;;https://x.org\x1b\\link\x1b]8;;\x1b\\ ok");
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 2));
let mut links = crate::ansi::Links::new();
render_term(&term, Rect::new(0, 0, 20, 2), &mut buf, Some(&mut links));
let mut cols: Vec<u16> = links.keys().map(|(x, _)| *x).collect();
cols.sort();
assert_eq!(cols, vec![4, 5, 6, 7]);
assert_eq!(&*links[&(4, 0)], "https://x.org");
}
#[test]
fn urls_in_text() {
let line = "see https://example.org/a?b=1). and more";
assert_eq!(url_in_text(line, 10).as_deref(), Some("https://example.org/a?b=1"));
assert_eq!(url_in_text(line, 1), None);
assert_eq!(url_in_text("(http://x.y/z)", 3).as_deref(), Some("http://x.y/z"));
assert_eq!(url_in_text("nothing here", 3), None);
}
#[test]
fn urls_are_found_under_wide_characters_columns() {
let events = Rc::new(RefCell::new(Vec::new()));
let mut term = Term::new(TermConfig::default(), &Size { cols: 30, rows: 1 }, Listener(events));
let mut parser: Processor = Processor::new();
parser.advance(&mut term, "日本 https://example.org".as_bytes());
let (line, index) = super::screen::row_chars(&term, alacritty_terminal::index::Line(0));
let col = 4 + "https://example.org".len();
assert_eq!(url_in_text(&line, index[col]).as_deref(), Some("https://example.org"));
assert_eq!(url_in_text(&line, index[1]), None);
}
#[test]
fn xcolor_parsing() {
assert_eq!(parse_xcolor("rgb:ffff/8080/0000"), Some(Rgb { r: 255, g: 128, b: 0 }));
assert_eq!(parse_xcolor("rgb:f/8/0"), Some(Rgb { r: 255, g: 136, b: 0 }));
assert_eq!(parse_xcolor("#fff"), None);
}
#[test]
fn default_palette_is_xterm_like() {
assert_eq!(default_color(16), Rgb { r: 0, g: 0, b: 0 });
assert_eq!(default_color(231), Rgb { r: 255, g: 255, b: 255 });
assert_eq!(default_color(232), Rgb { r: 8, g: 8, b: 8 });
}
}