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:
1 parent
65794784a9
commit
c520bc6de2
4 files changed
+685
-250
No files matched your search
+109
-223
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user