diff --git a/Cargo.lock b/Cargo.lock index 223b2f4..8039e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/gpui-client/Cargo.toml b/gpui-client/Cargo.toml index d7fe86a..9788e63 100644 --- a/gpui-client/Cargo.toml +++ b/gpui-client/Cargo.toml @@ -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(只用公共 diff --git a/gpui-client/examples/terminal_smoke.rs b/gpui-client/examples/terminal_smoke.rs new file mode 100644 index 0000000..46bc8d9 --- /dev/null +++ b/gpui-client/examples/terminal_smoke.rs @@ -0,0 +1,59 @@ +//! 冒烟:连真实守护进程,为一台主机创建终端 pane,把收到的字节流解析成 +//! 网格并打印可见文本。用法:`cargo run -p vps-switch-gpui --example terminal_smoke -- ` + +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}"); + } + } +} diff --git a/gpui-client/src/api.rs b/gpui-client/src/api.rs index 3f98ccb..f5c48e1 100644 --- a/gpui-client/src/api.rs +++ b/gpui-client/src/api.rs @@ -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 { diff --git a/gpui-client/src/lib.rs b/gpui-client/src/lib.rs index 7b27b46..502445e 100644 --- a/gpui-client/src/lib.rs +++ b/gpui-client/src/lib.rs @@ -4,5 +4,6 @@ pub mod api; pub mod hosts; +pub mod terminal; pub mod terminal_panel; pub mod workspace; diff --git a/gpui-client/src/terminal/mod.rs b/gpui-client/src/terminal/mod.rs new file mode 100644 index 0000000..5cdade1 --- /dev/null +++ b/gpui-client/src/terminal/mod.rs @@ -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; diff --git a/gpui-client/src/terminal/session.rs b/gpui-client/src/terminal/session.rs new file mode 100644 index 0000000..3d7e16c --- /dev/null +++ b/gpui-client/src/terminal/session.rs @@ -0,0 +1,286 @@ +//! 终端会话:后台线程里创建 pane、连 WS、用 `alacritty_terminal` 解析字节流, +//! 产出一份可绘制的快照。 +//! +//! 线程模型与 api.rs 相同:专用线程跑 current_thread tokio 运行时;UI 线程通过 +//! 事件通道得知「快照有更新」,再从 `Arc>>` 取数据—— +//! 解析线程永远不碰 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, + /// 光标 (行, 列),行是屏幕坐标。 + 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), + Resize { cols: u16, rows: u16 }, + /// 向上(负)或向下(正)滚动历史。 + Scroll(i32), +} + +pub struct Session { + commands: async_channel::Sender, + events: mpsc::Receiver, + snapshot: Arc>>, +} + +impl Session { + pub fn spawn(host_id: String, cols: u16, rows: u16) -> Self { + let (command_tx, command_rx) = async_channel::unbounded::(); + let (event_tx, event_rx) = mpsc::channel::(); + 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 { + self.events.try_recv().ok() + } + + pub fn snapshot(&self) -> Option { + self.snapshot.lock().ok().and_then(|guard| guard.clone()) + } +} + +async fn run( + host_id: String, + cols: u16, + rows: u16, + commands: async_channel::Receiver, + events: &mpsc::Sender, + snapshot: &Arc>>, +) -> 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>>, term: &Term, 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); + } +} diff --git a/gpui-client/src/terminal/view.rs b/gpui-client/src/terminal/view.rs new file mode 100644 index 0000000..b25ebe2 --- /dev/null +++ b/gpui-client/src/terminal/view.rs @@ -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, + focus_handle: FocusHandle, + /// 会话结束的原因;有值时在底部显示提示条。 + status: Option, + cols: u16, + rows: u16, +} + +impl TerminalView { + pub fn new(host_id: String, cx: &mut Context) -> 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) { + 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) { + 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) -> 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, +} + +impl IntoElement for TerminalElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct TerminalPaintState { + segments: Vec, + backgrounds: Vec, + cursor: Option, + cell_width: Pixels, + line_height: Pixels, +} + +struct Segment { + origin: Point, + line: ShapedLine, +} + +impl Element for TerminalElement { + type RequestLayoutState = (); + type PrepaintState = TerminalPaintState; + + fn id(&self) -> Option { + 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, + _: &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 = 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, + _: &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 { + 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> { + let key = keystroke.key.as_str(); + let ctrl = keystroke.modifiers.control; + let alt = keystroke.modifiers.alt; + let sequence: Vec = 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) + } +} diff --git a/gpui-client/src/terminal_panel.rs b/gpui-client/src/terminal_panel.rs index 57a27b3..8fab214 100644 --- a/gpui-client/src/terminal_panel.rs +++ b/gpui-client/src/terminal_panel.rs @@ -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, } impl TerminalPanel { pub fn new(host: HostSummary, cx: &mut Context) -> 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()), ) } }