use crate::{PhysicalRect, ShellError, ShellEvent, TextPurpose, WindowId};
use super::ffi::{WlArgument, WlProxy};
use super::protocol::text_input::{zwp_text_input_manager_v3, zwp_text_input_v3};
use super::{ObjectKind, WaylandShell};
pub(super) const TEXT_INPUT_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Field {
field: Option<TextPurpose>,
area: Option<PhysicalRect>,
}
#[derive(Debug)]
pub(super) struct TextInput {
proxy: *mut WlProxy,
focus: Option<WindowId>,
enabled: bool,
pending: Pending,
shown: String,
}
#[derive(Debug, Default)]
struct Pending {
preedit: Option<(String, i32)>,
commit: Option<String>,
}
#[derive(Clone, Debug)]
pub(super) enum Event {
Enter { surface: usize },
Leave,
Preedit { text: String, cursor_begin: i32 },
Commit { text: String },
Done,
}
pub(super) unsafe fn decode(opcode: u32, args: &WlArgument) -> Option<Event> {
Some(
match unsafe { zwp_text_input_v3::decode_event(opcode, args) }? {
zwp_text_input_v3::Event::Enter { surface } => Event::Enter {
surface: surface as usize,
},
zwp_text_input_v3::Event::Leave { .. } => Event::Leave,
zwp_text_input_v3::Event::PreeditString {
text, cursor_begin, ..
} => Event::Preedit {
text: text
.map(|text| text.to_string_lossy().into_owned())
.unwrap_or_default(),
cursor_begin,
},
zwp_text_input_v3::Event::CommitString { text } => Event::Commit {
text: text
.map(|text| text.to_string_lossy().into_owned())
.unwrap_or_default(),
},
zwp_text_input_v3::Event::Done { .. } => Event::Done,
_ => return None,
},
)
}
fn caret(text: &str, cursor_begin: i32) -> Option<usize> {
let offset = usize::try_from(cursor_begin).ok()?;
(!text.is_empty() && text.is_char_boundary(offset)).then_some(offset)
}
fn content_type(purpose: TextPurpose) -> (u32, u32) {
use zwp_text_input_v3::{content_hint, content_purpose};
let hint = if purpose.is_sensitive() {
content_hint::HIDDEN_TEXT | content_hint::SENSITIVE_DATA
} else {
content_hint::NONE
};
let purpose = match purpose {
TextPurpose::Normal => content_purpose::NORMAL,
TextPurpose::Password => content_purpose::PASSWORD,
TextPurpose::Pin => content_purpose::PIN,
};
(hint, purpose)
}
fn logical_rect(area: PhysicalRect, scale: f64) -> [i32; 4] {
let scale = scale.max(f64::MIN_POSITIVE);
let left = (f64::from(area.x) / scale).floor();
let top = (f64::from(area.y) / scale).floor();
let right = ((f64::from(area.x) + f64::from(area.width)) / scale).ceil();
let bottom = ((f64::from(area.y) + f64::from(area.height)) / scale).ceil();
#[expect(
clippy::cast_possible_truncation,
reason = "a window's own pixels over a positive scale, already whole"
)]
[
left as i32,
top as i32,
(right - left) as i32,
(bottom - top) as i32,
]
}
impl WaylandShell {
pub(super) fn ensure_text_inputs(&mut self) {
let manager = self.conn.text_input_manager;
if manager.is_null() {
return;
}
for index in 0..self.seats.len() {
if self.seats[index].text_input.is_some() {
continue;
}
let seat = self.seats[index].proxy;
let proxy = unsafe { zwp_text_input_manager_v3::get_text_input(manager, seat) };
if proxy.is_null() {
continue;
}
self.conn.watch(proxy, ObjectKind::TextInput);
self.seats[index].text_input = Some(TextInput {
proxy,
focus: None,
enabled: false,
pending: Pending::default(),
shown: String::new(),
});
}
self.conn.flush();
}
pub(super) fn remove_text_input(&mut self, index: usize) {
if let Some(text_input) = self.seats[index].text_input.take() {
unsafe {
self.conn
.release(text_input.proxy, zwp_text_input_v3::destroy);
}
}
}
pub(super) fn text_input_event(&mut self, proxy: usize, event: Event) {
let Some(index) = self.seats.iter().position(|seat| {
seat.text_input
.as_ref()
.is_some_and(|text_input| text_input.proxy as usize == proxy)
}) else {
return;
};
match event {
Event::Enter { surface } => {
let window = self.window_by_proxy(surface, |window| window.surface as usize);
if let Some(text_input) = self.seats[index].text_input.as_mut() {
text_input.focus = window;
text_input.enabled = false;
}
self.sync_text_input(index);
}
Event::Leave => {
let Some(text_input) = self.seats[index].text_input.as_mut() else {
return;
};
let window = text_input.focus.take();
text_input.enabled = false;
text_input.pending = Pending::default();
if let Some(window) = window
&& !core::mem::take(&mut text_input.shown).is_empty()
{
self.queue.push_back(ShellEvent::TextPreedit {
window,
time: self.time.event_time_now(),
text: String::new(),
cursor: None,
});
}
}
Event::Preedit { text, cursor_begin } => {
if let Some(text_input) = self.seats[index].text_input.as_mut() {
text_input.pending.preedit = Some((text, cursor_begin));
}
}
Event::Commit { text } => {
if let Some(text_input) = self.seats[index].text_input.as_mut() {
text_input.pending.commit = Some(text);
}
}
Event::Done => self.apply_text_input(index),
}
}
fn apply_text_input(&mut self, index: usize) {
let Some(text_input) = self.seats[index].text_input.as_mut() else {
return;
};
let pending = core::mem::take(&mut text_input.pending);
let Some(window) = text_input.focus else {
return;
};
let time = self.time.event_time_now();
if let Some(text) = pending.commit.filter(|text| !text.is_empty()) {
self.queue
.push_back(ShellEvent::TextCommit { window, time, text });
}
let (text, cursor_begin) = pending.preedit.unwrap_or_default();
if text != text_input.shown {
text_input.shown.clone_from(&text);
let cursor = caret(&text, cursor_begin);
self.queue.push_back(ShellEvent::TextPreedit {
window,
time,
text,
cursor,
});
}
}
fn sync_text_input(&mut self, index: usize) {
let Some(text_input) = self.seats[index].text_input.as_ref() else {
return;
};
let (proxy, focus, enabled) = (text_input.proxy, text_input.focus, text_input.enabled);
let wanted = focus.and_then(|window| {
let state = self.window(window).ok()?;
state
.text_input
.field
.map(|purpose| (purpose, state.text_input.area, state.scale_factor()))
});
unsafe {
match wanted {
Some((purpose, area, scale)) => {
let (hint, purpose) = content_type(purpose);
zwp_text_input_v3::enable(proxy);
zwp_text_input_v3::set_content_type(proxy, hint, purpose);
if let Some(area) = area {
let [x, y, width, height] = logical_rect(area, scale);
zwp_text_input_v3::set_cursor_rectangle(proxy, x, y, width, height);
}
zwp_text_input_v3::commit(proxy);
}
None if enabled => {
zwp_text_input_v3::disable(proxy);
zwp_text_input_v3::commit(proxy);
}
None => return,
}
}
if let Some(text_input) = self.seats[index].text_input.as_mut() {
text_input.enabled = wanted.is_some();
}
self.conn.flush();
}
pub(super) fn enable_text_input(
&mut self,
window: WindowId,
field: Option<TextPurpose>,
) -> Result<(), ShellError> {
let state = &mut self.window_mut(window)?.text_input;
if state.field == field {
return Ok(());
}
state.field = field;
self.sync_focused(window);
Ok(())
}
pub(super) fn place_text_input(
&mut self,
window: WindowId,
area: Option<PhysicalRect>,
) -> Result<(), ShellError> {
let state = self.window_mut(window)?;
if state.text_input.area == area {
return Ok(());
}
state.text_input.area = area;
let scale = state.scale_factor();
let Some(area) = area else {
self.sync_focused(window);
return Ok(());
};
let [x, y, width, height] = logical_rect(area, scale);
for seat in &self.seats {
if let Some(text_input) = &seat.text_input
&& text_input.enabled
&& text_input.focus == Some(window)
{
unsafe {
zwp_text_input_v3::set_cursor_rectangle(text_input.proxy, x, y, width, height);
zwp_text_input_v3::commit(text_input.proxy);
}
}
}
self.conn.flush();
Ok(())
}
fn sync_focused(&mut self, window: WindowId) {
for index in 0..self.seats.len() {
let focused = self.seats[index]
.text_input
.as_ref()
.is_some_and(|text_input| text_input.focus == Some(window));
if focused {
self.sync_text_input(index);
}
}
}
pub(super) fn forget_text_input_focus(&mut self, window: WindowId) {
for seat in &mut self.seats {
if let Some(text_input) = seat.text_input.as_mut()
&& text_input.focus == Some(window)
{
text_input.focus = None;
text_input.pending = Pending::default();
text_input.shown.clear();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sensitive_field_is_hidden_and_sensitive_with_its_purpose() {
assert_eq!(content_type(TextPurpose::Normal), (0, 0));
assert_eq!(content_type(TextPurpose::Password), (0x40 | 0x80, 8));
assert_eq!(content_type(TextPurpose::Pin), (0x40 | 0x80, 9));
}
#[test]
fn the_preedit_cursor_is_a_char_boundary_in_the_text_or_none() {
assert_eq!(caret("ka", 2), Some(2), "the end is a position");
assert_eq!(caret("ka", 0), Some(0));
assert_eq!(caret("ka", -1), None, "hidden");
assert_eq!(caret("ka", 3), None, "past the end");
assert_eq!(caret("か", 1), None, "inside a character");
assert_eq!(caret("か", 3), Some(3));
assert_eq!(caret("", 0), None, "no text, no cursor");
}
#[test]
fn the_caret_rectangle_is_logical_and_covers_the_caret() {
let area = PhysicalRect::new(30, 45, 3, 30);
assert_eq!(logical_rect(area, 1.0), [30, 45, 3, 30]);
assert_eq!(logical_rect(area, 2.0), [15, 22, 2, 16]);
assert_eq!(logical_rect(area, 1.5), [20, 30, 2, 20]);
}
}