Skip to content

Commit f2ce53e

Browse files
authored
feat: render chars shaping (#66)
1 parent 3858194 commit f2ce53e

16 files changed

Lines changed: 1782 additions & 89 deletions

File tree

.github/ISSUE_TEMPLATE/10_bug_report.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Bug report
22
title: "bug: "
33
description: Problems and issues with code of OTTY
4-
labels: [ "C-bug" ]
4+
labels: ["bug"]
55
body:
66
- type: markdown
77
attributes:

.github/ISSUE_TEMPLATE/20_feature_request.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Feature request
22
description: Suggest an idea for OTTY
3-
labels: ["C-feature"]
3+
labels: ["enhancement"]
44
body:
55
- type: markdown
66
attributes:

.github/workflows/audit.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
name: Security Audit
22

33
on:
4+
pull_request:
45
push:
6+
branches:
7+
- main
58
schedule:
69
- cron: '0 2 * * *'
710

.pre-commit-config.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
minimum_pre_commit_version: "3.0.0"
2+
3+
repos:
4+
- repo: local
5+
hooks:
6+
- id: fmt-check
7+
name: formatter check
8+
entry: cargo +nightly fmt --check
9+
language: system
10+
pass_filenames: false
11+
always_run: true
12+
- id: clippy
13+
name: clippy check
14+
entry: cargo clippy --workspace --all-targets --all-features -- -D warnings
15+
language: system
16+
pass_filenames: false
17+
always_run: true
18+
- id: lint
19+
name: lint
20+
entry: cargo lint
21+
language: system
22+
pass_filenames: false
23+
always_run: true
24+
- id: deny-check
25+
name: cargo deny check
26+
entry: cargo deny check
27+
language: system
28+
pass_filenames: false
29+
always_run: true
30+
- id: tests
31+
name: Run tests
32+
entry: cargo test --workspace --all-features
33+
language: system
34+
pass_filenames: false
35+
always_run: true

AGENTS.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,23 @@
22

33
General context lives in [README.md](./README.md) at the repository root.
44

5-
## Development workflow
5+
## Rules
66

7-
- Crate names stay prefixed with `otty-`.
7+
- You MUST write the tests before writting the implementation.
8+
- 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.
9+
- You MUST use `mockall` for mocks in RUST code
10+
- You MUST ask me before installing the new dependencies with dependency description and reason.
11+
- 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.
12+
- 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.
13+
- 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.
14+
- 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.
15+
- 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.
16+
- 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.
17+
- 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.
18+
- 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.
19+
- crate names MUST stay prefixed with `otty-`.
820
- Prefer `format!("{value}")`-style interpolation instead of passing variables as separate arguments when formatting strings.
9-
- Add concise documentation comments to new public items to communicate intent.
21+
- You MUST add concise documentation comments to new public items to communicate intent.
1022
- Prefer borrowing over cloning; pass `&T`/`&str` where possible and keep ownership at boundaries.
1123
- Avoid unnecessary heap allocations; use slices and references for read-only data.
1224
- Use `Result`/`Option` for error handling; no `unwrap()` in production code (prefer `expect()` with context during initialization).
@@ -15,6 +27,7 @@ General context lives in [README.md](./README.md) at the repository root.
1527
- 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.
1628
- For `match` on `enum`, prefer a wildcard arm (`_ => ...`) by default for fallback logic.
1729
- Document public items with concise doc comments and examples.
30+
- You MUST run all linters, checks and tests before finishing your work.
1831
- Run `cargo +nightly fmt`, `cargo clippy --workspace --all-targets --all-features -- -D warnings` and fix all errors and warnings.
1932
- Run `cargo deny check` and fix all output errors.
2033
- Run `cargo test --workspace --all-features` all tests MUST be passed

CONTRIBUTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (feat:, fix:, docs:, chore:, refactor:, perf:, test:, build:)
1010
- Use [AGENTS.md](./AGENTS.md) for enriching LLM context
1111

12+
#### Repository setup
13+
14+
- Always run `pre-commit install` when setting up the repository so local commits run the configured checks.
15+
1216
#### Quick start
1317

1418
- Run the desktop app: `cargo run -p otty`

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deny.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
unsound = "workspace"
33
unmaintained = "workspace"
44
yanked = "warn"
5+
ignore = [
6+
{ 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" },
7+
{ 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" },
8+
]
59

610
[licenses]
711
allow = [

otty-libterm/src/terminal/mod.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,47 @@ mod tests {
817817
Ok(())
818818
}
819819

820+
#[test]
821+
fn parses_hebrew_niqqud_into_zero_width_cells() -> anyhow::Result<()> {
822+
let text = \u{05b0}\u{05bc}ר\u{05b5}אש\u{05b4}\u{05c1}ית";
823+
let session = FakeSession::with_reads(vec![text.as_bytes().to_vec()]);
824+
let parser = DefaultParser::default();
825+
let surface =
826+
Surface::new(SurfaceConfig::default(), &TerminalSize::default());
827+
let (mut engine, _handle, events) = TerminalEngine::new(
828+
session,
829+
parser,
830+
surface,
831+
TerminalOptions::default(),
832+
)?;
833+
834+
engine.on_readable()?;
835+
836+
let collected = collect_events(&events);
837+
let frame = match collected.last() {
838+
Some(TerminalEvent::Frame { frame }) => frame,
839+
_ => panic!("expected frame event last"),
840+
};
841+
let view = frame.view();
842+
let visible_text = view
843+
.cells
844+
.iter()
845+
.filter(|cell| cell.cell.c != ' ')
846+
.map(|cell| {
847+
let mut text = cell.cell.c.to_string();
848+
if let Some(zerowidth) = cell.cell.zerowidth() {
849+
text.extend(zerowidth.iter());
850+
}
851+
text
852+
})
853+
.collect::<Vec<_>>()
854+
.join("");
855+
856+
assert_eq!(visible_text, text);
857+
858+
Ok(())
859+
}
860+
820861
#[test]
821862
fn propagates_action_events_before_frame_delivery() -> anyhow::Result<()> {
822863
let actions = vec![

otty-surface/src/snapshot.rs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,9 @@ impl<'a> SnapshotView<'a> {
259259
for indexed in self.cells {
260260
if range.contains(indexed.point) {
261261
result.push(indexed.cell.c);
262+
if let Some(zerowidth) = indexed.cell.zerowidth() {
263+
result.extend(zerowidth.iter());
264+
}
262265
}
263266
}
264267
}
@@ -290,7 +293,7 @@ mod tests {
290293
use super::*;
291294
use crate::actor::SurfaceActor;
292295
use crate::cell::Hyperlink;
293-
use crate::index::{Column, Line};
296+
use crate::index::{Column, Line, Side};
294297
use crate::selection::SelectionType;
295298
use crate::{
296299
SnapshotDamage, SnapshotView, Surface, SurfaceConfig, SurfaceModel,
@@ -360,14 +363,10 @@ mod tests {
360363
surface.reset_damage();
361364

362365
let start = Point::new(crate::index::Line(0), crate::index::Column(0));
363-
surface.start_selection(
364-
SelectionType::Simple,
365-
start,
366-
crate::index::Side::Left,
367-
);
366+
surface.start_selection(SelectionType::Simple, start, Side::Left);
368367
surface.update_selection(
369368
Point::new(crate::index::Line(0), crate::index::Column(1)),
370-
crate::index::Side::Right,
369+
Side::Right,
371370
);
372371

373372
let frame = surface.snapshot_owned();
@@ -377,6 +376,34 @@ mod tests {
377376
assert_eq!(view.cursor.point, surface.grid().cursor.point);
378377
}
379378

379+
#[test]
380+
fn selectable_content_preserves_zero_width_marks() {
381+
let mut surface =
382+
Surface::new(SurfaceConfig::default(), &TestDimensions::new(40, 2));
383+
let text = "ที่นี่, น้ำ, or กำลัง";
384+
for ch in text.chars() {
385+
surface.print(ch);
386+
}
387+
388+
let has_zero_width_marks = surface.grid().display_iter().any(|cell| {
389+
cell.cell.zerowidth().is_some_and(|marks| !marks.is_empty())
390+
});
391+
assert!(has_zero_width_marks);
392+
393+
surface.start_selection(
394+
SelectionType::Simple,
395+
Point::new(Line(0), Column(0)),
396+
Side::Left,
397+
);
398+
let end_column = surface.grid().cursor.point.column - 1;
399+
surface.update_selection(Point::new(Line(0), end_column), Side::Right);
400+
401+
let frame = surface.snapshot_owned();
402+
let view = frame.view();
403+
404+
assert_eq!(view.selectable_content(), text);
405+
}
406+
380407
#[test]
381408
fn osc_hyperlink_is_exposed_in_snapshot() {
382409
let mut surface =

0 commit comments

Comments
 (0)