use std::{
ffi::OsString,
ops::Range,
panic::AssertUnwindSafe,
path::PathBuf,
sync::{
Arc, Mutex as StdMutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use scv_protocol::{AttachColor, AttachPage, AttachRow, AttachSpan};
use tokio::{sync::watch, time::Instant};
use crate::{
DelegationContext,
delegate::{
activity::{RunActivity, RunHeader, RunTransport},
adapters::{self, Takeover as Offer},
background::lane::Place,
conversation::TakeoverHold,
progress,
pty::{self, Pty, PtySpec},
records::{self, DEPTH_VARIABLE, STOP_GRACE, group_exists},
},
process::ProcessGroup,
sync::lock,
};
pub(crate) const DEFAULT_SIZE: (u16, u16) = (100, 30);
const MIN_SIZE: (u16, u16) = (20, 5);
const MAX_SIZE: (u16, u16) = (400, 200);
pub(crate) const MAX_INPUT_BYTES: usize = 64 * 1024;
const INPUT_GRACE: Duration = Duration::from_secs(2);
const DEVICE_ATTRIBUTES: &[u8] = b"\x1b[?62;22c";
#[derive(Debug, Clone)]
pub(crate) struct TakeoverLaunch {
pub(crate) agent: String,
executable: PathBuf,
args: Vec<String>,
environment: Vec<(OsString, OsString)>,
delegation: Option<DelegationContext>,
}
pub(crate) fn launch(
name: &str,
command: &str,
search_dirs: &[PathBuf],
full: bool,
environment: &[(OsString, OsString)],
delegation: Option<&DelegationContext>,
) -> Result<TakeoverLaunch, String> {
let Some(adapter) = adapters::adapter(name) else {
return Err(format!("{name} has no terminal SCV can open"));
};
let (args, full_args) = match adapter.takeover {
Offer::Unsupported => {
return Err(format!(
"{} has no interactive mode SCV has verified to resume a conversation by its \
session ID, so it cannot be taken over in its terminal; follow its activity \
view and send it prompts instead",
adapter.product
));
}
Offer::Refused(why) => {
return Err(format!(
"{name} cannot be taken over in its terminal: {why}. Follow its activity view \
and send it prompts instead"
));
}
Offer::Supported { args, full_args } => (args, full_args),
};
if !full {
return Err(format!(
"{name} asks for approval in its own terminal unless its policy mode is auto \
([policy] mode, or [agents.{name}] mode): SCV relays those requests to its approval \
gate when it runs {name}, so a terminal whose keys would answer them is not offered"
));
}
let executable = adapters::resolve_agent_executable(command, search_dirs).ok_or_else(|| {
format!(
"{name}'s CLI {command:?} was not found on PATH or in the user's install directories"
)
})?;
Ok(TakeoverLaunch {
agent: name.to_owned(),
executable,
args: args
.iter()
.chain(full_args)
.map(|arg| (*arg).to_owned())
.collect(),
environment: environment.to_vec(),
delegation: delegation.cloned(),
})
}
pub(crate) fn clamp_size(cols: Option<u16>, rows: Option<u16>) -> (u16, u16) {
(
cols.unwrap_or(DEFAULT_SIZE.0).clamp(MIN_SIZE.0, MAX_SIZE.0),
rows.unwrap_or(DEFAULT_SIZE.1).clamp(MIN_SIZE.1, MAX_SIZE.1),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Life {
Running,
Exited,
Finished,
}
pub(crate) struct Takeover {
pub(crate) agent: String,
pub(crate) session: String,
pub(crate) conversation: String,
pub(crate) run: Option<String>,
pid: u32,
pty: Pty,
screen: StdMutex<Screen>,
life: watch::Receiver<Life>,
ended: StdMutex<Option<String>>,
stopping: AtomicBool,
failed: StdMutex<Option<String>>,
activity: Option<RunActivity>,
}
impl std::fmt::Debug for Takeover {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Takeover")
.field("conversation", &self.conversation)
.field("pid", &self.pid)
.finish_non_exhaustive()
}
}
pub(in crate::delegate) async fn start(
launch: &TakeoverLaunch,
hold: TakeoverHold,
place: Place,
session: &str,
(cols, rows): (u16, u16),
) -> Result<Arc<Takeover>, String> {
let args: Vec<String> = launch
.args
.iter()
.map(|arg| arg.replace("{session}", &hold.vendor))
.collect();
let pending = launch.delegation.as_ref().map(|delegation| {
delegation.registry.begin_at(
delegation.owner_depth(),
&launch.agent,
&delegation.session,
&hold.cwd,
Some((hold.handle.as_str(), hold.turns)),
)
});
let mut environment = launch.environment.clone();
match &pending {
Some(pending) => environment.extend(pending.environment.iter().cloned()),
None => environment.push((
DEPTH_VARIABLE.into(),
(records::current_depth() + 1).to_string().into(),
)),
}
let project =
crate::project_environment::resolve(&hold.cwd, &environment, std::future::pending::<()>())
.await
.map_err(|error| format!("{error:#}"))?;
environment.extend(project.environment);
environment.push(("TERM".into(), "xterm-256color".into()));
environment.push(("COLORTERM".into(), "truecolor".into()));
let spawned = pty::spawn(&PtySpec {
executable: &launch.executable,
args: &args,
cwd: &hold.cwd,
environment: &environment,
cols,
rows,
})
.map_err(|error| format!("could not open {}'s terminal: {error}", launch.agent))?;
let pid = spawned.pid;
records::track_spawned(pid);
let (run, guard, activity) = match (pending, &launch.delegation) {
(Some(pending), Some(delegation)) => {
let run = pending.handle.clone();
let activity = delegation.registry.open_activity(RunHeader {
handle: pending.handle.clone(),
agent: launch.agent.clone(),
session: delegation.session.clone(),
transport: RunTransport::Terminal,
cwd: hold.cwd.clone(),
conversation: Some((hold.handle.clone(), hold.turns)),
streams: true,
});
let guard = delegation.registry.register(pending, pid).ok();
if let Some(guard) = &guard {
guard.set_idle();
}
(Some(run), guard, activity)
}
_ => (None, None, None),
};
if let Some(activity) = &activity {
activity.set_vendor(&hold.vendor);
activity.terminal(
&format!(
"taken over by the owner in {}'s terminal ({})",
launch.agent,
args.join(" ")
),
ATTACHED,
);
}
let (life_tx, life) = watch::channel(Life::Running);
let takeover = Arc::new(Takeover {
agent: launch.agent.clone(),
session: session.to_owned(),
conversation: hold.handle.clone(),
run,
pid,
pty: spawned.pty,
screen: StdMutex::new(Screen::new(cols, rows)),
life,
ended: StdMutex::new(None),
stopping: AtomicBool::new(false),
failed: StdMutex::new(None),
activity,
});
tokio::spawn(reap(
Arc::clone(&takeover),
spawned.child,
guard,
(hold, place),
life_tx,
));
let io = Arc::clone(&takeover);
let runtime = tokio::runtime::Handle::current();
let started = std::thread::Builder::new()
.name("scv-terminal".into())
.spawn(move || {
let result = io.pty.run_io(|output| io.take_output(output));
let failed = match result {
Err(error) => {
lock(&io.failed).get_or_insert_with(|| {
format!("its terminal failed ({error}), so it was ended")
});
true
}
Ok(()) => lock(&io.failed).is_some(),
};
if failed && io.is_running() {
let takeover = Arc::clone(&io);
runtime.spawn(async move { takeover.end().await });
}
});
if let Err(error) = started {
takeover.kill_now();
return Err(format!(
"could not start {}'s terminal: {error}",
takeover.agent
));
}
Ok(takeover)
}
const ATTACHED: &str = "taken over in its terminal; the owner is attached";
const DETACHED: &str = "taken over in its terminal; nobody is attached";
impl Takeover {
pub(crate) fn is_running(&self) -> bool {
*self.life.borrow() == Life::Running
}
pub(crate) fn is_finished(&self) -> bool {
*self.life.borrow() == Life::Finished
}
pub(crate) fn note_attached(&self, attached: bool) {
if let Some(activity) = &self.activity
&& self.is_running()
{
if attached {
activity.terminal("the owner attached", ATTACHED);
} else {
activity.terminal("the owner detached", DETACHED);
}
}
}
pub(crate) async fn input(&self, input: &[u8]) -> Option<String> {
if input.is_empty() {
return None;
}
if !self.is_running() {
return Some("The terminal has closed; nothing was typed into it.".into());
}
let taken = self
.pty
.type_input(input, Instant::now() + INPUT_GRACE)
.await;
(taken < input.len()).then(|| {
format!(
"{} did not read its input within {} seconds; {} of {} bytes typed were dropped.",
self.agent,
INPUT_GRACE.as_secs(),
input.len() - taken,
input.len()
)
})
}
pub(crate) fn resize(&self, cols: Option<u16>, rows: Option<u16>) {
if cols.is_none() && rows.is_none() {
return;
}
let mut screen = lock(&self.screen);
let (current_rows, current_cols) = screen.parser.screen().size();
let (cols, rows) = clamp_size(cols.or(Some(current_cols)), rows.or(Some(current_rows)));
if (cols, rows) == (current_cols, current_rows) {
return;
}
screen.parser.set_size(rows, cols);
screen.generation += 1;
screen.resized = screen.generation;
screen.rows.clear();
screen.dirty = true;
drop(screen);
if let Err(error) = self.pty.resize(cols, rows) {
tracing::debug!(%error, "could not resize a takeover's terminal");
}
}
pub(crate) fn page(&self, id: &str, after: u64) -> AttachPage {
let mut screen = lock(&self.screen);
screen.refresh();
let full = after == 0 || after < screen.resized || after > screen.generation;
let lines = screen.rows_since(if full { None } else { Some(after) });
let view = screen.parser.screen();
let (rows, cols) = view.size();
let cursor = (!view.hide_cursor()).then(|| {
let (row, col) = view.cursor_position();
[row, col]
});
let page = AttachPage {
id: id.to_owned(),
session: self.session.clone(),
conversation: self.conversation.clone(),
agent: self.agent.clone(),
run: self.run.clone(),
generation: screen.generation,
cols,
rows,
lines,
full,
cursor,
application_cursor: view.application_cursor(),
bracketed_paste: view.bracketed_paste(),
ended: self.is_finished(),
exit: lock(&self.ended).clone(),
note: None,
};
drop(screen);
page
}
pub(crate) async fn end(&self) {
self.stopping.store(true, Ordering::Release);
let mut life = self.life.clone();
if *life.borrow() == Life::Running
&& let Some(group) = ProcessGroup::new(self.pid)
{
group.signal(libc::SIGHUP);
group.signal(libc::SIGCONT);
}
if tokio::time::timeout(STOP_GRACE, life.wait_for(|state| *state != Life::Running))
.await
.is_err()
&& let Some(group) = ProcessGroup::new(self.pid)
{
group.signal(libc::SIGKILL);
}
let _ = tokio::time::timeout(
STOP_GRACE * 2 + Duration::from_millis(500),
life.wait_for(|state| *state == Life::Finished),
)
.await;
}
}
impl Takeover {
pub(crate) fn kill_now(&self) {
self.stopping.store(true, Ordering::Release);
if self.is_running()
&& let Some(group) = ProcessGroup::new(self.pid)
{
group.signal(libc::SIGKILL);
}
}
fn take_output(&self, output: &[u8]) -> Vec<u8> {
let mut replies = Vec::new();
for slice in output.chunks(PARSE_SLICE) {
let mut screen = lock(&self.screen);
if screen.broken {
break;
}
let Ok(reply) = std::panic::catch_unwind(AssertUnwindSafe(|| screen.process(slice)))
else {
screen.broken = true;
drop(screen);
tracing::warn!(
agent = %self.agent,
"a takeover's terminal output could not be parsed; ending it"
);
*lock(&self.failed) =
Some("SCV could not keep its screen, so it was ended".to_owned());
self.pty.stop();
break;
};
replies.extend(reply);
}
replies
}
}
async fn reap(
takeover: Arc<Takeover>,
mut child: tokio::process::Child,
guard: Option<records::DelegationGuard>,
held: (TakeoverHold, Place),
life: watch::Sender<Life>,
) {
let status = child.wait().await;
let _ = life.send(Life::Exited);
let failed = lock(&takeover.failed).clone();
let how = if let Some(failed) = failed {
failed
} else if takeover.stopping.load(Ordering::Acquire) {
"ended from SCV".to_owned()
} else {
match status {
Ok(status) => match (
status.code(),
std::os::unix::process::ExitStatusExt::signal(&status),
) {
(Some(code), _) => format!("exit {code}"),
(None, Some(signal)) => format!("killed by signal {signal}"),
(None, None) => "exited".to_owned(),
},
Err(error) => format!("lost: {error}"),
}
};
if group_exists(takeover.pid)
&& let Some(group) = ProcessGroup::new(takeover.pid)
{
group.signal(libc::SIGKILL);
}
records::untrack_spawned(takeover.pid);
if let Some(guard) = guard {
guard.finish().await;
}
if let Some(activity) = &takeover.activity {
activity.terminal_closed(&how);
}
*lock(&takeover.ended) = Some(how);
takeover.pty.stop();
drop(held);
let _ = life.send(Life::Finished);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Query {
CursorPosition,
Status,
Attributes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mark {
Query(Query),
Restored,
}
#[derive(Debug, Default)]
struct Scanner {
state: Scan,
held: Vec<u8>,
high: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum Scan {
#[default]
Ground,
Escape,
Intermediate,
Sequence,
Dropping,
}
const MAX_HELD: usize = 256;
fn is_control(byte: u8) -> bool {
matches!(byte, 0x00..=0x17 | 0x19 | 0x1c..=0x1f)
}
impl Scanner {
fn scan(&mut self, bytes: &[u8], size: (u16, u16)) -> (Vec<u8>, Vec<(usize, Mark)>) {
let mut out = Vec::with_capacity(bytes.len() + 8);
let mut marks = Vec::new();
for &byte in bytes {
match self.state {
Scan::Ground if byte == 0x1b => {
if std::mem::take(&mut self.high) {
out.push(0);
}
self.state = Scan::Escape;
}
Scan::Ground => {
self.high = byte >= 0x80;
out.push(byte);
}
Scan::Escape => match byte {
b'[' => {
self.held.clear();
self.held.extend_from_slice(b"\x1b[");
self.state = Scan::Sequence;
}
_ if is_control(byte) => out.push(byte),
0x7f..=0xff => {}
0x1b => out.push(0x1b),
0x20..=0x2f => {
out.extend_from_slice(&[0x1b, byte]);
self.state = Scan::Intermediate;
}
_ => {
out.extend_from_slice(&[0x1b, byte]);
if byte == b'8' {
marks.push((out.len(), Mark::Restored));
}
self.state = Scan::Ground;
}
},
Scan::Intermediate => match byte {
0x1b => self.state = Scan::Escape,
0x18 | 0x1a | 0x30..=0x7e => {
out.push(byte);
self.state = Scan::Ground;
}
_ => out.push(byte),
},
Scan::Sequence => match byte {
0x40..=0x7e => {
self.held.push(byte);
self.state = Scan::Ground;
if let Some(mark) = finish(&self.held, size, &mut out) {
marks.push((out.len(), mark));
}
self.held.clear();
}
0x18 | 0x1a | 0x1b => {
out.append(&mut self.held);
if byte == 0x1b {
self.state = Scan::Escape;
} else {
out.push(byte);
self.state = Scan::Ground;
}
}
_ if self.held.len() < MAX_HELD => self.held.push(byte),
_ => {
out.extend(self.held.iter().copied().filter(|held| is_control(*held)));
self.held.clear();
self.state = Scan::Dropping;
if is_control(byte) {
out.push(byte);
}
}
},
Scan::Dropping => match byte {
0x40..=0x7e => self.state = Scan::Ground,
0x18 | 0x1a => {
out.push(byte);
self.state = Scan::Ground;
}
0x1b => self.state = Scan::Escape,
_ if is_control(byte) => out.push(byte),
_ => {}
},
}
}
(out, marks)
}
}
fn finish(sequence: &[u8], (rows, cols): (u16, u16), out: &mut Vec<u8>) -> Option<Mark> {
let (&last, body) = sequence[2..].split_last()?;
let params: Vec<u8> = body
.iter()
.copied()
.filter(|byte| (0x20..0x7f).contains(byte))
.collect();
let mark = match (params.as_slice(), last) {
(b"6", b'n') => Some(Mark::Query(Query::CursorPosition)),
(b"5", b'n') => Some(Mark::Query(Query::Status)),
(b"" | b"0", b'c') => Some(Mark::Query(Query::Attributes)),
([b'?', modes @ ..], b'l')
if modes
.split(|byte| *byte == b';')
.any(|mode| mode == b"1048" || mode == b"1049") =>
{
Some(Mark::Restored)
}
_ => None,
};
let bound = match last {
b'L' | b'M' | b'S' | b'T' => rows,
b'@' | b'P' | b'X' => cols,
_ => {
out.extend_from_slice(sequence);
return mark;
}
};
out.extend(body.iter().copied().filter(|byte| is_control(*byte)));
out.extend_from_slice(b"\x1b[");
let mut number: Option<u32> = None;
for &byte in params.iter().chain(std::iter::once(&last)) {
if byte.is_ascii_digit() {
let digit = u32::from(byte - b'0');
number = Some(number.unwrap_or(0).saturating_mul(10).saturating_add(digit));
continue;
}
if let Some(value) = number.take() {
out.extend_from_slice(value.min(u32::from(bound)).to_string().as_bytes());
}
out.push(byte);
}
mark
}
#[cfg(test)]
const FAIL_FOR_TESTS: &[u8] = b"\x1b]scv-test-parser-failure\x07";
const PARSE_SLICE: usize = 512;
struct Screen {
parser: vt100::Parser,
scanner: Scanner,
broken: bool,
dirty: bool,
generation: u64,
rows: Vec<(AttachRow, u64)>,
resized: u64,
}
impl Screen {
fn new(cols: u16, rows: u16) -> Self {
Self {
parser: vt100::Parser::new(rows, cols, 0),
scanner: Scanner::default(),
broken: false,
dirty: true,
generation: 0,
rows: Vec::new(),
resized: 0,
}
}
fn process(&mut self, bytes: &[u8]) -> Vec<u8> {
#[cfg(test)]
assert!(
!bytes
.windows(FAIL_FOR_TESTS.len())
.any(|window| window == FAIL_FOR_TESTS),
"a parser failure, for tests"
);
let (rows, cols) = self.parser.screen().size();
let (bytes, marks) = self.scanner.scan(bytes, (rows, cols));
let mut replies = Vec::new();
let mut done = 0;
for (end, mark) in marks {
self.parser.process(&bytes[done..end]);
done = end;
match mark {
Mark::Query(Query::CursorPosition) => {
let (row, col) = self.parser.screen().cursor_position();
replies.extend_from_slice(format!("\x1b[{};{}R", row + 1, col + 1).as_bytes());
}
Mark::Query(Query::Status) => replies.extend_from_slice(b"\x1b[0n"),
Mark::Query(Query::Attributes) => replies.extend_from_slice(DEVICE_ATTRIBUTES),
Mark::Restored => self.keep_cursor_on_screen(),
}
}
self.parser.process(&bytes[done..]);
self.dirty = true;
replies
}
fn keep_cursor_on_screen(&mut self) {
let view = self.parser.screen();
let (rows, cols) = view.size();
let (row, col) = view.cursor_position();
if row >= rows || col >= cols {
let back = format!("\x1b[{};{}H", row.min(rows - 1) + 1, col.min(cols - 1) + 1);
self.parser.process(back.as_bytes());
}
}
fn refresh(&mut self) {
if !self.dirty {
return;
}
self.dirty = false;
let shown = render(self.parser.screen());
let next = self.generation + 1;
let mut changed = self.rows.len() != shown.len();
if changed {
self.rows = shown.into_iter().map(|row| (row, next)).collect();
} else {
for (kept, row) in self.rows.iter_mut().zip(shown) {
if kept.0 != row {
*kept = (row, next);
changed = true;
}
}
}
if changed {
self.generation = next;
}
}
fn rows_since(&self, after: Option<u64>) -> Vec<AttachRow> {
self.rows
.iter()
.filter(|(_, at)| after.is_none_or(|after| *at > after))
.map(|(row, _)| row.clone())
.collect()
}
}
fn render(view: &vt100::Screen) -> Vec<AttachRow> {
let (rows, cols) = view.size();
let mut shown = Vec::with_capacity(usize::from(rows));
let mut hidden = false;
for line in logical_lines(view, rows, cols) {
let (line_rows, ends_hidden) = masked_rows(view, line, cols, hidden);
shown.extend(line_rows);
hidden = ends_hidden;
}
shown
}
fn logical_lines(view: &vt100::Screen, rows: u16, cols: u16) -> Vec<Range<u16>> {
let filled = |row: u16, col: u16| {
view.cell(row, col)
.is_some_and(|cell| !cell.contents().trim().is_empty())
};
let mut lines = Vec::new();
let mut start = 0;
for row in 0..rows {
let joins = row + 1 < rows
&& (view.row_wrapped(row)
|| (filled(row, cols.saturating_sub(1)) && filled(row + 1, 0)));
if !joins {
#[allow(
clippy::range_plus_one,
reason = "the return type is Range, whose end is exclusive"
)]
lines.push(start..row + 1);
start = row + 1;
}
}
lines
}
const WRAP_MARGIN: u16 = 3;
fn masked_rows(
view: &vt100::Screen,
line: Range<u16>,
cols: u16,
hidden: bool,
) -> (Vec<AttachRow>, bool) {
let mut text = String::new();
let mut cells: Vec<(u16, u16, Range<usize>)> = Vec::new();
for row in line.clone() {
for col in 0..cols {
let Some(cell) = view.cell(row, col) else {
continue;
};
if cell.is_wide_continuation() {
continue;
}
let start = text.len();
if cell.has_contents() {
text.push_str(&cell.contents());
} else {
text.push(' ');
}
cells.push((row, col, start..text.len()));
}
}
let (secrets, waiting) = progress::scrubbed_ranges(&text, hidden);
let shown = text.trim_end_matches(' ').len();
let wrapped = secrets.last().is_some_and(|secret| {
shown > 0
&& secret.end >= shown
&& cells.iter().any(|(_, col, range)| {
range.start < shown && shown <= range.end && *col + WRAP_MARGIN >= cols
})
});
let ends_hidden = waiting || wrapped;
let masked = |range: &Range<usize>| {
secrets
.iter()
.find(|secret| secret.start < range.end && range.start < secret.end)
};
let mut rows: Vec<AttachRow> = line
.clone()
.map(|row| AttachRow {
row,
spans: Vec::new(),
})
.collect();
for (row, col, range) in &cells {
let Some(cell) = view.cell(*row, *col) else {
continue;
};
let style = Style::of(cell);
let shown: String = match masked(range) {
Some(secret) => {
let mark = if range.start <= secret.start {
"…"
} else {
" "
};
if cell.is_wide() {
format!("{mark} ")
} else {
mark.to_owned()
}
}
None if cell.has_contents() => cell.contents(),
None => " ".to_owned(),
};
let spans = &mut rows[usize::from(row - line.start)].spans;
match spans.last_mut() {
Some(last) if Style::of_span(last) == style => last.text.push_str(&shown),
_ => spans.push(style.span(shown)),
}
}
for row in &mut rows {
while let Some(last) = row.spans.last_mut() {
if Style::of_span(last) != Style::default() {
break;
}
let trimmed = last.text.trim_end_matches(' ').len();
last.text.truncate(trimmed);
if !last.text.is_empty() {
break;
}
row.spans.pop();
}
}
(rows, ends_hidden)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
struct Style {
fg: Option<AttachColor>,
bg: Option<AttachColor>,
bold: bool,
italic: bool,
underline: bool,
inverse: bool,
}
impl Style {
fn of(cell: &vt100::Cell) -> Self {
Self {
fg: color(cell.fgcolor()),
bg: color(cell.bgcolor()),
bold: cell.bold(),
italic: cell.italic(),
underline: cell.underline(),
inverse: cell.inverse(),
}
}
fn of_span(span: &AttachSpan) -> Self {
Self {
fg: span.fg,
bg: span.bg,
bold: span.bold,
italic: span.italic,
underline: span.underline,
inverse: span.inverse,
}
}
fn span(self, text: String) -> AttachSpan {
AttachSpan {
text,
fg: self.fg,
bg: self.bg,
bold: self.bold,
italic: self.italic,
underline: self.underline,
inverse: self.inverse,
}
}
}
fn color(color: vt100::Color) -> Option<AttachColor> {
match color {
vt100::Color::Default => None,
vt100::Color::Idx(index) => Some(AttachColor::Index(index)),
vt100::Color::Rgb(red, green, blue) => Some(AttachColor::Rgb([red, green, blue])),
}
}
#[cfg(test)]
mod tests;