use crate::{ App, Element, Length, Message, Snapshot, Task, store::{Edit, set}, visuals, }; use cosmic::widget::{column, combo_box, container, row}; use std::{ collections::BTreeMap, io::Read, sync::{Arc, OnceLock}, }; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct Choice { pub code: String, pub name: String, translated: Option, } impl std::fmt::Display for Choice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let name = if ferese_config::locale::resolved() == ferese_config::locale::Language::Chinese { self.translated .clone() .unwrap_or_else(|| ferese_config::locale::translate(&self.name)) } else { self.name.clone() }; if self.code.is_empty() { f.write_str(&name) } else { write!(f, "{} · {}", name, self.code) } } } #[derive(Default, Debug)] pub(super) struct Catalog { layouts: Vec, variants: BTreeMap>, switches: Vec, } impl Catalog { fn parse(source: &str) -> Self { let mut result = Self::default(); let mut section = ""; for line in source.lines().map(str::trim).filter(|s| !s.is_empty()) { if let Some(header) = line.strip_prefix('!') { section = header.trim(); continue; } let Some((code, rest)) = line.split_once(char::is_whitespace) else { continue; }; if section == "layout" { result.layouts.push(Choice { code: code.into(), name: rest.trim().into(), translated: None, }); } else if section == "variant" && let Some((layout, name)) = rest.trim().split_once(':') { result .variants .entry(layout.trim().into()) .or_default() .push(Choice { code: code.into(), name: name.trim().into(), translated: None, }); } else if section == "option" && code.starts_with("grp:") { result.switches.push(Choice { code: code.into(), name: rest.trim().into(), translated: None, }); } } result.layouts.sort_by_key(|c| c.name.to_lowercase()); result.layouts.dedup_by(|a, b| a.code == b.code); result } } pub(super) fn catalog() -> Result, String> { static CACHE: OnceLock> = OnceLock::new(); if let Some(value) = CACHE.get() { return Ok(value.clone()); } let root = std::env::var_os("XKB_CONFIG_ROOT") .map(std::path::PathBuf::from) .unwrap_or_else(|| "/usr/share/X11/xkb".into()); let mut source = String::new(); std::fs::File::open(root.join("rules/evdev.lst")) .map_err(|e| e.to_string())? .take(1024 * 1024 + 1) .read_to_string(&mut source) .map_err(|e| e.to_string())?; if source.len() > 1024 * 1024 { return Err("Keyboard catalog exceeds the size limit".into()); } let mut parsed = Catalog::parse(&source); if let Ok(file) = std::fs::File::open("/usr/share/locale/zh_CN/LC_MESSAGES/xkeyboard-config.mo") { let mut bytes = Vec::new(); if file .take(4 * 1024 * 1024 + 1) .read_to_end(&mut bytes) .is_ok() && bytes.len() <= 4 * 1024 * 1024 { let translations = translations(&bytes); for choice in parsed .layouts .iter_mut() .chain(parsed.variants.values_mut().flatten()) .chain(parsed.switches.iter_mut()) { choice.translated = translations.get(&choice.name).cloned(); } } } if parsed.layouts.is_empty() { return Err("No keyboard layouts found".into()); } let value = Arc::new(parsed); let _ = CACHE.set(value.clone()); Ok(value) } // Read the installed XKB gettext catalog without changing process-wide locale. fn translations(bytes: &[u8]) -> BTreeMap { let mut result = BTreeMap::new(); let Some(magic) = bytes .get(..4) .and_then(|s| s.try_into().ok()) .map(u32::from_le_bytes) else { return result; }; let big = match magic { 0x950412de => false, 0xde120495 => true, _ => return result, }; let word = |offset: usize| -> Option { let raw: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?; Some(if big { u32::from_be_bytes(raw) } else { u32::from_le_bytes(raw) } as usize) }; let Some((count, original, translated)) = word(8) .zip(word(12)) .zip(word(16)) .map(|((a, b), c)| (a, b, c)) else { return result; }; if count > 100_000 { return result; } let string = |table: usize, index: usize| -> Option<&str> { let entry = table.checked_add(index.checked_mul(8)?)?; let length = word(entry)?; let start = word(entry.checked_add(4)?)?; std::str::from_utf8(bytes.get(start..start.checked_add(length)?)?).ok() }; for i in 0..count { if let Some((key, value)) = string(original, i).zip(string(translated, i)) && !key.is_empty() && !value.is_empty() { result.insert( key.split('\0').next().unwrap_or_default().into(), value.split('\0').next().unwrap_or_default().into(), ); } } result } pub(super) struct LayoutRow { layouts: combo_box::State, variants: combo_box::State, layout: Choice, variant: Choice, } #[derive(Default)] pub(super) struct State { catalog: Option>, loading: bool, error: Option, rows: Vec, signature: String, pub adding: bool, add_choices: Option>, switches: Option>, switch: Option, } #[derive(Clone, Debug)] pub(super) enum Input { Layout(usize, Choice), Variant(usize, Choice), Switch(Choice), Remove(usize), Move(usize, bool), Add, Retry, } fn configured_layouts(snapshot: &Snapshot) -> Vec<(String, String)> { let variants = snapshot.string("input.xkb_variant", ""); let variants: Vec<_> = variants.split(',').collect(); snapshot .string("input.xkb_layout", "us") .split(',') .enumerate() .map(|(i, l)| { ( l.to_owned(), variants.get(i).copied().unwrap_or_default().to_owned(), ) }) .collect() } fn edits(rows: &[(String, String)]) -> Vec { vec![ set( "input.xkb_layout", rows.iter() .map(|r| r.0.as_str()) .collect::>() .join(","), ), set( "input.xkb_variant", rows.iter() .map(|r| r.1.as_str()) .collect::>() .join(","), ), ] } impl State { pub fn sync(&mut self, snapshot: &Snapshot) { let signature = format!( "{}|{}|{:?}|{}", snapshot.string("input.xkb_layout", "us"), snapshot.string("input.xkb_variant", ""), ferese_config::locale::resolved(), self.adding ); let signature = format!("{signature}|{:?}", snapshot.item("input.xkb_options")); if signature == self.signature { return; } self.signature = signature; let Some(catalog) = &self.catalog else { return }; let mut switches = vec![Choice { code: String::new(), name: "No layout switching shortcut".into(), translated: None, }]; switches.extend(catalog.switches.clone()); let configured = snapshot .item("input.xkb_options") .and_then(|v| v.as_array()) .into_iter() .flatten() .filter_map(|v| v.as_str()) .find(|v| v.starts_with("grp:")) .unwrap_or(""); let selected = switches .iter() .find(|s| s.code == configured) .cloned() .unwrap_or_else(|| Choice { code: configured.into(), name: configured.into(), translated: None, }); if !switches.contains(&selected) { switches.push(selected.clone()); } self.switch = Some(selected); self.switches = Some(combo_box::State::new(switches)); self.rows = configured_layouts(snapshot) .into_iter() .map(|(layout, variant)| { let mut layouts = catalog.layouts.clone(); let layout = layouts .iter() .find(|c| c.code == layout) .cloned() .unwrap_or_else(|| Choice { code: layout.clone(), name: layout, translated: None, }); if !layouts.contains(&layout) { layouts.push(layout.clone()); } let mut variants = vec![Choice { code: String::new(), name: "Default variant".into(), translated: None, }]; variants.extend( catalog .variants .get(&layout.code) .cloned() .unwrap_or_default(), ); let variant = variants .iter() .find(|v| v.code == variant) .cloned() .unwrap_or_else(|| Choice { code: variant.clone(), name: variant, translated: None, }); if !variants.contains(&variant) { variants.push(variant.clone()); } LayoutRow { layouts: combo_box::State::new(layouts), variants: combo_box::State::new(variants), layout, variant, } }) .collect(); } } impl App { pub(super) fn load_keyboard_catalog(&mut self) -> Task { if self.keyboard.catalog.is_some() || self.keyboard.loading { return Task::none(); } self.keyboard.loading = true; cosmic::task::future(async { Message::KeyboardCatalog( tokio::task::spawn_blocking(catalog) .await .unwrap_or_else(|e| Err(e.to_string())), ) }) } pub(super) fn keyboard_catalog_loaded(&mut self, result: Result, String>) { self.keyboard.loading = false; match result { Ok(value) => { self.keyboard.catalog = Some(value); self.keyboard.error = None; self.keyboard.signature.clear(); self.keyboard.sync(&self.draft) } Err(error) => self.keyboard.error = Some(error), } } pub(super) fn keyboard_input(&mut self, input: Input) -> Task { match input { Input::Retry => return self.load_keyboard_catalog(), Input::Add => { self.keyboard.adding = true; self.keyboard.add_choices = self .keyboard .catalog .as_ref() .map(|c| combo_box::State::new(c.layouts.clone())); return Task::none(); } _ => {} } if let Input::Switch(choice) = input { let mut options: Vec<_> = self .draft .item("input.xkb_options") .and_then(|v| v.as_array()) .into_iter() .flatten() .filter_map(|v| v.as_str()) .filter(|v| !v.starts_with("grp:")) .map(str::to_owned) .collect(); if !choice.code.is_empty() { options.push(choice.code); } return self.change(set("input.xkb_options", options)); } let mut rows = configured_layouts(&self.draft); match input { Input::Layout(index, choice) => { if index == rows.len() { rows.push((choice.code, String::new())); self.keyboard.adding = false; self.keyboard.add_choices = None; } else if let Some(row) = rows.get_mut(index) { row.0 = choice.code; row.1.clear(); } } Input::Variant(index, choice) => { if let Some(row) = rows.get_mut(index) { row.1 = choice.code; } } Input::Remove(index) if rows.len() > 1 && index < rows.len() => { rows.remove(index); } Input::Move(index, up) if index < rows.len() => { let next = if up { index.saturating_sub(1) } else { (index + 1).min(rows.len() - 1) }; rows.swap(index, next); } _ => return Task::none(), } let task = self.edit_many(edits(&rows)); self.keyboard.sync(&self.draft); task } pub(super) fn keyboard_layout_controls(&self) -> Element<'_, Message> { let p = visuals::Palette::from_resolved(&self.resolved.presented); let mut items = column([]) .spacing(10) .push(self.panel_heading(ferese_config::tr!("Keyboard layouts"), 14.)); if let Some(error) = &self.keyboard.error { items = items.push(self.note(error)).push(self.settings_button( "Retry", "M20 7V3l-3 3a8 8 0 1 0 3 6", Some(Message::Keyboard(Input::Retry)), false, )); } else if self.keyboard.catalog.is_none() { items = items.push(self.note("Loading keyboard layouts…")); } for (i, entry) in self.keyboard.rows.iter().enumerate() { items = items.push( column([]) .spacing(6) .push( row([]) .spacing(8) .align_y(cosmic::iced::Alignment::Center) .push(self.search_select( &entry.layouts, ferese_config::tr!("Search layouts…"), Some(&entry.layout), move |v| Message::Keyboard(Input::Layout(i, v)), )) .push( self.settings_icon_button( "Move up", "M5 15l7-7 7 7", (i > 0 && !self.saving) .then_some(Message::Keyboard(Input::Move(i, true))), ), ) .push( self.settings_icon_button( "Remove layout", "M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13", (self.keyboard.rows.len() > 1 && !self.saving) .then_some(Message::Keyboard(Input::Remove(i))), ), ), ) .push(self.search_select( &entry.variants, ferese_config::tr!("Search variants…"), Some(&entry.variant), move |v| Message::Keyboard(Input::Variant(i, v)), )), ); } if let Some(options) = &self.keyboard.switches { items = items .push(self.label(ferese_config::tr!("Switch keyboard layout"), 12.)) .push(self.search_select( options, ferese_config::tr!("Search layout switching shortcuts…"), self.keyboard.switch.as_ref(), |v| Message::Keyboard(Input::Switch(v)), )); } if self.keyboard.adding && let Some(choices) = &self.keyboard.add_choices { let index = self.keyboard.rows.len(); items = items.push(self.search_select( choices, ferese_config::tr!("Select a layout to add…"), None, move |v| Message::Keyboard(Input::Layout(index, v)), )); } items = items.push( row([]) .spacing(8) .push( self.settings_button( "Add layout", "M12 5v14 M5 12h14", (!self.saving && self.keyboard.catalog.is_some()) .then_some(Message::Keyboard(Input::Add)), false, ), ) .push(self.settings_button( "Shortcuts", "M8 8h8v8H8z", Some(Message::Page(crate::schema::Page::Shortcuts)), false, )), ); container(items) .padding(10) .width(Length::Fill) .class(visuals::section(p)) .into() } } #[cfg(test)] mod tests { use super::*; use cosmic::Application; #[test] fn switching_layouts_preserves_other_xkb_options_and_catalog_loads_coalesce() { let (mut app, _) = App::init( cosmic::app::Core::default(), ( "/unused/keyboard-test.kdl".into(), Snapshot::parse( "input { xkb-options \"compose:ralt\" \"grp:alt_shift_toggle\"; }".into(), ), None, None, ), ); assert_eq!(app.load_keyboard_catalog().units(), 1); assert_eq!(app.load_keyboard_catalog().units(), 0); app.keyboard_catalog_loaded(Ok(Arc::new(Catalog::parse( "! layout\n us English (US)\n de German\n! option\n grp:ctrl_shift_toggle Ctrl+Shift\n", )))); app.saving = true; drop(app.keyboard_input(Input::Switch(Choice { code: "grp:ctrl_shift_toggle".into(), name: "Ctrl+Shift".into(), translated: None, }))); assert_eq!( app.draft.item("input.xkb_options").unwrap(), &serde_json::json!(["compose:ralt", "grp:ctrl_shift_toggle"]) ); assert_eq!(app.load_keyboard_catalog().units(), 0); } #[test] fn gettext_reader_is_bounded_and_does_not_change_the_process_locale() { let mut bytes = Vec::new(); for word in [0x950412de_u32, 0, 1, 28, 36, 0, 0, 6, 44, 6, 50] { bytes.extend_from_slice(&word.to_le_bytes()); } bytes.extend_from_slice("German德语".as_bytes()); assert_eq!( translations(&bytes).get("German").map(String::as_str), Some("德语") ); for end in 0..bytes.len() { let _ = translations(&bytes[..end]); } let language = ferese_config::locale::resolved(); let _ = translations(&bytes); assert_eq!(language, ferese_config::locale::resolved()); } #[test] fn catalog_variants_stay_with_their_layout_and_roundtrip_aligned_lists() { let c = Catalog::parse( "! layout\n us English (US)\n de German\n! variant\n intl us: English (US, intl.)\n nodeadkeys de: German (no dead keys)\n! option\n ignored nope\n", ); assert_eq!(c.layouts.len(), 2); assert_eq!(c.variants["us"][0].code, "intl"); assert_eq!(c.variants["de"][0].code, "nodeadkeys"); let mut s = Snapshot::parse("input { xkb-layout \"us,de\"; xkb-variant \",nodeadkeys\"; }".into()) .unwrap(); let mut rows = configured_layouts(&s); rows.swap(0, 1); for e in edits(&rows) { s.edit(&e).unwrap(); } assert_eq!(s.string("input.xkb_variant", ""), "nodeadkeys,"); let mut state = State { catalog: Some(Arc::new(c)), ..Default::default() }; state.sync(&s); assert_eq!(state.rows[0].variant.code, "nodeadkeys"); } }