Files
shell-rs/src/terminal/engine.rs
T
Xuz ef83812f45 表结构发布前重构,「会话」在代码和数据库里统一改名为 Host
- 数据库升到第 13 版:主机的登录、连接方式、代理和备注合进 hosts 一行,只有一对多的跳板、书签和端口转发单独成表;全部表改为 STRICT,并用 CHECK 写明约束;用凭据的主机不再冗余存用户名,凭据外键不再级联,在用的凭据删不掉
- 表名 sessions → hosts、session_jumps → host_jumps,session_id → host_id,地址列 host → address;schema 比对去掉排版,以后加字段可以直接 ADD COLUMN
- 删除开发期的历史迁移代码:版本 1–12 的库直接拒绝打开、不动文件
- 代码里 Session / SessionId / SessionStore 等改为 Host*,模块 session/ → host/,Host.host → Host.address,菜单命令与界面元素 id 同步改名
- 外部 CLI 的参数、JSON 字段和错误码改为 host(host_not_found),协议版本升到 2
- CLAUDE.md、AGENTS.md、README 与 Agent skill 同步更新
2026-10-01 14:04:30 +08:00

1146 lines
39 KiB
Rust

use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
use alacritty_terminal::Term;
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::{Line, Point, Side};
use alacritty_terminal::selection::{Selection, SelectionType};
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::color::COUNT;
use alacritty_terminal::term::{Config, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::host::HostOs;
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
use super::{
Latency, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, TerminalStatus,
TerminalTransportCommand, TerminalTransportEvent,
};
pub type AlacrittyTerm = Term<TerminalEventProxy>;
#[derive(Clone, Debug)]
pub struct TerminalCell {
pub character: String,
pub foreground: Color,
pub background: Color,
pub flags: Flags,
pub selected: bool,
pub search: SearchMark,
}
#[derive(Clone, Debug)]
pub struct TerminalSnapshot {
pub columns: usize,
pub rows: usize,
pub cells: Vec<TerminalCell>,
pub cursor_row: Option<usize>,
pub cursor_column: usize,
pub cursor_shape: CursorShape,
pub cursor_blinking: bool,
pub display_offset: usize,
pub colors: [Option<Rgb>; COUNT],
}
impl TerminalSnapshot {
pub fn visible_text(&self) -> String {
self.cells
.chunks(self.columns)
.map(|row| {
row.iter()
.filter(|cell| !cell.flags.contains(Flags::WIDE_CHAR_SPACER))
.map(|cell| cell.character.as_str())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n")
}
}
pub struct TerminalEngine {
factory: SharedTerminalTransportFactory,
runtime: TerminalRuntime,
lifecycle: TerminalLifecycle,
title: Option<String>,
/// The connection's latest round trip, while one is running.
latency: Option<Latency>,
/// The open find, if any. Rendering reads it through `&self` and still
/// has to advance the search engine's cache, hence the `RefCell`.
search: RefCell<Option<TerminalSearch>>,
generation: u64,
size: TerminalSize,
event_sender: mpsc::Sender<TerminalUiEvent>,
event_task: Option<gpui_kit::Task<()>>,
}
impl TerminalEngine {
pub fn new(factory: SharedTerminalTransportFactory, cx: &mut gpui_kit::Context<Self>) -> Self {
let size = TerminalSize::DEFAULT;
let generation = 1;
// A timer drains this standard channel on the UI thread. Parser
// threads therefore never wake GPUI's local executor directly, and a
// burst of terminal bytes results in a single render notification.
let (event_sender, event_receiver) = mpsc::channel();
let runtime =
TerminalRuntime::start(generation, size, factory.create(), event_sender.clone());
let mut this = Self {
factory,
runtime,
lifecycle: TerminalLifecycle::Starting,
title: None,
latency: None,
search: RefCell::new(None),
generation,
size,
event_sender,
event_task: None,
};
this.event_task = Some(Self::listen(event_receiver, cx));
this
}
fn listen(
events: mpsc::Receiver<TerminalUiEvent>,
cx: &mut gpui_kit::Context<Self>,
) -> gpui_kit::Task<()> {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
let batch: Vec<_> = events.try_iter().collect();
if !batch.is_empty()
&& this
.update(cx, |this, cx| {
for event in batch {
if let Some(event) = this.handle_event(event) {
cx.emit(event);
}
}
cx.notify();
})
.is_err()
{
break;
}
}
})
}
/// Fold one transport event into the engine's state. Anything the view
/// must hear about comes back as an event to emit.
fn handle_event(&mut self, event: TerminalUiEvent) -> Option<TerminalEvent> {
if event.generation != self.generation {
return None;
}
match event.kind {
TerminalUiEventKind::Started => self.lifecycle = TerminalLifecycle::Running,
TerminalUiEventKind::Wakeup => {
self.runtime.wakeup_pending.store(false, Ordering::Release);
}
TerminalUiEventKind::Title(title) => self.title = sanitize_title(&title),
TerminalUiEventKind::ResetTitle => self.title = None,
TerminalUiEventKind::Exited { code, signal } => {
self.lifecycle = TerminalLifecycle::Exited { code, signal };
self.latency = None;
}
TerminalUiEventKind::Failed(error) => {
self.lifecycle = TerminalLifecycle::Failed(error);
self.latency = None;
}
TerminalUiEventKind::Latency(latency) => self.latency = Some(latency),
TerminalUiEventKind::Prompt(prompt) => {
return Some(TerminalEvent::PromptRequested(prompt));
}
TerminalUiEventKind::HostOs(os) => {
return Some(TerminalEvent::HostOsDetected(os));
}
TerminalUiEventKind::ColorRequest(index, formatter) => {
let color = self.runtime.term.lock().colors()[index]
.unwrap_or_else(|| default_query_color(index));
self.runtime.write(formatter(color).into_bytes());
}
}
None
}
pub fn restart(&mut self, cx: &mut gpui_kit::Context<Self>) {
self.generation = self.generation.saturating_add(1);
self.runtime.shutdown();
self.lifecycle = TerminalLifecycle::Starting;
self.title = None;
self.latency = None;
// The new emulator starts empty; matches in the old one mean nothing.
self.search.replace(None);
self.runtime = TerminalRuntime::start(
self.generation,
self.size,
self.factory.create(),
self.event_sender.clone(),
);
cx.notify();
}
pub fn restart_with_factory(
&mut self,
factory: SharedTerminalTransportFactory,
cx: &mut gpui_kit::Context<Self>,
) {
self.factory = factory;
self.restart(cx);
}
pub fn reply_to_prompt(&self, request_id: u64, reply: ConnectionPromptReply) {
self.runtime.reply_to_prompt(request_id, reply);
}
pub fn shutdown(&mut self, cx: &mut gpui_kit::Context<Self>) {
self.generation = self.generation.saturating_add(1);
self.lifecycle = TerminalLifecycle::Closing;
self.runtime.shutdown();
cx.notify();
}
pub fn stop(&mut self, message: &str, cx: &mut gpui_kit::Context<Self>) {
self.generation = self.generation.saturating_add(1);
self.runtime.shutdown();
append_message(&self.runtime.term, message);
self.lifecycle = TerminalLifecycle::Exited {
code: 0,
signal: None,
};
cx.notify();
}
pub fn write(&self, bytes: impl Into<Vec<u8>>) {
if self.lifecycle.accepts_input() {
self.runtime.write(bytes.into());
}
}
/// Send bytes originating from the user. Unlike protocol writes such as
/// terminal query responses and focus reports, interactive input returns
/// to the live screen and retires the previous selection first.
pub fn send_user_input(&self, bytes: impl Into<Vec<u8>>) {
if !self.lifecycle.accepts_input() {
return;
}
let mut term = self.runtime.term.lock();
prepare_term_for_user_input(&mut term);
drop(term);
self.runtime.write(bytes.into());
}
pub fn prepare_for_user_input(&self) {
if !self.lifecycle.accepts_input() {
return;
}
let mut term = self.runtime.term.lock();
prepare_term_for_user_input(&mut term);
}
pub fn paste(&self, text: &str) {
self.send_user_input(encode_paste(text, self.mode()));
}
pub fn resize(&mut self, size: TerminalSize, cx: &mut gpui_kit::Context<Self>) {
if size == self.size {
return;
}
self.size = size;
*self
.runtime
.size
.lock()
.unwrap_or_else(|error| error.into_inner()) = size;
self.runtime.term.lock().resize(size);
self.runtime.resize(size);
cx.notify();
}
pub fn scroll(&mut self, lines: i32, cx: &mut gpui_kit::Context<Self>) {
self.runtime
.term
.lock()
.scroll_display(Scroll::Delta(lines));
cx.notify();
}
pub fn start_selection(
&mut self,
point: Point,
side: Side,
selection_type: SelectionType,
cx: &mut gpui_kit::Context<Self>,
) {
self.runtime.term.lock().selection = Some(Selection::new(selection_type, point, side));
cx.notify();
}
pub fn update_selection(&mut self, point: Point, side: Side, cx: &mut gpui_kit::Context<Self>) {
if let Some(selection) = self.runtime.term.lock().selection.as_mut() {
selection.update(point, side);
}
cx.notify();
}
pub fn clear_selection(&mut self, cx: &mut gpui_kit::Context<Self>) {
self.runtime.term.lock().selection = None;
cx.notify();
}
/// Open the find, or search the open one for `query`, focusing the newest
/// match at or above the bottom of the viewport.
pub fn set_search_query(&mut self, query: &str, cx: &mut gpui_kit::Context<Self>) {
let mut term = self.runtime.term.lock();
self.search
.borrow_mut()
.get_or_insert_with(TerminalSearch::new)
.set_query(query, &mut term);
drop(term);
cx.notify();
}
/// Focus the next match up (older output) or down (newer output).
pub fn step_search(&mut self, direction: SearchDirection, cx: &mut gpui_kit::Context<Self>) {
let mut term = self.runtime.term.lock();
if let Some(search) = self.search.borrow_mut().as_mut() {
search.step(direction, &mut term);
}
drop(term);
cx.notify();
}
pub fn clear_search(&mut self, cx: &mut gpui_kit::Context<Self>) {
if self.search.replace(None).is_some() {
cx.notify();
}
}
/// Where the focused match is, while the find has a query.
pub fn search_position(&self) -> Option<SearchPosition> {
self.search
.borrow()
.as_ref()
.and_then(|search| search.position())
}
/// Whether `clear_keeping_prompt` has anything to do: a full-screen program
/// owns its screen and has no scrollback to clear.
pub fn accepts_clear(&self) -> bool {
!self.mode().contains(TermMode::ALT_SCREEN)
}
/// Clear the screen and the scrollback, keeping only the line the cursor
/// is on (with everything wrapped into it), at the top. This happens in
/// the emulator alone; the shell is not told, and carries on writing from
/// the cursor, which stays beside its prompt.
pub fn clear_keeping_prompt(&mut self, cx: &mut gpui_kit::Context<Self>) {
let mut term = self.runtime.term.lock();
if !clear_keeping_cursor_line(&mut term) {
return;
}
if let Some(search) = self.search.borrow_mut().as_mut() {
search.refresh(&mut term);
}
drop(term);
cx.notify();
}
pub fn selection_text(&self) -> Option<String> {
self.runtime.term.lock().selection_to_string()
}
pub fn has_selection(&self) -> bool {
self.selection_text().is_some_and(|text| !text.is_empty())
}
pub fn mode(&self) -> TermMode {
*self.runtime.term.lock().mode()
}
pub fn set_focused(&self, focused: bool) {
let mut term = self.runtime.term.lock();
if term.is_focused == focused {
return;
}
term.is_focused = focused;
if term.mode().contains(TermMode::FOCUS_IN_OUT) {
drop(term);
self.runtime
.write(if focused { b"\x1b[I" } else { b"\x1b[O" }.to_vec());
}
}
pub fn lifecycle(&self) -> &TerminalLifecycle {
&self.lifecycle
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn latency(&self) -> Option<Latency> {
self.latency
}
pub fn status(&self) -> TerminalStatus {
TerminalStatus::new(
self.lifecycle.clone(),
self.size.columns(),
self.size.rows(),
)
}
pub fn snapshot(&self) -> TerminalSnapshot {
let term = self.runtime.term.lock();
let content = term.renderable_content();
let selection = content.selection;
let display_offset = content.display_offset as i32;
let cursor_row = content.cursor.point.line.0 + display_offset;
let cursor_point = content.cursor.point;
let cursor_shape = content.cursor.shape;
let columns = term.grid().columns();
let (matches, focused) = self
.search
.borrow_mut()
.as_mut()
.map(|search| search.visible(&term))
.unwrap_or_default();
let mut marker = MatchMarker::new(&matches, focused.as_ref());
let mut cells: Vec<_> = content
.display_iter
.map(|indexed| {
let mut character = indexed.cell.c.to_string();
if let Some(zerowidth) = indexed.cell.zerowidth() {
character.extend(zerowidth);
}
TerminalCell {
character,
foreground: indexed.cell.fg,
background: indexed.cell.bg,
flags: indexed.cell.flags,
selected: selection.is_some_and(|range| {
range.contains_cell(&indexed, cursor_point, cursor_shape)
}),
search: marker.mark(indexed.point),
}
})
.collect();
normalize_wide_character_selection(&mut cells, columns);
TerminalSnapshot {
columns,
rows: term.grid().screen_lines(),
cells,
cursor_row: (0..term.grid().screen_lines() as i32)
.contains(&cursor_row)
.then_some(cursor_row as usize),
cursor_column: content.cursor.point.column.0,
cursor_shape: content.cursor.shape,
cursor_blinking: term.cursor_style().blinking,
display_offset: content.display_offset,
colors: std::array::from_fn(|index| content.colors[index]),
}
}
}
/// Keep the leading cell and spacer of a full-width glyph visually atomic,
/// for the selection and for find highlights alike.
/// Alacritty stores CJK glyphs in one `WIDE_CHAR` cell followed by a
/// `WIDE_CHAR_SPACER`; painting only one half produces a one-column highlight
/// that cannot line up with the two-column glyph.
fn normalize_wide_character_selection(cells: &mut [TerminalCell], columns: usize) {
if columns == 0 {
return;
}
for row in cells.chunks_mut(columns) {
for column in 0..row.len().saturating_sub(1) {
if row[column].flags.contains(Flags::WIDE_CHAR)
&& row[column + 1].flags.contains(Flags::WIDE_CHAR_SPACER)
{
let selected = row[column].selected || row[column + 1].selected;
row[column].selected = selected;
row[column + 1].selected = selected;
let search = row[column].search.max(row[column + 1].search);
row[column].search = search;
row[column + 1].search = search;
}
}
}
}
impl gpui_kit::EventEmitter<TerminalEvent> for TerminalEngine {}
impl Drop for TerminalEngine {
fn drop(&mut self) {
self.runtime.shutdown();
}
}
#[derive(Clone, Debug)]
pub enum TerminalEvent {
PromptRequested(ConnectionPrompt),
HostOsDetected(HostOs),
}
struct TerminalRuntime {
term: Arc<FairMutex<AlacrittyTerm>>,
commands: mpsc::Sender<TerminalTransportCommand>,
size: Arc<Mutex<TerminalSize>>,
wakeup_pending: Arc<AtomicBool>,
}
impl TerminalRuntime {
fn start(
generation: u64,
size: TerminalSize,
transport: Box<dyn super::TerminalTransport>,
ui_events: mpsc::Sender<TerminalUiEvent>,
) -> Self {
let (commands, command_receiver) = mpsc::channel();
let (transport_events, transport_receiver) = async_channel::unbounded();
let shared_size = Arc::new(Mutex::new(size));
let wakeup_pending = Arc::new(AtomicBool::new(false));
let proxy = TerminalEventProxy {
generation,
commands: commands.clone(),
ui_events: ui_events.clone(),
size: shared_size.clone(),
wakeup_pending: wakeup_pending.clone(),
};
let parser_proxy = proxy.clone();
let config = terminal_config();
let term = Arc::new(FairMutex::new(AlacrittyTerm::new(config, &size, proxy)));
let parser_term = term.clone();
thread::Builder::new()
.name("shellrs-terminal-parser".into())
.spawn(move || {
let mut processor = Processor::new();
while let Ok(event) = transport_receiver.recv_blocking() {
match event {
TerminalTransportEvent::Started => {
parser_proxy.send(TerminalUiEventKind::Started);
}
TerminalTransportEvent::Output(bytes) => {
processor.advance(&mut *parser_term.lock(), &bytes);
parser_proxy.wakeup();
}
TerminalTransportEvent::HostOsDetected(os) => {
parser_proxy.send(TerminalUiEventKind::HostOs(os));
}
TerminalTransportEvent::Latency(latency) => {
parser_proxy.send(TerminalUiEventKind::Latency(latency));
}
TerminalTransportEvent::Prompt(prompt) => {
parser_proxy.send(TerminalUiEventKind::Prompt(prompt));
}
TerminalTransportEvent::Exited { code, signal } => {
let description = signal
.as_deref()
.map(|signal| format!("进程已退出:{signal},退出码 {code}"))
.unwrap_or_else(|| format!("进程已退出,退出码 {code}"));
append_message_with_processor(
&parser_term,
&mut processor,
&description,
);
parser_proxy.send(TerminalUiEventKind::Exited { code, signal });
parser_proxy.wakeup();
break;
}
TerminalTransportEvent::Failed(error) => {
append_message_with_processor(
&parser_term,
&mut processor,
&format!("终端错误:{error}"),
);
parser_proxy.send(TerminalUiEventKind::Failed(error));
parser_proxy.wakeup();
}
}
}
})
.expect("terminal parser thread");
thread::Builder::new()
.name("shellrs-terminal-transport".into())
.spawn(move || {
if let Err(error) = transport.run(size, command_receiver, transport_events.clone())
{
let _ = transport_events
.send_blocking(TerminalTransportEvent::Failed(error.to_string()));
}
})
.expect("terminal transport thread");
Self {
term,
commands,
size: shared_size,
wakeup_pending,
}
}
fn write(&self, bytes: Vec<u8>) {
let _ = self.commands.send(TerminalTransportCommand::Write(bytes));
}
fn resize(&self, size: TerminalSize) {
let _ = self.commands.send(TerminalTransportCommand::Resize(size));
}
fn shutdown(&self) {
let _ = self.commands.send(TerminalTransportCommand::Shutdown);
}
fn reply_to_prompt(&self, request_id: u64, reply: ConnectionPromptReply) {
let _ = self
.commands
.send(TerminalTransportCommand::PromptReply { request_id, reply });
}
}
fn terminal_config() -> Config {
Config {
scrolling_history: 10_000,
default_cursor_style: CursorStyle {
shape: CursorShape::Block,
blinking: true,
},
osc52: alacritty_terminal::term::Osc52::Disabled,
kitty_keyboard: false,
..Config::default()
}
}
impl Drop for TerminalRuntime {
fn drop(&mut self) {
self.shutdown();
}
}
#[derive(Clone)]
pub struct TerminalEventProxy {
generation: u64,
commands: mpsc::Sender<TerminalTransportCommand>,
ui_events: mpsc::Sender<TerminalUiEvent>,
size: Arc<Mutex<TerminalSize>>,
wakeup_pending: Arc<AtomicBool>,
}
impl EventListener for TerminalEventProxy {
fn send_event(&self, event: Event) {
match event {
Event::Title(title) => self.send(TerminalUiEventKind::Title(title)),
Event::ResetTitle => self.send(TerminalUiEventKind::ResetTitle),
Event::ColorRequest(index, formatter) => {
self.send(TerminalUiEventKind::ColorRequest(index, formatter));
}
Event::PtyWrite(text) => {
let _ = self
.commands
.send(TerminalTransportCommand::Write(text.into_bytes()));
}
Event::TextAreaSizeRequest(formatter) => {
let size = *self.size.lock().unwrap_or_else(|error| error.into_inner());
let response = formatter(WindowSize {
num_lines: size.rows().min(u16::MAX as usize) as u16,
num_cols: size.columns().min(u16::MAX as usize) as u16,
cell_width: size.cell_width(),
cell_height: size.cell_height(),
});
let _ = self
.commands
.send(TerminalTransportCommand::Write(response.into_bytes()));
}
Event::Wakeup
| Event::MouseCursorDirty
| Event::CursorBlinkingChange
| Event::Bell
| Event::Exit
| Event::ChildExit(_) => self.wakeup(),
Event::ClipboardStore(_, _) | Event::ClipboardLoad(_, _) => {}
}
}
}
impl TerminalEventProxy {
/// Queue an event for the UI thread, stamped with this run's generation.
fn send(&self, kind: TerminalUiEventKind) {
let _ = self.ui_events.send(TerminalUiEvent {
generation: self.generation,
kind,
});
}
fn wakeup(&self) {
if !self.wakeup_pending.swap(true, Ordering::AcqRel) {
self.send(TerminalUiEventKind::Wakeup);
}
}
}
struct TerminalUiEvent {
generation: u64,
kind: TerminalUiEventKind,
}
enum TerminalUiEventKind {
Started,
Wakeup,
Title(String),
ResetTitle,
Exited { code: u32, signal: Option<String> },
Failed(String),
Prompt(ConnectionPrompt),
HostOs(HostOs),
Latency(Latency),
ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
}
fn append_message(term: &Arc<FairMutex<AlacrittyTerm>>, message: &str) {
let mut processor = Processor::new();
append_message_with_processor(term, &mut processor, message);
}
fn append_message_with_processor(
term: &Arc<FairMutex<AlacrittyTerm>>,
processor: &mut Processor,
message: &str,
) {
let line = format!("\r\n\x1b[2m[{message}]\x1b[0m\r\n");
processor.advance(&mut *term.lock(), line.as_bytes());
}
fn sanitize_title(title: &str) -> Option<String> {
let title: String = title
.chars()
.filter(|character| !character.is_control())
.take(80)
.collect();
(!title.trim().is_empty()).then_some(title)
}
fn default_query_color(index: usize) -> Rgb {
if index == alacritty_terminal::vte::ansi::NamedColor::Background as usize {
Rgb {
r: 30,
g: 30,
b: 30,
}
} else {
Rgb {
r: 224,
g: 224,
b: 224,
}
}
}
fn encode_paste(text: &str, mode: TermMode) -> Vec<u8> {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
if mode.contains(TermMode::BRACKETED_PASTE) {
format!("\x1b[200~{}\x1b[201~", normalized.replace('\x1b', "")).into_bytes()
} else {
normalized.into_bytes()
}
}
/// See `TerminalEngine::clear_keeping_prompt`. Returns whether anything was
/// cleared.
fn clear_keeping_cursor_line(term: &mut AlacrittyTerm) -> bool {
if term.mode().contains(TermMode::ALT_SCREEN) {
return false;
}
term.scroll_display(Scroll::Bottom);
term.selection = None;
let cursor = term.grid().cursor.point;
// The prompt may have wrapped onto the cursor's row; keep it whole. A part
// already pushed into the scrollback goes with the scrollback.
let start = term.line_search_left(cursor).line.max(Line(0));
let lines = start.0 as usize;
if lines > 0 {
let screen = Line(0)..Line(term.screen_lines() as i32);
let grid = term.grid_mut();
grid.scroll_up(&screen, lines);
grid.cursor.point.line -= lines;
}
term.grid_mut().clear_history();
true
}
fn prepare_term_for_user_input(term: &mut AlacrittyTerm) {
term.scroll_display(Scroll::Bottom);
term.selection = None;
}
#[cfg(test)]
mod tests {
use super::*;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::vte::ansi::NamedColor;
fn test_term(columns: usize, rows: usize) -> AlacrittyTerm {
let size = TerminalSize::new(columns, rows, 8, 16);
let (commands, _command_receiver) = mpsc::channel();
let (ui_events, _ui_receiver) = mpsc::channel();
let config = terminal_config();
AlacrittyTerm::new(
config,
&size,
TerminalEventProxy {
generation: 1,
commands,
ui_events,
size: Arc::new(Mutex::new(size)),
wakeup_pending: Arc::new(AtomicBool::new(false)),
},
)
}
fn feed(term: &mut AlacrittyTerm, bytes: &[u8]) {
Processor::<alacritty_terminal::vte::ansi::StdSyncHandler>::new().advance(term, bytes);
}
fn line_text(term: &AlacrittyTerm, line: i32) -> String {
term.grid()[Line(line)]
.into_iter()
.map(|cell| cell.c)
.collect::<String>()
.trim_end()
.to_owned()
}
fn position(search: &TerminalSearch) -> (Option<usize>, usize) {
let position = search.position().unwrap();
(position.current(), position.total())
}
#[test]
fn find_reaches_into_the_scrollback_and_wraps() {
let mut term = test_term(10, 3);
feed(&mut term, b"err 1\r\nok\r\nerr 2\r\nok\r\nok\r\nerr 3");
assert!(term.history_size() > 0);
let mut search = TerminalSearch::new();
search.set_query("err", &mut term);
assert_eq!(position(&search), (Some(3), 3));
search.step(SearchDirection::Up, &mut term);
assert_eq!(position(&search), (Some(2), 3));
search.step(SearchDirection::Up, &mut term);
assert_eq!(position(&search), (Some(1), 3));
// The oldest match is in the scrollback; the view scrolls up to it.
assert!(term.grid().display_offset() > 0);
let (_, focused) = search.visible(&term);
assert_eq!(line_text(&term, focused.unwrap().start().line.0), "err 1");
search.step(SearchDirection::Up, &mut term);
assert_eq!(position(&search), (Some(3), 3));
search.step(SearchDirection::Down, &mut term);
assert_eq!(position(&search), (Some(1), 3));
}
#[test]
fn find_starts_at_the_first_match_on_screen() {
let mut term = test_term(10, 3);
feed(&mut term, b"err 1\r\nok\r\nok\r\nerr 2\r\nerr 3");
let mut search = TerminalSearch::new();
search.set_query("err", &mut term);
assert_eq!(position(&search), (Some(2), 3));
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn find_is_literal_and_smart_case() {
let mut term = test_term(20, 2);
feed(&mut term, b"Error: err");
let mut search = TerminalSearch::new();
search.set_query("err", &mut term);
assert_eq!(position(&search).1, 2);
search.set_query("Err", &mut term);
assert_eq!(position(&search).1, 1);
search.set_query("e.r", &mut term);
assert_eq!(position(&search).1, 0);
assert_eq!(search.position().unwrap().label(), "无结果");
search.set_query("", &mut term);
assert!(search.position().is_none());
}
#[test]
fn focused_match_follows_output_into_the_scrollback() {
let mut term = test_term(10, 3);
feed(&mut term, b"one\r\nmark\r\n");
let mut search = TerminalSearch::new();
search.set_query("mark", &mut term);
feed(&mut term, b"two\r\nthree\r\nfour\r\n");
let (_, focused) = search.visible(&term);
let focused = focused.expect("the match is still in the buffer");
assert!(focused.start().line.0 < 0);
assert_eq!(line_text(&term, focused.start().line.0), "mark");
assert_eq!(position(&search), (Some(1), 1));
}
#[test]
fn find_highlights_both_cells_of_a_wide_match() {
let mut term = test_term(12, 2);
feed(&mut term, "a 日志".as_bytes());
let mut search = TerminalSearch::new();
search.set_query("日志", &mut term);
let (matches, focused) = search.visible(&term);
let mut marker = MatchMarker::new(&matches, focused.as_ref());
let marks: Vec<_> = (0..6)
.map(|column| marker.mark(Point::new(Line(0), Column(column))))
.collect();
assert_eq!(marks[1], SearchMark::None);
assert_eq!(marks[2], SearchMark::Focused);
assert_eq!(marks[4], SearchMark::Focused);
}
#[test]
fn clearing_keeps_only_the_prompt_line() {
let mut term = test_term(30, 4);
feed(&mut term, b"a\r\nb\r\nc\r\nd\r\ne\r\nroot@localhost:~# ls");
assert!(term.history_size() > 0);
assert!(clear_keeping_cursor_line(&mut term));
assert_eq!(term.history_size(), 0);
assert_eq!(line_text(&term, 0), "root@localhost:~# ls");
assert!((1..4).all(|line| line_text(&term, line).is_empty()));
assert_eq!(term.grid().cursor.point, Point::new(Line(0), Column(20)));
// The shell keeps writing beside its prompt.
feed(&mut term, b"\r\nfile");
assert_eq!(line_text(&term, 1), "file");
}
#[test]
fn clearing_keeps_a_wrapped_prompt_whole() {
let mut term = test_term(10, 4);
feed(&mut term, b"x\r\ny\r\n0123456789abc");
assert!(clear_keeping_cursor_line(&mut term));
assert_eq!(line_text(&term, 0), "0123456789");
assert_eq!(line_text(&term, 1), "abc");
assert_eq!(term.grid().cursor.point, Point::new(Line(1), Column(3)));
}
#[test]
fn clearing_leaves_a_full_screen_program_alone() {
let mut term = test_term(10, 3);
feed(&mut term, b"\x1b[?1049hvim");
assert!(!clear_keeping_cursor_line(&mut term));
assert_eq!(line_text(&term, 0), "vim");
}
#[test]
fn sanitizes_terminal_titles() {
assert_eq!(sanitize_title("hello\nworld"), Some("helloworld".into()));
assert_eq!(sanitize_title("\n\t"), None);
}
#[test]
fn terminal_size_implements_dimensions() {
let size = TerminalSize::new(120, 40, 9, 18);
assert_eq!(size.columns(), 120);
assert_eq!(size.screen_lines(), 40);
}
#[test]
fn default_cursor_is_a_blinking_block() {
let term = test_term(8, 2);
assert_eq!(term.cursor_style().shape, CursorShape::Block);
assert!(term.cursor_style().blinking);
}
#[test]
fn parses_ansi_color_wide_characters_and_alternate_screen() {
let mut term = test_term(12, 3);
feed(&mut term, b"\x1b[31mA\x1b[0m");
assert_eq!(
term.grid()[Line(0)][Column(0)].fg,
Color::Named(NamedColor::Red)
);
feed(&mut term, "你".as_bytes());
assert!(
term.grid()[Line(0)][Column(1)]
.flags
.contains(Flags::WIDE_CHAR)
);
assert!(
term.grid()[Line(0)][Column(2)]
.flags
.contains(Flags::WIDE_CHAR_SPACER)
);
feed(&mut term, b"\x1b[?1049hB");
assert!(term.mode().contains(TermMode::ALT_SCREEN));
assert!((0..12).any(|column| term.grid()[Line(0)][Column(column)].c == 'B'));
feed(&mut term, b"\x1b[?1049l");
assert!(!term.mode().contains(TermMode::ALT_SCREEN));
assert_eq!(term.grid()[Line(0)][Column(0)].c, 'A');
}
#[test]
fn caps_scrollback_at_ten_thousand_lines() {
let mut term = test_term(4, 2);
feed(&mut term, "x\r\n".repeat(10_050).as_bytes());
assert_eq!(
term.grid().total_lines() - term.grid().screen_lines(),
10_000
);
}
#[test]
fn returns_selected_text() {
let mut term = test_term(8, 2);
feed(&mut term, b"hello");
term.selection = Some(Selection::new(
SelectionType::Simple,
Point::new(Line(0), Column(1)),
Side::Left,
));
term.selection
.as_mut()
.unwrap()
.update(Point::new(Line(0), Column(3)), Side::Right);
assert_eq!(term.selection_to_string().as_deref(), Some("ell"));
}
#[test]
fn supports_reverse_word_line_and_empty_selections() {
let mut term = test_term(20, 2);
feed(&mut term, b"alpha.txt beta");
term.selection = Some(Selection::new(
SelectionType::Simple,
Point::new(Line(0), Column(4)),
Side::Right,
));
term.selection
.as_mut()
.unwrap()
.update(Point::new(Line(0), Column(1)), Side::Left);
assert_eq!(term.selection_to_string().as_deref(), Some("lpha"));
term.selection = Some(Selection::new(
SelectionType::Semantic,
Point::new(Line(0), Column(2)),
Side::Left,
));
assert_eq!(term.selection_to_string().as_deref(), Some("alpha.txt"));
term.selection = Some(Selection::new(
SelectionType::Lines,
Point::new(Line(0), Column(7)),
Side::Left,
));
assert_eq!(
term.selection_to_string().as_deref(),
Some("alpha.txt beta\n")
);
term.selection = Some(Selection::new(
SelectionType::Simple,
Point::new(Line(0), Column(0)),
Side::Left,
));
assert_eq!(term.selection_to_string(), None);
}
#[test]
fn selected_text_preserves_cjk_and_joins_soft_wrapped_lines() {
let mut term = test_term(4, 2);
feed(&mut term, "ab你cd".as_bytes());
term.selection = Some(Selection::new(
SelectionType::Simple,
Point::new(Line(0), Column(1)),
Side::Left,
));
term.selection
.as_mut()
.unwrap()
.update(Point::new(Line(1), Column(1)), Side::Right);
assert_eq!(term.selection_to_string().as_deref(), Some("b你cd"));
}
#[test]
fn wide_character_selection_highlights_both_grid_cells() {
let cell = |flags, selected| TerminalCell {
character: " ".into(),
foreground: Color::Named(NamedColor::Foreground),
background: Color::Named(NamedColor::Background),
flags,
selected,
search: if selected {
SearchMark::Match
} else {
SearchMark::None
},
};
let mut cells = vec![
cell(Flags::WIDE_CHAR, true),
cell(Flags::WIDE_CHAR_SPACER, false),
cell(Flags::empty(), false),
cell(Flags::WIDE_CHAR, false),
cell(Flags::WIDE_CHAR_SPACER, true),
cell(Flags::empty(), false),
];
normalize_wide_character_selection(&mut cells, 3);
assert!(cells[0].selected);
assert!(cells[1].selected);
assert!(!cells[2].selected);
assert!(cells[3].selected);
assert!(cells[4].selected);
assert!(!cells[5].selected);
assert_eq!(cells[1].search, SearchMark::Match);
assert_eq!(cells[3].search, SearchMark::Match);
assert_eq!(cells[2].search, SearchMark::None);
}
#[test]
fn user_input_returns_to_bottom_and_clears_selection() {
let mut term = test_term(8, 2);
feed(&mut term, "one\r\ntwo\r\nthree\r\n".as_bytes());
term.scroll_display(Scroll::Delta(2));
term.selection = Some(Selection::new(
SelectionType::Semantic,
Point::new(Line(-1), Column(1)),
Side::Left,
));
assert!(term.grid().display_offset() > 0);
assert!(term.selection.is_some());
prepare_term_for_user_input(&mut term);
assert_eq!(term.grid().display_offset(), 0);
assert!(term.selection.is_none());
}
#[test]
fn bracketed_paste_filters_escape_and_normalizes_newlines() {
assert_eq!(
encode_paste("a\r\nb\x1bc", TermMode::BRACKETED_PASTE),
b"\x1b[200~a\nbc\x1b[201~"
);
assert_eq!(encode_paste("a\rb", TermMode::default()), b"a\nb");
}
}