diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-11 01:46:59 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-11 01:46:59 +0200 |
| commit | 247d2805db0c1b686ef4c6cc0f786ff416aeb7e3 (patch) | |
| tree | b6dae4a848df5ae75c4d1bc8e73bad7c63d17575 /src | |
| download | xkbpreview-247d2805db0c1b686ef4c6cc0f786ff416aeb7e3.tar.gz xkbpreview-247d2805db0c1b686ef4c6cc0f786ff416aeb7e3.zip | |
init
Diffstat (limited to '')
| -rw-r--r-- | src/keymap.rs | 253 | ||||
| -rw-r--r-- | src/layout.rs | 171 | ||||
| -rw-r--r-- | src/main.rs | 136 | ||||
| -rw-r--r-- | src/ui.rs | 221 | ||||
| -rw-r--r-- | src/x11.rs | 92 |
5 files changed, 873 insertions, 0 deletions
diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..d14f9ab --- /dev/null +++ b/src/keymap.rs | |||
| @@ -0,0 +1,253 @@ | |||
| 1 | use crate::layout; | ||
| 2 | use crate::x11; | ||
| 3 | |||
| 4 | use xkbcommon::xkb; | ||
| 5 | |||
| 6 | #[derive(Debug, Clone)] | ||
| 7 | pub enum KeyKind { | ||
| 8 | Normal, | ||
| 9 | Shift, | ||
| 10 | Level3, | ||
| 11 | Lock, | ||
| 12 | Control, | ||
| 13 | Alt, | ||
| 14 | Super, | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Debug, Clone)] | ||
| 18 | pub struct KeyEntry { | ||
| 19 | pub labels: [[String; 4]; 4], | ||
| 20 | pub kind: KeyKind, | ||
| 21 | pub dead: [[bool; 4]; 4], | ||
| 22 | pub pos: layout::KeyPos, | ||
| 23 | } | ||
| 24 | |||
| 25 | #[derive(Debug, Clone)] | ||
| 26 | pub struct KeyData { | ||
| 27 | pub keys: Vec<KeyEntry>, | ||
| 28 | pub layouts: Vec<String>, | ||
| 29 | pub is_iso: bool, | ||
| 30 | } | ||
| 31 | |||
| 32 | fn dead_display(name: &str) -> &'static str { | ||
| 33 | match name.to_lowercase().as_str() { | ||
| 34 | "dead_grave" => "`", | ||
| 35 | "dead_acute" => "'", | ||
| 36 | "dead_circumflex" => "^", | ||
| 37 | "dead_tilde" => "~", | ||
| 38 | "dead_macron" => "-", | ||
| 39 | "dead_breve" => "u", | ||
| 40 | "dead_abovedot" => ".", | ||
| 41 | "dead_diaeresis" => "\"", | ||
| 42 | "dead_caron" => "v", | ||
| 43 | "dead_cedilla" => ",", | ||
| 44 | "dead_ogonek" => "~", | ||
| 45 | "dead_doubleacute" => "\"", | ||
| 46 | "dead_stroke" => "/", | ||
| 47 | "dead_hook" => "?", | ||
| 48 | "dead_horn" => "?", | ||
| 49 | "dead_psili" => "'", | ||
| 50 | "dead_dasia" => "'", | ||
| 51 | "dead_iota" => "i", | ||
| 52 | "dead_voiced_sound" => "\"", | ||
| 53 | "dead_semivoiced_sound" => "o", | ||
| 54 | _ => "?", | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | fn keysym_to_display(sym: xkb::Keysym) -> String { | ||
| 59 | let utf8 = xkb::keysym_to_utf8(sym); | ||
| 60 | let trimmed = utf8.trim().to_string(); | ||
| 61 | if !trimmed.is_empty() { | ||
| 62 | let name = xkb::keysym_get_name(sym); | ||
| 63 | if name.to_lowercase().starts_with("dead_") { | ||
| 64 | return dead_display(&name).to_string(); | ||
| 65 | } | ||
| 66 | return trimmed; | ||
| 67 | } | ||
| 68 | let name = xkb::keysym_get_name(sym); | ||
| 69 | if name.to_lowercase().starts_with("dead_") { | ||
| 70 | return dead_display(&name).to_string(); | ||
| 71 | } | ||
| 72 | match sym { | ||
| 73 | xkb::Keysym::Shift_L | xkb::Keysym::Shift_R => "Shift".into(), | ||
| 74 | xkb::Keysym::ISO_Level3_Shift => "AltGr".into(), | ||
| 75 | xkb::Keysym::Control_L | xkb::Keysym::Control_R => "Ctrl".into(), | ||
| 76 | xkb::Keysym::Alt_L | xkb::Keysym::Alt_R => "Alt".into(), | ||
| 77 | xkb::Keysym::Super_L | xkb::Keysym::Super_R => "Super".into(), | ||
| 78 | xkb::Keysym::Menu => "Menu".into(), | ||
| 79 | xkb::Keysym::Caps_Lock => "Caps".into(), | ||
| 80 | xkb::Keysym::Tab => "Tab".into(), | ||
| 81 | xkb::Keysym::Return => "Enter".into(), | ||
| 82 | xkb::Keysym::Escape => "Esc".into(), | ||
| 83 | xkb::Keysym::BackSpace => "Bksp".into(), | ||
| 84 | xkb::Keysym::space => " ".into(), | ||
| 85 | xkb::Keysym::Delete => "Del".into(), | ||
| 86 | xkb::Keysym::Insert => "Ins".into(), | ||
| 87 | xkb::Keysym::Home => "Home".into(), | ||
| 88 | xkb::Keysym::End => "End".into(), | ||
| 89 | xkb::Keysym::Page_Up => "PgUp".into(), | ||
| 90 | xkb::Keysym::Page_Down => "PgDn".into(), | ||
| 91 | xkb::Keysym::Up => "^".into(), | ||
| 92 | xkb::Keysym::Down => "v".into(), | ||
| 93 | xkb::Keysym::Left => "<".into(), | ||
| 94 | xkb::Keysym::Right => ">".into(), | ||
| 95 | xkb::Keysym::KP_Enter => "Enter".into(), | ||
| 96 | xkb::Keysym::Num_Lock => "Num".into(), | ||
| 97 | xkb::Keysym::Scroll_Lock => "Scrl".into(), | ||
| 98 | xkb::Keysym::Pause => "Pause".into(), | ||
| 99 | xkb::Keysym::Print => "PrtSc".into(), | ||
| 100 | _ => name, | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | fn detect_key_kind(syms: &[xkb::Keysym]) -> KeyKind { | ||
| 105 | for &sym in syms { | ||
| 106 | match sym { | ||
| 107 | xkb::Keysym::Shift_L | xkb::Keysym::Shift_R => return KeyKind::Shift, | ||
| 108 | xkb::Keysym::ISO_Level3_Shift => return KeyKind::Level3, | ||
| 109 | xkb::Keysym::Caps_Lock => return KeyKind::Lock, | ||
| 110 | xkb::Keysym::Control_L | xkb::Keysym::Control_R => return KeyKind::Control, | ||
| 111 | xkb::Keysym::Alt_L | xkb::Keysym::Alt_R => return KeyKind::Alt, | ||
| 112 | xkb::Keysym::Super_L | xkb::Keysym::Super_R => return KeyKind::Super, | ||
| 113 | _ => {} | ||
| 114 | } | ||
| 115 | } | ||
| 116 | KeyKind::Normal | ||
| 117 | } | ||
| 118 | |||
| 119 | fn is_keysym_dead(sym: xkb::Keysym) -> bool { | ||
| 120 | xkb::keysym_get_name(sym).to_lowercase().starts_with("dead_") | ||
| 121 | } | ||
| 122 | |||
| 123 | pub fn level_from_x11_modmask(mask: u16) -> usize { | ||
| 124 | let shift = mask & 0x01 != 0; | ||
| 125 | let altgr = mask & 0x80 != 0; | ||
| 126 | match (shift, altgr) { | ||
| 127 | (false, false) => 0, | ||
| 128 | (true, false) => 1, | ||
| 129 | (false, true) => 2, | ||
| 130 | (true, true) => 3, | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | struct RawKey { | ||
| 135 | name: String, | ||
| 136 | labels: [[String; 4]; 4], | ||
| 137 | dead: [[bool; 4]; 4], | ||
| 138 | kind: KeyKind, | ||
| 139 | } | ||
| 140 | |||
| 141 | fn detect_is_iso(model: &str, keymap_has_lsgt: bool) -> bool { | ||
| 142 | if model.contains("105") || model.contains("102") || model.contains("98") { | ||
| 143 | return true; | ||
| 144 | } | ||
| 145 | if model.contains("101") || model.contains("104") || model.contains("106") { | ||
| 146 | return false; | ||
| 147 | } | ||
| 148 | keymap_has_lsgt | ||
| 149 | } | ||
| 150 | |||
| 151 | pub fn compile_keymap(names: &x11::RuleNames) -> Result<KeyData, String> { | ||
| 152 | let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); | ||
| 153 | |||
| 154 | let options = Some(names.options.clone()); | ||
| 155 | |||
| 156 | let keymap = xkb::Keymap::new_from_names( | ||
| 157 | &context, | ||
| 158 | &names.rules, | ||
| 159 | &names.model, | ||
| 160 | &names.layout, | ||
| 161 | &names.variant, | ||
| 162 | options, | ||
| 163 | xkb::KEYMAP_COMPILE_NO_FLAGS, | ||
| 164 | ) | ||
| 165 | .ok_or_else(|| "failed to compile keymap".to_string())?; | ||
| 166 | |||
| 167 | let num_groups = keymap.num_layouts() as usize; | ||
| 168 | |||
| 169 | let layouts: Vec<String> = if names.layout.is_empty() || num_groups <= 1 { | ||
| 170 | vec![if names.layout.is_empty() { | ||
| 171 | "default".into() | ||
| 172 | } else { | ||
| 173 | names.layout.clone() | ||
| 174 | }] | ||
| 175 | } else { | ||
| 176 | let layout_parts: Vec<&str> = names.layout.split(',').collect(); | ||
| 177 | let variant_parts: Vec<&str> = if names.variant.is_empty() { | ||
| 178 | vec![] | ||
| 179 | } else { | ||
| 180 | names.variant.split(',').collect() | ||
| 181 | }; | ||
| 182 | layout_parts | ||
| 183 | .iter() | ||
| 184 | .enumerate() | ||
| 185 | .map(|(i, l)| { | ||
| 186 | let v = variant_parts.get(i).filter(|v| !v.is_empty()); | ||
| 187 | match v { | ||
| 188 | Some(v) => format!("{}({})", l, v), | ||
| 189 | None => l.to_string(), | ||
| 190 | } | ||
| 191 | }) | ||
| 192 | .collect() | ||
| 193 | }; | ||
| 194 | |||
| 195 | layout::reset_default_pos(); | ||
| 196 | |||
| 197 | let mut raw = Vec::new(); | ||
| 198 | let mut has_lsgt = false; | ||
| 199 | |||
| 200 | keymap.key_for_each(|km, kc| { | ||
| 201 | let name = match km.key_get_name(kc) { | ||
| 202 | Some(n) => n.to_string(), | ||
| 203 | None => return, | ||
| 204 | }; | ||
| 205 | |||
| 206 | if name == "LSGT" { | ||
| 207 | has_lsgt = true; | ||
| 208 | } | ||
| 209 | |||
| 210 | let mut labels: [[String; 4]; 4] = Default::default(); | ||
| 211 | let mut dead = [[false; 4]; 4]; | ||
| 212 | let mut all_syms = Vec::new(); | ||
| 213 | |||
| 214 | for group in 0..num_groups.min(4) { | ||
| 215 | for level in 0..4_u32 { | ||
| 216 | let syms = km.key_get_syms_by_level(kc, group as u32, level); | ||
| 217 | let li = level as usize; | ||
| 218 | if syms.is_empty() { | ||
| 219 | labels[group][li] = String::new(); | ||
| 220 | } else { | ||
| 221 | let display: Vec<String> = | ||
| 222 | syms.iter().map(|&s| keysym_to_display(s)).collect(); | ||
| 223 | labels[group][li] = display.join(" "); | ||
| 224 | dead[group][li] = syms.iter().any(|&s| is_keysym_dead(s)); | ||
| 225 | all_syms.extend_from_slice(syms); | ||
| 226 | } | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | let kind = detect_key_kind(&all_syms); | ||
| 231 | |||
| 232 | raw.push(RawKey { name, labels, dead, kind }); | ||
| 233 | }); | ||
| 234 | |||
| 235 | let is_iso = detect_is_iso(&names.model, has_lsgt); | ||
| 236 | |||
| 237 | let keys: Vec<KeyEntry> = raw | ||
| 238 | .into_iter() | ||
| 239 | .filter(|k| !(k.name == "LSGT" && !is_iso)) | ||
| 240 | .map(|k| KeyEntry { | ||
| 241 | pos: layout::resolve_position(&k.name, is_iso), | ||
| 242 | labels: k.labels, | ||
| 243 | dead: k.dead, | ||
| 244 | kind: k.kind, | ||
| 245 | }) | ||
| 246 | .collect(); | ||
| 247 | |||
| 248 | Ok(KeyData { | ||
| 249 | keys, | ||
| 250 | layouts, | ||
| 251 | is_iso, | ||
| 252 | }) | ||
| 253 | } | ||
diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..4dad3fb --- /dev/null +++ b/src/layout.rs | |||
| @@ -0,0 +1,171 @@ | |||
| 1 | #[derive(Debug, Clone, Copy)] | ||
| 2 | pub struct KeyPos { | ||
| 3 | pub row: usize, | ||
| 4 | pub col: f32, | ||
| 5 | pub width: f32, | ||
| 6 | } | ||
| 7 | |||
| 8 | fn ansi_row0(name: &str) -> Option<KeyPos> { | ||
| 9 | let keys: &[(&str, f32, f32)] = &[ | ||
| 10 | ("ESC", 0.0, 1.0), | ||
| 11 | ("TLDE", 1.0, 1.0), | ||
| 12 | ("AE01", 2.0, 1.0), | ||
| 13 | ("AE02", 3.0, 1.0), | ||
| 14 | ("AE03", 4.0, 1.0), | ||
| 15 | ("AE04", 5.0, 1.0), | ||
| 16 | ("AE05", 6.0, 1.0), | ||
| 17 | ("AE06", 7.0, 1.0), | ||
| 18 | ("AE07", 8.0, 1.0), | ||
| 19 | ("AE08", 9.0, 1.0), | ||
| 20 | ("AE09", 10.0, 1.0), | ||
| 21 | ("AE10", 11.0, 1.0), | ||
| 22 | ("AE11", 12.0, 1.0), | ||
| 23 | ("AE12", 13.0, 1.0), | ||
| 24 | ("BKSP", 14.0, 1.5), | ||
| 25 | ]; | ||
| 26 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 0, col: *c, width: *w }) | ||
| 27 | } | ||
| 28 | |||
| 29 | fn ansi_row1(name: &str) -> Option<KeyPos> { | ||
| 30 | let keys: &[(&str, f32, f32)] = &[ | ||
| 31 | ("TAB", 0.0, 1.5), | ||
| 32 | ("AD01", 1.5, 1.0), | ||
| 33 | ("AD02", 2.5, 1.0), | ||
| 34 | ("AD03", 3.5, 1.0), | ||
| 35 | ("AD04", 4.5, 1.0), | ||
| 36 | ("AD05", 5.5, 1.0), | ||
| 37 | ("AD06", 6.5, 1.0), | ||
| 38 | ("AD07", 7.5, 1.0), | ||
| 39 | ("AD08", 8.5, 1.0), | ||
| 40 | ("AD09", 9.5, 1.0), | ||
| 41 | ("AD10", 10.5, 1.0), | ||
| 42 | ("AD11", 11.5, 1.0), | ||
| 43 | ("AD12", 12.5, 1.0), | ||
| 44 | ("BKSL", 13.5, 1.5), | ||
| 45 | ]; | ||
| 46 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 1, col: *c, width: *w }) | ||
| 47 | } | ||
| 48 | |||
| 49 | fn ansi_row2(name: &str) -> Option<KeyPos> { | ||
| 50 | let keys: &[(&str, f32, f32)] = &[ | ||
| 51 | ("CAPS", 0.0, 1.75), | ||
| 52 | ("AC01", 1.75, 1.0), | ||
| 53 | ("AC02", 2.75, 1.0), | ||
| 54 | ("AC03", 3.75, 1.0), | ||
| 55 | ("AC04", 4.75, 1.0), | ||
| 56 | ("AC05", 5.75, 1.0), | ||
| 57 | ("AC06", 6.75, 1.0), | ||
| 58 | ("AC07", 7.75, 1.0), | ||
| 59 | ("AC08", 8.75, 1.0), | ||
| 60 | ("AC09", 9.75, 1.0), | ||
| 61 | ("AC10", 10.75, 1.0), | ||
| 62 | ("AC11", 11.75, 1.0), | ||
| 63 | ("RET", 12.75, 2.25), | ||
| 64 | ]; | ||
| 65 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 2, col: *c, width: *w }) | ||
| 66 | } | ||
| 67 | |||
| 68 | fn ansi_row3(name: &str) -> Option<KeyPos> { | ||
| 69 | let keys: &[(&str, f32, f32)] = &[ | ||
| 70 | ("LFSH", 0.0, 2.25), | ||
| 71 | ("AB01", 2.25, 1.0), | ||
| 72 | ("AB02", 3.25, 1.0), | ||
| 73 | ("AB03", 4.25, 1.0), | ||
| 74 | ("AB04", 5.25, 1.0), | ||
| 75 | ("AB05", 6.25, 1.0), | ||
| 76 | ("AB06", 7.25, 1.0), | ||
| 77 | ("AB07", 8.25, 1.0), | ||
| 78 | ("AB08", 9.25, 1.0), | ||
| 79 | ("AB09", 10.25, 1.0), | ||
| 80 | ("AB10", 11.25, 1.0), | ||
| 81 | ("RTSH", 12.25, 2.75), | ||
| 82 | ]; | ||
| 83 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 3, col: *c, width: *w }) | ||
| 84 | } | ||
| 85 | |||
| 86 | fn ansi_row4(name: &str) -> Option<KeyPos> { | ||
| 87 | let keys: &[(&str, f32, f32)] = &[ | ||
| 88 | ("LCTL", 0.0, 1.25), | ||
| 89 | ("LWIN", 1.25, 1.25), | ||
| 90 | ("LALT", 2.50, 1.25), | ||
| 91 | ("SPCE", 3.75, 6.25), | ||
| 92 | ("RALT", 10.0, 1.25), | ||
| 93 | ("RWIN", 11.25, 1.25), | ||
| 94 | ("MENU", 12.50, 1.25), | ||
| 95 | ("RCTL", 13.75, 1.25), | ||
| 96 | ]; | ||
| 97 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 4, col: *c, width: *w }) | ||
| 98 | } | ||
| 99 | |||
| 100 | fn iso_row3(name: &str, _has_lsgt: bool) -> Option<KeyPos> { | ||
| 101 | let keys: &[(&str, f32, f32)] = &[ | ||
| 102 | ("LFSH", 0.0, 1.25), | ||
| 103 | ("LSGT", 1.25, 1.0), | ||
| 104 | ("AB01", 2.25, 1.0), | ||
| 105 | ("AB02", 3.25, 1.0), | ||
| 106 | ("AB03", 4.25, 1.0), | ||
| 107 | ("AB04", 5.25, 1.0), | ||
| 108 | ("AB05", 6.25, 1.0), | ||
| 109 | ("AB06", 7.25, 1.0), | ||
| 110 | ("AB07", 8.25, 1.0), | ||
| 111 | ("AB08", 9.25, 1.0), | ||
| 112 | ("AB09", 10.25, 1.0), | ||
| 113 | ("AB10", 11.25, 1.0), | ||
| 114 | ("RTSH", 12.25, 2.75), | ||
| 115 | ]; | ||
| 116 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 3, col: *c, width: *w }) | ||
| 117 | } | ||
| 118 | |||
| 119 | fn iso_row2(name: &str) -> Option<KeyPos> { | ||
| 120 | let keys: &[(&str, f32, f32)] = &[ | ||
| 121 | ("CAPS", 0.0, 1.75), | ||
| 122 | ("AC01", 1.75, 1.0), | ||
| 123 | ("AC02", 2.75, 1.0), | ||
| 124 | ("AC03", 3.75, 1.0), | ||
| 125 | ("AC04", 4.75, 1.0), | ||
| 126 | ("AC05", 5.75, 1.0), | ||
| 127 | ("AC06", 6.75, 1.0), | ||
| 128 | ("AC07", 7.75, 1.0), | ||
| 129 | ("AC08", 8.75, 1.0), | ||
| 130 | ("AC09", 9.75, 1.0), | ||
| 131 | ("AC10", 10.75, 1.0), | ||
| 132 | ("AC11", 11.75, 1.0), | ||
| 133 | ("RET", 12.75, 2.25), | ||
| 134 | ]; | ||
| 135 | keys.iter().find(|(n, _, _)| *n == name).map(|(_, c, w)| KeyPos { row: 2, col: *c, width: *w }) | ||
| 136 | } | ||
| 137 | |||
| 138 | pub fn resolve_position(name: &str, is_iso: bool) -> KeyPos { | ||
| 139 | if is_iso { | ||
| 140 | if let Some(pos) = iso_row3(name, true).or_else(|| ansi_row0(name)) | ||
| 141 | .or_else(|| ansi_row1(name)) | ||
| 142 | .or_else(|| iso_row2(name)) | ||
| 143 | .or_else(|| ansi_row4(name)) | ||
| 144 | { | ||
| 145 | return pos; | ||
| 146 | } | ||
| 147 | } else { | ||
| 148 | if let Some(pos) = ansi_row0(name) | ||
| 149 | .or_else(|| ansi_row1(name)) | ||
| 150 | .or_else(|| ansi_row2(name)) | ||
| 151 | .or_else(|| ansi_row3(name)) | ||
| 152 | .or_else(|| ansi_row4(name)) | ||
| 153 | { | ||
| 154 | return pos; | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | let extra = DEFAULT_POS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| 159 | KeyPos { | ||
| 160 | row: 5, | ||
| 161 | col: (extra % 16) as f32, | ||
| 162 | width: 1.0, | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | use std::sync::atomic::AtomicU32; | ||
| 167 | static DEFAULT_POS: AtomicU32 = AtomicU32::new(0); | ||
| 168 | |||
| 169 | pub fn reset_default_pos() { | ||
| 170 | DEFAULT_POS.store(0, std::sync::atomic::Ordering::Relaxed); | ||
| 171 | } | ||
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7a814c1 --- /dev/null +++ b/src/main.rs | |||
| @@ -0,0 +1,136 @@ | |||
| 1 | mod x11; | ||
| 2 | mod keymap; | ||
| 3 | mod layout; | ||
| 4 | mod ui; | ||
| 5 | |||
| 6 | use eframe::egui; | ||
| 7 | |||
| 8 | struct XkbPreviewApp { | ||
| 9 | key_data: Option<keymap::KeyData>, | ||
| 10 | x11_conn: Option<x11::X11Connection>, | ||
| 11 | selected_level: usize, | ||
| 12 | active_level: usize, | ||
| 13 | active_group: usize, | ||
| 14 | error: Option<String>, | ||
| 15 | } | ||
| 16 | |||
| 17 | impl Default for XkbPreviewApp { | ||
| 18 | fn default() -> Self { | ||
| 19 | Self { | ||
| 20 | key_data: None, | ||
| 21 | x11_conn: None, | ||
| 22 | selected_level: 0, | ||
| 23 | active_level: 0, | ||
| 24 | active_group: 0, | ||
| 25 | error: None, | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | impl eframe::App for XkbPreviewApp { | ||
| 31 | fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { | ||
| 32 | if self.key_data.is_none() && self.error.is_none() { | ||
| 33 | match load_keyboard() { | ||
| 34 | Ok(data) => self.key_data = Some(data), | ||
| 35 | Err(e) => self.error = Some(e), | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | if self.x11_conn.is_none() { | ||
| 40 | self.x11_conn = x11::X11Connection::open(); | ||
| 41 | } | ||
| 42 | |||
| 43 | if let Some(ref conn) = self.x11_conn { | ||
| 44 | let mask = conn.modifier_mask(); | ||
| 45 | self.active_level = keymap::level_from_x11_modmask(mask); | ||
| 46 | self.active_group = conn.active_group(); | ||
| 47 | } | ||
| 48 | |||
| 49 | ui::render( | ||
| 50 | ui, | ||
| 51 | &self.key_data, | ||
| 52 | &mut self.selected_level, | ||
| 53 | self.active_level, | ||
| 54 | self.active_group, | ||
| 55 | &self.error, | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | fn load_keyboard() -> Result<keymap::KeyData, String> { | ||
| 61 | let names = x11::get_xkb_rule_names().unwrap_or_default(); | ||
| 62 | keymap::compile_keymap(&names) | ||
| 63 | } | ||
| 64 | |||
| 65 | fn main() -> eframe::Result { | ||
| 66 | let options = eframe::NativeOptions { | ||
| 67 | viewport: egui::ViewportBuilder::default() | ||
| 68 | .with_inner_size([980.0, 560.0]), | ||
| 69 | ..Default::default() | ||
| 70 | }; | ||
| 71 | eframe::run_native( | ||
| 72 | "xkbpreview", | ||
| 73 | options, | ||
| 74 | Box::new(|cc| { | ||
| 75 | setup_fonts(&cc.egui_ctx); | ||
| 76 | Ok(Box::new(XkbPreviewApp::default())) | ||
| 77 | }), | ||
| 78 | ) | ||
| 79 | } | ||
| 80 | |||
| 81 | fn setup_fonts(ctx: &egui::Context) { | ||
| 82 | let mut fonts = egui::FontDefinitions::default(); | ||
| 83 | |||
| 84 | let font_candidates = [ | ||
| 85 | "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | ||
| 86 | "/usr/share/fonts/dejavu/DejaVuSans.ttf", | ||
| 87 | "/usr/share/fonts/truetype/dejavu/DejaVuSans-Regular.ttf", | ||
| 88 | "/usr/share/fonts/dejavu/DejaVuSans-Regular.ttf", | ||
| 89 | "/usr/share/fonts/TTF/DejaVuSans.ttf", | ||
| 90 | "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", | ||
| 91 | "/usr/share/fonts/noto/NotoSans-Regular.ttf", | ||
| 92 | "/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf", | ||
| 93 | ]; | ||
| 94 | |||
| 95 | let mut loaded = false; | ||
| 96 | for path in &font_candidates { | ||
| 97 | if let Ok(data) = std::fs::read(path) { | ||
| 98 | fonts | ||
| 99 | .font_data | ||
| 100 | .insert("fallback_font".into(), egui::FontData::from_owned(data).into()); | ||
| 101 | fonts | ||
| 102 | .families | ||
| 103 | .entry(egui::FontFamily::Proportional) | ||
| 104 | .or_default() | ||
| 105 | .push("fallback_font".into()); | ||
| 106 | loaded = true; | ||
| 107 | break; | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | if !loaded { | ||
| 112 | if let Ok(home) = std::env::var("HOME") { | ||
| 113 | let user_candidates = [ | ||
| 114 | format!("{}/.fonts/DejaVuSans.ttf", home), | ||
| 115 | format!("{}/.local/share/fonts/DejaVuSans.ttf", home), | ||
| 116 | format!("{}/.fonts/NotoSans-Regular.ttf", home), | ||
| 117 | format!("{}/.local/share/fonts/NotoSans-Regular.ttf", home), | ||
| 118 | ]; | ||
| 119 | for path in &user_candidates { | ||
| 120 | if let Ok(data) = std::fs::read(path) { | ||
| 121 | fonts | ||
| 122 | .font_data | ||
| 123 | .insert("fallback_font".into(), egui::FontData::from_owned(data).into()); | ||
| 124 | fonts | ||
| 125 | .families | ||
| 126 | .entry(egui::FontFamily::Proportional) | ||
| 127 | .or_default() | ||
| 128 | .push("fallback_font".into()); | ||
| 129 | break; | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | ctx.set_fonts(fonts); | ||
| 136 | } | ||
diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..b9abeff --- /dev/null +++ b/src/ui.rs | |||
| @@ -0,0 +1,221 @@ | |||
| 1 | use crate::keymap; | ||
| 2 | use eframe::egui::{self, Color32, CornerRadius, Stroke, Vec2}; | ||
| 3 | |||
| 4 | const LEVEL_NAMES: &[&str; 4] = &[ | ||
| 5 | "Level 1 (base)", | ||
| 6 | "Level 2 (Shift)", | ||
| 7 | "Level 3 (AltGr)", | ||
| 8 | "Level 4 (Shift+AltGr)", | ||
| 9 | ]; | ||
| 10 | |||
| 11 | const KEY_BG: Color32 = Color32::from_rgb(0x33, 0x33, 0x33); | ||
| 12 | const KEY_BG_MOD_SHIFT: Color32 = Color32::from_rgb(0x2a, 0x5a, 0x2a); | ||
| 13 | const KEY_BG_MOD_L3: Color32 = Color32::from_rgb(0x4a, 0x2a, 0x5a); | ||
| 14 | const KEY_BG_MOD_OTHER: Color32 = Color32::from_rgb(0x3a, 0x3a, 0x3a); | ||
| 15 | const KEY_TEXT: Color32 = Color32::from_rgb(0xcc, 0xcc, 0xcc); | ||
| 16 | const KEY_TEXT_DIM: Color32 = Color32::from_rgb(0x55, 0x55, 0x55); | ||
| 17 | const KEY_TEXT_ACTIVE: Color32 = Color32::from_rgb(0xff, 0xff, 0xff); | ||
| 18 | const KEY_TEXT_MOD: Color32 = Color32::from_rgb(0xff, 0xff, 0xcc); | ||
| 19 | const KEY_TEXT_DEAD: Color32 = Color32::from_rgb(0xff, 0xaa, 0x44); | ||
| 20 | |||
| 21 | const KEY_SPACING: f32 = 4.0; | ||
| 22 | const KEY_UNIT: f32 = 52.0; | ||
| 23 | const KEY_HEIGHT: f32 = 48.0; | ||
| 24 | |||
| 25 | fn key_bg(kind: &keymap::KeyKind) -> Color32 { | ||
| 26 | match kind { | ||
| 27 | keymap::KeyKind::Shift => KEY_BG_MOD_SHIFT, | ||
| 28 | keymap::KeyKind::Level3 => KEY_BG_MOD_L3, | ||
| 29 | keymap::KeyKind::Lock | keymap::KeyKind::Control | keymap::KeyKind::Alt | keymap::KeyKind::Super => { | ||
| 30 | KEY_BG_MOD_OTHER | ||
| 31 | } | ||
| 32 | keymap::KeyKind::Normal => KEY_BG, | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | fn text_color_for_level(level: usize, active_level: usize, kind: &keymap::KeyKind) -> Color32 { | ||
| 37 | if level == active_level { | ||
| 38 | match kind { | ||
| 39 | keymap::KeyKind::Shift | keymap::KeyKind::Level3 => KEY_TEXT_MOD, | ||
| 40 | _ => KEY_TEXT_ACTIVE, | ||
| 41 | } | ||
| 42 | } else { | ||
| 43 | KEY_TEXT_DIM | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | pub fn render( | ||
| 48 | ui: &mut egui::Ui, | ||
| 49 | key_data: &Option<keymap::KeyData>, | ||
| 50 | selected_level: &mut usize, | ||
| 51 | active_level: usize, | ||
| 52 | active_group: usize, | ||
| 53 | error: &Option<String>, | ||
| 54 | ) { | ||
| 55 | egui::Panel::top("toolbar").show_inside(ui, |ui| { | ||
| 56 | ui.horizontal(|ui| { | ||
| 57 | ui.label(egui::RichText::new("xkbpreview").strong()); | ||
| 58 | |||
| 59 | if let Some(data) = key_data { | ||
| 60 | let g = active_group.min(data.layouts.len().saturating_sub(1)); | ||
| 61 | ui.label(format!(" layout: {}", data.layouts[g])); | ||
| 62 | if data.is_iso { | ||
| 63 | ui.label(" [ISO]"); | ||
| 64 | } else { | ||
| 65 | ui.label(" [ANSI]"); | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | ui.label(format!(" Lv:{}", active_level)); | ||
| 70 | |||
| 71 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | ||
| 72 | ui.label("Highlight:"); | ||
| 73 | for (i, name) in LEVEL_NAMES.iter().enumerate() { | ||
| 74 | let mark = if i == active_level { "[x]" } else { "[ ]" }; | ||
| 75 | if ui.selectable_label(*selected_level == i, format!("{} {}", mark, name)).clicked() { | ||
| 76 | *selected_level = i; | ||
| 77 | } | ||
| 78 | } | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | if let Some(err) = error { | ||
| 84 | egui::CentralPanel::default().show_inside(ui, |ui| { | ||
| 85 | ui.colored_label(Color32::RED, format!("Error: {}", err)); | ||
| 86 | ui.label("Make sure you're running under X11 and the XKB configuration is available."); | ||
| 87 | }); | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | |||
| 91 | let data = match key_data { | ||
| 92 | Some(d) => d, | ||
| 93 | None => { | ||
| 94 | egui::CentralPanel::default().show_inside(ui, |ui| { | ||
| 95 | ui.label("Loading keyboard layout..."); | ||
| 96 | }); | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | }; | ||
| 100 | |||
| 101 | egui::CentralPanel::default().show_inside(ui, |ui| { | ||
| 102 | ui.vertical_centered(|ui| { | ||
| 103 | let rows = group_by_row(&data.keys); | ||
| 104 | let max_row = rows.iter().map(|(r, _)| *r).max().unwrap_or(0); | ||
| 105 | let total_w = 15.5 * (KEY_UNIT + KEY_SPACING); | ||
| 106 | |||
| 107 | let (rect, _) = ui.allocate_exact_size( | ||
| 108 | Vec2::new(total_w, (max_row + 1) as f32 * (KEY_HEIGHT + KEY_SPACING)), | ||
| 109 | egui::Sense::hover(), | ||
| 110 | ); | ||
| 111 | |||
| 112 | let painter = ui.painter_at(rect); | ||
| 113 | let origin = rect.min; | ||
| 114 | |||
| 115 | for (row_idx, entries) in &rows { | ||
| 116 | for entry in entries { | ||
| 117 | let p = &entry.pos; | ||
| 118 | let x = p.col * (KEY_UNIT + KEY_SPACING); | ||
| 119 | let y = *row_idx as f32 * (KEY_HEIGHT + KEY_SPACING); | ||
| 120 | let w = p.width * KEY_UNIT + (p.width - 1.0) * KEY_SPACING; | ||
| 121 | |||
| 122 | let key_rect = egui::Rect::from_min_size( | ||
| 123 | origin + Vec2::new(x, y), | ||
| 124 | Vec2::new(w, KEY_HEIGHT), | ||
| 125 | ); | ||
| 126 | |||
| 127 | let bg = key_bg(&entry.kind); | ||
| 128 | painter.rect_filled(key_rect, CornerRadius::same(3), bg); | ||
| 129 | painter.rect_stroke(key_rect, CornerRadius::same(3), Stroke::new(1.0, Color32::from_rgb(0x55, 0x55, 0x55)), egui::StrokeKind::Middle); | ||
| 130 | |||
| 131 | let inset = 3.0; | ||
| 132 | let cw = key_rect.width() / 2.0; | ||
| 133 | |||
| 134 | let centers: [egui::Pos2; 4] = [ | ||
| 135 | key_rect.center(), | ||
| 136 | egui::Pos2::new(key_rect.left() + cw / 2.0, key_rect.top() + inset + 4.0), | ||
| 137 | egui::Pos2::new(key_rect.right() - cw / 2.0, key_rect.top() + inset + 4.0), | ||
| 138 | egui::Pos2::new(key_rect.left() + cw / 2.0, key_rect.bottom() - inset - 4.0), | ||
| 139 | ]; | ||
| 140 | |||
| 141 | let level_order = [0usize, 1, 2, 3]; | ||
| 142 | let font_sizes = [15.0, 10.0, 10.0, 9.0]; | ||
| 143 | let aligns = [ | ||
| 144 | egui::Align2::CENTER_CENTER, | ||
| 145 | egui::Align2::LEFT_TOP, | ||
| 146 | egui::Align2::RIGHT_TOP, | ||
| 147 | egui::Align2::LEFT_BOTTOM, | ||
| 148 | ]; | ||
| 149 | |||
| 150 | let g = active_group.min(3); | ||
| 151 | for &li in &level_order { | ||
| 152 | let label = &entry.labels[g][li]; | ||
| 153 | if label.is_empty() { | ||
| 154 | continue; | ||
| 155 | } | ||
| 156 | let color = if entry.dead[g][li] { | ||
| 157 | KEY_TEXT_DEAD | ||
| 158 | } else if li == 0 { | ||
| 159 | if 0 == active_level { | ||
| 160 | text_color_for_level(li, active_level, &entry.kind) | ||
| 161 | } else { | ||
| 162 | KEY_TEXT | ||
| 163 | } | ||
| 164 | } else { | ||
| 165 | text_color_for_level(li, active_level, &entry.kind) | ||
| 166 | }; | ||
| 167 | let font = if label.len() > 2 { | ||
| 168 | egui::FontId::proportional(font_sizes[li] - 1.0) | ||
| 169 | } else { | ||
| 170 | egui::FontId::proportional(font_sizes[li]) | ||
| 171 | }; | ||
| 172 | painter.text(centers[li], aligns[li], label, font, color); | ||
| 173 | } | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | let legend_y = (max_row + 1) as f32 * (KEY_HEIGHT + KEY_SPACING) + 16.0; | ||
| 178 | let legend_items = [ | ||
| 179 | (KEY_BG_MOD_SHIFT, "Shift (triggers L2)"), | ||
| 180 | (KEY_BG_MOD_L3, "AltGr (triggers L3)"), | ||
| 181 | (KEY_BG_MOD_OTHER, "Other modifiers"), | ||
| 182 | (KEY_TEXT_DEAD, "Dead key accent"), | ||
| 183 | ]; | ||
| 184 | for (i, (color, text)) in legend_items.iter().enumerate() { | ||
| 185 | let ly = legend_y + i as f32 * 20.0; | ||
| 186 | let r = egui::Rect::from_min_size( | ||
| 187 | origin + Vec2::new(0.0, ly), | ||
| 188 | Vec2::new(14.0, 14.0), | ||
| 189 | ); | ||
| 190 | painter.rect_filled(r, CornerRadius::same(2), *color); | ||
| 191 | painter.text( | ||
| 192 | egui::Pos2::new(r.right() + 6.0, r.center().y), | ||
| 193 | egui::Align2::LEFT_CENTER, | ||
| 194 | *text, | ||
| 195 | egui::FontId::proportional(12.0), | ||
| 196 | KEY_TEXT, | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | }); | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | |||
| 203 | fn group_by_row<'a>(keys: &'a [keymap::KeyEntry]) -> Vec<(usize, Vec<&'a keymap::KeyEntry>)> { | ||
| 204 | let mut rows: Vec<(usize, Vec<&'a keymap::KeyEntry>)> = Vec::new(); | ||
| 205 | for key in keys { | ||
| 206 | let row = key.pos.row; | ||
| 207 | if row > 4 { | ||
| 208 | continue; | ||
| 209 | } | ||
| 210 | if let Some(entry) = rows.iter_mut().find(|(r, _)| *r == row) { | ||
| 211 | entry.1.push(key); | ||
| 212 | } else { | ||
| 213 | rows.push((row, vec![key])); | ||
| 214 | } | ||
| 215 | } | ||
| 216 | rows.sort_by_key(|(r, _)| *r); | ||
| 217 | for (_, entries) in &mut rows { | ||
| 218 | entries.sort_by(|a, b| a.pos.col.partial_cmp(&b.pos.col).unwrap()); | ||
| 219 | } | ||
| 220 | rows | ||
| 221 | } | ||
diff --git a/src/x11.rs b/src/x11.rs new file mode 100644 index 0000000..27052b9 --- /dev/null +++ b/src/x11.rs | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | use x11rb::connection::{Connection, RequestConnection}; | ||
| 2 | use x11rb::protocol::xkb::{self, ConnectionExt as XkbConnectionExt}; | ||
| 3 | use x11rb::protocol::xproto::{AtomEnum, ConnectionExt}; | ||
| 4 | use x11rb::rust_connection::RustConnection; | ||
| 5 | |||
| 6 | #[derive(Debug, Default)] | ||
| 7 | pub struct RuleNames { | ||
| 8 | pub rules: String, | ||
| 9 | pub model: String, | ||
| 10 | pub layout: String, | ||
| 11 | pub variant: String, | ||
| 12 | pub options: String, | ||
| 13 | } | ||
| 14 | |||
| 15 | pub struct X11Connection { | ||
| 16 | pub conn: RustConnection, | ||
| 17 | pub root: x11rb::protocol::xproto::Window, | ||
| 18 | xkb_ok: bool, | ||
| 19 | } | ||
| 20 | |||
| 21 | impl X11Connection { | ||
| 22 | pub fn open() -> Option<Self> { | ||
| 23 | let (conn, screen_num) = x11rb::connect(None).ok()?; | ||
| 24 | let root = conn.setup().roots[screen_num].root; | ||
| 25 | let xkb_ok = conn | ||
| 26 | .extension_information(xkb::X11_EXTENSION_NAME) | ||
| 27 | .ok() | ||
| 28 | .flatten() | ||
| 29 | .is_some(); | ||
| 30 | if xkb_ok { | ||
| 31 | let _ = conn.xkb_use_extension(1, 0).ok(); | ||
| 32 | } | ||
| 33 | Some(Self { conn, root, xkb_ok }) | ||
| 34 | } | ||
| 35 | |||
| 36 | pub fn modifier_mask(&self) -> u16 { | ||
| 37 | self.conn | ||
| 38 | .query_pointer(self.root) | ||
| 39 | .ok() | ||
| 40 | .and_then(|c| c.reply().ok()) | ||
| 41 | .map(|r| r.mask.into()) | ||
| 42 | .unwrap_or(0) | ||
| 43 | } | ||
| 44 | |||
| 45 | pub fn active_group(&self) -> usize { | ||
| 46 | if !self.xkb_ok { | ||
| 47 | return 0; | ||
| 48 | } | ||
| 49 | self.conn | ||
| 50 | .xkb_get_state(0x0100) | ||
| 51 | .ok() | ||
| 52 | .and_then(|c| c.reply().ok()) | ||
| 53 | .map(|r| u8::from(r.group) as usize) | ||
| 54 | .unwrap_or(0) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | pub fn get_xkb_rule_names() -> Option<RuleNames> { | ||
| 59 | let (conn, screen_num) = x11rb::connect(None).ok()?; | ||
| 60 | let screen = &conn.setup().roots[screen_num]; | ||
| 61 | |||
| 62 | let atom = conn | ||
| 63 | .intern_atom(false, b"_XKB_RULES_NAMES") | ||
| 64 | .ok()? | ||
| 65 | .reply() | ||
| 66 | .ok()? | ||
| 67 | .atom; | ||
| 68 | |||
| 69 | let prop = conn | ||
| 70 | .get_property(false, screen.root, atom, AtomEnum::ANY, 0, 1024) | ||
| 71 | .ok()? | ||
| 72 | .reply() | ||
| 73 | .ok()?; | ||
| 74 | |||
| 75 | if prop.value.is_empty() || prop.format == 0 { | ||
| 76 | return None; | ||
| 77 | } | ||
| 78 | |||
| 79 | let parts: Vec<&str> = prop | ||
| 80 | .value | ||
| 81 | .split(|&b| b == 0) | ||
| 82 | .filter_map(|s| std::str::from_utf8(s).ok()) | ||
| 83 | .collect(); | ||
| 84 | |||
| 85 | Some(RuleNames { | ||
| 86 | rules: parts.first().map(|s| s.to_string()).unwrap_or_default(), | ||
| 87 | model: parts.get(1).map(|s| s.to_string()).unwrap_or_default(), | ||
| 88 | layout: parts.get(2).map(|s| s.to_string()).unwrap_or_default(), | ||
| 89 | variant: parts.get(3).map(|s| s.to_string()).unwrap_or_default(), | ||
| 90 | options: parts.get(4).map(|s| s.to_string()).unwrap_or_default(), | ||
| 91 | }) | ||
| 92 | } | ||
