feat(gpui): render real terminals from the daemon's byte stream
The client creates a pane over HTTP, attaches to the terminal WebSocket, and re-parses the byte stream with the same alacritty_terminal release the daemon uses. Rendering splits each row into ASCII runs shaped with a per-cell force width and wide CJK glyphs shaped individually at their column, because force_width snaps every glyph to one cell and would overlap Chinese text. Verified against the live daemon: the local PowerShell prompt and vip-hk's full MOTD parse correctly (examples/terminal_smoke.rs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5299ff513f
commit
582c4f4958
9 files changed
+943
-27
No files matched your search
Generated
+36
-4
@@ -676,7 +676,7 @@ dependencies = [
|
||||
"sha1 0.10.7",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -1956,7 +1956,7 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7953,7 +7953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"encoding_rs_io",
|
||||
"granit-parser",
|
||||
"nohash-hasher",
|
||||
@@ -9102,7 +9102,19 @@ dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9425,6 +9437,23 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -9715,8 +9744,10 @@ dependencies = [
|
||||
name = "vps-switch-gpui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"futures-util",
|
||||
"gpui-base",
|
||||
"gpui-component",
|
||||
"gpui-kit",
|
||||
@@ -9724,6 +9755,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -18,8 +18,13 @@ serde_json = "1"
|
||||
async-channel = "2"
|
||||
# 客户端网络层跑在专用线程的 current_thread 运行时上(与 shell-rs 同款),
|
||||
# 不碰 GPUI 自己的 executor。
|
||||
tokio = { version = "1", features = ["rt", "macros", "time"] }
|
||||
tokio = { version = "1", features = ["rt", "macros", "time", "net"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "system-proxy"] }
|
||||
# 终端:与守护进程同版本的 VTE 解析器;客户端拿 WS 字节流自己再解析一遍
|
||||
# (与浏览器里 xterm.js 的地位相同)。
|
||||
alacritty_terminal = "0.26"
|
||||
tokio-tungstenite = "0.28"
|
||||
futures-util = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
# 无头 UI 测试。用 gpui-base / gpui-component 的 test_support(只用公共
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 冒烟:连真实守护进程,为一台主机创建终端 pane,把收到的字节流解析成
|
||||
//! 网格并打印可见文本。用法:`cargo run -p vps-switch-gpui --example terminal_smoke -- <host>`
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use vps_switch_gpui::terminal::{Session, SessionEvent};
|
||||
|
||||
fn main() {
|
||||
let host = std::env::args().nth(1).unwrap_or_else(|| "local".into());
|
||||
let session = Session::spawn(host.clone(), 120, 30);
|
||||
println!("session spawned for {host}");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(6);
|
||||
let mut closed = None;
|
||||
while std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
while let Some(event) = session.try_recv() {
|
||||
match event {
|
||||
SessionEvent::Updated => {}
|
||||
SessionEvent::Closed(reason) => {
|
||||
closed = Some(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
if closed.is_some() {
|
||||
break;
|
||||
}
|
||||
if let Some(snapshot) = session.snapshot() {
|
||||
if snapshot
|
||||
.cells
|
||||
.iter()
|
||||
.any(|cell| cell.c != ' ' && !cell.c.is_control())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match closed {
|
||||
Some(reason) => println!("CLOSED: {reason}"),
|
||||
None => {}
|
||||
}
|
||||
let Some(snapshot) = session.snapshot() else {
|
||||
println!("NO SNAPSHOT");
|
||||
return;
|
||||
};
|
||||
println!(
|
||||
"snapshot {}x{}, cursor={:?}, offset={}, history={}",
|
||||
snapshot.cols, snapshot.rows, snapshot.cursor, snapshot.display_offset, snapshot.history_size
|
||||
);
|
||||
for row in 0..snapshot.rows {
|
||||
let line: String = snapshot.cells[row * snapshot.cols..(row + 1) * snapshot.cols]
|
||||
.iter()
|
||||
.map(|cell| if cell.c.is_control() { ' ' } else { cell.c })
|
||||
.collect();
|
||||
let trimmed = line.trim_end();
|
||||
if !trimmed.is_empty() {
|
||||
println!("{row:02} | {trimmed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,12 @@ pub struct Api {
|
||||
}
|
||||
|
||||
/// 守护进程地址。本机优先定位下固定回环;端口留环境变量覆盖以便隔离测试。
|
||||
fn base_url() -> String {
|
||||
let port = std::env::var("VPS_SWITCH_PORT").unwrap_or_else(|_| "18800".into());
|
||||
format!("http://127.0.0.1:{port}")
|
||||
pub fn base_url() -> String {
|
||||
format!("http://127.0.0.1:{}", port())
|
||||
}
|
||||
|
||||
pub fn port() -> String {
|
||||
std::env::var("VPS_SWITCH_PORT").unwrap_or_else(|_| "18800".into())
|
||||
}
|
||||
|
||||
impl Api {
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod hosts;
|
||||
pub mod terminal;
|
||||
pub mod terminal_panel;
|
||||
pub mod workspace;
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 终端:会话(WS + alacritty_terminal 解析)与 GPUI 渲染。
|
||||
|
||||
pub mod session;
|
||||
pub mod view;
|
||||
|
||||
pub use session::{Session, SessionCommand, SessionEvent, Snapshot};
|
||||
pub use view::TerminalView;
|
||||
@@ -0,0 +1,286 @@
|
||||
//! 终端会话:后台线程里创建 pane、连 WS、用 `alacritty_terminal` 解析字节流,
|
||||
//! 产出一份可绘制的快照。
|
||||
//!
|
||||
//! 线程模型与 api.rs 相同:专用线程跑 current_thread tokio 运行时;UI 线程通过
|
||||
//! 事件通道得知「快照有更新」,再从 `Arc<Mutex<Option<Snapshot>>>` 取数据——
|
||||
//! 解析线程永远不碰 GPUI 的 executor。
|
||||
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
use alacritty_terminal::event::VoidListener;
|
||||
use alacritty_terminal::grid::{Dimensions, Scroll};
|
||||
use alacritty_terminal::index::Point;
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use alacritty_terminal::term::{Config as TermConfig, Term};
|
||||
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GridSize {
|
||||
cols: usize,
|
||||
lines: usize,
|
||||
}
|
||||
|
||||
impl Dimensions for GridSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.lines
|
||||
}
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.lines
|
||||
}
|
||||
fn columns(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
}
|
||||
|
||||
/// 单格快照。`extra.zerowidth` 这类组合字符暂不展开,宽字符占位格保留。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CellSnapshot {
|
||||
pub c: char,
|
||||
pub fg: Color,
|
||||
pub bg: Color,
|
||||
pub flags: Flags,
|
||||
}
|
||||
|
||||
impl Default for CellSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
c: ' ',
|
||||
fg: Color::Named(NamedColor::Foreground),
|
||||
bg: Color::Named(NamedColor::Background),
|
||||
flags: Flags::empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一帧可绘制状态。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
pub cells: Vec<CellSnapshot>,
|
||||
/// 光标 (行, 列),行是屏幕坐标。
|
||||
pub cursor: Option<(usize, usize)>,
|
||||
pub cursor_shape: CursorShape,
|
||||
pub display_offset: usize,
|
||||
pub history_size: usize,
|
||||
pub cursor_blinking: bool,
|
||||
}
|
||||
|
||||
/// 网络/解析线程交给 UI 的事件。
|
||||
pub enum SessionEvent {
|
||||
/// 快照有更新。
|
||||
Updated,
|
||||
/// 会话结束;附原因。
|
||||
Closed(String),
|
||||
}
|
||||
|
||||
/// UI 交给会话的命令。
|
||||
pub enum SessionCommand {
|
||||
Input(Vec<u8>),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
/// 向上(负)或向下(正)滚动历史。
|
||||
Scroll(i32),
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
commands: async_channel::Sender<SessionCommand>,
|
||||
events: mpsc::Receiver<SessionEvent>,
|
||||
snapshot: Arc<Mutex<Option<Snapshot>>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn spawn(host_id: String, cols: u16, rows: u16) -> Self {
|
||||
let (command_tx, command_rx) = async_channel::unbounded::<SessionCommand>();
|
||||
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
|
||||
let snapshot = Arc::new(Mutex::new(None));
|
||||
let worker_snapshot = snapshot.clone();
|
||||
thread::Builder::new()
|
||||
.name("vps-switch-terminal".into())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
runtime.block_on(async move {
|
||||
let reason = match run(
|
||||
host_id,
|
||||
cols,
|
||||
rows,
|
||||
command_rx,
|
||||
&event_tx,
|
||||
&worker_snapshot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => "连接已断开".to_owned(),
|
||||
Err(error) => format!("{error:#}"),
|
||||
};
|
||||
let _ = event_tx.send(SessionEvent::Closed(reason));
|
||||
});
|
||||
})
|
||||
.expect("terminal thread");
|
||||
Self {
|
||||
commands: command_tx,
|
||||
events: event_rx,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, command: SessionCommand) {
|
||||
let _ = self.commands.send_blocking(command);
|
||||
}
|
||||
|
||||
pub fn try_recv(&self) -> Option<SessionEvent> {
|
||||
self.events.try_recv().ok()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Option<Snapshot> {
|
||||
self.snapshot.lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
host_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
commands: async_channel::Receiver<SessionCommand>,
|
||||
events: &mpsc::Sender<SessionEvent>,
|
||||
snapshot: &Arc<Mutex<Option<Snapshot>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/api/terminals", crate::api::base_url()))
|
||||
.json(&serde_json::json!({"hostId": host_id, "cols": cols, "rows": rows}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
if !status.is_success() {
|
||||
let reason = body
|
||||
.get("error")
|
||||
.and_then(|error| error.as_str())
|
||||
.unwrap_or("创建终端失败");
|
||||
anyhow::bail!("{reason}");
|
||||
}
|
||||
let pane_id = body
|
||||
.get("pane")
|
||||
.and_then(|pane| pane.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("守护进程未返回 pane id"))?
|
||||
.to_owned();
|
||||
|
||||
let (socket, _) = tokio_tungstenite::connect_async(format!(
|
||||
"ws://127.0.0.1:{}/api/ws/terminal/{pane_id}",
|
||||
crate::api::port()
|
||||
))
|
||||
.await?;
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
let mut size = GridSize {
|
||||
cols: cols as usize,
|
||||
lines: rows as usize,
|
||||
};
|
||||
let mut term = Term::new(TermConfig::default(), &size, VoidListener);
|
||||
let mut processor: Processor = Processor::new();
|
||||
store_snapshot(snapshot, &term, size);
|
||||
let _ = events.send(SessionEvent::Updated);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = ws_rx.next() => {
|
||||
match message {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
// 0x02 是整份回放:重建现场(与浏览器端收到回放先 \x1bc 同理),
|
||||
// 0x00 是实时输出;其余(无前缀)按原样喂。
|
||||
let payload: &[u8] = match data.first() {
|
||||
Some(0x02) => {
|
||||
term = Term::new(TermConfig::default(), &size, VoidListener);
|
||||
&data[1..]
|
||||
}
|
||||
Some(0x00) => &data[1..],
|
||||
_ => &data[..],
|
||||
};
|
||||
if !payload.is_empty() {
|
||||
processor.advance(&mut term, payload);
|
||||
}
|
||||
store_snapshot(snapshot, &term, size);
|
||||
let _ = events.send(SessionEvent::Updated);
|
||||
}
|
||||
Some(Ok(Message::Text(_))) | Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Ok(SessionCommand::Input(bytes)) => {
|
||||
let mut frame = Vec::with_capacity(1 + bytes.len());
|
||||
frame.push(0x01);
|
||||
frame.extend_from_slice(&bytes);
|
||||
ws_tx.send(Message::Binary(frame.into())).await?;
|
||||
}
|
||||
Ok(SessionCommand::Resize { cols, rows }) => {
|
||||
size = GridSize { cols: cols as usize, lines: rows as usize };
|
||||
term.resize(size);
|
||||
ws_tx
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type": "resize", "cols": cols, "rows": rows})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await?;
|
||||
store_snapshot(snapshot, &term, size);
|
||||
let _ = events.send(SessionEvent::Updated);
|
||||
}
|
||||
Ok(SessionCommand::Scroll(delta)) => {
|
||||
term.scroll_display(Scroll::Delta(delta));
|
||||
store_snapshot(snapshot, &term, size);
|
||||
let _ = events.send(SessionEvent::Updated);
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_snapshot(snapshot: &Arc<Mutex<Option<Snapshot>>>, term: &Term<VoidListener>, size: GridSize) {
|
||||
let content = term.renderable_content();
|
||||
let mut cells = vec![CellSnapshot::default(); size.cols * size.lines];
|
||||
for indexed in content.display_iter {
|
||||
let Point { line, column } = indexed.point;
|
||||
let row = line.0;
|
||||
if row < 0 || row as usize >= size.lines || column.0 >= size.cols {
|
||||
continue;
|
||||
}
|
||||
let cell = indexed.cell;
|
||||
cells[row as usize * size.cols + column.0] = CellSnapshot {
|
||||
c: cell.c,
|
||||
fg: cell.fg,
|
||||
bg: cell.bg,
|
||||
flags: cell.flags,
|
||||
};
|
||||
}
|
||||
let cursor_point = content.cursor.point;
|
||||
let cursor_row = cursor_point.line.0;
|
||||
let cursor = if cursor_row >= 0 && (cursor_row as usize) < size.lines {
|
||||
Some((cursor_row as usize, cursor_point.column.0))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = Snapshot {
|
||||
cols: size.cols,
|
||||
rows: size.lines,
|
||||
cells,
|
||||
cursor,
|
||||
cursor_shape: content.cursor.shape,
|
||||
display_offset: content.display_offset,
|
||||
history_size: term.grid().history_size(),
|
||||
cursor_blinking: true,
|
||||
};
|
||||
if let Ok(mut guard) = snapshot.lock() {
|
||||
*guard = Some(next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
//! 终端视图:GPUI Element 渲染网格快照 + 键盘输入编码。
|
||||
//!
|
||||
//! 渲染策略(性能与正确性的折中,依据 `force_width` 的真实语义):
|
||||
//! `shape_line` 的 `force_width` 会把**每个字形**吸附到 `n × 单格宽度` 的
|
||||
//! 网格上。对 ASCII 等宽字符这是精确对齐;对中文等双宽字符会把后续字符
|
||||
//! 挤进上一格。因此每行按段塑形:连续 ASCII 段带 `force_width` 合并成一次
|
||||
//! 塑形,双宽字符单独塑形、画在 `列号 × cell_width` 处。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use alacritty_terminal::term::cell::Flags;
|
||||
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor};
|
||||
use gpui_kit::component::*;
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::session::{Session, SessionCommand, SessionEvent, Snapshot};
|
||||
|
||||
const FONT_SIZE: f32 = 13.0;
|
||||
const LINE_HEIGHT: f32 = 1.4;
|
||||
|
||||
pub struct TerminalView {
|
||||
session: Session,
|
||||
snapshot: Option<Snapshot>,
|
||||
focus_handle: FocusHandle,
|
||||
/// 会话结束的原因;有值时在底部显示提示条。
|
||||
status: Option<String>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
pub fn new(host_id: String, cx: &mut Context<Self>) -> Self {
|
||||
let (cols, rows) = (100, 30);
|
||||
let this = Self {
|
||||
session: Session::spawn(host_id, cols, rows),
|
||||
snapshot: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
status: None,
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
Self::poll(cx);
|
||||
this
|
||||
}
|
||||
|
||||
fn poll(cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(16))
|
||||
.await;
|
||||
let alive = this.update(cx, |this, cx| {
|
||||
let mut changed = false;
|
||||
while let Some(event) = this.session.try_recv() {
|
||||
changed = true;
|
||||
match event {
|
||||
SessionEvent::Updated => {
|
||||
this.snapshot = this.session.snapshot();
|
||||
}
|
||||
SessionEvent::Closed(reason) => {
|
||||
this.status = Some(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if alive.is_err() {
|
||||
break;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn request_size(&mut self, cols: u16, rows: u16) {
|
||||
if cols == 0 || rows == 0 || (cols == self.cols && rows == self.rows) {
|
||||
return;
|
||||
}
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
self.session.send(SessionCommand::Resize { cols, rows });
|
||||
}
|
||||
|
||||
fn input(&mut self, bytes: Vec<u8>) {
|
||||
if !bytes.is_empty() {
|
||||
self.session.send(SessionCommand::Input(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll(&mut self, lines: i32) {
|
||||
self.session.send(SessionCommand::Scroll(lines));
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for TerminalView {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let this = cx.entity();
|
||||
let focus = self.focus_handle.clone();
|
||||
let mut root = div()
|
||||
.size_full()
|
||||
.bg(terminal_background())
|
||||
.track_focus(&focus)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
|
||||
if let Some(bytes) = encode_key(&event.keystroke) {
|
||||
this.input(bytes);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, _cx| {
|
||||
let dy = event.delta.pixel_delta(px(FONT_SIZE)).y;
|
||||
if dy != px(0.) {
|
||||
let lines = (dy.as_f32() / (FONT_SIZE * LINE_HEIGHT)).round() as i32;
|
||||
if lines != 0 {
|
||||
this.scroll(lines);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.child(TerminalElement { view: this });
|
||||
if let Some(status) = &self.status {
|
||||
root = root.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_2()
|
||||
.left_1_2()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().danger)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(status.clone()),
|
||||
);
|
||||
}
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalElement {
|
||||
view: Entity<TerminalView>,
|
||||
}
|
||||
|
||||
impl IntoElement for TerminalElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalPaintState {
|
||||
segments: Vec<Segment>,
|
||||
backgrounds: Vec<PaintQuad>,
|
||||
cursor: Option<PaintQuad>,
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
}
|
||||
|
||||
struct Segment {
|
||||
origin: Point<Pixels>,
|
||||
line: ShapedLine,
|
||||
}
|
||||
|
||||
impl Element for TerminalElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = TerminalPaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let font_size = px(FONT_SIZE);
|
||||
let mono_family = cx.theme().mono_font_family.clone();
|
||||
let mono = font(mono_family);
|
||||
let measure = TextRun {
|
||||
len: 1,
|
||||
font: mono.clone(),
|
||||
color: Hsla::default(),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let cell_width = window
|
||||
.text_system()
|
||||
.shape_line("M".into(), font_size, &[measure], None)
|
||||
.width()
|
||||
.max(px(1.));
|
||||
let line_height = window.pixel_snap(font_size * LINE_HEIGHT).max(px(1.));
|
||||
let cols = ((bounds.size.width / cell_width).floor() as usize).max(1);
|
||||
let rows = ((bounds.size.height / line_height).floor() as usize).max(1);
|
||||
|
||||
let snapshot = self.view.read(cx).snapshot.clone();
|
||||
let mut state = TerminalPaintState {
|
||||
segments: Vec::new(),
|
||||
backgrounds: Vec::new(),
|
||||
cursor: None,
|
||||
cell_width,
|
||||
line_height,
|
||||
};
|
||||
let Some(snapshot) = snapshot else {
|
||||
return state;
|
||||
};
|
||||
|
||||
// 尺寸变化同步给后端 PTY。
|
||||
let clamped_cols = cols.min(u16::MAX as usize) as u16;
|
||||
let clamped_rows = rows.min(u16::MAX as usize) as u16;
|
||||
if clamped_cols != snapshot.cols as u16 || clamped_rows != snapshot.rows as u16 {
|
||||
self.view
|
||||
.update(cx, |view, _| view.request_size(clamped_cols, clamped_rows));
|
||||
}
|
||||
|
||||
let default_fg: Hsla = terminal_foreground();
|
||||
let default_bg: Hsla = terminal_background();
|
||||
let palette = ansi_palette();
|
||||
|
||||
let rows_to_draw = rows.min(snapshot.rows);
|
||||
let cols_to_draw = cols.min(snapshot.cols);
|
||||
for row in 0..rows_to_draw {
|
||||
let y = bounds.top() + line_height * row as f32;
|
||||
let mut col = 0;
|
||||
// 背景:连续的非默认背景区间合并成矩形。
|
||||
while col < cols_to_draw {
|
||||
let cell = &snapshot.cells[row * snapshot.cols + col];
|
||||
if is_default_bg(cell.bg) {
|
||||
col += 1;
|
||||
continue;
|
||||
}
|
||||
let color = resolve_color(cell.bg, default_fg, default_bg, &palette);
|
||||
let start = col;
|
||||
while col < cols_to_draw {
|
||||
let next = &snapshot.cells[row * snapshot.cols + col];
|
||||
if is_default_bg(next.bg)
|
||||
|| resolve_color(next.bg, default_fg, default_bg, &palette) != color
|
||||
{
|
||||
break;
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
let rect = Bounds {
|
||||
origin: point(bounds.left() + cell_width * start as f32, y),
|
||||
size: size(cell_width * (col - start) as f32, line_height),
|
||||
};
|
||||
state.backgrounds.push(fill(rect, color));
|
||||
}
|
||||
|
||||
// 文本:按段塑形。
|
||||
col = 0;
|
||||
while col < cols_to_draw {
|
||||
let cell = &snapshot.cells[row * snapshot.cols + col];
|
||||
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) || cell.c == ' ' {
|
||||
col += 1;
|
||||
continue;
|
||||
}
|
||||
let wide = cell.flags.contains(Flags::WIDE_CHAR);
|
||||
let start_col = col;
|
||||
if wide {
|
||||
// 双宽字符单独塑形,按列定位;不经过 force_width 网格。
|
||||
let text = cell.c.to_string();
|
||||
let run = TextRun {
|
||||
len: text.len(),
|
||||
font: styled_font(&mono, cell.flags),
|
||||
color: resolve_color(cell.fg, default_fg, default_bg, &palette),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let line = window
|
||||
.text_system()
|
||||
.shape_line(SharedString::from(text), font_size, &[run], None);
|
||||
state.segments.push(Segment {
|
||||
origin: point(bounds.left() + cell_width * start_col as f32, y),
|
||||
line,
|
||||
});
|
||||
col += 1;
|
||||
continue;
|
||||
}
|
||||
// 连续单宽字符段(同前景色)合并为一次塑形。
|
||||
let fg = resolve_color(cell.fg, default_fg, default_bg, &palette);
|
||||
let mut text = String::new();
|
||||
let mut runs: Vec<TextRun> = Vec::new();
|
||||
while col < cols_to_draw {
|
||||
let next = &snapshot.cells[row * snapshot.cols + col];
|
||||
if next.flags.contains(Flags::WIDE_CHAR_SPACER)
|
||||
|| next.flags.contains(Flags::WIDE_CHAR)
|
||||
{
|
||||
break;
|
||||
}
|
||||
let next_fg = resolve_color(next.fg, default_fg, default_bg, &palette);
|
||||
if next_fg != fg && !text.is_empty() {
|
||||
break;
|
||||
}
|
||||
let ch = next.c;
|
||||
let len = ch.len_utf8();
|
||||
text.push(ch);
|
||||
match runs.last_mut() {
|
||||
Some(run) => run.len += len,
|
||||
None => runs.push(TextRun {
|
||||
len,
|
||||
font: mono.clone(),
|
||||
color: fg,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}),
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
// 纯空白不塑形,只推进列(背景已经画过)。
|
||||
continue;
|
||||
}
|
||||
let line = window.text_system().shape_line(
|
||||
SharedString::from(text),
|
||||
font_size,
|
||||
&runs,
|
||||
Some(cell_width),
|
||||
);
|
||||
state.segments.push(Segment {
|
||||
origin: point(bounds.left() + cell_width * start_col as f32, y),
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 光标:实心块。
|
||||
if let Some((row, col)) = snapshot.cursor.filter(|(row, _)| *row < rows_to_draw) {
|
||||
let rect = Bounds {
|
||||
origin: point(
|
||||
bounds.left() + cell_width * col as f32,
|
||||
bounds.top() + line_height * row as f32,
|
||||
),
|
||||
size: size(cell_width, line_height),
|
||||
};
|
||||
let color = match snapshot.cursor_shape {
|
||||
CursorShape::Hidden => {
|
||||
state.cursor = None;
|
||||
return state;
|
||||
}
|
||||
CursorShape::Block | CursorShape::HollowBlock | CursorShape::Beam => {
|
||||
Hsla::from(rgb(0xd8dee9)).opacity(0.7)
|
||||
}
|
||||
CursorShape::Underline => Hsla::from(rgb(0xd8dee9)).opacity(0.5),
|
||||
};
|
||||
state.cursor = Some(fill(rect, color));
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
state: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let mask = ContentMask { bounds };
|
||||
window.with_content_mask(Some(mask), |window| {
|
||||
for quad in state.backgrounds.drain(..) {
|
||||
window.paint_quad(quad);
|
||||
}
|
||||
for segment in &state.segments {
|
||||
let _ = segment.line.paint(
|
||||
segment.origin,
|
||||
state.line_height,
|
||||
TextAlign::Left,
|
||||
Some(state.cell_width * 4.),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
if let Some(cursor) = state.cursor.take() {
|
||||
window.paint_quad(cursor);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端底色/前景:固定深色(后续接设置里的独立终端主题)。
|
||||
fn terminal_background() -> Hsla {
|
||||
Hsla::from(rgb(0x16181d))
|
||||
}
|
||||
|
||||
fn terminal_foreground() -> Hsla {
|
||||
Hsla::from(rgb(0xd8dee9))
|
||||
}
|
||||
|
||||
fn is_default_bg(color: Color) -> bool {
|
||||
matches!(color, Color::Named(NamedColor::Background))
|
||||
}
|
||||
|
||||
fn ansi_palette() -> [Hsla; 16] {
|
||||
[
|
||||
0x1d1f21, 0xc65d5c, 0x8cc46b, 0xd4b96a, 0x7aa2f7, 0xb48ead, 0x62b3b2, 0xc4c9d1,
|
||||
0x585b70, 0xe06866, 0xa3d17a, 0xf0c167, 0x9ab8ff, 0xc79bda, 0x7fc9c8, 0xf2f4f8,
|
||||
]
|
||||
.map(|hex| Hsla::from(rgb(hex)))
|
||||
}
|
||||
|
||||
fn resolve_color(color: Color, default_fg: Hsla, default_bg: Hsla, palette: &[Hsla; 16]) -> Hsla {
|
||||
match color {
|
||||
Color::Named(named) => match named {
|
||||
NamedColor::Foreground | NamedColor::BrightForeground => default_fg,
|
||||
NamedColor::Background => default_bg,
|
||||
NamedColor::DimForeground => default_fg.opacity(0.66),
|
||||
NamedColor::Cursor => default_fg,
|
||||
other => named_index(other)
|
||||
.map(|index| palette[index])
|
||||
.unwrap_or(default_fg),
|
||||
},
|
||||
Color::Indexed(index) => palette[index as usize % palette.len()],
|
||||
Color::Spec(rgb_color) => Hsla::from(rgb(u32::from(rgb_color.r) << 16 | u32::from(rgb_color.g) << 8 | u32::from(rgb_color.b))),
|
||||
}
|
||||
}
|
||||
|
||||
fn named_index(color: NamedColor) -> Option<usize> {
|
||||
use NamedColor::*;
|
||||
Some(match color {
|
||||
Black | DimBlack => 0,
|
||||
Red | DimRed => 1,
|
||||
Green | DimGreen => 2,
|
||||
Yellow | DimYellow => 3,
|
||||
Blue | DimBlue => 4,
|
||||
Magenta | DimMagenta => 5,
|
||||
Cyan | DimCyan => 6,
|
||||
White | DimWhite => 7,
|
||||
BrightBlack => 8,
|
||||
BrightRed => 9,
|
||||
BrightGreen => 10,
|
||||
BrightYellow => 11,
|
||||
BrightBlue => 12,
|
||||
BrightMagenta => 13,
|
||||
BrightCyan => 14,
|
||||
BrightWhite => 15,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn styled_font(font: &Font, flags: Flags) -> Font {
|
||||
let mut font = font.clone();
|
||||
if flags.contains(Flags::BOLD) {
|
||||
font.weight = FontWeight::BOLD;
|
||||
}
|
||||
if flags.contains(Flags::ITALIC) {
|
||||
font.style = FontStyle::Italic;
|
||||
}
|
||||
font
|
||||
}
|
||||
|
||||
/// 把一次按键编码成终端字节。M2 第一版覆盖 ASCII 与控制键;
|
||||
/// IME / 中文输入走 InputHandler 路径(后续里程碑)。
|
||||
fn encode_key(keystroke: &Keystroke) -> Option<Vec<u8>> {
|
||||
let key = keystroke.key.as_str();
|
||||
let ctrl = keystroke.modifiers.control;
|
||||
let alt = keystroke.modifiers.alt;
|
||||
let sequence: Vec<u8> = match key {
|
||||
"enter" => b"\r".to_vec(),
|
||||
"backspace" => b"\x7f".to_vec(),
|
||||
"tab" => b"\t".to_vec(),
|
||||
"escape" => b"\x1b".to_vec(),
|
||||
"up" => b"\x1b[A".to_vec(),
|
||||
"down" => b"\x1b[B".to_vec(),
|
||||
"right" => b"\x1b[C".to_vec(),
|
||||
"left" => b"\x1b[D".to_vec(),
|
||||
"home" => b"\x1b[H".to_vec(),
|
||||
"end" => b"\x1b[F".to_vec(),
|
||||
"delete" => b"\x1b[3~".to_vec(),
|
||||
"pageup" => b"\x1b[5~".to_vec(),
|
||||
"pagedown" => b"\x1b[6~".to_vec(),
|
||||
"space" if ctrl => vec![0x00],
|
||||
_ => {
|
||||
if ctrl {
|
||||
let ch = key.chars().next()?.to_ascii_lowercase();
|
||||
if ch.is_ascii_lowercase() {
|
||||
vec![(ch as u8) & 0x1f]
|
||||
} else if key == "[" {
|
||||
vec![0x1b]
|
||||
} else if key == "\\" {
|
||||
vec![0x1c]
|
||||
} else if key == "]" {
|
||||
vec![0x1d]
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let text = keystroke.key_char.clone()?;
|
||||
text.into_bytes()
|
||||
}
|
||||
}
|
||||
};
|
||||
if alt {
|
||||
let mut bytes = vec![0x1b];
|
||||
bytes.extend_from_slice(&sequence);
|
||||
Some(bytes)
|
||||
} else {
|
||||
Some(sequence)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
//! 中央区的终端标签页。M1 是占位:显示主机信息;M2 接入 alacritty_terminal
|
||||
//! 渲染与字节 WS。
|
||||
//! 中央区的终端标签页:顶栏显示主机,正文是真实的终端视图。
|
||||
|
||||
use gpui_kit::component::*;
|
||||
use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::api::HostSummary;
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
pub struct TerminalPanel {
|
||||
focus_handle: FocusHandle,
|
||||
host: HostSummary,
|
||||
terminal: Entity<TerminalView>,
|
||||
}
|
||||
|
||||
impl TerminalPanel {
|
||||
pub fn new(host: HostSummary, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
host,
|
||||
}
|
||||
let terminal = cx.new(|cx| TerminalView::new(host.id.clone(), cx));
|
||||
Self { host, terminal }
|
||||
}
|
||||
|
||||
pub fn host_id(&self) -> &str {
|
||||
@@ -26,8 +24,8 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
impl Focusable for TerminalPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.terminal.read(cx).focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,17 +75,10 @@ impl Render for TerminalPanel {
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
div()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("终端将在 M2 接入"),
|
||||
),
|
||||
.min_h_0()
|
||||
.child(self.terminal.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user