clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 53924d381aa8
tarballunverified2 files changed+168-0
@@ -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(@@ -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);