diff --git a/nebula_gpui/src/terminal/element.rs b/nebula_gpui/src/terminal/element.rs index 1c2dad0..0f4ce98 100644 --- a/nebula_gpui/src/terminal/element.rs +++ b/nebula_gpui/src/terminal/element.rs @@ -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>, - bgs: Vec, - selection: Vec, - cursor: Option, -} - -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 { + /// 在一次锁内通过渲染合同截取快照;锁外解析颜色与绘制。 + fn snapshot(&self, rows: usize, cols: usize, focused: bool, cx: &App) -> Option { 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, 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 { 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 = 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 = 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); } } diff --git a/nebula_gpui/src/terminal/view.rs b/nebula_gpui/src/terminal/view.rs index 1b4dcc1..40fad41 100644 --- a/nebula_gpui/src/terminal/view.rs +++ b/nebula_gpui/src/terminal/view.rs @@ -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, pub marked_text: Option, pub ime_bounds: Bounds, 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 { - let mono = |weight: FontWeight, style: FontStyle| Font { + // 字体与调色板来自用户 nebula.toml(bootstrap 时装载为全局 Settings)。 + let (families, font_size, palette) = match cx.try_global::() { + 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, cell_width: Pixels, line_height: Pixels, - cols: usize, - rows: usize, + content: Size, 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)) diff --git a/nebula_terminal/src/lib.rs b/nebula_terminal/src/lib.rs index 2a3b00d..4c2b496 100644 --- a/nebula_terminal/src/lib.rs +++ b/nebula_terminal/src/lib.rs @@ -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; diff --git a/nebula_terminal/src/render.rs b/nebula_terminal/src/render.rs new file mode 100644 index 0000000..569b29f --- /dev/null +++ b/nebula_terminal/src/render.rs @@ -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, + 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 { + 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, +} + +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, + pub selection_runs: Vec, + pub segments: Vec, + pub cursor: Option, +} + +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(term: &Term, 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, 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, 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 = None; + let flush = |segments: &mut Vec, open: &mut Option| { + 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 { + 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); + } +}