terminal: Implement basic Japanese IME support on macOS (#29879)

Gen Tamura and Conrad Irwin created

## Description

This PR implements basic support for Japanese Input Method Editors
(IMEs) in the Zed terminal on macOS, addressing issue #9900. Previously,
users had to switch input modes to confirm Japanese text, and pre-edit
(marked) text was not displayed.

With these changes:

- **Marked Text Display:** Pre-edit text (e.g., underlined characters
during Japanese composition) is now rendered directly in the terminal at
the cursor's current position.
- **Composition Confirmation:** Pressing Enter correctly finalizes the
IME composition, clears the marked text, and sends the confirmed string
to the underlying PTY process. This allows for a more natural input flow
similar to other macOS applications like iTerm2.
- **State Management:** IME state (marked text and its selected range
within the marked text) is now managed within the `TerminalView` struct.
- **Input Handling:** `TerminalInputHandler` has been updated to
correctly process IME callbacks (`replace_and_mark_text_in_range`,
`replace_text_in_range`, `unmark_text`, `marked_text_range`) by
interacting with `TerminalView`.
- **Painting Logic:** `TerminalElement::paint` now fetches the marked
text and its range from `TerminalView` and renders it with an underline.
The standard terminal cursor is hidden when marked text is present to
avoid visual clutter.
- **Candidate Window Positioning:**
`TerminalInputHandler::bounds_for_range` now attempts to provide more
accurate bounds for the IME candidate window by using the actual painted
bounds of the pre-edit text, falling back to a cursor-based
approximation if necessary.

This significantly improves the usability of the Zed terminal for users
who need to input Japanese characters, bringing the experience closer to
system-standard IME behavior.

## Movies


https://github.com/user-attachments/assets/be6c7597-7b65-49a6-b376-e1adff6da974

---

Closes #9900

Release Notes:

- **Terminal:** Implemented basic support for Japanese Input Method
Editors (IMEs) on macOS. Users can now see pre-edit (marked) text as
they type Japanese and confirm their input with the Enter key directly
in the terminal. This provides a more natural and efficient experience
for Japanese language input. (Fixes #9900)

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/terminal_view/src/terminal_element.rs | 91 ++++++++++++++++++---
crates/terminal_view/src/terminal_view.rs    | 45 ++++++++++
2 files changed, 121 insertions(+), 15 deletions(-)

Detailed changes

crates/terminal_view/src/terminal_element.rs 🔗

@@ -26,6 +26,7 @@ use terminal::{
 };
 use theme::{ActiveTheme, Theme, ThemeSettings};
 use ui::{ParentElement, Tooltip};
+use util::ResultExt;
 use workspace::Workspace;
 
 use std::mem;
@@ -47,6 +48,7 @@ pub struct LayoutState {
     hyperlink_tooltip: Option<AnyElement>,
     gutter: Pixels,
     block_below_cursor_element: Option<AnyElement>,
+    base_text_style: TextStyle,
 }
 
 /// Helper struct for converting data between Alacritty's cursor points, and displayed cursor points.
@@ -898,6 +900,7 @@ impl Element for TerminalElement {
                     hyperlink_tooltip,
                     gutter,
                     block_below_cursor_element,
+                    base_text_style: text_style,
                 }
             },
         )
@@ -919,8 +922,14 @@ impl Element for TerminalElement {
             let origin =
                 bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top);
 
