feat(terminal): renderer-agnostic render contract (nebula_terminal::render)

Layering: Term (model) -> render contract -> frontend backend.

- CellMetrics + TerminalViewport: floor division, minimum grid, exact
  text-area pixels (cols x cell_width) for PTY/app reporting.
- ViewportTracker: resize coalescing, monotonic revisions (a stale resize
  can never override a newer one), and pixel-only changes still reported
  to the PTY even when rows/cols are unchanged.
- RenderSnapshot: single-lock plain-data capture (bg runs, selection runs,
  wide-aware text segments, cursor, OSC color-override copy). Colors stay
  as vte values; frontends resolve them outside the lock.
- gpui element/view now consume the contract: grid math single-sourced in
  the tracker, cursor-cell isolation for block-cursor inversion. Swapping
  the paint backend later touches element.rs only.
- 6 contract tests (segmentation, cursor isolation, viewport rules).
This commit is contained in:
kuddev committed 2026-08-12 23:54:14 +08:00
1 parent 65794784a9
commit c520bc6de2
4 files changed
+685 -250

No files matched your search

+109 -223
View File
@@ -1,21 +1,20 @@
//! 终端网格的自定义 GPUI Element。
//!
//! 热路径约定:paint 内只对 `Term` 加一次锁,把可见网格快照成纯数据后立刻放
//! 锁,再用快照分三层绘制(背景块 → 选区/光标底 → 文字段),文字按
//! `force_width` 锁定单元格步进,保证 CJK 宽字符与网格对齐。
//! 热路径约定:paint 内只对 `Term` 加一次锁,通过渲染合同
//! (`nebula_terminal::render::RenderSnapshot`)把可见网格快照成纯数据后立刻
//! 放锁;颜色解析(调色板 + OSC 覆盖表)与绘制都在锁外进行。文字按
//! `force_width` 锁定单元格步进,保证 CJK 宽字符与网格对齐 —— 排版引擎在
//! 这里只承担栅格化与批处理,没有移动字形的权力。
use gpui::{
App, Bounds, Element, ElementId, GlobalElementId, HitboxBehavior, Hsla, InspectorElementId,
LayoutId, Pixels, Rgba, SharedString, Style, TextRun, UnderlineStyle, Window, fill, outline,
point, px, relative, size,
LayoutId, Pixels, SharedString, Style, TextRun, UnderlineStyle, Window, fill, outline, point,
px, relative, size,
};
use gpui_component::PixelsExt as _;
use nebula_terminal::index::Point as TermPoint;
use nebula_terminal::term::cell::Flags;
use nebula_terminal::term::point_to_viewport;
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig};
use nebula_terminal::vte::ansi::CursorShape;
use super::colors;
use super::view::TerminalView;
pub struct TerminalElement {
@@ -32,145 +31,24 @@ pub struct TermLayout {
cell_width: Pixels,
line_height: Pixels,
rows: usize,
}
struct CellSnap {
col: usize,
text: String,
fg: Hsla,
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
wide: bool,
}
struct BgRun {
row: usize,
start: usize,
end: usize,
color: Rgba,
}
struct CursorSnap {
row: usize,
col: usize,
shape: CursorShape,
wide: bool,
}
struct Snapshot {
rows: Vec<Vec<CellSnap>>,
bgs: Vec<BgRun>,
selection: Vec<BgRun>,
cursor: Option<CursorSnap>,
}
fn rgba_eq(a: Rgba, b: Rgba) -> bool {
a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a
cols: usize,
}
impl TerminalElement {
/// 在一次锁内把可见内容快照成绘制数据。
fn snapshot(&self, rows: usize, cols: usize, focused: bool, cx: &App) -> Option<Snapshot> {
/// 在一次锁内通过渲染合同截取快照;锁外解析颜色与绘制。
fn snapshot(&self, rows: usize, cols: usize, focused: bool, cx: &App) -> Option<RenderSnapshot> {
let view = self.view.read(cx);
let session = view.session.as_ref()?;
let term = session.term.lock();
let content = term.renderable_content();
let display_offset = content.display_offset;
let palette = content.colors;
let selection_range = content.selection;
let cursor_vp = point_to_viewport(display_offset, content.cursor.point);
let cursor_shape = content.cursor.shape;
let mut snap = Snapshot {
rows: (0..rows).map(|_| Vec::new()).collect(),
bgs: Vec::new(),
selection: Vec::new(),
cursor: None,
};
let push_bg = |bgs: &mut Vec<BgRun>, row: usize, col: usize, color: Rgba| {
if let Some(last) = bgs.last_mut() {
if last.row == row && last.end == col && rgba_eq(last.color, color) {
last.end = col + 1;
return;
}
}
bgs.push(BgRun { row, start: col, end: col + 1, color });
};
for indexed in content.display_iter {
let Some(vp) = point_to_viewport(display_offset, indexed.point) else { continue };
let (row, col) = (vp.line, vp.column.0);
if row >= rows || col >= cols {
continue;
}
let flags = indexed.cell.flags;
let bold = flags.intersects(Flags::BOLD);
let mut fg = colors::resolve(indexed.cell.fg, palette, bold);
let mut bg = colors::resolve(indexed.cell.bg, palette, false);
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if !rgba_eq(bg, colors::BACKGROUND) {
push_bg(&mut snap.bgs, row, col, bg);
}
if selection_range.is_some_and(|range| range.contains(indexed.point)) {
push_bg(&mut snap.selection, row, col, colors::SELECTION);
}
if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
|| flags.contains(Flags::HIDDEN)
{
continue;
}
let is_cursor_cell = focused
&& matches!(cursor_shape, CursorShape::Block)
&& cursor_vp.is_some_and(|c| c.line == row && c.column.0 == col);
let c = indexed.cell.c;
if c == ' '
&& indexed.cell.extra.is_none()
&& !flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
&& !is_cursor_cell
{
continue;
}
let mut text = String::new();
text.push(c);
if let Some(zerowidth) = indexed.cell.zerowidth() {
text.extend(zerowidth);
}
snap.rows[row].push(CellSnap {
col,
text,
fg: if is_cursor_cell { colors::BACKGROUND.into() } else { fg.into() },
bold,
italic: flags.intersects(Flags::ITALIC),
underline: flags.intersects(Flags::ALL_UNDERLINES),
strikethrough: flags.contains(Flags::STRIKEOUT),
wide: flags.contains(Flags::WIDE_CHAR),
});
}
if let Some(vp) = cursor_vp {
if vp.line < rows && vp.column.0 < cols && cursor_shape != CursorShape::Hidden {
let wide = term.grid()
[TermPoint::new(content.cursor.point.line, content.cursor.point.column)]
.flags
.contains(Flags::WIDE_CHAR);
snap.cursor =
Some(CursorSnap { row: vp.line, col: vp.column.0, shape: cursor_shape, wide });
}
}
Some(snap)
Some(RenderSnapshot::capture(
&term,
&SnapshotConfig {
rows: rows as u16,
cols: cols as u16,
// 聚焦时把光标格隔离成单格段,方便下面反色绘制。
isolate_cursor_cell: focused,
},
))
}
}
@@ -237,17 +115,17 @@ impl Element for TerminalElement {
let view = self.view.read(cx);
let line_height = px((font_size.as_f32() * view.line_height_mul).round().max(1.0));
let cols = ((bounds.size.width.as_f32() / cell_width.as_f32()).floor().max(2.0)) as usize;
let rows = ((bounds.size.height.as_f32() / line_height.as_f32()).floor().max(1.0)) as usize;
// 网格裁定(floor、最小网格、resize 合流)全部由渲染合同的
// ViewportTracker 在 view 内完成;元素只上报内容矩形与度量。
let scale = window.scale_factor();
self.view.update(cx, |view, _| {
view.set_layout(bounds.origin, cell_width, line_height, cols, rows, scale);
view.set_layout(bounds.origin, cell_width, line_height, bounds.size, scale);
});
window.insert_hitbox(bounds, HitboxBehavior::Normal);
TermLayout { cell_width, line_height, rows }
let view = self.view.read(cx);
TermLayout { cell_width, line_height, rows: view.grid_rows(), cols: view.grid_cols() }
}
fn paint(
@@ -260,7 +138,8 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) {
window.paint_quad(fill(bounds, colors::BACKGROUND));
let theme = self.view.read(cx).palette.clone();
window.paint_quad(fill(bounds, theme.background));
let focus_handle = self.view.read(cx).focus_handle.clone();
window.handle_input(
@@ -270,11 +149,10 @@ impl Element for TerminalElement {
);
let focused = focus_handle.is_focused(window);
let cols =
((bounds.size.width.as_f32() / layout.cell_width.as_f32()).floor().max(2.0)) as usize;
let Some(snap) = self.snapshot(layout.rows, cols, focused, cx) else {
let Some(snap) = self.snapshot(layout.rows, layout.cols, focused, cx) else {
return;
};
let overrides = snap.color_overrides;
let cell_rect = |row: usize, start: usize, count: usize| -> Bounds<Pixels> {
Bounds::new(
@@ -286,18 +164,28 @@ impl Element for TerminalElement {
)
};
for run in &snap.bgs {
window.paint_quad(fill(cell_rect(run.row, run.start, run.end - run.start), run.color));
for run in &snap.bg_runs {
let color = theme.resolve(run.color, &overrides, false);
window.paint_quad(fill(
cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize),
color,
));
}
for run in &snap.selection {
window.paint_quad(fill(cell_rect(run.row, run.start, run.end - run.start), run.color));
for run in &snap.selection_runs {
window.paint_quad(fill(
cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize),
theme.selection,
));
}
// 聚焦时的块状光标垫在文字下面,让字形以反色盖在上面。
if let Some(cursor) = &snap.cursor {
if focused && matches!(cursor.shape, CursorShape::Block) {
let width = if cursor.wide { 2 } else { 1 };
window.paint_quad(fill(cell_rect(cursor.row, cursor.col, width), colors::CURSOR));
window.paint_quad(fill(
cell_rect(cursor.row as usize, cursor.col as usize, width),
theme.cursor,
));
}
}
@@ -317,84 +205,82 @@ impl Element for TerminalElement {
(false, true) => italic_font.clone(),
(true, true) => bold_italic_font.clone(),
};
// 聚焦块光标下的字形反色(合同保证该格是独立段)。
let cursor_inverts = |row: u16, col: u16| {
focused
&& snap.cursor.as_ref().is_some_and(|c| {
matches!(c.shape, CursorShape::Block) && c.row == row && c.col == col
})
};
for (row, cells) in snap.rows.iter().enumerate() {
let mut i = 0;
while i < cells.len() {
// 一段:列连续且宽度类别一致的单元格,样式差异用多个 TextRun 表达。
let seg_wide = cells[i].wide;
let seg_start_col = cells[i].col;
let step = if seg_wide { 2 } else { 1 };
let mut text = String::new();
let mut runs: Vec<TextRun> = Vec::new();
let mut expected = seg_start_col;
let mut cell_count = 0usize;
while i < cells.len() && cells[i].wide == seg_wide && cells[i].col == expected {
let cell = &cells[i];
let bytes = cell.text.len();
let underline = cell.underline.then(|| UnderlineStyle {
thickness: px(1.0),
color: Some(cell.fg),
wavy: false,
for seg in &snap.segments {
let step = seg.step() as usize;
let mut text = String::new();
let mut runs: Vec<TextRun> = Vec::new();
for cell in &seg.cells {
let fg: Hsla = if cursor_inverts(seg.row, cell.col) {
theme.background.into()
} else {
theme.resolve(cell.fg, &overrides, cell.bold).into()
};
let underline = cell.underline.then(|| UnderlineStyle {
thickness: px(1.0),
color: Some(fg),
wavy: false,
});
let strikethrough = cell
.strikethrough
.then(|| gpui::StrikethroughStyle { thickness: px(1.0), color: Some(fg) });
let bytes = cell.text.len();
let mergeable = runs.last().is_some_and(|run: &TextRun| {
run.color == fg
&& run.font == pick_font(cell.bold, cell.italic)
&& run.underline == underline
&& run.strikethrough == strikethrough
});
if mergeable {
runs.last_mut().unwrap().len += bytes;
} else {
runs.push(TextRun {
len: bytes,
font: pick_font(cell.bold, cell.italic),
color: fg,
background_color: None,
underline,
strikethrough,
});
let strikethrough = cell.strikethrough.then(|| gpui::StrikethroughStyle {
thickness: px(1.0),
color: Some(cell.fg),
});
let mergeable = runs.last().is_some_and(|run: &TextRun| {
run.color == cell.fg
&& run.font == pick_font(cell.bold, cell.italic)
&& run.underline == underline
&& run.strikethrough == strikethrough
});
if mergeable {
runs.last_mut().unwrap().len += bytes;
} else {
runs.push(TextRun {
len: bytes,
font: pick_font(cell.bold, cell.italic),
color: cell.fg,
background_color: None,
underline,
strikethrough,
});
}
text.push_str(&cell.text);
expected += step;
cell_count += 1;
i += 1;
}
if cell_count == 0 {
i += 1;
continue;
}
let shaped = window.text_system().shape_line(
SharedString::from(text),
font_size,
&runs,
Some(layout.cell_width * step as f32),
);
let origin = point(
bounds.origin.x + layout.cell_width * seg_start_col as f32,
bounds.origin.y + layout.line_height * row as f32,
);
let _ = shaped.paint(origin, layout.line_height, window, cx);
text.push_str(&cell.text);
}
if runs.is_empty() {
continue;
}
let shaped = window.text_system().shape_line(
SharedString::from(text),
font_size,
&runs,
Some(layout.cell_width * step as f32),
);
let origin = point(
bounds.origin.x + layout.cell_width * seg.start_col as f32,
bounds.origin.y + layout.line_height * seg.row as f32,
);
let _ = shaped.paint(origin, layout.line_height, window, cx);
}
if let Some(cursor) = &snap.cursor {
let width = if cursor.wide { 2 } else { 1 };
let rect = cell_rect(cursor.row, cursor.col, width);
let rect = cell_rect(cursor.row as usize, cursor.col as usize, width);
match (focused, cursor.shape) {
(true, CursorShape::Block) => {}, // 已在文字层下方填充
(false, CursorShape::Block | CursorShape::HollowBlock)
| (true, CursorShape::HollowBlock) => {
window.paint_quad(outline(rect, colors::CURSOR, gpui::BorderStyle::Solid));
window.paint_quad(outline(rect, theme.cursor, gpui::BorderStyle::Solid));
},
(_, CursorShape::Beam) => {
window.paint_quad(fill(
Bounds::new(rect.origin, size(px(2.0), layout.line_height)),
colors::CURSOR,
theme.cursor,
));
},
(_, CursorShape::Underline) => {
@@ -403,25 +289,25 @@ impl Element for TerminalElement {
point(rect.origin.x, rect.origin.y + layout.line_height - px(2.0)),
size(rect.size.width, px(2.0)),
),
colors::CURSOR,
theme.cursor,
));
},
(_, CursorShape::Hidden) => {},
}
// IME 组合文本锚点与预编辑串:跟随光标单元格。
let anchor = cell_rect(cursor.row, cursor.col, 1);
let anchor = cell_rect(cursor.row as usize, cursor.col as usize, 1);
let marked = self.view.read(cx).marked_text.clone();
self.view.update(cx, |view, _| view.ime_bounds = anchor);
if let Some(marked) = marked.filter(|m| !m.is_empty()) {
let run = TextRun {
len: marked.len(),
font: font.clone(),
color: colors::FOREGROUND.into(),
color: theme.foreground.into(),
background_color: None,
underline: Some(UnderlineStyle {
thickness: px(1.0),
color: Some(colors::FOREGROUND.into()),
color: Some(theme.foreground.into()),
wavy: false,
}),
strikethrough: None,
@@ -433,7 +319,7 @@ impl Element for TerminalElement {
None,
);
let bg = Bounds::new(anchor.origin, size(shaped.width, layout.line_height));
window.paint_quad(fill(bg, colors::BACKGROUND));
window.paint_quad(fill(bg, theme.background));
let _ = shaped.paint(anchor.origin, layout.line_height, window, cx);
}
}
+70 -27
View File
@@ -4,21 +4,25 @@ use gpui::{
App, Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontStyle,
FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollWheelEvent,
Styled as _, UTF16Selection, Window, div, point, px,
Size, Styled as _, UTF16Selection, Window, div, point, px,
};
use gpui_component::PixelsExt as _;
use nebula_terminal::event::{Event as TermEvent, Notify as _, OnResize as _, WindowSize};
use nebula_terminal::event_loop::Msg;
use nebula_terminal::grid::Scroll;
use nebula_terminal::index::{Column, Point as TermPoint, Side};
use nebula_terminal::render::{CellMetrics, ViewportTracker};
use nebula_terminal::selection::{Selection, SelectionType};
use nebula_terminal::term::TermMode;
use nebula_terminal::term::viewport_to_point;
use super::colors;
use std::sync::Arc;
use super::colors::Palette;
use super::element::TerminalElement;
use super::keymap;
use super::session::{self, GridSize, TerminalSession};
use crate::config::Settings;
use futures::StreamExt as _;
@@ -39,6 +43,7 @@ pub struct TerminalView {
pub font_bold_italic: Font,
pub font_size: Pixels,
pub line_height_mul: f32,
pub palette: Arc<Palette>,
pub marked_text: Option<String>,
pub ime_bounds: Bounds<Pixels>,
pub title: String,
@@ -50,16 +55,36 @@ pub struct TerminalView {
cols: usize,
rows: usize,
window_size: WindowSize,
viewports: ViewportTracker,
scroll_px: f32,
selecting: bool,
}
impl TerminalView {
pub fn new(cx: &mut Context<Self>) -> Self {
let mono = |weight: FontWeight, style: FontStyle| Font {
// 字体与调色板来自用户 nebula.toml(bootstrap 时装载为全局 Settings)。
let (families, font_size, palette) = match cx.try_global::<Settings>() {
Some(settings) => (
[
settings.font_family.clone(),
settings.font_bold_family.clone(),
settings.font_italic_family.clone(),
settings.font_bold_italic_family.clone(),
],
px(settings.font_size_px),
Arc::new(settings.palette.clone()),
),
None => (
std::array::from_fn(|_| String::from("Cascadia Mono")),
px(15.0),
Arc::new(Palette::default()),
),
};
// to_string:SharedString 的 &str 转换要求 'static,字体名是运行时值。
let mono = |family: &str, weight: FontWeight, style: FontStyle| Font {
weight,
style,
..gpui::font("Cascadia Mono")
..gpui::font(family.to_string())
};
let initial = WindowSize { num_lines: 30, num_cols: 100, cell_width: 9, cell_height: 18 };
@@ -98,12 +123,13 @@ impl TerminalView {
Self {
session,
focus_handle: cx.focus_handle(),
font: mono(FontWeight::NORMAL, FontStyle::Normal),
font_bold: mono(FontWeight::BOLD, FontStyle::Normal),
font_italic: mono(FontWeight::NORMAL, FontStyle::Italic),
font_bold_italic: mono(FontWeight::BOLD, FontStyle::Italic),
font_size: px(14.0),
font: mono(&families[0], FontWeight::NORMAL, FontStyle::Normal),
font_bold: mono(&families[1], FontWeight::BOLD, FontStyle::Normal),
font_italic: mono(&families[2], FontWeight::NORMAL, FontStyle::Italic),
font_bold_italic: mono(&families[3], FontWeight::BOLD, FontStyle::Italic),
font_size,
line_height_mul: 1.25,
palette,
marked_text: None,
ime_bounds: Bounds::default(),
title: String::from("shell"),
@@ -115,6 +141,7 @@ impl TerminalView {
cols: initial.num_cols as usize,
rows: initial.num_lines as usize,
window_size: initial,
viewports: ViewportTracker::default(),
scroll_px: 0.0,
selecting: false,
}
@@ -147,7 +174,7 @@ impl TerminalView {
TermEvent::ColorRequest(index, formatter) => {
let reply = self.session.as_ref().map(|session| {
let term = session.term.lock();
colors::query_reply(index, term.colors())
self.palette.query_reply(index, term.colors())
});
if let Some(rgb) = reply {
self.write_bytes(formatter(rgb).into_bytes());
@@ -205,38 +232,54 @@ impl TerminalView {
cx.notify();
}
/// 元素 prepaint 回写布局;行列变化时同步 Term 和 ConPTY。
/// 元素 prepaint 回写布局:内容矩形与度量交给渲染合同裁定网格。
/// 网格变化时同步 Term 与 ConPTY;行列不变但像素口径变化也上报 PTY
/// (Ghostty 规则);稳态帧 observe 返回 None,零额外开销。
pub fn set_layout(
&mut self,
origin: Point<Pixels>,
cell_width: Pixels,
line_height: Pixels,
cols: usize,
rows: usize,
content: Size<Pixels>,
scale: f32,
) {
self.origin = origin;
self.cell_width = cell_width;
self.line_height = line_height;
if cols == self.cols && rows == self.rows {
return;
}
self.cols = cols;
self.rows = rows;
let window_size = WindowSize {
num_lines: rows as u16,
num_cols: cols as u16,
cell_width: (cell_width.as_f32() * scale) as u16,
cell_height: (line_height.as_f32() * scale) as u16,
let metrics = CellMetrics {
cell_width: cell_width.as_f32(),
cell_height: line_height.as_f32(),
scale,
};
self.window_size = window_size;
let Some(change) =
self.viewports.observe(content.width.as_f32(), content.height.as_f32(), &metrics)
else {
return;
};
let viewport = change.viewport;
self.cols = viewport.cols as usize;
self.rows = viewport.rows as usize;
self.window_size = viewport.window_size();
if let Some(session) = &self.session {
session.term.lock().resize(GridSize { columns: cols, screen_lines: rows });
if change.grid_changed {
session
.term
.lock()
.resize(GridSize { columns: self.cols, screen_lines: self.rows });
}
let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone());
notifier.on_resize(window_size);
notifier.on_resize(viewport.window_size());
}
}
pub fn grid_rows(&self) -> usize {
self.rows
}
pub fn grid_cols(&self) -> usize {
self.cols
}
fn term_mode(&self) -> TermMode {
self.session.as_ref().map(|s| *s.term.lock().mode()).unwrap_or_default()
}
@@ -496,7 +539,7 @@ impl Render for TerminalView {
.id("nebula-terminal")
.size_full()
.overflow_hidden()
.bg(colors::BACKGROUND)
.bg(self.palette.background)
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))