Add isolated native Rime service and MiSans candidate UI
This commit is contained in:
1 parent
f21167e1a2
commit
8e9a5838ab
28 files changed
+2381
-17
No files matched your search
@@ -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}},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "ferese-rime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ab_glyph = "0.2.32"
|
||||
fontdb = "0.23.0"
|
||||
ferese-ipc = { path = "../ferese-ipc" }
|
||||
libc.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
tiny-skia = "0.11.4"
|
||||
wayland-client.workspace = true
|
||||
wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
|
||||
wayland-protocols-misc = { version = "0.3.12", features = ["client"] }
|
||||
xkbcommon = "0.9.0"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
pkg-config = "0.3"
|
||||
|
||||
[[bin]]
|
||||
name = "ferese-rime"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ferese-rime-probe"
|
||||
path = "src/bin/probe.rs"
|
||||
@@ -0,0 +1,13 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/rime_shim.c");
|
||||
let library = pkg_config::Config::new()
|
||||
.atleast_version("1.8")
|
||||
.probe("rime")
|
||||
.expect("librime development headers are required");
|
||||
let mut build = cc::Build::new();
|
||||
build.file("src/rime_shim.c");
|
||||
for path in library.include_paths {
|
||||
build.include(path);
|
||||
}
|
||||
build.compile("ferese_rime_shim");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Integration-test input source. Refuses physical sessions and non-Ferese
|
||||
//! servers before creating a keyboard; never injects into the host desktop.
|
||||
use std::error::Error;
|
||||
use std::io::{Seek, Write};
|
||||
use std::os::fd::AsFd;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::{wl_output, wl_registry, wl_seat};
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1 as manager, zwp_virtual_keyboard_v1 as keyboard,
|
||||
};
|
||||
use xkbcommon::xkb;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
manager: Option<manager::ZwpVirtualKeyboardManagerV1>,
|
||||
seat: Option<wl_seat::WlSeat>,
|
||||
ferese: bool,
|
||||
outputs: Vec<String>,
|
||||
}
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"ferese_material_manager_v1" => state.ferese = true,
|
||||
"zwp_virtual_keyboard_manager_v1" => {
|
||||
state.manager = Some(registry.bind(name, 1, qh, ()))
|
||||
}
|
||||
"wl_seat" => state.seat = Some(registry.bind(name, version.min(9), qh, ())),
|
||||
"wl_output" if version >= 4 => {
|
||||
let _: wl_output::WlOutput = registry.bind(name, 4, qh, ());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Dispatch<wl_output::WlOutput, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &wl_output::WlOutput,
|
||||
event: wl_output::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_output::Event::Name { name } = event {
|
||||
state.outputs.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||
delegate_noop!(State: ignore manager::ZwpVirtualKeyboardManagerV1);
|
||||
delegate_noop!(State: ignore keyboard::ZwpVirtualKeyboardV1);
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") {
|
||||
return Err(
|
||||
"test input requires FERESE_TEST_INPUT=1 and an isolated Ferese nested display".into(),
|
||||
);
|
||||
}
|
||||
let mut sequences: Vec<Vec<u32>> = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--keys" => {
|
||||
for character in args.next().ok_or("missing text")?.chars() {
|
||||
sequences.push(vec![character as u32]);
|
||||
}
|
||||
}
|
||||
"--key" => sequences.push(vec![
|
||||
xkb::keysym_from_name(&args.next().ok_or("missing key")?, xkb::KEYSYM_NO_FLAGS)
|
||||
.raw(),
|
||||
]),
|
||||
"--chord" => sequences.push(
|
||||
args.next()
|
||||
.ok_or("missing chord")?
|
||||
.split('+')
|
||||
.map(|name| xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw())
|
||||
.collect(),
|
||||
),
|
||||
_ => return Err(format!("unknown test input argument {arg}").into()),
|
||||
}
|
||||
}
|
||||
let connection = Connection::connect_to_env()?;
|
||||
let mut queue = connection.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = connection.display().get_registry(&qh, ());
|
||||
let mut state = State::default();
|
||||
queue.roundtrip(&mut state)?;
|
||||
queue.roundtrip(&mut state)?;
|
||||
if !state.ferese
|
||||
|| state.outputs.is_empty()
|
||||
|| state.outputs.iter().any(|name| name != "ferese-winit")
|
||||
{
|
||||
return Err("refusing input: target is not an isolated Ferese nested output".into());
|
||||
}
|
||||
let keyboard = state
|
||||
.manager
|
||||
.as_ref()
|
||||
.ok_or("virtual keyboard protocol missing")?
|
||||
.create_virtual_keyboard(state.seat.as_ref().ok_or("seat missing")?, &qh, ());
|
||||
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||
let keymap = xkb::Keymap::new_from_names(
|
||||
&context,
|
||||
"",
|
||||
"",
|
||||
"us",
|
||||
"",
|
||||
None,
|
||||
xkb::KEYMAP_COMPILE_NO_FLAGS,
|
||||
)
|
||||
.ok_or("US test keymap missing")?;
|
||||
let xkb = xkb::State::new(&keymap);
|
||||
let text = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1) + "\0";
|
||||
let mut file = tempfile::tempfile()?;
|
||||
file.write_all(text.as_bytes())?;
|
||||
file.rewind()?;
|
||||
keyboard.keymap(1, file.as_fd(), text.len() as u32);
|
||||
queue.roundtrip(&mut state)?;
|
||||
let started = Instant::now();
|
||||
for sequence in sequences {
|
||||
let codes = sequence
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
(keymap.min_keycode().raw()..=keymap.max_keycode().raw())
|
||||
.find(|code| xkb.key_get_one_sym(xkb::Keycode::new(*code)).raw() == *symbol)
|
||||
.map(|code| code - 8)
|
||||
.ok_or_else(|| format!("test keysym {symbol:#x} has no US keycode"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for key in &codes {
|
||||
keyboard.key(started.elapsed().as_millis() as u32, *key, 1);
|
||||
queue.roundtrip(&mut state)?;
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
}
|
||||
for key in codes.iter().rev() {
|
||||
keyboard.key(started.elapsed().as_millis() as u32, *key, 0);
|
||||
queue.roundtrip(&mut state)?;
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
}
|
||||
}
|
||||
keyboard.destroy();
|
||||
queue.roundtrip(&mut state)?;
|
||||
println!("nested test input complete");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
pub mod panel;
|
||||
|
||||
/// The number-row paging keys are local to a composition with candidates.
|
||||
/// Control/Alt/Super shortcuts and literal ASCII typing retain their symbols.
|
||||
#[must_use]
|
||||
pub fn paging_key(key: u32, mask: u32, has_menu: bool) -> (u32, u32, bool) {
|
||||
if has_menu && mask & ((1 << 2) | (1 << 3) | (1 << 26)) == 0 {
|
||||
let page = match key {
|
||||
0x2d => Some(0xff55),
|
||||
0x3d | 0x2b => Some(0xff56),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(page) = page {
|
||||
return (page, mask & !1, true);
|
||||
}
|
||||
}
|
||||
(key, mask, false)
|
||||
}
|
||||
|
||||
use std::ffi::{CStr, CString, c_char, c_int};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Candidate {
|
||||
pub text: String,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Snapshot {
|
||||
pub accepted: bool,
|
||||
pub ascii: bool,
|
||||
pub commit: String,
|
||||
pub preedit: String,
|
||||
/// Byte offset, matching text-input-v3 (not a character count).
|
||||
pub cursor: i32,
|
||||
pub highlighted: usize,
|
||||
pub page: usize,
|
||||
pub last_page: bool,
|
||||
pub candidates: Vec<Candidate>,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct RawResult {
|
||||
accepted: c_int,
|
||||
ascii_mode: c_int,
|
||||
cursor: c_int,
|
||||
highlighted: c_int,
|
||||
page_no: c_int,
|
||||
last_page: c_int,
|
||||
count: c_int,
|
||||
commit: *mut c_char,
|
||||
preedit: *mut c_char,
|
||||
texts: [*mut c_char; 9],
|
||||
comments: [*mut c_char; 9],
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ferese_rime_open(user: *const c_char, shared: *const c_char, schema: *const c_char)
|
||||
-> c_int;
|
||||
fn ferese_rime_snapshot(key: c_int, mask: c_int, out: *mut RawResult);
|
||||
fn ferese_rime_result_free(out: *mut RawResult);
|
||||
fn ferese_rime_clear();
|
||||
fn ferese_rime_close();
|
||||
}
|
||||
|
||||
static OPEN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// One librime owner, confined to its service thread/process.
|
||||
pub struct Engine {
|
||||
_paths: [CString; 3],
|
||||
_thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn open(user: &Path, shared: &Path, schema: &str) -> Result<Self, String> {
|
||||
if OPEN.swap(true, Ordering::SeqCst) {
|
||||
return Err("librime already initialized in this process".into());
|
||||
}
|
||||
let values = [
|
||||
user.to_string_lossy().into_owned(),
|
||||
shared.to_string_lossy().into_owned(),
|
||||
schema.to_owned(),
|
||||
];
|
||||
let paths = values.map(|s| CString::new(s).expect("paths cannot contain NUL"));
|
||||
// SAFETY: strings remain alive for this owner's entire lifetime. The C
|
||||
// shim uses the installed, versioned librime header and owns all handles.
|
||||
let ok =
|
||||
unsafe { ferese_rime_open(paths[0].as_ptr(), paths[1].as_ptr(), paths[2].as_ptr()) };
|
||||
if ok == 0 {
|
||||
// SAFETY: balances the partial initialization above.
|
||||
unsafe { ferese_rime_close() };
|
||||
OPEN.store(false, Ordering::SeqCst);
|
||||
return Err(format!("could not initialize Rime schema {schema}"));
|
||||
}
|
||||
Ok(Self {
|
||||
_paths: paths,
|
||||
_thread_bound: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn process(&mut self, key: u32, mask: u32) -> Snapshot {
|
||||
self.snapshot(key as i32, mask as i32)
|
||||
}
|
||||
|
||||
pub fn current(&mut self) -> Snapshot {
|
||||
self.snapshot(-1, 0)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
// SAFETY: this thread-bound owner has the only initialized session.
|
||||
unsafe { ferese_rime_clear() };
|
||||
}
|
||||
|
||||
fn snapshot(&mut self, key: i32, mask: i32) -> Snapshot {
|
||||
// SAFETY: all-zero is valid for this C struct's integers/null pointers;
|
||||
// the shim initializes and returns owned, NUL-terminated UTF-8 strings.
|
||||
let mut raw: RawResult = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: the shim writes the matching repr(C) structure synchronously.
|
||||
unsafe { ferese_rime_snapshot(key, mask, &mut raw) };
|
||||
fn text(pointer: *const c_char) -> String {
|
||||
if pointer.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: snapshot returns live NUL-terminated strings until free.
|
||||
unsafe { CStr::from_ptr(pointer) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
let preedit = text(raw.preedit);
|
||||
let mut cursor = (raw.cursor.max(0) as usize).min(preedit.len());
|
||||
while !preedit.is_char_boundary(cursor) {
|
||||
cursor -= 1;
|
||||
}
|
||||
let result = Snapshot {
|
||||
accepted: raw.accepted != 0,
|
||||
ascii: raw.ascii_mode != 0,
|
||||
commit: text(raw.commit),
|
||||
preedit,
|
||||
cursor: cursor as i32,
|
||||
highlighted: raw.highlighted.max(0) as usize,
|
||||
page: raw.page_no.max(0) as usize,
|
||||
last_page: raw.last_page != 0,
|
||||
candidates: (0..raw.count.clamp(0, 9) as usize)
|
||||
.map(|n| Candidate {
|
||||
text: text(raw.texts[n]),
|
||||
comment: text(raw.comments[n]),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// SAFETY: every returned allocation is copied above and freed once here.
|
||||
unsafe { ferese_rime_result_free(&mut raw) };
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Engine {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: unique thread-bound owner; balances successful initialization.
|
||||
unsafe { ferese_rime_close() };
|
||||
OPEN.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::paging_key;
|
||||
#[test]
|
||||
fn number_row_paging_preserves_ascii_and_modified_shortcuts() {
|
||||
assert_eq!(paging_key(0x2d, 0, true), (0xff55, 0, true));
|
||||
assert_eq!(paging_key(0x3d, 0, true), (0xff56, 0, true));
|
||||
assert_eq!(paging_key(0x2b, 1, true), (0xff56, 0, true));
|
||||
assert_eq!(paging_key(0x3d, 1 << 30, true), (0xff56, 1 << 30, true));
|
||||
assert_eq!(paging_key(0x2d, 0, false), (0x2d, 0, false));
|
||||
assert_eq!(paging_key(0x3d, 4, true), (0x3d, 4, false));
|
||||
assert_eq!(paging_key(0x2b, 1, false), (0x2b, 1, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
//! Independent native Wayland IME. librime and candidate rasterization never
|
||||
//! execute on Ferese's compositor thread.
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ferese_rime::{
|
||||
Engine, Snapshot,
|
||||
panel::{Colors, Panel},
|
||||
};
|
||||
use wayland_client::protocol::{
|
||||
wl_buffer, wl_compositor, wl_keyboard, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface,
|
||||
};
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle, WEnum, delegate_noop};
|
||||
use wayland_protocols::wp::{
|
||||
fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1},
|
||||
viewporter::client::{wp_viewport, wp_viewporter},
|
||||
};
|
||||
use wayland_protocols_misc::zwp_input_method_v2::client::{
|
||||
zwp_input_method_keyboard_grab_v2 as grab, zwp_input_method_manager_v2 as manager,
|
||||
zwp_input_method_v2 as method, zwp_input_popup_surface_v2 as popup,
|
||||
};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1 as vk_manager, zwp_virtual_keyboard_v1 as vk,
|
||||
};
|
||||
use xkbcommon::xkb;
|
||||
|
||||
struct Popup {
|
||||
surface: wl_surface::WlSurface,
|
||||
role: popup::ZwpInputPopupSurfaceV2,
|
||||
viewport: Option<wp_viewport::WpViewport>,
|
||||
fractional: Option<wp_fractional_scale_v1::WpFractionalScaleV1>,
|
||||
scale: f64,
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
self.role.destroy();
|
||||
if let Some(value) = &self.fractional {
|
||||
value.destroy();
|
||||
}
|
||||
if let Some(value) = &self.viewport {
|
||||
value.destroy();
|
||||
}
|
||||
self.surface.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
struct Repeat {
|
||||
key: u32,
|
||||
next: Instant,
|
||||
}
|
||||
|
||||
struct State {
|
||||
engine: Engine,
|
||||
panel: Panel,
|
||||
theme: Receiver<Colors>,
|
||||
compositor: Option<wl_compositor::WlCompositor>,
|
||||
shm: Option<wl_shm::WlShm>,
|
||||
seat: Option<wl_seat::WlSeat>,
|
||||
manager: Option<manager::ZwpInputMethodManagerV2>,
|
||||
vk_manager: Option<vk_manager::ZwpVirtualKeyboardManagerV1>,
|
||||
viewporter: Option<wp_viewporter::WpViewporter>,
|
||||
fractional: Option<wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1>,
|
||||
method: Option<method::ZwpInputMethodV2>,
|
||||
virtual_keyboard: Option<vk::ZwpVirtualKeyboardV1>,
|
||||
grab: Option<grab::ZwpInputMethodKeyboardGrabV2>,
|
||||
xkb: Option<xkb::State>,
|
||||
popup: Option<Popup>,
|
||||
buffers: Vec<wl_buffer::WlBuffer>,
|
||||
routes: HashMap<u32, bool>,
|
||||
serial: u32,
|
||||
active: bool,
|
||||
pending_active: bool,
|
||||
sensitive: bool,
|
||||
pending_sensitive: bool,
|
||||
last: Snapshot,
|
||||
error: Option<String>,
|
||||
repeat: Option<Repeat>,
|
||||
repeat_rate: i32,
|
||||
repeat_delay: i32,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn initialize(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
let manager = self
|
||||
.manager
|
||||
.as_ref()
|
||||
.ok_or("input-method-v2 is not available; enable FERESE_ENABLE_INPUT_METHOD=1")?;
|
||||
let keyboard = self
|
||||
.vk_manager
|
||||
.as_ref()
|
||||
.ok_or("virtual-keyboard-v1 is not available")?;
|
||||
let seat = self.seat.as_ref().ok_or("Wayland seat is not available")?;
|
||||
if self.compositor.is_none() || self.shm.is_none() {
|
||||
return Err("Wayland compositor/SHM is missing".into());
|
||||
}
|
||||
self.method = Some(manager.get_input_method(seat, qh, ()));
|
||||
self.virtual_keyboard = Some(keyboard.create_virtual_keyboard(seat, qh, ()));
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"event":"ready", "pid":std::process::id(), "framework":"native-wayland+librime"})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn activate(&mut self, qh: &QueueHandle<Self>) {
|
||||
self.active = self.pending_active;
|
||||
self.sensitive = self.pending_sensitive;
|
||||
if !self.active || self.sensitive {
|
||||
self.engine.clear();
|
||||
self.last = Snapshot::default();
|
||||
self.repeat = None;
|
||||
self.popup = None;
|
||||
if let Some(grab) = self.grab.take() {
|
||||
grab.release();
|
||||
}
|
||||
self.routes.clear();
|
||||
return;
|
||||
}
|
||||
if self.grab.is_some() {
|
||||
return;
|
||||
}
|
||||
self.engine.clear();
|
||||
self.last = self.engine.current();
|
||||
self.grab = Some(self.method.as_ref().unwrap().grab_keyboard(qh, ()));
|
||||
let surface = self.compositor.as_ref().unwrap().create_surface(qh, ());
|
||||
let viewport = self
|
||||
.viewporter
|
||||
.as_ref()
|
||||
.map(|manager| manager.get_viewport(&surface, qh, ()));
|
||||
let fractional = if viewport.is_some() {
|
||||
self.fractional
|
||||
.as_ref()
|
||||
.map(|manager| manager.get_fractional_scale(&surface, qh, ()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let role = self
|
||||
.method
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_input_popup_surface(&surface, qh, ());
|
||||
self.popup = Some(Popup {
|
||||
surface,
|
||||
role,
|
||||
viewport,
|
||||
fractional,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
fn mask(&self) -> u32 {
|
||||
let Some(state) = &self.xkb else {
|
||||
return 0;
|
||||
};
|
||||
let mut result = 0;
|
||||
for (name, bit) in [
|
||||
("Shift", 0),
|
||||
("Lock", 1),
|
||||
("Control", 2),
|
||||
("Mod1", 3),
|
||||
("Mod4", 26),
|
||||
] {
|
||||
if state.mod_name_is_active(name, xkb::STATE_MODS_EFFECTIVE) {
|
||||
result |= 1 << bit;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn key(&mut self, time: u32, key: u32, pressed: bool, qh: &QueueHandle<Self>) {
|
||||
if self.sensitive || !self.active {
|
||||
return;
|
||||
}
|
||||
let Some(xkb) = &self.xkb else {
|
||||
return;
|
||||
};
|
||||
let keysym = xkb.key_get_one_sym(xkb::Keycode::new(key + 8)).raw();
|
||||
let repeatable = xkb.get_keymap().key_repeats(xkb::Keycode::new(key + 8));
|
||||
let mask = self.mask() | if pressed { 0 } else { 1 << 30 };
|
||||
let (keysym, mask, paging) =
|
||||
ferese_rime::paging_key(keysym, mask, !self.last.candidates.is_empty());
|
||||
let mut snapshot = self.engine.process(keysym, mask);
|
||||
if paging {
|
||||
snapshot.accepted = true;
|
||||
}
|
||||
let forward = if pressed {
|
||||
let forward = !snapshot.accepted;
|
||||
self.routes.insert(key, forward);
|
||||
if !forward && repeatable && self.repeat_rate > 0 {
|
||||
self.repeat = Some(Repeat {
|
||||
key,
|
||||
next: Instant::now() + Duration::from_millis(self.repeat_delay.max(1) as u64),
|
||||
});
|
||||
}
|
||||
forward
|
||||
} else {
|
||||
if self.repeat.as_ref().is_some_and(|repeat| repeat.key == key) {
|
||||
self.repeat = None;
|
||||
}
|
||||
self.routes.remove(&key).unwrap_or(false)
|
||||
};
|
||||
self.publish(snapshot, qh);
|
||||
if forward {
|
||||
self.virtual_keyboard
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.key(time, key, u32::from(pressed));
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(&mut self, snapshot: Snapshot, qh: &QueueHandle<Self>) {
|
||||
let method = self.method.as_ref().unwrap();
|
||||
if !snapshot.commit.is_empty() {
|
||||
method.commit_string(snapshot.commit.clone());
|
||||
}
|
||||
method.set_preedit_string(snapshot.preedit.clone(), snapshot.cursor, snapshot.cursor);
|
||||
method.commit(self.serial);
|
||||
let visible = !snapshot.preedit.is_empty();
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"event":"composition", "preedit_chars":snapshot.preedit.chars().count(),
|
||||
"candidates":snapshot.candidates.len(), "committed_chars":snapshot.commit.chars().count(), "ascii":snapshot.ascii, "page":snapshot.page})
|
||||
);
|
||||
self.last = snapshot;
|
||||
if !visible {
|
||||
self.repeat = None;
|
||||
}
|
||||
if let Err(error) = self.draw(qh) {
|
||||
self.error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
let Some(popup) = &self.popup else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.last.preedit.is_empty() {
|
||||
popup.surface.attach(None, 0, 0);
|
||||
popup.surface.commit();
|
||||
return Ok(());
|
||||
}
|
||||
let scale = if popup.viewport.is_some() {
|
||||
popup.scale
|
||||
} else {
|
||||
popup.scale.ceil()
|
||||
};
|
||||
let (image, width, height) = self.panel.render(&self.last, scale)?;
|
||||
let mut bytes = image.data().to_vec();
|
||||
// tiny-skia uses premultiplied RGBA; wl_shm ARGB8888 is BGRA on LE.
|
||||
for pixel in bytes.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
let mut file = tempfile::tempfile()?;
|
||||
file.write_all(&bytes)?;
|
||||
let pool = self
|
||||
.shm
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.create_pool(file.as_fd(), bytes.len() as i32, qh, ());
|
||||
let buffer = pool.create_buffer(
|
||||
0,
|
||||
image.width() as i32,
|
||||
image.height() as i32,
|
||||
image.width() as i32 * 4,
|
||||
wl_shm::Format::Argb8888,
|
||||
qh,
|
||||
(),
|
||||
);
|
||||
pool.destroy();
|
||||
if let Some(viewport) = &popup.viewport {
|
||||
popup.surface.set_buffer_scale(1);
|
||||
viewport.set_destination(width, height);
|
||||
} else {
|
||||
popup.surface.set_buffer_scale(scale as i32);
|
||||
}
|
||||
popup.surface.attach(Some(&buffer), 0, 0);
|
||||
popup
|
||||
.surface
|
||||
.damage_buffer(0, 0, image.width() as i32, image.height() as i32);
|
||||
popup.surface.commit();
|
||||
self.buffers.push(buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tick(&mut self, qh: &QueueHandle<Self>) {
|
||||
let mut changed = false;
|
||||
while let Ok(colors) = self.theme.try_recv() {
|
||||
self.panel.colors = colors;
|
||||
changed = true;
|
||||
}
|
||||
if changed && let Err(error) = self.draw(qh) {
|
||||
self.error = Some(error.to_string());
|
||||
}
|
||||
if let Some(repeat) = &self.repeat
|
||||
&& repeat.next <= Instant::now()
|
||||
{
|
||||
let key = repeat.key;
|
||||
let keysym = self
|
||||
.xkb
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.key_get_one_sym(xkb::Keycode::new(key + 8))
|
||||
.raw();
|
||||
let (keysym, mask, paging) =
|
||||
ferese_rime::paging_key(keysym, self.mask(), !self.last.candidates.is_empty());
|
||||
let mut snapshot = self.engine.process(keysym, mask);
|
||||
if paging {
|
||||
snapshot.accepted = true;
|
||||
}
|
||||
if snapshot.accepted {
|
||||
self.repeat.as_mut().unwrap().next =
|
||||
Instant::now() + Duration::from_secs_f64(1. / self.repeat_rate.max(1) as f64);
|
||||
} else {
|
||||
self.repeat = None;
|
||||
}
|
||||
self.publish(snapshot, qh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"wl_compositor" => {
|
||||
state.compositor = Some(registry.bind(name, version.min(6), qh, ()))
|
||||
}
|
||||
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
|
||||
"wl_seat" if state.seat.is_none() => {
|
||||
state.seat = Some(registry.bind(name, version.min(9), qh, ()))
|
||||
}
|
||||
"zwp_input_method_manager_v2" => {
|
||||
state.manager = Some(registry.bind(name, 1, qh, ()))
|
||||
}
|
||||
"zwp_virtual_keyboard_manager_v1" => {
|
||||
state.vk_manager = Some(registry.bind(name, 1, qh, ()))
|
||||
}
|
||||
"wp_viewporter" => state.viewporter = Some(registry.bind(name, 1, qh, ())),
|
||||
"wp_fractional_scale_manager_v1" => {
|
||||
state.fractional = Some(registry.bind(name, 1, qh, ()))
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<method::ZwpInputMethodV2, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &method::ZwpInputMethodV2,
|
||||
event: method::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
method::Event::Activate => {
|
||||
state.pending_active = true;
|
||||
state.pending_sensitive = false;
|
||||
}
|
||||
method::Event::Deactivate => state.pending_active = false,
|
||||
method::Event::ContentType { hint, purpose } => {
|
||||
let hint: u32 = hint.into();
|
||||
let purpose: u32 = purpose.into();
|
||||
state.pending_sensitive = purpose == 8 || purpose == 9 || hint & (0x40 | 0x80) != 0;
|
||||
}
|
||||
method::Event::Done => {
|
||||
state.serial = state.serial.wrapping_add(1);
|
||||
state.activate(qh);
|
||||
}
|
||||
method::Event::Unavailable => {
|
||||
state.error = Some("another input method owns this seat".into())
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<grab::ZwpInputMethodKeyboardGrabV2, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &grab::ZwpInputMethodKeyboardGrabV2,
|
||||
event: grab::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
grab::Event::Keymap {
|
||||
format: WEnum::Value(wl_keyboard::KeymapFormat::XkbV1),
|
||||
fd,
|
||||
size,
|
||||
} => {
|
||||
let mut file = File::from(fd);
|
||||
let mut bytes = Vec::new();
|
||||
if size > 4 * 1024 * 1024
|
||||
|| file.rewind().is_err()
|
||||
|| file.read_to_end(&mut bytes).is_err()
|
||||
{
|
||||
state.error = Some("invalid input-method keymap".into());
|
||||
return;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes)
|
||||
.trim_end_matches('\0')
|
||||
.to_owned();
|
||||
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||
if let Some(keymap) = xkb::Keymap::new_from_string(
|
||||
&context,
|
||||
text,
|
||||
xkb::KEYMAP_FORMAT_TEXT_V1,
|
||||
xkb::KEYMAP_COMPILE_NO_FLAGS,
|
||||
) {
|
||||
state.xkb = Some(xkb::State::new(&keymap));
|
||||
let _ = file.rewind();
|
||||
state
|
||||
.virtual_keyboard
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.keymap(1, file.as_fd(), size);
|
||||
} else {
|
||||
state.error = Some("could not parse input-method keymap".into());
|
||||
}
|
||||
}
|
||||
grab::Event::Modifiers {
|
||||
mods_depressed,
|
||||
mods_latched,
|
||||
mods_locked,
|
||||
group,
|
||||
..
|
||||
} => {
|
||||
if let Some(xkb) = &mut state.xkb {
|
||||
xkb.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group);
|
||||
}
|
||||
state.virtual_keyboard.as_ref().unwrap().modifiers(
|
||||
mods_depressed,
|
||||
mods_latched,
|
||||
mods_locked,
|
||||
group,
|
||||
);
|
||||
}
|
||||
grab::Event::Key {
|
||||
time,
|
||||
key,
|
||||
state: key_state,
|
||||
..
|
||||
} => state.key(
|
||||
time,
|
||||
key,
|
||||
key_state == WEnum::Value(wl_keyboard::KeyState::Pressed),
|
||||
qh,
|
||||
),
|
||||
grab::Event::RepeatInfo { rate, delay } => {
|
||||
state.repeat_rate = rate;
|
||||
state.repeat_delay = delay;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_surface::WlSurface, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
surface: &wl_surface::WlSurface,
|
||||
event: wl_surface::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let Some(popup) = &mut state.popup
|
||||
&& &popup.surface == surface
|
||||
&& popup.fractional.is_none()
|
||||
&& let wl_surface::Event::PreferredBufferScale { factor } = event
|
||||
{
|
||||
popup.scale = factor.max(1) as f64;
|
||||
if let Err(error) = state.draw(qh) {
|
||||
state.error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Dispatch<wp_fractional_scale_v1::WpFractionalScaleV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
object: &wp_fractional_scale_v1::WpFractionalScaleV1,
|
||||
event: wp_fractional_scale_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event
|
||||
&& let Some(popup) = &mut state.popup
|
||||
&& popup.fractional.as_ref() == Some(object)
|
||||
{
|
||||
popup.scale = scale as f64 / 120.;
|
||||
if let Err(error) = state.draw(qh) {
|
||||
state.error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Dispatch<wl_buffer::WlBuffer, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
buffer: &wl_buffer::WlBuffer,
|
||||
event: wl_buffer::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_buffer::Event::Release = event {
|
||||
buffer.destroy();
|
||||
state.buffers.retain(|value| value != buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate_noop!(State: ignore wl_compositor::WlCompositor);
|
||||
delegate_noop!(State: ignore wl_shm::WlShm);
|
||||
delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
|
||||
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||
delegate_noop!(State: ignore manager::ZwpInputMethodManagerV2);
|
||||
delegate_noop!(State: ignore popup::ZwpInputPopupSurfaceV2);
|
||||
delegate_noop!(State: ignore vk_manager::ZwpVirtualKeyboardManagerV1);
|
||||
delegate_noop!(State: ignore vk::ZwpVirtualKeyboardV1);
|
||||
delegate_noop!(State: ignore wp_viewporter::WpViewporter);
|
||||
delegate_noop!(State: ignore wp_viewport::WpViewport);
|
||||
delegate_noop!(State: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1);
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut user = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|path| PathBuf::from(path).join(".local/share")))
|
||||
.ok_or("no user data directory")?
|
||||
.join("ferese/rime");
|
||||
let mut shared = PathBuf::from("/usr/share/rime-data");
|
||||
let mut schema = "luna_pinyin_simp".to_owned();
|
||||
let mut self_test = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--user-data" => user = args.next().ok_or("missing user data path")?.into(),
|
||||
"--shared-data" => shared = args.next().ok_or("missing shared data path")?.into(),
|
||||
"--schema" => schema = args.next().ok_or("missing Rime schema")?,
|
||||
"--self-test" => self_test = true,
|
||||
"--version" => {
|
||||
println!("ferese-rime {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" => {
|
||||
println!(
|
||||
"ferese-rime [--user-data PATH] [--shared-data PATH] [--schema NAME] [--self-test]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Err(format!("unknown option {arg}").into()),
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&user)?;
|
||||
std::fs::set_permissions(&user, std::fs::Permissions::from_mode(0o700))?;
|
||||
let mut engine = Engine::open(&user, &shared, &schema)?;
|
||||
if self_test {
|
||||
let mut committed = String::new();
|
||||
let mut candidates = 0;
|
||||
for key in "nihao ".bytes() {
|
||||
let snapshot = engine.process(key as u32, 0);
|
||||
candidates = candidates.max(snapshot.candidates.len());
|
||||
committed.push_str(&snapshot.commit);
|
||||
committed.push_str(&engine.process(key as u32, 1 << 30).commit);
|
||||
}
|
||||
if committed != "你好" || candidates == 0 {
|
||||
return Err(
|
||||
format!("self-test failed: commit={committed:?}, candidates={candidates}").into(),
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"self_test":"passed", "commit":committed, "candidates":candidates})
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let panel = Panel::new()?;
|
||||
let (send, theme) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(mut connection) = ferese_ipc::Connection::connect() {
|
||||
let Ok(mut theme) = connection.get(Vec::new) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
if send.send(Colors::from_theme(&theme)).is_err() {
|
||||
return;
|
||||
}
|
||||
match connection.watch(theme.revision, Vec::new) {
|
||||
Ok(next) => theme = next,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
let connection = Connection::connect_to_env()?;
|
||||
let mut queue = connection.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = connection.display().get_registry(&qh, ());
|
||||
let mut state = State {
|
||||
engine,
|
||||
panel,
|
||||
theme,
|
||||
compositor: None,
|
||||
shm: None,
|
||||
seat: None,
|
||||
manager: None,
|
||||
vk_manager: None,
|
||||
viewporter: None,
|
||||
fractional: None,
|
||||
method: None,
|
||||
virtual_keyboard: None,
|
||||
grab: None,
|
||||
xkb: None,
|
||||
popup: None,
|
||||
buffers: Vec::new(),
|
||||
routes: HashMap::new(),
|
||||
serial: 0,
|
||||
active: false,
|
||||
pending_active: false,
|
||||
sensitive: false,
|
||||
pending_sensitive: false,
|
||||
last: Snapshot::default(),
|
||||
error: None,
|
||||
repeat: None,
|
||||
repeat_rate: 25,
|
||||
repeat_delay: 600,
|
||||
};
|
||||
queue.roundtrip(&mut state)?;
|
||||
state.initialize(&qh)?;
|
||||
loop {
|
||||
queue.dispatch_pending(&mut state)?;
|
||||
state.tick(&qh);
|
||||
if let Some(error) = state.error.take() {
|
||||
return Err(error.into());
|
||||
}
|
||||
let blocked = match connection.flush() {
|
||||
Ok(()) => false,
|
||||
Err(wayland_client::backend::WaylandError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock =>
|
||||
{
|
||||
true
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if let Some(read) = queue.prepare_read() {
|
||||
let mut descriptor = libc::pollfd {
|
||||
fd: connection.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN | if blocked { libc::POLLOUT } else { 0 },
|
||||
revents: 0,
|
||||
};
|
||||
let timeout = state.repeat.as_ref().map_or(200, |repeat| {
|
||||
repeat
|
||||
.next
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(200) as i32
|
||||
});
|
||||
// SAFETY: one initialized pollfd is passed for the duration of poll.
|
||||
let ready = unsafe { libc::poll(&mut descriptor, 1, timeout) };
|
||||
if ready < 0
|
||||
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
if ready > 0 && descriptor.revents & libc::POLLIN != 0 {
|
||||
match read.read() {
|
||||
Ok(_) => (),
|
||||
Err(wayland_client::backend::WaylandError::Io(error))
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
|
||||
) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
} else {
|
||||
drop(read);
|
||||
}
|
||||
if descriptor.revents & (libc::POLLERR | libc::POLLHUP) != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use ab_glyph::{Font, FontArc, FontVec, PxScale, ScaleFont, point};
|
||||
use tiny_skia::{Paint, PathBuilder, Pixmap, Transform};
|
||||
|
||||
use crate::Snapshot;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Colors {
|
||||
background: [u8; 4],
|
||||
text: [u8; 4],
|
||||
muted: [u8; 4],
|
||||
accent: [u8; 4],
|
||||
on_accent: [u8; 4],
|
||||
border: [u8; 4],
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
impl Default for Colors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background: [30, 37, 48, 255],
|
||||
text: [244, 247, 251, 255],
|
||||
muted: [135, 147, 162, 255],
|
||||
accent: [55, 111, 207, 255],
|
||||
on_accent: [244, 247, 251, 255],
|
||||
border: [255, 255, 255, 24],
|
||||
radius: 14.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
#[must_use]
|
||||
pub fn from_theme(snapshot: &ferese_ipc::theme::Snapshot) -> Self {
|
||||
let theme = &snapshot.theme.tokens;
|
||||
let tokens = &theme.colors;
|
||||
let parse = |value: &str, fallback| parse_color(value).unwrap_or(fallback);
|
||||
let fallback = Self::default();
|
||||
Self {
|
||||
background: parse(&tokens.surface_raised, fallback.background),
|
||||
text: parse(&tokens.text_primary, fallback.text),
|
||||
muted: parse(&tokens.text_muted, fallback.muted),
|
||||
accent: parse(&tokens.accent, fallback.accent),
|
||||
on_accent: parse(&tokens.on_accent, fallback.on_accent),
|
||||
border: parse(&tokens.border, fallback.border),
|
||||
radius: theme.geometry.shell_radius.clamp(0., 18.) as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(value: &str) -> Option<[u8; 4]> {
|
||||
let value = value.strip_prefix('#')?;
|
||||
if !value.is_ascii() || !matches!(value.len(), 6 | 8) {
|
||||
return None;
|
||||
}
|
||||
let mut result = [255; 4];
|
||||
for (n, channel) in result.iter_mut().enumerate().take(value.len() / 2) {
|
||||
*channel = u8::from_str_radix(&value[n * 2..n * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
pub struct Panel {
|
||||
fonts: Vec<FontArc>,
|
||||
pub colors: Colors,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let mut database = fontdb::Database::new();
|
||||
database.load_system_fonts();
|
||||
let mut fonts = Vec::new();
|
||||
let mut families = Vec::new();
|
||||
for family in [
|
||||
"MiSans",
|
||||
"MiSans TC",
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans",
|
||||
"DejaVu Sans",
|
||||
] {
|
||||
let Some(id) = database.query(&fontdb::Query {
|
||||
families: &[fontdb::Family::Name(family)],
|
||||
..Default::default()
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(Ok(font)) = database.with_face_data(id, |bytes, index| {
|
||||
FontVec::try_from_vec_and_index(bytes.to_vec(), index).map(FontArc::new)
|
||||
}) {
|
||||
fonts.push(font);
|
||||
families.push(family);
|
||||
}
|
||||
if fonts.len() >= 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if fonts.is_empty() {
|
||||
return Err("no candidate panel font found".into());
|
||||
}
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"event":"candidate-fonts", "families":families})
|
||||
);
|
||||
Ok(Self {
|
||||
fonts,
|
||||
colors: Colors::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A single horizontal row. The service owns rasterization, while Ferese
|
||||
/// owns input-popup placement and stacking. All sizes below are logical.
|
||||
pub fn render(&self, state: &Snapshot, scale: f64) -> Result<(Pixmap, i32, i32), String> {
|
||||
let scale = scale.clamp(1., 4.) as f32;
|
||||
let count = state.candidates.len().max(1);
|
||||
let maximum_cell = (960. - 28. - (count - 1) as f32 * 6.) / count as f32;
|
||||
let items = state
|
||||
.candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, candidate)| {
|
||||
let label = (n + 1).to_string();
|
||||
let word = self.fit(&candidate.text, 18., (maximum_cell - 40.).max(24.));
|
||||
let word_width = self.metrics(&word, 18.).0;
|
||||
let remaining = maximum_cell - 40. - word_width;
|
||||
let comment = if remaining > 24. {
|
||||
self.fit(&candidate.comment, 12., remaining - 7.)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let comment_width = if comment.is_empty() {
|
||||
0.
|
||||
} else {
|
||||
7. + self.metrics(&comment, 12.).0
|
||||
};
|
||||
let width = (34. + word_width + comment_width)
|
||||
.max(64.)
|
||||
.min(maximum_cell);
|
||||
(label, word, comment, width)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let width = (items.iter().map(|item| item.3).sum::<f32>() + 28. + (count - 1) as f32 * 6.)
|
||||
.max(280.)
|
||||
.ceil() as i32;
|
||||
let height = 88;
|
||||
let mut image = Pixmap::new(
|
||||
(width as f32 * scale).ceil() as u32,
|
||||
(height as f32 * scale).ceil() as u32,
|
||||
)
|
||||
.ok_or("invalid candidate panel size")?;
|
||||
let transform = Transform::from_scale(scale, scale);
|
||||
rounded(
|
||||
&mut image,
|
||||
(3., 5., width as f32 - 6., height as f32 - 8.),
|
||||
self.colors.radius + 2.,
|
||||
[0, 0, 0, 32],
|
||||
transform,
|
||||
);
|
||||
rounded(
|
||||
&mut image,
|
||||
(4., 3., width as f32 - 8., height as f32 - 8.),
|
||||
self.colors.radius,
|
||||
self.colors.border,
|
||||
transform,
|
||||
);
|
||||
rounded(
|
||||
&mut image,
|
||||
(5., 4., width as f32 - 10., height as f32 - 10.),
|
||||
(self.colors.radius - 1.).max(0.),
|
||||
self.colors.background,
|
||||
transform,
|
||||
);
|
||||
let hint = format!("{} · − / +", state.page + 1);
|
||||
let hint_width = self.metrics(&hint, 11.).0;
|
||||
let preedit = self.fit(
|
||||
&state.preedit,
|
||||
13.,
|
||||
(width as f32 - hint_width - 48.).max(20.),
|
||||
);
|
||||
self.text_center(
|
||||
&mut image,
|
||||
&preedit,
|
||||
(15., 21.),
|
||||
13.,
|
||||
self.colors.muted,
|
||||
scale,
|
||||
);
|
||||
self.text_center(
|
||||
&mut image,
|
||||
&hint,
|
||||
(width as f32 - hint_width - 15., 21.),
|
||||
11.,
|
||||
self.colors.muted,
|
||||
scale,
|
||||
);
|
||||
let mut x = 14.;
|
||||
for (n, (label, word, comment, cell)) in items.iter().enumerate() {
|
||||
let selected = n == state.highlighted;
|
||||
if selected {
|
||||
rounded(
|
||||
&mut image,
|
||||
(x, 37., *cell, 38.),
|
||||
(self.colors.radius - 5.).clamp(4., 10.),
|
||||
self.colors.accent,
|
||||
transform,
|
||||
);
|
||||
}
|
||||
let color = if selected {
|
||||
self.colors.on_accent
|
||||
} else {
|
||||
self.colors.text
|
||||
};
|
||||
let muted = if selected {
|
||||
self.colors.on_accent
|
||||
} else {
|
||||
self.colors.muted
|
||||
};
|
||||
self.text_center(&mut image, label, (x + 10., 56.), 11., muted, scale);
|
||||
self.text_center(&mut image, word, (x + 28., 56.), 18., color, scale);
|
||||
if !comment.is_empty() {
|
||||
self.text_center(
|
||||
&mut image,
|
||||
comment,
|
||||
(x + 35. + self.metrics(word, 18.).0, 56.),
|
||||
12.,
|
||||
muted,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
x += cell + 6.;
|
||||
}
|
||||
Ok((image, width, height))
|
||||
}
|
||||
|
||||
fn font(&self, character: char) -> &FontArc {
|
||||
self.fonts
|
||||
.iter()
|
||||
.find(|font| font.glyph_id(character).0 != 0)
|
||||
.unwrap_or(&self.fonts[0])
|
||||
}
|
||||
|
||||
/// Width and actual ink bounds around baseline zero. Using ink rather than
|
||||
/// the font's line box centers MiSans Chinese, Latin and digits optically.
|
||||
fn metrics(&self, text: &str, size: f32) -> (f32, f32, f32) {
|
||||
let scale = PxScale::from(size);
|
||||
let (mut width, mut min_y, mut max_y) = (0., f32::INFINITY, f32::NEG_INFINITY);
|
||||
for character in text.chars().take(80) {
|
||||
let font = self.font(character);
|
||||
let id = font.glyph_id(character);
|
||||
width += font.as_scaled(scale).h_advance(id);
|
||||
if let Some(outline) =
|
||||
font.outline_glyph(id.with_scale_and_position(scale, point(0., 0.)))
|
||||
{
|
||||
min_y = min_y.min(outline.px_bounds().min.y);
|
||||
max_y = max_y.max(outline.px_bounds().max.y);
|
||||
}
|
||||
}
|
||||
if !min_y.is_finite() {
|
||||
min_y = 0.;
|
||||
max_y = 0.;
|
||||
}
|
||||
(width, min_y, max_y)
|
||||
}
|
||||
|
||||
fn fit(&self, text: &str, size: f32, width: f32) -> String {
|
||||
let text = text.chars().take(80).collect::<String>();
|
||||
if self.metrics(&text, size).0 <= width {
|
||||
return text;
|
||||
}
|
||||
let mut result = String::new();
|
||||
for character in text.chars() {
|
||||
let next = format!("{result}{character}…");
|
||||
if self.metrics(&next, size).0 > width {
|
||||
break;
|
||||
}
|
||||
result.push(character);
|
||||
}
|
||||
result.push('…');
|
||||
result
|
||||
}
|
||||
|
||||
fn text_center(
|
||||
&self,
|
||||
image: &mut Pixmap,
|
||||
text: &str,
|
||||
position: (f32, f32),
|
||||
size: f32,
|
||||
color: [u8; 4],
|
||||
factor: f32,
|
||||
) {
|
||||
let scale = PxScale::from(size * factor);
|
||||
let (_, min_y, max_y) = self.metrics(text, size * factor);
|
||||
let baseline = position.1 * factor - (min_y + max_y) / 2.;
|
||||
let mut x = position.0 * factor;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
for character in text.chars().take(80) {
|
||||
let font = self.font(character);
|
||||
let id = font.glyph_id(character);
|
||||
let advance = font.as_scaled(scale).h_advance(id);
|
||||
if let Some(outline) =
|
||||
font.outline_glyph(id.with_scale_and_position(scale, point(x, baseline)))
|
||||
{
|
||||
let bounds = outline.px_bounds();
|
||||
outline.draw(|gx, gy, coverage| {
|
||||
let px = bounds.min.x as i32 + gx as i32;
|
||||
let py = bounds.min.y as i32 + gy as i32;
|
||||
if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 {
|
||||
return;
|
||||
}
|
||||
let offset = ((py as u32 * width + px as u32) * 4) as usize;
|
||||
let alpha = coverage * color[3] as f32 / 255.;
|
||||
let pixel = &mut image.data_mut()[offset..offset + 4];
|
||||
for n in 0..3 {
|
||||
pixel[n] = (color[n] as f32 * alpha + pixel[n] as f32 * (1. - alpha))
|
||||
.round() as u8;
|
||||
}
|
||||
pixel[3] = (255. * alpha + pixel[3] as f32 * (1. - alpha)).round() as u8;
|
||||
});
|
||||
}
|
||||
x += advance;
|
||||
if x > width as f32 - 5. * factor {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded(
|
||||
image: &mut Pixmap,
|
||||
rect: (f32, f32, f32, f32),
|
||||
radius: f32,
|
||||
color: [u8; 4],
|
||||
transform: Transform,
|
||||
) {
|
||||
let (x, y, w, h) = rect;
|
||||
let r = radius.min(w / 2.).min(h / 2.).max(0.);
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(x + r, y);
|
||||
path.line_to(x + w - r, y);
|
||||
path.quad_to(x + w, y, x + w, y + r);
|
||||
path.line_to(x + w, y + h - r);
|
||||
path.quad_to(x + w, y + h, x + w - r, y + h);
|
||||
path.line_to(x + r, y + h);
|
||||
path.quad_to(x, y + h, x, y + h - r);
|
||||
path.line_to(x, y + r);
|
||||
path.quad_to(x, y, x + r, y);
|
||||
path.close();
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
|
||||
if let Some(path) = path.finish() {
|
||||
image.fill_path(&path, &paint, tiny_skia::FillRule::Winding, transform, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ferese_theme_color_formats() {
|
||||
assert_eq!(parse_color("#1E2530FF"), Some([30, 37, 48, 255]));
|
||||
assert_eq!(parse_color("#F4F7FB"), Some([244, 247, 251, 255]));
|
||||
assert_eq!(parse_color("#中文字体"), None);
|
||||
assert_eq!(parse_color("rgb(1, 2, 3)"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#include <rime_api.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Keep the versioned librime ABI in this small, header-checked boundary. */
|
||||
typedef struct {
|
||||
int accepted, ascii_mode, cursor, highlighted, page_no, last_page, count;
|
||||
char *commit, *preedit;
|
||||
char *texts[9], *comments[9];
|
||||
} FereseRimeResult;
|
||||
|
||||
static RimeApi *api;
|
||||
static RimeSessionId session;
|
||||
|
||||
int ferese_rime_open(const char *user, const char *shared, const char *schema) {
|
||||
api = rime_get_api();
|
||||
RIME_STRUCT(RimeTraits, traits);
|
||||
traits.shared_data_dir = shared;
|
||||
traits.user_data_dir = user;
|
||||
traits.distribution_name = "Ferese Rime";
|
||||
traits.distribution_code_name = "ferese-rime";
|
||||
traits.distribution_version = "0.1.0";
|
||||
traits.app_name = "rime.ferese";
|
||||
traits.log_dir = "";
|
||||
traits.min_log_level = 2;
|
||||
api->setup(&traits);
|
||||
api->initialize(&traits);
|
||||
if (api->start_maintenance(False)) api->join_maintenance_thread();
|
||||
session = api->create_session();
|
||||
if (!session || !api->select_schema(session, schema)) return 0;
|
||||
api->set_option(session, "ascii_mode", False);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void ferese_rime_snapshot(int key, int mask, FereseRimeResult *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->accepted = key < 0 ? 0 : api->process_key(session, key, mask);
|
||||
RIME_STRUCT(RimeCommit, commit);
|
||||
if (api->get_commit(session, &commit)) {
|
||||
out->commit = strdup(commit.text ? commit.text : "");
|
||||
api->free_commit(&commit);
|
||||
}
|
||||
RIME_STRUCT(RimeContext, context);
|
||||
if (api->get_context(session, &context)) {
|
||||
out->preedit = strdup(context.composition.preedit ? context.composition.preedit : "");
|
||||
out->cursor = context.composition.cursor_pos;
|
||||
out->highlighted = context.menu.highlighted_candidate_index;
|
||||
out->page_no = context.menu.page_no;
|
||||
out->last_page = context.menu.is_last_page;
|
||||
out->count = context.menu.num_candidates < 9 ? context.menu.num_candidates : 9;
|
||||
for (int n = 0; n < out->count; n++) {
|
||||
out->texts[n] = strdup(context.menu.candidates[n].text ? context.menu.candidates[n].text : "");
|
||||
out->comments[n] = strdup(context.menu.candidates[n].comment ? context.menu.candidates[n].comment : "");
|
||||
}
|
||||
api->free_context(&context);
|
||||
}
|
||||
RIME_STRUCT(RimeStatus, status);
|
||||
if (api->get_status(session, &status)) {
|
||||
out->ascii_mode = status.is_ascii_mode;
|
||||
api->free_status(&status);
|
||||
}
|
||||
}
|
||||
|
||||
void ferese_rime_result_free(FereseRimeResult *out) {
|
||||
free(out->commit); free(out->preedit);
|
||||
for (int n = 0; n < out->count; n++) { free(out->texts[n]); free(out->comments[n]); }
|
||||
}
|
||||
void ferese_rime_clear(void) { if (session) api->clear_composition(session); }
|
||||
void ferese_rime_close(void) {
|
||||
if (session) api->destroy_session(session);
|
||||
if (api) api->finalize();
|
||||
session = 0; api = NULL;
|
||||
}
|
||||
Reference in new issue
Block a user