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:
weidu12123andClaude Fable 5 committed 2026-10-17 17:48:03 +08:00
1 parent 5299ff513f
commit 582c4f4958
9 files changed
+943 -27

No files matched your search

Generated
+36 -4
View File
@@ -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]]
+6 -1
View File
@@ -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(只用公共
+59
View File
@@ -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}");
}
}
}
+6 -3
View File
@@ -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 {
+1
View File
@@ -4,5 +4,6 @@
pub mod api;
pub mod hosts;
pub mod terminal;
pub mod terminal_panel;
pub mod workspace;
+7
View File
@@ -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;
+286
View File
@@ -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);
}
}
+532
View File
@@ -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)
}
}
+10 -19
View File
@@ -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()),
)
}
}