Add isolated native Rime service and MiSans candidate UI

This commit is contained in:
aria committed 2026-10-28 00:58:38 +09:00
1 parent f21167e1a2
commit 8e9a5838ab
28 files changed
+2372 -8

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
/target/ /target/
/.artifacts/
/tools/ /tools/
*.log *.log
Generated
+20
View File
@@ -1953,6 +1953,26 @@ dependencies = [
"wayland-server", "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]] [[package]]
name = "ferese-settings" name = "ferese-settings"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -13,6 +13,7 @@ members = [
"crates/ferese-layout", "crates/ferese-layout",
"crates/ferese-panel", "crates/ferese-panel",
"crates/ferese-protocols", "crates/ferese-protocols",
"crates/ferese-rime",
"shell/ferese-shell", "shell/ferese-shell",
"shell/ferese-settings", "shell/ferese-settings",
"shell/ferese-lock", "shell/ferese-lock",
+4
View File
@@ -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. for what's included. Packages target Fedora 44 and Arch Linux on x86_64.
This README and the wiki describe `main`. 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) ![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) [Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots)
+1
View File
@@ -81,6 +81,7 @@ impl CompositorHandler for Ferese {
layer_shell::handle_commit(self, surface); layer_shell::handle_commit(self, surface);
xdg_shell::handle_commit(self, surface); xdg_shell::handle_commit(self, surface);
self.position_committed_input_popup(surface);
crate::backends::direct::render_surface(self, surface); crate::backends::direct::render_surface(self, surface);
} }
+2
View File
@@ -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_manager_v2::ZwpInputMethodManagerV2,
misc::zwp_input_method_v2::server::zwp_input_method_v2::ZwpInputMethodV2, 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_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_device_v1::ZwlrDataControlDeviceV1,
wlr::data_control::v1::server::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1, wlr::data_control::v1::server::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
wlr::data_control::v1::server::zwlr_data_control_source_v1::ZwlrDataControlSourceV1, wlr::data_control::v1::server::zwlr_data_control_source_v1::ZwlrDataControlSourceV1,
+95
View File
@@ -1,12 +1,14 @@
use smithay::desktop::{PopupKind, WindowSurfaceType, layer_map_for_output}; use smithay::desktop::{PopupKind, WindowSurfaceType, layer_map_for_output};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Rectangle}; use smithay::utils::{Logical, Rectangle};
use smithay::utils::{Point, Size};
use smithay::wayland::input_method::{InputMethodHandler, PopupSurface}; use smithay::wayland::input_method::{InputMethodHandler, PopupSurface};
use crate::Ferese; use crate::Ferese;
impl InputMethodHandler for Ferese { impl InputMethodHandler for Ferese {
fn new_popup(&mut self, surface: PopupSurface) { fn new_popup(&mut self, surface: PopupSurface) {
self.position_input_popup(&surface);
if let Err(error) = self if let Err(error) = self
.popups .popups
.track_popup(PopupKind::InputMethod(surface.clone())) .track_popup(PopupKind::InputMethod(surface.clone()))
@@ -23,6 +25,7 @@ impl InputMethodHandler for Ferese {
} }
fn popup_repositioned(&mut self, surface: PopupSurface) { fn popup_repositioned(&mut self, surface: PopupSurface) {
self.position_input_popup(&surface);
crate::backends::direct::render_surface(self, surface.wl_surface()); crate::backends::direct::render_surface(self, surface.wl_surface());
} }
@@ -58,3 +61,95 @@ impl InputMethodHandler for Ferese {
.unwrap_or_default() .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<i32, Logical>,
cursor: Rectangle<i32, Logical>,
size: Size<i32, Logical>,
output: Rectangle<i32, Logical>,
) -> Point<i32, Logical> {
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))
);
}
}
+1
View File
@@ -11,6 +11,7 @@ pub(crate) mod screenshot_worker;
pub(crate) mod security_context; pub(crate) mod security_context;
#[cfg(test)] #[cfg(test)]
mod toplevel_icon_tests; mod toplevel_icon_tests;
mod virtual_keyboard;
pub(crate) mod window_capture; pub(crate) mod window_capture;
mod xdg_shell; mod xdg_shell;
@@ -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<VirtualKeyboardBackend>) {
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::<VirtualKeyboardBackend>(
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()),
_ => (),
}
}
}
+17 -4
View File
@@ -9,7 +9,7 @@ use smithay::backend::input::{
KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch,
SwitchState, SwitchToggleEvent, TouchEvent, SwitchState, SwitchToggleEvent, TouchEvent,
}; };
use smithay::input::keyboard::{FilterResult, keysyms}; use smithay::input::keyboard::{FilterResult, KeyboardSource, keysyms};
use smithay::input::pointer::{ use smithay::input::pointer::{
AxisFrame, ButtonEvent, Focus, GestureSwipeBeginEvent, GestureSwipeEndEvent, AxisFrame, ButtonEvent, Focus, GestureSwipeBeginEvent, GestureSwipeEndEvent,
GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent, GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent,
@@ -133,11 +133,23 @@ impl Ferese {
} }
pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) { pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) {
self.process_input_event_inner(event); self.process_input_event_from_source(event, KeyboardSource::MAIN);
}
pub(crate) fn process_input_event_from_source<I: InputBackend>(
&mut self,
event: InputEvent<I>,
source: KeyboardSource,
) {
self.process_input_event_inner(event, source);
self.poll_window_interaction(); self.poll_window_interaction();
} }
fn process_input_event_inner<I: InputBackend>(&mut self, event: InputEvent<I>) { fn process_input_event_inner<I: InputBackend>(
&mut self,
event: InputEvent<I>,
source: KeyboardSource,
) {
if self.input_capture.active() { if self.input_capture.active() {
self.set_binding_mode("default"); self.set_binding_mode("default");
} }
@@ -329,7 +341,8 @@ impl Ferese {
let client_grab = keyboard let client_grab = keyboard
.with_grab(|_, grab| grab.is::<smithay::desktop::PopupKeyboardGrab<Self>>()) .with_grab(|_, grab| grab.is::<smithay::desktop::PopupKeyboardGrab<Self>>())
.unwrap_or(true); .unwrap_or(true);
let intercepted = keyboard.input::<(), _>( let intercepted = keyboard.input_from_source::<(), _>(
source,
self, self,
keycode, keycode,
state, state,
@@ -78,6 +78,43 @@ binding "Super+r" binding-mode resize
binding-mode resize { binding h resize left; binding l binding-mode launch; } binding-mode resize { binding h resize left; binding l binding-mode launch; }
binding-mode launch { binding h binding-mode default; } 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 { fn state(events: &mut EventLoop<'static, Ferese>) -> Ferese {
let (_, mut config, _) = crate::config::prepare(SOURCE, std::path::Path::new(".")).unwrap(); let (_, mut config, _) = crate::config::prepare(SOURCE, std::path::Path::new(".")).unwrap();
config.wallpaper.path = None; config.wallpaper.path = None;
+28 -2
View File
@@ -469,8 +469,31 @@ pub(super) fn rounded_window_elements(
let radius = corners.radius; let radius = corners.radius;
let surface = toplevel.wl_surface(); let surface = toplevel.wl_surface();
let mut input_popups = Vec::new();
let mut content = PopupManager::popups_for_surface(surface) let mut content = PopupManager::popups_for_surface(surface)
.flat_map(|(popup, popup_offset)| { .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<GlesRenderer>,
>(
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) let offset = (geometry.loc + popup_offset - popup.geometry().loc)
.to_physical_precise_round(scale); .to_physical_precise_round(scale);
@@ -487,6 +510,7 @@ pub(super) fn rounded_window_elements(
) )
.into_iter() .into_iter()
.map(WindowContentRenderElement::from) .map(WindowContentRenderElement::from)
.collect::<Vec<_>>()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
content.extend( content.extend(
@@ -518,6 +542,7 @@ pub(super) fn rounded_window_elements(
}), }),
); );
input_popups.extend(
constrain_render_elements( constrain_render_elements(
content, content,
location, location,
@@ -527,8 +552,9 @@ pub(super) fn rounded_window_elements(
ConstrainAlign::TOP | ConstrainAlign::LEFT, ConstrainAlign::TOP | ConstrainAlign::LEFT,
scale, scale,
) )
.map(Into::into) .map(Into::into),
.collect() );
input_popups
} }
pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior { pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior {
+1 -1
View File
@@ -100,7 +100,7 @@ render_elements! {
Surface=SurfaceRenderElement, Surface=SurfaceRenderElement,
Memory=MemoryRenderElement, Memory=MemoryRenderElement,
Solid=SolidColorRenderElement, Solid=SolidColorRenderElement,
LockSurface=WaylandSurfaceRenderElement<GlesRenderer>, RawSurface=WaylandSurfaceRenderElement<GlesRenderer>,
Border=SharedPixelShaderElement, Border=SharedPixelShaderElement,
Blur=BlurRenderElement, Blur=BlurRenderElement,
Effect=PhysicalShaderElement, Effect=PhysicalShaderElement,
+7
View File
@@ -94,6 +94,7 @@ use smithay::wayland::single_pixel_buffer::SinglePixelBufferState;
use smithay::wayland::socket::ListeningSocketSource; use smithay::wayland::socket::ListeningSocketSource;
use smithay::wayland::text_input::TextInputManagerState; use smithay::wayland::text_input::TextInputManagerState;
use smithay::wayland::viewporter::ViewporterState; use smithay::wayland::viewporter::ViewporterState;
use smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState;
use smithay::wayland::xdg_activation::XdgActivationState; use smithay::wayland::xdg_activation::XdgActivationState;
use smithay::wayland::xdg_foreign::XdgForeignState; use smithay::wayland::xdg_foreign::XdgForeignState;
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconManager; use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconManager;
@@ -571,6 +572,7 @@ pub struct Ferese {
pub idle_inhibit_state: IdleInhibitManagerState, pub idle_inhibit_state: IdleInhibitManagerState,
pub idle_notifier_state: IdleNotifierState<Self>, pub idle_notifier_state: IdleNotifierState<Self>,
pub input_method_manager_state: InputMethodManagerState, pub input_method_manager_state: InputMethodManagerState,
pub virtual_keyboard_manager_state: VirtualKeyboardManagerState,
pub keyboard_shortcuts_inhibit_state: KeyboardShortcutsInhibitState, pub keyboard_shortcuts_inhibit_state: KeyboardShortcutsInhibitState,
pub output_manager_state: OutputManagerState, pub output_manager_state: OutputManagerState,
pub pointer_constraints_state: PointerConstraintsState, 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"); std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1");
let input_method_manager_state = let input_method_manager_state =
InputMethodManagerState::new::<Self, _>(&display_handle, move |_| input_method_enabled); InputMethodManagerState::new::<Self, _>(&display_handle, move |_| input_method_enabled);
let virtual_keyboard_manager_state =
VirtualKeyboardManagerState::new::<Self, _>(&display_handle, move |_| {
input_method_enabled
});
let keyboard_shortcuts_inhibit_state = let keyboard_shortcuts_inhibit_state =
KeyboardShortcutsInhibitState::new::<Self>(&display_handle); KeyboardShortcutsInhibitState::new::<Self>(&display_handle);
let shortcut_inhibit_enabled = let shortcut_inhibit_enabled =
@@ -893,6 +899,7 @@ impl Ferese {
idle_inhibit_state, idle_inhibit_state,
idle_notifier_state, idle_notifier_state,
input_method_manager_state, input_method_manager_state,
virtual_keyboard_manager_state,
keyboard_shortcuts_inhibit_state, keyboard_shortcuts_inhibit_state,
output_manager_state, output_manager_state,
pointer_constraints_state, pointer_constraints_state,
+16
View File
@@ -132,6 +132,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
pointer = ?state.seat.get_pointer().map(|pointer| pointer.current_location()), pointer = ?state.seat.get_pointer().map(|pointer| pointer.current_location()),
"host input in nested output"); "host input in nested output");
} }
WinitEvent::Focus(false) => state.release_nested_keyboard(),
WinitEvent::Redraw => { WinitEvent::Redraw => {
if !drawable.get() || !usable_size(backend.window_size()) { if !drawable.get() || !usable_size(backend.window_size()) {
drawable.set(false); drawable.set(false);
@@ -251,6 +252,21 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
Ok(()) 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) { fn configure_nested_protocols(state: &mut Ferese) {
// Winit's swap has no host presentation timestamp or retrace counter. // Winit's swap has no host presentation timestamp or retrace counter.
// Do not advertise timing we cannot report accurately. // Do not advertise timing we cannot report accurately.
+1 -1
View File
@@ -216,7 +216,7 @@ pub fn default_tokens() -> Tokens {
"material":{"style":"solid","opacity":0.78,"blur_radius":12.0,"tint_strength":0.5}, "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, "geometry":{"border_width":1.0,"focus_ring_width":2.0,"window_radius":14.0,"shell_radius":14.0,
"control_gap":12.0}, "control_gap":12.0},
"typography":{"font_family":"Inter"}, "typography":{"font_family":"MiSans"},
"background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"}, "background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"},
"surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}}, "surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}},
"shadow":{"soft":{"offset_y":4.0,"blur":18.0,"opacity":0.2}}, "shadow":{"soft":{"offset_y":4.0,"blur":18.0,"opacity":0.2}},
+35
View File
@@ -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"
+13
View File
@@ -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");
}
+159
View File
@@ -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<manager::ZwpVirtualKeyboardManagerV1>,
seat: Option<wl_seat::WlSeat>,
ferese: bool,
outputs: Vec<String>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wl_output::WlOutput, ()> for State {
fn event(
state: &mut Self,
_: &wl_output::WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<dyn Error>> {
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<u32>> = 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::<Result<Vec<_>, _>>()?;
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(())
}
+179
View File
@@ -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<Candidate>,
}
#[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<std::rc::Rc<()>>,
}
impl Engine {
pub fn open(user: &Path, shared: &Path, schema: &str) -> Result<Self, String> {
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));
}
}
+708
View File
@@ -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<wp_viewport::WpViewport>,
fractional: Option<wp_fractional_scale_v1::WpFractionalScaleV1>,
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<Colors>,
compositor: Option<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>,
seat: Option<wl_seat::WlSeat>,
manager: Option<manager::ZwpInputMethodManagerV2>,
vk_manager: Option<vk_manager::ZwpVirtualKeyboardManagerV1>,
viewporter: Option<wp_viewporter::WpViewporter>,
fractional: Option<wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1>,
method: Option<method::ZwpInputMethodV2>,
virtual_keyboard: Option<vk::ZwpVirtualKeyboardV1>,
grab: Option<grab::ZwpInputMethodKeyboardGrabV2>,
xkb: Option<xkb::State>,
popup: Option<Popup>,
buffers: Vec<wl_buffer::WlBuffer>,
routes: HashMap<u32, bool>,
serial: u32,
active: bool,
pending_active: bool,
sensitive: bool,
pending_sensitive: bool,
last: Snapshot,
error: Option<String>,
repeat: Option<Repeat>,
repeat_rate: i32,
repeat_delay: i32,
}
impl State {
fn initialize(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
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>) {
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<Self>) {
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<Self>) {
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<Self>) -> Result<(), Box<dyn Error>> {
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<Self>) {
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<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<method::ZwpInputMethodV2, ()> for State {
fn event(
state: &mut Self,
_: &method::ZwpInputMethodV2,
event: method::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<grab::ZwpInputMethodKeyboardGrabV2, ()> for State {
fn event(
state: &mut Self,
_: &grab::ZwpInputMethodKeyboardGrabV2,
event: grab::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wl_surface::WlSurface, ()> for State {
fn event(
state: &mut Self,
surface: &wl_surface::WlSurface,
event: wl_surface::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wp_fractional_scale_v1::WpFractionalScaleV1, ()> for State {
fn event(
state: &mut Self,
object: &wp_fractional_scale_v1::WpFractionalScaleV1,
event: wp_fractional_scale_v1::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wl_buffer::WlBuffer, ()> for State {
fn event(
state: &mut Self,
buffer: &wl_buffer::WlBuffer,
event: wl_buffer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<dyn Error>> {
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(());
}
}
}
}
+363
View File
@@ -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<FontArc>,
pub colors: Colors,
}
impl Panel {
pub fn new() -> Result<Self, String> {
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::<Vec<_>>();
let width = (items.iter().map(|item| item.3).sum::<f32>() + 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::<String>();
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);
}
}
+73
View File
@@ -0,0 +1,73 @@
#include <rime_api.h>
#include <stdlib.h>
#include <string.h>
/* 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;
}
+6
View File
@@ -12,6 +12,12 @@ environment for screenshots to work.
Text input is available by default; Ctrl+Alt+Escape releases an active shortcut Text input is available by default; Ctrl+Alt+Escape releases an active shortcut
inhibitor. Use `feresectl --help` for runtime control commands. 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. Shell rounding is controlled in Settings → Panel & Shell → Shell corner radius.
The shell and compositor use the same squircle profile for fills, borders, shadows 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 and blur masks. Small controls cap the radius to fit their size; circles and pills
+107
View File
@@ -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/<uid>/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` 仍沿用上游“受信任会话客户端”的许可边界,
专用私有连接授权属于后续工作。
后续可以继续复用这套独立进程边界,逐步补齐候选交互和会话管理。
+190
View File
@@ -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]()
+37
View File
@@ -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([]);
+185
View File
@@ -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()