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); 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), 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; 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); }