Add searchable input controls and display profile editing

This commit is contained in:
aria committed 2026-10-28 08:19:05 +09:00
1 parent 78ce1ed590
commit a2be421974
24 files changed
+3426 -249

No files matched your search

@@ -0,0 +1,604 @@
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");
}
}