use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use super::keysyms::KEYSYMS;
const BACKSPACE: u32 = 0xff08;
pub(crate) fn keysym_from_name(name: &str) -> Option<u32> {
if let Ok(i) = KEYSYMS.binary_search_by(|(n, _, _)| n.as_bytes().cmp(name.as_bytes())) {
return Some(KEYSYMS[i].1);
}
if let Some(hex) = name.strip_prefix('U')
&& (4..=6).contains(&hex.len())
&& let Ok(cp) = u32::from_str_radix(hex, 16)
{
return char::from_u32(cp).map(|_| 0x0100_0000 + cp);
}
if let Some(hex) = name.strip_prefix("0x") {
return u32::from_str_radix(hex, 16).ok();
}
None
}
fn unicode_by_keysym() -> &'static HashMap<u32, u32> {
static MAP: OnceLock<HashMap<u32, u32>> = OnceLock::new();
MAP.get_or_init(|| {
KEYSYMS
.iter()
.filter(|(_, _, cp)| *cp != 0)
.map(|&(_, ks, cp)| (ks, cp))
.collect()
})
}
pub(crate) fn keysym_to_char(ks: u32) -> Option<char> {
let cp = match ks {
0x20..=0x7e | 0xa0..=0xff => ks,
0x0100_0000..=0x0110_ffff => ks - 0x0100_0000,
_ => *unicode_by_keysym().get(&ks)?,
};
char::from_u32(cp).filter(|c| !c.is_control())
}
fn normalize(ks: u32) -> u32 {
if (0xfe00..=0xffff).contains(&ks) {
return ks;
}
match keysym_to_char(ks) {
Some(c) if (c as u32) < 0x100 => c as u32,
Some(c) => 0x0100_0000 + c as u32,
None => ks,
}
}
pub(crate) fn is_modifier(ks: u32) -> bool {
matches!(ks, 0xffe1..=0xffee | 0xfe01..=0xfe0f | 0xff7e | 0xff7f)
}
fn preedit_char(ks: u32) -> char {
match ks {
0xfe50 => '`',
0xfe51 => '´',
0xfe52 => '^',
0xfe53 => '~',
0xfe54 => '¯',
0xfe55 => '˘',
0xfe56 => '˙',
0xfe57 => '¨',
0xfe58 => '˚',
0xfe59 => '˝',
0xfe5a => 'ˇ',
0xfe5b => '¸',
0xfe5c => '˛',
0xfe5d => 'ͺ',
0xfe5e => '゛',
0xfe5f => '゜',
0xfe6f => '¤',
_ => keysym_to_char(ks).unwrap_or('·'),
}
}
#[derive(Clone, Debug, Default)]
struct Node {
next: Vec<(u32, usize)>,
result: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ComposeTable {
nodes: Vec<Node>,
}
impl Default for ComposeTable {
fn default() -> Self {
Self {
nodes: vec![Node::default()],
}
}
}
struct Paths {
home: Option<PathBuf>,
system: PathBuf,
locale_file: Option<PathBuf>,
}
impl ComposeTable {
fn parse_with(text: &str, paths: &Paths, depth: u32) -> Self {
let mut table = Self::default();
table.add(text, paths, depth);
table
}
#[cfg(test)]
pub(crate) fn parse(text: &str) -> Self {
let paths = Paths {
home: None,
system: PathBuf::new(),
locale_file: None,
};
Self::parse_with(text, &paths, 8)
}
fn add(&mut self, text: &str, paths: &Paths, depth: u32) {
for line in text.lines() {
let line = line.trim_start();
if let Some(rest) = line.strip_prefix("include") {
if depth == 0 {
continue;
}
let Some((path, _)) = quoted(rest.trim_start()) else {
continue;
};
let Some(path) = substitute(&path, paths) else {
continue;
};
if let Some(text) = read_text(&path) {
self.add(&text, paths, depth - 1);
}
} else if line.starts_with('<')
&& let Some((keys, result)) = parse_sequence(line)
{
self.insert(&keys, result);
}
}
}
fn insert(&mut self, keys: &[u32], result: String) {
let mut at = 0;
for (i, &ks) in keys.iter().enumerate() {
let last = i + 1 == keys.len();
let child = self.nodes[at]
.next
.iter()
.find(|(k, _)| *k == ks)
.map(|&(_, n)| n);
at = match child {
Some(n) => n,
None => {
self.nodes.push(Node::default());
let n = self.nodes.len() - 1;
self.nodes[at].next.push((ks, n));
n
}
};
if last {
self.nodes[at].next.clear();
self.nodes[at].result = Some(result.clone());
} else {
self.nodes[at].result = None;
}
}
}
fn walk(&self, keys: &[u32]) -> Option<&Node> {
let mut at = 0;
for &ks in keys {
at = self.nodes[at]
.next
.iter()
.find(|(k, _)| *k == ks)
.map(|&(_, n)| n)?;
}
Some(&self.nodes[at])
}
pub(crate) fn is_empty(&self) -> bool {
self.nodes[0].next.is_empty()
}
}
fn parse_sequence(line: &str) -> Option<(Vec<u32>, String)> {
let colon = line.find(':')?;
let (lhs, rhs) = (&line[..colon], line[colon + 1..].trim_start());
let mut keys = Vec::new();
let mut rest = lhs.trim();
while !rest.is_empty() {
let inner = rest.strip_prefix('<')?;
let end = inner.find('>')?;
keys.push(normalize(keysym_from_name(&inner[..end])?));
rest = inner[end + 1..].trim_start();
}
if keys.is_empty() {
return None;
}
let result = match quoted(rhs) {
Some((text, _)) => text,
None => {
let name = rhs.split(|c: char| c.is_whitespace() || c == '#').next()?;
keysym_to_char(keysym_from_name(name)?)?.to_string()
}
};
(!result.is_empty()).then_some((keys, result))
}
fn quoted(s: &str) -> Option<(String, &str)> {
let body = s.strip_prefix('"')?;
let mut bytes = Vec::new();
let mut chars = body.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'"' => {
let text = String::from_utf8_lossy(&bytes).into_owned();
return Some((text, &body[i + 1..]));
}
'\\' => {
let Some((_, e)) = chars.next() else { break };
match e {
'n' => bytes.push(b'\n'),
'r' => bytes.push(b'\r'),
't' => bytes.push(b'\t'),
'x' | 'X' => {
let mut v = 0u32;
let mut n = 0;
while n < 2 {
let mut peek = chars.clone();
match peek.next().and_then(|(_, d)| d.to_digit(16)) {
Some(d) => {
v = v * 16 + d;
chars = peek;
n += 1;
}
None => break,
}
}
bytes.push(v as u8);
}
'0'..='7' => {
let mut v = e.to_digit(8).unwrap_or(0);
for _ in 0..2 {
let mut peek = chars.clone();
match peek.next().and_then(|(_, d)| d.to_digit(8)) {
Some(d) => {
v = v * 8 + d;
chars = peek;
}
None => break,
}
}
bytes.push(v as u8);
}
other => {
let mut buf = [0u8; 4];
bytes.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
}
}
other => {
let mut buf = [0u8; 4];
bytes.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
}
}
None
}
fn substitute(path: &str, paths: &Paths) -> Option<PathBuf> {
let mut out = String::new();
let mut chars = path.chars();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
match chars.next() {
Some('H') => out.push_str(paths.home.as_ref()?.to_str()?),
Some('L') => out.push_str(paths.locale_file.as_ref()?.to_str()?),
Some('S') => out.push_str(paths.system.to_str()?),
Some('%') => out.push('%'),
_ => return None,
}
}
Some(PathBuf::from(out))
}
fn read_text(path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
Some(match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => e.into_bytes().iter().map(|&b| b as char).collect(),
})
}
fn current_locale() -> String {
["LC_ALL", "LC_CTYPE", "LANG"]
.iter()
.filter_map(|v| std::env::var(v).ok())
.find(|v| !v.is_empty())
.unwrap_or_else(|| "C".to_string())
}
fn lookup_table(text: &str, key: &str, dir_first: bool) -> Option<String> {
text.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.find_map(|l| {
let mut cols = l.split_whitespace();
let a = cols.next()?.trim_end_matches(':');
let b = cols.next()?;
if dir_first {
(b == key).then(|| a.to_string())
} else {
(a == key).then(|| b.to_string())
}
})
}
fn locale_compose_file(system: &Path, locale: &str) -> Option<PathBuf> {
let dir = std::fs::read_to_string(system.join("compose.dir")).ok();
let alias = std::fs::read_to_string(system.join("locale.alias")).ok();
let mut names = vec![locale.to_string()];
if let Some(a) = alias
.as_deref()
.and_then(|t| lookup_table(t, locale, false))
{
names.push(a);
}
let c_locale = matches!(locale, "C" | "POSIX");
if !c_locale && let Some(dir) = dir.as_deref() {
for name in &names {
if let Some(file) = lookup_table(dir, name, true) {
return Some(system.join(file));
}
}
}
let fallback = system.join("en_US.UTF-8/Compose");
fallback.exists().then_some(fallback)
}
fn load() -> ComposeTable {
let home = std::env::var_os("HOME").map(PathBuf::from);
let system = std::env::var_os("XLOCALEDIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/usr/share/X11/locale"));
let locale_file = locale_compose_file(&system, ¤t_locale());
let paths = Paths {
home: home.clone(),
system,
locale_file: locale_file.clone(),
};
let xdg = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| home.as_ref().map(|h| h.join(".config")));
let candidates = [
std::env::var_os("XCOMPOSEFILE").map(PathBuf::from),
home.as_ref().map(|h| h.join(".XCompose")),
xdg.map(|d| d.join("XCompose")),
locale_file,
];
for path in candidates.into_iter().flatten() {
if let Some(text) = read_text(&path) {
return ComposeTable::parse_with(&text, &paths, 8);
}
}
ComposeTable::default()
}
pub(crate) fn table() -> &'static ComposeTable {
static TABLE: OnceLock<ComposeTable> = OnceLock::new();
TABLE.get_or_init(|| {
let table = load();
if std::env::var_os("STIPPLE_COMPOSE_DEBUG").is_some() {
eprintln!(
"stipple compose: {} nodes (locale {})",
table.nodes.len(),
current_locale()
);
}
table
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Feed {
Pass,
Pending(String),
Composed(String),
Cancelled,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ComposeState {
keys: Vec<u32>,
typed: Vec<u32>,
}
impl ComposeState {
pub(crate) fn composing(&self) -> bool {
!self.keys.is_empty()
}
pub(crate) fn reset(&mut self) {
self.keys.clear();
self.typed.clear();
}
fn preedit(&self) -> String {
self.typed.iter().map(|&k| preedit_char(k)).collect()
}
pub(crate) fn feed(&mut self, table: &ComposeTable, ks: u32) -> Feed {
if ks == 0 || is_modifier(ks) {
return Feed::Pass;
}
if self.composing() && ks == BACKSPACE {
self.keys.pop();
self.typed.pop();
return if self.composing() {
Feed::Pending(self.preedit())
} else {
Feed::Cancelled
};
}
let key = normalize(ks);
let mut keys = self.keys.clone();
keys.push(key);
match table.walk(&keys) {
Some(node) => {
if let Some(result) = &node.result {
let result = result.clone();
self.reset();
Feed::Composed(result)
} else {
self.keys = keys;
self.typed.push(ks);
Feed::Pending(self.preedit())
}
}
None if self.composing() => {
self.reset();
Feed::Cancelled
}
None => Feed::Pass,
}
}
}
pub(crate) fn feed_event(
state: &mut ComposeState,
ks: u32,
shortcut: bool,
) -> Option<crate::platform::event::Ime> {
use crate::platform::event::Ime;
if shortcut && !state.composing() {
return None;
}
let table = table();
if table.is_empty() {
return None;
}
match state.feed(table, ks) {
Feed::Pass => None,
Feed::Pending(text) => {
let end = text.len();
Some(Ime::Preedit {
text,
cursor: Some((end, end)),
})
}
Feed::Composed(text) => Some(Ime::Commit(text)),
Feed::Cancelled => Some(Ime::Preedit {
text: String::new(),
cursor: None,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
const MULTI_KEY: u32 = 0xff20;
const DEAD_ACUTE: u32 = 0xfe51;
const DEAD_DIAERESIS: u32 = 0xfe57;
#[test]
fn keysym_names_and_characters() {
assert_eq!(keysym_from_name("eacute"), Some(0xe9));
assert_eq!(keysym_from_name("dead_acute"), Some(DEAD_ACUTE));
assert_eq!(keysym_from_name("Multi_key"), Some(MULTI_KEY));
assert_eq!(keysym_from_name("a"), Some(0x61));
assert_eq!(keysym_from_name("U20AC"), Some(0x0100_20ac));
assert_eq!(keysym_from_name("0xff08"), Some(0xff08));
assert_eq!(keysym_from_name("no_such_key"), None);
assert_eq!(keysym_to_char(0xe9), Some('é'));
assert_eq!(keysym_to_char(0x1b9), Some('š')); assert_eq!(keysym_to_char(0x0100_20ac), Some('€'));
assert_eq!(keysym_to_char(DEAD_ACUTE), None);
assert_eq!(keysym_to_char(0xff08), None); assert!(
KEYSYMS
.windows(2)
.all(|w| w[0].0.as_bytes() < w[1].0.as_bytes())
);
assert_eq!(normalize(0x0100_00e9), 0xe9);
assert_eq!(normalize(0x1b9), 0x0100_0161);
assert_eq!(normalize(0xffaf), 0xffaf); }
const SAMPLE: &str = r#"
# A comment line
<dead_acute> <e> : "é" eacute # LATIN SMALL LETTER E WITH ACUTE
<dead_acute> <space> : "'" apostrophe
<dead_diaeresis> <U0075> : "ü"
<Multi_key> <o> <c> : "©" copyright
<Multi_key> <minus> <greater> : "→"
<Multi_key> <q> <quotedbl> : "say \"hi\"\n"
<Multi_key> <x> <x> : multiply
~Ctrl <a> : "skipped"
<Multi_key> <no_such_key> : "skipped too"
"#;
#[test]
fn parses_sequences_strings_and_escapes() {
let t = ComposeTable::parse(SAMPLE);
let run = |keys: &[u32]| {
let mut s = ComposeState::default();
let mut last = Feed::Pass;
for &k in keys {
last = s.feed(&t, k);
}
last
};
assert_eq!(run(&[DEAD_ACUTE, 0x65]), Feed::Composed("é".into()));
assert_eq!(run(&[DEAD_ACUTE, 0x20]), Feed::Composed("'".into()));
assert_eq!(run(&[DEAD_DIAERESIS, 0x75]), Feed::Composed("ü".into()));
assert_eq!(run(&[MULTI_KEY, 0x6f, 0x63]), Feed::Composed("©".into()));
assert_eq!(run(&[MULTI_KEY, 0x2d, 0x3e]), Feed::Composed("→".into()));
assert_eq!(
run(&[MULTI_KEY, 0x71, 0x22]),
Feed::Composed("say \"hi\"\n".into())
);
assert_eq!(run(&[MULTI_KEY, 0x78, 0x78]), Feed::Composed("×".into()));
assert_eq!(run(&[0x61]), Feed::Pass);
}
#[test]
fn escapes_in_octal_and_hex() {
let t = ComposeTable::parse("<Multi_key> <a> : \"\\303\\251\\x41\"\n");
let mut s = ComposeState::default();
s.feed(&t, MULTI_KEY);
assert_eq!(s.feed(&t, 0x61), Feed::Composed("éA".into()));
}
#[test]
fn state_machine_pending_cancel_and_backspace() {
let t = ComposeTable::parse(SAMPLE);
let mut s = ComposeState::default();
assert_eq!(s.feed(&t, 0x65), Feed::Pass);
assert_eq!(s.feed(&t, DEAD_ACUTE), Feed::Pending("´".into()));
assert!(s.composing());
assert_eq!(s.feed(&t, 0xffe1), Feed::Pass);
assert_eq!(s.feed(&t, 0x65), Feed::Composed("é".into()));
assert!(!s.composing());
assert_eq!(s.feed(&t, MULTI_KEY), Feed::Pending("·".into()));
assert_eq!(s.feed(&t, 0x6f), Feed::Pending("·o".into()));
assert_eq!(s.feed(&t, 0x7a), Feed::Cancelled);
assert!(!s.composing());
s.feed(&t, MULTI_KEY);
s.feed(&t, 0x6f);
assert_eq!(s.feed(&t, BACKSPACE), Feed::Pending("·".into()));
assert_eq!(s.feed(&t, BACKSPACE), Feed::Cancelled);
assert_eq!(s.feed(&t, BACKSPACE), Feed::Pass);
}
#[test]
fn later_definitions_win() {
let t = ComposeTable::parse(
"<Multi_key> <a> : \"1\"\n<Multi_key> <a> <b> : \"2\"\n<Multi_key> <c> <d> : \"3\"\n<Multi_key> <c> : \"4\"\n",
);
let mut s = ComposeState::default();
s.feed(&t, MULTI_KEY);
assert_eq!(s.feed(&t, 0x61), Feed::Pending("·a".into()));
assert_eq!(s.feed(&t, 0x62), Feed::Composed("2".into()));
s.feed(&t, MULTI_KEY);
assert_eq!(s.feed(&t, 0x63), Feed::Composed("4".into()));
}
#[test]
fn includes_and_substitutions() {
let dir = std::env::temp_dir().join(format!("stipple-compose-{}", std::process::id()));
std::fs::create_dir_all(dir.join("en_US.UTF-8")).unwrap();
std::fs::write(
dir.join("en_US.UTF-8/Compose"),
"<dead_acute> <e> : \"é\"\n",
)
.unwrap();
std::fs::write(
dir.join("compose.dir"),
"# comment\nen_US.UTF-8/Compose:\t\ten_US.UTF-8\n",
)
.unwrap();
std::fs::write(dir.join("locale.alias"), "en_US.utf8:\t\ten_US.UTF-8\n").unwrap();
let locale_file = locale_compose_file(&dir, "en_US.utf8");
assert_eq!(locale_file, Some(dir.join("en_US.UTF-8/Compose")));
assert_eq!(locale_compose_file(&dir, "C"), locale_file);
assert_eq!(locale_compose_file(&dir, "xx_XX.UTF-8"), locale_file);
let paths = Paths {
home: Some(dir.clone()),
system: dir.clone(),
locale_file,
};
std::fs::write(dir.join("extra"), "<Multi_key> <o> <c> : \"©\"\n").unwrap();
let user = "include \"%L\"\ninclude \"%H/extra\"\n<dead_acute> <e> : \"E\"\n";
let t = ComposeTable::parse_with(user, &paths, 8);
let mut s = ComposeState::default();
s.feed(&t, DEAD_ACUTE);
assert_eq!(s.feed(&t, 0x65), Feed::Composed("E".into()));
s.feed(&t, MULTI_KEY);
s.feed(&t, 0x6f);
assert_eq!(s.feed(&t, 0x63), Feed::Composed("©".into()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn system_compose_file_parses() {
let path = Path::new("/usr/share/X11/locale/en_US.UTF-8/Compose");
let Some(text) = read_text(path) else { return };
let t = ComposeTable::parse(&text);
let mut s = ComposeState::default();
s.feed(&t, DEAD_ACUTE);
assert_eq!(s.feed(&t, 0x65), Feed::Composed("é".into()));
s.feed(&t, MULTI_KEY);
s.feed(&t, 0x6f);
assert_eq!(s.feed(&t, 0x63), Feed::Composed("©".into()));
}
}