use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use wimux_protocol::transport::user_pipe_name;
use wimux_vt::{Cell, Color, Grid, Pen, Terminal};
pub enum CopyAction {
None,
Exit,
Copied(String),
}
#[derive(Clone)]
struct Search {
backward: bool,
query: String,
}
struct CopyMode {
view_top: usize,
cursor_line: usize,
cursor_col: u16,
anchor: Option<(usize, u16)>,
search_input: Option<Search>,
last_search: Option<Search>,
}
static NEXT_PANE_ID: AtomicU64 = AtomicU64::new(1);
pub type PaneId = u64;
pub fn next_pane_id() -> PaneId {
NEXT_PANE_ID.fetch_add(1, Ordering::Relaxed)
}
#[derive(Clone)]
pub struct PaneSpawnCtx {
pub session: String,
pub log: bool,
}
impl PaneSpawnCtx {
pub fn shell(session: &str) -> Self {
Self {
session: session.to_string(),
log: false,
}
}
pub fn agent(session: &str) -> Self {
Self {
session: session.to_string(),
log: true,
}
}
}
fn open_pane_log(session: &str, pane_id: PaneId) -> Option<(File, String)> {
let sanitized: String = session
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = std::env::var_os("LOCALAPPDATA")?;
let dir = PathBuf::from(base)
.join("wimux")
.join("logs")
.join(sanitized);
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!("{pane_id}.log"));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some((file, path.to_string_lossy().into_owned()))
}
pub struct Notifier {
generation: Mutex<u64>,
cond: Condvar,
bell: AtomicBool,
last_output_at: Mutex<Instant>,
notifications: Mutex<Vec<PaneNotif>>,
}
impl Notifier {
pub fn new() -> Arc<Notifier> {
Arc::new(Notifier {
generation: Mutex::new(0),
cond: Condvar::new(),
bell: AtomicBool::new(false),
last_output_at: Mutex::new(Instant::now()),
notifications: Mutex::new(Vec::new()),
})
}
pub fn push_notification(&self, n: PaneNotif) {
self.notifications.lock().unwrap().push(n);
}
pub fn drain_notifications(&self) -> Vec<PaneNotif> {
std::mem::take(&mut *self.notifications.lock().unwrap())
}
pub fn bump(&self) {
let mut g = self.generation.lock().unwrap();
*g += 1;
*self.last_output_at.lock().unwrap() = Instant::now();
self.cond.notify_all();
}
pub fn generation(&self) -> u64 {
*self.generation.lock().unwrap()
}
pub fn last_output_elapsed(&self) -> Duration {
self.last_output_at.lock().unwrap().elapsed()
}
pub fn notify(&self) {
self.cond.notify_all();
}
pub fn signal_bell(&self) {
self.bell.store(true, Ordering::Relaxed);
}
pub fn bell(&self) -> bool {
self.bell.load(Ordering::Relaxed)
}
pub fn clear_bell(&self) {
self.bell.store(false, Ordering::Relaxed);
}
pub fn wait_change(&self, last_seen: u64, keep_going: &AtomicBool) -> u64 {
let mut g = self.generation.lock().unwrap();
while *g == last_seen && keep_going.load(Ordering::Relaxed) {
let (guard, _timeout) = self
.cond
.wait_timeout(g, Duration::from_millis(200))
.unwrap();
g = guard;
}
*g
}
}
struct PaneState {
terminal: Terminal,
writer: Box<dyn Write + Send>,
master: Option<Box<dyn MasterPty + Send>>,
child: Option<Box<dyn Child + Send + Sync>>,
cols: u16,
rows: u16,
exit_code: Option<u32>,
copy: Option<CopyMode>,
subscribers: Vec<std::sync::mpsc::Sender<(PaneId, Vec<u8>)>>,
cwd: Option<String>,
sniffer: Osc7Sniffer,
log: Option<File>,
log_path: Option<String>,
}
const OSC7_MAX: usize = 4096;
#[derive(Debug, Clone, PartialEq)]
pub struct PaneNotif {
pub title: Option<String>,
pub body: String,
}
#[derive(Default, PartialEq)]
enum PayloadKind {
#[default]
Cwd,
Notif9,
Notif777,
}
#[derive(Default)]
struct Osc7Sniffer {
state: SniffState,
ps: Vec<u8>,
payload: Vec<u8>,
payload_kind: PayloadKind,
notifs: Vec<PaneNotif>,
}
#[derive(Default, PartialEq)]
enum SniffState {
#[default]
Ground,
Esc,
Ps,
Payload,
PayloadEsc,
Skip,
SkipEsc,
}
impl Osc7Sniffer {
fn feed(&mut self, bytes: &[u8]) -> Option<String> {
let mut last: Option<String> = None;
for &b in bytes {
match self.state {
SniffState::Ground => {
if b == 0x1b {
self.state = SniffState::Esc;
}
}
SniffState::Esc => {
if b == 0x5d {
self.ps.clear();
self.state = SniffState::Ps;
} else if b == 0x1b {
self.state = SniffState::Esc;
} else {
self.state = SniffState::Ground;
}
}
SniffState::Ps => match b {
b';' => {
let kind = match self.ps.as_slice() {
b"7" => Some(PayloadKind::Cwd),
b"9" => Some(PayloadKind::Notif9),
b"777" => Some(PayloadKind::Notif777),
_ => None,
};
match kind {
Some(k) => {
self.payload.clear();
self.payload_kind = k;
self.state = SniffState::Payload;
}
None => self.state = SniffState::Skip,
}
}
0x30..=0x39 => {
self.ps.push(b);
if self.ps.len() > 4 {
self.state = SniffState::Skip;
}
}
0x07 => self.state = SniffState::Ground, 0x1b => self.state = SniffState::Esc,
_ => self.state = SniffState::Skip,
},
SniffState::Payload => match b {
0x07 => {
self.finish_payload(&mut last);
self.state = SniffState::Ground;
}
0x1b => self.state = SniffState::PayloadEsc,
_ => {
self.payload.push(b);
if self.payload.len() > OSC7_MAX {
self.state = SniffState::Ground;
}
}
},
SniffState::PayloadEsc => {
if b == b'\\' {
self.finish_payload(&mut last);
self.state = SniffState::Ground;
} else if b == 0x1b {
self.state = SniffState::Esc;
} else {
self.state = SniffState::Ground;
}
}
SniffState::Skip => match b {
0x07 => self.state = SniffState::Ground,
0x1b => self.state = SniffState::SkipEsc,
_ => {}
},
SniffState::SkipEsc => {
if b == 0x1b {
self.state = SniffState::Esc;
} else if b == b'\\' {
self.state = SniffState::Ground;
} else {
self.state = SniffState::Skip;
}
}
}
}
last
}
fn finish_payload(&mut self, last: &mut Option<String>) {
match self.payload_kind {
PayloadKind::Cwd => {
if let Some(p) = decode_osc7_uri(&self.payload) {
*last = Some(p);
}
}
PayloadKind::Notif9 => {
if let Some(n) = parse_osc9(&self.payload) {
self.notifs.push(n);
}
}
PayloadKind::Notif777 => {
if let Some(n) = parse_osc777(&self.payload) {
self.notifs.push(n);
}
}
}
}
fn take_notifs(&mut self) -> Vec<PaneNotif> {
std::mem::take(&mut self.notifs)
}
}
fn parse_osc9(payload: &[u8]) -> Option<PaneNotif> {
let body = String::from_utf8_lossy(payload).trim().to_string();
if body.is_empty() {
None
} else {
Some(PaneNotif { title: None, body })
}
}
fn parse_osc777(payload: &[u8]) -> Option<PaneNotif> {
let s = String::from_utf8_lossy(payload);
let mut parts = s.splitn(3, ';');
if parts.next()? != "notify" {
return None;
}
let title = parts.next()?.to_string();
let body = parts.next().unwrap_or("").to_string();
Some(PaneNotif {
title: if title.is_empty() { None } else { Some(title) },
body,
})
}
fn decode_osc7_uri(payload: &[u8]) -> Option<String> {
let s = std::str::from_utf8(payload).ok()?;
let rest = s.strip_prefix("file://")?;
let slash = rest.find('/')?;
let host = &rest[..slash];
if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
return None;
}
let decoded = percent_decode(&rest[slash..]);
Some(uri_path_to_windows(&decoded))
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn uri_path_to_windows(path: &str) -> String {
let b = path.as_bytes();
let trimmed = if b.len() >= 3 && b[0] == b'/' && b[2] == b':' && b[1].is_ascii_alphabetic() {
&path[1..]
} else {
path
};
trimmed.replace('/', "\\")
}
pub struct Pane {
pub id: PaneId,
state: Mutex<PaneState>,
notifier: Arc<Notifier>,
}
impl Pane {
pub fn spawn(
cols: u16,
rows: u16,
shell: &str,
notifier: Arc<Notifier>,
ctx: PaneSpawnCtx,
) -> Result<Arc<Pane>> {
Pane::spawn_command(cols, rows, shell, &[], None, notifier, ctx)
}
pub fn spawn_command(
cols: u16,
rows: u16,
program: &str,
args: &[String],
cwd: Option<&str>,
notifier: Arc<Notifier>,
ctx: PaneSpawnCtx,
) -> Result<Arc<Pane>> {
let cols = cols.max(1);
let rows = rows.max(1);
let id = next_pane_id();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("ouverture de la pseudo-console")?;
let mut cmd = CommandBuilder::new(program);
cmd.args(args);
if let Some(dir) = cwd {
cmd.cwd(dir);
}
cmd.env("WIMUX_SESSION", &ctx.session);
cmd.env("WIMUX_PANE", id.to_string());
cmd.env("WIMUX_PIPE", user_pipe_name());
let child = pair
.slave
.spawn_command(cmd)
.context("lancement du programme")?;
let reader = pair
.master
.try_clone_reader()
.context("clonage du lecteur PTY")?;
let writer = pair
.master
.take_writer()
.context("prise de l'écrivain PTY")?;
drop(pair.slave);
let (log, log_path) = if ctx.log {
match open_pane_log(&ctx.session, id) {
Some((f, p)) => (Some(f), Some(p)),
None => (None, None),
}
} else {
(None, None)
};
let pane = Arc::new(Pane {
id,
state: Mutex::new(PaneState {
terminal: Terminal::new(cols, rows),
writer,
master: Some(pair.master),
child: Some(child),
cols,
rows,
exit_code: None,
copy: None,
subscribers: Vec::new(),
cwd: None,
sniffer: Osc7Sniffer::default(),
log,
log_path,
}),
notifier,
});
let reader_pane = Arc::clone(&pane);
std::thread::spawn(move || reader_loop(reader_pane, reader));
let waiter_pane = Arc::clone(&pane);
std::thread::spawn(move || wait_for_exit(waiter_pane));
Ok(pane)
}
pub fn log_path(&self) -> Option<String> {
self.state.lock().unwrap().log_path.clone()
}
pub fn send_input(&self, bytes: &[u8]) {
let mut st = self.state.lock().unwrap();
let _ = st.writer.write_all(bytes);
let _ = st.writer.flush();
}
pub fn resize(&self, cols: u16, rows: u16) {
let cols = cols.max(1);
let rows = rows.max(1);
let mut st = self.state.lock().unwrap();
if st.cols == cols && st.rows == rows {
return;
}
if let Some(m) = st.master.as_ref() {
let _ = m.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
st.terminal.resize(cols, rows);
st.cols = cols;
st.rows = rows;
}
pub fn snapshot(&self) -> (Grid, (u16, u16)) {
let st = self.state.lock().unwrap();
match &st.copy {
Some(cm) => render_copy_view(cm, &st.terminal, st.cols, st.rows),
None => (st.terminal.grid().clone(), st.terminal.cursor()),
}
}
pub fn size(&self) -> (u16, u16) {
let st = self.state.lock().unwrap();
(st.cols, st.rows)
}
pub fn cwd(&self) -> Option<String> {
self.state.lock().unwrap().cwd.clone()
}
pub fn snapshot_and_subscribe(
&self,
) -> (Vec<u8>, std::sync::mpsc::Receiver<(PaneId, Vec<u8>)>) {
let mut st = self.state.lock().unwrap();
let snapshot = grid_to_ansi(st.terminal.grid(), st.terminal.cursor());
let (tx, rx) = std::sync::mpsc::channel();
st.subscribers.push(tx);
(snapshot, rx)
}
pub fn snapshot_and_subscribe_into(
&self,
tx: std::sync::mpsc::Sender<(PaneId, Vec<u8>)>,
) -> Vec<u8> {
let mut st = self.state.lock().unwrap();
let snapshot = grid_to_ansi(st.terminal.grid(), st.terminal.cursor());
st.subscribers.push(tx);
snapshot
}
pub fn capture_text(&self) -> String {
let st = self.state.lock().unwrap();
let grid = st.terminal.grid();
let mut lines = Vec::with_capacity(grid.rows() as usize);
for row in 0..grid.rows() {
let line: String = grid
.row(row)
.iter()
.filter(|c| c.width != 0)
.map(|c| c.ch)
.collect();
lines.push(line.trim_end().to_string());
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\r\n")
}
pub fn in_copy_mode(&self) -> bool {
self.state.lock().unwrap().copy.is_some()
}
pub fn copy_status(&self) -> Option<String> {
let st = self.state.lock().unwrap();
st.copy.as_ref().map(|cm| match &cm.search_input {
Some(si) => {
let prefix = if si.backward { '?' } else { '/' };
format!("{prefix}{}", si.query)
}
None => {
let total = total_lines(&st.terminal);
format!("COPIE {}/{}", cm.cursor_line + 1, total)
}
})
}
pub fn enter_copy_mode(&self) {
let mut st = self.state.lock().unwrap();
if st.copy.is_some() {
return;
}
let history = st.terminal.history().len();
let (ccol, crow) = st.terminal.cursor();
let rows = st.rows;
let cursor_line = history + crow as usize;
let view_top = (history + st.rows as usize).saturating_sub(rows as usize);
st.copy = Some(CopyMode {
view_top,
cursor_line,
cursor_col: ccol,
anchor: None,
search_input: None,
last_search: None,
});
self.notifier.bump();
}
pub fn copy_key(&self, byte: u8) -> CopyAction {
let mut guard = self.state.lock().unwrap();
let st = &mut *guard;
let rows = st.rows as usize;
let cols = st.cols;
let total = total_lines(&st.terminal);
let Some(cm) = st.copy.as_mut() else {
return CopyAction::None;
};
if cm.search_input.is_some() {
match byte {
0x0d => {
let search = cm.search_input.take().unwrap();
if !search.query.is_empty() {
run_search(cm, &st.terminal, &search);
cm.last_search = Some(search);
}
}
0x1b => cm.search_input = None, 0x08 | 0x7f => {
cm.search_input.as_mut().unwrap().query.pop();
}
b if (0x20..0x7f).contains(&b) => {
cm.search_input.as_mut().unwrap().query.push(b as char);
}
_ => {}
}
keep_cursor_visible(cm, rows);
self.notifier.bump();
return CopyAction::None;
}
match byte {
b'q' | 0x1b => {
st.copy = None;
self.notifier.bump();
return CopyAction::Exit;
}
b'j' => cm.cursor_line = (cm.cursor_line + 1).min(total.saturating_sub(1)),
b'k' => cm.cursor_line = cm.cursor_line.saturating_sub(1),
b'h' => cm.cursor_col = cm.cursor_col.saturating_sub(1),
b'l' => cm.cursor_col = (cm.cursor_col + 1).min(cols.saturating_sub(1)),
b'0' => cm.cursor_col = 0,
b'$' => cm.cursor_col = cols.saturating_sub(1),
b'g' => cm.cursor_line = 0,
b'G' => cm.cursor_line = total.saturating_sub(1),
0x15 => cm.cursor_line = cm.cursor_line.saturating_sub(rows / 2), 0x04 => {
cm.cursor_line = (cm.cursor_line + rows / 2).min(total.saturating_sub(1)); }
b'w' => {
cm.cursor_col = word_forward(
&logical_line(&st.terminal, cm.cursor_line),
cm.cursor_col,
cols,
)
}
b'b' => {
cm.cursor_col =
word_backward(&logical_line(&st.terminal, cm.cursor_line), cm.cursor_col)
}
b'/' => {
cm.search_input = Some(Search {
backward: false,
query: String::new(),
})
}
b'?' => {
cm.search_input = Some(Search {
backward: true,
query: String::new(),
})
}
b'n' | b'N' => {
if let Some(mut search) = cm.last_search.clone() {
if byte == b'N' {
search.backward = !search.backward;
}
run_search(cm, &st.terminal, &search);
}
}
b' ' => cm.anchor = Some((cm.cursor_line, cm.cursor_col)),
b'y' | 0x0d => {
let text = extract_selection(cm, &st.terminal, cols);
st.copy = None;
self.notifier.bump();
return CopyAction::Copied(text);
}
_ => return CopyAction::None,
}
if let Some(cm) = st.copy.as_mut() {
keep_cursor_visible(cm, rows);
}
self.notifier.bump();
CopyAction::None
}
pub fn scroll(&self, up: bool, lines: u16) {
let mut guard = self.state.lock().unwrap();
let st = &mut *guard;
if st.copy.is_none() {
let history = st.terminal.history().len();
let (ccol, crow) = st.terminal.cursor();
let cursor_line = history + crow as usize;
let view_top = (history + st.rows as usize).saturating_sub(st.rows as usize);
st.copy = Some(CopyMode {
view_top,
cursor_line,
cursor_col: ccol,
anchor: None,
search_input: None,
last_search: None,
});
}
let total = total_lines(&st.terminal);
let rows = st.rows as usize;
if let Some(cm) = st.copy.as_mut() {
if up {
cm.cursor_line = cm.cursor_line.saturating_sub(lines as usize);
} else {
cm.cursor_line = (cm.cursor_line + lines as usize).min(total.saturating_sub(1));
}
keep_cursor_visible(cm, rows);
}
drop(guard);
self.notifier.bump();
}
pub fn is_alive(&self) -> bool {
self.state.lock().unwrap().exit_code.is_none()
}
pub fn exit_code(&self) -> Option<u32> {
self.state.lock().unwrap().exit_code
}
pub fn kill(&self) {
let mut st = self.state.lock().unwrap();
if let Some(child) = st.child.as_mut() {
let _ = child.kill();
}
st.master.take();
}
}
fn wait_for_exit(pane: Arc<Pane>) {
loop {
let done = {
let mut st = pane.state.lock().unwrap();
if st.exit_code.is_some() {
return;
}
st.child.as_mut().and_then(|c| c.try_wait().ok().flatten())
};
if let Some(status) = done {
let mut st = pane.state.lock().unwrap();
if st.exit_code.is_none() {
st.exit_code = Some(status.exit_code());
drop(st);
pane.notifier.bump();
}
return;
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn reader_loop(pane: Arc<Pane>, mut reader: Box<dyn Read + Send>) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let rang = {
let mut st = pane.state.lock().unwrap();
st.terminal.advance(&buf[..n]);
if let Some(f) = st.log.as_mut() {
let _ = f.write_all(&buf[..n]);
}
if let Some(cwd) = st.sniffer.feed(&buf[..n]) {
st.cwd = Some(cwd);
}
for notif in st.sniffer.take_notifs() {
pane.notifier.push_notification(notif);
}
let responses = st.terminal.take_responses();
if !responses.is_empty() {
let _ = st.writer.write_all(&responses);
let _ = st.writer.flush();
}
st.subscribers
.retain(|tx| tx.send((pane.id, buf[..n].to_vec())).is_ok());
st.terminal.take_bell()
};
if rang {
pane.notifier.signal_bell();
}
pane.notifier.bump();
}
}
}
let child = {
let mut st = pane.state.lock().unwrap();
if st.exit_code.is_some() {
return;
}
st.child.take()
};
let code = child
.and_then(|mut c| c.wait().ok())
.map(|status| status.exit_code())
.unwrap_or(0);
let mut st = pane.state.lock().unwrap();
if st.exit_code.is_none() {
st.exit_code = Some(code);
drop(st);
pane.notifier.bump();
}
}
fn total_lines(term: &Terminal) -> usize {
term.history().len() + term.grid().rows() as usize
}
fn logical_line(term: &Terminal, idx: usize) -> Vec<Cell> {
let h = term.history().len();
if idx < h {
term.history()[idx].clone()
} else {
term.grid().row((idx - h) as u16).to_vec()
}
}
fn render_copy_view(cm: &CopyMode, term: &Terminal, cols: u16, rows: u16) -> (Grid, (u16, u16)) {
let mut grid = Grid::new(cols, rows);
let total = total_lines(term);
let sel = cm.anchor.map(|a| {
let c = (cm.cursor_line, cm.cursor_col);
if a <= c { (a, c) } else { (c, a) }
});
for r in 0..rows {
let line_idx = cm.view_top + r as usize;
if line_idx >= total {
break;
}
let cells = logical_line(term, line_idx);
for (col, cell) in cells.iter().enumerate() {
let col = col as u16;
if col >= cols {
break;
}
let mut c = *cell;
if let Some(((sl, sc), (el, ec))) = sel
&& in_selection(line_idx, col, sl, sc, el, ec)
{
c.pen.attrs.reverse = true;
}
grid.set(col, r, c);
}
}
let cy =
(cm.cursor_line.saturating_sub(cm.view_top)).min(rows.saturating_sub(1) as usize) as u16;
let cx = cm.cursor_col.min(cols.saturating_sub(1));
(grid, (cx, cy))
}
fn in_selection(line: usize, col: u16, sl: usize, sc: u16, el: usize, ec: u16) -> bool {
if line < sl || line > el {
return false;
}
if sl == el {
col >= sc && col <= ec
} else if line == sl {
col >= sc
} else if line == el {
col <= ec
} else {
true
}
}
fn extract_selection(cm: &CopyMode, term: &Terminal, cols: u16) -> String {
let Some(anchor) = cm.anchor else {
let cells = logical_line(term, cm.cursor_line);
return line_text(&cells, 0, cols.saturating_sub(1));
};
let c = (cm.cursor_line, cm.cursor_col);
let ((sl, sc), (el, ec)) = if anchor <= c {
(anchor, c)
} else {
(c, anchor)
};
let mut lines = Vec::new();
for line in sl..=el {
let cells = logical_line(term, line);
let (from, to) = if sl == el {
(sc, ec)
} else if line == sl {
(sc, cols.saturating_sub(1))
} else if line == el {
(0, ec)
} else {
(0, cols.saturating_sub(1))
};
lines.push(line_text(&cells, from, to));
}
lines.join("\r\n")
}
fn keep_cursor_visible(cm: &mut CopyMode, rows: usize) {
if cm.cursor_line < cm.view_top {
cm.view_top = cm.cursor_line;
} else if rows > 0 && cm.cursor_line >= cm.view_top + rows {
cm.view_top = cm.cursor_line + 1 - rows;
}
}
fn is_word(c: char) -> bool {
!c.is_whitespace()
}
fn word_forward(cells: &[Cell], col: u16, cols: u16) -> u16 {
let n = cells.len().min(cols as usize);
let mut i = col as usize;
while i < n && is_word(cells[i].ch) {
i += 1;
}
while i < n && !is_word(cells[i].ch) {
i += 1;
}
i.min(n.saturating_sub(1)) as u16
}
fn word_backward(cells: &[Cell], col: u16) -> u16 {
let mut i = col as usize;
if i == 0 {
return 0;
}
i -= 1;
while i > 0 && !is_word(cells[i].ch) {
i -= 1;
}
while i > 0 && is_word(cells[i - 1].ch) {
i -= 1;
}
i as u16
}
fn run_search(cm: &mut CopyMode, term: &Terminal, search: &Search) {
let total = total_lines(term);
let needle: Vec<char> = search.query.chars().collect();
if needle.is_empty() {
return;
}
let chars_of = |idx: usize| -> Vec<char> {
logical_line(term, idx)
.iter()
.filter(|c| c.width != 0)
.map(|c| c.ch)
.collect()
};
if !search.backward {
for line in cm.cursor_line..total {
let hay = chars_of(line);
let from = if line == cm.cursor_line {
cm.cursor_col as usize + 1
} else {
0
};
if let Some(pos) = find_sub(&hay, &needle, from) {
cm.cursor_line = line;
cm.cursor_col = pos as u16;
return;
}
}
} else {
for line in (0..=cm.cursor_line).rev() {
let hay = chars_of(line);
let upto = if line == cm.cursor_line {
cm.cursor_col as usize
} else {
hay.len()
};
if let Some(pos) = rfind_sub(&hay, &needle, upto) {
cm.cursor_line = line;
cm.cursor_col = pos as u16;
return;
}
}
}
}
fn find_sub(hay: &[char], needle: &[char], from: usize) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
(from..=hay.len() - needle.len()).find(|&i| hay[i..i + needle.len()] == *needle)
}
fn rfind_sub(hay: &[char], needle: &[char], upto: usize) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
let max_start = upto.min(hay.len() - needle.len() + 1);
(0..max_start)
.rev()
.find(|&i| hay[i..i + needle.len()] == *needle)
}
fn line_text(cells: &[Cell], from: u16, to: u16) -> String {
let mut s = String::new();
for col in from..=to {
if let Some(cell) = cells.get(col as usize)
&& cell.width != 0
{
s.push(cell.ch);
}
}
s.trim_end().to_string()
}
fn grid_to_ansi(grid: &Grid, cursor: (u16, u16)) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"\x1b[2J\x1b[H");
for row in 0..grid.rows() {
let mut cur_pen: Option<Pen> = None;
for cell in grid.row(row) {
if cell.width == 0 {
continue; }
if cur_pen != Some(cell.pen) {
out.extend_from_slice(b"\x1b[0m");
let sgr = pen_to_sgr(&cell.pen);
if !sgr.is_empty() {
out.extend_from_slice(format!("\x1b[{sgr}m").as_bytes());
}
cur_pen = Some(cell.pen);
}
let mut buf = [0u8; 4];
out.extend_from_slice(cell.ch.encode_utf8(&mut buf).as_bytes());
}
out.extend_from_slice(b"\x1b[0m");
if row + 1 < grid.rows() {
out.extend_from_slice(b"\r\n");
}
}
let (col, row) = cursor;
out.extend_from_slice(format!("\x1b[{};{}H", row + 1, col + 1).as_bytes());
out
}
fn pen_to_sgr(pen: &Pen) -> String {
let mut codes: Vec<String> = Vec::new();
if pen.attrs.bold {
codes.push("1".into());
}
if pen.attrs.italic {
codes.push("3".into());
}
if pen.attrs.underline {
codes.push("4".into());
}
if pen.attrs.reverse {
codes.push("7".into());
}
match pen.fg {
Color::Default => {}
Color::Indexed(n @ 0..=7) => codes.push((30 + n as u16).to_string()),
Color::Indexed(n @ 8..=15) => codes.push((90 + n as u16 - 8).to_string()),
Color::Indexed(n) => codes.push(format!("38;5;{n}")),
Color::Rgb(r, g, b) => codes.push(format!("38;2;{r};{g};{b}")),
}
match pen.bg {
Color::Default => {}
Color::Indexed(n @ 0..=7) => codes.push((40 + n as u16).to_string()),
Color::Indexed(n @ 8..=15) => codes.push((100 + n as u16 - 8).to_string()),
Color::Indexed(n) => codes.push(format!("48;5;{n}")),
Color::Rgb(r, g, b) => codes.push(format!("48;2;{r};{g};{b}")),
}
codes.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notifier_cloche() {
let n = Notifier::new();
assert!(!n.bell(), "cloche neuve à faux");
n.signal_bell();
assert!(n.bell());
n.clear_bell();
assert!(!n.bell());
}
#[test]
fn snapshot_reproduit_le_texte_visible() {
let mut term = wimux_vt::Terminal::new(20, 3);
term.advance(b"abc\r\ndef");
let bytes = grid_to_ansi(term.grid(), term.cursor());
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("abc"));
assert!(text.contains("def"));
}
#[test]
fn grid_to_ansi_preserve_couleur_et_curseur() {
let mut term = wimux_vt::Terminal::new(20, 3);
term.advance(b"\x1b[31mRED\x1b[0m");
let bytes = grid_to_ansi(term.grid(), term.cursor());
let mut term2 = wimux_vt::Terminal::new(20, 3);
term2.advance(&bytes);
let cell = term2.grid().cell(0, 0).unwrap();
assert_eq!(cell.ch, 'R');
assert_eq!(cell.pen.fg, wimux_vt::Color::Indexed(1));
assert_eq!(term2.cursor(), term.cursor());
}
#[test]
fn grid_to_ansi_fidelite_couleurs_attributs() {
let mut term = wimux_vt::Terminal::new(30, 2);
term.advance(b"\x1b[44;1mAB\x1b[0mC\x1b[38;5;200mD\x1b[0m\x1b[38;2;10;20;30mE\x1b[0m");
let bytes = grid_to_ansi(term.grid(), term.cursor());
let mut term2 = wimux_vt::Terminal::new(30, 2);
term2.advance(&bytes);
let g1 = term.grid();
let g2 = term2.grid();
for col in 0..5u16 {
let c1 = g1.cell(col, 0).unwrap();
let c2 = g2.cell(col, 0).unwrap();
assert_eq!(c2.ch, c1.ch, "caractere different en colonne {col}");
assert_eq!(c2.pen, c1.pen, "pen different en colonne {col}");
}
}
#[test]
fn notifier_horodatage_apres_bump() {
let n = Notifier::new();
n.bump();
assert!(
n.last_output_elapsed() < Duration::from_secs(1),
"juste après un bump, l'écoulement doit être petit"
);
}
#[test]
fn notifier_neuf_a_un_horodatage_defini() {
let n = Notifier::new();
assert!(n.last_output_elapsed() < Duration::from_secs(1));
}
#[test]
fn exit_code_none_pour_volet_vivant() {
let n = Notifier::new();
let p = Pane::spawn(20, 5, "cmd.exe", n, PaneSpawnCtx::shell("test")).unwrap();
assert_eq!(
p.exit_code(),
None,
"un volet vivant n'a pas de code de sortie"
);
p.kill();
}
#[test]
fn kill_ferme_le_master_et_libere_le_lecteur() {
let n = Notifier::new();
let pane = Pane::spawn(20, 5, "cmd.exe", n, PaneSpawnCtx::shell("test")).unwrap();
pane.send_input(b"exit 0\r\n");
let deadline = Instant::now() + Duration::from_secs(10);
while pane.exit_code().is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
}
assert!(
pane.exit_code().is_some(),
"le shell aurait dû sortir (exit 0)"
);
pane.kill();
let deadline = Instant::now() + Duration::from_secs(10);
while Arc::strong_count(&pane) > 1 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
}
assert_eq!(
Arc::strong_count(&pane),
1,
"après kill (master fermé), le thread lecteur doit relâcher son Arc<Pane>"
);
}
#[test]
fn sniffer_bel_extrait_le_cwd() {
let mut s = Osc7Sniffer::default();
let out = s.feed(b"before\x1b]7;file:///C:/a/b\x07after");
assert_eq!(out.as_deref(), Some("C:\\a\\b"));
}
#[test]
fn sniffer_st_extrait_le_cwd() {
let mut s = Osc7Sniffer::default();
let out = s.feed(b"\x1b]7;file:///C:/a/b\x1b\\");
assert_eq!(out.as_deref(), Some("C:\\a\\b"));
}
#[test]
fn sniffer_sequence_coupee_en_deux_lectures() {
let mut s = Osc7Sniffer::default();
assert_eq!(s.feed(b"\x1b]7;file:///C:/a"), None);
let out = s.feed(b"/b\x07");
assert_eq!(out.as_deref(), Some("C:\\a\\b"));
}
#[test]
fn sniffer_url_decode_espace() {
let mut s = Osc7Sniffer::default();
let out = s.feed(b"\x1b]7;file:///C:/a%20b\x07");
assert_eq!(out.as_deref(), Some("C:\\a b"));
}
#[test]
fn sniffer_localhost_accepte() {
let mut s = Osc7Sniffer::default();
let out = s.feed(b"\x1b]7;file://localhost/C:/x\x07");
assert_eq!(out.as_deref(), Some("C:\\x"));
}
#[test]
fn sniffer_host_distant_ignore() {
let mut s = Osc7Sniffer::default();
assert_eq!(s.feed(b"\x1b]7;file://autremachine/C:/x\x07"), None);
}
#[test]
fn sniffer_sans_osc7_pas_de_faux_positif() {
let mut s = Osc7Sniffer::default();
assert_eq!(s.feed(b"texte ordinaire\r\nPS C:\\> "), None);
}
#[test]
fn sniffer_osc_autre_que_7_ignore() {
let mut s = Osc7Sniffer::default();
assert_eq!(s.feed(b"\x1b]0;mon titre\x07"), None);
assert!(s.take_notifs().is_empty());
}
#[test]
fn sniffer_osc9_notification() {
let mut s = Osc7Sniffer::default();
assert_eq!(s.feed(b"\x1b]9;Build termine\x07"), None); let n = s.take_notifs();
assert_eq!(n.len(), 1);
assert_eq!(n[0].title, None);
assert_eq!(n[0].body, "Build termine");
}
#[test]
fn sniffer_osc777_notification() {
let mut s = Osc7Sniffer::default();
s.feed(b"\x1b]777;notify;Titre;Corps du message\x07");
let n = s.take_notifs();
assert_eq!(n.len(), 1);
assert_eq!(n[0].title.as_deref(), Some("Titre"));
assert_eq!(n[0].body, "Corps du message");
}
#[test]
fn sniffer_notif_et_cwd_coexistent() {
let mut s = Osc7Sniffer::default();
let cwd = s.feed(b"\x1b]9;msg\x07\x1b]7;file:///C:/a\x07");
assert_eq!(cwd.as_deref(), Some("C:\\a"));
assert_eq!(s.take_notifs().len(), 1);
}
#[test]
fn sniffer_osc777_sans_notify_ignore() {
let mut s = Osc7Sniffer::default();
s.feed(b"\x1b]777;autre;x\x07");
assert!(s.take_notifs().is_empty());
}
#[test]
fn decode_osc7_uri_hote_vide() {
assert_eq!(
decode_osc7_uri(b"file:///C:/foo/bar").as_deref(),
Some("C:\\foo\\bar")
);
}
#[test]
fn sniffer_esc_parasite_mi_sequence_recupere_le_suivant() {
let mut s = Osc7Sniffer::default();
let out = s.feed(b"\x1b]7;file:///C:/a\x1b\x1b]7;file:///C:/b\x07");
assert_eq!(out.as_deref(), Some("C:\\b"));
}
}