From a7cdd78b7fddfcff7b251534371274ecf70cb569 Mon Sep 17 00:00:00 2001 From: Siriwat Uamngamsup Date: Thu, 2 Jul 2026 23:08:46 +0700 Subject: [PATCH 1/5] fix(otty-ui-term): render zero-width combining marks over base glyphs The draw loop built each cell's Text from the base char alone, so combining characters stored in Cell::zerowidth() (e.g. Thai upper/lower vowels and tone marks) were silently dropped. Append them to the shaped content so cosmic-text positions the full grapheme cluster, and draw cells whose base is a space when they carry combining marks. Co-Authored-By: Claude Fable 5 --- otty-ui/terminal/src/view.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/otty-ui/terminal/src/view.rs b/otty-ui/terminal/src/view.rs index 05321eb..1477352 100644 --- a/otty-ui/terminal/src/view.rs +++ b/otty-ui/terminal/src/view.rs @@ -687,7 +687,12 @@ impl Widget for TerminalView<'_> { } // Draw text - if indexed.cell.c != ' ' && indexed.cell.c != '\t' { + let zerowidth = indexed.cell.zerowidth(); + let has_zerowidth = + zerowidth.is_some_and(|marks| !marks.is_empty()); + if (indexed.cell.c != ' ' && indexed.cell.c != '\t') + || has_zerowidth + { if view.cursor.point == indexed.point && !view.mode.contains(SurfaceMode::ALT_SCREEN) { @@ -701,8 +706,14 @@ impl Widget for TerminalView<'_> { if flags.contains(Flags::ITALIC) { font.style = FontStyle::Italic; } + // Append zerowidth combining marks (e.g. Thai vowels/tone + // marks) so cosmic-text shapes them over the base glyph. + let mut content = indexed.cell.c.to_string(); + if let Some(marks) = zerowidth { + content.extend(marks); + } let text = Text { - content: indexed.cell.c.to_string(), + content, position: Point::new(cell_center_x, cell_center_y), font, size: iced_core::Pixels(font_size), From 7fab4f345cf988f50cf47ff0d841648100c49288 Mon Sep 17 00:00:00 2001 From: Siriwat Uamngamsup Date: Thu, 2 Jul 2026 23:11:30 +0700 Subject: [PATCH 2/5] test(otty): make settings shell dirty-tracking tests SHELL-independent Both tests hardcoded /bin/zsh as the new shell value; on machines where $SHELL is already /bin/zsh the value matched the default draft, no dirty flag was set, and the assertions failed. Derive the target value from the live default so it always differs. Co-Authored-By: Claude Fable 5 --- otty/src/widgets/settings/reducer.rs | 5 ++++- otty/src/widgets/settings/state.rs | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/otty/src/widgets/settings/reducer.rs b/otty/src/widgets/settings/reducer.rs index 69fca3d..e414cad 100644 --- a/otty/src/widgets/settings/reducer.rs +++ b/otty/src/widgets/settings/reducer.rs @@ -122,7 +122,10 @@ mod tests { #[test] fn given_save_completed_when_reduced_then_marks_state_saved() { let mut state = default_state(); - state.set_shell(String::from("/bin/zsh")); + // Derive a value guaranteed to differ from whatever $SHELL the test + // runner defaults to, so this test isn't environment-dependent. + let new_shell = format!("{}-test", state.draft().terminal_shell()); + state.set_shell(new_shell); assert!(state.is_dirty()); let normalized = state.normalized_draft(); diff --git a/otty/src/widgets/settings/state.rs b/otty/src/widgets/settings/state.rs index cf3e6f9..2a300a0 100644 --- a/otty/src/widgets/settings/state.rs +++ b/otty/src/widgets/settings/state.rs @@ -222,10 +222,13 @@ mod tests { #[test] fn given_default_state_when_set_shell_then_marks_dirty() { let mut state = SettingsState::default(); + // Derive a value guaranteed to differ from whatever $SHELL the test + // runner defaults to, so this test isn't environment-dependent. + let new_shell = format!("{}-test", state.draft.terminal_shell()); - state.set_shell(String::from("/bin/zsh")); + state.set_shell(new_shell.clone()); - assert_eq!(state.draft.terminal_shell(), "/bin/zsh"); + assert_eq!(state.draft.terminal_shell(), new_shell); assert!(state.dirty); } From d1d0ce41c24203ff0257d6d3c5c79817ba39b325 Mon Sep 17 00:00:00 2001 From: Siriwat Uamngamsup Date: Thu, 2 Jul 2026 23:13:02 +0700 Subject: [PATCH 3/5] chore: fix macOS clippy warnings and rustfmt drift Gate resize-grip and window imports behind cfg(not(macos)) since they are only used by the non-macOS resize path, split the ResizeWindow match arm per platform instead of binding an unused direction on macOS, and apply pending rustfmt reordering in otty-libterm re-exports. Co-Authored-By: Claude Fable 5 --- otty-libterm/src/lib.rs | 4 +--- otty/src/events/mod.rs | 18 +++++++----------- otty/src/view.rs | 6 +++--- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/otty-libterm/src/lib.rs b/otty-libterm/src/lib.rs index 133fc0a..15073ac 100644 --- a/otty-libterm/src/lib.rs +++ b/otty-libterm/src/lib.rs @@ -25,9 +25,6 @@ mod runtime; mod terminal; pub use error::{Error, Result}; -pub use otty_escape as escape; -pub use otty_pty as pty; -pub use otty_surface as surface; pub use runtime::{Driver, Runtime, RuntimeHooks, RuntimeRequestProxy}; pub use terminal::builder::{ DefaultParser, DefaultSurface, RuntimeTerminal, Terminal, TerminalBuilder, @@ -41,6 +38,7 @@ pub use terminal::size::TerminalSize; pub use terminal::{ SnapshotArc, TerminalEngine, TerminalEvent, TerminalRequest, }; +pub use {otty_escape as escape, otty_pty as pty, otty_surface as surface}; #[cfg(test)] pub(crate) mod tests { diff --git a/otty/src/events/mod.rs b/otty/src/events/mod.rs index 4466b98..74f22ed 100644 --- a/otty/src/events/mod.rs +++ b/otty/src/events/mod.rs @@ -1,5 +1,7 @@ +use iced::Task; +#[cfg(not(target_os = "macos"))] +use iced::window; use iced::window::Direction; -use iced::{Task, window}; use crate::app::App; use crate::layout::screen_size_from_window; @@ -71,17 +73,11 @@ pub(crate) fn handle(app: &mut App, event: AppEvent) -> Task { ), ) }, + #[cfg(target_os = "macos")] + AppEvent::ResizeWindow(_) => Task::none(), + #[cfg(not(target_os = "macos"))] AppEvent::ResizeWindow(dir) => { - #[cfg(target_os = "macos")] - { - Task::none() - } - - #[cfg(not(target_os = "macos"))] - { - window::latest() - .and_then(move |id| window::drag_resize(id, dir)) - } + window::latest().and_then(move |id| window::drag_resize(id, dir)) }, AppEvent::Window(_) => Task::none(), } diff --git a/otty/src/view.rs b/otty/src/view.rs index 8ce6a64..28df3e7 100644 --- a/otty/src/view.rs +++ b/otty/src/view.rs @@ -3,9 +3,9 @@ use iced::widget::{Space, column, container, mouse_area, row, text}; use iced::{Element, Length, Size, Theme, alignment}; use super::{App, AppEvent}; -use crate::components::primitive::{ - menu_item, resize_grips, sidebar_workspace_panel, -}; +#[cfg(not(target_os = "macos"))] +use crate::components::primitive::resize_grips; +use crate::components::primitive::{menu_item, sidebar_workspace_panel}; use crate::geometry::{anchor_position, menu_height_for_items}; use crate::layout::BUTTON_SIZE_COMPACT; use crate::style::menu_panel_style; From fce022554a584f6582ea275a9ab10a702702ff5c Mon Sep 17 00:00:00 2001 From: Siriwat Uamngamsup Date: Thu, 2 Jul 2026 23:13:58 +0700 Subject: [PATCH 4/5] fix(otty-surface): decompose Thai/Lao AM vowels for correct mark placement THAI SARA AM (U+0E33) and LAO AM (U+0EB3) have display width 1, so they were written to their own cell and shaped standalone, leaving the nikhahit/niggahita ring detached from the preceding consonant. Split them on print into the zero-width mark (attached to the previous cell) plus the spacing AA vowel so the cluster shapes correctly. Copied text now yields the decomposed sequence (U+0E4D U+0E32) instead of the original single codepoint; it remains canonically equivalent under NFC normalization. Co-Authored-By: Claude Fable 5 --- otty-surface/src/surface.rs | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/otty-surface/src/surface.rs b/otty-surface/src/surface.rs index 12f1da9..1acf196 100644 --- a/otty-surface/src/surface.rs +++ b/otty-surface/src/surface.rs @@ -856,8 +856,34 @@ impl Dimensions for Surface { } } +/// Decompose a Thai/Lao "AM" vowel into its zero-width mark and spacing +/// vowel. +/// +/// `THAI SARA AM` (U+0E33) and `LAO AM` (U+0EB3) have a display width of 1, +/// so without decomposition they are shaped as standalone glyphs. Splitting +/// them lets the nikhahit/niggahita ring render over the preceding +/// consonant, matching how the character is actually drawn. +fn decompose_am(ch: char) -> Option<(char, char)> { + match ch { + // THAI SARA AM -> THAI CHARACTER NIKHAHIT + THAI CHARACTER SARA AA. + '\u{0E33}' => Some(('\u{0E4D}', '\u{0E32}')), + // LAO AM -> LAO NIGGAHITA + LAO VOWEL SIGN AA. + '\u{0EB3}' => Some(('\u{0ECD}', '\u{0EB2}')), + _ => None, + } +} + impl SurfaceActor for Surface { fn print(&mut self, ch: char) { + // Split Thai/Lao "AM" vowels so their nikhahit/niggahita mark is + // placed over the preceding consonant cell, instead of standing + // alone in its own cell. + if let Some((mark, base)) = decompose_am(ch) { + self.print(mark); + self.print(base); + return; + } + // Number of cells the char will occupy. let width = match ch.width() { Some(width) => width, @@ -2525,6 +2551,51 @@ mod tests { assert_eq!(surface.grid()[cursor].c, '▒'); } + #[test] + fn thai_sara_am_decomposes_over_preceding_consonant() { + let size = SurfaceSize::new(7, 17); + let mut surface = Surface::new(SurfaceConfig::default(), &size); + surface.print('\u{0E01}'); // THAI CHARACTER KO KAI (ก). + surface.print('\u{0E33}'); // THAI CHARACTER SARA AM (ำ). + + let first = surface.grid()[Point::new(Line(0), Column(0))].clone(); + let second = surface.grid()[Point::new(Line(0), Column(1))].clone(); + + assert_eq!(first.c, '\u{0E01}'); + assert_eq!(first.zerowidth(), Some(&['\u{0E4D}'][..])); + assert_eq!(second.c, '\u{0E32}'); + } + + #[test] + fn lao_am_decomposes_over_preceding_consonant() { + let size = SurfaceSize::new(7, 17); + let mut surface = Surface::new(SurfaceConfig::default(), &size); + surface.print('\u{0E81}'); // LAO LETTER KO. + surface.print('\u{0EB3}'); // LAO AM. + + let first = surface.grid()[Point::new(Line(0), Column(0))].clone(); + let second = surface.grid()[Point::new(Line(0), Column(1))].clone(); + + assert_eq!(first.c, '\u{0E81}'); + assert_eq!(first.zerowidth(), Some(&['\u{0ECD}'][..])); + assert_eq!(second.c, '\u{0EB2}'); + } + + #[test] + fn thai_sara_am_at_column_zero_does_not_panic() { + let size = SurfaceSize::new(7, 17); + let mut surface = Surface::new(SurfaceConfig::default(), &size); + + // No preceding consonant: the nikhahit mark has no earlier cell to + // attach to, so it lands on the same cell as the spacing vowel that + // follows it and is overwritten. This mirrors how a bare zero-width + // combining mark at column 0 already behaves in `print`. + surface.print('\u{0E33}'); + + let cell = surface.grid()[Point::new(Line(0), Column(0))].clone(); + assert_eq!(cell.c, '\u{0E32}'); + } + #[test] fn clearing_viewport_keeps_history_position() { let size = SurfaceSize::new(10, 20); From 3c2301e894e6bbd33e848160a476a79e9a249b3b Mon Sep 17 00:00:00 2001 From: Siriwat Uamngamsup Date: Fri, 3 Jul 2026 09:48:32 +0700 Subject: [PATCH 5/5] chore: restore rustfmt layout expected by CI-pinned nightly The re-export grouping applied by a newer local nightly rustfmt disagrees with the nightly-2026-03-10 toolchain pinned in the lint workflow; format with the pinned version instead. Co-Authored-By: Claude Fable 5 --- otty-libterm/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/otty-libterm/src/lib.rs b/otty-libterm/src/lib.rs index 15073ac..133fc0a 100644 --- a/otty-libterm/src/lib.rs +++ b/otty-libterm/src/lib.rs @@ -25,6 +25,9 @@ mod runtime; mod terminal; pub use error::{Error, Result}; +pub use otty_escape as escape; +pub use otty_pty as pty; +pub use otty_surface as surface; pub use runtime::{Driver, Runtime, RuntimeHooks, RuntimeRequestProxy}; pub use terminal::builder::{ DefaultParser, DefaultSurface, RuntimeTerminal, Terminal, TerminalBuilder, @@ -38,7 +41,6 @@ pub use terminal::size::TerminalSize; pub use terminal::{ SnapshotArc, TerminalEngine, TerminalEvent, TerminalRequest, }; -pub use {otty_escape as escape, otty_pty as pty, otty_surface as surface}; #[cfg(test)] pub(crate) mod tests {