From 53924d381aa89c53ab1e3b2881c1da27f8cbe098 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 12:41:16 +0300 Subject: [PATCH] wayland: render IME preedit and commit via `text-input-v3` Signed-off-by: NotAShelf Change-Id: I84a3735ca2e75e63d098fb17836ffd786a6a6964 --- src/render.rs | 40 ++++++++++++++++ src/wayland.rs | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/src/render.rs b/src/render.rs index 74d9c8a..cc3b0e3 100644 --- a/src/render.rs +++ b/src/render.rs @@ -250,6 +250,46 @@ impl Renderer { } } + /// Draw the IME preedit string inline, starting at grid cell `start_col` of + /// row `row`, over whatever was there. The preedit sits on the selection + /// background and is underlined so it reads as uncommitted, in-flight text. + pub fn render_preedit( + &mut self, + pixels: &mut [u8], + dims: (usize, usize), + theme: &Theme, + row: usize, + start_col: usize, + text: &str, + ) { + let (width, height) = dims; + let mut canvas = Canvas { + pixels, + width, + height, + }; + let m = self.fonts.metrics(); + let (pad_x, pad_y) = self.pad; + let row_top = pad_y + row as i32 * m.height as i32; + let style = Style { + bold: false, + italic: false, + }; + let mut x = pad_x + start_col as i32 * m.width as i32; + for c in text.chars() { + if x < 0 || x as usize + m.width as usize > width { + break; + } + canvas.fill_rect(x, row_top, m.width, m.height, theme.selection_bg); + if c != ' ' { + self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg); + } + // Underline the run a row above the cell bottom. + canvas.hline(x, row_top + m.height as i32 - 2, m.width, theme.fg); + x += m.width as i32; + } + } + /// Draw the cursor: a solid block/underline/beam when focused, a hollow /// outline when not. A blinking cursor shape is only drawn while `blink_on`. fn draw_cursor( diff --git a/src/wayland.rs b/src/wayland.rs index f044366..b5ef3a4 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -82,6 +82,10 @@ use wayland_protocols::wp::fractional_scale::v1::client::{ wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1, wp_fractional_scale_v1::{self, WpFractionalScaleV1}, }; +use wayland_protocols::wp::text_input::zv3::client::{ + zwp_text_input_manager_v3::ZwpTextInputManagerV3, + zwp_text_input_v3::{self, ContentHint, ContentPurpose, ZwpTextInputV3}, +}; use wayland_protocols::wp::viewporter::client::{ wp_viewport::WpViewport, wp_viewporter::WpViewporter, }; @@ -137,6 +141,8 @@ struct RowSnap { search: Vec<(usize, usize, bool)>, /// Search-prompt text drawn over this row (only the bottom row, when active). overlay: Option, + /// IME preedit `(start_col, text)` drawn inline over this row (cursor row). + preedit: Option<(usize, String)>, /// Blink phase, but only varied when the row actually has blinking ink, so /// non-blinking rows stay equal across phase toggles. blink: bool, @@ -168,6 +174,7 @@ fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap { sel: grid.selection_span_on(abs), search: grid.search_spans_on(abs), overlay: None, + preedit: None, blink: if has_blink { blink_on } else { true }, } } @@ -215,6 +222,7 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R bind_global::(&globals, &qh) .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ())) }); + let text_input_manager = bind_global::(&globals, &qh); // First commit with no buffer kicks off the initial configure. window.commit(); @@ -251,6 +259,10 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R data_device_manager, primary_manager, cursor_shape_manager, + text_input_manager, + preedit: String::new(), + ime_preedit_pending: String::new(), + ime_commit_pending: String::new(), viewport, fractional_scale, scale120: 120, @@ -393,6 +405,8 @@ struct SeatData { cursor_shape_device: Option, data_device: Option, primary_device: Option, + /// text-input-v3 handle for IME preedit/commit, if the compositor offers it. + text_input: Option, } /// Window + Wayland client state shared across all protocol handlers. @@ -411,6 +425,13 @@ struct App { primary_manager: Option, /// Sets the pointer to an I-beam over the window (cursor-shape-v1). cursor_shape_manager: Option, + /// IME manager (text-input-v3); per-seat handles live in `seats`. + text_input_manager: Option, + /// Committed IME preedit string shown inline at the cursor while composing. + preedit: String, + /// Preedit/commit accumulated since the last text-input `done`. + ime_preedit_pending: String, + ime_commit_pending: String, /// Presents a scaled buffer at the logical surface size (viewporter). viewport: Option, /// Per-surface fractional-scale object; kept alive to receive scale events. @@ -837,6 +858,7 @@ impl App { cursor_shape_device: None, data_device: None, primary_device: None, + text_input: None, }); self.seats.len() - 1 } @@ -1125,6 +1147,39 @@ impl App { } } + /// Point the IME's candidate popup at the terminal cursor, in logical + /// surface coordinates (the renderer works in physical pixels, so divide + /// the physical cell rectangle back down by the scale). + fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) { + let Some(session) = self.session.as_ref() else { + return; + }; + let (cx, cy) = session.term.grid().cursor(); + let m = self.renderer.metrics(); + let s = f64::from(self.scale120) / 120.0; + let pad_x = f64::from(self.to_phys(self.config.main.pad_x)); + let pad_y = f64::from(self.to_phys(self.config.main.pad_y)); + let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32; + let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32; + let w = (f64::from(m.width) / s) as i32; + let h = (f64::from(m.height) / s) as i32; + ti.set_cursor_rectangle(x, y, w.max(1), h.max(1)); + } + + /// Apply one IME transaction: commit any committed text to the shell, adopt + /// the new preedit, then re-commit our state (cursor rectangle) to the IME. + fn ime_done(&mut self, ti: &ZwpTextInputV3) { + let commit = std::mem::take(&mut self.ime_commit_pending); + // Preedit is replaced wholesale each cycle; an absent preedit clears it. + self.preedit = std::mem::take(&mut self.ime_preedit_pending); + if !commit.is_empty() { + self.send_to_shell(commit.as_bytes()); + } + self.ime_set_cursor_rect(ti); + ti.commit(); + self.needs_draw = true; + } + /// Act on the OSC 52 clipboard requests an application made: take ownership /// of the selection it set, or answer a query with what we currently hold. fn handle_clipboard_ops(&mut self, ops: Vec) { @@ -1414,6 +1469,14 @@ impl App { cur[rows - 1].overlay = Some(text.clone()); } + // The IME preedit is drawn inline at the cursor while composing. + if !self.preedit.is_empty() && grid.view_at_bottom() { + let (cx, cy) = grid.cursor(); + if cy < rows { + cur[cy].preedit = Some((cx, self.preedit.clone())); + } + } + // Reuse a buffer the compositor has released, else grow the ring. let stride = w as i32 * 4; let mut idx = None; @@ -1486,6 +1549,13 @@ impl App { self.renderer .render_search_bar(canvas, dims, theme, rows - 1, text); } + // Draw the IME preedit inline over its (repainted) cursor row. + for &y in &dirty { + if let Some((col, text)) = &cur[y].preedit { + self.renderer + .render_preedit(canvas, dims, theme, y, *col, text); + } + } self.frames[idx].rows = cur; let surface = self.window.wl_surface(); @@ -1640,6 +1710,11 @@ impl SeatHandler for App { Ok(keyboard) => self.seats[i].keyboard = Some(keyboard), Err(err) => tracing::warn!("get keyboard: {err}"), } + if self.seats[i].text_input.is_none() + && let Some(mgr) = self.text_input_manager.as_ref() + { + self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ())); + } } if capability == Capability::Pointer && self.seats[i].pointer.is_none() { match self.seat_state.get_pointer(qh, &seat) { @@ -2104,6 +2179,59 @@ impl Dispatch for App { } } +impl Dispatch for App { + fn event( + _: &mut Self, + _: &ZwpTextInputManagerV3, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the +// accumulated transaction on `done` and re-enable on focus enter. +impl Dispatch for App { + fn event( + state: &mut Self, + ti: &ZwpTextInputV3, + event: zwp_text_input_v3::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use zwp_text_input_v3::Event; + match event { + Event::Enter { .. } => { + ti.enable(); + ti.set_content_type(ContentHint::None, ContentPurpose::Terminal); + state.ime_set_cursor_rect(ti); + ti.commit(); + } + Event::Leave { .. } => { + ti.disable(); + ti.commit(); + state.preedit.clear(); + state.ime_preedit_pending.clear(); + state.ime_commit_pending.clear(); + state.needs_draw = true; + } + Event::PreeditString { text, .. } => { + state.ime_preedit_pending = text.unwrap_or_default(); + } + Event::CommitString { text } => { + state.ime_commit_pending.push_str(&text.unwrap_or_default()); + } + Event::Done { .. } => state.ime_done(ti), + // We do not expose surrounding text, so nothing to delete. + Event::DeleteSurroundingText { .. } => {} + _ => {} + } + } +} + delegate_compositor!(App); delegate_output!(App); delegate_shm!(App);