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))
+1
View File
@@ -9,6 +9,7 @@ pub mod event_loop;
pub mod grid;
pub mod index;
pub mod osc_cwd;
pub mod render;
pub mod selection;
pub mod sync;
pub mod term;
+505
View File
@@ -0,0 +1,505 @@
//! Renderer-agnostic terminal render contract (Ghostty-style layering).
//!
//! Layering: `Term`/grid (terminal model) → this module (viewport protocol +
//! plain-data snapshot) → a frontend renderer (today the GPUI element; later
//! possibly a self-managed glyph atlas or another backend). Frontends depend
//! on these types; nothing here may depend on a UI framework.
//!
//! Hard rules encoded here:
//! - Terminal content only exists as a cell grid (column × fixed step). There
//! is no "string flow" representation; typography must never move a glyph.
//! - A viewport is immutable once issued and carries a monotonically
//! increasing `revision`: a stale resize must never override a newer one.
//! - Pixel sizes reported to the PTY / applications are always the exact
//! `columns × cell_width` product (WezTerm rule), never leftover pixels.
//! - A pixel-size change is reported even when rows/cols are unchanged
//! (Ghostty rule): applications may care about pixel metrics.
use crate::event::{EventListener, WindowSize};
use crate::term::cell::Flags;
use crate::term::color::Colors;
use crate::term::{Term, point_to_viewport};
use crate::vte::ansi::{Color, CursorShape, NamedColor};
/// Cell metrics measured by the frontend (logical pixels + device scale).
///
/// Contract: once issued, every glyph in the grid lands at
/// `column × cell_width`; no layout engine may change the step.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct CellMetrics {
pub cell_width: f32,
pub cell_height: f32,
pub scale: f32,
}
impl CellMetrics {
pub fn device_cell_width(&self) -> u16 {
(self.cell_width * self.scale).round().max(1.0) as u16
}
pub fn device_cell_height(&self) -> u16 {
(self.cell_height * self.scale).round().max(1.0) as u16
}
}
pub const MIN_COLS: u16 = 2;
pub const MIN_ROWS: u16 = 1;
/// One immutable grid decision derived from a pane's content rect.
///
/// The caller must pass the *content* rect in logical pixels — paddings,
/// dividers, tab bars and side bars already subtracted. UI-framework logical
/// points must be converted before they reach this type.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct TerminalViewport {
pub cols: u16,
pub rows: u16,
/// Device-pixel cell size: the unit used when reporting to the PTY and
/// answering text-area size queries.
pub cell_width_px: u16,
pub cell_height_px: u16,
pub revision: u64,
}
impl TerminalViewport {
/// Floor-divide a content rect into a grid; clamps to the minimum grid.
pub fn from_content_size(
width: f32,
height: f32,
metrics: &CellMetrics,
revision: u64,
) -> Self {
let cols = (width / metrics.cell_width.max(1.0)).floor().max(MIN_COLS as f32) as u16;
let rows = (height / metrics.cell_height.max(1.0)).floor().max(MIN_ROWS as f32) as u16;
Self {
cols,
rows,
cell_width_px: metrics.device_cell_width(),
cell_height_px: metrics.device_cell_height(),
revision,
}
}
/// Exact text-area width: `cols × cell_width` (WezTerm rule).
pub fn text_area_width_px(&self) -> u32 {
u32::from(self.cols) * u32::from(self.cell_width_px)
}
/// Exact text-area height: `rows × cell_height` (WezTerm rule).
pub fn text_area_height_px(&self) -> u32 {
u32::from(self.rows) * u32::from(self.cell_height_px)
}
/// The size reported to the PTY and to text-area size queries.
pub fn window_size(&self) -> WindowSize {
WindowSize {
num_lines: self.rows,
num_cols: self.cols,
cell_width: self.cell_width_px,
cell_height: self.cell_height_px,
}
}
pub fn grid_eq(&self, other: &Self) -> bool {
self.cols == other.cols && self.rows == other.rows
}
pub fn pixel_eq(&self, other: &Self) -> bool {
self.cell_width_px == other.cell_width_px && self.cell_height_px == other.cell_height_px
}
}
/// Outcome of [`ViewportTracker::observe`].
#[derive(Copy, Clone, Debug)]
pub struct ViewportChange {
pub viewport: TerminalViewport,
/// Rows/cols changed: the grid and the PTY must be resized.
pub grid_changed: bool,
/// Device cell size changed: the PTY must be re-informed even when the
/// grid stayed the same (Ghostty rule).
pub pixel_changed: bool,
}
/// Coalesces layout observations into viewport revisions.
///
/// `observe` returns `None` while nothing changed, so per-frame layout code
/// can call it unconditionally. Every returned viewport carries a strictly
/// increasing revision; consumers must drop events whose revision is older
/// than the latest they have seen.
#[derive(Default)]
pub struct ViewportTracker {
current: Option<TerminalViewport>,
issued: u64,
}
impl ViewportTracker {
pub fn current(&self) -> Option<&TerminalViewport> {
self.current.as_ref()
}
pub fn observe(&mut self, width: f32, height: f32, metrics: &CellMetrics) -> Option<ViewportChange> {
let candidate = TerminalViewport::from_content_size(width, height, metrics, self.issued + 1);
let (grid_changed, pixel_changed) = match &self.current {
Some(current) => (!current.grid_eq(&candidate), !current.pixel_eq(&candidate)),
None => (true, true),
};
if !grid_changed && !pixel_changed {
return None;
}
self.issued += 1;
self.current = Some(candidate);
Some(ViewportChange { viewport: candidate, grid_changed, pixel_changed })
}
}
/// One renderable cell: text plus unresolved style. Colors stay as vte
/// [`Color`] values; the frontend resolves them against its palette and the
/// snapshot's [`RenderSnapshot::color_overrides`].
pub struct SnapCell {
pub col: u16,
/// Base character plus any zero-width combining characters.
pub text: String,
pub fg: Color,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
}
/// A run of column-contiguous cells sharing one width class. `wide`
/// segments step two columns per cell; narrow segments step one.
pub struct TextSegment {
pub row: u16,
pub start_col: u16,
pub wide: bool,
pub cells: Vec<SnapCell>,
}
impl TextSegment {
pub fn step(&self) -> u16 {
if self.wide { 2 } else { 1 }
}
}
/// Geometry-only cell run (selection highlight).
pub struct CellRun {
pub row: u16,
pub start: u16,
pub end: u16,
}
/// Background run with its unresolved color.
pub struct BgRun {
pub row: u16,
pub start: u16,
pub end: u16,
pub color: Color,
}
pub struct CursorSnapshot {
pub row: u16,
pub col: u16,
pub shape: CursorShape,
/// Cursor sits on a wide char: draw it two cells wide.
pub wide: bool,
}
/// Everything a frontend needs to paint one frame, as plain data. Built in a
/// single pass under the `Term` lock; owning no references, it lets the lock
/// drop before any painting or color resolution happens.
pub struct RenderSnapshot {
pub cols: u16,
pub rows: u16,
pub display_offset: usize,
/// OSC 4/10/11… override table copied out of the terminal.
pub color_overrides: Colors,
pub bg_runs: Vec<BgRun>,
pub selection_runs: Vec<CellRun>,
pub segments: Vec<TextSegment>,
pub cursor: Option<CursorSnapshot>,
}
pub struct SnapshotConfig {
pub rows: u16,
pub cols: u16,
/// Force the cursor cell into its own single-cell segment so the
/// frontend can recolor it (focused block cursor draws inverted).
pub isolate_cursor_cell: bool,
}
impl RenderSnapshot {
pub fn capture<T: EventListener>(term: &Term<T>, cfg: &SnapshotConfig) -> Self {
let rows = cfg.rows as usize;
let cols = cfg.cols as usize;
let content = term.renderable_content_with_viewport(rows, cols);
let display_offset = content.display_offset;
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 = Self {
cols: cfg.cols,
rows: cfg.rows,
display_offset,
color_overrides: *content.colors,
bg_runs: Vec::new(),
selection_runs: Vec::new(),
segments: Vec::new(),
cursor: None,
};
fn push_bg(runs: &mut Vec<BgRun>, row: u16, col: u16, color: Color) {
if let Some(last) = runs.last_mut() {
if last.row == row && last.end == col && last.color == color {
last.end = col + 1;
return;
}
}
runs.push(BgRun { row, start: col, end: col + 1, color });
}
fn push_sel(runs: &mut Vec<CellRun>, row: u16, col: u16) {
if let Some(last) = runs.last_mut() {
if last.row == row && last.end == col {
last.end = col + 1;
return;
}
}
runs.push(CellRun { row, start: col, end: col + 1 });
}
// Segment assembly state: (row, wide, next expected col).
let mut open: Option<TextSegment> = None;
let flush = |segments: &mut Vec<TextSegment>, open: &mut Option<TextSegment>| {
if let Some(seg) = open.take() {
segments.push(seg);
}
};
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 (row, col) = (row as u16, col as u16);
let flags = indexed.cell.flags;
let bold = flags.intersects(Flags::BOLD);
let mut fg = indexed.cell.fg;
let mut bg = indexed.cell.bg;
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
// Default background is suppressed at the source; anything else
// is emitted and resolved by the frontend.
if bg != Color::Named(NamedColor::Background) {
push_bg(&mut snap.bg_runs, row, col, bg);
}
if selection_range.is_some_and(|range| range.contains(indexed.point)) {
push_sel(&mut snap.selection_runs, row, col);
}
if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
|| flags.contains(Flags::HIDDEN)
{
continue;
}
let at_cursor = cfg.isolate_cursor_cell
&& cursor_vp.is_some_and(|c| c.line == row as usize && c.column.0 == col as usize);
let c = indexed.cell.c;
if c == ' '
&& indexed.cell.extra.is_none()
&& !flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
&& !at_cursor
{
continue;
}
let mut text = String::new();
text.push(c);
if let Some(zerowidth) = indexed.cell.zerowidth() {
text.extend(zerowidth);
}
let wide = flags.contains(Flags::WIDE_CHAR);
let cell = SnapCell {
col,
text,
fg,
bold,
italic: flags.intersects(Flags::ITALIC),
underline: flags.intersects(Flags::ALL_UNDERLINES),
strikethrough: flags.contains(Flags::STRIKEOUT),
};
let continues = open.as_ref().is_some_and(|seg| {
seg.row == row
&& seg.wide == wide
&& seg.start_col + seg.step() * seg.cells.len() as u16 == col
});
if !continues || at_cursor {
flush(&mut snap.segments, &mut open);
}
match &mut open {
Some(seg) => seg.cells.push(cell),
None => {
open = Some(TextSegment { row, start_col: col, wide, cells: vec![cell] });
},
}
if at_cursor {
flush(&mut snap.segments, &mut open);
}
}
flush(&mut snap.segments, &mut open);
if let Some(vp) = cursor_vp {
if vp.line < rows && vp.column.0 < cols && cursor_shape != CursorShape::Hidden {
let wide =
term.grid()[content.cursor.point].flags.contains(Flags::WIDE_CHAR);
snap.cursor = Some(CursorSnapshot {
row: vp.line as u16,
col: vp.column.0 as u16,
shape: cursor_shape,
wide,
});
}
}
snap
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::VoidListener;
use crate::grid::Dimensions;
use crate::index::{Column, Line};
use crate::term::Config;
struct TestSize {
cols: usize,
rows: usize,
}
impl Dimensions for TestSize {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
fn metrics() -> CellMetrics {
CellMetrics { cell_width: 9.0, cell_height: 18.0, scale: 2.0 }
}
#[test]
fn viewport_floor_division_and_clamps() {
let vp = TerminalViewport::from_content_size(93.0, 40.0, &metrics(), 1);
assert_eq!((vp.cols, vp.rows), (10, 2));
// Below one cell: clamps to the minimum grid instead of zero.
let vp = TerminalViewport::from_content_size(3.0, 3.0, &metrics(), 2);
assert_eq!((vp.cols, vp.rows), (MIN_COLS, MIN_ROWS));
}
#[test]
fn viewport_reports_exact_text_area_pixels() {
let vp = TerminalViewport::from_content_size(95.0, 41.0, &metrics(), 1);
// 95/9 → 10 cols; device cell = 18px → 180, never the leftover 190.
assert_eq!(vp.text_area_width_px(), u32::from(vp.cols) * 18);
assert_eq!(vp.window_size().cell_width, 18);
assert_eq!(vp.window_size().cell_height, 36);
}
#[test]
fn tracker_coalesces_and_advances_revision() {
let mut tracker = ViewportTracker::default();
let first = tracker.observe(900.0, 360.0, &metrics()).expect("initial viewport");
assert!(first.grid_changed && first.pixel_changed);
// Sub-cell jitter: same grid, same pixels → no event.
assert!(tracker.observe(902.0, 361.0, &metrics()).is_none());
// One more column: grid change, revision strictly increases.
let second = tracker.observe(911.0, 360.0, &metrics()).expect("grid change");
assert!(second.grid_changed);
assert!(second.viewport.revision > first.viewport.revision);
// Same grid but larger glyphs (font size change on a fluke-equal
// grid): pixel change alone must still be reported (Ghostty rule).
let mut larger = metrics();
larger.scale = 3.0;
let third = tracker
.observe(911.0 / 9.0 * 9.0, 360.0, &larger)
.expect("pixel change");
assert!(third.pixel_changed);
}
fn term_with(content: &[&str]) -> Term<VoidListener> {
let size = TestSize { cols: 8, rows: 4 };
let mut term = Term::new(Config::default(), &size, VoidListener);
for (line, text) in content.iter().enumerate() {
let mut col = 0usize;
for ch in text.chars() {
let line = Line(line as i32);
let cell = &mut term.grid_mut()[line][Column(col)];
cell.c = ch;
if unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) == 2 {
cell.flags.insert(Flags::WIDE_CHAR);
col += 1;
term.grid_mut()[line][Column(col)].flags.insert(Flags::WIDE_CHAR_SPACER);
}
col += 1;
}
}
term
}
fn cfg(rows: u16, cols: u16) -> SnapshotConfig {
SnapshotConfig { rows, cols, isolate_cursor_cell: false }
}
#[test]
fn capture_splits_segments_on_width_class() {
let term = term_with(&["ab中c"]);
let snap = RenderSnapshot::capture(&term, &cfg(4, 8));
let rows: Vec<_> =
snap.segments.iter().map(|s| (s.row, s.start_col, s.wide, s.cells.len())).collect();
// "ab" narrow at col 0, "中" wide at col 2 (spacer col 3 skipped),
// "c" narrow resumes at col 4.
assert_eq!(rows, vec![(0, 0, false, 2), (0, 2, true, 1), (0, 4, false, 1)]);
}
#[test]
fn capture_isolates_cursor_cell_for_inversion() {
let mut term = term_with(&["abc"]);
term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(1));
let snap = RenderSnapshot::capture(
&term,
&SnapshotConfig { rows: 4, cols: 8, isolate_cursor_cell: true },
);
let shape: Vec<_> =
snap.segments.iter().map(|s| (s.start_col, s.cells.len())).collect();
assert_eq!(shape, vec![(0, 1), (1, 1), (2, 1)]);
let cursor = snap.cursor.expect("cursor visible");
assert_eq!((cursor.row, cursor.col, cursor.wide), (0, 1, false));
}
#[test]
fn capture_marks_wide_cursor() {
let mut term = term_with(&["中"]);
term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0));
let snap = RenderSnapshot::capture(&term, &cfg(4, 8));
assert!(snap.cursor.expect("cursor").wide);
}
}