diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yaml b/.github/ISSUE_TEMPLATE/10_bug_report.yaml index 6e1f97d..84c95ee 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yaml @@ -1,7 +1,7 @@ name: Bug report title: "bug: " description: Problems and issues with code of OTTY -labels: [ "C-bug" ] +labels: ["bug"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/20_feature_request.yaml b/.github/ISSUE_TEMPLATE/20_feature_request.yaml index 3106780..41c4bd7 100644 --- a/.github/ISSUE_TEMPLATE/20_feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/20_feature_request.yaml @@ -1,6 +1,6 @@ name: Feature request description: Suggest an idea for OTTY -labels: ["C-feature"] +labels: ["enhancement"] body: - type: markdown attributes: diff --git a/.github/workflows/audit.yaml b/.github/workflows/audit.yaml index e50f2c5..9088b19 100644 --- a/.github/workflows/audit.yaml +++ b/.github/workflows/audit.yaml @@ -1,7 +1,10 @@ name: Security Audit on: + pull_request: push: + branches: + - main schedule: - cron: '0 2 * * *' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..595da27 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +minimum_pre_commit_version: "3.0.0" + +repos: + - repo: local + hooks: + - id: fmt-check + name: formatter check + entry: cargo +nightly fmt --check + language: system + pass_filenames: false + always_run: true + - id: clippy + name: clippy check + entry: cargo clippy --workspace --all-targets --all-features -- -D warnings + language: system + pass_filenames: false + always_run: true + - id: lint + name: lint + entry: cargo lint + language: system + pass_filenames: false + always_run: true + - id: deny-check + name: cargo deny check + entry: cargo deny check + language: system + pass_filenames: false + always_run: true + - id: tests + name: Run tests + entry: cargo test --workspace --all-features + language: system + pass_filenames: false + always_run: true diff --git a/AGENTS.md b/AGENTS.md index 1459e9b..d0b7adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,23 @@ General context lives in [README.md](./README.md) at the repository root. -## Development workflow +## Rules -- Crate names stay prefixed with `otty-`. +- You MUST write the tests before writting the implementation. +- You MUST write tests only for business-significant packages such as usecases, repositories, helpers, and domain logic. Do not add tests for infrastructure/bootstrap packages such as lifecycle, config, metrics, logging, or server wiring unless they contain business-significant behavior. +- You MUST use `mockall` for mocks in RUST code +- You MUST ask me before installing the new dependencies with dependency description and reason. +- You MUST prefer simple, direct, readable code with explicit business logic. Avoid clever generics, macros, type gymnastics, and dense control flow when straightforward code is easier to understand. +- You MUST keep each source file focused on one cohesive responsibility. When one file combines multiple independent responsibilities or becomes difficult to navigate, split it into clearly named modules and files, with each file owning one responsibility. Do not split tightly coupled logic solely because of line count, and do not introduce empty, pass-through, or speculative modules. +- Rust modules MUST be organized in this order: imports; structs with their implementations; public functions; private functions; tests. Each struct declaration MUST be followed immediately by its related implementations before declaring the next struct. Within a struct's implementations, use this order: getters; constructors and other logic methods; `#[cfg(test)]` implementations; trait implementations. Use `otty-ui/terminal/src/render_runs.rs` and `otty-ui/terminal/src/shaped_text.rs` as good examples of this layout. +- You MUST separate distinct logical phases inside Rust functions with a single blank line, including input preparation, validation or branching, external or repository I/O, state changes, and result construction. Keep statements that form one tightly coupled operation together; do not add a blank line after every statement mechanically. +- You MUST NOT create abstractions by default. Every new trait, interface, layer, factory, manager, service, or extension point MUST solve a current problem and be briefly justified. "Maybe useful later" is not a valid justification; use a concrete implementation or private function instead. +- You MAY introduce an abstraction only when it has multiple real implementations, crosses an actual infrastructure boundary, protects domain or usecase code from infrastructure, removes duplication with the same business meaning and reason to change, or makes testing significantly simpler without hiding logic. +- You MUST prefer meaningful domain names over generic names such as `Manager`, `Processor`, `Helper`, `Service`, or `Util`. Split functions, files, and layers only when doing so improves the current design and readability. +- You MUST apply DRY only when duplicated logic has the same business meaning and changes for the same reason. Duplication is acceptable when extraction would create a vague or harder-to-read abstraction, and speculative traits, configuration, factories, placeholder layers, and unused extension points are forbidden by YAGNI. +- crate names MUST stay prefixed with `otty-`. - Prefer `format!("{value}")`-style interpolation instead of passing variables as separate arguments when formatting strings. -- Add concise documentation comments to new public items to communicate intent. +- You MUST add concise documentation comments to new public items to communicate intent. - Prefer borrowing over cloning; pass `&T`/`&str` where possible and keep ownership at boundaries. - Avoid unnecessary heap allocations; use slices and references for read-only data. - Use `Result`/`Option` for error handling; no `unwrap()` in production code (prefer `expect()` with context during initialization). @@ -15,6 +27,7 @@ General context lives in [README.md](./README.md) at the repository root. - Do not expose struct fields as `pub`; use idiomatic Rust accessors for reads (`field()` or `is_*` for booleans), and prefer domain-specific mutators for writes (use `set_*` only when a generic setter is the clearest option, or keep mutation local to the module). Exception: plain input/context structs with no invariants to protect (e.g. feature `Ctx` types passed into `reduce`) MAY use `pub(crate)` fields directly — accessors would be unnecessary boilerplate for parameter bags. - For `match` on `enum`, prefer a wildcard arm (`_ => ...`) by default for fallback logic. - Document public items with concise doc comments and examples. +- You MUST run all linters, checks and tests before finishing your work. - Run `cargo +nightly fmt`, `cargo clippy --workspace --all-targets --all-features -- -D warnings` and fix all errors and warnings. - Run `cargo deny check` and fix all output errors. - Run `cargo test --workspace --all-features` all tests MUST be passed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b750eb7..d6e88ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,10 @@ - Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (feat:, fix:, docs:, chore:, refactor:, perf:, test:, build:) - Use [AGENTS.md](./AGENTS.md) for enriching LLM context +#### Repository setup + +- Always run `pre-commit install` when setting up the repository so local commits run the configured checks. + #### Quick start - Run the desktop app: `cargo run -p otty` diff --git a/Cargo.lock b/Cargo.lock index 9c033cf..fea2061 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3337,9 +3337,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -4609,9 +4609,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", "quick-xml", diff --git a/deny.toml b/deny.toml index a038824..7989d96 100644 --- a/deny.toml +++ b/deny.toml @@ -2,6 +2,10 @@ unsound = "workspace" unmaintained = "workspace" yanked = "warn" +ignore = [ + { id = "RUSTSEC-2026-0194", reason = "wayland-scanner 0.31.10 is the latest compatible release and still constrains quick-xml to ^0.39; remove once the iced Wayland stack can use quick-xml >=0.41" }, + { id = "RUSTSEC-2026-0195", reason = "wayland-scanner 0.31.10 is the latest compatible release and still constrains quick-xml to ^0.39; remove once the iced Wayland stack can use quick-xml >=0.41" }, +] [licenses] allow = [ diff --git a/otty-libterm/src/terminal/mod.rs b/otty-libterm/src/terminal/mod.rs index 4972178..d4d6450 100644 --- a/otty-libterm/src/terminal/mod.rs +++ b/otty-libterm/src/terminal/mod.rs @@ -817,6 +817,47 @@ mod tests { Ok(()) } + #[test] + fn parses_hebrew_niqqud_into_zero_width_cells() -> anyhow::Result<()> { + let text = "ב\u{05b0}\u{05bc}ר\u{05b5}אש\u{05b4}\u{05c1}ית"; + let session = FakeSession::with_reads(vec![text.as_bytes().to_vec()]); + let parser = DefaultParser::default(); + let surface = + Surface::new(SurfaceConfig::default(), &TerminalSize::default()); + let (mut engine, _handle, events) = TerminalEngine::new( + session, + parser, + surface, + TerminalOptions::default(), + )?; + + engine.on_readable()?; + + let collected = collect_events(&events); + let frame = match collected.last() { + Some(TerminalEvent::Frame { frame }) => frame, + _ => panic!("expected frame event last"), + }; + let view = frame.view(); + let visible_text = view + .cells + .iter() + .filter(|cell| cell.cell.c != ' ') + .map(|cell| { + let mut text = cell.cell.c.to_string(); + if let Some(zerowidth) = cell.cell.zerowidth() { + text.extend(zerowidth.iter()); + } + text + }) + .collect::>() + .join(""); + + assert_eq!(visible_text, text); + + Ok(()) + } + #[test] fn propagates_action_events_before_frame_delivery() -> anyhow::Result<()> { let actions = vec![ diff --git a/otty-surface/src/snapshot.rs b/otty-surface/src/snapshot.rs index d8e7bc2..f6fcd95 100644 --- a/otty-surface/src/snapshot.rs +++ b/otty-surface/src/snapshot.rs @@ -259,6 +259,9 @@ impl<'a> SnapshotView<'a> { for indexed in self.cells { if range.contains(indexed.point) { result.push(indexed.cell.c); + if let Some(zerowidth) = indexed.cell.zerowidth() { + result.extend(zerowidth.iter()); + } } } } @@ -290,7 +293,7 @@ mod tests { use super::*; use crate::actor::SurfaceActor; use crate::cell::Hyperlink; - use crate::index::{Column, Line}; + use crate::index::{Column, Line, Side}; use crate::selection::SelectionType; use crate::{ SnapshotDamage, SnapshotView, Surface, SurfaceConfig, SurfaceModel, @@ -360,14 +363,10 @@ mod tests { surface.reset_damage(); let start = Point::new(crate::index::Line(0), crate::index::Column(0)); - surface.start_selection( - SelectionType::Simple, - start, - crate::index::Side::Left, - ); + surface.start_selection(SelectionType::Simple, start, Side::Left); surface.update_selection( Point::new(crate::index::Line(0), crate::index::Column(1)), - crate::index::Side::Right, + Side::Right, ); let frame = surface.snapshot_owned(); @@ -377,6 +376,34 @@ mod tests { assert_eq!(view.cursor.point, surface.grid().cursor.point); } + #[test] + fn selectable_content_preserves_zero_width_marks() { + let mut surface = + Surface::new(SurfaceConfig::default(), &TestDimensions::new(40, 2)); + let text = "ที่นี่, น้ำ, or กำลัง"; + for ch in text.chars() { + surface.print(ch); + } + + let has_zero_width_marks = surface.grid().display_iter().any(|cell| { + cell.cell.zerowidth().is_some_and(|marks| !marks.is_empty()) + }); + assert!(has_zero_width_marks); + + surface.start_selection( + SelectionType::Simple, + Point::new(Line(0), Column(0)), + Side::Left, + ); + let end_column = surface.grid().cursor.point.column - 1; + surface.update_selection(Point::new(Line(0), end_column), Side::Right); + + let frame = surface.snapshot_owned(); + let view = frame.view(); + + assert_eq!(view.selectable_content(), text); + } + #[test] fn osc_hyperlink_is_exposed_in_snapshot() { let mut surface = diff --git a/otty-ui/terminal/src/input.rs b/otty-ui/terminal/src/input.rs index 01331fe..e31c1f5 100644 --- a/otty-ui/terminal/src/input.rs +++ b/otty-ui/terminal/src/input.rs @@ -90,11 +90,11 @@ impl<'a> InputManager<'a> { publisher: &mut impl FnMut(crate::Event), ) -> iced::event::Status { state.selection_in_progress = false; - let cmd = if terminal_state + let is_mouse_mode = terminal_state .view() .mode - .intersects(SurfaceMode::MOUSE_MODE) - { + .intersects(SurfaceMode::MOUSE_MODE); + let cmd = if is_mouse_mode { crate::Event::MouseReport { id: self.terminal_id, button: MouseButton::LeftButton, @@ -124,6 +124,11 @@ impl<'a> InputManager<'a> { } }; publisher(cmd); + if !is_mouse_mode { + publisher(crate::Event::Redraw { + id: self.terminal_id, + }); + } state.is_dragged = true; iced::event::Status::Captured } @@ -183,6 +188,9 @@ impl<'a> InputManager<'a> { id: self.terminal_id, position: (cursor_x, cursor_y), }); + publisher(crate::Event::Redraw { + id: self.terminal_id, + }); return iced::event::Status::Captured; } else if !in_alt_screen { let hovered_span_id = terminal_state @@ -619,7 +627,7 @@ mod tests { &mut publish, ); - assert_eq!(commands.len(), 1); + assert_eq!(commands.len(), 2); assert!(matches!( commands[0], crate::Event::SelectStart { @@ -628,6 +636,7 @@ mod tests { position: (150.0, 100.0) } )); + assert!(matches!(commands[1], crate::Event::Redraw { .. })); assert!(state.is_dragged); } } @@ -721,7 +730,7 @@ mod tests { &mut publish, ); - assert_eq!(commands.len(), 1); + assert_eq!(commands.len(), 2); assert!(matches!( commands[0], crate::Event::SelectUpdate { @@ -729,6 +738,7 @@ mod tests { position: (95.0, 145.0) } )); + assert!(matches!(commands[1], crate::Event::Redraw { .. })); } #[test] @@ -758,7 +768,7 @@ mod tests { &mut publish, ); - assert_eq!(commands.len(), 1); + assert_eq!(commands.len(), 2); assert!(matches!( commands[0], crate::Event::SelectUpdate { @@ -766,6 +776,7 @@ mod tests { position: (95.0, 145.0) } )); + assert!(matches!(commands[1], crate::Event::Redraw { .. })); assert!(state.selection_in_progress); } @@ -801,7 +812,7 @@ mod tests { assert!(state.selection_in_progress); assert!(state.selected_block_id.is_none()); assert!(state.selected_block_kind.is_none()); - assert_eq!(commands.len(), 3); + assert_eq!(commands.len(), 4); assert!(matches!( commands[0], crate::Event::BlockSelectionCleared { .. } @@ -814,6 +825,7 @@ mod tests { position: (95.0, 145.0) } )); + assert!(matches!(commands[3], crate::Event::Redraw { .. })); } } diff --git a/otty-ui/terminal/src/lib.rs b/otty-ui/terminal/src/lib.rs index 33fe330..9824475 100644 --- a/otty-ui/terminal/src/lib.rs +++ b/otty-ui/terminal/src/lib.rs @@ -7,6 +7,8 @@ mod engine; mod error; mod font; mod input; +mod render_runs; +mod shaped_text; mod term; mod theme; mod view; diff --git a/otty-ui/terminal/src/render_runs.rs b/otty-ui/terminal/src/render_runs.rs new file mode 100644 index 0000000..ad14fe6 --- /dev/null +++ b/otty-ui/terminal/src/render_runs.rs @@ -0,0 +1,729 @@ +//! Builds shape-ready text runs from terminal snapshot cells. +//! +//! The renderer needs larger text runs for correct shaping of complex scripts, +//! but terminal cells still define grid columns, colors, selection, cursor text, +//! and hyperlink state. This module groups contiguous cells that can be shaped +//! together while preserving per-cell foreground spans for drawing. + +use std::ops::Range; + +use iced::font::{Style as FontStyle, Weight as FontWeight}; +use iced::{Color, Font}; +use otty_libterm::surface::{ + Flags, Point as TerminalPoint, SelectionRange, SnapshotCell, SnapshotView, +}; + +use crate::theme::Theme; + +/// Shape-ready terminal text with grid position and foreground spans. +#[derive(Debug, Default, Clone, PartialEq)] +pub(crate) struct RenderRun { + text: String, + line: i32, + start_column: usize, + cell_columns: usize, + font: Font, + color_spans: Vec, +} + +impl RenderRun { + pub(crate) fn text(&self) -> &str { + &self.text + } + + pub(crate) fn line(&self) -> i32 { + self.line + } + + pub(crate) fn start_column(&self) -> usize { + self.start_column + } + + pub(crate) fn cell_columns(&self) -> usize { + self.cell_columns + } + + pub(crate) fn font(&self) -> Font { + self.font + } + + pub(crate) fn color_spans(&self) -> &[RenderTextSpan] { + &self.color_spans + } + + pub(crate) fn fallback_foreground(&self) -> Color { + self.color_spans + .first() + .map_or(Color::TRANSPARENT, RenderTextSpan::foreground) + } +} + +impl RenderRun { + fn new(line: i32, start_column: usize, font: Font) -> Self { + Self { + text: String::new(), + line, + start_column, + font, + ..Default::default() + } + } + + fn append_cell( + &mut self, + indexed: &SnapshotCell, + cell_columns: usize, + ) -> (Range, usize) { + let byte_start = self.text.len(); + let span_start_column = self.cell_columns; + self.text.push(indexed.cell.c); + + if let Some(zerowidth) = indexed.cell.zerowidth() { + self.text.extend(zerowidth.iter()); + } + + self.cell_columns += cell_columns; + (byte_start..self.text.len(), span_start_column) + } + + fn append_color_span( + &mut self, + byte_range: Range, + start_column: usize, + cell_columns: usize, + foreground: Color, + ) { + if byte_range.is_empty() { + return; + }; + + if let Some(span) = self.color_spans.last_mut() + && span.foreground == foreground + && span.byte_range.end == byte_range.start + && span.start_column + span.cell_columns == start_column + { + span.byte_range.end = byte_range.end; + span.cell_columns += cell_columns; + return; + } + + self.color_spans.push(RenderTextSpan { + byte_range, + start_column, + cell_columns, + foreground, + }); + } + + fn end_column(&self) -> usize { + self.start_column + self.cell_columns + } +} + +#[cfg(test)] +impl RenderRun { + pub(crate) fn new_empty_color_spans( + text: &str, + line: i32, + start_column: usize, + cell_columns: usize, + style: RenderTextStyle, + ) -> Self { + Self { + text: text.to_string(), + line, + start_column, + cell_columns, + font: style.font, + color_spans: (!text.is_empty()) + .then_some(RenderTextSpan { + byte_range: 0..text.len(), + start_column: 0, + cell_columns, + foreground: style.foreground, + }) + .into_iter() + .collect(), + } + } + + pub(crate) fn new_with_color_spans( + text: &str, + line: i32, + start_column: usize, + cell_columns: usize, + font: Font, + color_spans: Vec<(Range, usize, usize, Color)>, + ) -> Self { + Self { + text: text.to_string(), + line, + start_column, + cell_columns, + font, + color_spans: color_spans + .into_iter() + .map(|(byte_range, start_column, cell_columns, foreground)| { + RenderTextSpan { + byte_range, + start_column, + cell_columns, + foreground, + } + }) + .collect(), + } + } +} + +/// Foreground color range within a [`RenderRun`]. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RenderTextSpan { + byte_range: Range, + start_column: usize, + cell_columns: usize, + foreground: Color, +} + +impl RenderTextSpan { + pub(crate) fn byte_range(&self) -> &Range { + &self.byte_range + } + + pub(crate) fn foreground(&self) -> Color { + self.foreground + } +} + +/// Resolved text style for one terminal cell during run construction. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub(crate) struct RenderTextStyle { + foreground: Color, + font: Font, + selected: bool, + inverse: bool, + cursor_override: bool, + hovered_hyperlink: bool, +} + +#[cfg(test)] +impl RenderTextStyle { + pub(crate) fn from_fg_and_font(foreground: Color, font: Font) -> Self { + Self { + foreground, + font, + ..Default::default() + } + } +} + +struct RenderRunBuildContext<'a> { + selection: Option<&'a SelectionRange>, + cursor_point: TerminalPoint, + theme: &'a Theme, + base_font: Font, + hovered_span_id: Option, + cursor_text_override: bool, +} + +/// Build shape-ready text runs for the current terminal snapshot view. +pub(crate) fn build_render_runs( + view: &SnapshotView<'_>, + theme: &Theme, + base_font: Font, + hovered_span_id: Option, + cursor_text_override: bool, +) -> Vec { + let context = RenderRunBuildContext { + selection: view.selection, + cursor_point: view.cursor.point, + theme, + base_font, + hovered_span_id, + cursor_text_override, + }; + + build_render_runs_from_cells(view.cells, &context, |point| { + view.hyperlink_span_id_at(point) + }) +} + +fn build_render_runs_from_cells( + cells: &[SnapshotCell], + context: &RenderRunBuildContext<'_>, + span_id_at: F, +) -> Vec +where + F: Fn(TerminalPoint) -> Option, +{ + let mut runs = Vec::new(); + let mut current: Option = None; + + for indexed in cells { + let flags = indexed.cell.flags; + if flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + if indexed.cell.c == '\t' { + flush_current_run(&mut runs, &mut current); + continue; + } + + let point = indexed.point; + let line = point.line.0; + let column = point.column.0; + let cell_columns = if flags.contains(Flags::WIDE_CHAR) { + 2 + } else { + 1 + }; + let style = resolve_text_style(indexed, context, &span_id_at); + let requires_isolated_run = requires_grid_isolated_run(indexed); + let can_extend = current.as_ref().is_some_and(|run| { + !requires_isolated_run + && run.line == line + && run.end_column() == column + && run.font == style.font + }); + + if !can_extend { + flush_current_run(&mut runs, &mut current); + } + + let is_renderable = is_renderable_text_cell(indexed); + if !is_renderable && current.is_none() { + continue; + } + + let run = current + .get_or_insert_with(|| RenderRun::new(line, column, style.font)); + let (byte_range, span_start_column) = + run.append_cell(indexed, cell_columns); + run.append_color_span( + byte_range, + span_start_column, + cell_columns, + style.foreground, + ); + + if requires_isolated_run { + flush_current_run(&mut runs, &mut current); + } + } + + flush_current_run(&mut runs, &mut current); + runs +} + +fn flush_current_run( + runs: &mut Vec, + current: &mut Option, +) { + let Some(run) = current.take() else { + return; + }; + + if !run.text.is_empty() { + runs.push(run); + } +} + +fn is_renderable_text_cell(indexed: &SnapshotCell) -> bool { + indexed.cell.c != ' ' + || indexed + .cell + .zerowidth() + .is_some_and(|zerowidth| !zerowidth.is_empty()) +} + +fn requires_grid_isolated_run(indexed: &SnapshotCell) -> bool { + indexed.cell.c.len_utf8() > 1 + || indexed + .cell + .zerowidth() + .is_some_and(|zerowidth| !zerowidth.is_empty()) +} + +fn resolve_text_style( + indexed: &SnapshotCell, + context: &RenderRunBuildContext<'_>, + span_id_at: &F, +) -> RenderTextStyle +where + F: Fn(TerminalPoint) -> Option, +{ + let flags = indexed.cell.flags; + let is_inverse = flags.contains(Flags::INVERSE); + let is_dim = flags.intersects(Flags::DIM | Flags::DIM_BOLD); + let selected = context + .selection + .is_some_and(|range| range.contains(indexed.point)); + let hovered_hyperlink = context + .hovered_span_id + .is_some_and(|target| span_id_at(indexed.point) == Some(target)); + let cursor_override = + context.cursor_text_override && indexed.point == context.cursor_point; + + let mut foreground = context.theme.get_color(indexed.cell.fg); + let mut background = context.theme.get_color(indexed.cell.bg); + if is_dim { + foreground.a *= 0.7; + } + if is_inverse || selected { + std::mem::swap(&mut foreground, &mut background); + } + if cursor_override { + foreground = background; + } + + let mut font = context.base_font; + if flags.intersects(Flags::BOLD | Flags::DIM_BOLD) { + font.weight = FontWeight::Bold; + } + if flags.contains(Flags::ITALIC) { + font.style = FontStyle::Italic; + } + + RenderTextStyle { + foreground, + font, + selected, + inverse: is_inverse, + cursor_override, + hovered_hyperlink, + } +} + +#[cfg(test)] +mod tests { + use iced::font::{Style as FontStyle, Weight as FontWeight}; + use otty_libterm::escape::{Color as AnsiColor, StdColor}; + use otty_libterm::surface::{Cell, Column, Line}; + + use super::*; + + fn cell(line: i32, column: usize, c: char) -> SnapshotCell { + SnapshotCell { + point: TerminalPoint::new(Line(line), Column(column)), + cell: Cell { + c, + ..Cell::default() + }, + } + } + + fn cells_from_text(line: i32, text: &str) -> Vec { + text.chars() + .enumerate() + .map(|(column, c)| cell(line, column, c)) + .collect() + } + + fn cells_from_clusters( + line: i32, + clusters: &[(char, &[char])], + ) -> Vec { + clusters + .iter() + .enumerate() + .map(|(column, (base, marks))| { + let mut snapshot_cell = cell(line, column, *base); + for mark in *marks { + snapshot_cell.cell.push_zerowidth(*mark); + } + snapshot_cell + }) + .collect() + } + + fn build(cells: &[SnapshotCell]) -> Vec { + let theme = Theme::default(); + let context = RenderRunBuildContext { + selection: None, + cursor_point: TerminalPoint::default(), + theme: &theme, + base_font: Font::MONOSPACE, + hovered_span_id: None, + cursor_text_override: false, + }; + + build_render_runs_from_cells(cells, &context, |_| None) + } + + #[test] + fn preserves_combining_marks_in_cell_clusters() { + type Cluster<'a> = (char, &'a [char]); + type Sample<'a> = (&'a str, &'a [Cluster<'a>]); + + let samples: [Sample<'_>; 7] = [ + ("e\u{0301}", &[('e', &['\u{0301}'])]), + ( + "ที่นี่", + &[ + ('ท', &['\u{0e35}', '\u{0e48}']), + ('น', &['\u{0e35}', '\u{0e48}']), + ], + ), + ("น้ำ", &[('น', &['\u{0e49}']), ('\u{0e33}', &[])]), + ( + "กำลัง", + &[ + ('ก', &[]), + ('\u{0e33}', &[]), + ('ล', &['\u{0e31}']), + ('ง', &[]), + ], + ), + ("ນ້ຳ", &[('ນ', &['\u{0ec9}']), ('\u{0eb3}', &[])]), + ("مُ", &[('م', &['\u{064f}'])]), + ("שׁ", &[('ש', &['\u{05c1}'])]), + ]; + + for (expected, clusters) in samples { + let cells = cells_from_clusters(0, clusters); + let runs = build(&cells); + let rendered_text = runs + .iter() + .map(RenderRun::text) + .collect::>() + .join(""); + let cell_columns = + runs.iter().map(RenderRun::cell_columns).sum::(); + + assert_eq!(rendered_text, expected); + assert_eq!(cell_columns, clusters.len()); + } + } + + #[test] + fn preserves_hebrew_niqqud_in_render_runs() { + let cells = cells_from_clusters( + 0, + &[ + ('ב', &['\u{05b0}', '\u{05bc}']), + ('ר', &['\u{05b5}']), + ('א', &[]), + ('ש', &['\u{05b4}', '\u{05c1}']), + ('י', &[]), + ('ת', &[]), + ], + ); + + let runs = build(&cells); + let rendered_text = runs + .iter() + .map(RenderRun::text) + .collect::>() + .join(""); + let cell_columns = + runs.iter().map(RenderRun::cell_columns).sum::(); + + assert_eq!( + rendered_text, + "ב\u{05b0}\u{05bc}ר\u{05b5}אש\u{05b4}\u{05c1}ית" + ); + assert_eq!(cell_columns, 6); + } + + #[test] + fn builds_contiguous_runs_with_grid_column_accounting() { + let cells = cells_from_text(0, "ab cd"); + + let runs = build(&cells); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "ab cd"); + assert_eq!(runs[0].line(), 0); + assert_eq!(runs[0].start_column(), 0); + assert_eq!(runs[0].cell_columns(), 5); + } + + #[test] + fn skips_leading_spaces_and_keeps_start_column_aligned() { + let cells = cells_from_text(0, " ab"); + + let runs = build(&cells); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "ab"); + assert_eq!(runs[0].start_column(), 2); + assert_eq!(runs[0].cell_columns(), 2); + } + + #[test] + fn wide_char_spacer_does_not_emit_a_glyph() { + let mut wide = cell(0, 0, '界'); + wide.cell.flags.insert(Flags::WIDE_CHAR); + let mut spacer = cell(0, 1, ' '); + spacer.cell.flags.insert(Flags::WIDE_CHAR_SPACER); + let next = cell(0, 2, 'x'); + + let runs = build(&[wide, spacer, next]); + + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].text(), "界"); + assert_eq!(runs[0].start_column(), 0); + assert_eq!(runs[0].cell_columns(), 2); + assert_eq!(runs[1].text(), "x"); + assert_eq!(runs[1].start_column(), 2); + assert_eq!(runs[1].cell_columns(), 1); + } + + #[test] + fn complex_cell_clusters_start_at_their_grid_columns() { + let cells = cells_from_clusters( + 0, + &[ + ('ท', &['\u{0e35}', '\u{0e48}']), + ('น', &['\u{0e35}', '\u{0e48}']), + ], + ); + + let runs = build(&cells); + + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].text(), "ท\u{0e35}\u{0e48}"); + assert_eq!(runs[0].start_column(), 0); + assert_eq!(runs[0].cell_columns(), 1); + assert_eq!(runs[1].text(), "น\u{0e35}\u{0e48}"); + assert_eq!(runs[1].start_column(), 1); + assert_eq!(runs[1].cell_columns(), 1); + } + + #[test] + fn splits_runs_on_font_style_boundaries() { + let mut cells = cells_from_text(0, "abc"); + cells[1].cell.flags.insert(Flags::BOLD); + cells[2].cell.flags.insert(Flags::ITALIC); + + let runs = build(&cells); + + assert_eq!(runs.len(), 3); + assert_eq!(runs[0].text(), "a"); + assert_eq!(runs[1].text(), "b"); + assert_eq!(runs[2].text(), "c"); + assert_eq!(runs[1].font().weight, FontWeight::Bold); + assert_eq!(runs[2].font().style, FontStyle::Italic); + } + + #[test] + fn foreground_changes_create_color_spans_without_splitting_shape_run() { + let mut cells = cells_from_text(0, "abc"); + cells[1].cell.fg = AnsiColor::Std(StdColor::Red); + + let runs = build(&cells); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "abc"); + assert_eq!(runs[0].color_spans().len(), 3); + assert_ne!( + runs[0].color_spans()[0].foreground(), + runs[0].color_spans()[1].foreground() + ); + assert_ne!( + runs[0].color_spans()[1].foreground(), + runs[0].color_spans()[2].foreground() + ); + } + + #[test] + fn selection_keeps_shape_run_boundaries_stable() { + let cells = cells_from_text(0, "abc"); + let selection = SelectionRange::new( + TerminalPoint::new(Line(0), Column(1)), + TerminalPoint::new(Line(0), Column(1)), + false, + ); + let theme = Theme::default(); + let context = RenderRunBuildContext { + selection: Some(&selection), + cursor_point: TerminalPoint::default(), + theme: &theme, + base_font: Font::MONOSPACE, + hovered_span_id: None, + cursor_text_override: false, + }; + + let runs = build_render_runs_from_cells(&cells, &context, |_| None); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "abc"); + assert_eq!(runs[0].start_column(), 0); + assert_eq!(runs[0].cell_columns(), 3); + assert_eq!(runs[0].color_spans().len(), 3); + assert_eq!( + runs[0].color_spans()[1].foreground(), + theme.get_color(cells[1].cell.bg) + ); + } + + #[test] + fn selected_text_foreground_stays_distinct_from_selected_background() { + let cells = cells_from_text(0, "a"); + let selection = SelectionRange::new( + TerminalPoint::new(Line(0), Column(0)), + TerminalPoint::new(Line(0), Column(0)), + false, + ); + let theme = Theme::default(); + let context = RenderRunBuildContext { + selection: Some(&selection), + cursor_point: TerminalPoint::default(), + theme: &theme, + base_font: Font::MONOSPACE, + hovered_span_id: None, + cursor_text_override: false, + }; + + let runs = build_render_runs_from_cells(&cells, &context, |_| None); + let selected_background = theme.get_color(cells[0].cell.fg); + + assert_eq!(runs.len(), 1); + assert_ne!(runs[0].color_spans()[0].foreground(), selected_background); + } + + #[test] + fn hovered_hyperlink_does_not_split_shape_run() { + let cells = cells_from_text(0, "abc"); + let theme = Theme::default(); + let context = RenderRunBuildContext { + selection: None, + cursor_point: TerminalPoint::default(), + theme: &theme, + base_font: Font::MONOSPACE, + hovered_span_id: Some(7), + cursor_text_override: false, + }; + + let runs = build_render_runs_from_cells(&cells, &context, |point| { + (point.column.0 == 1).then_some(7) + }); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "abc"); + } + + #[test] + fn cursor_text_override_changes_color_span_without_splitting_shape_run() { + let cells = cells_from_text(0, "abc"); + let theme = Theme::default(); + let context = RenderRunBuildContext { + selection: None, + cursor_point: TerminalPoint::new(Line(0), Column(1)), + theme: &theme, + base_font: Font::MONOSPACE, + hovered_span_id: None, + cursor_text_override: true, + }; + + let runs = build_render_runs_from_cells(&cells, &context, |_| None); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "abc"); + assert_eq!(runs[0].color_spans().len(), 3); + assert_eq!( + runs[0].color_spans()[1].foreground(), + theme.get_color(cells[1].cell.bg) + ); + } +} diff --git a/otty-ui/terminal/src/shaped_text.rs b/otty-ui/terminal/src/shaped_text.rs new file mode 100644 index 0000000..f2f8b46 --- /dev/null +++ b/otty-ui/terminal/src/shaped_text.rs @@ -0,0 +1,808 @@ +//! Shapes and draws terminal text runs with `cosmic_text`. +//! +//! This module owns the expensive text shaping step for terminal glyphs. It +//! keeps shaped buffers alive for the renderer and reuses them while only the +//! draw position changes, which is common during fast layout and window resize +//! updates. + +use std::cell::RefCell; +use std::sync::Arc; + +use iced::{Color, Point, Rectangle}; +use iced_core::Pixels; +use iced_core::text::LineHeight; +use iced_graphics::text::{self, Renderer as TextRenderer, cosmic_text}; + +use crate::render_runs::RenderRun; + +/// Draw-time geometry and font metrics for terminal text runs. +pub(crate) struct TextRunDrawConfig { + layout_position: Point, + display_offset: f32, + cell_width: f32, + cell_height: f32, + font_size: f32, + font_scale_factor: f32, +} + +impl TextRunDrawConfig { + /// Create draw configuration from the current terminal layout metrics. + pub(crate) fn new( + layout_position: Point, + display_offset: f32, + cell_width: f32, + cell_height: f32, + font_size: f32, + font_scale_factor: f32, + ) -> Self { + Self { + layout_position, + display_offset, + cell_width, + cell_height, + font_size, + font_scale_factor, + } + } +} + +/// Keeps shaped text buffers alive and reuses them across unchanged draws. +pub(crate) struct TextRunBufferStore { + cache: RefCell, +} + +impl TextRunBufferStore { + fn buffer_count(&self) -> usize { + self.cache.borrow().shaped_runs.len() + } +} + +impl TextRunBufferStore { + pub(crate) fn new() -> Self { + Self { + cache: RefCell::new(TextRunShapeCache::default()), + } + } + + fn refresh( + &self, + runs: &[RenderRun], + config: &TextRunDrawConfig, + font_system: &mut cosmic_text::FontSystem, + ) { + let mut cache = self.cache.borrow_mut(); + if cache + .key + .as_ref() + .is_some_and(|key| key.matches(runs, config)) + { + return; + } + + cache.shaped_runs = runs + .iter() + .map(|run| shape_render_run(run, config, font_system)) + .collect(); + cache.key = Some(TextRunShapeKey::new(runs, config)); + } +} + +impl Clone for TextRunBufferStore { + fn clone(&self) -> Self { + Self { + cache: RefCell::new(self.cache.borrow().clone()), + } + } +} + +impl std::fmt::Debug for TextRunBufferStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TextRunBufferStore") + .field("buffer_count", &self.buffer_count()) + .finish() + } +} + +#[derive(Clone)] +struct ShapedRenderRun { + buffer: Arc, + color: Color, +} + +impl ShapedRenderRun { + fn baseline_y(&self) -> f32 { + self.buffer + .layout_runs() + .next() + .map_or(0.0, |layout_run| layout_run.line_y) + } +} + +#[derive(Debug, Clone, PartialEq)] +struct TextRunShapeKey { + runs: Vec, + cell_width: u32, + cell_height: u32, + font_size: u32, + font_scale_factor: u32, +} + +impl TextRunShapeKey { + fn new(runs: &[RenderRun], config: &TextRunDrawConfig) -> Self { + Self { + runs: runs.to_vec(), + cell_width: config.cell_width.to_bits(), + cell_height: config.cell_height.to_bits(), + font_size: config.font_size.to_bits(), + font_scale_factor: config.font_scale_factor.to_bits(), + } + } + + fn matches(&self, runs: &[RenderRun], config: &TextRunDrawConfig) -> bool { + self.cell_width == config.cell_width.to_bits() + && self.cell_height == config.cell_height.to_bits() + && self.font_size == config.font_size.to_bits() + && self.font_scale_factor == config.font_scale_factor.to_bits() + && self.runs == runs + } +} + +#[derive(Clone, Default)] +struct TextRunShapeCache { + key: Option, + shaped_runs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct LineBaseline { + line: i32, + baseline: f32, +} + +/// Shape, cache, and draw terminal text runs through the Iced text renderer. +pub(crate) fn draw_render_runs( + renderer: &mut Renderer, + runs: &[RenderRun], + config: &TextRunDrawConfig, + clip_bounds: Rectangle, + retained_buffers: &TextRunBufferStore, +) where + Renderer: TextRenderer, +{ + let mut font_system = + text::font_system().write().expect("Write font system"); + let font_system = font_system.raw(); + retained_buffers.refresh(runs, config, font_system); + + let cache = retained_buffers.cache.borrow(); + let shaped_runs = &cache.shaped_runs; + let line_baselines = collect_line_baselines(runs, shaped_runs); + + for (run, shaped) in runs.iter().zip(shaped_runs) { + let origin = render_run_origin(run, config); + let line_baseline = + baseline_for_line(&line_baselines, run.line(), shaped.baseline_y()); + let position = Point::new( + origin.x, + origin.y + line_baseline - shaped.baseline_y(), + ); + + draw_shaped_run(renderer, shaped, position, clip_bounds); + } +} + +fn shape_render_run( + run: &RenderRun, + config: &TextRunDrawConfig, + font_system: &mut cosmic_text::FontSystem, +) -> ShapedRenderRun { + let size = Pixels(config.font_size); + let line_height: f32 = LineHeight::Relative(config.font_scale_factor) + .to_absolute(size) + .into(); + let metrics = cosmic_text::Metrics::new(config.font_size, line_height); + let run_width = run.cell_columns() as f32 * config.cell_width; + + let mut buffer = cosmic_text::Buffer::new(font_system, metrics); + buffer.set_size(font_system, Some(run_width), Some(config.cell_height)); + buffer.set_wrap(font_system, cosmic_text::Wrap::None); + buffer.set_monospace_width(font_system, Some(config.cell_width)); + let mut raw_color = set_buffer_text(font_system, &mut buffer, run, None); + if let Some(letter_spacing) = grid_fit_letter_spacing(&buffer, run, config) + { + raw_color = set_buffer_text( + font_system, + &mut buffer, + run, + Some(letter_spacing), + ); + } + + ShapedRenderRun { + buffer: Arc::new(buffer), + color: raw_color, + } +} + +fn render_run_origin(run: &RenderRun, config: &TextRunDrawConfig) -> Point { + Point::new( + config.layout_position.x + + (run.start_column() as f32 * config.cell_width), + config.layout_position.y + + ((run.line() as f32 + config.display_offset) + * config.cell_height), + ) +} + +fn draw_shaped_run( + renderer: &mut Renderer, + shaped: &ShapedRenderRun, + position: Point, + clip_bounds: Rectangle, +) where + Renderer: TextRenderer, +{ + fill_raw_run(renderer, shaped, position, shaped.color, clip_bounds); +} + +fn fill_raw_run( + renderer: &mut Renderer, + shaped: &ShapedRenderRun, + position: Point, + color: Color, + clip_bounds: Rectangle, +) where + Renderer: TextRenderer, +{ + renderer.fill_raw(text::Raw { + buffer: Arc::downgrade(&shaped.buffer), + position, + color, + clip_bounds, + }); +} + +fn set_buffer_text( + font_system: &mut cosmic_text::FontSystem, + buffer: &mut cosmic_text::Buffer, + run: &RenderRun, + letter_spacing: Option, +) -> Color { + let default_attrs = text_attributes(run.font(), letter_spacing); + let fallback_foreground = run.fallback_foreground(); + let has_color_overrides = run + .color_spans() + .iter() + .any(|span| span.foreground() != fallback_foreground); + + if !has_color_overrides { + buffer.set_text( + font_system, + run.text(), + &default_attrs, + cosmic_text::Shaping::Advanced, + Some(cosmic_text::Align::Left), + ); + return fallback_foreground; + } + + buffer.set_rich_text( + font_system, + run.color_spans().iter().map(|span| { + let attrs = default_attrs + .clone() + .color(text::to_color(span.foreground())); + + (&run.text()[span.byte_range().clone()], attrs) + }), + &default_attrs, + cosmic_text::Shaping::Advanced, + Some(cosmic_text::Align::Left), + ); + Color::WHITE +} + +fn text_attributes( + font: iced::Font, + letter_spacing: Option, +) -> cosmic_text::Attrs<'static> { + let attrs = text::to_attributes(font); + + if let Some(letter_spacing) = letter_spacing { + attrs.letter_spacing(letter_spacing) + } else { + attrs + } +} + +fn grid_fit_letter_spacing( + buffer: &cosmic_text::Buffer, + run: &RenderRun, + config: &TextRunDrawConfig, +) -> Option { + if run.text().chars().count() != run.cell_columns() { + return None; + } + + if config.font_size <= 0.0 { + return None; + } + + let glyph_count = buffer + .layout_runs() + .map(|layout_run| layout_run.glyphs.len()) + .sum::(); + if glyph_count == 0 { + return None; + } + + let actual_width = buffer + .layout_runs() + .map(|layout_run| layout_run.line_w) + .fold(0.0_f32, f32::max); + let target_width = run.cell_columns() as f32 * config.cell_width; + let width_delta = target_width - actual_width; + if width_delta.abs() < 0.01 { + return None; + } + + Some(width_delta / glyph_count as f32 / config.font_size) +} + +fn collect_line_baselines( + runs: &[RenderRun], + shaped_runs: &[ShapedRenderRun], +) -> Vec { + let mut baselines: Vec = Vec::new(); + + for (run, shaped) in runs.iter().zip(shaped_runs) { + let baseline = shaped.baseline_y(); + if let Some(existing) = + baselines.iter_mut().find(|entry| entry.line == run.line()) + { + existing.baseline = existing.baseline.max(baseline); + } else { + baselines.push(LineBaseline { + line: run.line(), + baseline, + }); + } + } + + baselines +} + +fn baseline_for_line( + baselines: &[LineBaseline], + line: i32, + fallback: f32, +) -> f32 { + baselines + .iter() + .find(|entry| entry.line == line) + .map_or(fallback, |entry| entry.baseline) +} + +#[cfg(test)] +mod tests { + use iced::{Font, Size}; + + use super::*; + use crate::render_runs::{RenderRun, RenderTextStyle}; + + fn run(text: &str) -> RenderRun { + RenderRun::new_empty_color_spans( + text, + 2, + 3, + 5, + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), + ) + } + + fn config() -> TextRunDrawConfig { + TextRunDrawConfig::new( + Point::new(10.0, 20.0), + 1.0, + 8.0, + 16.0, + 14.0, + 1.2, + ) + } + + fn config_at(position: Point) -> TextRunDrawConfig { + TextRunDrawConfig::new(position, 1.0, 8.0, 16.0, 14.0, 1.2) + } + + fn config_with_cell_width(cell_width: f32) -> TextRunDrawConfig { + TextRunDrawConfig::new( + Point::new(10.0, 20.0), + 1.0, + cell_width, + 16.0, + 14.0, + 1.2, + ) + } + + #[test] + fn shape_render_run_uses_terminal_grid_constraints() { + let run = run("ab cd"); + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + + let shaped = shape_render_run(&run, &config, font_system.raw()); + let origin = render_run_origin(&run, &config); + let first_glyph_x = shaped + .buffer + .layout_runs() + .next() + .and_then(|layout_run| layout_run.glyphs.first()) + .map(|glyph| glyph.x); + + assert_eq!(shaped.buffer.wrap(), cosmic_text::Wrap::None); + assert_eq!(shaped.buffer.monospace_width(), Some(8.0)); + assert_eq!(shaped.buffer.size(), (Some(40.0), Some(16.0))); + assert_eq!(origin.x, 34.0); + assert_eq!(first_glyph_x, Some(0.0)); + assert!(origin.y.is_finite()); + } + + #[test] + fn same_line_runs_keep_stable_vertical_origin_when_split() { + let plain_run = run("abc"); + let complex_run = run("ที่นี่"); + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + + let plain = shape_render_run(&plain_run, &config, font_system.raw()); + let complex = + shape_render_run(&complex_run, &config, font_system.raw()); + let plain_origin = render_run_origin(&plain_run, &config); + let complex_origin = render_run_origin(&complex_run, &config); + + assert_eq!(plain_origin.y, complex_origin.y); + assert!(plain.baseline_y().is_finite()); + assert!(complex.baseline_y().is_finite()); + } + + #[test] + fn same_line_runs_share_one_draw_baseline_when_split() { + let plain_run = run("abc"); + let complex_run = run("ที่นี่"); + let runs = [plain_run, complex_run]; + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + let shaped_runs = runs + .iter() + .map(|run| shape_render_run(run, &config, font_system.raw())) + .collect::>(); + + let baselines = collect_line_baselines(&runs, &shaped_runs); + let plain_baseline = baseline_for_line(&baselines, runs[0].line(), 0.0); + let complex_baseline = + baseline_for_line(&baselines, runs[1].line(), 0.0); + + assert_eq!(plain_baseline, complex_baseline); + } + + #[test] + fn shape_render_run_uses_monospace_width_for_combining_marks() { + let text = "e\u{0301}x"; + let run = RenderRun::new_empty_color_spans( + text, + 2, + 3, + 2, + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), + ); + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + let shaped = shape_render_run(&run, &config, font_system.raw()); + + assert_eq!(shaped.buffer.monospace_width(), Some(8.0)); + } + + #[test] + fn shape_render_run_keeps_hebrew_niqqud_in_layout_text() { + let text = "ב\u{05b0}\u{05bc}"; + let run = RenderRun::new_empty_color_spans( + text, + 2, + 3, + 1, + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), + ); + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + let shaped = shape_render_run(&run, &config, font_system.raw()); + let layout_text = shaped + .buffer + .layout_runs() + .map(|layout_run| layout_run.text) + .collect::>() + .join(""); + let glyph_ranges = shaped + .buffer + .layout_runs() + .flat_map(|layout_run| { + layout_run.glyphs.iter().map(|glyph| glyph.start..glyph.end) + }) + .collect::>(); + let covered_end = glyph_ranges.iter().map(|range| range.end).max(); + + assert_eq!(layout_text, text); + assert_eq!(covered_end, Some(text.len())); + } + + #[test] + fn color_spans_keep_glyph_geometry_stable() { + let selected_color = Color::from_rgb(0.2, 0.7, 0.9); + let plain = RenderRun::new_empty_color_spans( + "abc", + 2, + 3, + 3, + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), + ); + let colored = RenderRun::new_with_color_spans( + "abc", + 2, + 3, + 3, + Font::MONOSPACE, + vec![ + (0..1, 0, 1, Color::WHITE), + (1..2, 1, 1, selected_color), + (2..3, 2, 1, Color::WHITE), + ], + ); + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + + let plain = shape_render_run(&plain, &config, font_system.raw()); + let colored = shape_render_run(&colored, &config, font_system.raw()); + let plain_geometry = glyph_geometry(&plain.buffer); + let colored_geometry = glyph_geometry(&colored.buffer); + + assert_eq!(plain_geometry, colored_geometry); + } + + #[test] + fn shape_render_run_fits_monospace_advances_to_terminal_grid() { + let run = RenderRun::new_empty_color_spans( + "otty git:(chars)x", + 0, + 0, + 17, + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), + ); + let config = config(); + let mut font_system = + text::font_system().write().expect("Write font system"); + let shaped = shape_render_run(&run, &config, font_system.raw()); + let glyphs = shaped + .buffer + .layout_runs() + .flat_map(|layout_run| { + layout_run.glyphs.iter().map(|glyph| (glyph.x, glyph.w)) + }) + .collect::>(); + + assert_eq!(glyphs.len(), run.cell_columns()); + for (index, (x, width)) in glyphs.into_iter().enumerate() { + let expected_x = index as f32 * config.cell_width; + assert!((x - expected_x).abs() < 0.01); + assert!((width - config.cell_width).abs() < 0.01); + } + } + + #[test] + fn draw_render_runs_submits_raw_text_and_retains_buffers() { + let runs = [run("e\u{0301} ที่นี่ ن\u{064f} ש\u{05c1}")]; + let config = config(); + let clip_bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 80.0)); + let retained_buffers = TextRunBufferStore::new(); + let mut renderer = RecordingTextRenderer::default(); + + draw_render_runs( + &mut renderer, + &runs, + &config, + clip_bounds, + &retained_buffers, + ); + + assert_eq!(renderer.raw_text.len(), 1); + assert_eq!(renderer.raw_text[0].position, Point::new(34.0, 68.0)); + assert_eq!(renderer.raw_text[0].color, Color::WHITE); + assert_eq!(renderer.raw_text[0].clip_bounds, clip_bounds); + assert_eq!(retained_buffers.buffer_count(), 1); + assert!(renderer.raw_text[0].buffer.upgrade().is_some()); + } + + #[test] + fn draw_render_runs_uses_glyph_colors_without_clipped_overlays() { + let selected_color = Color::from_rgb(0.2, 0.7, 0.9); + let runs = [RenderRun::new_with_color_spans( + "abc", + 2, + 3, + 3, + Font::MONOSPACE, + vec![ + (0..1, 0, 1, Color::WHITE), + (1..2, 1, 1, selected_color), + (2..3, 2, 1, Color::WHITE), + ], + )]; + let config = config(); + let clip_bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 80.0)); + let retained_buffers = TextRunBufferStore::new(); + let mut renderer = RecordingTextRenderer::default(); + + draw_render_runs( + &mut renderer, + &runs, + &config, + clip_bounds, + &retained_buffers, + ); + + assert_eq!(renderer.raw_text.len(), 1); + assert_eq!(renderer.raw_text[0].clip_bounds, clip_bounds); + assert_eq!(renderer.raw_text[0].color, Color::WHITE); + assert_eq!(retained_buffers.buffer_count(), 1); + + let buffer = renderer.raw_text[0] + .buffer + .upgrade() + .expect("Retained shaped buffer"); + let glyph_colors = buffer + .layout_runs() + .flat_map(|layout_run| { + layout_run + .glyphs + .iter() + .map(|glyph| (glyph.start..glyph.end, glyph.color_opt)) + }) + .collect::>(); + + assert_eq!(glyph_colors.len(), 3); + assert_eq!(glyph_colors[0], (0..1, Some(text::to_color(Color::WHITE)))); + assert_eq!( + glyph_colors[1], + (1..2, Some(text::to_color(selected_color))) + ); + assert_eq!(glyph_colors[2], (2..3, Some(text::to_color(Color::WHITE)))); + } + + #[test] + fn draw_render_runs_accepts_empty_run_list() { + let config = config(); + let clip_bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 80.0)); + let retained_buffers = TextRunBufferStore::new(); + let mut renderer = RecordingTextRenderer::default(); + + draw_render_runs( + &mut renderer, + &[], + &config, + clip_bounds, + &retained_buffers, + ); + + assert!(renderer.raw_text.is_empty()); + assert_eq!(retained_buffers.buffer_count(), 0); + } + + #[test] + fn draw_render_runs_reuses_shaped_buffers_when_only_position_changes() { + let runs = [run("cached text")]; + let clip_bounds = + Rectangle::new(Point::ORIGIN, Size::new(300.0, 120.0)); + let retained_buffers = TextRunBufferStore::new(); + let mut renderer = RecordingTextRenderer::default(); + + draw_render_runs( + &mut renderer, + &runs, + &config_at(Point::new(10.0, 20.0)), + clip_bounds, + &retained_buffers, + ); + let first_buffer = renderer.raw_text[0] + .buffer + .upgrade() + .expect("retained shaped buffer"); + + renderer.raw_text.clear(); + draw_render_runs( + &mut renderer, + &runs, + &config_at(Point::new(40.0, 60.0)), + clip_bounds, + &retained_buffers, + ); + let second_buffer = renderer.raw_text[0] + .buffer + .upgrade() + .expect("retained shaped buffer"); + + assert!(Arc::ptr_eq(&first_buffer, &second_buffer)); + assert_eq!(renderer.raw_text[0].position, Point::new(64.0, 108.0)); + } + + #[test] + fn draw_render_runs_reshapes_when_cell_width_changes() { + let runs = [run("cached text")]; + let clip_bounds = + Rectangle::new(Point::ORIGIN, Size::new(300.0, 120.0)); + let retained_buffers = TextRunBufferStore::new(); + let mut renderer = RecordingTextRenderer::default(); + + draw_render_runs( + &mut renderer, + &runs, + &config_with_cell_width(8.0), + clip_bounds, + &retained_buffers, + ); + let first_buffer = renderer.raw_text[0] + .buffer + .upgrade() + .expect("retained shaped buffer"); + + renderer.raw_text.clear(); + draw_render_runs( + &mut renderer, + &runs, + &config_with_cell_width(9.0), + clip_bounds, + &retained_buffers, + ); + let second_buffer = renderer.raw_text[0] + .buffer + .upgrade() + .expect("retained shaped buffer"); + + assert!(!Arc::ptr_eq(&first_buffer, &second_buffer)); + assert_eq!(second_buffer.monospace_width(), Some(9.0)); + } + + #[derive(Default)] + struct RecordingTextRenderer { + raw_text: Vec, + } + + impl text::Renderer for RecordingTextRenderer { + fn fill_raw(&mut self, raw: text::Raw) { + self.raw_text.push(raw); + } + } + + fn glyph_geometry( + buffer: &cosmic_text::Buffer, + ) -> Vec<(usize, usize, u32, u32)> { + buffer + .layout_runs() + .flat_map(|layout_run| { + layout_run.glyphs.iter().map(|glyph| { + ( + glyph.start, + glyph.end, + glyph.x.to_bits(), + glyph.w.to_bits(), + ) + }) + }) + .collect() + } +} diff --git a/otty-ui/terminal/src/view.rs b/otty-ui/terminal/src/view.rs index 05321eb..7504277 100644 --- a/otty-ui/terminal/src/view.rs +++ b/otty-ui/terminal/src/view.rs @@ -2,16 +2,13 @@ use std::any::Any; use std::collections::VecDeque; use std::time::{Duration, Instant}; -use iced::alignment::Vertical; -use iced::font::{Style as FontStyle, Weight as FontWeight}; use iced::mouse::Cursor; -use iced::widget::canvas::{Path, Text}; +use iced::widget::canvas::Path; use iced::widget::container; use iced::{Color, Element, Length, Point, Rectangle, Size, Theme}; use iced_core::clipboard::Kind as ClipboardKind; use iced_core::keyboard::Modifiers; use iced_core::mouse; -use iced_core::text::{Alignment, LineHeight, Shaping}; use iced_core::widget::operation; use iced_graphics::core::Widget; use iced_graphics::core::widget::{Tree, tree}; @@ -24,6 +21,10 @@ use otty_libterm::surface::{ use crate::block_controls::BlockActionButtonGeometry; use crate::block_layout::{self, BlockRect}; use crate::input::InputManager; +use crate::render_runs::build_render_runs; +use crate::shaped_text::{ + TextRunBufferStore, TextRunDrawConfig, draw_render_runs, +}; use crate::term::{BlockCommand, BlockUiMode, Event, Terminal}; use crate::theme::TerminalStyle; @@ -511,33 +512,48 @@ impl Widget for TerminalView<'_> { _cursor: Cursor, viewport: &Rectangle, ) { - let geom = self.term.cache.draw(renderer, viewport.size(), |frame| { - // Precompute constants used in the inner loop - let state = tree.state.downcast_ref::(); - let content = self.term.engine.snapshot(); - let view = content.view(); - let term_size = self.term.engine.terminal_size(); - let cell_width = term_size.cell_width as f32; - let cell_height = term_size.cell_height as f32; - let font_size = self.term.font.size; - let font_scale_factor = self.term.font.scale_factor; - let layout_position = layout.position(); - let layout_offset_x = layout_position.x; - let layout_offset_y = layout_position.y; - let layout_bounds = layout.bounds(); - let display_offset = view.display_offset as f32; - let half_h = cell_height * 0.5; - let block_rects = if view.mode.contains(SurfaceMode::ALT_SCREEN) { - Vec::new() - } else { - block_layout::block_rects( - &view, - layout_position, - layout_bounds.size(), - cell_height, - ) - }; + let state = tree.state.downcast_ref::(); + let content = self.term.engine.snapshot(); + let view = content.view(); + let term_size = self.term.engine.terminal_size(); + let cell_width = term_size.cell_width as f32; + let cell_height = term_size.cell_height as f32; + let font_size = self.term.font.size; + let font_scale_factor = self.term.font.scale_factor; + let layout_position = layout.position(); + let layout_offset_x = layout_position.x; + let layout_offset_y = layout_position.y; + let layout_bounds = layout.bounds(); + let display_offset = view.display_offset as f32; + let block_rects = if view.mode.contains(SurfaceMode::ALT_SCREEN) { + Vec::new() + } else { + block_layout::block_rects( + &view, + layout_position, + layout_bounds.size(), + cell_height, + ) + }; + let hovered_span_id = + view.hyperlink_span_id_at(state.mouse_position_on_grid); + let render_runs = build_render_runs( + &view, + &self.term.theme, + self.term.font.font_type, + hovered_span_id, + !view.mode.contains(SurfaceMode::ALT_SCREEN), + ); + let text_config = TextRunDrawConfig::new( + layout_position, + display_offset, + cell_width, + cell_height, + font_size, + font_scale_factor, + ); + let geom = self.term.cache.draw(renderer, viewport.size(), |frame| { // We use the background pallete color as a default // because the widget global background color must be the same let default_bg = self @@ -545,8 +561,6 @@ impl Widget for TerminalView<'_> { .theme .get_color(ansi::Color::Std(StdColor::Background)); - let hovered_span_id = - view.hyperlink_span_id_at(state.mouse_position_on_grid); let block_ui_mode = self.term.block_ui_mode(); let block_visuals = BlockUiVisuals::from_rects( &block_rects, @@ -590,8 +604,6 @@ impl Widget for TerminalView<'_> { cell_width }; let cell_size = Size::new(cell_render_width, cell_height); - let cell_center_y = y + half_h; - let cell_center_x = x + (cell_render_width * 0.5); // Resolve colors for this cell let mut fg = self.term.theme.get_color(indexed.cell.fg); @@ -685,36 +697,6 @@ impl Widget for TerminalView<'_> { Path::rectangle(Point::new(x, y), cell_size); frame.fill(&cursor_rect, cursor_color); } - - // Draw text - if indexed.cell.c != ' ' && indexed.cell.c != '\t' { - if view.cursor.point == indexed.point - && !view.mode.contains(SurfaceMode::ALT_SCREEN) - { - fg = bg; - } - // Resolve font style (bold/italic) from cell flags - let mut font = self.term.font.font_type; - if flags.intersects(Flags::BOLD | Flags::DIM_BOLD) { - font.weight = FontWeight::Bold; - } - if flags.contains(Flags::ITALIC) { - font.style = FontStyle::Italic; - } - let text = Text { - content: indexed.cell.c.to_string(), - position: Point::new(cell_center_x, cell_center_y), - font, - size: iced_core::Pixels(font_size), - color: fg, - align_x: Alignment::Center, - align_y: Vertical::Center, - shaping: Shaping::Advanced, - line_height: LineHeight::Relative(font_scale_factor), - ..Default::default() - }; - frame.fill_text(text); - } } // Flush any remaining background run at the end @@ -773,6 +755,13 @@ impl Widget for TerminalView<'_> { use iced::advanced::graphics::geometry::Renderer as _; renderer.draw_geometry(geom); + draw_render_runs( + renderer, + &render_runs, + &text_config, + layout_bounds, + &state.text_buffers, + ); } fn update( @@ -917,6 +906,7 @@ pub(crate) struct TerminalViewState { pending_cell_size: Option>, pending_resize_deadline: Option, last_resize_sent_at: Option, + text_buffers: TextRunBufferStore, } impl TerminalViewState { @@ -942,6 +932,7 @@ impl TerminalViewState { pending_cell_size: None, pending_resize_deadline: None, last_resize_sent_at: None, + text_buffers: TextRunBufferStore::new(), } } @@ -972,7 +963,9 @@ impl TerminalViewState { self.pending_cell_size = Some(cell_size); self.pending_resize_deadline = self.last_resize_sent_at.map(|last| last + THROTTLE); - shell.request_redraw(); + if let Some(deadline) = self.pending_resize_deadline { + shell.request_redraw_at(deadline); + } } } @@ -1014,7 +1007,7 @@ impl TerminalViewState { Instant::now(), ); } else { - shell.request_redraw(); + shell.request_redraw_at(deadline); } } } @@ -1175,4 +1168,26 @@ mod tests { assert!(visuals.action_buttons.is_empty()); assert_eq!(visuals.dividers.len(), 1); } + + #[test] + fn pending_resize_schedules_redraw_at_throttle_deadline() { + let mut state = TerminalViewState::new(); + let mut events = Vec::new(); + let mut shell = iced_graphics::core::Shell::new(&mut events); + let cell_size = Size::new(8.0, 16.0); + + state.queue_resize(7, Size::new(640.0, 480.0), cell_size, &mut shell); + state.queue_resize(7, Size::new(641.0, 480.0), cell_size, &mut shell); + + let Some(deadline) = state.pending_resize_deadline else { + panic!("expected pending resize deadline"); + }; + assert_eq!( + shell.redraw_request(), + iced_core::window::RedrawRequest::At(deadline) + ); + + drop(shell); + assert_eq!(events.len(), 1); + } } diff --git a/tasks/.gitkeep b/tasks/.gitkeep new file mode 100644 index 0000000..e69de29