+            let marked_text_cloned: Option<String> = {
+                let ime_state = self.terminal_view.read(cx);
+                ime_state.marked_text.clone()
+            };
+
             let terminal_input_handler = TerminalInputHandler {
                 terminal: self.terminal.clone(),
+                terminal_view: self.terminal_view.clone(),
                 cursor_bounds: layout
                     .cursor
                     .as_ref()
@@ -938,7 +947,7 @@ impl Element for TerminalElement {
                 window.set_cursor_style(gpui::CursorStyle::IBeam, Some(&layout.hitbox));
             }
 
-            let cursor = layout.cursor.take();
+            let original_cursor = layout.cursor.take();
             let hyperlink_tooltip = layout.hyperlink_tooltip.take();
             let block_below_cursor_element = layout.block_below_cursor_element.take();
             self.interactivity.paint(
@@ -988,8 +997,41 @@ impl Element for TerminalElement {
                         cell.paint(origin, &layout.dimensions, bounds, window, cx);
                     }
 
-                    if self.cursor_visible {
-                        if let Some(mut cursor) = cursor {
+                    if let Some(text_to_mark) = &marked_text_cloned {
+                        if !text_to_mark.is_empty() {
+                            if let Some(cursor_layout) = &original_cursor {
+                                let ime_position = cursor_layout.bounding_rect(origin).origin;
+                                let mut ime_style = layout.base_text_style.clone();
+                                ime_style.underline = Some(UnderlineStyle {
+                                    color: Some(ime_style.color),
+                                    thickness: px(1.0),
+                                    wavy: false,
+                                });
+
+                                let shaped_line = window
+                                    .text_system()
+                                    .shape_line(
+                                        text_to_mark.clone().into(),
+                                        ime_style.font_size.to_pixels(window.rem_size()),
+                                        &[TextRun {
+                                            len: text_to_mark.len(),
+                                            font: ime_style.font(),
+                                            color: ime_style.color,
+                                            background_color: None,
+                                            underline: ime_style.underline,
+                                            strikethrough: None,
+                                        }],
+                                    )
+                                    .unwrap();
+                                shaped_line
+                                    .paint(ime_position, layout.dimensions.line_height, window, cx)
+                                    .log_err();
+                            }
+                        }
+                    }
+
+                    if self.cursor_visible && marked_text_cloned.is_none() {
+                        if let Some(mut cursor) = original_cursor {
                             cursor.paint(origin, window, cx);
                         }
                     }
@@ -1017,6 +1059,7 @@ impl IntoElement for TerminalElement {
 
 struct TerminalInputHandler {
     terminal: Entity<Terminal>,
+    terminal_view: Entity<TerminalView>,
     workspace: WeakEntity<Workspace>,
     cursor_bounds: Option<Bounds<Pixels>>,
 }
@@ -1044,8 +1087,12 @@ impl InputHandler for TerminalInputHandler {
         }
     }
 
-    fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option<std::ops::Range<usize>> {
-        None
+    fn marked_text_range(
+        &mut self,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Option<std::ops::Range<usize>> {
+        self.terminal_view.read(cx).marked_text_range()
     }
 
     fn text_for_range(
@@ -1065,8 +1112,9 @@ impl InputHandler for TerminalInputHandler {
         window: &mut Window,
         cx: &mut App,
     ) {
-        self.terminal.update(cx, |terminal, _| {
-            terminal.input(text);
+        self.terminal_view.update(cx, |view, view_cx| {
+            view.clear_marked_text(view_cx);
+            view.commit_text(text, view_cx);
         });
 
         self.workspace
@@ -1082,22 +1130,37 @@ impl InputHandler for TerminalInputHandler {
     fn replace_and_mark_text_in_range(
         &mut self,
         _range_utf16: Option<std::ops::Range<usize>>,
-        _new_text: &str,
-        _new_selected_range: Option<std::ops::Range<usize>>,
+        new_text: &str,
+        new_marked_range: Option<std::ops::Range<usize>>,
         _window: &mut Window,
-        _cx: &mut App,
+        cx: &mut App,
     ) {
+        if let Some(range) = new_marked_range {
+            self.terminal_view.update(cx, |view, view_cx| {
+                view.set_marked_text(new_text.to_string(), range, view_cx);
+            });
+        }
     }
 
-    fn unmark_text(&mut self, _window: &mut Window, _cx: &mut App) {}
+    fn unmark_text(&mut self, _window: &mut Window, cx: &mut App) {
+        self.terminal_view.update(cx, |view, view_cx| {
+            view.clear_marked_text(view_cx);
+        });
+    }
 
     fn bounds_for_range(
         &mut self,
-        _range_utf16: std::ops::Range<usize>,
+        range_utf16: std::ops::Range<usize>,
         _window: &mut Window,
-        _cx: &mut App,
+        cx: &mut App,
     ) -> Option<Bounds<Pixels>> {
-        self.cursor_bounds
+        let term_bounds = self.terminal_view.read(cx).terminal_bounds(cx);
+
+        let mut bounds = self.cursor_bounds?;
+        let offset_x = term_bounds.cell_width * range_utf16.start as f32;
+        bounds.origin.x += offset_x;
+
+        Some(bounds)
     }
 
     fn apple_press_and_hold_enabled(&mut self) -> bool {

crates/terminal_view/src/terminal_view.rs 🔗

@@ -52,7 +52,7 @@ use zed_actions::assistant::InlineAssist;
 
 use std::{
     cmp,
-    ops::RangeInclusive,
+    ops::{Range, RangeInclusive},
     path::{Path, PathBuf},
     rc::Rc,
     sync::Arc,
@@ -126,6 +126,8 @@ pub struct TerminalView {
     scroll_handle: TerminalScrollHandle,
     show_scrollbar: bool,
     hide_scrollbar_task: Option<Task<()>>,
+    marked_text: Option<String>,
+    marked_range_utf16: Option<Range<usize>>,
     _subscriptions: Vec<Subscription>,
     _terminal_subscriptions: Vec<Subscription>,
 }
@@ -218,6 +220,8 @@ impl TerminalView {
             show_scrollbar: !Self::should_autohide_scrollbar(cx),
             hide_scrollbar_task: None,
             cwd_serialized: false,
+            marked_text: None,
+            marked_range_utf16: None,
             _subscriptions: vec![
                 focus_in,
                 focus_out,
@@ -227,6 +231,45 @@ impl TerminalView {
         }
     }
 
+    /// Sets the marked (pre-edit) text from the IME.
+    pub(crate) fn set_marked_text(
+        &mut self,
+        text: String,
+        range: Range<usize>,
+        cx: &mut Context<Self>,
+    ) {
+        self.marked_text = Some(text);
+        self.marked_range_utf16 = Some(range);
+        cx.notify();
+    }
+
+    /// Gets the current marked range (UTF-16).
+    pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
+        self.marked_range_utf16.clone()
+    }
+
+    /// Clears the marked (pre-edit) text state.
+    pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
+        if self.marked_text.is_some() {
+            self.marked_text = None;
+            self.marked_range_utf16 = None;
+            cx.notify();
+        }
+    }
+
+    /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
+    pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
+        if !text.is_empty() {
+            self.terminal.update(cx, |term, _| {
+                term.input(text.to_string());
+            });
+        }
+    }
+
+    pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds {
+        self.terminal.read(cx).last_content().terminal_bounds
+    }
+
     pub fn entity(&self) -> &Entity<Terminal> {
         &self.terminal
     }