From 52ae3487498a36de4bdd811c5181e130de7635c4 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 14:27:39 +0200 Subject: [PATCH 01/12] chore: add rules --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1459e9b..ad59930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,19 @@ General context lives in [README.md](./README.md) at the repository root. -## Development workflow +## Rules +- 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. +- 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 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. @@ -15,6 +26,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 From 8d1f2f8394a2c3e4455409df7bc2901fac0aa8bf Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 15:32:15 +0200 Subject: [PATCH 02/12] feat: render runs task --- tasks/shaping.md | 437 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 tasks/shaping.md diff --git a/tasks/shaping.md b/tasks/shaping.md new file mode 100644 index 0000000..284635b --- /dev/null +++ b/tasks/shaping.md @@ -0,0 +1,437 @@ +# Terminal text shaping + +## Цель + +Спроектировать и реализовать долгосрочную архитектуру отрисовки complex text в +терминале без отказа от cell-based модели. Grid остается источником истины для +терминального состояния, а renderer строит временные text runs только на время +кадра и только для передачи в text shaper. + +Задача нужна из-за текущего дефекта: `otty-surface` хранит zero-width combining +marks в `Cell::zerowidth()`, но `otty-ui-terminal` при отрисовке передает в +`Text.content` только `cell.c`. В результате Thai tone marks, Arabic harakat, +Hebrew niqqud, decomposed Latin accents и другие combining marks могут быть +потеряны или расположены неправильно. + +Эта задача не должна решаться набором коротких language-specific или per-cell +фиксов. Нужный результат - renderer, который корректно shape-ит текстовые runs +поверх terminal grid и сохраняет cell-based поведение TUI, selection, cursor, +links и resize. + +## Текущая модель + +- `otty-surface` хранит терминальную сетку: base character, zero-width marks, + cell flags, colors, cursor, selection, hyperlinks, scrollback и wrapping. +- `otty-ui-terminal` рисует viewport из snapshot-а surface. +- Фон, cursor, selection и hyperlink hit testing уже естественно выражены через + grid cells. +- Текст сейчас рисуется per cell: одна canvas `Text` команда получает один + `cell.c`. + +Эта модель хорошо подходит для TUI geometry, но плохо подходит для complex text: +shaper не видит полный grapheme cluster, соседние glyphs и contextual forms. + +## Предлагаемый подход + +Оставить surface cell-based, но заменить text rendering path на run-based +отрисовку поверх сетки. + +Renderer должен делать два независимых прохода: + +1. Cell-based geometry pass: + - background rectangles; + - cursor rectangle; + - selection rectangles; + - hyperlink hover underline; + - block UI overlays. + +2. Text shaping pass: + - пройти по видимым строкам snapshot-а; + - собрать текст каждой renderable cell как `cell.c + cell.zerowidth()`; + - объединить соседние cells в `RenderRun`, пока их text style совместим; + - передать весь run в shaper; + - нарисовать shaped text в grid-aligned rectangle, начинающемся с первой + колонки run-а. + +Surface при этом не должен знать про Arabic shaping, bidi, Thai positioning, +font fallback или glyph placement. Его ответственность - терминальная модель, не +rendering. + +## Non-goals + +Эта задача не должна ограничиваться следующими частичными решениями: + +- append `Cell::zerowidth()` к per-cell `canvas::Text`; +- language-specific storage hacks в `otty-surface` как основной путь решения; +- ручное позиционирование Thai/Arabic/Hebrew marks в renderer-е; +- включение Unicode normalization как замена shaping; +- изменение terminal grid в сторону plain text buffer. + +Такие изменения могут быть полезны для диагностики или временного сравнения, но +они не закрывают требования задачи и не должны быть финальным design direction. + +## Run-based renderer design + +### RenderRun + +Нужен внутренний тип только для renderer-а: + +```rust +struct RenderRun { + text: String, + line: i32, + start_column: usize, + cell_columns: usize, + style: RenderTextStyle, +} +``` + +`cell_columns` - terminal width run-а в cells. Для wide character он должен +учитывать две колонки. `WIDE_CHAR_SPACER` cells не должны добавлять отдельный +символ в run. + +### RenderTextStyle + +Run можно продолжать только пока совместимы: + +- resolved foreground color; +- resolved font family; +- bold/dim-bold weight; +- italic style; +- selected/inverse state after color resolution; +- cursor text color override, если cursor меняет foreground. + +Background не обязан участвовать в text run style, если он уже рисуется отдельным +cell-based pass-ом. + +Hyperlink hover и selection могут менять foreground или underline. Если меняют, +run нужно split-ить на границах этих состояний. + +### Grid alignment + +Терминальная геометрия не должна зависеть от natural glyph advances. + +Первый вариант реализации: + +- run origin: `layout_x + start_column * cell_width`; +- run width: `cell_columns * cell_width`; +- line origin: текущая terminal row baseline/center; +- layout width для shaper-а равен run width; +- фон, cursor и selection остаются привязанными к cells. + +Более строгий вариант для будущего: + +- хранить cluster-to-cell mapping; +- контролировать, что visual advance cluster-а не сдвигает соседние terminal + columns; +- не split-ить run внутри `base + zerowidth` cluster-а. + +## TUI behavior + +TUI layout должен остаться стабильным, если выполняются правила: + +- grid остается единственным источником cursor position и hit testing; +- total rendered width строки не превышает terminal cell width allocation; +- runs split-ятся на style boundaries, но не меняют cell geometry; +- wide char spacer cells не создают отдельного glyph-а; +- tabs продолжают обрабатываться текущей terminal моделью. + +Риск для TUI: если shaper natural advances будут использоваться без +ограничения grid width, borders и columns могут визуально съехать. Поэтому +renderer должен считать grid width сильным constraint-ом. + +## Text clipping + +Shaped text должен clip-иться по terminal viewport/widget bounds. + +Правила: + +- glyphs, combining marks, italic overhang и fallback glyphs не должны рисоваться + за пределами terminal viewport; +- visual glyph bounds не расширяют terminal drawing area; +- tight per-run clipping по exact cell range не включается по умолчанию, потому + что он может обрезать combining marks и полезный glyph overhang; +- небольшой overflow внутри terminal viewport допустим, если он нужен для + корректного shaping; +- per-run clipping можно добавить только после измеренной regression и только с + проверкой, что он не ломает complex-script marks. + +## Hyperlink hover + +Hyperlink hit testing должен остаться cell-based: + +- mouse position -> grid point; +- grid point -> hyperlink span id; +- hover underline рисуется по cells; +- run splitting нужен только если hover меняет text style. + +Риск: если run содержит cells из разных hyperlink states и hover меняет +foreground, часть текста может получить неверный стиль. Для этого run builder +должен учитывать hovered span id как style boundary. + +Hover underline также должен оставаться cell-based даже при complex glyph +overflow: + +- underline рисуется по terminal cell range, принадлежащему hyperlink span; +- visual overflow shaped glyphs не расширяет clickable area и underline bounds; +- glyph overflow должен решаться clipping-ом run/viewport, а не изменением + hyperlink geometry; +- это сохраняет совпадение hover, selection, cursor и TUI grid semantics. + +## Selection + +Selection также остается cell-based: + +- selected background рисуется прямоугольниками cells; +- selected foreground вычисляется как сейчас через resolved colors; +- text runs split-ятся на selected/unselected boundaries. + +Риск: split selection посередине complex-script слова ухудшит shaping на границе +selection. Это типичное ограничение cell selection в терминалах. Минимальное +правило - не split-ить внутри одной cell cluster. + +## Resize + +Resize не требует persistent text layout state: + +- surface пересчитывает grid/reflow; +- snapshot обновляется; +- renderer заново собирает runs из нового snapshot-а. + +Кеши runs должны инвалидироваться при: + +- изменении snapshot/damage; +- изменении font или font size; +- изменении cell size; +- изменении theme; +- изменении selection; +- изменении hovered hyperlink span; +- изменении display offset. + +## Run caching decision + +Начальный renderer не должен кешировать shaped glyph layouts до появления +benchmark-данных, показывающих, что shaping является bottleneck. + +Первый этап: + +- собирать `RenderRun` из visible snapshot rows; +- при необходимости кешировать или пересобирать только render runs по + damaged/affected rows; +- каждый frame shape-ить актуальные runs заново; +- держать типы разделенными: `RenderRun` для text/cell/style mapping и + `ShapedRenderRun` для будущего glyph layout cache. + +Причины: + +- run construction дешевле и проще инвалидируется; +- shaped glyph cache требует более сложного ключа: text, style, font metrics, + cell width, shaping mode, line height, font system version и layout bounds; +- selection, cursor и hyperlink hover могут менять split/style boundaries; +- resize, font changes и theme changes легко делают cached glyph positions + stale. + +Архитектура должна позволять добавить shaped-run cache позже без переписывания +run builder-а. Решение о shaped cache принимается только после измерений на +ASCII logs, colorful TUI, scrollback и complex-script samples. + +## CPU и память + +Текущий renderer потенциально создает text draw/shaping operation на каждую +непустую cell. Run-based renderer может уменьшить количество операций до +количества style runs в viewport. + +Ожидаемое влияние: + +- plain output: CPU может снизиться из-за меньшего числа text operations; +- colorful TUI: CPU зависит от количества style boundaries и может остаться + близким к текущему; +- complex scripts: shaping run-а дороже, но корректнее; +- память: временные strings и metadata по visible rows обычно десятки килобайт + для обычного viewport-а. + +Нужны измерения до включения по умолчанию: + +- viewport 80x24, 120x40, 200x60; +- plain ASCII log; +- colorful TUI с частыми style changes; +- Thai/Arabic/Hebrew sample text; +- scroll performance; +- resize performance. + +## Bidi + +Run-based shaping не обязан сразу включать Unicode bidi reordering. + +Причины: + +- многие terminal applications уже выводят visual order; +- автоматический bidi может сломать TUI alignment и mixed LTR/RTL prompts; +- shaping Arabic contextual forms полезен отдельно от bidi. + +Начальный scope: shape text in grid order. Bidi рассматривать как отдельную +явно спроектированную опцию после измерений и тестов. + +Default renderer должен сохранять terminal grid order и не применять +автоматический line-wide Unicode bidi reordering. + +Приемлемое поведение для этой задачи: + +- renderer shape-ит runs без нарушения cell ownership; +- emitted/grid order остается источником истины для visual placement; +- selection, cursor, hyperlink mapping и mouse hit testing остаются в grid order; +- full bidi visual reordering считается out of scope для начального run-based + renderer-а; +- bidi reordering можно добавлять позже только как explicit setting после + отдельного design-а и regression-тестов на TUI, cursor, selection и links. + +## Iced and cosmic-text capabilities + +Текущий `iced` canvas `Text` недостаточен как основной API для строгого +run-based terminal renderer-а. + +Причины: + +- `canvas::Text` позволяет задать `max_width`, но не позволяет явно задать + wrapping mode; +- внутри `canvas::Text` используется default wrapping, а default в `iced` это + word wrapping; +- terminal renderer не должен получать paragraph-like переносы строк внутри + одного grid row; +- `canvas::Text` не дает доступа к cluster/glyph mapping и не прокидывает + `cosmic_text::Buffer::set_monospace_width`; +- для terminal grid важно контролировать run width, clipping, split boundaries и + glyph positions более явно. + +`cosmic-text` как нижний уровень выглядит достаточным для прототипа: + +- `Buffer::set_size` задает bounds run-а; +- `Buffer::set_wrap(Wrap::None)` отключает переносы; +- `Buffer::set_monospace_width(Some(cell_width))` может помочь удерживать + monospace glyphs в terminal grid; +- `layout_runs()` возвращает glyph positions, byte ranges и bidi levels. + +Вывод: целевой renderer должен использовать lower-level путь: либо +`iced_graphics::text::Paragraph` с явным `Wrapping::None`, либо прямую работу с +`iced_graphics::text::cosmic_text`. Даже при использовании `cosmic-text` grid +alignment остается ответственностью renderer-а: нужно задавать run bounds, +clip-region и style/split boundaries. + +## Renderer mode setting + +Renderer mode для сравнения per-cell и run-based implementation должен жить в +application settings. + +Правила: + +- setting нужен для экспериментального rollout-а, диагностики и сравнения + поведения на реальных TUI/screens; +- env var не подходит как основной механизм, потому что пользовательские и QA + сценарии должны быть воспроизводимы через обычную конфигурацию приложения; +- compile-time switch не подходит как основной механизм, потому что он усложняет + сравнение поведения в одном build-е; +- значение должно быть явным enum, например `Cell` и `Runs`; +- если setting временный, это нужно отметить в задаче rollout-а и удалить после + стабилизации run-based renderer-а. + +## Thai and Lao SARA AM + +`U+0E33` Thai SARA AM и `U+0EB3` Lao VOWEL SIGN AM являются отдельным спорным +случаем. + +Surface-level decomposition не является target design для этой задачи: + +- `U+0E33` -> `U+0E4D` + `U+0E32`; +- `U+0EB3` -> `U+0ECD` + `U+0EB2`. + +Такой подход может работать с текущей per-cell отрисовкой и grid storage, но он +закрепляет language-specific знание в terminal model. + +Главный минус: меняет текстовую модель, copy может вернуть decomposed pair +вместо исходного codepoint. Это compatibility decomposition, не canonical NFC +equivalence. NFC не раскладывает `U+0E33`. + +Целевой вариант для этой задачи: не добавлять language-specific storage hacks в +surface, а дать renderer-у shape-ить соседние cells как один run. Нужно отдельно +проверить, сможет ли выбранный text stack корректно расположить SARA AM при +run-based shaping без surface decomposition. Surface-level decomposition можно +рассматривать только как явно задокументированный fallback, если lower-level +shaping не способен корректно отрисовать этот случай при сохранении terminal +grid. + +## Этапы реализации + +### Этап 1. Зафиксировать целевое поведение + +- Зафиксировать, что surface остается cell-based source of truth. +- Добавить regression samples для renderer-а: + - `e\u{0301}`; + - Thai `ที่นี่`; + - Thai `น้ำ` и `กำลัง`; + - Lao `ນ້ຳ` или аналогичный sample с `U+0EB3`; + - Arabic base + harakat; + - Hebrew base + niqqud. +- Добавить fixtures для ASCII и colorful TUI layouts, чтобы проверять отсутствие + regressions в cell geometry. + +### Этап 2. RenderRun model + +- Добавить private run builder в `otty-ui-terminal`. +- Собрать runs per visible row из snapshot cells. +- Сохранять mapping между run text и terminal columns. +- Split по style boundaries: font, weight, italic, resolved foreground, + selection, inverse, cursor text override, hovered hyperlink state. +- Не split-ить внутри `base + zerowidth` cluster-а. +- Учитывать wide chars и `WIDE_CHAR_SPACER`. +- Добавить unit-тесты run builder-а без canvas и без shaper-а. + +### Этап 3. Lower-level shaping backend + +- Реализовать shaping path через `iced_graphics::text::Paragraph` с + `Wrapping::None` или напрямую через `cosmic_text::Buffer`. +- Явно задавать run bounds: `cell_columns * cell_width`. +- Явно отключать wrapping. +- Применять обязательный clipping по terminal viewport/widget bounds. +- Не включать tight per-run clipping по умолчанию; рассматривать его только + после измеренной regression и проверки complex-script samples. +- Проверить применимость `set_monospace_width(Some(cell_width))`. +- Сохранять доступ к glyph positions и byte ranges для диагностики и будущего + cluster-to-cell mapping. + +### Этап 4. Интеграция с cell-based geometry + +- Оставить background, cursor, selection, hyperlink hover и block UI + cell-based. +- Заменить только text rendering path на shaped runs. +- Убедиться, что hover hit testing, selection ranges и cursor movement не + зависят от shaped glyph positions. +- Проверить, что resize не хранит stale text layout state. +- Добавить внутренний renderer mode только если он нужен для сравнения и + безопасного rollout-а. + +### Этап 5. Validation and rollout + +- Включать run-based renderer только после regression screenshots и benchmark + данных. +- Сравнить per-cell и run-based rendering на sample screens. +- Измерить CPU и allocation behavior. +- Проверить hover links, selection, cursor, scroll и resize. +- Если TUI regressions значимые, исправить run construction/alignment, а не + подменять задачу per-cell workaround-ом. + +## Acceptance criteria + +- Combining marks больше не теряются при отрисовке. +- `base + zerowidth` cluster всегда передается shaper-у вместе. +- Текстовый renderer shape-ит contiguous runs, а не отдельные cells. +- TUI geometry остается cell-aligned. +- Hyperlink hover hit testing остается cell-based. +- Selection background и cursor не зависят от text shaping. +- Resize не хранит stale layout state. +- Performance на ASCII/TUI viewport не хуже текущей без принятого объяснения. +- Thai/Lao SARA AM проверен на renderer-only shaping; любой fallback на уровне + surface должен быть отдельно обоснован как последнее средство. + +## Открытые вопросы + +На текущем уровне проектирования открытых вопросов нет. From 9c82935cd083ca5a79fe233130e3a91f533f2ca3 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 23:34:00 +0200 Subject: [PATCH 03/12] feat: render runs impl --- Cargo.lock | 8 +- deny.toml | 4 + otty-libterm/src/terminal/mod.rs | 41 ++ otty-ui/terminal/src/input.rs | 26 +- otty-ui/terminal/src/lib.rs | 2 + otty-ui/terminal/src/render_runs.rs | 721 ++++++++++++++++++++++++++++ otty-ui/terminal/src/shaped_text.rs | 660 +++++++++++++++++++++++++ otty-ui/terminal/src/view.rs | 119 +++-- tasks/.gitkeep | 0 tasks/shaping.md | 437 ----------------- 10 files changed, 1506 insertions(+), 512 deletions(-) create mode 100644 otty-ui/terminal/src/render_runs.rs create mode 100644 otty-ui/terminal/src/shaped_text.rs create mode 100644 tasks/.gitkeep delete mode 100644 tasks/shaping.md 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-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..65d1cec --- /dev/null +++ b/otty-ui/terminal/src/render_runs.rs @@ -0,0 +1,721 @@ +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; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RenderRun { + text: String, + line: i32, + start_column: usize, + cell_columns: usize, + font: Font, + color_spans: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RenderTextSpan { + byte_range: Range, + start_column: usize, + cell_columns: usize, + foreground: Color, +} + +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 RenderTextSpan { + pub(crate) fn byte_range(&self) -> &Range { + &self.byte_range + } + + pub(crate) fn foreground(&self) -> Color { + self.foreground + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RenderTextStyle { + foreground: Color, + font: Font, + selected: bool, + inverse: bool, + cursor_override: bool, + hovered_hyperlink: bool, +} + +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) + }) +} + +struct RenderRunBuildContext<'a> { + selection: Option<&'a SelectionRange>, + cursor_point: TerminalPoint, + theme: &'a Theme, + base_font: Font, + hovered_span_id: Option, + cursor_text_override: bool, +} + +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 +} + +impl RenderRun { + fn new(line: i32, start_column: usize, font: Font) -> Self { + Self { + text: String::new(), + line, + start_column, + cell_columns: 0, + font, + color_spans: Vec::new(), + } + } + + 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)] + pub(crate) fn for_test( + 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(), + } + } + + #[cfg(test)] + pub(crate) fn for_test_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(), + } + } +} + +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)] +impl RenderTextStyle { + pub(crate) fn for_test(foreground: Color, font: Font) -> Self { + Self { + foreground, + font, + selected: false, + inverse: false, + cursor_override: false, + hovered_hyperlink: false, + } + } +} + +#[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..795a6dd --- /dev/null +++ b/otty-ui/terminal/src/shaped_text.rs @@ -0,0 +1,660 @@ +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; + +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 { + 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 raw shaped text buffers alive until the renderer consumes them. +pub(crate) struct TextRunBufferStore { + buffers: RefCell>>, +} + +impl TextRunBufferStore { + pub(crate) fn new() -> Self { + Self { + buffers: RefCell::new(Vec::new()), + } + } + + fn replace(&self, buffers: Vec>) { + self.buffers.replace(buffers); + } + + fn buffer_count(&self) -> usize { + self.buffers.borrow().len() + } + + #[cfg(test)] + fn len(&self) -> usize { + self.buffer_count() + } +} + +impl Clone for TextRunBufferStore { + fn clone(&self) -> Self { + Self { + buffers: RefCell::new(self.buffers.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() + } +} + +struct ShapedRenderRun { + buffer: Arc, + origin: Point, + color: Color, +} + +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(); + + let shaped_runs = runs + .iter() + .map(|run| shape_render_run(run, config, font_system)) + .collect::>(); + let line_baselines = collect_line_baselines(runs, &shaped_runs); + + for (run, shaped) in runs.iter().zip(&shaped_runs) { + let line_baseline = + baseline_for_line(&line_baselines, run.line(), shaped.baseline_y()); + let position = Point::new( + shaped.origin.x, + shaped.origin.y + line_baseline - shaped.baseline_y(), + ); + + draw_shaped_run(renderer, shaped, position, clip_bounds); + } + + retained_buffers.replace( + shaped_runs + .into_iter() + .map(|shaped| shaped.buffer) + .collect(), + ); +} + +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), + ); + } + + let origin = 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), + ); + + ShapedRenderRun { + buffer: Arc::new(buffer), + origin, + color: raw_color, + } +} + +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) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct LineBaseline { + line: i32, + baseline: f32, +} + +impl ShapedRenderRun { + fn baseline_y(&self) -> f32 { + self.buffer + .layout_runs() + .next() + .map_or(0.0, |layout_run| layout_run.line_y) + } +} + +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::for_test( + text, + 2, + 3, + 5, + RenderTextStyle::for_test(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, + ) + } + + #[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 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!(shaped.origin.x, 34.0); + assert_eq!(first_glyph_x, Some(0.0)); + assert!(shaped.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()); + + assert_eq!(plain.origin.y, complex.origin.y); + } + + #[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::for_test( + text, + 2, + 3, + 2, + RenderTextStyle::for_test(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::for_test( + text, + 2, + 3, + 1, + RenderTextStyle::for_test(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::for_test( + "abc", + 2, + 3, + 3, + RenderTextStyle::for_test(Color::WHITE, Font::MONOSPACE), + ); + let colored = RenderRun::for_test_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::for_test( + "otty git:(chars)x", + 0, + 0, + 17, + RenderTextStyle::for_test(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.len(), 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::for_test_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.len(), 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.len(), 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..f85c299 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(), } } diff --git a/tasks/.gitkeep b/tasks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/shaping.md b/tasks/shaping.md deleted file mode 100644 index 284635b..0000000 --- a/tasks/shaping.md +++ /dev/null @@ -1,437 +0,0 @@ -# Terminal text shaping - -## Цель - -Спроектировать и реализовать долгосрочную архитектуру отрисовки complex text в -терминале без отказа от cell-based модели. Grid остается источником истины для -терминального состояния, а renderer строит временные text runs только на время -кадра и только для передачи в text shaper. - -Задача нужна из-за текущего дефекта: `otty-surface` хранит zero-width combining -marks в `Cell::zerowidth()`, но `otty-ui-terminal` при отрисовке передает в -`Text.content` только `cell.c`. В результате Thai tone marks, Arabic harakat, -Hebrew niqqud, decomposed Latin accents и другие combining marks могут быть -потеряны или расположены неправильно. - -Эта задача не должна решаться набором коротких language-specific или per-cell -фиксов. Нужный результат - renderer, который корректно shape-ит текстовые runs -поверх terminal grid и сохраняет cell-based поведение TUI, selection, cursor, -links и resize. - -## Текущая модель - -- `otty-surface` хранит терминальную сетку: base character, zero-width marks, - cell flags, colors, cursor, selection, hyperlinks, scrollback и wrapping. -- `otty-ui-terminal` рисует viewport из snapshot-а surface. -- Фон, cursor, selection и hyperlink hit testing уже естественно выражены через - grid cells. -- Текст сейчас рисуется per cell: одна canvas `Text` команда получает один - `cell.c`. - -Эта модель хорошо подходит для TUI geometry, но плохо подходит для complex text: -shaper не видит полный grapheme cluster, соседние glyphs и contextual forms. - -## Предлагаемый подход - -Оставить surface cell-based, но заменить text rendering path на run-based -отрисовку поверх сетки. - -Renderer должен делать два независимых прохода: - -1. Cell-based geometry pass: - - background rectangles; - - cursor rectangle; - - selection rectangles; - - hyperlink hover underline; - - block UI overlays. - -2. Text shaping pass: - - пройти по видимым строкам snapshot-а; - - собрать текст каждой renderable cell как `cell.c + cell.zerowidth()`; - - объединить соседние cells в `RenderRun`, пока их text style совместим; - - передать весь run в shaper; - - нарисовать shaped text в grid-aligned rectangle, начинающемся с первой - колонки run-а. - -Surface при этом не должен знать про Arabic shaping, bidi, Thai positioning, -font fallback или glyph placement. Его ответственность - терминальная модель, не -rendering. - -## Non-goals - -Эта задача не должна ограничиваться следующими частичными решениями: - -- append `Cell::zerowidth()` к per-cell `canvas::Text`; -- language-specific storage hacks в `otty-surface` как основной путь решения; -- ручное позиционирование Thai/Arabic/Hebrew marks в renderer-е; -- включение Unicode normalization как замена shaping; -- изменение terminal grid в сторону plain text buffer. - -Такие изменения могут быть полезны для диагностики или временного сравнения, но -они не закрывают требования задачи и не должны быть финальным design direction. - -## Run-based renderer design - -### RenderRun - -Нужен внутренний тип только для renderer-а: - -```rust -struct RenderRun { - text: String, - line: i32, - start_column: usize, - cell_columns: usize, - style: RenderTextStyle, -} -``` - -`cell_columns` - terminal width run-а в cells. Для wide character он должен -учитывать две колонки. `WIDE_CHAR_SPACER` cells не должны добавлять отдельный -символ в run. - -### RenderTextStyle - -Run можно продолжать только пока совместимы: - -- resolved foreground color; -- resolved font family; -- bold/dim-bold weight; -- italic style; -- selected/inverse state after color resolution; -- cursor text color override, если cursor меняет foreground. - -Background не обязан участвовать в text run style, если он уже рисуется отдельным -cell-based pass-ом. - -Hyperlink hover и selection могут менять foreground или underline. Если меняют, -run нужно split-ить на границах этих состояний. - -### Grid alignment - -Терминальная геометрия не должна зависеть от natural glyph advances. - -Первый вариант реализации: - -- run origin: `layout_x + start_column * cell_width`; -- run width: `cell_columns * cell_width`; -- line origin: текущая terminal row baseline/center; -- layout width для shaper-а равен run width; -- фон, cursor и selection остаются привязанными к cells. - -Более строгий вариант для будущего: - -- хранить cluster-to-cell mapping; -- контролировать, что visual advance cluster-а не сдвигает соседние terminal - columns; -- не split-ить run внутри `base + zerowidth` cluster-а. - -## TUI behavior - -TUI layout должен остаться стабильным, если выполняются правила: - -- grid остается единственным источником cursor position и hit testing; -- total rendered width строки не превышает terminal cell width allocation; -- runs split-ятся на style boundaries, но не меняют cell geometry; -- wide char spacer cells не создают отдельного glyph-а; -- tabs продолжают обрабатываться текущей terminal моделью. - -Риск для TUI: если shaper natural advances будут использоваться без -ограничения grid width, borders и columns могут визуально съехать. Поэтому -renderer должен считать grid width сильным constraint-ом. - -## Text clipping - -Shaped text должен clip-иться по terminal viewport/widget bounds. - -Правила: - -- glyphs, combining marks, italic overhang и fallback glyphs не должны рисоваться - за пределами terminal viewport; -- visual glyph bounds не расширяют terminal drawing area; -- tight per-run clipping по exact cell range не включается по умолчанию, потому - что он может обрезать combining marks и полезный glyph overhang; -- небольшой overflow внутри terminal viewport допустим, если он нужен для - корректного shaping; -- per-run clipping можно добавить только после измеренной regression и только с - проверкой, что он не ломает complex-script marks. - -## Hyperlink hover - -Hyperlink hit testing должен остаться cell-based: - -- mouse position -> grid point; -- grid point -> hyperlink span id; -- hover underline рисуется по cells; -- run splitting нужен только если hover меняет text style. - -Риск: если run содержит cells из разных hyperlink states и hover меняет -foreground, часть текста может получить неверный стиль. Для этого run builder -должен учитывать hovered span id как style boundary. - -Hover underline также должен оставаться cell-based даже при complex glyph -overflow: - -- underline рисуется по terminal cell range, принадлежащему hyperlink span; -- visual overflow shaped glyphs не расширяет clickable area и underline bounds; -- glyph overflow должен решаться clipping-ом run/viewport, а не изменением - hyperlink geometry; -- это сохраняет совпадение hover, selection, cursor и TUI grid semantics. - -## Selection - -Selection также остается cell-based: - -- selected background рисуется прямоугольниками cells; -- selected foreground вычисляется как сейчас через resolved colors; -- text runs split-ятся на selected/unselected boundaries. - -Риск: split selection посередине complex-script слова ухудшит shaping на границе -selection. Это типичное ограничение cell selection в терминалах. Минимальное -правило - не split-ить внутри одной cell cluster. - -## Resize - -Resize не требует persistent text layout state: - -- surface пересчитывает grid/reflow; -- snapshot обновляется; -- renderer заново собирает runs из нового snapshot-а. - -Кеши runs должны инвалидироваться при: - -- изменении snapshot/damage; -- изменении font или font size; -- изменении cell size; -- изменении theme; -- изменении selection; -- изменении hovered hyperlink span; -- изменении display offset. - -## Run caching decision - -Начальный renderer не должен кешировать shaped glyph layouts до появления -benchmark-данных, показывающих, что shaping является bottleneck. - -Первый этап: - -- собирать `RenderRun` из visible snapshot rows; -- при необходимости кешировать или пересобирать только render runs по - damaged/affected rows; -- каждый frame shape-ить актуальные runs заново; -- держать типы разделенными: `RenderRun` для text/cell/style mapping и - `ShapedRenderRun` для будущего glyph layout cache. - -Причины: - -- run construction дешевле и проще инвалидируется; -- shaped glyph cache требует более сложного ключа: text, style, font metrics, - cell width, shaping mode, line height, font system version и layout bounds; -- selection, cursor и hyperlink hover могут менять split/style boundaries; -- resize, font changes и theme changes легко делают cached glyph positions - stale. - -Архитектура должна позволять добавить shaped-run cache позже без переписывания -run builder-а. Решение о shaped cache принимается только после измерений на -ASCII logs, colorful TUI, scrollback и complex-script samples. - -## CPU и память - -Текущий renderer потенциально создает text draw/shaping operation на каждую -непустую cell. Run-based renderer может уменьшить количество операций до -количества style runs в viewport. - -Ожидаемое влияние: - -- plain output: CPU может снизиться из-за меньшего числа text operations; -- colorful TUI: CPU зависит от количества style boundaries и может остаться - близким к текущему; -- complex scripts: shaping run-а дороже, но корректнее; -- память: временные strings и metadata по visible rows обычно десятки килобайт - для обычного viewport-а. - -Нужны измерения до включения по умолчанию: - -- viewport 80x24, 120x40, 200x60; -- plain ASCII log; -- colorful TUI с частыми style changes; -- Thai/Arabic/Hebrew sample text; -- scroll performance; -- resize performance. - -## Bidi - -Run-based shaping не обязан сразу включать Unicode bidi reordering. - -Причины: - -- многие terminal applications уже выводят visual order; -- автоматический bidi может сломать TUI alignment и mixed LTR/RTL prompts; -- shaping Arabic contextual forms полезен отдельно от bidi. - -Начальный scope: shape text in grid order. Bidi рассматривать как отдельную -явно спроектированную опцию после измерений и тестов. - -Default renderer должен сохранять terminal grid order и не применять -автоматический line-wide Unicode bidi reordering. - -Приемлемое поведение для этой задачи: - -- renderer shape-ит runs без нарушения cell ownership; -- emitted/grid order остается источником истины для visual placement; -- selection, cursor, hyperlink mapping и mouse hit testing остаются в grid order; -- full bidi visual reordering считается out of scope для начального run-based - renderer-а; -- bidi reordering можно добавлять позже только как explicit setting после - отдельного design-а и regression-тестов на TUI, cursor, selection и links. - -## Iced and cosmic-text capabilities - -Текущий `iced` canvas `Text` недостаточен как основной API для строгого -run-based terminal renderer-а. - -Причины: - -- `canvas::Text` позволяет задать `max_width`, но не позволяет явно задать - wrapping mode; -- внутри `canvas::Text` используется default wrapping, а default в `iced` это - word wrapping; -- terminal renderer не должен получать paragraph-like переносы строк внутри - одного grid row; -- `canvas::Text` не дает доступа к cluster/glyph mapping и не прокидывает - `cosmic_text::Buffer::set_monospace_width`; -- для terminal grid важно контролировать run width, clipping, split boundaries и - glyph positions более явно. - -`cosmic-text` как нижний уровень выглядит достаточным для прототипа: - -- `Buffer::set_size` задает bounds run-а; -- `Buffer::set_wrap(Wrap::None)` отключает переносы; -- `Buffer::set_monospace_width(Some(cell_width))` может помочь удерживать - monospace glyphs в terminal grid; -- `layout_runs()` возвращает glyph positions, byte ranges и bidi levels. - -Вывод: целевой renderer должен использовать lower-level путь: либо -`iced_graphics::text::Paragraph` с явным `Wrapping::None`, либо прямую работу с -`iced_graphics::text::cosmic_text`. Даже при использовании `cosmic-text` grid -alignment остается ответственностью renderer-а: нужно задавать run bounds, -clip-region и style/split boundaries. - -## Renderer mode setting - -Renderer mode для сравнения per-cell и run-based implementation должен жить в -application settings. - -Правила: - -- setting нужен для экспериментального rollout-а, диагностики и сравнения - поведения на реальных TUI/screens; -- env var не подходит как основной механизм, потому что пользовательские и QA - сценарии должны быть воспроизводимы через обычную конфигурацию приложения; -- compile-time switch не подходит как основной механизм, потому что он усложняет - сравнение поведения в одном build-е; -- значение должно быть явным enum, например `Cell` и `Runs`; -- если setting временный, это нужно отметить в задаче rollout-а и удалить после - стабилизации run-based renderer-а. - -## Thai and Lao SARA AM - -`U+0E33` Thai SARA AM и `U+0EB3` Lao VOWEL SIGN AM являются отдельным спорным -случаем. - -Surface-level decomposition не является target design для этой задачи: - -- `U+0E33` -> `U+0E4D` + `U+0E32`; -- `U+0EB3` -> `U+0ECD` + `U+0EB2`. - -Такой подход может работать с текущей per-cell отрисовкой и grid storage, но он -закрепляет language-specific знание в terminal model. - -Главный минус: меняет текстовую модель, copy может вернуть decomposed pair -вместо исходного codepoint. Это compatibility decomposition, не canonical NFC -equivalence. NFC не раскладывает `U+0E33`. - -Целевой вариант для этой задачи: не добавлять language-specific storage hacks в -surface, а дать renderer-у shape-ить соседние cells как один run. Нужно отдельно -проверить, сможет ли выбранный text stack корректно расположить SARA AM при -run-based shaping без surface decomposition. Surface-level decomposition можно -рассматривать только как явно задокументированный fallback, если lower-level -shaping не способен корректно отрисовать этот случай при сохранении terminal -grid. - -## Этапы реализации - -### Этап 1. Зафиксировать целевое поведение - -- Зафиксировать, что surface остается cell-based source of truth. -- Добавить regression samples для renderer-а: - - `e\u{0301}`; - - Thai `ที่นี่`; - - Thai `น้ำ` и `กำลัง`; - - Lao `ນ້ຳ` или аналогичный sample с `U+0EB3`; - - Arabic base + harakat; - - Hebrew base + niqqud. -- Добавить fixtures для ASCII и colorful TUI layouts, чтобы проверять отсутствие - regressions в cell geometry. - -### Этап 2. RenderRun model - -- Добавить private run builder в `otty-ui-terminal`. -- Собрать runs per visible row из snapshot cells. -- Сохранять mapping между run text и terminal columns. -- Split по style boundaries: font, weight, italic, resolved foreground, - selection, inverse, cursor text override, hovered hyperlink state. -- Не split-ить внутри `base + zerowidth` cluster-а. -- Учитывать wide chars и `WIDE_CHAR_SPACER`. -- Добавить unit-тесты run builder-а без canvas и без shaper-а. - -### Этап 3. Lower-level shaping backend - -- Реализовать shaping path через `iced_graphics::text::Paragraph` с - `Wrapping::None` или напрямую через `cosmic_text::Buffer`. -- Явно задавать run bounds: `cell_columns * cell_width`. -- Явно отключать wrapping. -- Применять обязательный clipping по terminal viewport/widget bounds. -- Не включать tight per-run clipping по умолчанию; рассматривать его только - после измеренной regression и проверки complex-script samples. -- Проверить применимость `set_monospace_width(Some(cell_width))`. -- Сохранять доступ к glyph positions и byte ranges для диагностики и будущего - cluster-to-cell mapping. - -### Этап 4. Интеграция с cell-based geometry - -- Оставить background, cursor, selection, hyperlink hover и block UI - cell-based. -- Заменить только text rendering path на shaped runs. -- Убедиться, что hover hit testing, selection ranges и cursor movement не - зависят от shaped glyph positions. -- Проверить, что resize не хранит stale text layout state. -- Добавить внутренний renderer mode только если он нужен для сравнения и - безопасного rollout-а. - -### Этап 5. Validation and rollout - -- Включать run-based renderer только после regression screenshots и benchmark - данных. -- Сравнить per-cell и run-based rendering на sample screens. -- Измерить CPU и allocation behavior. -- Проверить hover links, selection, cursor, scroll и resize. -- Если TUI regressions значимые, исправить run construction/alignment, а не - подменять задачу per-cell workaround-ом. - -## Acceptance criteria - -- Combining marks больше не теряются при отрисовке. -- `base + zerowidth` cluster всегда передается shaper-у вместе. -- Текстовый renderer shape-ит contiguous runs, а не отдельные cells. -- TUI geometry остается cell-aligned. -- Hyperlink hover hit testing остается cell-based. -- Selection background и cursor не зависят от text shaping. -- Resize не хранит stale layout state. -- Performance на ASCII/TUI viewport не хуже текущей без принятого объяснения. -- Thai/Lao SARA AM проверен на renderer-only shaping; любой fallback на уровне - surface должен быть отдельно обоснован как последнее средство. - -## Открытые вопросы - -На текущем уровне проектирования открытых вопросов нет. From a88741cad9c030ee6e23c435b7aa5294ac4d5952 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 23:41:59 +0200 Subject: [PATCH 04/12] feat: audit to mr --- .github/workflows/audit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/audit.yaml b/.github/workflows/audit.yaml index e50f2c5..5695197 100644 --- a/.github/workflows/audit.yaml +++ b/.github/workflows/audit.yaml @@ -2,6 +2,7 @@ name: Security Audit on: push: + pull_request: schedule: - cron: '0 2 * * *' From 03bb6377bbca2c286091a51c2d73477dca057524 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 23:49:23 +0200 Subject: [PATCH 05/12] feat: audit to mr --- .github/workflows/audit.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit.yaml b/.github/workflows/audit.yaml index 5695197..9088b19 100644 --- a/.github/workflows/audit.yaml +++ b/.github/workflows/audit.yaml @@ -1,8 +1,10 @@ name: Security Audit on: - push: pull_request: + push: + branches: + - main schedule: - cron: '0 2 * * *' From aa402286d99f56bca0c6ec43e6b68d782b141712 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 23:50:45 +0200 Subject: [PATCH 06/12] ci: retrigger audit workflow From 3263a109eb9500bc84128d2b2504f550a9bf2173 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Fri, 3 Jul 2026 23:53:21 +0200 Subject: [PATCH 07/12] feat: audit to mr --- .github/ISSUE_TEMPLATE/10_bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/20_feature_request.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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: From e7b302fe7956bfe23b13cc9a830b5acb677a5f4d Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Sat, 4 Jul 2026 00:15:17 +0200 Subject: [PATCH 08/12] feat: audit to mr --- AGENTS.md | 4 +- otty-ui/terminal/src/render_runs.rs | 11 ++ otty-ui/terminal/src/shaped_text.rs | 219 +++++++++++++++++++++++----- otty-ui/terminal/src/view.rs | 28 +++- 4 files changed, 224 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad59930..3c6d433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ General context lives in [README.md](./README.md) at the repository root. - 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 stay prefixed with `otty-`. +- 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). diff --git a/otty-ui/terminal/src/render_runs.rs b/otty-ui/terminal/src/render_runs.rs index 65d1cec..c88a6aa 100644 --- a/otty-ui/terminal/src/render_runs.rs +++ b/otty-ui/terminal/src/render_runs.rs @@ -1,3 +1,10 @@ +//! 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}; @@ -8,6 +15,7 @@ use otty_libterm::surface::{ use crate::theme::Theme; +/// Shape-ready terminal text with grid position and foreground spans. #[derive(Debug, Clone, PartialEq)] pub(crate) struct RenderRun { text: String, @@ -18,6 +26,7 @@ pub(crate) struct RenderRun { color_spans: Vec, } +/// Foreground color range within a [`RenderRun`]. #[derive(Debug, Clone, PartialEq)] pub(crate) struct RenderTextSpan { byte_range: Range, @@ -68,6 +77,7 @@ impl RenderTextSpan { } } +/// Resolved text style for one terminal cell during run construction. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct RenderTextStyle { foreground: Color, @@ -78,6 +88,7 @@ pub(crate) struct RenderTextStyle { hovered_hyperlink: bool, } +/// Build shape-ready text runs for the current terminal snapshot view. pub(crate) fn build_render_runs( view: &SnapshotView<'_>, theme: &Theme, diff --git a/otty-ui/terminal/src/shaped_text.rs b/otty-ui/terminal/src/shaped_text.rs index 795a6dd..fefd428 100644 --- a/otty-ui/terminal/src/shaped_text.rs +++ b/otty-ui/terminal/src/shaped_text.rs @@ -1,3 +1,10 @@ +//! 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; @@ -8,6 +15,7 @@ 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, @@ -18,6 +26,7 @@ pub(crate) struct TextRunDrawConfig { } impl TextRunDrawConfig { + /// Create draw configuration from the current terminal layout metrics. pub(crate) fn new( layout_position: Point, display_offset: f32, @@ -37,24 +46,42 @@ impl TextRunDrawConfig { } } -/// Keeps raw shaped text buffers alive until the renderer consumes them. +/// Keeps shaped text buffers alive and reuses them across unchanged draws. pub(crate) struct TextRunBufferStore { - buffers: RefCell>>, + cache: RefCell, } impl TextRunBufferStore { pub(crate) fn new() -> Self { Self { - buffers: RefCell::new(Vec::new()), + cache: RefCell::new(TextRunShapeCache::default()), } } - fn replace(&self, buffers: Vec>) { - self.buffers.replace(buffers); + 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)); } fn buffer_count(&self) -> usize { - self.buffers.borrow().len() + self.cache.borrow().shaped_runs.len() } #[cfg(test)] @@ -66,7 +93,7 @@ impl TextRunBufferStore { impl Clone for TextRunBufferStore { fn clone(&self) -> Self { Self { - buffers: RefCell::new(self.buffers.borrow().clone()), + cache: RefCell::new(self.cache.borrow().clone()), } } } @@ -79,12 +106,48 @@ impl std::fmt::Debug for TextRunBufferStore { } } +#[derive(Clone)] struct ShapedRenderRun { buffer: Arc, - origin: Point, color: Color, } +#[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, +} + +/// Shape, cache, and draw terminal text runs through the Iced text renderer. pub(crate) fn draw_render_runs( renderer: &mut Renderer, runs: &[RenderRun], @@ -97,30 +160,23 @@ pub(crate) fn draw_render_runs( 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 shaped_runs = runs - .iter() - .map(|run| shape_render_run(run, config, font_system)) - .collect::>(); - let line_baselines = collect_line_baselines(runs, &shaped_runs); + 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) { + 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( - shaped.origin.x, - shaped.origin.y + line_baseline - shaped.baseline_y(), + origin.x, + origin.y + line_baseline - shaped.baseline_y(), ); draw_shaped_run(renderer, shaped, position, clip_bounds); } - - retained_buffers.replace( - shaped_runs - .into_iter() - .map(|shaped| shaped.buffer) - .collect(), - ); } fn shape_render_run( @@ -150,19 +206,20 @@ fn shape_render_run( ); } - let origin = Point::new( + 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), - ); - - ShapedRenderRun { - buffer: Arc::new(buffer), - origin, - color: raw_color, - } + ) } fn draw_shaped_run( @@ -357,6 +414,21 @@ mod tests { ) } + 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"); @@ -365,6 +437,7 @@ mod tests { 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() @@ -375,9 +448,9 @@ mod tests { 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!(shaped.origin.x, 34.0); + assert_eq!(origin.x, 34.0); assert_eq!(first_glyph_x, Some(0.0)); - assert!(shaped.origin.y.is_finite()); + assert!(origin.y.is_finite()); } #[test] @@ -391,8 +464,12 @@ mod tests { 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_eq!(plain_origin.y, complex_origin.y); + assert!(plain.baseline_y().is_finite()); + assert!(complex.baseline_y().is_finite()); } #[test] @@ -629,6 +706,80 @@ mod tests { assert_eq!(retained_buffers.len(), 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, diff --git a/otty-ui/terminal/src/view.rs b/otty-ui/terminal/src/view.rs index f85c299..7504277 100644 --- a/otty-ui/terminal/src/view.rs +++ b/otty-ui/terminal/src/view.rs @@ -963,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); + } } } @@ -1005,7 +1007,7 @@ impl TerminalViewState { Instant::now(), ); } else { - shell.request_redraw(); + shell.request_redraw_at(deadline); } } } @@ -1166,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); + } } From 3c2f1bbcd41a4d565ace376f125d26263da145c0 Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Sun, 5 Jul 2026 23:09:04 +0200 Subject: [PATCH 09/12] feat: audit to mr --- otty-surface/src/snapshot.rs | 41 ++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) 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 = From 2a2e8a557b42f17800eb5b73ca3db92f36f350ef Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Sun, 5 Jul 2026 23:42:37 +0200 Subject: [PATCH 10/12] feat: audit to mr --- otty-ui/terminal/src/render_runs.rs | 295 ++++++++++++++-------------- otty-ui/terminal/src/shaped_text.rs | 75 ++++--- 2 files changed, 182 insertions(+), 188 deletions(-) diff --git a/otty-ui/terminal/src/render_runs.rs b/otty-ui/terminal/src/render_runs.rs index c88a6aa..ad14fe6 100644 --- a/otty-ui/terminal/src/render_runs.rs +++ b/otty-ui/terminal/src/render_runs.rs @@ -16,7 +16,7 @@ use otty_libterm::surface::{ use crate::theme::Theme; /// Shape-ready terminal text with grid position and foreground spans. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq)] pub(crate) struct RenderRun { text: String, line: i32, @@ -26,15 +26,6 @@ pub(crate) struct RenderRun { color_spans: Vec, } -/// 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 RenderRun { pub(crate) fn text(&self) -> &str { &self.text @@ -67,134 +58,14 @@ impl RenderRun { } } -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, Clone, Copy, PartialEq)] -pub(crate) struct RenderTextStyle { - foreground: Color, - font: Font, - selected: bool, - inverse: bool, - cursor_override: bool, - hovered_hyperlink: 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) - }) -} - -struct RenderRunBuildContext<'a> { - selection: Option<&'a SelectionRange>, - cursor_point: TerminalPoint, - theme: &'a Theme, - base_font: Font, - hovered_span_id: Option, - cursor_text_override: bool, -} - -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 -} - impl RenderRun { fn new(line: i32, start_column: usize, font: Font) -> Self { Self { text: String::new(), line, start_column, - cell_columns: 0, font, - color_spans: Vec::new(), + ..Default::default() } } @@ -247,9 +118,11 @@ impl RenderRun { fn end_column(&self) -> usize { self.start_column + self.cell_columns } +} - #[cfg(test)] - pub(crate) fn for_test( +#[cfg(test)] +impl RenderRun { + pub(crate) fn new_empty_color_spans( text: &str, line: i32, start_column: usize, @@ -274,8 +147,7 @@ impl RenderRun { } } - #[cfg(test)] - pub(crate) fn for_test_with_color_spans( + pub(crate) fn new_with_color_spans( text: &str, line: i32, start_column: usize, @@ -304,6 +176,145 @@ impl RenderRun { } } +/// 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, @@ -383,20 +394,6 @@ where } } -#[cfg(test)] -impl RenderTextStyle { - pub(crate) fn for_test(foreground: Color, font: Font) -> Self { - Self { - foreground, - font, - selected: false, - inverse: false, - cursor_override: false, - hovered_hyperlink: false, - } - } -} - #[cfg(test)] mod tests { use iced::font::{Style as FontStyle, Weight as FontWeight}; diff --git a/otty-ui/terminal/src/shaped_text.rs b/otty-ui/terminal/src/shaped_text.rs index fefd428..f2f8b46 100644 --- a/otty-ui/terminal/src/shaped_text.rs +++ b/otty-ui/terminal/src/shaped_text.rs @@ -51,6 +51,12 @@ 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 { @@ -79,15 +85,6 @@ impl TextRunBufferStore { .collect(); cache.key = Some(TextRunShapeKey::new(runs, config)); } - - fn buffer_count(&self) -> usize { - self.cache.borrow().shaped_runs.len() - } - - #[cfg(test)] - fn len(&self) -> usize { - self.buffer_count() - } } impl Clone for TextRunBufferStore { @@ -112,6 +109,15 @@ struct ShapedRenderRun { 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, @@ -147,6 +153,12 @@ struct TextRunShapeCache { 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, @@ -337,21 +349,6 @@ fn grid_fit_letter_spacing( Some(width_delta / glyph_count as f32 / config.font_size) } -#[derive(Debug, Clone, Copy, PartialEq)] -struct LineBaseline { - line: i32, - baseline: f32, -} - -impl ShapedRenderRun { - fn baseline_y(&self) -> f32 { - self.buffer - .layout_runs() - .next() - .map_or(0.0, |layout_run| layout_run.line_y) - } -} - fn collect_line_baselines( runs: &[RenderRun], shaped_runs: &[ShapedRenderRun], @@ -394,12 +391,12 @@ mod tests { use crate::render_runs::{RenderRun, RenderTextStyle}; fn run(text: &str) -> RenderRun { - RenderRun::for_test( + RenderRun::new_empty_color_spans( text, 2, 3, 5, - RenderTextStyle::for_test(Color::WHITE, Font::MONOSPACE), + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), ) } @@ -496,12 +493,12 @@ mod tests { #[test] fn shape_render_run_uses_monospace_width_for_combining_marks() { let text = "e\u{0301}x"; - let run = RenderRun::for_test( + let run = RenderRun::new_empty_color_spans( text, 2, 3, 2, - RenderTextStyle::for_test(Color::WHITE, Font::MONOSPACE), + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), ); let config = config(); let mut font_system = @@ -514,12 +511,12 @@ mod tests { #[test] fn shape_render_run_keeps_hebrew_niqqud_in_layout_text() { let text = "ב\u{05b0}\u{05bc}"; - let run = RenderRun::for_test( + let run = RenderRun::new_empty_color_spans( text, 2, 3, 1, - RenderTextStyle::for_test(Color::WHITE, Font::MONOSPACE), + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), ); let config = config(); let mut font_system = @@ -547,14 +544,14 @@ mod tests { #[test] fn color_spans_keep_glyph_geometry_stable() { let selected_color = Color::from_rgb(0.2, 0.7, 0.9); - let plain = RenderRun::for_test( + let plain = RenderRun::new_empty_color_spans( "abc", 2, 3, 3, - RenderTextStyle::for_test(Color::WHITE, Font::MONOSPACE), + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), ); - let colored = RenderRun::for_test_with_color_spans( + let colored = RenderRun::new_with_color_spans( "abc", 2, 3, @@ -580,12 +577,12 @@ mod tests { #[test] fn shape_render_run_fits_monospace_advances_to_terminal_grid() { - let run = RenderRun::for_test( + let run = RenderRun::new_empty_color_spans( "otty git:(chars)x", 0, 0, 17, - RenderTextStyle::for_test(Color::WHITE, Font::MONOSPACE), + RenderTextStyle::from_fg_and_font(Color::WHITE, Font::MONOSPACE), ); let config = config(); let mut font_system = @@ -627,14 +624,14 @@ mod tests { 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.len(), 1); + 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::for_test_with_color_spans( + let runs = [RenderRun::new_with_color_spans( "abc", 2, 3, @@ -662,7 +659,7 @@ mod tests { 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.len(), 1); + assert_eq!(retained_buffers.buffer_count(), 1); let buffer = renderer.raw_text[0] .buffer @@ -703,7 +700,7 @@ mod tests { ); assert!(renderer.raw_text.is_empty()); - assert_eq!(retained_buffers.len(), 0); + assert_eq!(retained_buffers.buffer_count(), 0); } #[test] From 4cb813c56db0dff33e4bc44b58d4ebec537e7d7c Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Sun, 5 Jul 2026 23:48:45 +0200 Subject: [PATCH 11/12] feat: audit to mr --- .pre-commit-config.yaml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .pre-commit-config.yaml 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 From d9dc4113462d0ba0792a517ea07f3ad0e03aef0d Mon Sep 17 00:00:00 2001 From: Ilya fanyShu Date: Sun, 5 Jul 2026 23:59:20 +0200 Subject: [PATCH 12/12] feat: audit to mr --- AGENTS.md | 1 + CONTRIBUTING.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3c6d433..d0b7adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ General context lives in [README.md](./README.md) at the repository root. - 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. 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`