Skip to content
Closed
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
71 changes: 71 additions & 0 deletions otty-surface/src/surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a valid fix for Thai/Lao SARA AM, but I’m not sure surface is the right layer for this kind of logic.

otty-surface should model and manage the terminal grid: cells, widths, zero-width marks, cursor movement, wrapping, insert/delete behavior, etc. Text shaping is a renderer responsibility, because it depends on Unicode shaping rules, adjacent characters, fonts, OpenType positioning, bidi, and other rendering context.

Adding script-specific decomposition rules to surface can fix this case, but it does not scale well. Each new Unicode/script-specific rendering issue would need another exception in grid construction, and some of those exceptions can also change the logical text used by copy/search. For example, SARA AM decomposition is a compatibility decomposition, so the grid no longer stores exactly the same text emitted by the application.

I prepared the another PR with render aproach impl that could solving your case.

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,
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions otty-ui/terminal/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,12 @@ impl Widget<Event, Theme, iced::Renderer> 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)
{
Expand All @@ -701,8 +706,14 @@ impl Widget<Event, Theme, iced::Renderer> 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),
Expand Down
18 changes: 7 additions & 11 deletions otty/src/events/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -71,17 +73,11 @@ pub(crate) fn handle(app: &mut App, event: AppEvent) -> Task<AppEvent> {
),
)
},
#[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(),
}
Expand Down
6 changes: 3 additions & 3 deletions otty/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion otty/src/widgets/settings/reducer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
7 changes: 5 additions & 2 deletions otty/src/widgets/settings/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down