From 881fb2487873538d4c23dcf9171dd80adf84a76c Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Wed, 15 Oct 2025 22:01:24 +0200 Subject: [PATCH 1/6] text-input: add input method abstraction --- src/gfx_api.rs | 2 +- src/ifs/wl_seat.rs | 13 +++-- src/ifs/wl_seat/event_handling.rs | 21 +++++--- src/ifs/wl_seat/text_input.rs | 48 +++++++++++++------ .../zwp_input_method_keyboard_grab_v2.rs | 25 ++++++++-- .../wl_seat/text_input/zwp_input_method_v2.rs | 36 +++++++++++++- .../wl_seat/text_input/zwp_text_input_v3.rs | 20 ++++---- src/ifs/wl_surface.rs | 2 +- 8 files changed, 122 insertions(+), 45 deletions(-) diff --git a/src/gfx_api.rs b/src/gfx_api.rs index 810bdd81..5193f40b 100644 --- a/src/gfx_api.rs +++ b/src/gfx_api.rs @@ -955,7 +955,7 @@ pub fn create_render_pass( for seat in seats.values() { let (x, y) = seat.pointer_cursor().position_int(); if let Some(im) = seat.input_method() { - for (_, popup) in &im.popups { + for (_, popup) in im.popups() { if popup.surface.node_visible() { let pos = popup.surface.buffer_abs_pos.get(); let extents = popup.surface.extents.get().move_(pos.x1(), pos.y1()); diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs index c89853d9..596d5dcc 100644 --- a/src/ifs/wl_seat.rs +++ b/src/ifs/wl_seat.rs @@ -52,8 +52,7 @@ use { pointer_owner::PointerOwnerHolder, tablet::TabletSeatData, text_input::{ - zwp_input_method_keyboard_grab_v2::ZwpInputMethodKeyboardGrabV2, - zwp_input_method_v2::ZwpInputMethodV2, zwp_text_input_v3::ZwpTextInputV3, + InputMethod, InputMethodKeyboardGrab, zwp_text_input_v3::ZwpTextInputV3, }, touch_owner::TouchOwnerHolder, wl_keyboard::{REPEAT_INFO_SINCE, WlKeyboard, WlKeyboardError}, @@ -216,8 +215,8 @@ pub struct WlSeatGlobal { last_input_usec: Cell, text_inputs: RefCell>>>, text_input: CloneCell>>, - input_method: CloneCell>>, - input_method_grab: CloneCell>>, + input_method: CloneCell>>, + input_method_grab: CloneCell>>, forward: Cell, focus_follows_mouse: Cell, swipe_bindings: PerClientBindings, @@ -371,7 +370,7 @@ impl WlSeatGlobal { self.seat_kb_map.get() } - pub fn input_method(&self) -> Option> { + pub fn input_method(&self) -> Option> { self.input_method.get() } @@ -693,7 +692,7 @@ impl WlSeatGlobal { } } if let Some(grab) = self.input_method_grab.get() { - grab.send_repeat_info(); + grab.on_repeat_info(); } } @@ -1315,7 +1314,7 @@ impl WlSeatGlobal { tl.tl_set_visible(visible); } if let Some(im) = self.input_method.get() { - for (_, popup) in &im.popups { + for (_, popup) in im.popups() { popup.update_visible(); } } diff --git a/src/ifs/wl_seat/event_handling.rs b/src/ifs/wl_seat/event_handling.rs index 616c9d3b..8cb052b7 100644 --- a/src/ifs/wl_seat/event_handling.rs +++ b/src/ifs/wl_seat/event_handling.rs @@ -981,15 +981,19 @@ impl WlSeatGlobal { } } self.send_components(&mut components_changed, &kbvm_state); - match self.input_method_grab.get() { - Some(g) => g.on_key(time_usec, kc.to_evdev(), key_state, &kbvm_state.kb_state), - _ => self.keyboard_node.get().node_on_key( + let mut forward_to_node = true; + if let Some(g) = self.input_method_grab.get() { + forward_to_node = + g.on_key(time_usec, kc.to_evdev(), key_state, &kbvm_state.kb_state); + } + if forward_to_node { + self.keyboard_node.get().node_on_key( self, time_usec, kc.to_evdev(), key_state, &kbvm_state.kb_state, - ), + ) } self.for_each_ei_seat(|ei_seat| { ei_seat.handle_key(time_usec, kc.to_evdev(), key_state, &kbvm_state.kb_state); @@ -1064,9 +1068,12 @@ impl WlSeatGlobal { self.state.for_each_seat_tester(|t| { t.send_modifiers(self.id, &kb_state.mods); }); - match self.input_method_grab.get() { - Some(g) => g.on_modifiers(kb_state), - _ => self.keyboard_node.get().node_on_mods(self, kb_state), + let mut forward_to_node = true; + if let Some(g) = self.input_method_grab.get() { + forward_to_node = g.on_modifiers(kb_state); + } + if forward_to_node { + self.keyboard_node.get().node_on_mods(self, kb_state) } } diff --git a/src/ifs/wl_seat/text_input.rs b/src/ifs/wl_seat/text_input.rs index 4f4f8418..4ec1516a 100644 --- a/src/ifs/wl_seat/text_input.rs +++ b/src/ifs/wl_seat/text_input.rs @@ -1,12 +1,13 @@ use { - crate::ifs::{ - wl_seat::{ - WlSeatGlobal, - text_input::{ - zwp_input_method_v2::ZwpInputMethodV2, zwp_text_input_v3::ZwpTextInputV3, - }, + crate::{ + backend::KeyState, + ifs::{ + wl_seat::{WlSeatGlobal, text_input::zwp_text_input_v3::ZwpTextInputV3}, + wl_surface::{WlSurface, zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2}, }, - wl_surface::WlSurface, + keyboard::KeyboardState, + utils::smallmap::SmallMap, + wire::ZwpInputPopupSurfaceV2Id, }, std::rc::Rc, }; @@ -22,10 +23,27 @@ const MAX_TEXT_SIZE: usize = 4000; pub struct TextInputConnection { pub seat: Rc, pub text_input: Rc, - pub input_method: Rc, + pub input_method: Rc, pub surface: Rc, } +pub trait InputMethod { + fn set_connection(&self, con: Option<&Rc>); + fn popups(&self) -> &SmallMap, 1>; + fn activate(&self); + fn deactivate(&self); + fn content_type(&self, hint: u32, purpose: u32); + fn text_change_cause(&self, cause: u32); + fn surrounding_text(&self, text: &str, cursor: u32, anchor: u32); + fn done(self: Rc, seat: &WlSeatGlobal); +} + +pub trait InputMethodKeyboardGrab { + fn on_key(&self, time_usec: u64, key: u32, state: KeyState, kb_state: &KeyboardState) -> bool; + fn on_modifiers(&self, kb_state: &KeyboardState) -> bool; + fn on_repeat_info(&self); +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum TextConnectReason { TextInputEnabled, @@ -67,7 +85,7 @@ impl WlSeatGlobal { impl TextInputConnection { fn connect(self: &Rc, reason: TextConnectReason) { - self.input_method.connection.set(Some(self.clone())); + self.input_method.set_connection(Some(self)); self.text_input.connection.set(Some(self.clone())); self.surface .text_input_connections @@ -75,20 +93,20 @@ impl TextInputConnection { self.input_method.activate(); if reason == TextConnectReason::InputMethodCreated { - self.text_input.send_all_to(&self.input_method); - self.input_method.send_done(); + self.text_input.send_all_to(&*self.input_method); + self.input_method.clone().done(&self.seat); } } pub fn disconnect(&self, reason: TextDisconnectReason) { self.text_input.connection.take(); - self.input_method.connection.take(); + self.input_method.set_connection(None); self.surface.text_input_connections.remove(&self.seat.id); if reason != TextDisconnectReason::InputMethodDestroyed { - self.input_method.send_deactivate(); - self.input_method.send_done(); - for (_, popup) in &self.input_method.popups { + self.input_method.deactivate(); + self.input_method.clone().done(&self.seat); + for (_, popup) in self.input_method.popups() { popup.update_visible(); } } diff --git a/src/ifs/wl_seat/text_input/zwp_input_method_keyboard_grab_v2.rs b/src/ifs/wl_seat/text_input/zwp_input_method_keyboard_grab_v2.rs index adedd3b3..53de655d 100644 --- a/src/ifs/wl_seat/text_input/zwp_input_method_keyboard_grab_v2.rs +++ b/src/ifs/wl_seat/text_input/zwp_input_method_keyboard_grab_v2.rs @@ -2,7 +2,10 @@ use { crate::{ backend::KeyState, client::{Client, ClientError}, - ifs::wl_seat::{text_input::zwp_input_method_v2::ZwpInputMethodV2, wl_keyboard}, + ifs::wl_seat::{ + text_input::{InputMethodKeyboardGrab, zwp_input_method_v2::ZwpInputMethodV2}, + wl_keyboard, + }, keyboard::{KeyboardState, KeyboardStateId}, leaks::Tracker, object::{Object, Version}, @@ -49,7 +52,7 @@ impl ZwpInputMethodKeyboardGrabV2 { self.kb_state_id.set(kb_state.id); } - pub fn on_key(&self, time_usec: u64, key: u32, state: KeyState, kb_state: &KeyboardState) { + fn on_key(&self, time_usec: u64, key: u32, state: KeyState, kb_state: &KeyboardState) { let serial = self.client.next_serial(); if self.kb_state_id.get() != kb_state.id { self.update_state(serial, kb_state); @@ -70,7 +73,7 @@ impl ZwpInputMethodKeyboardGrabV2 { }) } - pub fn on_modifiers(&self, kb_state: &KeyboardState) { + fn on_modifiers(&self, kb_state: &KeyboardState) { let serial = self.client.next_serial(); if self.kb_state_id.get() != kb_state.id { self.update_state(serial, kb_state); @@ -99,6 +102,22 @@ impl ZwpInputMethodKeyboardGrabV2 { } } +impl InputMethodKeyboardGrab for ZwpInputMethodKeyboardGrabV2 { + fn on_key(&self, time_usec: u64, key: u32, state: KeyState, kb_state: &KeyboardState) -> bool { + self.on_key(time_usec, key, state, kb_state); + false + } + + fn on_modifiers(&self, kb_state: &KeyboardState) -> bool { + self.on_modifiers(kb_state); + false + } + + fn on_repeat_info(&self) { + self.send_repeat_info(); + } +} + impl ZwpInputMethodKeyboardGrabV2RequestHandler for ZwpInputMethodKeyboardGrabV2 { type Error = ZwpInputMethodKeyboardGrabV2Error; diff --git a/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs b/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs index 3848bc56..5f6e3b15 100644 --- a/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs +++ b/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs @@ -5,7 +5,7 @@ use { wl_seat::{ WlSeatGlobal, text_input::{ - MAX_TEXT_SIZE, TextDisconnectReason, TextInputConnection, + InputMethod, MAX_TEXT_SIZE, TextDisconnectReason, TextInputConnection, zwp_input_method_keyboard_grab_v2::ZwpInputMethodKeyboardGrabV2, }, }, @@ -104,6 +104,40 @@ impl ZwpInputMethodV2 { } } +impl InputMethod for ZwpInputMethodV2 { + fn set_connection(&self, con: Option<&Rc>) { + self.connection.set(con.cloned()); + } + + fn popups(&self) -> &SmallMap, 1> { + &self.popups + } + + fn activate(&self) { + self.activate(); + } + + fn deactivate(&self) { + self.send_deactivate(); + } + + fn content_type(&self, hint: u32, purpose: u32) { + self.send_content_type(hint, purpose); + } + + fn text_change_cause(&self, cause: u32) { + self.send_text_change_cause(cause); + } + + fn surrounding_text(&self, text: &str, cursor: u32, anchor: u32) { + self.send_surrounding_text(text, cursor, anchor); + } + + fn done(self: Rc, _seat: &WlSeatGlobal) { + (*self).send_done(); + } +} + impl ZwpInputMethodV2RequestHandler for ZwpInputMethodV2 { type Error = ZwpInputMethodV2Error; diff --git a/src/ifs/wl_seat/text_input/zwp_text_input_v3.rs b/src/ifs/wl_seat/text_input/zwp_text_input_v3.rs index 54211b16..f831ac33 100644 --- a/src/ifs/wl_seat/text_input/zwp_text_input_v3.rs +++ b/src/ifs/wl_seat/text_input/zwp_text_input_v3.rs @@ -5,8 +5,8 @@ use { wl_seat::{ WlSeatGlobal, text_input::{ - MAX_TEXT_SIZE, TextConnectReason, TextDisconnectReason, TextInputConnection, - zwp_input_method_v2::ZwpInputMethodV2, + InputMethod, MAX_TEXT_SIZE, TextConnectReason, TextDisconnectReason, + TextInputConnection, }, }, wl_surface::WlSurface, @@ -72,13 +72,13 @@ impl ZwpTextInputV3 { } } - pub fn send_all_to(&self, im: &ZwpInputMethodV2) { + pub fn send_all_to(&self, im: &dyn InputMethod) { let state = &*self.state.borrow(); { let (a, b, c) = &state.surrounding_text; - im.send_surrounding_text(a, *b, *c); + im.surrounding_text(a, *b, *c); } - im.send_content_type(state.content_type.0, state.content_type.1); + im.content_type(state.content_type.0, state.content_type.1); } pub fn send_enter(&self, surface: &WlSurface) { @@ -255,7 +255,7 @@ impl ZwpTextInputV3RequestHandler for ZwpTextInputV3 { if state.cursor_rectangle != val && let Some(con) = &con { - for (_, popup) in &con.input_method.popups { + for (_, popup) in con.input_method.popups() { popup.schedule_positioning(); } } @@ -264,26 +264,26 @@ impl ZwpTextInputV3RequestHandler for ZwpTextInputV3 { if let Some(val) = pending.content_type { if let Some(con) = &con { sent_any = true; - con.input_method.send_content_type(val.0, val.1); + con.input_method.content_type(val.0, val.1); } state.content_type = val; } if let Some(val) = pending.text_change_cause { if let Some(con) = &con { sent_any = true; - con.input_method.send_text_change_cause(val); + con.input_method.text_change_cause(val); } state.text_change_cause = val; } if let Some(val) = pending.surrounding_text { if let Some(con) = &con { sent_any = true; - con.input_method.send_surrounding_text(&val.0, val.1, val.2); + con.input_method.surrounding_text(&val.0, val.1, val.2); } state.surrounding_text = val; } if sent_any && let Some(con) = &con { - con.input_method.send_done(); + con.input_method.clone().done(&self.seat); } Ok(()) } diff --git a/src/ifs/wl_surface.rs b/src/ifs/wl_surface.rs index 282a7696..a108b7f4 100644 --- a/src/ifs/wl_surface.rs +++ b/src/ifs/wl_surface.rs @@ -784,7 +784,7 @@ impl WlSurface { } } for (_, con) in &self.text_input_connections { - for (_, popup) in &con.input_method.popups { + for (_, popup) in con.input_method.popups() { popup.schedule_positioning(); } } From 8372f8373724d0d91068c3e4427970b54beda81c Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Thu, 16 Oct 2025 01:53:56 +0200 Subject: [PATCH 2/6] text-input: reset text-input state after disconnect --- src/ifs/wl_seat/text_input.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ifs/wl_seat/text_input.rs b/src/ifs/wl_seat/text_input.rs index 4ec1516a..b8855295 100644 --- a/src/ifs/wl_seat/text_input.rs +++ b/src/ifs/wl_seat/text_input.rs @@ -110,5 +110,10 @@ impl TextInputConnection { popup.update_visible(); } } + if reason != TextDisconnectReason::TextInputDisabled { + self.text_input.send_preedit_string(None, 0, 0); + self.text_input.send_commit_string(None); + self.text_input.send_done(); + } } } From 58b9830aaa6ae88b367655ec610695823768e0f6 Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Thu, 16 Oct 2025 00:58:30 +0200 Subject: [PATCH 3/6] seat: add a simple input method --- Cargo.toml | 2 +- src/ifs/wl_seat.rs | 8 +- src/ifs/wl_seat/text_input.rs | 29 +++ src/ifs/wl_seat/text_input/simple_im.rs | 204 ++++++++++++++++++ .../text_input/zwp_input_method_manager_v2.rs | 8 +- .../wl_seat/text_input/zwp_input_method_v2.rs | 10 +- 6 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 src/ifs/wl_seat/text_input/simple_im.rs diff --git a/Cargo.toml b/Cargo.toml index 381eddf5..a4e37d31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ linearize = { version = "0.1.3", features = ["derive"] } png = "0.18.0" rustc-demangle = { version = "0.1.24", optional = true } tracy-client-sys = { version = "0.24.1", features = ["ondemand", "manual-lifetime", "debuginfod", "demangle"], optional = true } -kbvm = "0.1.5" +kbvm = { version = "0.1.5", features = ["compose"] } tiny-skia = { version = "0.11.4", default-features = false, features = ["std"] } regex = "1.11.1" cfg-if = "1.0.0" diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs index 596d5dcc..e54758fc 100644 --- a/src/ifs/wl_seat.rs +++ b/src/ifs/wl_seat.rs @@ -52,7 +52,8 @@ use { pointer_owner::PointerOwnerHolder, tablet::TabletSeatData, text_input::{ - InputMethod, InputMethodKeyboardGrab, zwp_text_input_v3::ZwpTextInputV3, + InputMethod, InputMethodKeyboardGrab, simple_im::SimpleIm, + zwp_text_input_v3::ZwpTextInputV3, }, touch_owner::TouchOwnerHolder, wl_keyboard::{REPEAT_INFO_SINCE, WlKeyboard, WlKeyboardError}, @@ -237,6 +238,7 @@ pub struct WlSeatGlobal { marks: CopyHashMap>, modifiers_listener: EventListener, modifiers_forward: EventSource, + simple_im: CloneCell>>, } #[derive(Copy, Clone)] @@ -258,6 +260,7 @@ impl WlSeatGlobal { let cursor_user_group = CursorUserGroup::create(state); let cursor_user = cursor_user_group.create_user(); cursor_user.activate(); + let simple_im = SimpleIm::new(&state.kb_ctx.ctx); let slf = Rc::new_cyclic(|slf: &Weak| Self { id: state.seat_ids.next(), name, @@ -305,7 +308,7 @@ impl WlSeatGlobal { data_control_devices: Default::default(), text_inputs: Default::default(), text_input: Default::default(), - input_method: Default::default(), + input_method: CloneCell::new(simple_im.clone().map(|im| im as _)), input_method_grab: Default::default(), forward: Cell::new(false), focus_follows_mouse: Cell::new(true), @@ -326,6 +329,7 @@ impl WlSeatGlobal { marks: Default::default(), modifiers_listener: EventListener::new(slf.clone()), modifiers_forward: Default::default(), + simple_im: CloneCell::new(simple_im), }); slf.pointer_cursor.set_owner(slf.clone()); slf.modifiers_listener diff --git a/src/ifs/wl_seat/text_input.rs b/src/ifs/wl_seat/text_input.rs index b8855295..3802afa6 100644 --- a/src/ifs/wl_seat/text_input.rs +++ b/src/ifs/wl_seat/text_input.rs @@ -12,6 +12,7 @@ use { std::rc::Rc, }; +pub mod simple_im; pub mod zwp_input_method_keyboard_grab_v2; pub mod zwp_input_method_manager_v2; pub mod zwp_input_method_v2; @@ -36,6 +37,8 @@ pub trait InputMethod { fn text_change_cause(&self, cause: u32); fn surrounding_text(&self, text: &str, cursor: u32, anchor: u32); fn done(self: Rc, seat: &WlSeatGlobal); + fn is_simple(&self) -> bool; + fn cancel_simple(&self, seat: &WlSeatGlobal); } pub trait InputMethodKeyboardGrab { @@ -58,6 +61,32 @@ pub enum TextDisconnectReason { } impl WlSeatGlobal { + fn can_set_new_im(&self) -> bool { + match self.input_method.get() { + None => true, + Some(im) => im.is_simple(), + } + } + + fn cannot_set_new_im(&self) -> bool { + !self.can_set_new_im() + } + + fn set_input_method(self: &Rc, im: Rc) { + if let Some(old) = self.input_method.take() { + old.cancel_simple(self); + } + self.input_method.set(Some(im)); + self.create_text_input_connection(TextConnectReason::InputMethodCreated); + } + + fn remove_input_method(self: &Rc) { + self.input_method.take(); + if let Some(im) = self.simple_im.get() { + self.set_input_method(im); + } + } + fn create_text_input_connection(self: &Rc, text_connect_reason: TextConnectReason) { let Some(im) = self.input_method.get() else { return; diff --git a/src/ifs/wl_seat/text_input/simple_im.rs b/src/ifs/wl_seat/text_input/simple_im.rs new file mode 100644 index 00000000..589d2c11 --- /dev/null +++ b/src/ifs/wl_seat/text_input/simple_im.rs @@ -0,0 +1,204 @@ +use { + crate::{ + backend::KeyState, + ifs::{ + wl_seat::{ + WlSeatGlobal, + text_input::{ + InputMethod, InputMethodKeyboardGrab, TextDisconnectReason, TextInputConnection, + }, + }, + wl_surface::zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2, + }, + keyboard::KeyboardState, + utils::{clonecell::CloneCell, smallmap::SmallMap}, + wire::ZwpInputPopupSurfaceV2Id, + }, + kbvm::{ + Keycode, ModifierMask, syms, + xkb::{ + self, + compose::{self, FeedResult}, + diagnostic::WriteToLog, + }, + }, + std::{ + cell::{Cell, RefCell}, + rc::Rc, + }, +}; + +pub struct SimpleIm { + con: CloneCell>>, + popups: SmallMap, 1>, + active: Cell, + activate: Cell>, + table: compose::ComposeTable, + initial_state: compose::State, + states: RefCell>, +} + +struct State { + state: compose::State, + char: char, +} + +impl SimpleIm { + pub fn new(ctx: &xkb::Context) -> Option> { + let table = ctx.compose_table_builder().build(WriteToLog)?; + Some(Rc::new(Self { + con: Default::default(), + popups: Default::default(), + active: Default::default(), + activate: Default::default(), + states: Default::default(), + initial_state: table.create_state(), + table, + })) + } +} + +impl InputMethod for SimpleIm { + fn set_connection(&self, con: Option<&Rc>) { + self.con.set(con.cloned()); + } + + fn popups(&self) -> &SmallMap, 1> { + &self.popups + } + + fn activate(&self) { + self.activate.set(Some(true)); + } + + fn deactivate(&self) { + self.activate.set(Some(false)); + } + + fn content_type(&self, _hint: u32, _purpose: u32) { + // nothing + } + + fn text_change_cause(&self, _cause: u32) { + // nothing + } + + fn surrounding_text(&self, _text: &str, _cursor: u32, _anchor: u32) { + // nothing + } + + fn done(self: Rc, seat: &WlSeatGlobal) { + let Some(active) = self.activate.take() else { + return; + }; + self.active.set(active); + if active { + self.states.borrow_mut().clear(); + seat.input_method_grab.set(Some(self)); + } else { + seat.input_method_grab.take(); + } + } + + fn is_simple(&self) -> bool { + true + } + + fn cancel_simple(&self, seat: &WlSeatGlobal) { + seat.input_method_grab.take(); + if let Some(con) = self.con.get() { + con.disconnect(TextDisconnectReason::InputMethodDestroyed); + } + } +} + +impl InputMethodKeyboardGrab for SimpleIm { + fn on_key(&self, _time_usec: u64, key: u32, state: KeyState, kb_state: &KeyboardState) -> bool { + if state != KeyState::Pressed { + return true; + } + let Some(con) = self.con.get() else { + return true; + }; + let mut buf = [0; 4]; + let mut forward_to_node = true; + let states = &mut *self.states.borrow_mut(); + let lookup = kb_state.map.lookup_table.lookup( + kb_state.mods.group, + kb_state.mods.mods, + Keycode::from_evdev(key), + ); + let mods = lookup.remaining_mods(); + let is_control = mods.contains(ModifierMask::CONTROL); + for sym in lookup { + let sym = sym.keysym(); + let mut new_state = states + .last() + .map(|s| s.state.clone()) + .unwrap_or_else(|| self.initial_state.clone()); + let Some(fr) = self.table.feed(&mut new_state, sym) else { + continue; + }; + forward_to_node = false; + let mut send_preedit = |char: char| { + let s = char.encode_utf8(&mut buf); + let len = s.len() as i32; + con.text_input.send_preedit_string(Some(s), len, len); + }; + match fr { + FeedResult::Pending => { + let char = sym.char().unwrap_or('ยท'); + states.push(State { + state: new_state, + char, + }); + send_preedit(char); + con.text_input.send_done(); + } + FeedResult::Aborted + if sym == syms::Escape || (matches!(sym, syms::c | syms::w) && is_control) => + { + states.clear(); + con.text_input.send_preedit_string(None, 0, 0); + con.text_input.send_done(); + } + FeedResult::Aborted if sym == syms::BackSpace => { + states.pop(); + if let Some(state) = states.last() { + send_preedit(state.char); + } else { + con.text_input.send_preedit_string(None, 0, 0); + } + con.text_input.send_done(); + } + FeedResult::Aborted => { + // nothing + } + FeedResult::Composed { string, keysym } => { + states.clear(); + let s = if string.is_some() { + string + } else if let Some(sym) = keysym + && let Some(char) = sym.char() + { + Some(char.encode_utf8(&mut buf) as &str) + } else { + None + }; + con.text_input.send_preedit_string(None, 0, 0); + con.text_input.send_commit_string(s); + con.text_input.send_done(); + } + } + } + forward_to_node + } + + fn on_modifiers(&self, _kb_state: &KeyboardState) -> bool { + true + } + + fn on_repeat_info(&self) { + // nothing + } +} diff --git a/src/ifs/wl_seat/text_input/zwp_input_method_manager_v2.rs b/src/ifs/wl_seat/text_input/zwp_input_method_manager_v2.rs index d78dbc7b..c848fb23 100644 --- a/src/ifs/wl_seat/text_input/zwp_input_method_manager_v2.rs +++ b/src/ifs/wl_seat/text_input/zwp_input_method_manager_v2.rs @@ -2,7 +2,7 @@ use { crate::{ client::{CAP_INPUT_METHOD, Client, ClientCaps, ClientError}, globals::{Global, GlobalName}, - ifs::wl_seat::text_input::{TextConnectReason, zwp_input_method_v2::ZwpInputMethodV2}, + ifs::wl_seat::text_input::zwp_input_method_v2::ZwpInputMethodV2, leaks::Tracker, object::{Object, Version}, wire::{ZwpInputMethodManagerV2Id, zwp_input_method_manager_v2::*}, @@ -72,7 +72,7 @@ impl ZwpInputMethodManagerV2RequestHandler for ZwpInputMethodManagerV2 { fn get_input_method(&self, req: GetInputMethod, _slf: &Rc) -> Result<(), Self::Error> { let seat = self.client.lookup(req.seat)?; - let inert = seat.global.input_method.is_some(); + let inert = seat.global.cannot_set_new_im(); let im = Rc::new(ZwpInputMethodV2 { id: req.input_method, client: self.client.clone(), @@ -90,9 +90,7 @@ impl ZwpInputMethodManagerV2RequestHandler for ZwpInputMethodManagerV2 { if inert { im.send_unavailable(); } else { - seat.global.input_method.set(Some(im)); - seat.global - .create_text_input_connection(TextConnectReason::InputMethodCreated); + seat.global.set_input_method(im); } Ok(()) } diff --git a/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs b/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs index 5f6e3b15..fe82d42a 100644 --- a/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs +++ b/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs @@ -53,7 +53,7 @@ impl ZwpInputMethodV2 { } self.popups.clear(); if !self.inert { - self.seat.input_method.take(); + self.seat.remove_input_method(); } } @@ -136,6 +136,14 @@ impl InputMethod for ZwpInputMethodV2 { fn done(self: Rc, _seat: &WlSeatGlobal) { (*self).send_done(); } + + fn is_simple(&self) -> bool { + false + } + + fn cancel_simple(&self, _seat: &WlSeatGlobal) { + unreachable!(); + } } impl ZwpInputMethodV2RequestHandler for ZwpInputMethodV2 { From 2f22a61710290346c90b016c05998ec11ff02de3 Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Thu, 16 Oct 2025 01:48:47 +0200 Subject: [PATCH 4/6] config: allow configuring the simple IM --- jay-config/src/_private/client.rs | 14 +++++ jay-config/src/_private/ipc.rs | 13 ++++ jay-config/src/input.rs | 28 +++++++++ src/config/handler.rs | 29 +++++++++ src/ifs/wl_seat.rs | 2 + src/ifs/wl_seat/text_input.rs | 45 +++++++++++++- toml-config/src/config.rs | 9 +++ toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/action.rs | 4 ++ toml-config/src/config/parsers/config.rs | 20 +++++- toml-config/src/config/parsers/simple_im.rs | 44 ++++++++++++++ toml-config/src/lib.rs | 17 ++++++ toml-spec/spec/spec.generated.json | 23 ++++++- toml-spec/spec/spec.generated.md | 67 ++++++++++++++++++++- toml-spec/spec/spec.yaml | 58 +++++++++++++++++- 15 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 toml-config/src/config/parsers/simple_im.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 936b8b5c..898ddd2e 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1026,6 +1026,20 @@ impl ConfigClient { self.send(&ClientMessage::SeatCopyMark { seat, src, dst }); } + pub fn seat_set_simple_im_enabled(&self, seat: Seat, enabled: bool) { + self.send(&ClientMessage::SeatSetSimpleImEnabled { seat, enabled }); + } + + pub fn seat_get_simple_im_enabled(&self, seat: Seat) -> bool { + let res = self.send_with_response(&ClientMessage::SeatGetSimpleImEnabled { seat }); + get_response!(res, false, SeatGetSimpleImEnabled { enabled }); + enabled + } + + pub fn seat_reload_simple_im(&self, seat: Seat) { + self.send(&ClientMessage::SeatReloadSimpleIm { seat }); + } + pub fn set_show_float_pin_icon(&self, show: bool) { self.send(&ClientMessage::SetShowFloatPinIcon { show }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 02e9fbfd..b1e91ec4 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -788,6 +788,16 @@ pub enum ClientMessage<'a> { workspace: Workspace, connector: Connector, }, + SeatSetSimpleImEnabled { + seat: Seat, + enabled: bool, + }, + SeatGetSimpleImEnabled { + seat: Seat, + }, + SeatReloadSimpleIm { + seat: Seat, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -1020,6 +1030,9 @@ pub enum Response { GetShowBar { show: bool, }, + SeatGetSimpleImEnabled { + enabled: bool, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 61b6c484..1956791b 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -603,6 +603,34 @@ impl Seat { pub fn copy_mark(self, src: u32, dst: u32) { get!().seat_copy_mark(self, src, dst); } + + /// Sets whether the simple, XCompose based input method is enabled. + /// + /// Regardless of this setting, this input method is not used if an external input + /// method is running. + /// + /// The default is `true`. + pub fn set_simple_im_enabled(self, enabled: bool) { + get!().seat_set_simple_im_enabled(self, enabled); + } + + /// Returns whether the simple, XCompose based input method is enabled. + pub fn simple_im_enabled(self) -> bool { + get!(true).seat_get_simple_im_enabled(self) + } + + /// Toggles whether the simple, XCompose based input method is enabled. + pub fn toggle_simple_im_enabled(self) { + let get = get!(); + get.seat_set_simple_im_enabled(self, !get.seat_get_simple_im_enabled(self)); + } + + /// Reloads the simple, XCompose based input method. + /// + /// This is useful if you change the XCompose files after starting the compositor. + pub fn reload_simple_im(self) { + get!().seat_reload_simple_im(self); + } } /// A focus-follows-mouse mode. diff --git a/src/config/handler.rs b/src/config/handler.rs index 8573532e..c1cd7759 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -2281,6 +2281,26 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_seat_set_simple_im_enabled(&self, seat: Seat, enabled: bool) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.set_simple_im_enabled(enabled); + Ok(()) + } + + fn handle_seat_get_simple_im_enabled(&self, seat: Seat) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + self.respond(Response::SeatGetSimpleImEnabled { + enabled: seat.simple_im_enabled(), + }); + Ok(()) + } + + fn handle_seat_reload_simple_im(&self, seat: Seat) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.reload_simple_im(); + Ok(()) + } + fn spaces_change(&self) { struct V; impl NodeVisitorBase for V { @@ -3216,6 +3236,15 @@ impl ConfigProxyHandler { } => self .handle_show_workspace(seat, workspace, Some(connector)) .wrn("show_workspace_on")?, + ClientMessage::SeatSetSimpleImEnabled { seat, enabled } => self + .handle_seat_set_simple_im_enabled(seat, enabled) + .wrn("seat_set_simple_im_enabled")?, + ClientMessage::SeatGetSimpleImEnabled { seat } => self + .handle_seat_get_simple_im_enabled(seat) + .wrn("seat_get_simple_im_enabled")?, + ClientMessage::SeatReloadSimpleIm { seat } => self + .handle_seat_reload_simple_im(seat) + .wrn("seat_reload_simple_im")?, } Ok(()) } diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs index e54758fc..a2e3200e 100644 --- a/src/ifs/wl_seat.rs +++ b/src/ifs/wl_seat.rs @@ -239,6 +239,7 @@ pub struct WlSeatGlobal { modifiers_listener: EventListener, modifiers_forward: EventSource, simple_im: CloneCell>>, + simple_im_enabled: Cell, } #[derive(Copy, Clone)] @@ -330,6 +331,7 @@ impl WlSeatGlobal { modifiers_listener: EventListener::new(slf.clone()), modifiers_forward: Default::default(), simple_im: CloneCell::new(simple_im), + simple_im_enabled: Cell::new(true), }); slf.pointer_cursor.set_owner(slf.clone()); slf.modifiers_listener diff --git a/src/ifs/wl_seat/text_input.rs b/src/ifs/wl_seat/text_input.rs index 3802afa6..2cdf08bf 100644 --- a/src/ifs/wl_seat/text_input.rs +++ b/src/ifs/wl_seat/text_input.rs @@ -2,7 +2,10 @@ use { crate::{ backend::KeyState, ifs::{ - wl_seat::{WlSeatGlobal, text_input::zwp_text_input_v3::ZwpTextInputV3}, + wl_seat::{ + WlSeatGlobal, + text_input::{simple_im::SimpleIm, zwp_text_input_v3::ZwpTextInputV3}, + }, wl_surface::{WlSurface, zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2}, }, keyboard::KeyboardState, @@ -61,6 +64,42 @@ pub enum TextDisconnectReason { } impl WlSeatGlobal { + pub fn set_simple_im_enabled(self: &Rc, enabled: bool) { + if self.simple_im_enabled.replace(enabled) == enabled { + return; + } + if enabled { + if self.input_method.is_none() + && let Some(im) = self.simple_im.get() + { + self.set_input_method(im); + } + } else { + if let Some(im) = self.input_method.get() + && im.is_simple() + { + self.input_method.take(); + im.cancel_simple(self); + } + } + } + + pub fn simple_im_enabled(&self) -> bool { + self.simple_im_enabled.get() + } + + pub fn reload_simple_im(self: &Rc) { + let im = SimpleIm::new(&self.state.kb_ctx.ctx); + self.simple_im.set(im.clone()); + if self.simple_im_enabled.get() && self.can_set_new_im() { + if let Some(im) = im { + self.set_input_method(im); + } else if let Some(old) = self.input_method.take() { + old.cancel_simple(self); + } + } + } + fn can_set_new_im(&self) -> bool { match self.input_method.get() { None => true, @@ -82,7 +121,9 @@ impl WlSeatGlobal { fn remove_input_method(self: &Rc) { self.input_method.take(); - if let Some(im) = self.simple_im.get() { + if self.simple_im_enabled.get() + && let Some(im) = self.simple_im.get() + { self.set_input_method(im); } } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 6d240d96..5c7785e8 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -85,6 +85,9 @@ pub enum SimpleCommand { CreateMark, JumpToMark, PopMode(bool), + EnableSimpleIm(bool), + ToggleSimpleImEnabled, + ReloadSimpleIm, } #[derive(Debug, Clone)] @@ -446,6 +449,11 @@ pub struct Vrr { pub cursor_hz: Option, } +#[derive(Debug, Clone)] +pub struct SimpleIm { + pub enabled: Option, +} + #[derive(Debug, Clone)] pub struct Xwayland { pub scaling_mode: Option, @@ -520,6 +528,7 @@ pub struct Config { pub middle_click_paste: Option, pub input_modes: AHashMap, pub workspace_display_order: Option, + pub simple_im: Option, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 46ec09f2..c786c71d 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -39,6 +39,7 @@ mod output; mod output_match; mod repeat_rate; pub mod shortcuts; +mod simple_im; mod status; mod tearing; mod theme; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 10a2efcd..1f1bd327 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -155,6 +155,10 @@ impl ActionParser<'_> { "jump-to-mark" => JumpToMark, "clear-modes" => PopMode(false), "pop-mode" => PopMode(true), + "enable-simple-im" => EnableSimpleIm(true), + "disable-simple-im" => EnableSimpleIm(false), + "toggle-simple-im-enabled" => ToggleSimpleImEnabled, + "reload-simple-im" => ReloadSimpleIm, _ => { return Err( ActionParserError::UnknownSimpleAction(string.to_string()).spanned(span) diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 59268800..03cefe0f 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -30,6 +30,7 @@ use { ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, parse_modified_keysym_str, }, + simple_im::SimpleImParser, status::StatusParser, tearing::TearingParser, theme::ThemeParser, @@ -139,7 +140,13 @@ impl Parser for ConfigParser<'_> { show_bar, focus_history_val, ), - (middle_click_paste, input_modes_val, workspace_display_order_val, auto_reload), + ( + middle_click_paste, + input_modes_val, + workspace_display_order_val, + auto_reload, + simple_im_val, + ), ) = ext.extract(( ( opt(val("keymap")), @@ -194,6 +201,7 @@ impl Parser for ConfigParser<'_> { opt(val("modes")), opt(val("workspace-display-order")), recover(opt(bol("auto-reload"))), + opt(val("simple-im")), ), ))?; let mut keymap = None; @@ -505,6 +513,15 @@ impl Parser for ConfigParser<'_> { } } } + let mut simple_im = None; + if let Some(value) = simple_im_val { + match value.parse(&mut SimpleImParser(self.0)) { + Ok(v) => simple_im = Some(v), + Err(e) => { + log::warn!("Could not parse simple IM setting: {}", self.0.error(e)); + } + } + } Ok(Config { keymap, repeat_rate, @@ -549,6 +566,7 @@ impl Parser for ConfigParser<'_> { middle_click_paste: middle_click_paste.despan(), input_modes, workspace_display_order, + simple_im, }) } } diff --git a/toml-config/src/config/parsers/simple_im.rs b/toml-config/src/config/parsers/simple_im.rs new file mode 100644 index 00000000..b4c08095 --- /dev/null +++ b/toml-config/src/config/parsers/simple_im.rs @@ -0,0 +1,44 @@ +use { + crate::{ + config::{ + SimpleIm, + context::Context, + extractor::{Extractor, ExtractorError, bol, opt, recover}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum SimpleImParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), +} + +pub struct SimpleImParser<'a>(pub &'a Context<'a>); + +impl Parser for SimpleImParser<'_> { + type Value = SimpleIm; + type Error = SimpleImParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (enabled,) = ext.extract((recover(opt(bol("enabled"))),))?; + Ok(SimpleIm { + enabled: enabled.despan(), + }) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index cf5bd21b..7ba79aeb 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -224,6 +224,18 @@ impl Action { let state = state.clone(); b.new(move || state.pop_mode(pop)) } + SimpleCommand::EnableSimpleIm(v) => { + let persistent = state.persistent.clone(); + b.new(move || persistent.seat.set_simple_im_enabled(v)) + } + SimpleCommand::ToggleSimpleImEnabled => { + let persistent = state.persistent.clone(); + b.new(move || persistent.seat.toggle_simple_im_enabled()) + } + SimpleCommand::ReloadSimpleIm => { + let persistent = state.persistent.clone(); + b.new(move || persistent.seat.reload_simple_im()) + } }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -1559,6 +1571,11 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Command { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 0de5ae68..a2fa94e9 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -361,7 +361,7 @@ ] }, { - "description": "Sets the log level of the compositor..\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-j = { type = \"set-log-level\", level = \"debug\" }\n ```\n", + "description": "Sets the log level of the compositor.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-j = { type = \"set-log-level\", level = \"debug\" }\n ```\n", "type": "object", "properties": { "type": { @@ -1049,6 +1049,10 @@ "workspace-display-order": { "description": "Configures the order of workspaces displayed.\n\nThe default is `manual`.\n\n- Example:\n\n ```toml\n workspace-display-order = \"sorted\"\n ```\n", "$ref": "#/$defs/WorkspaceDisplayOrder" + }, + "simple-im": { + "description": "Configures the simple, XCompose based input method.\n\nBy default, the input method is enabled. \n\nEven if the input method is enabled, it will only be used if there is no\nrunning external IM.\n\n- Example:\n\n ```toml\n [simple-im]\n enabled = false\n ```\n", + "$ref": "#/$defs/SimpleIm" } }, "required": [] @@ -1835,9 +1839,24 @@ "create-mark", "jump-to-mark", "clear-modes", - "pop-mode" + "pop-mode", + "enable-simple-im", + "disable-simple-im", + "toggle-simple-im-enabled", + "reload-simple-im" ] }, + "SimpleIm": { + "description": "Describes the settings of the simple, XCompose based input method.\n\n- Example:\n\n ```toml\n [simple-im]\n enabled = false\n ```\n", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the input method is enabled.\n\nEven if the input method is enabled, it will only be used if there is no\nrunning external IM.\n" + } + }, + "required": [] + }, "Status": { "description": "The configuration of a status program whose output will be shown in the bar.\n\n- Example:\n\n ```toml\n [status]\n format = \"i3bar\"\n exec = \"i3status\"\n ```\n", "type": "object", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 41a72d2e..e4666c24 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -538,7 +538,7 @@ This table is a tagged union. The variant is determined by the `type` field. It - `set-log-level`: - Sets the log level of the compositor.. + Sets the log level of the compositor. - Example: @@ -2145,6 +2145,24 @@ The table has the following fields: The value of this field should be a [WorkspaceDisplayOrder](#types-WorkspaceDisplayOrder). +- `simple-im` (optional): + + Configures the simple, XCompose based input method. + + By default, the input method is enabled. + + Even if the input method is enabled, it will only be used if there is no + running external IM. + + - Example: + + ```toml + [simple-im] + enabled = false + ``` + + The value of this field should be a [SimpleIm](#types-SimpleIm). + ### `Connector` @@ -4177,6 +4195,53 @@ The string should have one of the following values: Pops the topmost mode from the input-mode stack. +- `enable-simple-im`: + + Enables the simple, XCompose based input method. + + Even if the input method is enabled, it will only be used if there is no + running external IM. + +- `disable-simple-im`: + + Disables the simple, XCompose based input method. + +- `toggle-simple-im-enabled`: + + Toggles whether the simple, XCompose based input method is enabled. + +- `reload-simple-im`: + + Reloads the simple, XCompose based input method. + + This is useful if you change the XCompose files after starting the compositor. + + + + +### `SimpleIm` + +Describes the settings of the simple, XCompose based input method. + +- Example: + + ```toml + [simple-im] + enabled = false + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `enabled` (optional): + + Whether the input method is enabled. + + Even if the input method is enabled, it will only be used if there is no + running external IM. + + The value of this field should be a boolean. diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 8932c35d..4262deff 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -497,7 +497,7 @@ Action: ref: Theme set-log-level: description: | - Sets the log level of the compositor.. + Sets the log level of the compositor. - Example: @@ -1033,6 +1033,23 @@ SimpleActionName: description: Disables all previously set input modes, clearing the input-mode stack. - value: pop-mode description: Pops the topmost mode from the input-mode stack. + - value: enable-simple-im + description: | + Enables the simple, XCompose based input method. + + Even if the input method is enabled, it will only be used if there is no + running external IM. + - value: disable-simple-im + description: | + Disables the simple, XCompose based input method. + - value: toggle-simple-im-enabled + description: | + Toggles whether the simple, XCompose based input method is enabled. + - value: reload-simple-im + description: | + Reloads the simple, XCompose based input method. + + This is useful if you change the XCompose files after starting the compositor. Color: @@ -2881,6 +2898,23 @@ Config: ```toml workspace-display-order = "sorted" ``` + simple-im: + ref: SimpleIm + required: false + description: | + Configures the simple, XCompose based input method. + + By default, the input method is enabled. + + Even if the input method is enabled, it will only be used if there is no + running external IM. + + - Example: + + ```toml + [simple-im] + enabled = false + ``` Idle: @@ -4195,3 +4229,25 @@ ClientCapabilities: description: An array of masks that are OR'd. items: ref: ClientCapabilities + + +SimpleIm: + kind: table + description: | + Describes the settings of the simple, XCompose based input method. + + - Example: + + ```toml + [simple-im] + enabled = false + ``` + fields: + enabled: + kind: boolean + required: false + description: | + Whether the input method is enabled. + + Even if the input method is enabled, it will only be used if there is no + running external IM. From 9ac4fea594bbad1cdf96d90ec32ca9a407e85093 Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Thu, 16 Oct 2025 13:12:25 +0200 Subject: [PATCH 5/6] cli: allow configuring the simple IM --- src/cli/input.rs | 43 +++++++++++++++++++++++++++++++++++++++ src/ifs/jay_compositor.rs | 2 +- src/ifs/jay_input.rs | 24 ++++++++++++++++++++++ src/tools/tool_client.rs | 2 +- wire/jay_input.txt | 9 ++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/cli/input.rs b/src/cli/input.rs index aaf75aa5..ee253ebf 100644 --- a/src/cli/input.rs +++ b/src/cli/input.rs @@ -83,6 +83,28 @@ pub enum SeatCommand { UseHardwareCursor(UseHardwareCursorArgs), /// Set the size of the cursor. SetCursorSize(SetCursorSizeArgs), + /// Configure the simple, XCompose based input method. + SimpleIm(SimpleImArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct SimpleImArgs { + #[clap(subcommand)] + pub command: SimpleImCommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum SimpleImCommand { + /// Enable the simple IM. + /// + /// Even if the IM is enabled, it will not be used if an external IM is running. + Enable, + /// Disable the simple IM. + Disable, + /// Reload the simple IM. + /// + /// This is useful if you change the XCompose files after starting the compositor. + Reload, } impl Default for SeatCommand { @@ -460,6 +482,27 @@ impl Input { size: a.size, }); } + SeatCommand::SimpleIm(a) => match a.command { + SimpleImCommand::Enable | SimpleImCommand::Disable => { + self.handle_error(input, |e| { + eprintln!("Could not enable/disable the simple IM: {}", e); + }); + tc.send(jay_input::SetSimpleImEnabled { + self_id: input, + seat: &args.seat, + enabled: matches!(a.command, SimpleImCommand::Enable) as _, + }); + } + SimpleImCommand::Reload => { + self.handle_error(input, |e| { + eprintln!("Could not reload the simple IM: {}", e); + }); + tc.send(jay_input::ReloadSimpleIm { + self_id: input, + seat: &args.seat, + }); + } + }, } tc.round_trip().await; } diff --git a/src/ifs/jay_compositor.rs b/src/ifs/jay_compositor.rs index 05aa3064..7e1dde99 100644 --- a/src/ifs/jay_compositor.rs +++ b/src/ifs/jay_compositor.rs @@ -79,7 +79,7 @@ impl Global for JayCompositorGlobal { } fn version(&self) -> u32 { - 21 + 22 } fn required_caps(&self) -> ClientCaps { diff --git a/src/ifs/jay_input.rs b/src/ifs/jay_input.rs index dbcb75e3..531d2cd3 100644 --- a/src/ifs/jay_input.rs +++ b/src/ifs/jay_input.rs @@ -515,6 +515,30 @@ impl JayInputRequestHandler for JayInput { Ok(()) }) } + + fn set_simple_im_enabled( + &self, + req: SetSimpleImEnabled<'_>, + _slf: &Rc, + ) -> Result<(), Self::Error> { + self.or_error(|| { + let seat = self.seat(req.seat)?; + seat.set_simple_im_enabled(req.enabled != 0); + Ok(()) + }) + } + + fn reload_simple_im( + &self, + req: ReloadSimpleIm<'_>, + _slf: &Rc, + ) -> Result<(), Self::Error> { + self.or_error(|| { + let seat = self.seat(req.seat)?; + seat.reload_simple_im(); + Ok(()) + }) + } } object_base! { diff --git a/src/tools/tool_client.rs b/src/tools/tool_client.rs index 82f013ba..7a66b935 100644 --- a/src/tools/tool_client.rs +++ b/src/tools/tool_client.rs @@ -335,7 +335,7 @@ impl ToolClient { self_id: s.registry, name: s.jay_compositor.0, interface: JayCompositor.name(), - version: s.jay_compositor.1.min(21), + version: s.jay_compositor.1.min(22), id: id.into(), }); self.jay_compositor.set(Some(id)); diff --git a/wire/jay_input.txt b/wire/jay_input.txt index f94b95ce..2f81bb75 100644 --- a/wire/jay_input.txt +++ b/wire/jay_input.txt @@ -134,6 +134,15 @@ request set_middle_button_emulation (since = 19) { enabled: u32, } +request set_simple_im_enabled (since = 22) { + seat: str, + enabled: u32, +} + +request reload_simple_im (since = 22) { + seat: str, +} + # events event seat { From 481e9b3854269d78e6fdf7c1984d1c31734aaf9f Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Thu, 16 Oct 2025 15:46:49 +0200 Subject: [PATCH 6/6] simple-im: add support for unicode input --- jay-config/src/_private/client.rs | 4 + jay-config/src/_private/ipc.rs | 3 + jay-config/src/input.rs | 7 ++ src/config/handler.rs | 9 ++ src/ifs/wl_seat/text_input.rs | 7 ++ src/ifs/wl_seat/text_input/simple_im.rs | 105 ++++++++++++++++++ .../wl_seat/text_input/zwp_input_method_v2.rs | 4 + toml-config/src/config.rs | 1 + toml-config/src/config/parsers/action.rs | 1 + toml-config/src/lib.rs | 4 + toml-spec/spec/spec.generated.json | 3 +- toml-spec/spec/spec.generated.md | 6 + toml-spec/spec/spec.yaml | 5 + 13 files changed, 158 insertions(+), 1 deletion(-) diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 898ddd2e..fc1bfcec 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1040,6 +1040,10 @@ impl ConfigClient { self.send(&ClientMessage::SeatReloadSimpleIm { seat }); } + pub fn seat_enable_unicode_input(&self, seat: Seat) { + self.send(&ClientMessage::SeatEnableUnicodeInput { seat }); + } + pub fn set_show_float_pin_icon(&self, show: bool) { self.send(&ClientMessage::SetShowFloatPinIcon { show }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index b1e91ec4..746e91c0 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -798,6 +798,9 @@ pub enum ClientMessage<'a> { SeatReloadSimpleIm { seat: Seat, }, + SeatEnableUnicodeInput { + seat: Seat, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 1956791b..4672663c 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -631,6 +631,13 @@ impl Seat { pub fn reload_simple_im(self) { get!().seat_reload_simple_im(self); } + + /// Enables Unicode input in the simple, XCompose based input method. + /// + /// This has no effect if the simple IM is not currently active. + pub fn enable_unicode_input(self) { + get!().seat_enable_unicode_input(self); + } } /// A focus-follows-mouse mode. diff --git a/src/config/handler.rs b/src/config/handler.rs index c1cd7759..db46821a 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -2301,6 +2301,12 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_seat_enable_unicode_input(&self, seat: Seat) -> Result<(), CphError> { + let seat = self.get_seat(seat)?; + seat.enable_unicode_input(); + Ok(()) + } + fn spaces_change(&self) { struct V; impl NodeVisitorBase for V { @@ -3245,6 +3251,9 @@ impl ConfigProxyHandler { ClientMessage::SeatReloadSimpleIm { seat } => self .handle_seat_reload_simple_im(seat) .wrn("seat_reload_simple_im")?, + ClientMessage::SeatEnableUnicodeInput { seat } => self + .handle_seat_enable_unicode_input(seat) + .wrn("seat_enable_unicode_input")?, } Ok(()) } diff --git a/src/ifs/wl_seat/text_input.rs b/src/ifs/wl_seat/text_input.rs index 2cdf08bf..51cea1d3 100644 --- a/src/ifs/wl_seat/text_input.rs +++ b/src/ifs/wl_seat/text_input.rs @@ -42,6 +42,7 @@ pub trait InputMethod { fn done(self: Rc, seat: &WlSeatGlobal); fn is_simple(&self) -> bool; fn cancel_simple(&self, seat: &WlSeatGlobal); + fn enable_unicode_input(&self); } pub trait InputMethodKeyboardGrab { @@ -64,6 +65,12 @@ pub enum TextDisconnectReason { } impl WlSeatGlobal { + pub fn enable_unicode_input(&self) { + if let Some(im) = self.input_method.get() { + im.enable_unicode_input(); + } + } + pub fn set_simple_im_enabled(self: &Rc, enabled: bool) { if self.simple_im_enabled.replace(enabled) == enabled { return; diff --git a/src/ifs/wl_seat/text_input/simple_im.rs b/src/ifs/wl_seat/text_input/simple_im.rs index 589d2c11..e7f03188 100644 --- a/src/ifs/wl_seat/text_input/simple_im.rs +++ b/src/ifs/wl_seat/text_input/simple_im.rs @@ -24,6 +24,7 @@ use { }, std::{ cell::{Cell, RefCell}, + fmt::Write, rc::Rc, }, }; @@ -36,6 +37,8 @@ pub struct SimpleIm { table: compose::ComposeTable, initial_state: compose::State, states: RefCell>, + unicode_input: RefCell, + unicode_input_enabled: Cell, } struct State { @@ -43,6 +46,14 @@ struct State { char: char, } +#[derive(Default)] +struct UnicodeInput { + text: String, + cp: u32, + cursor: i32, + chars: usize, +} + impl SimpleIm { pub fn new(ctx: &xkb::Context) -> Option> { let table = ctx.compose_table_builder().build(WriteToLog)?; @@ -54,6 +65,8 @@ impl SimpleIm { states: Default::default(), initial_state: table.create_state(), table, + unicode_input: Default::default(), + unicode_input_enabled: Default::default(), })) } } @@ -94,6 +107,7 @@ impl InputMethod for SimpleIm { self.active.set(active); if active { self.states.borrow_mut().clear(); + self.unicode_input_enabled.set(false); seat.input_method_grab.set(Some(self)); } else { seat.input_method_grab.take(); @@ -110,6 +124,46 @@ impl InputMethod for SimpleIm { con.disconnect(TextDisconnectReason::InputMethodDestroyed); } } + + fn enable_unicode_input(&self) { + if !self.active.get() { + return; + } + let Some(con) = self.con.get() else { + return; + }; + if self.unicode_input_enabled.replace(true) { + return; + } + self.states.borrow_mut().clear(); + let ui = &mut *self.unicode_input.borrow_mut(); + ui.cp = 0; + ui.chars = 0; + ui.flush_preedit(&con); + } +} + +impl UnicodeInput { + fn update_text(&mut self) { + self.text.clear(); + if self.chars == 0 { + let _ = write!(self.text, "U+"); + self.cursor = self.text.len() as _; + return; + } + let _ = write!(self.text, "U+{:x}", self.cp); + self.cursor = self.text.len() as _; + if let Some(char) = char::from_u32(self.cp) { + let _ = write!(self.text, " = {}", char); + } + } + + fn flush_preedit(&mut self, con: &TextInputConnection) { + self.update_text(); + con.text_input + .send_preedit_string(Some(&self.text), self.cursor, self.cursor); + con.text_input.send_done(); + } } impl InputMethodKeyboardGrab for SimpleIm { @@ -122,7 +176,11 @@ impl InputMethodKeyboardGrab for SimpleIm { }; let mut buf = [0; 4]; let mut forward_to_node = true; + if self.unicode_input_enabled.get() { + forward_to_node = false; + } let states = &mut *self.states.borrow_mut(); + let ui = &mut self.unicode_input.borrow_mut(); let lookup = kb_state.map.lookup_table.lookup( kb_state.mods.group, kb_state.mods.mods, @@ -132,6 +190,53 @@ impl InputMethodKeyboardGrab for SimpleIm { let is_control = mods.contains(ModifierMask::CONTROL); for sym in lookup { let sym = sym.keysym(); + if self.unicode_input_enabled.get() { + let is_terminator = matches!( + sym, + syms::Return | syms::KP_Enter | syms::space | syms::KP_Space + ); + if (is_terminator || (sym == syms::j && is_control)) + && ui.chars > 0 + && let Some(char) = char::from_u32(ui.cp) + { + self.unicode_input_enabled.set(false); + let s = char.encode_utf8(&mut buf); + con.text_input.send_preedit_string(None, 0, 0); + con.text_input.send_commit_string(Some(s)); + con.text_input.send_done(); + } else if sym == syms::Escape + || (sym == syms::c && is_control) + || (ui.chars == 0 && matches!(sym, syms::w | syms::d) && is_control) + { + self.unicode_input_enabled.set(false); + con.text_input.send_preedit_string(None, 0, 0); + con.text_input.send_done(); + } else if sym == syms::BackSpace && ui.chars > 0 { + ui.chars -= 1; + ui.cp >>= 4; + ui.flush_preedit(&con); + } else if sym == syms::w && is_control { + ui.chars = 0; + ui.cp = 0; + ui.flush_preedit(&con); + } else if let Some(c) = sym.char() + && ui.chars < 6 + && !is_control + { + let c = match c { + '0'..='9' => c as u32 - '0' as u32, + 'a'..='f' => c as u32 - 'a' as u32 + 10, + 'A'..='F' => c as u32 - 'A' as u32 + 10, + _ => continue, + }; + ui.cp = (ui.cp << 4) | c; + if ui.cp != 0 { + ui.chars += 1; + ui.flush_preedit(&con); + } + } + continue; + } let mut new_state = states .last() .map(|s| s.state.clone()) diff --git a/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs b/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs index fe82d42a..35c4467a 100644 --- a/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs +++ b/src/ifs/wl_seat/text_input/zwp_input_method_v2.rs @@ -144,6 +144,10 @@ impl InputMethod for ZwpInputMethodV2 { fn cancel_simple(&self, _seat: &WlSeatGlobal) { unreachable!(); } + + fn enable_unicode_input(&self) { + // nothing + } } impl ZwpInputMethodV2RequestHandler for ZwpInputMethodV2 { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 5c7785e8..d77948d4 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -88,6 +88,7 @@ pub enum SimpleCommand { EnableSimpleIm(bool), ToggleSimpleImEnabled, ReloadSimpleIm, + EnableUnicodeInput, } #[derive(Debug, Clone)] diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 1f1bd327..9063fc89 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -159,6 +159,7 @@ impl ActionParser<'_> { "disable-simple-im" => EnableSimpleIm(false), "toggle-simple-im-enabled" => ToggleSimpleImEnabled, "reload-simple-im" => ReloadSimpleIm, + "enable-unicode-input" => EnableUnicodeInput, _ => { return Err( ActionParserError::UnknownSimpleAction(string.to_string()).spanned(span) diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 7ba79aeb..b4d99fdb 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -236,6 +236,10 @@ impl Action { let persistent = state.persistent.clone(); b.new(move || persistent.seat.reload_simple_im()) } + SimpleCommand::EnableUnicodeInput => { + let persistent = state.persistent.clone(); + b.new(move || persistent.seat.enable_unicode_input()) + } }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index a2fa94e9..6f5df501 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -1843,7 +1843,8 @@ "enable-simple-im", "disable-simple-im", "toggle-simple-im-enabled", - "reload-simple-im" + "reload-simple-im", + "enable-unicode-input" ] }, "SimpleIm": { diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index e4666c24..43a32c02 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -4216,6 +4216,12 @@ The string should have one of the following values: This is useful if you change the XCompose files after starting the compositor. +- `enable-unicode-input`: + + Enables Unicode input in the simple, XCompose based input method. + + This has no effect if the simple IM is not currently active. + diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 4262deff..32d122bc 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -1050,6 +1050,11 @@ SimpleActionName: Reloads the simple, XCompose based input method. This is useful if you change the XCompose files after starting the compositor. + - value: enable-unicode-input + description: | + Enables Unicode input in the simple, XCompose based input method. + + This has no effect if the simple IM is not currently active. Color: