Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/10_bug_report.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/20_feature_request.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Feature request
description: Suggest an idea for OTTY
labels: ["C-feature"]
labels: ["enhancement"]
body:
- type: markdown
attributes:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/audit.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
name: Security Audit

on:
pull_request:
push:
branches:
- main
schedule:
- cron: '0 2 * * *'

Expand Down
35 changes: 35 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 16 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@

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

## Development workflow
## Rules

- Crate names stay prefixed with `otty-`.
- You MUST write the tests before writting the implementation.
- You MUST write tests only for business-significant packages such as usecases, repositories, helpers, and domain logic. Do not add tests for infrastructure/bootstrap packages such as lifecycle, config, metrics, logging, or server wiring unless they contain business-significant behavior.
- You MUST use `mockall` for mocks in RUST code
- You MUST ask me before installing the new dependencies with dependency description and reason.
- You MUST prefer simple, direct, readable code with explicit business logic. Avoid clever generics, macros, type gymnastics, and dense control flow when straightforward code is easier to understand.
- You MUST keep each source file focused on one cohesive responsibility. When one file combines multiple independent responsibilities or becomes difficult to navigate, split it into clearly named modules and files, with each file owning one responsibility. Do not split tightly coupled logic solely because of line count, and do not introduce empty, pass-through, or speculative modules.
- Rust modules MUST be organized in this order: imports; structs with their implementations; public functions; private functions; tests. Each struct declaration MUST be followed immediately by its related implementations before declaring the next struct. Within a struct's implementations, use this order: getters; constructors and other logic methods; `#[cfg(test)]` implementations; trait implementations. Use `otty-ui/terminal/src/render_runs.rs` and `otty-ui/terminal/src/shaped_text.rs` as good examples of this layout.
- You MUST separate distinct logical phases inside Rust functions with a single blank line, including input preparation, validation or branching, external or repository I/O, state changes, and result construction. Keep statements that form one tightly coupled operation together; do not add a blank line after every statement mechanically.
- You MUST NOT create abstractions by default. Every new trait, interface, layer, factory, manager, service, or extension point MUST solve a current problem and be briefly justified. "Maybe useful later" is not a valid justification; use a concrete implementation or private function instead.
- You MAY introduce an abstraction only when it has multiple real implementations, crosses an actual infrastructure boundary, protects domain or usecase code from infrastructure, removes duplication with the same business meaning and reason to change, or makes testing significantly simpler without hiding logic.
- You MUST prefer meaningful domain names over generic names such as `Manager`, `Processor`, `Helper`, `Service`, or `Util`. Split functions, files, and layers only when doing so improves the current design and readability.
- You MUST apply DRY only when duplicated logic has the same business meaning and changes for the same reason. Duplication is acceptable when extraction would create a vague or harder-to-read abstraction, and speculative traits, configuration, factories, placeholder layers, and unused extension points are forbidden by YAGNI.
- crate names MUST stay prefixed with `otty-`.
- Prefer `format!("{value}")`-style interpolation instead of passing variables as separate arguments when formatting strings.
- Add concise documentation comments to new public items to communicate intent.
- You MUST add concise documentation comments to new public items to communicate intent.
- Prefer borrowing over cloning; pass `&T`/`&str` where possible and keep ownership at boundaries.
- Avoid unnecessary heap allocations; use slices and references for read-only data.
- Use `Result`/`Option` for error handling; no `unwrap()` in production code (prefer `expect()` with context during initialization).
Expand All @@ -15,6 +27,7 @@ General context lives in [README.md](./README.md) at the repository root.
- Do not expose struct fields as `pub`; use idiomatic Rust accessors for reads (`field()` or `is_*` for booleans), and prefer domain-specific mutators for writes (use `set_*` only when a generic setter is the clearest option, or keep mutation local to the module). Exception: plain input/context structs with no invariants to protect (e.g. feature `Ctx` types passed into `reduce`) MAY use `pub(crate)` fields directly — accessors would be unnecessary boilerplate for parameter bags.
- For `match` on `enum`, prefer a wildcard arm (`_ => ...`) by default for fallback logic.
- Document public items with concise doc comments and examples.
- You MUST run all linters, checks and tests before finishing your work.
- Run `cargo +nightly fmt`, `cargo clippy --workspace --all-targets --all-features -- -D warnings` and fix all errors and warnings.
- Run `cargo deny check` and fix all output errors.
- Run `cargo test --workspace --all-features` all tests MUST be passed
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
41 changes: 41 additions & 0 deletions otty-libterm/src/terminal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.join("");

assert_eq!(visible_text, text);

Ok(())
}

#[test]
fn propagates_action_events_before_frame_delivery() -> anyhow::Result<()> {
let actions = vec![
Expand Down
41 changes: 34 additions & 7 deletions otty-surface/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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 =
Expand Down
26 changes: 19 additions & 7 deletions otty-ui/terminal/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -628,6 +636,7 @@ mod tests {
position: (150.0, 100.0)
}
));
assert!(matches!(commands[1], crate::Event::Redraw { .. }));
assert!(state.is_dragged);
}
}
Expand Down Expand Up @@ -721,14 +730,15 @@ mod tests {
&mut publish,
);

assert_eq!(commands.len(), 1);
assert_eq!(commands.len(), 2);
assert!(matches!(
commands[0],
crate::Event::SelectUpdate {
id: TEST_ID,
position: (95.0, 145.0)
}
));
assert!(matches!(commands[1], crate::Event::Redraw { .. }));
}

#[test]
Expand Down Expand Up @@ -758,14 +768,15 @@ mod tests {
&mut publish,
);

assert_eq!(commands.len(), 1);
assert_eq!(commands.len(), 2);
assert!(matches!(
commands[0],
crate::Event::SelectUpdate {
id: TEST_ID,
position: (95.0, 145.0)
}
));
assert!(matches!(commands[1], crate::Event::Redraw { .. }));
assert!(state.selection_in_progress);
}

Expand Down Expand Up @@ -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 { .. }
Expand All @@ -814,6 +825,7 @@ mod tests {
position: (95.0, 145.0)
}
));
assert!(matches!(commands[3], crate::Event::Redraw { .. }));
}
}

Expand Down
2 changes: 2 additions & 0 deletions otty-ui/terminal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ mod engine;
mod error;
mod font;
mod input;
mod render_runs;
mod shaped_text;
mod term;
mod theme;
mod view;
Expand Down
Loading
Loading