605 lines
21 KiB
Rust
605 lines
21 KiB
Rust
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<String>,
|
|
}
|
|
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<Choice>,
|
|
variants: BTreeMap<String, Vec<Choice>>,
|
|
switches: Vec<Choice>,
|
|
}
|
|
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<Arc<Catalog>, String> {
|
|
static CACHE: OnceLock<Arc<Catalog>> = 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<String, String> {
|
|
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<usize> {
|
|
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<Choice>,
|
|
variants: combo_box::State<Choice>,
|
|
layout: Choice,
|
|
variant: Choice,
|
|
}
|
|
#[derive(Default)]
|
|
pub(super) struct State {
|
|
catalog: Option<Arc<Catalog>>,
|
|
loading: bool,
|
|
error: Option<String>,
|
|
rows: Vec<LayoutRow>,
|
|
signature: String,
|
|
pub adding: bool,
|
|
add_choices: Option<combo_box::State<Choice>>,
|
|
switches: Option<combo_box::State<Choice>>,
|
|
switch: Option<Choice>,
|
|
}
|
|
#[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<Edit> {
|
|
vec![
|
|
set(
|
|
"input.xkb_layout",
|
|
rows.iter()
|
|
.map(|r| r.0.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
),
|
|
set(
|
|
"input.xkb_variant",
|
|
rows.iter()
|
|
.map(|r| r.1.as_str())
|
|
.collect::<Vec<_>>()
|
|
.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<Message> {
|
|
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<Arc<Catalog>, 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<Message> {
|
|
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");
|
|
}
|
|
}
|