terminal: Implement basic Japanese IME support on macOS (#29879)
## 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>
This commit is contained in:
parent
e26620d1cf
commit
c7725e31d9
2 changed files with 121 additions and 15 deletions
|
@ -26,6 +26,7 @@ use terminal::{
|
||||||
};
|
};
|
||||||
use theme::{ActiveTheme, Theme, ThemeSettings};
|
use theme::{ActiveTheme, Theme, ThemeSettings};
|
||||||
use ui::{ParentElement, Tooltip};
|
use ui::{ParentElement, Tooltip};
|
||||||
|
use util::ResultExt;
|
||||||
use workspace::Workspace;
|
use workspace::Workspace;
|
||||||
|
|
||||||
use std::mem;
|
use std::mem;
|
||||||
|
@ -47,6 +48,7 @@ pub struct LayoutState {
|
||||||
hyperlink_tooltip: Option<AnyElement>,
|
hyperlink_tooltip: Option<AnyElement>,
|
||||||
gutter: Pixels,
|
gutter: Pixels,
|
||||||
block_below_cursor_element: Option<AnyElement>,
|
block_below_cursor_element: Option<AnyElement>,
|
||||||
|
base_text_style: TextStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper struct for converting data between Alacritty's cursor points, and displayed cursor points.
|
/// Helper struct for converting data between Alacritty's cursor points, and displayed cursor points.
|
||||||
|
@ -898,6 +900,7 @@ impl Element for TerminalElement {
|
||||||
hyperlink_tooltip,
|
hyperlink_tooltip,
|
||||||
gutter,
|
gutter,
|
||||||
block_below_cursor_element,
|
block_below_cursor_element,
|
||||||
|
base_text_style: text_style,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -919,8 +922,14 @@ impl Element for TerminalElement {
|
||||||
let origin =
|
let origin =
|
||||||
bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top);
|
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 {
|
let terminal_input_handler = TerminalInputHandler {
|
||||||
terminal: self.terminal.clone(),
|
terminal: self.terminal.clone(),
|
||||||
|
terminal_view: self.terminal_view.clone(),
|
||||||
cursor_bounds: layout
|
cursor_bounds: layout
|
||||||
.cursor
|
.cursor
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
@ -938,7 +947,7 @@ impl Element for TerminalElement {
|
||||||
window.set_cursor_style(gpui::CursorStyle::IBeam, Some(&layout.hitbox));
|
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 hyperlink_tooltip = layout.hyperlink_tooltip.take();
|
||||||
let block_below_cursor_element = layout.block_below_cursor_element.take();
|
let block_below_cursor_element = layout.block_below_cursor_element.take();
|
||||||
self.interactivity.paint(
|
self.interactivity.paint(
|
||||||
|
@ -988,8 +997,41 @@ impl Element for TerminalElement {
|
||||||
cell.paint(origin, &layout.dimensions, bounds, window, cx);
|
cell.paint(origin, &layout.dimensions, bounds, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.cursor_visible {
|
if let Some(text_to_mark) = &marked_text_cloned {
|
||||||
if let Some(mut cursor) = cursor {
|
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);
|
cursor.paint(origin, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1017,6 +1059,7 @@ impl IntoElement for TerminalElement {
|
||||||
|
|
||||||
struct TerminalInputHandler {
|
struct TerminalInputHandler {
|
||||||
terminal: Entity<Terminal>,
|
terminal: Entity<Terminal>,
|
||||||
|
terminal_view: Entity<TerminalView>,
|
||||||
workspace: WeakEntity<Workspace>,
|
workspace: WeakEntity<Workspace>,
|
||||||
cursor_bounds: Option<Bounds<Pixels>>,
|
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>> {
|
fn marked_text_range(
|
||||||
None
|
&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(
|
fn text_for_range(
|
||||||
|
@ -1065,8 +1112,9 @@ impl InputHandler for TerminalInputHandler {
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
self.terminal.update(cx, |terminal, _| {
|
self.terminal_view.update(cx, |view, view_cx| {
|
||||||
terminal.input(text);
|
view.clear_marked_text(view_cx);
|
||||||
|
view.commit_text(text, view_cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.workspace
|
self.workspace
|
||||||
|
@ -1082,22 +1130,37 @@ impl InputHandler for TerminalInputHandler {
|
||||||
fn replace_and_mark_text_in_range(
|
fn replace_and_mark_text_in_range(
|
||||||
&mut self,
|
&mut self,
|
||||||
_range_utf16: Option<std::ops::Range<usize>>,
|
_range_utf16: Option<std::ops::Range<usize>>,
|
||||||
_new_text: &str,
|
new_text: &str,
|
||||||
_new_selected_range: Option<std::ops::Range<usize>>,
|
new_marked_range: Option<std::ops::Range<usize>>,
|
||||||
_window: &mut Window,
|
_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(
|
fn bounds_for_range(
|
||||||
&mut self,
|
&mut self,
|
||||||
_range_utf16: std::ops::Range<usize>,
|
range_utf16: std::ops::Range<usize>,
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
_cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Option<Bounds<Pixels>> {
|
) -> 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 {
|
fn apple_press_and_hold_enabled(&mut self) -> bool {
|
||||||
|
|
|
@ -52,7 +52,7 @@ use zed_actions::assistant::InlineAssist;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
cmp,
|
cmp,
|
||||||
ops::RangeInclusive,
|
ops::{Range, RangeInclusive},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
@ -126,6 +126,8 @@ pub struct TerminalView {
|
||||||
scroll_handle: TerminalScrollHandle,
|
scroll_handle: TerminalScrollHandle,
|
||||||
show_scrollbar: bool,
|
show_scrollbar: bool,
|
||||||
hide_scrollbar_task: Option<Task<()>>,
|
hide_scrollbar_task: Option<Task<()>>,
|
||||||
|
marked_text: Option<String>,
|
||||||
|
marked_range_utf16: Option<Range<usize>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
_terminal_subscriptions: Vec<Subscription>,
|
_terminal_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
@ -218,6 +220,8 @@ impl TerminalView {
|
||||||
show_scrollbar: !Self::should_autohide_scrollbar(cx),
|
show_scrollbar: !Self::should_autohide_scrollbar(cx),
|
||||||
hide_scrollbar_task: None,
|
hide_scrollbar_task: None,
|
||||||
cwd_serialized: false,
|
cwd_serialized: false,
|
||||||
|
marked_text: None,
|
||||||
|
marked_range_utf16: None,
|
||||||
_subscriptions: vec![
|
_subscriptions: vec![
|
||||||
focus_in,
|
focus_in,
|
||||||
focus_out,
|
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> {
|
pub fn entity(&self) -> &Entity<Terminal> {
|
||||||
&self.terminal
|
&self.terminal
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue