Add isolated native Rime service and MiSans candidate UI
This commit is contained in:
1 parent
f21167e1a2
commit
8e9a5838ab
28 files changed
+2381
-17
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
/target/
|
/target/
|
||||||
|
/.artifacts/
|
||||||
/tools/
|
/tools/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
Generated
+20
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
[Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots)
|
[Screenshots](https://github.com/ferese-wm/ferese/wiki/Screenshots)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,17 +542,19 @@ pub(super) fn rounded_window_elements(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
constrain_render_elements(
|
input_popups.extend(
|
||||||
content,
|
constrain_render_elements(
|
||||||
location,
|
content,
|
||||||
physical_constrain,
|
location,
|
||||||
reference,
|
physical_constrain,
|
||||||
behavior,
|
reference,
|
||||||
ConstrainAlign::TOP | ConstrainAlign::LEFT,
|
behavior,
|
||||||
scale,
|
ConstrainAlign::TOP | ConstrainAlign::LEFT,
|
||||||
)
|
scale,
|
||||||
.map(Into::into)
|
)
|
||||||
.collect()
|
.map(Into::into),
|
||||||
|
);
|
||||||
|
input_popups
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior {
|
pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScaleBehavior {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}},
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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` 仍沿用上游“受信任会话客户端”的许可边界,
|
||||||
|
专用私有连接授权属于后续工作。
|
||||||
|
|
||||||
|
后续可以继续复用这套独立进程边界,逐步补齐候选交互和会话管理。
|
||||||
@@ -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]()
|
||||||
Vendored
+37
@@ -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([]);
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user