diff --git a/.gitignore b/.gitignore index 47dd3c7..47bb962 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target/ +/.artifacts/ /tools/ *.log diff --git a/Cargo.lock b/Cargo.lock index d813f42..2511b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1953,6 +1953,26 @@ dependencies = [ "wayland-server", ] +[[package]] +name = "ferese-rime" +version = "0.1.0" +dependencies = [ + "ab_glyph", + "cc", + "ferese-ipc", + "fontdb", + "libc", + "pkg-config", + "serde", + "serde_json", + "tempfile", + "tiny-skia 0.11.4", + "wayland-client", + "wayland-protocols", + "wayland-protocols-misc", + "xkbcommon 0.9.0", +] + [[package]] name = "ferese-settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 90e6038..d290144 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/ferese-layout", "crates/ferese-panel", "crates/ferese-protocols", + "crates/ferese-rime", "shell/ferese-shell", "shell/ferese-settings", "shell/ferese-lock", diff --git a/README.md b/README.md index 1f7fa31..3e58a56 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Choose a layout per workspace and configure the desktop through Settings or a KD for what's included. Packages target Fedora 44 and Arch Linux on x86_64. This README and the wiki describe `main`. +This local `twilight/rime-service-poc` branch adds an independent native Wayland +Rime service and compositor popup fixes. See [Rime prototype validation](docs/development/rime-poc.md) +for its architecture, isolated preview, test commands and current limits. + ![Ferese desktop with the launcher, build progress, Appearance settings and terminal windows](docs/images/screenshots/ferese-desktop.png) [Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots) diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 6c98838..785bbb8 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -81,6 +81,7 @@ impl CompositorHandler for Ferese { layer_shell::handle_commit(self, surface); xdg_shell::handle_commit(self, surface); + self.position_committed_input_popup(surface); crate::backends::direct::render_surface(self, surface); } diff --git a/compositor/src/handlers/dispatch.rs b/compositor/src/handlers/dispatch.rs index d449d67..4be5b80 100644 --- a/compositor/src/handlers/dispatch.rs +++ b/compositor/src/handlers/dispatch.rs @@ -116,6 +116,8 @@ delegate_protocols!( misc::zwp_input_method_v2::server::zwp_input_method_manager_v2::ZwpInputMethodManagerV2, misc::zwp_input_method_v2::server::zwp_input_method_v2::ZwpInputMethodV2, misc::zwp_input_method_v2::server::zwp_input_popup_surface_v2::ZwpInputPopupSurfaceV2, + misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1, + misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1, wlr::data_control::v1::server::zwlr_data_control_device_v1::ZwlrDataControlDeviceV1, wlr::data_control::v1::server::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1, wlr::data_control::v1::server::zwlr_data_control_source_v1::ZwlrDataControlSourceV1, diff --git a/compositor/src/handlers/input_method.rs b/compositor/src/handlers/input_method.rs index 66eb86a..80994c8 100644 --- a/compositor/src/handlers/input_method.rs +++ b/compositor/src/handlers/input_method.rs @@ -1,12 +1,14 @@ use smithay::desktop::{PopupKind, WindowSurfaceType, layer_map_for_output}; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::{Logical, Rectangle}; +use smithay::utils::{Point, Size}; use smithay::wayland::input_method::{InputMethodHandler, PopupSurface}; use crate::Ferese; impl InputMethodHandler for Ferese { fn new_popup(&mut self, surface: PopupSurface) { + self.position_input_popup(&surface); if let Err(error) = self .popups .track_popup(PopupKind::InputMethod(surface.clone())) @@ -23,6 +25,7 @@ impl InputMethodHandler for Ferese { } fn popup_repositioned(&mut self, surface: PopupSurface) { + self.position_input_popup(&surface); crate::backends::direct::render_surface(self, surface.wl_surface()); } @@ -58,3 +61,95 @@ impl InputMethodHandler for Ferese { .unwrap_or_default() } } + +impl Ferese { + pub(crate) fn position_committed_input_popup(&mut self, surface: &WlSurface) { + if let Some(PopupKind::InputMethod(popup)) = self.popups.find_popup(surface) { + self.position_input_popup(&popup); + } + } + + fn position_input_popup(&self, popup: &PopupSurface) { + let Some(parent) = popup.get_parent() else { + return; + }; + let parent = self.parent_geometry(&parent.surface); + let cursor = popup.text_input_rectangle(); + let caret = parent.loc + cursor.loc; + let output = self + .space + .outputs() + .filter_map(|output| self.space.output_geometry(output)) + .find(|geometry| geometry.contains(caret)) + .or_else(|| { + self.space + .outputs() + .find_map(|output| self.space.output_geometry(output)) + }); + let Some(output) = output else { + return; + }; + let bounds = smithay::desktop::utils::bbox_from_surface_tree(popup.wl_surface(), (0, 0)); + popup.set_location(input_popup_location(parent, cursor, bounds.size, output)); + } +} + +fn input_popup_location( + parent: Rectangle, + cursor: Rectangle, + size: Size, + output: Rectangle, +) -> Point { + let caret = parent.loc + cursor.loc; + let mut y = caret.y + cursor.size.h; + if y + size.h > output.loc.y + output.size.h { + y = caret.y - size.h; + } + let x = caret.x.clamp( + output.loc.x, + (output.loc.x + output.size.w - size.w).max(output.loc.x), + ); + let y = y.clamp( + output.loc.y, + (output.loc.y + output.size.h - size.h).max(output.loc.y), + ); + Point::from((x, y)) - parent.loc +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn input_popup_uses_cursor_and_flips_at_output_edges() { + let output = Rectangle::new((0, 0).into(), (1280, 800).into()); + let parent = Rectangle::new((400, 36).into(), (880, 560).into()); + assert_eq!( + input_popup_location( + parent, + Rectangle::new((28, 164).into(), (1, 24).into()), + (420, 219).into(), + output + ), + Point::from((28, 188)) + ); + assert_eq!( + input_popup_location( + Rectangle::default(), + Rectangle::new((1230, 750).into(), (1, 20).into()), + (420, 220).into(), + output + ), + Point::from((860, 530)) + ); + let left = Rectangle::new((-1280, 0).into(), (1280, 800).into()); + assert_eq!( + input_popup_location( + Rectangle::default(), + Rectangle::new((-10, 780).into(), (1, 20).into()), + (420, 220).into(), + left + ), + Point::from((-420, 560)) + ); + } +} diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index f7ea73f..98536b7 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod screenshot_worker; pub(crate) mod security_context; #[cfg(test)] mod toplevel_icon_tests; +mod virtual_keyboard; pub(crate) mod window_capture; mod xdg_shell; diff --git a/compositor/src/handlers/virtual_keyboard.rs b/compositor/src/handlers/virtual_keyboard.rs new file mode 100644 index 0000000..a2e0ca2 --- /dev/null +++ b/compositor/src/handlers/virtual_keyboard.rs @@ -0,0 +1,85 @@ +use smithay::backend::input::{Event, InputEvent, KeyboardKeyEvent}; +use smithay::utils::SERIAL_COUNTER; +use smithay::wayland::input_method::InputMethodSeat; +use smithay::wayland::virtual_keyboard::{ + VirtualKeyboardBackend, VirtualKeyboardHandler, VirtualKeyboardSpecialEvent, +}; + +use crate::Ferese; + +impl VirtualKeyboardHandler for Ferese { + fn process_virtual_keyboard_event(&mut self, event: InputEvent) { + let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); + if self.session_lock.active() { + // Synthetic input never enters a lock surface. Still reconcile + // keys from a departing/previously active auxiliary device. + let device = match &event { + InputEvent::Keyboard { event } => Some(event.device()), + InputEvent::DeviceRemoved { device } => Some(device.clone()), + _ => None, + }; + if let Some(device) = device { + keyboard.release_source(self, device.source()); + } + return; + } + match event { + InputEvent::DeviceAdded { device } + | InputEvent::Special(VirtualKeyboardSpecialEvent::KeymapChanged { device }) => { + if let Some(keymap) = device.keymap() { + let current = keyboard.with_xkb_state(self, |context| { + context.xkb().lock().unwrap().keymap_as_string() + }); + if current != *keymap + && let Err(error) = + keyboard.set_keymap_from_string(self, keymap.to_string()) + { + tracing::warn!(%error, "invalid virtual keyboard keymap"); + } + } + } + InputEvent::Keyboard { event } => { + let device = event.device(); + let grab_client = self.seat.input_method().keyboard_grab_client(); + if grab_client.is_some() && grab_client == device.client() { + // An IME forwards keys it did not consume. The physical path + // already updated XKB and compositor shortcuts: forwarding + // through that path again would recurse into the IME grab. + keyboard.input_forward_bypassing_grab( + self, + event.key_code(), + event.state(), + SERIAL_COUNTER.next_serial(), + event.time(), + true, + ); + } else { + self.process_input_event_from_source::( + InputEvent::Keyboard { event }, + device.source(), + ); + } + } + InputEvent::Special(VirtualKeyboardSpecialEvent::Modifiers { + device, + mods_depressed, + mods_latched, + mods_locked, + group, + }) => { + let grab_client = self.seat.input_method().keyboard_grab_client(); + if grab_client.is_some() && grab_client == device.client() { + // The physical path already owns these modifiers. An + // asynchronous IME echo must not restore an old mask after + // the physical release has arrived. + return; + } + keyboard.with_xkb_state(self, |mut context| { + context.set_modifier_mask(mods_depressed, mods_latched, mods_locked, group) + }); + } + InputEvent::DeviceRemoved { device } => keyboard.release_source(self, device.source()), + _ => (), + } + } +} diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 244d15c..3a8058b 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -9,7 +9,7 @@ use smithay::backend::input::{ KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch, SwitchState, SwitchToggleEvent, TouchEvent, }; -use smithay::input::keyboard::{FilterResult, keysyms}; +use smithay::input::keyboard::{FilterResult, KeyboardSource, keysyms}; use smithay::input::pointer::{ AxisFrame, ButtonEvent, Focus, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent, @@ -133,11 +133,23 @@ impl Ferese { } pub fn process_input_event(&mut self, event: InputEvent) { - self.process_input_event_inner(event); + self.process_input_event_from_source(event, KeyboardSource::MAIN); + } + + pub(crate) fn process_input_event_from_source( + &mut self, + event: InputEvent, + source: KeyboardSource, + ) { + self.process_input_event_inner(event, source); self.poll_window_interaction(); } - fn process_input_event_inner(&mut self, event: InputEvent) { + fn process_input_event_inner( + &mut self, + event: InputEvent, + source: KeyboardSource, + ) { if self.input_capture.active() { self.set_binding_mode("default"); } @@ -329,7 +341,8 @@ impl Ferese { let client_grab = keyboard .with_grab(|_, grab| grab.is::>()) .unwrap_or(true); - let intercepted = keyboard.input::<(), _>( + let intercepted = keyboard.input_from_source::<(), _>( + source, self, keycode, state, diff --git a/compositor/src/input/binding_mode_tests.rs b/compositor/src/input/binding_mode_tests.rs index 6ea0bb5..9363ba8 100644 --- a/compositor/src/input/binding_mode_tests.rs +++ b/compositor/src/input/binding_mode_tests.rs @@ -78,6 +78,43 @@ binding "Super+r" binding-mode resize binding-mode resize { binding h resize left; binding l binding-mode launch; } binding-mode launch { binding h binding-mode default; } "#; + +#[test] +fn nested_focus_loss_releases_modifiers_and_preserves_auxiliary_keys() { + if !crate::startup_tests::private_runtime( + "input::binding_mode_tests::nested_focus_loss_releases_modifiers_and_preserves_auxiliary_keys", + ) { + return; + } + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + let keyboard = state.seat.get_keyboard().unwrap(); + key(&mut state, 64, true); // Alt + key(&mut state, 133, true); // Super + let auxiliary = KeyboardSource::new_auxiliary(); + keyboard.input_from_source::<(), _>( + auxiliary, + &mut state, + Keycode::new(38), + KeyState::Pressed, + SERIAL_COUNTER.next_serial(), + smithay::backend::input::InputTime::now(), + |_, _, _| FilterResult::Forward, + ); + assert!(keyboard.modifier_state().alt); + assert!(keyboard.modifier_state().logo); + state.release_nested_keyboard(); + assert!(!keyboard.modifier_state().alt); + assert!(!keyboard.modifier_state().logo); + assert_eq!( + keyboard.pressed_keys(), + [Keycode::new(38)].into_iter().collect() + ); + assert!(state.binding_mode_pressed.is_empty()); + assert!(state.binding_mode_repeats.is_empty()); + keyboard.release_source(&mut state, auxiliary); + assert!(keyboard.pressed_keys().is_empty()); +} fn state(events: &mut EventLoop<'static, Ferese>) -> Ferese { let (_, mut config, _) = crate::config::prepare(SOURCE, std::path::Path::new(".")).unwrap(); config.wallpaper.path = None; diff --git a/compositor/src/render/decorations.rs b/compositor/src/render/decorations.rs index ac2bfcd..c94c5f5 100644 --- a/compositor/src/render/decorations.rs +++ b/compositor/src/render/decorations.rs @@ -469,8 +469,31 @@ pub(super) fn rounded_window_elements( let radius = corners.radius; let surface = toplevel.wl_surface(); + let mut input_popups = Vec::new(); let mut content = PopupManager::popups_for_surface(surface) .flat_map(|(popup, popup_offset)| { + if matches!(popup, smithay::desktop::PopupKind::InputMethod(_)) { + // InputMethod geometry is the parent's global rectangle, not + // an xdg window-geometry origin. IME popups also extend beyond + // the parent and must not enter its rounded-window crop. + let offset = (geometry.loc + popup_offset).to_physical_precise_round(scale); + input_popups.extend( + render_elements_from_surface_tree::< + GlesRenderer, + WaylandSurfaceRenderElement, + >( + renderer, + popup.wl_surface(), + location + offset, + scale, + alpha, + RenderElementKind::Unspecified, + ) + .into_iter() + .map(AnimatedWindowRenderElement::RawSurface), + ); + return Vec::new(); + } let offset = (geometry.loc + popup_offset - popup.geometry().loc) .to_physical_precise_round(scale); @@ -487,6 +510,7 @@ pub(super) fn rounded_window_elements( ) .into_iter() .map(WindowContentRenderElement::from) + .collect::>() }) .collect::>(); content.extend( @@ -518,17 +542,19 @@ pub(super) fn rounded_window_elements( }), ); - constrain_render_elements( - content, - location, - physical_constrain, - reference, - behavior, - ConstrainAlign::TOP | ConstrainAlign::LEFT, - scale, - ) - .map(Into::into) - .collect() + input_popups.extend( + constrain_render_elements( + content, + location, + physical_constrain, + reference, + behavior, + ConstrainAlign::TOP | ConstrainAlign::LEFT, + scale, + ) + .map(Into::into), + ); + input_popups } pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior { diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index 1616abf..9d3de05 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -100,7 +100,7 @@ render_elements! { Surface=SurfaceRenderElement, Memory=MemoryRenderElement, Solid=SolidColorRenderElement, - LockSurface=WaylandSurfaceRenderElement, + RawSurface=WaylandSurfaceRenderElement, Border=SharedPixelShaderElement, Blur=BlurRenderElement, Effect=PhysicalShaderElement, diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 4a10db2..c3252d2 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -94,6 +94,7 @@ use smithay::wayland::single_pixel_buffer::SinglePixelBufferState; use smithay::wayland::socket::ListeningSocketSource; use smithay::wayland::text_input::TextInputManagerState; use smithay::wayland::viewporter::ViewporterState; +use smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState; use smithay::wayland::xdg_activation::XdgActivationState; use smithay::wayland::xdg_foreign::XdgForeignState; use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconManager; @@ -571,6 +572,7 @@ pub struct Ferese { pub idle_inhibit_state: IdleInhibitManagerState, pub idle_notifier_state: IdleNotifierState, pub input_method_manager_state: InputMethodManagerState, + pub virtual_keyboard_manager_state: VirtualKeyboardManagerState, pub keyboard_shortcuts_inhibit_state: KeyboardShortcutsInhibitState, pub output_manager_state: OutputManagerState, pub pointer_constraints_state: PointerConstraintsState, @@ -674,6 +676,10 @@ impl Ferese { std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1"); let input_method_manager_state = InputMethodManagerState::new::(&display_handle, move |_| input_method_enabled); + let virtual_keyboard_manager_state = + VirtualKeyboardManagerState::new::(&display_handle, move |_| { + input_method_enabled + }); let keyboard_shortcuts_inhibit_state = KeyboardShortcutsInhibitState::new::(&display_handle); let shortcut_inhibit_enabled = @@ -893,6 +899,7 @@ impl Ferese { idle_inhibit_state, idle_notifier_state, input_method_manager_state, + virtual_keyboard_manager_state, keyboard_shortcuts_inhibit_state, output_manager_state, pointer_constraints_state, diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 7985c50..48a63b1 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -132,6 +132,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() pointer = ?state.seat.get_pointer().map(|pointer| pointer.current_location()), "host input in nested output"); } + WinitEvent::Focus(false) => state.release_nested_keyboard(), WinitEvent::Redraw => { if !drawable.get() || !usable_size(backend.window_size()) { drawable.set(false); @@ -251,6 +252,21 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() Ok(()) } +impl Ferese { + pub(crate) fn release_nested_keyboard(&mut self) { + // Host shortcuts can consume releases while this window is unfocused. + // Release only the physical/nested source, including modifiers, without + // executing compositor shortcuts. Auxiliary keyboards retain ownership. + if let Some(keyboard) = self.seat.get_keyboard() { + keyboard.release_source(self, smithay::input::keyboard::KeyboardSource::MAIN); + } + self.binding_mode_pressed.clear(); + self.binding_mode_repeats.clear(); + self.intercepted_keys.clear(); + self.cancel_focus_cycle(); + } +} + fn configure_nested_protocols(state: &mut Ferese) { // Winit's swap has no host presentation timestamp or retrace counter. // Do not advertise timing we cannot report accurately. diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index 21f820e..a078291 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -216,7 +216,7 @@ pub fn default_tokens() -> Tokens { "material":{"style":"solid","opacity":0.78,"blur_radius":12.0,"tint_strength":0.5}, "geometry":{"border_width":1.0,"focus_ring_width":2.0,"window_radius":14.0,"shell_radius":14.0, "control_gap":12.0}, - "typography":{"font_family":"Inter"}, + "typography":{"font_family":"MiSans"}, "background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"}, "surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}}, "shadow":{"soft":{"offset_y":4.0,"blur":18.0,"opacity":0.2}}, diff --git a/crates/ferese-rime/Cargo.toml b/crates/ferese-rime/Cargo.toml new file mode 100644 index 0000000..9f7cccc --- /dev/null +++ b/crates/ferese-rime/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "ferese-rime" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +ab_glyph = "0.2.32" +fontdb = "0.23.0" +ferese-ipc = { path = "../ferese-ipc" } +libc.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +tiny-skia = "0.11.4" +wayland-client.workspace = true +wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } +wayland-protocols-misc = { version = "0.3.12", features = ["client"] } +xkbcommon = "0.9.0" + +[build-dependencies] +cc = "1" +pkg-config = "0.3" + +[[bin]] +name = "ferese-rime" +path = "src/main.rs" + +[[bin]] +name = "ferese-rime-probe" +path = "src/bin/probe.rs" diff --git a/crates/ferese-rime/build.rs b/crates/ferese-rime/build.rs new file mode 100644 index 0000000..4a7487b --- /dev/null +++ b/crates/ferese-rime/build.rs @@ -0,0 +1,13 @@ +fn main() { + println!("cargo:rerun-if-changed=src/rime_shim.c"); + let library = pkg_config::Config::new() + .atleast_version("1.8") + .probe("rime") + .expect("librime development headers are required"); + let mut build = cc::Build::new(); + build.file("src/rime_shim.c"); + for path in library.include_paths { + build.include(path); + } + build.compile("ferese_rime_shim"); +} diff --git a/crates/ferese-rime/src/bin/probe.rs b/crates/ferese-rime/src/bin/probe.rs new file mode 100644 index 0000000..b6f4f73 --- /dev/null +++ b/crates/ferese-rime/src/bin/probe.rs @@ -0,0 +1,159 @@ +//! Integration-test input source. Refuses physical sessions and non-Ferese +//! servers before creating a keyboard; never injects into the host desktop. +use std::error::Error; +use std::io::{Seek, Write}; +use std::os::fd::AsFd; +use std::time::{Duration, Instant}; +use wayland_client::protocol::{wl_output, wl_registry, wl_seat}; +use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop}; +use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{ + zwp_virtual_keyboard_manager_v1 as manager, zwp_virtual_keyboard_v1 as keyboard, +}; +use xkbcommon::xkb; + +#[derive(Default)] +struct State { + manager: Option, + seat: Option, + ferese: bool, + outputs: Vec, +} +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + match interface.as_str() { + "ferese_material_manager_v1" => state.ferese = true, + "zwp_virtual_keyboard_manager_v1" => { + state.manager = Some(registry.bind(name, 1, qh, ())) + } + "wl_seat" => state.seat = Some(registry.bind(name, version.min(9), qh, ())), + "wl_output" if version >= 4 => { + let _: wl_output::WlOutput = registry.bind(name, 4, qh, ()); + } + _ => (), + } + } + } +} +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_output::WlOutput, + event: wl_output::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event { + state.outputs.push(name); + } + } +} +delegate_noop!(State: ignore wl_seat::WlSeat); +delegate_noop!(State: ignore manager::ZwpVirtualKeyboardManagerV1); +delegate_noop!(State: ignore keyboard::ZwpVirtualKeyboardV1); + +fn main() -> Result<(), Box> { + if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") { + return Err( + "test input requires FERESE_TEST_INPUT=1 and an isolated Ferese nested display".into(), + ); + } + let mut sequences: Vec> = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--keys" => { + for character in args.next().ok_or("missing text")?.chars() { + sequences.push(vec![character as u32]); + } + } + "--key" => sequences.push(vec![ + xkb::keysym_from_name(&args.next().ok_or("missing key")?, xkb::KEYSYM_NO_FLAGS) + .raw(), + ]), + "--chord" => sequences.push( + args.next() + .ok_or("missing chord")? + .split('+') + .map(|name| xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw()) + .collect(), + ), + _ => return Err(format!("unknown test input argument {arg}").into()), + } + } + let connection = Connection::connect_to_env()?; + let mut queue = connection.new_event_queue(); + let qh = queue.handle(); + let _registry = connection.display().get_registry(&qh, ()); + let mut state = State::default(); + queue.roundtrip(&mut state)?; + queue.roundtrip(&mut state)?; + if !state.ferese + || state.outputs.is_empty() + || state.outputs.iter().any(|name| name != "ferese-winit") + { + return Err("refusing input: target is not an isolated Ferese nested output".into()); + } + let keyboard = state + .manager + .as_ref() + .ok_or("virtual keyboard protocol missing")? + .create_virtual_keyboard(state.seat.as_ref().ok_or("seat missing")?, &qh, ()); + let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); + let keymap = xkb::Keymap::new_from_names( + &context, + "", + "", + "us", + "", + None, + xkb::KEYMAP_COMPILE_NO_FLAGS, + ) + .ok_or("US test keymap missing")?; + let xkb = xkb::State::new(&keymap); + let text = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1) + "\0"; + let mut file = tempfile::tempfile()?; + file.write_all(text.as_bytes())?; + file.rewind()?; + keyboard.keymap(1, file.as_fd(), text.len() as u32); + queue.roundtrip(&mut state)?; + let started = Instant::now(); + for sequence in sequences { + let codes = sequence + .iter() + .map(|symbol| { + (keymap.min_keycode().raw()..=keymap.max_keycode().raw()) + .find(|code| xkb.key_get_one_sym(xkb::Keycode::new(*code)).raw() == *symbol) + .map(|code| code - 8) + .ok_or_else(|| format!("test keysym {symbol:#x} has no US keycode")) + }) + .collect::, _>>()?; + for key in &codes { + keyboard.key(started.elapsed().as_millis() as u32, *key, 1); + queue.roundtrip(&mut state)?; + std::thread::sleep(Duration::from_millis(35)); + } + for key in codes.iter().rev() { + keyboard.key(started.elapsed().as_millis() as u32, *key, 0); + queue.roundtrip(&mut state)?; + std::thread::sleep(Duration::from_millis(35)); + } + } + keyboard.destroy(); + queue.roundtrip(&mut state)?; + println!("nested test input complete"); + Ok(()) +} diff --git a/crates/ferese-rime/src/lib.rs b/crates/ferese-rime/src/lib.rs new file mode 100644 index 0000000..1b4b499 --- /dev/null +++ b/crates/ferese-rime/src/lib.rs @@ -0,0 +1,179 @@ +pub mod panel; + +/// The number-row paging keys are local to a composition with candidates. +/// Control/Alt/Super shortcuts and literal ASCII typing retain their symbols. +#[must_use] +pub fn paging_key(key: u32, mask: u32, has_menu: bool) -> (u32, u32, bool) { + if has_menu && mask & ((1 << 2) | (1 << 3) | (1 << 26)) == 0 { + let page = match key { + 0x2d => Some(0xff55), + 0x3d | 0x2b => Some(0xff56), + _ => None, + }; + if let Some(page) = page { + return (page, mask & !1, true); + } + } + (key, mask, false) +} + +use std::ffi::{CStr, CString, c_char, c_int}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Clone, Debug, Default)] +pub struct Candidate { + pub text: String, + pub comment: String, +} + +#[derive(Clone, Debug, Default)] +pub struct Snapshot { + pub accepted: bool, + pub ascii: bool, + pub commit: String, + pub preedit: String, + /// Byte offset, matching text-input-v3 (not a character count). + pub cursor: i32, + pub highlighted: usize, + pub page: usize, + pub last_page: bool, + pub candidates: Vec, +} + +#[repr(C)] +struct RawResult { + accepted: c_int, + ascii_mode: c_int, + cursor: c_int, + highlighted: c_int, + page_no: c_int, + last_page: c_int, + count: c_int, + commit: *mut c_char, + preedit: *mut c_char, + texts: [*mut c_char; 9], + comments: [*mut c_char; 9], +} + +unsafe extern "C" { + fn ferese_rime_open(user: *const c_char, shared: *const c_char, schema: *const c_char) + -> c_int; + fn ferese_rime_snapshot(key: c_int, mask: c_int, out: *mut RawResult); + fn ferese_rime_result_free(out: *mut RawResult); + fn ferese_rime_clear(); + fn ferese_rime_close(); +} + +static OPEN: AtomicBool = AtomicBool::new(false); + +/// One librime owner, confined to its service thread/process. +pub struct Engine { + _paths: [CString; 3], + _thread_bound: std::marker::PhantomData>, +} + +impl Engine { + pub fn open(user: &Path, shared: &Path, schema: &str) -> Result { + if OPEN.swap(true, Ordering::SeqCst) { + return Err("librime already initialized in this process".into()); + } + let values = [ + user.to_string_lossy().into_owned(), + shared.to_string_lossy().into_owned(), + schema.to_owned(), + ]; + let paths = values.map(|s| CString::new(s).expect("paths cannot contain NUL")); + // SAFETY: strings remain alive for this owner's entire lifetime. The C + // shim uses the installed, versioned librime header and owns all handles. + let ok = + unsafe { ferese_rime_open(paths[0].as_ptr(), paths[1].as_ptr(), paths[2].as_ptr()) }; + if ok == 0 { + // SAFETY: balances the partial initialization above. + unsafe { ferese_rime_close() }; + OPEN.store(false, Ordering::SeqCst); + return Err(format!("could not initialize Rime schema {schema}")); + } + Ok(Self { + _paths: paths, + _thread_bound: std::marker::PhantomData, + }) + } + + pub fn process(&mut self, key: u32, mask: u32) -> Snapshot { + self.snapshot(key as i32, mask as i32) + } + + pub fn current(&mut self) -> Snapshot { + self.snapshot(-1, 0) + } + + pub fn clear(&mut self) { + // SAFETY: this thread-bound owner has the only initialized session. + unsafe { ferese_rime_clear() }; + } + + fn snapshot(&mut self, key: i32, mask: i32) -> Snapshot { + // SAFETY: all-zero is valid for this C struct's integers/null pointers; + // the shim initializes and returns owned, NUL-terminated UTF-8 strings. + let mut raw: RawResult = unsafe { std::mem::zeroed() }; + // SAFETY: the shim writes the matching repr(C) structure synchronously. + unsafe { ferese_rime_snapshot(key, mask, &mut raw) }; + fn text(pointer: *const c_char) -> String { + if pointer.is_null() { + return String::new(); + } + // SAFETY: snapshot returns live NUL-terminated strings until free. + unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned() + } + let preedit = text(raw.preedit); + let mut cursor = (raw.cursor.max(0) as usize).min(preedit.len()); + while !preedit.is_char_boundary(cursor) { + cursor -= 1; + } + let result = Snapshot { + accepted: raw.accepted != 0, + ascii: raw.ascii_mode != 0, + commit: text(raw.commit), + preedit, + cursor: cursor as i32, + highlighted: raw.highlighted.max(0) as usize, + page: raw.page_no.max(0) as usize, + last_page: raw.last_page != 0, + candidates: (0..raw.count.clamp(0, 9) as usize) + .map(|n| Candidate { + text: text(raw.texts[n]), + comment: text(raw.comments[n]), + }) + .collect(), + }; + // SAFETY: every returned allocation is copied above and freed once here. + unsafe { ferese_rime_result_free(&mut raw) }; + result + } +} + +impl Drop for Engine { + fn drop(&mut self) { + // SAFETY: unique thread-bound owner; balances successful initialization. + unsafe { ferese_rime_close() }; + OPEN.store(false, Ordering::SeqCst); + } +} + +#[cfg(test)] +mod tests { + use super::paging_key; + #[test] + fn number_row_paging_preserves_ascii_and_modified_shortcuts() { + assert_eq!(paging_key(0x2d, 0, true), (0xff55, 0, true)); + assert_eq!(paging_key(0x3d, 0, true), (0xff56, 0, true)); + assert_eq!(paging_key(0x2b, 1, true), (0xff56, 0, true)); + assert_eq!(paging_key(0x3d, 1 << 30, true), (0xff56, 1 << 30, true)); + assert_eq!(paging_key(0x2d, 0, false), (0x2d, 0, false)); + assert_eq!(paging_key(0x3d, 4, true), (0x3d, 4, false)); + assert_eq!(paging_key(0x2b, 1, false), (0x2b, 1, false)); + } +} diff --git a/crates/ferese-rime/src/main.rs b/crates/ferese-rime/src/main.rs new file mode 100644 index 0000000..dff66a5 --- /dev/null +++ b/crates/ferese-rime/src/main.rs @@ -0,0 +1,708 @@ +//! Independent native Wayland IME. librime and candidate rasterization never +//! execute on Ferese's compositor thread. +use std::collections::HashMap; +use std::error::Error; +use std::fs::File; +use std::io::{Read, Seek, Write}; +use std::os::fd::{AsFd, AsRawFd}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use ferese_rime::{ + Engine, Snapshot, + panel::{Colors, Panel}, +}; +use wayland_client::protocol::{ + wl_buffer, wl_compositor, wl_keyboard, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface, +}; +use wayland_client::{Connection, Dispatch, QueueHandle, WEnum, delegate_noop}; +use wayland_protocols::wp::{ + fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1}, + viewporter::client::{wp_viewport, wp_viewporter}, +}; +use wayland_protocols_misc::zwp_input_method_v2::client::{ + zwp_input_method_keyboard_grab_v2 as grab, zwp_input_method_manager_v2 as manager, + zwp_input_method_v2 as method, zwp_input_popup_surface_v2 as popup, +}; +use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{ + zwp_virtual_keyboard_manager_v1 as vk_manager, zwp_virtual_keyboard_v1 as vk, +}; +use xkbcommon::xkb; + +struct Popup { + surface: wl_surface::WlSurface, + role: popup::ZwpInputPopupSurfaceV2, + viewport: Option, + fractional: Option, + scale: f64, +} +impl Drop for Popup { + fn drop(&mut self) { + self.role.destroy(); + if let Some(value) = &self.fractional { + value.destroy(); + } + if let Some(value) = &self.viewport { + value.destroy(); + } + self.surface.destroy(); + } +} + +struct Repeat { + key: u32, + next: Instant, +} + +struct State { + engine: Engine, + panel: Panel, + theme: Receiver, + compositor: Option, + shm: Option, + seat: Option, + manager: Option, + vk_manager: Option, + viewporter: Option, + fractional: Option, + method: Option, + virtual_keyboard: Option, + grab: Option, + xkb: Option, + popup: Option, + buffers: Vec, + routes: HashMap, + serial: u32, + active: bool, + pending_active: bool, + sensitive: bool, + pending_sensitive: bool, + last: Snapshot, + error: Option, + repeat: Option, + repeat_rate: i32, + repeat_delay: i32, +} + +impl State { + fn initialize(&mut self, qh: &QueueHandle) -> Result<(), Box> { + let manager = self + .manager + .as_ref() + .ok_or("input-method-v2 is not available; enable FERESE_ENABLE_INPUT_METHOD=1")?; + let keyboard = self + .vk_manager + .as_ref() + .ok_or("virtual-keyboard-v1 is not available")?; + let seat = self.seat.as_ref().ok_or("Wayland seat is not available")?; + if self.compositor.is_none() || self.shm.is_none() { + return Err("Wayland compositor/SHM is missing".into()); + } + self.method = Some(manager.get_input_method(seat, qh, ())); + self.virtual_keyboard = Some(keyboard.create_virtual_keyboard(seat, qh, ())); + eprintln!( + "{}", + serde_json::json!({"event":"ready", "pid":std::process::id(), "framework":"native-wayland+librime"}) + ); + Ok(()) + } + + fn activate(&mut self, qh: &QueueHandle) { + self.active = self.pending_active; + self.sensitive = self.pending_sensitive; + if !self.active || self.sensitive { + self.engine.clear(); + self.last = Snapshot::default(); + self.repeat = None; + self.popup = None; + if let Some(grab) = self.grab.take() { + grab.release(); + } + self.routes.clear(); + return; + } + if self.grab.is_some() { + return; + } + self.engine.clear(); + self.last = self.engine.current(); + self.grab = Some(self.method.as_ref().unwrap().grab_keyboard(qh, ())); + let surface = self.compositor.as_ref().unwrap().create_surface(qh, ()); + let viewport = self + .viewporter + .as_ref() + .map(|manager| manager.get_viewport(&surface, qh, ())); + let fractional = if viewport.is_some() { + self.fractional + .as_ref() + .map(|manager| manager.get_fractional_scale(&surface, qh, ())) + } else { + None + }; + let role = self + .method + .as_ref() + .unwrap() + .get_input_popup_surface(&surface, qh, ()); + self.popup = Some(Popup { + surface, + role, + viewport, + fractional, + scale: 1.0, + }); + } + + fn mask(&self) -> u32 { + let Some(state) = &self.xkb else { + return 0; + }; + let mut result = 0; + for (name, bit) in [ + ("Shift", 0), + ("Lock", 1), + ("Control", 2), + ("Mod1", 3), + ("Mod4", 26), + ] { + if state.mod_name_is_active(name, xkb::STATE_MODS_EFFECTIVE) { + result |= 1 << bit; + } + } + result + } + + fn key(&mut self, time: u32, key: u32, pressed: bool, qh: &QueueHandle) { + if self.sensitive || !self.active { + return; + } + let Some(xkb) = &self.xkb else { + return; + }; + let keysym = xkb.key_get_one_sym(xkb::Keycode::new(key + 8)).raw(); + let repeatable = xkb.get_keymap().key_repeats(xkb::Keycode::new(key + 8)); + let mask = self.mask() | if pressed { 0 } else { 1 << 30 }; + let (keysym, mask, paging) = + ferese_rime::paging_key(keysym, mask, !self.last.candidates.is_empty()); + let mut snapshot = self.engine.process(keysym, mask); + if paging { + snapshot.accepted = true; + } + let forward = if pressed { + let forward = !snapshot.accepted; + self.routes.insert(key, forward); + if !forward && repeatable && self.repeat_rate > 0 { + self.repeat = Some(Repeat { + key, + next: Instant::now() + Duration::from_millis(self.repeat_delay.max(1) as u64), + }); + } + forward + } else { + if self.repeat.as_ref().is_some_and(|repeat| repeat.key == key) { + self.repeat = None; + } + self.routes.remove(&key).unwrap_or(false) + }; + self.publish(snapshot, qh); + if forward { + self.virtual_keyboard + .as_ref() + .unwrap() + .key(time, key, u32::from(pressed)); + } + } + + fn publish(&mut self, snapshot: Snapshot, qh: &QueueHandle) { + let method = self.method.as_ref().unwrap(); + if !snapshot.commit.is_empty() { + method.commit_string(snapshot.commit.clone()); + } + method.set_preedit_string(snapshot.preedit.clone(), snapshot.cursor, snapshot.cursor); + method.commit(self.serial); + let visible = !snapshot.preedit.is_empty(); + eprintln!( + "{}", + serde_json::json!({"event":"composition", "preedit_chars":snapshot.preedit.chars().count(), + "candidates":snapshot.candidates.len(), "committed_chars":snapshot.commit.chars().count(), "ascii":snapshot.ascii, "page":snapshot.page}) + ); + self.last = snapshot; + if !visible { + self.repeat = None; + } + if let Err(error) = self.draw(qh) { + self.error = Some(error.to_string()); + } + } + + fn draw(&mut self, qh: &QueueHandle) -> Result<(), Box> { + let Some(popup) = &self.popup else { + return Ok(()); + }; + if self.last.preedit.is_empty() { + popup.surface.attach(None, 0, 0); + popup.surface.commit(); + return Ok(()); + } + let scale = if popup.viewport.is_some() { + popup.scale + } else { + popup.scale.ceil() + }; + let (image, width, height) = self.panel.render(&self.last, scale)?; + let mut bytes = image.data().to_vec(); + // tiny-skia uses premultiplied RGBA; wl_shm ARGB8888 is BGRA on LE. + for pixel in bytes.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + let mut file = tempfile::tempfile()?; + file.write_all(&bytes)?; + let pool = self + .shm + .as_ref() + .unwrap() + .create_pool(file.as_fd(), bytes.len() as i32, qh, ()); + let buffer = pool.create_buffer( + 0, + image.width() as i32, + image.height() as i32, + image.width() as i32 * 4, + wl_shm::Format::Argb8888, + qh, + (), + ); + pool.destroy(); + if let Some(viewport) = &popup.viewport { + popup.surface.set_buffer_scale(1); + viewport.set_destination(width, height); + } else { + popup.surface.set_buffer_scale(scale as i32); + } + popup.surface.attach(Some(&buffer), 0, 0); + popup + .surface + .damage_buffer(0, 0, image.width() as i32, image.height() as i32); + popup.surface.commit(); + self.buffers.push(buffer); + Ok(()) + } + + fn tick(&mut self, qh: &QueueHandle) { + let mut changed = false; + while let Ok(colors) = self.theme.try_recv() { + self.panel.colors = colors; + changed = true; + } + if changed && let Err(error) = self.draw(qh) { + self.error = Some(error.to_string()); + } + if let Some(repeat) = &self.repeat + && repeat.next <= Instant::now() + { + let key = repeat.key; + let keysym = self + .xkb + .as_ref() + .unwrap() + .key_get_one_sym(xkb::Keycode::new(key + 8)) + .raw(); + let (keysym, mask, paging) = + ferese_rime::paging_key(keysym, self.mask(), !self.last.candidates.is_empty()); + let mut snapshot = self.engine.process(keysym, mask); + if paging { + snapshot.accepted = true; + } + if snapshot.accepted { + self.repeat.as_mut().unwrap().next = + Instant::now() + Duration::from_secs_f64(1. / self.repeat_rate.max(1) as f64); + } else { + self.repeat = None; + } + self.publish(snapshot, qh); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(6), qh, ())) + } + "wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())), + "wl_seat" if state.seat.is_none() => { + state.seat = Some(registry.bind(name, version.min(9), qh, ())) + } + "zwp_input_method_manager_v2" => { + state.manager = Some(registry.bind(name, 1, qh, ())) + } + "zwp_virtual_keyboard_manager_v1" => { + state.vk_manager = Some(registry.bind(name, 1, qh, ())) + } + "wp_viewporter" => state.viewporter = Some(registry.bind(name, 1, qh, ())), + "wp_fractional_scale_manager_v1" => { + state.fractional = Some(registry.bind(name, 1, qh, ())) + } + _ => (), + } + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &method::ZwpInputMethodV2, + event: method::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + match event { + method::Event::Activate => { + state.pending_active = true; + state.pending_sensitive = false; + } + method::Event::Deactivate => state.pending_active = false, + method::Event::ContentType { hint, purpose } => { + let hint: u32 = hint.into(); + let purpose: u32 = purpose.into(); + state.pending_sensitive = purpose == 8 || purpose == 9 || hint & (0x40 | 0x80) != 0; + } + method::Event::Done => { + state.serial = state.serial.wrapping_add(1); + state.activate(qh); + } + method::Event::Unavailable => { + state.error = Some("another input method owns this seat".into()) + } + _ => (), + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &grab::ZwpInputMethodKeyboardGrabV2, + event: grab::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + match event { + grab::Event::Keymap { + format: WEnum::Value(wl_keyboard::KeymapFormat::XkbV1), + fd, + size, + } => { + let mut file = File::from(fd); + let mut bytes = Vec::new(); + if size > 4 * 1024 * 1024 + || file.rewind().is_err() + || file.read_to_end(&mut bytes).is_err() + { + state.error = Some("invalid input-method keymap".into()); + return; + } + let text = String::from_utf8_lossy(&bytes) + .trim_end_matches('\0') + .to_owned(); + let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); + if let Some(keymap) = xkb::Keymap::new_from_string( + &context, + text, + xkb::KEYMAP_FORMAT_TEXT_V1, + xkb::KEYMAP_COMPILE_NO_FLAGS, + ) { + state.xkb = Some(xkb::State::new(&keymap)); + let _ = file.rewind(); + state + .virtual_keyboard + .as_ref() + .unwrap() + .keymap(1, file.as_fd(), size); + } else { + state.error = Some("could not parse input-method keymap".into()); + } + } + grab::Event::Modifiers { + mods_depressed, + mods_latched, + mods_locked, + group, + .. + } => { + if let Some(xkb) = &mut state.xkb { + xkb.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group); + } + state.virtual_keyboard.as_ref().unwrap().modifiers( + mods_depressed, + mods_latched, + mods_locked, + group, + ); + } + grab::Event::Key { + time, + key, + state: key_state, + .. + } => state.key( + time, + key, + key_state == WEnum::Value(wl_keyboard::KeyState::Pressed), + qh, + ), + grab::Event::RepeatInfo { rate, delay } => { + state.repeat_rate = rate; + state.repeat_delay = delay; + } + _ => (), + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + surface: &wl_surface::WlSurface, + event: wl_surface::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let Some(popup) = &mut state.popup + && &popup.surface == surface + && popup.fractional.is_none() + && let wl_surface::Event::PreferredBufferScale { factor } = event + { + popup.scale = factor.max(1) as f64; + if let Err(error) = state.draw(qh) { + state.error = Some(error.to_string()); + } + } + } +} +impl Dispatch for State { + fn event( + state: &mut Self, + object: &wp_fractional_scale_v1::WpFractionalScaleV1, + event: wp_fractional_scale_v1::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event + && let Some(popup) = &mut state.popup + && popup.fractional.as_ref() == Some(object) + { + popup.scale = scale as f64 / 120.; + if let Err(error) = state.draw(qh) { + state.error = Some(error.to_string()); + } + } + } +} +impl Dispatch for State { + fn event( + state: &mut Self, + buffer: &wl_buffer::WlBuffer, + event: wl_buffer::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_buffer::Event::Release = event { + buffer.destroy(); + state.buffers.retain(|value| value != buffer); + } + } +} +delegate_noop!(State: ignore wl_compositor::WlCompositor); +delegate_noop!(State: ignore wl_shm::WlShm); +delegate_noop!(State: ignore wl_shm_pool::WlShmPool); +delegate_noop!(State: ignore wl_seat::WlSeat); +delegate_noop!(State: ignore manager::ZwpInputMethodManagerV2); +delegate_noop!(State: ignore popup::ZwpInputPopupSurfaceV2); +delegate_noop!(State: ignore vk_manager::ZwpVirtualKeyboardManagerV1); +delegate_noop!(State: ignore vk::ZwpVirtualKeyboardV1); +delegate_noop!(State: ignore wp_viewporter::WpViewporter); +delegate_noop!(State: ignore wp_viewport::WpViewport); +delegate_noop!(State: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); + +fn main() -> Result<(), Box> { + let mut user = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|path| PathBuf::from(path).join(".local/share"))) + .ok_or("no user data directory")? + .join("ferese/rime"); + let mut shared = PathBuf::from("/usr/share/rime-data"); + let mut schema = "luna_pinyin_simp".to_owned(); + let mut self_test = false; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--user-data" => user = args.next().ok_or("missing user data path")?.into(), + "--shared-data" => shared = args.next().ok_or("missing shared data path")?.into(), + "--schema" => schema = args.next().ok_or("missing Rime schema")?, + "--self-test" => self_test = true, + "--version" => { + println!("ferese-rime {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + "--help" => { + println!( + "ferese-rime [--user-data PATH] [--shared-data PATH] [--schema NAME] [--self-test]" + ); + return Ok(()); + } + _ => return Err(format!("unknown option {arg}").into()), + } + } + std::fs::create_dir_all(&user)?; + std::fs::set_permissions(&user, std::fs::Permissions::from_mode(0o700))?; + let mut engine = Engine::open(&user, &shared, &schema)?; + if self_test { + let mut committed = String::new(); + let mut candidates = 0; + for key in "nihao ".bytes() { + let snapshot = engine.process(key as u32, 0); + candidates = candidates.max(snapshot.candidates.len()); + committed.push_str(&snapshot.commit); + committed.push_str(&engine.process(key as u32, 1 << 30).commit); + } + if committed != "你好" || candidates == 0 { + return Err( + format!("self-test failed: commit={committed:?}, candidates={candidates}").into(), + ); + } + println!( + "{}", + serde_json::json!({"self_test":"passed", "commit":committed, "candidates":candidates}) + ); + return Ok(()); + } + let panel = Panel::new()?; + let (send, theme) = mpsc::channel(); + std::thread::spawn(move || { + while let Ok(mut connection) = ferese_ipc::Connection::connect() { + let Ok(mut theme) = connection.get(Vec::new) else { + return; + }; + loop { + if send.send(Colors::from_theme(&theme)).is_err() { + return; + } + match connection.watch(theme.revision, Vec::new) { + Ok(next) => theme = next, + Err(_) => break, + } + } + std::thread::sleep(Duration::from_secs(1)); + } + }); + let connection = Connection::connect_to_env()?; + let mut queue = connection.new_event_queue(); + let qh = queue.handle(); + let _registry = connection.display().get_registry(&qh, ()); + let mut state = State { + engine, + panel, + theme, + compositor: None, + shm: None, + seat: None, + manager: None, + vk_manager: None, + viewporter: None, + fractional: None, + method: None, + virtual_keyboard: None, + grab: None, + xkb: None, + popup: None, + buffers: Vec::new(), + routes: HashMap::new(), + serial: 0, + active: false, + pending_active: false, + sensitive: false, + pending_sensitive: false, + last: Snapshot::default(), + error: None, + repeat: None, + repeat_rate: 25, + repeat_delay: 600, + }; + queue.roundtrip(&mut state)?; + state.initialize(&qh)?; + loop { + queue.dispatch_pending(&mut state)?; + state.tick(&qh); + if let Some(error) = state.error.take() { + return Err(error.into()); + } + let blocked = match connection.flush() { + Ok(()) => false, + Err(wayland_client::backend::WaylandError::Io(error)) + if error.kind() == std::io::ErrorKind::WouldBlock => + { + true + } + Err(error) => return Err(error.into()), + }; + if let Some(read) = queue.prepare_read() { + let mut descriptor = libc::pollfd { + fd: connection.as_fd().as_raw_fd(), + events: libc::POLLIN | if blocked { libc::POLLOUT } else { 0 }, + revents: 0, + }; + let timeout = state.repeat.as_ref().map_or(200, |repeat| { + repeat + .next + .saturating_duration_since(Instant::now()) + .as_millis() + .min(200) as i32 + }); + // SAFETY: one initialized pollfd is passed for the duration of poll. + let ready = unsafe { libc::poll(&mut descriptor, 1, timeout) }; + if ready < 0 + && std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted + { + return Err(std::io::Error::last_os_error().into()); + } + if ready > 0 && descriptor.revents & libc::POLLIN != 0 { + match read.read() { + Ok(_) => (), + Err(wayland_client::backend::WaylandError::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) => {} + Err(error) => return Err(error.into()), + } + } else { + drop(read); + } + if descriptor.revents & (libc::POLLERR | libc::POLLHUP) != 0 { + return Ok(()); + } + } + } +} diff --git a/crates/ferese-rime/src/panel.rs b/crates/ferese-rime/src/panel.rs new file mode 100644 index 0000000..7740c5d --- /dev/null +++ b/crates/ferese-rime/src/panel.rs @@ -0,0 +1,363 @@ +use ab_glyph::{Font, FontArc, FontVec, PxScale, ScaleFont, point}; +use tiny_skia::{Paint, PathBuilder, Pixmap, Transform}; + +use crate::Snapshot; + +#[derive(Clone, Debug)] +pub struct Colors { + background: [u8; 4], + text: [u8; 4], + muted: [u8; 4], + accent: [u8; 4], + on_accent: [u8; 4], + border: [u8; 4], + radius: f32, +} + +impl Default for Colors { + fn default() -> Self { + Self { + background: [30, 37, 48, 255], + text: [244, 247, 251, 255], + muted: [135, 147, 162, 255], + accent: [55, 111, 207, 255], + on_accent: [244, 247, 251, 255], + border: [255, 255, 255, 24], + radius: 14., + } + } +} + +impl Colors { + #[must_use] + pub fn from_theme(snapshot: &ferese_ipc::theme::Snapshot) -> Self { + let theme = &snapshot.theme.tokens; + let tokens = &theme.colors; + let parse = |value: &str, fallback| parse_color(value).unwrap_or(fallback); + let fallback = Self::default(); + Self { + background: parse(&tokens.surface_raised, fallback.background), + text: parse(&tokens.text_primary, fallback.text), + muted: parse(&tokens.text_muted, fallback.muted), + accent: parse(&tokens.accent, fallback.accent), + on_accent: parse(&tokens.on_accent, fallback.on_accent), + border: parse(&tokens.border, fallback.border), + radius: theme.geometry.shell_radius.clamp(0., 18.) as f32, + } + } +} + +fn parse_color(value: &str) -> Option<[u8; 4]> { + let value = value.strip_prefix('#')?; + if !value.is_ascii() || !matches!(value.len(), 6 | 8) { + return None; + } + let mut result = [255; 4]; + for (n, channel) in result.iter_mut().enumerate().take(value.len() / 2) { + *channel = u8::from_str_radix(&value[n * 2..n * 2 + 2], 16).ok()?; + } + Some(result) +} + +pub struct Panel { + fonts: Vec, + pub colors: Colors, +} + +impl Panel { + pub fn new() -> Result { + let mut database = fontdb::Database::new(); + database.load_system_fonts(); + let mut fonts = Vec::new(); + let mut families = Vec::new(); + for family in [ + "MiSans", + "MiSans TC", + "Noto Sans CJK SC", + "Noto Sans", + "DejaVu Sans", + ] { + let Some(id) = database.query(&fontdb::Query { + families: &[fontdb::Family::Name(family)], + ..Default::default() + }) else { + continue; + }; + if let Some(Ok(font)) = database.with_face_data(id, |bytes, index| { + FontVec::try_from_vec_and_index(bytes.to_vec(), index).map(FontArc::new) + }) { + fonts.push(font); + families.push(family); + } + if fonts.len() >= 2 { + break; + } + } + if fonts.is_empty() { + return Err("no candidate panel font found".into()); + } + eprintln!( + "{}", + serde_json::json!({"event":"candidate-fonts", "families":families}) + ); + Ok(Self { + fonts, + colors: Colors::default(), + }) + } + + /// A single horizontal row. The service owns rasterization, while Ferese + /// owns input-popup placement and stacking. All sizes below are logical. + pub fn render(&self, state: &Snapshot, scale: f64) -> Result<(Pixmap, i32, i32), String> { + let scale = scale.clamp(1., 4.) as f32; + let count = state.candidates.len().max(1); + let maximum_cell = (960. - 28. - (count - 1) as f32 * 6.) / count as f32; + let items = state + .candidates + .iter() + .enumerate() + .map(|(n, candidate)| { + let label = (n + 1).to_string(); + let word = self.fit(&candidate.text, 18., (maximum_cell - 40.).max(24.)); + let word_width = self.metrics(&word, 18.).0; + let remaining = maximum_cell - 40. - word_width; + let comment = if remaining > 24. { + self.fit(&candidate.comment, 12., remaining - 7.) + } else { + String::new() + }; + let comment_width = if comment.is_empty() { + 0. + } else { + 7. + self.metrics(&comment, 12.).0 + }; + let width = (34. + word_width + comment_width) + .max(64.) + .min(maximum_cell); + (label, word, comment, width) + }) + .collect::>(); + let width = (items.iter().map(|item| item.3).sum::() + 28. + (count - 1) as f32 * 6.) + .max(280.) + .ceil() as i32; + let height = 88; + let mut image = Pixmap::new( + (width as f32 * scale).ceil() as u32, + (height as f32 * scale).ceil() as u32, + ) + .ok_or("invalid candidate panel size")?; + let transform = Transform::from_scale(scale, scale); + rounded( + &mut image, + (3., 5., width as f32 - 6., height as f32 - 8.), + self.colors.radius + 2., + [0, 0, 0, 32], + transform, + ); + rounded( + &mut image, + (4., 3., width as f32 - 8., height as f32 - 8.), + self.colors.radius, + self.colors.border, + transform, + ); + rounded( + &mut image, + (5., 4., width as f32 - 10., height as f32 - 10.), + (self.colors.radius - 1.).max(0.), + self.colors.background, + transform, + ); + let hint = format!("{} · − / +", state.page + 1); + let hint_width = self.metrics(&hint, 11.).0; + let preedit = self.fit( + &state.preedit, + 13., + (width as f32 - hint_width - 48.).max(20.), + ); + self.text_center( + &mut image, + &preedit, + (15., 21.), + 13., + self.colors.muted, + scale, + ); + self.text_center( + &mut image, + &hint, + (width as f32 - hint_width - 15., 21.), + 11., + self.colors.muted, + scale, + ); + let mut x = 14.; + for (n, (label, word, comment, cell)) in items.iter().enumerate() { + let selected = n == state.highlighted; + if selected { + rounded( + &mut image, + (x, 37., *cell, 38.), + (self.colors.radius - 5.).clamp(4., 10.), + self.colors.accent, + transform, + ); + } + let color = if selected { + self.colors.on_accent + } else { + self.colors.text + }; + let muted = if selected { + self.colors.on_accent + } else { + self.colors.muted + }; + self.text_center(&mut image, label, (x + 10., 56.), 11., muted, scale); + self.text_center(&mut image, word, (x + 28., 56.), 18., color, scale); + if !comment.is_empty() { + self.text_center( + &mut image, + comment, + (x + 35. + self.metrics(word, 18.).0, 56.), + 12., + muted, + scale, + ); + } + x += cell + 6.; + } + Ok((image, width, height)) + } + + fn font(&self, character: char) -> &FontArc { + self.fonts + .iter() + .find(|font| font.glyph_id(character).0 != 0) + .unwrap_or(&self.fonts[0]) + } + + /// Width and actual ink bounds around baseline zero. Using ink rather than + /// the font's line box centers MiSans Chinese, Latin and digits optically. + fn metrics(&self, text: &str, size: f32) -> (f32, f32, f32) { + let scale = PxScale::from(size); + let (mut width, mut min_y, mut max_y) = (0., f32::INFINITY, f32::NEG_INFINITY); + for character in text.chars().take(80) { + let font = self.font(character); + let id = font.glyph_id(character); + width += font.as_scaled(scale).h_advance(id); + if let Some(outline) = + font.outline_glyph(id.with_scale_and_position(scale, point(0., 0.))) + { + min_y = min_y.min(outline.px_bounds().min.y); + max_y = max_y.max(outline.px_bounds().max.y); + } + } + if !min_y.is_finite() { + min_y = 0.; + max_y = 0.; + } + (width, min_y, max_y) + } + + fn fit(&self, text: &str, size: f32, width: f32) -> String { + let text = text.chars().take(80).collect::(); + if self.metrics(&text, size).0 <= width { + return text; + } + let mut result = String::new(); + for character in text.chars() { + let next = format!("{result}{character}…"); + if self.metrics(&next, size).0 > width { + break; + } + result.push(character); + } + result.push('…'); + result + } + + fn text_center( + &self, + image: &mut Pixmap, + text: &str, + position: (f32, f32), + size: f32, + color: [u8; 4], + factor: f32, + ) { + let scale = PxScale::from(size * factor); + let (_, min_y, max_y) = self.metrics(text, size * factor); + let baseline = position.1 * factor - (min_y + max_y) / 2.; + let mut x = position.0 * factor; + let (width, height) = (image.width(), image.height()); + for character in text.chars().take(80) { + let font = self.font(character); + let id = font.glyph_id(character); + let advance = font.as_scaled(scale).h_advance(id); + if let Some(outline) = + font.outline_glyph(id.with_scale_and_position(scale, point(x, baseline))) + { + let bounds = outline.px_bounds(); + outline.draw(|gx, gy, coverage| { + let px = bounds.min.x as i32 + gx as i32; + let py = bounds.min.y as i32 + gy as i32; + if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 { + return; + } + let offset = ((py as u32 * width + px as u32) * 4) as usize; + let alpha = coverage * color[3] as f32 / 255.; + let pixel = &mut image.data_mut()[offset..offset + 4]; + for n in 0..3 { + pixel[n] = (color[n] as f32 * alpha + pixel[n] as f32 * (1. - alpha)) + .round() as u8; + } + pixel[3] = (255. * alpha + pixel[3] as f32 * (1. - alpha)).round() as u8; + }); + } + x += advance; + if x > width as f32 - 5. * factor { + break; + } + } + } +} + +fn rounded( + image: &mut Pixmap, + rect: (f32, f32, f32, f32), + radius: f32, + color: [u8; 4], + transform: Transform, +) { + let (x, y, w, h) = rect; + let r = radius.min(w / 2.).min(h / 2.).max(0.); + let mut path = PathBuilder::new(); + path.move_to(x + r, y); + path.line_to(x + w - r, y); + path.quad_to(x + w, y, x + w, y + r); + path.line_to(x + w, y + h - r); + path.quad_to(x + w, y + h, x + w - r, y + h); + path.line_to(x + r, y + h); + path.quad_to(x, y + h, x, y + h - r); + path.line_to(x, y + r); + path.quad_to(x, y, x + r, y); + path.close(); + let mut paint = Paint::default(); + paint.set_color_rgba8(color[0], color[1], color[2], color[3]); + if let Some(path) = path.finish() { + image.fill_path(&path, &paint, tiny_skia::FillRule::Winding, transform, None); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ferese_theme_color_formats() { + assert_eq!(parse_color("#1E2530FF"), Some([30, 37, 48, 255])); + assert_eq!(parse_color("#F4F7FB"), Some([244, 247, 251, 255])); + assert_eq!(parse_color("#中文字体"), None); + assert_eq!(parse_color("rgb(1, 2, 3)"), None); + } +} diff --git a/crates/ferese-rime/src/rime_shim.c b/crates/ferese-rime/src/rime_shim.c new file mode 100644 index 0000000..aa27343 --- /dev/null +++ b/crates/ferese-rime/src/rime_shim.c @@ -0,0 +1,73 @@ +#include +#include +#include + +/* Keep the versioned librime ABI in this small, header-checked boundary. */ +typedef struct { + int accepted, ascii_mode, cursor, highlighted, page_no, last_page, count; + char *commit, *preedit; + char *texts[9], *comments[9]; +} FereseRimeResult; + +static RimeApi *api; +static RimeSessionId session; + +int ferese_rime_open(const char *user, const char *shared, const char *schema) { + api = rime_get_api(); + RIME_STRUCT(RimeTraits, traits); + traits.shared_data_dir = shared; + traits.user_data_dir = user; + traits.distribution_name = "Ferese Rime"; + traits.distribution_code_name = "ferese-rime"; + traits.distribution_version = "0.1.0"; + traits.app_name = "rime.ferese"; + traits.log_dir = ""; + traits.min_log_level = 2; + api->setup(&traits); + api->initialize(&traits); + if (api->start_maintenance(False)) api->join_maintenance_thread(); + session = api->create_session(); + if (!session || !api->select_schema(session, schema)) return 0; + api->set_option(session, "ascii_mode", False); + return 1; +} + +void ferese_rime_snapshot(int key, int mask, FereseRimeResult *out) { + memset(out, 0, sizeof(*out)); + out->accepted = key < 0 ? 0 : api->process_key(session, key, mask); + RIME_STRUCT(RimeCommit, commit); + if (api->get_commit(session, &commit)) { + out->commit = strdup(commit.text ? commit.text : ""); + api->free_commit(&commit); + } + RIME_STRUCT(RimeContext, context); + if (api->get_context(session, &context)) { + out->preedit = strdup(context.composition.preedit ? context.composition.preedit : ""); + out->cursor = context.composition.cursor_pos; + out->highlighted = context.menu.highlighted_candidate_index; + out->page_no = context.menu.page_no; + out->last_page = context.menu.is_last_page; + out->count = context.menu.num_candidates < 9 ? context.menu.num_candidates : 9; + for (int n = 0; n < out->count; n++) { + out->texts[n] = strdup(context.menu.candidates[n].text ? context.menu.candidates[n].text : ""); + out->comments[n] = strdup(context.menu.candidates[n].comment ? context.menu.candidates[n].comment : ""); + } + api->free_context(&context); + } + RIME_STRUCT(RimeStatus, status); + if (api->get_status(session, &status)) { + out->ascii_mode = status.is_ascii_mode; + api->free_status(&status); + } +} + +void ferese_rime_result_free(FereseRimeResult *out) { + free(out->commit); free(out->preedit); + for (int n = 0; n < out->count; n++) { free(out->texts[n]); free(out->comments[n]); } +} +void ferese_rime_clear(void) { if (session) api->clear_composition(session); } +void ferese_rime_close(void) { + if (session) api->destroy_session(session); + if (api) api->finalize(); + session = 0; api = NULL; +} diff --git a/docs/configuration/session-protocols.md b/docs/configuration/session-protocols.md index 3965d8f..554092a 100644 --- a/docs/configuration/session-protocols.md +++ b/docs/configuration/session-protocols.md @@ -12,6 +12,12 @@ environment for screenshots to work. Text input is available by default; Ctrl+Alt+Escape releases an active shortcut inhibitor. Use `feresectl --help` for runtime control commands. +On the local Rime prototype branch, `FERESE_ENABLE_INPUT_METHOD=1` also enables +`zwp_virtual_keyboard_manager_v1`, which native input-method-v2 clients use to +forward unconsumed keys. Both globals remain hidden by default. Treat the flag +as permission for trusted session clients; virtual key events are rejected +while the session lock is active. See [the isolated Rime prototype](../development/rime-poc.md). + Shell rounding is controlled in Settings → Panel & Shell → Shell corner radius. The shell and compositor use the same squircle profile for fills, borders, shadows and blur masks. Small controls cap the radius to fit their size; circles and pills diff --git a/docs/development/rime-poc.md b/docs/development/rime-poc.md new file mode 100644 index 0000000..f638b45 --- /dev/null +++ b/docs/development/rime-poc.md @@ -0,0 +1,107 @@ +# Ferese 原生 Rime 原型 + +本地分支:`twilight/rime-service-poc`。基线为 Ferese `v0.1.0`,提交 +`f21167e1a22d527460e20a0eb3184790ee9754df`。这是本设备上的架构验证,尚未作为日常桌面版本发布。 + +## 进程与协议 + +```mermaid +flowchart LR + A[原生 Wayland 应用] <-->|text-input-v3| B[Ferese 合成器] + B <-->|input-method-v2 / virtual-keyboard-v1| C[独立 Rust Rime 服务] + C <--> D[librime 与独立测试词库] + B -->|主题 IPC| C + C -->|原生 input-popup SHM 缓冲区| B +``` + +`ferese-rime` 是独立进程。它直接调用系统 librime,通过原生 Wayland 协议接收按键、 +提交文字和预编辑状态,并用 Rust 光栅化候选内容。候选颜色订阅现有 Ferese 主题 IPC。 +Ferese 负责候选弹窗的父窗口关联、层级、坐标和屏幕边界处理。候选不是普通应用窗口。 + +本设备的字体默认改为 **MiSans**,候选服务优先加载 **MiSans / MiSans TC**。 +候选使用紧凑的横向布局,编号和候选字按实际字形边界垂直居中, +圆角、底色、边框和选中态颜色取自 Ferese 主题。 +组字时数字行 `-` 翻上页、`=` 或 `+` 翻下页;普通英文状态保留这些字符。 + +librime 仍是 C++ 引擎,通过一个由安装头文件检查 ABI 的小型 C 接口连接 Rust。 +组字、词库和候选光栅化都在服务进程内执行。当前实现没有使用 Fcitx5、IBus 或其 D-Bus 兼容服务。 + +合成器的改动包括: + +- 在输入法开关下增加 `virtual-keyboard-v1`;默认不公开这两个输入法全局接口。 +- 为辅助键盘保留独立按键来源;输入法自身转发的按键绕过它自己的 grab,避免回环。 +- 拒绝锁屏期间的虚拟按键,并清理离线辅助设备的按键状态。 +- 失去嵌套窗口焦点时释放物理来源的按键,清除快捷键重复和拦截状态。 +- 区分 input-method 与 xdg popup 的几何语义;候选不再被父窗口的圆角边界裁剪。 +- 在输出底部向上展开候选,在左右边界约束位置;缩放使用逻辑坐标与原生 viewport。 + +## 构建与启动 + +需要仓库的构建依赖,以及 `librime`、`xkbcommon` 和 Wayland 开发文件。 +验证环境使用系统 librime 1.17.0、GTK4 4.24.1、RTX 4090、NVIDIA 615.78.08。 +Rust 工具链由仓库固定为 1.95.0。 + +```sh +cargo build -p ferese -p feresectl -p ferese-rime --locked -j 12 +python3 scripts/rime-preview.py start +python3 scripts/rime-preview.py status +``` + +预览使用开发版合成器、开发版输入法和已安装的 0.1.0 Shell。它会打开原生 GTK 测试框, +并在本机存在 Strata 时打开单独的测试目录。GTK 使用 `GTK_IM_MODULE=wayland`。 + +`scripts/rime-preview.py restart` 重开开发预览;`stop` 停止它;`ime` 只启动输入法服务。 +输入法崩溃后桌面保留,英文可直接输入,执行 `ime` 可重新启用中文。 + +本机应用菜单中已有“Ferese Rime 开发预览”入口,右键操作可重开或停止。 +入口仅调用本仓库脚本;移动仓库后应更新 +`~/.local/share/applications/ferese-rime-preview.desktop` 中的路径。 + +预览的配置、缓存、词库和证据位于 `.artifacts/rime-poc-20261028/`。 +运行时套接字位于当前 UID 的 `/run/user//ferese-rime-poc/`,目录权限为 0700。 +它使用独立 D-Bus 和独立 systemd 用户临时服务,不导入宿主桌面的激活环境。 +测试词库由系统方案建立,不导入原有 IBus/Fcitx 个人词库。 + +## 验证 + +```sh +cargo test -p ferese input::binding_mode_tests -- --nocapture +cargo test -p ferese handlers::input_method::tests -- --nocapture +cargo test -p ferese-rime --lib --locked +cargo clippy -p ferese-rime -p ferese --all-targets --locked -- -D warnings +./target/debug/ferese-rime --user-data .artifacts/rime-poc-20261028/rime-self-test --self-test +FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py +``` + +最后一个测试实际启动构建树预览,操作专用原生 GTK 测试框,验证: + +- 组字未提前提交、原生候选不是普通顶层窗口、空格和数字选字。 +- 退格、取消、Shift 中英文切换、英文透传和 Ctrl+A 的按下/释放配对。 +- 数字行减号、等号及 Shift+等号翻页,翻页后数字选字;候选保持单行横向排列。 +- 敏感输入框直接收到测试英文,Rime 未收到这些组字事件。 +- 底边候选组字和提交,以及候选主题的实时切换;截图单独检查位置和外观。 +- 在组字期间 SIGKILL 输入法:合成器进程保持不变,英文继续输入,重启服务后恢复中文。 +- 输入法进程实际映射 librime,未映射 IBus/Fcitx 库。 +- 测试输入工具连接真实宿主时拒绝输入。 + +`ferese-rime-probe` 只供集成测试使用,需要显式 `FERESE_TEST_INPUT=1`,且在创建虚拟键盘前 +检查目标确为只有 `ferese-winit` 输出的 Ferese 嵌套实例。测试不向宿主注入键盘事件。 +测试框只输入可丢弃样本,其普通字段保存在 `entry-state.json` 便于独立读回;敏感字段只记录长度。 +自动结果和截图保存在同一证据目录,不能用单纯的编译通过代替桌面输入验收。 + +2026-10-28 的本机自动检查全部通过;用户随后使用实际键盘确认 +“显示、翻页和选字正常”。底边候选向上展开、浅深色主题已逐张检查截图。 +输入法服务在组字时被强制终止后,桌面和英文输入仍工作,单独重启服务后中文恢复。 + +## 当前边界 + +- 已覆盖原生 GTK4 输入路径和 Strata 的候选显示,当前默认方案为 `luna_pinyin_simp`。 +- 候选支持键盘选字;鼠标点选、配置界面、自动恢复策略仍待实现。 +- 使用 US 键盘布局和嵌套输出验证;多布局切换、其他方案、个人词库迁移尚未验收。 +- Qt、Electron、Xwayland 和 Shell 的 layer-surface 输入场景尚未验收。 +- 独立登录后的 DRM、多屏热插拔、锁屏解锁和长期使用仍需验证。 +- 当前为调试构建;字体加载、词库部署和重复绘制尚未进行性能优化。 +- `FERESE_ENABLE_INPUT_METHOD=1` 仍沿用上游“受信任会话客户端”的许可边界, + 专用私有连接授权属于后续工作。 + +后续可以继续复用这套独立进程边界,逐步补齐候选交互和会话管理。 diff --git a/scripts/rime-preview.py b/scripts/rime-preview.py new file mode 100644 index 0000000..2493f08 --- /dev/null +++ b/scripts/rime-preview.py @@ -0,0 +1,190 @@ +#!/usr/bin/python3 +"""Build-tree-only Rime proof of concept, isolated from the host desktop.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / ".artifacts/rime-poc-20261028" +RUNTIME = Path(f"/run/user/{os.getuid()}/ferese-rime-poc") +MAIN = "ferese-rime-poc-20261028.service" +IME = "ferese-rime-native-20261028.service" +APP = "ferese-rime-entry-20261028.service" +STRATA = "ferese-rime-strata-20261028.service" +UNITS = [APP, STRATA, IME, MAIN] + + +def run(args, *, env=None, check=True): + p = subprocess.run(args, env=env, text=True, capture_output=True) + if check and p.returncode: + raise RuntimeError(p.stderr.strip() or p.stdout.strip() or f"command failed: {args[0]}") + return p.stdout.strip() + + +def prop(unit, field): + return run(["systemctl", "--user", "show", unit, f"--property={field}", "--value"], check=False) + + +def start_unit(unit, command, env, limit): + args = ["systemd-run", "--user", "--collect", f"--unit={unit}", "--service-type=exec", + f"--working-directory={ROOT}", f"--property=MemoryMax={limit}", "--property=TasksMax=512"] + if unit != MAIN: + args += [f"--property=BindsTo={MAIN}", f"--property=After={MAIN}"] + run(args + [f"--setenv={key}={value}" for key, value in env.items()] + list(command)) + + +def wait_for(fn, message, timeout=20): + until = time.monotonic() + timeout + while time.monotonic() < until: + value = fn() + if value: + return value + time.sleep(.1) + raise RuntimeError(message) + + +def context(): + group = prop(MAIN, "ControlGroup") + if not group: + return None + try: + pids = (Path("/sys/fs/cgroup") / group.lstrip("/") / "cgroup.procs").read_text().split() + except OSError: + return None + for pid in pids: + try: + proc = Path("/proc") / pid + command = (proc / "cmdline").read_bytes().split(b"\0")[0].decode() + if Path(command).name != "ferese-shell": + continue + env = dict(s.split("=", 1) for s in (proc / "environ").read_text().split("\0") if "=" in s) + if env.get("FERESE_SOCKET") and env.get("FERESE_PUBLIC_WAYLAND_DISPLAY"): + return {"socket": env["FERESE_SOCKET"], "wayland": env["FERESE_PUBLIC_WAYLAND_DISPLAY"], + "bus": env["DBUS_SESSION_BUS_ADDRESS"], "shell_pid": int(pid)} + except (OSError, ValueError): + pass + return None + + +def environment(): + values = {"XDG_RUNTIME_DIR": str(RUNTIME), "XDG_CURRENT_DESKTOP": "Ferese", "XDG_SESSION_DESKTOP": "ferese", + "XDG_SESSION_TYPE": "wayland", "GDK_BACKEND": "wayland", "GTK_IM_MODULE": "wayland", + "QT_IM_MODULE": "wayland", "QT_QPA_PLATFORM": "wayland", "NO_AT_BRIDGE": "1", "GTK_A11Y": "none", + "GSETTINGS_BACKEND": "keyfile", + "IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1", + "FERESE_ENABLE_SCREENCOPY": "1", "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"} + values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")}) + return values + + +def child_environment(): + c = context() + if not c: + raise RuntimeError("Rime development preview is not running") + return {**environment(), "WAYLAND_DISPLAY": c["wayland"], "DBUS_SESSION_BUS_ADDRESS": c["bus"], "FERESE_SOCKET": c["socket"]} + + +def windows(): + c = context() + return json.loads(run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "--json", "windows"])) + + +def focus_entry(): + wait_for(lambda: any(w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not map") + if not any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()): + c = context() + run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "focus-last-window"]) + wait_for(lambda: any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not focus") + + +def start_ime(): + if prop(IME, "ActiveState") == "active": + return + start_unit(IME, [str(ROOT / "target/debug/ferese-rime"), "--user-data", str(DATA / "rime-user")], child_environment(), "768M") + pid = prop(IME, "MainPID") + wait_for(lambda: '"event":"ready"' in run( + ["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"], check=False), "Native Rime did not become ready", timeout=40) + + +def start(): + if prop(MAIN, "ActiveState") == "active": + print("开发预览已在运行。") + return + if not (ROOT / "target/debug/ferese-rime").exists(): + raise RuntimeError("Build ferese, feresectl and ferese-rime first") + for kind in ("config/ferese", "data", "cache", "state"): + (DATA / "profile" / kind).mkdir(parents=True, exist_ok=True) + RUNTIME.mkdir(mode=0o700, exist_ok=True) + if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077: + raise RuntimeError("Unexpected private runtime owner or permissions") + (DATA / "profile/config/ferese/config.kdl").write_text(''' +xwayland { enabled #false; } +status { keybinding-guide #false; } +window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560 +window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720 +theme { typography { font-family "MiSans"; }; } +''') + host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) + host_display = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0")) + if not host_display.is_absolute(): + host_display = host_runtime / host_display + if not host_display.exists(): + raise RuntimeError("Parent Wayland display is missing") + env = {**environment(), "WAYLAND_DISPLAY": str(host_display), "RUST_LOG": "info"} + start_unit(MAIN, ["/usr/bin/dbus-run-session", "--", str(ROOT / "target/debug/ferese"), "--backend", "nested", + "--supervise-client", "--grant-effects", "--grant-shell-control", "--", "/usr/bin/ferese-shell"], env, "3G") + c = wait_for(context, "Development compositor did not publish its private connection") + (DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n") + start_ime() + fixture = DATA / "fixtures" + fixture.mkdir(exist_ok=True) + (fixture / "中文测试.txt").write_text("Ferese Rime 独立测试文件。\n") + strata = shutil.which("strata") or str(Path.home() / ".local/bin/strata") + if Path(strata).exists(): + start_unit(STRATA, [strata, str(fixture)], child_environment(), "2G") + wait_for(lambda: any(w["app_id"] == "io.github.lgse.Strata" for w in windows()), "Strata did not map") + start_unit(APP, ["/usr/bin/gjs", str(ROOT / "scripts/tests/fixtures/rime-entry.js"), str(DATA / "entry-state.json")], child_environment(), "512M") + focus_entry() + print("Ferese 原生 Rime 开发预览已打开。") + + +def stop(): + loaded = [u for u in UNITS if prop(u, "LoadState") not in ("", "not-found")] + if loaded: + run(["systemctl", "--user", "stop", *loaded]) + run(["systemctl", "--user", "reset-failed", *loaded], check=False) + print("Rime 开发预览已停止。") + + +def action(name): + focus_entry() + env = child_environment() + return run(["gdbus", "call", "--address", env["DBUS_SESSION_BUS_ADDRESS"], "--dest", "dev.ferese.RimeValidation", + "--object-path", "/dev/ferese/RimeValidation", "--method", "org.gtk.Actions.Activate", name, "[]", "{}"]) + + +def probe(arguments): + env = {**os.environ, **child_environment(), "FERESE_TEST_INPUT": "1"} + print(run([str(ROOT / "target/debug/ferese-rime-probe"), *arguments], env=env)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=["start", "stop", "restart", "status", "ime", "probe", "entry"]) + args, rest = parser.parse_known_args() + if args.action == "restart": + stop(); start() + elif args.action == "status": + print(json.dumps({u: prop(u, "ActiveState") for u in UNITS}, indent=2)) + elif args.action == "entry": + print(action(rest[0])) + elif args.action == "probe": + probe(rest) + elif args.action == "ime": + start_ime() + else: + globals()[args.action]() diff --git a/scripts/tests/fixtures/rime-entry.js b/scripts/tests/fixtures/rime-entry.js new file mode 100644 index 0000000..94336a1 --- /dev/null +++ b/scripts/tests/fixtures/rime-entry.js @@ -0,0 +1,37 @@ +imports.gi.versions.Gtk = '4.0'; +const {Gtk, Gio, GLib, Gdk} = imports.gi; +const output = ARGV[0]; +const application = new Gtk.Application({application_id: 'dev.ferese.RimeValidation'}); +application.connect('activate', () => { + const css = new Gtk.CssProvider(); css.load_from_string('window { font-family: "MiSans"; }'); + Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + const window = new Gtk.ApplicationWindow({application, title: 'Ferese 原生 Rime 验证', default_width: 880, default_height: 560}); + const box = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 18, + margin_start: 28, margin_end: 28, margin_top: 28, margin_bottom: 28}); + box.append(new Gtk.Label({label: '独立 Rime · 原生 Wayland 候选窗', xalign: 0})); + box.append(new Gtk.Label({label: '输入 nihao;空格或数字选字,− / + 翻页,Shift 切换中英文。', wrap: true, xalign: 0})); + const normal = new Gtk.Entry({placeholder_text: '普通输入框', hexpand: true}); + const password = new Gtk.Entry({placeholder_text: '敏感输入测试框(只使用测试字符)', visibility: false, + input_purpose: Gtk.InputPurpose.PASSWORD, hexpand: true}); + const status = new Gtk.Label({label: '等待输入', xalign: 0}); + const save = () => { + const focused = window.get_focus(); + const state = {normal: normal.get_text(), password_length: password.get_text().length, + focus: focused && (focused === password || focused.is_ancestor(password)) ? 'password' : 'normal'}; + // This is a disposable validation fixture, containing only test input. + GLib.file_set_contents(output, JSON.stringify(state)); + status.set_label('普通输入:' + normal.get_text().length + ' 字符;敏感输入:' + password.get_text().length + ' 字符'); + }; + for (const widget of [normal, password]) widget.connect('changed', save); + box.append(normal); box.append(password); box.append(status); + box.append(new Gtk.Box({vexpand: true})); + for (const [name, action] of [['normal', () => normal.grab_focus()], ['password', () => password.grab_focus()], + ['clear', () => {normal.set_text(''); password.set_text(''); normal.grab_focus(); save();}], + ['top', () => {box.remove(normal); box.insert_child_after(normal, box.get_first_child().get_next_sibling()); normal.grab_focus();}], + ['bottom', () => {box.remove(normal); box.append(normal); normal.grab_focus();}]]) { + const entry = new Gio.SimpleAction({name}); entry.connect('activate', action); application.add_action(entry); + } + window.set_child(box); window.present(); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {normal.grab_focus(); save(); return GLib.SOURCE_REMOVE;}); +}); +application.run([]); diff --git a/scripts/tests/test_rime_native_isolated.py b/scripts/tests/test_rime_native_isolated.py new file mode 100644 index 0000000..c43a0e4 --- /dev/null +++ b/scripts/tests/test_rime_native_isolated.py @@ -0,0 +1,185 @@ +"""Opt-in real Wayland/GTK/Rime integration checks against the build-tree preview. + +FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py +Only disposable fixture entries are modified. The probe refuses host displays. +""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("preview", ROOT / "scripts/rime-preview.py") +preview = importlib.util.module_from_spec(spec) +spec.loader.exec_module(preview) + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_RIME") == "1", "opt-in native Rime validation") +class NativeRimeTest(unittest.TestCase): + def test_native_pipeline_and_process_boundary(self): + preview.start() + preview.start_ime() + results = [] + + def state(): + return json.loads((preview.DATA / "entry-state.json").read_text()) + + def expect(text): + preview.wait_for(lambda: state()["normal"] == text, + f"Expected {text!r}; latest state {state()}", timeout=4) + + def probe(*args): + preview.probe(list(args)) + time.sleep(.12) + + def clear(): + preview.focus_entry() + probe("--key", "Escape") + preview.action("clear") + expect("") + + def screenshot(name): + c = preview.context() + p = subprocess.run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "screenshot"], capture_output=True, check=True) + (preview.DATA / name).write_bytes(p.stdout) + + clear() + probe("--keys", "nihao") + expect("") # Preedit is displayed, not prematurely committed. + screenshot("candidate-normal.png") + self.assertLessEqual({window["app_id"] for window in preview.windows()}, + {"dev.ferese.RimeValidation", "io.github.lgse.Strata"}) + probe("--keys", " ") + expect("你好") + results.append("native GTK preedit, candidate popup and space commit") + + clear() + probe("--keys", "nihao1") + expect("你好") + results.append("numeric candidate selection") + + clear() + probe("--keys", "ni") + pid = preview.prop(preview.IME, "MainPID") + def composition(): + lines = preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).splitlines() + return [json.loads(line) for line in lines if line.startswith('{') and '"event":"composition"' in line][-1] + self.assertEqual(composition()["page"], 0) + probe("--keys", "=") + self.assertEqual(composition()["page"], 1) + screenshot("candidate-horizontal-page2.png") + probe("--keys", "-") + self.assertEqual(composition()["page"], 0) + probe("--chord", "Shift_L+equal") + self.assertEqual(composition()["page"], 1) + self.assertFalse(composition()["ascii"]) + probe("--keys", "-1") + expect("你") + results.append("horizontal candidates page with number-row minus, equal and Shift+equal") + + clear() + probe("--keys", "nihao", "--key", "BackSpace", "--keys", "o ") + expect("你好") + results.append("composition backspace and repair") + + clear() + probe("--keys", "nihao", "--key", "Escape") + expect("") + probe("--key", "Shift_L", "--keys", "abc123") + expect("abc123") + time.sleep(.8) + expect("abc123") # Rejected keys must receive paired releases. + probe("--keys", "-=") + expect("abc123-=") + probe("--chord", "Control_L+a", "--key", "BackSpace") + expect("") + probe("--key", "Shift_L", "--keys", "nihao ") + expect("你好") + results.append("escape, Shift mode switching, ASCII and Ctrl+A release pairing") + + clear() + preview.action("password") + time.sleep(.2) + pid = preview.prop(preview.IME, "MainPID") + def compositions(): + return preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).count('"event":"composition"') + before = compositions() + probe("--keys", "nihao") + preview.wait_for(lambda: state()["password_length"] == 5, "Sensitive field did not receive literal keys") + self.assertEqual(compositions(), before) + screenshot("sensitive-no-candidates.png") + results.append("sensitive field bypasses Rime and has no candidates") + + clear() + c = preview.context() + control = [str(ROOT / "target/debug/feresectl"), "--socket", c["socket"]] + subprocess.run(control + ["toggle-maximized"], check=True) + time.sleep(.4) + preview.action("bottom") + time.sleep(.2) + probe("--keys", "nihao") + screenshot("candidate-bottom-flipped.png") + probe("--keys", " ") + expect("你好") + preview.action("top") + subprocess.run(control + ["toggle-maximized"], check=True) + time.sleep(.3) + results.append("bottom-edge native candidate composition and commit; screenshot for placement inspection") + + clear() + probe("--keys", "nihao") + theme = json.loads(preview.run(control + ["--json", "theme", "get"])) + original_mode = theme["mode"] + subprocess.run(control + ["theme", "mode", "light"], check=True, capture_output=True) + preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == "light", "Light theme did not finish resolving") + time.sleep(.35) + screenshot("candidate-theme-light.png") + subprocess.run(control + ["theme", "mode", original_mode], check=True, capture_output=True) + preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == original_mode, "Original theme did not finish resolving") + time.sleep(.35) + results.append("candidate UI follows Ferese light/dark theme through its existing IPC") + + clear() + probe("--keys", "nihao") + compositor_pid = preview.prop(preview.MAIN, "MainPID") + subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", preview.IME], check=True) + preview.wait_for(lambda: preview.prop(preview.IME, "ActiveState") != "active", "IME did not terminate") + self.assertEqual(preview.prop(preview.MAIN, "ActiveState"), "active") + self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid) + probe("--keys", "abc") + expect("abc") + screenshot("ime-crash-desktop-survives.png") + preview.start_ime() + time.sleep(.2) + probe("--keys", "nihao ") + expect("abc你好") + results.append("IME SIGKILL leaves desktop alive; ASCII works; restarted IME commits Chinese") + + # The validation driver must refuse the real host before creating a keyboard. + host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0")) + if not host.is_absolute(): + host = Path(os.environ["XDG_RUNTIME_DIR"]) / host + p = subprocess.run([str(ROOT / "target/debug/ferese-rime-probe"), "--keys", "x"], + env={**os.environ, "WAYLAND_DISPLAY": str(host), "FERESE_TEST_INPUT": "1"}, + text=True, capture_output=True) + self.assertNotEqual(p.returncode, 0) + self.assertIn("not an isolated Ferese", p.stderr) + results.append("test driver refuses host desktop") + + pid = preview.prop(preview.IME, "MainPID") + maps = (Path("/proc") / pid / "maps").read_text() + self.assertIn("librime.so", maps) + self.assertNotIn("libibus", maps) + self.assertNotIn("libfcitx", maps) + results.append("independent process links librime, with no IBus or Fcitx library") + + clear() + probe("--keys", "nihao") + (preview.DATA / "validation.json").write_text(json.dumps({"status": "passed", "checks": results}, ensure_ascii=False, indent=2) + "\n") + + +if __name__ == "__main__": + unittest.main()