Add isolated native Rime service and MiSans candidate UI

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

No files matched your search

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