Add searchable input controls and display profile editing
This commit is contained in:
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user