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 -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;
}