brewery
notashelf /
53924d381aa89c53ab1e3b2881c1da27f8cbe098

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 53924d381aa8

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified2 files changed+168-0
wayland: render IME preedit and commit via `text-input-v3`

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I84a3735ca2e75e63d098fb17836ffd786a6a6964
diff --git a/src/render.rs b/src/render.rsindex 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.rsindex 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<String>,+    /// 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<std::path::PathBuf>) -> anyhow::R         bind_global::<WpFractionalScaleManagerV1>(&globals, &qh)             .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))     });+    let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&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<std::path::PathBuf>) -> 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<WpCursorShapeDeviceV1>,     data_device: Option<DataDevice>,     primary_device: Option<PrimarySelectionDevice>,+    /// text-input-v3 handle for IME preedit/commit, if the compositor offers it.+    text_input: Option<ZwpTextInputV3>, }  /// Window + Wayland client state shared across all protocol handlers.@@ -411,6 +425,13 @@ struct App {     primary_manager: Option<PrimarySelectionManagerState>,     /// Sets the pointer to an I-beam over the window (cursor-shape-v1).     cursor_shape_manager: Option<CursorShapeManager>,+    /// IME manager (text-input-v3); per-seat handles live in `seats`.+    text_input_manager: Option<ZwpTextInputManagerV3>,+    /// 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<WpViewport>,     /// 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<crate::vt::ClipboardOp>) {@@ -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<WpViewport, ()> for App {     } } +impl Dispatch<ZwpTextInputManagerV3, ()> for App {+    fn event(+        _: &mut Self,+        _: &ZwpTextInputManagerV3,+        _: <ZwpTextInputManagerV3 as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++// 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<ZwpTextInputV3, ()> for App {+    fn event(+        state: &mut Self,+        ti: &ZwpTextInputV3,+        event: zwp_text_input_v3::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+        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);