// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! A keyboard layout as a data file: layers of rows of keys, and the variants a long press offers. //! //! Files are JSON, one per layout, named by their language tag (`es.json`). A layer is a list of rows; a key is a bare string for the text it types, or an object with an `action`, a named `key` (`Escape`, `Left`…), a `modifier` (`ctrl`, `alt`), a `width` in key units (default 1) and a `label`. The `shift` layer is derived from `base` by uppercasing when a file has none. `variants` maps a key's text to the alternatives a long press offers. Files named `bar-*.json` are rows laid above any language's layout, not layouts of their own: `bar-terminal.json` is the row of keys a terminal needs. use std::collections::HashMap; use std::path::PathBuf; use serde::Deserialize; use crate::Keysym; pub const BASE_LAYER: &str = "base"; pub const SHIFT_LAYER: &str = "shift"; pub const TERMINAL_BAR: &str = "bar-terminal"; const FALLBACK_LAYOUT: &str = include_str!( "../../keyboards/en.json" ); const FALLBACK_TERMINAL_BAR: &str = include_str!( "../../keyboards/bar-terminal.json" ); /// What a key does besides typing. #[ derive( Clone, Debug, PartialEq, Eq, Deserialize ) ] #[ serde( rename_all = "lowercase" ) ] pub enum Action { Shift, Backspace, Enter, Space, /// Switch to the named layer. Layer, /// Switch to the next installed layout. Layout, Hide, } /// A sticky modifier: held for the next key, then released. #[ derive( Clone, Copy, Debug, PartialEq, Eq, Deserialize ) ] #[ serde( rename_all = "lowercase" ) ] pub enum Modifier { Ctrl, Alt, } #[ derive( Clone, Debug, Deserialize ) ] #[ serde( untagged ) ] enum KeySpec { Text( String ), Full { #[ serde( default ) ] text: Option, #[ serde( default ) ] action: Option, #[ serde( default ) ] key: Option, #[ serde( default ) ] modifier: Option, #[ serde( default ) ] layer: Option, #[ serde( default ) ] label: Option, #[ serde( default = "one" ) ] width: f32, }, } fn one() -> f32 { 1.0 } #[ derive( Clone, Debug, Deserialize ) ] struct LayoutFile { name: String, #[ serde( default ) ] label: Option, layers: HashMap>>, #[ serde( default ) ] variants: HashMap>, } #[ derive( Clone, Debug, PartialEq ) ] pub struct Key { /// Typed when pressed, `None` for a key with an action only. pub text: Option, pub action: Option, /// A key that is not text: an arrow, Escape, Tab. pub keysym: Option, pub modifier: Option, /// The layer an `Action::Layer` key switches to. pub layer: Option, pub label: String, /// In key units; a row's units share its width. pub width: f32, } #[ derive( Clone, Debug ) ] pub struct Layout { pub name: String, /// Shown on the layout-switch key. pub label: String, layers: HashMap>>, variants: HashMap>, } impl Layout { pub fn from_json( json: &str ) -> Result { let file: LayoutFile = serde_json::from_str( json ).map_err( |e| e.to_string() )?; if !file.layers.contains_key( BASE_LAYER ) { return Err( format!( "layout {} has no `{BASE_LAYER}` layer", file.name ) ); } let mut layers: HashMap>> = file.layers.iter() .map( |( id, rows )| ( id.clone(), rows.iter().map( |row| row.iter().map( |k| Key::from_spec( k, id ) ).collect() ).collect() ) ) .collect(); if !layers.contains_key( SHIFT_LAYER ) { let shifted = layers[ BASE_LAYER ].iter() .map( |row| row.iter().map( Key::uppercased ).collect() ) .collect(); layers.insert( SHIFT_LAYER.to_string(), shifted ); } let label = file.label.unwrap_or_else( || file.name.to_uppercase() ); Ok( Self { name: file.name, label, layers, variants: file.variants } ) } pub fn rows( &self, layer: &str ) -> &[ Vec ] { self.layers.get( layer ).or_else( || self.layers.get( BASE_LAYER ) ).map( Vec::as_slice ).unwrap_or( &[] ) } pub fn has_layer( &self, layer: &str ) -> bool { self.layers.contains_key( layer ) } /// Alternatives a long press on `text` offers; the shifted form of a key uses its base's, uppercased. pub fn variants( &self, text: &str ) -> Vec { if let Some( v ) = self.variants.get( text ) { return v.clone(); } let lower = text.to_lowercase(); if lower != text { if let Some( v ) = self.variants.get( &lower ) { return v.iter().map( |s| s.to_uppercase() ).collect(); } } Vec::new() } } impl Key { fn from_spec( spec: &KeySpec, layer: &str ) -> Self { match spec { KeySpec::Text( text ) => Self { text: Some( text.clone() ), action: None, keysym: None, modifier: None, layer: None, label: text.clone(), width: 1.0 }, KeySpec::Full { text, action, key, modifier, layer: target, label, width } => { let keysym = key.as_deref().and_then( named_keysym ); let label = label.clone() .or_else( || text.clone() ) .or_else( || key.clone().filter( |_| keysym.is_some() ) ) .or_else( || modifier.map( |m| match m { Modifier::Ctrl => "Ctrl", Modifier::Alt => "Alt" }.to_string() ) ) .unwrap_or_else( || default_label( action.as_ref(), target.as_deref(), layer ) ); Self { text: text.clone(), action: action.clone(), keysym, modifier: *modifier, layer: target.clone(), label, width: *width } } } } fn uppercased( &self ) -> Self { match &self.text { Some( t ) if self.action.is_none() => { let up = t.to_uppercase(); Self { text: Some( up.clone() ), label: up, ..self.clone() } } _ => self.clone(), } } } fn default_label( action: Option<&Action>, target: Option<&str>, layer: &str ) -> String { match action { Some( Action::Shift ) => if layer == SHIFT_LAYER { "⇪" } else { "⇧" }.to_string(), Some( Action::Backspace ) => "⌫".to_string(), Some( Action::Enter ) => "↵".to_string(), Some( Action::Space ) => String::new(), Some( Action::Layer ) => target.map( |l| if l == BASE_LAYER { "ABC".to_string() } else { l.to_uppercase() } ).unwrap_or_default(), Some( Action::Layout ) => String::new(), Some( Action::Hide ) => "⌄".to_string(), None => String::new(), } } /// The keys a layout file may name by their X keysym name. fn named_keysym( name: &str ) -> Option { Some( match name { "Escape" => Keysym::Escape, "Tab" => Keysym::Tab, "Left" => Keysym::Left, "Right" => Keysym::Right, "Up" => Keysym::Up, "Down" => Keysym::Down, "Home" => Keysym::Home, "End" => Keysym::End, "Page_Up" => Keysym::Page_Up, "Page_Down" => Keysym::Page_Down, "Delete" => Keysym::Delete, "Insert" => Keysym::Insert, "F1" => Keysym::F1, "F2" => Keysym::F2, "F3" => Keysym::F3, "F4" => Keysym::F4, "F5" => Keysym::F5, "F6" => Keysym::F6, "F7" => Keysym::F7, "F8" => Keysym::F8, "F9" => Keysym::F9, "F10" => Keysym::F10, "F11" => Keysym::F11, "F12" => Keysym::F12, _ => return None, } ) } /// Directories searched for `.json`, highest priority first. pub fn search_paths() -> Vec { let mut out = Vec::new(); if let Some( dev ) = std::env::var_os( "LTK_KEYBOARDS_DIR" ) { out.push( PathBuf::from( dev ) ); } if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" ) { out.push( PathBuf::from( home ).join( "ltk/keyboards" ) ); } else if let Some( home ) = std::env::var_os( "HOME" ) { out.push( PathBuf::from( home ).join( ".local/share/ltk/keyboards" ) ); } if let Some( bundle ) = crate::resources::resource_dir() { out.push( bundle.join( "keyboards" ) ); } out.push( PathBuf::from( "/usr/share/ltk/keyboards" ) ); out } /// The installed layout `name`, or `None` when no directory has it or it does not parse. pub fn load( name: &str ) -> Option { search_paths().into_iter() .map( |dir| dir.join( format!( "{name}.json" ) ) ) .find_map( |path| crate::vfs::read_to_string( &path ).ok() ) .and_then( |json| Layout::from_json( &json ).map_err( |e| eprintln!( "[ltk] keyboard layout {name}: {e}" ) ).ok() ) } /// Names of every installed language layout, without duplicates, sorted; the `bar-*` files are rows, not layouts. pub fn available() -> Vec { let mut names: Vec = search_paths().into_iter() .filter_map( |dir| crate::vfs::read_dir( dir ).ok() ) .flatten() .filter_map( |path| path.file_stem().and_then( |s| s.to_str() ).map( str::to_string ).filter( |_| path.extension().is_some_and( |e| e == "json" ) ) ) .filter( |name| !name.starts_with( "bar-" ) ) .collect(); names.sort(); names.dedup(); names } /// The English layout compiled in, for a system with none installed. pub fn fallback() -> Layout { Layout::from_json( FALLBACK_LAYOUT ).expect( "the built-in layout parses" ) } /// The terminal row: the installed `bar-terminal.json`, or the one compiled in. pub fn terminal_bar() -> Layout { load( TERMINAL_BAR ).unwrap_or_else( || Layout::from_json( FALLBACK_TERMINAL_BAR ).expect( "the built-in terminal bar parses" ) ) } #[ cfg( test ) ] mod tests { use super::*; #[ test ] fn built_in_layout_parses_with_a_derived_shift_layer() { let layout = fallback(); assert_eq!( layout.name, "en" ); let base = layout.rows( BASE_LAYER ); let shift = layout.rows( SHIFT_LAYER ); assert_eq!( base.len(), shift.len() ); assert_eq!( base[ 0 ][ 0 ].text.as_deref(), Some( "q" ) ); assert_eq!( shift[ 0 ][ 0 ].text.as_deref(), Some( "Q" ) ); assert!( layout.has_layer( "symbols" ) ); } #[ test ] fn key_objects_take_action_width_and_default_labels() { let layout = Layout::from_json( r#"{ "name": "t", "layers": { "base": [ [ "a", { "action": "backspace", "width": 1.5 }, { "action": "layer", "layer": "symbols", "label": "?123" } ] ], "symbols": [ [ "1" ] ] } }"# ).unwrap(); let row = &layout.rows( BASE_LAYER )[ 0 ]; assert_eq!( row[ 1 ].action, Some( Action::Backspace ) ); assert_eq!( row[ 1 ].width, 1.5 ); assert_eq!( row[ 1 ].label, "⌫" ); assert_eq!( row[ 2 ].label, "?123" ); assert_eq!( row[ 2 ].layer.as_deref(), Some( "symbols" ) ); assert_eq!( layout.label, "T" ); } #[ test ] fn variants_follow_case() { let layout = Layout::from_json( r#"{ "name": "t", "layers": { "base": [ [ "a" ] ] }, "variants": { "a": [ "á", "à" ] } }"# ).unwrap(); assert_eq!( layout.variants( "a" ), [ "á", "à" ] ); assert_eq!( layout.variants( "A" ), [ "Á", "À" ] ); assert!( layout.variants( "b" ).is_empty() ); } #[ test ] fn named_keys_and_modifiers_parse_with_their_labels() { let layout = Layout::from_json( r#"{ "name": "t", "layers": { "base": [ [ { "key": "Escape", "label": "Esc" }, { "key": "Left" }, { "modifier": "ctrl" }, { "key": "NoSuchKey" } ] ] } }"# ).unwrap(); let row = &layout.rows( BASE_LAYER )[ 0 ]; assert_eq!( row[ 0 ].keysym, Some( Keysym::Escape ) ); assert_eq!( row[ 0 ].label, "Esc" ); assert_eq!( row[ 1 ].keysym, Some( Keysym::Left ) ); assert_eq!( row[ 1 ].label, "Left" ); assert_eq!( row[ 2 ].modifier, Some( Modifier::Ctrl ) ); assert_eq!( row[ 2 ].label, "Ctrl" ); assert_eq!( row[ 3 ].keysym, None ); } #[ test ] fn bars_are_not_listed_as_layouts() { let _globals = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); let dir = std::env::temp_dir().join( format!( "ltk-osk-test-{}", std::process::id() ) ); std::fs::create_dir_all( &dir ).unwrap(); std::fs::write( dir.join( "xx.json" ), "{}" ).unwrap(); std::fs::write( dir.join( "bar-xx.json" ), "{}" ).unwrap(); std::env::set_var( "LTK_KEYBOARDS_DIR", &dir ); let names = available(); std::env::remove_var( "LTK_KEYBOARDS_DIR" ); let _ = std::fs::remove_dir_all( &dir ); assert!( names.contains( &"xx".to_string() ) ); assert!( !names.iter().any( |n| n.starts_with( "bar-" ) ) ); } #[ test ] fn the_built_in_terminal_bar_parses() { let bar = Layout::from_json( FALLBACK_TERMINAL_BAR ).unwrap(); let row = &bar.rows( BASE_LAYER )[ 0 ]; assert!( row.iter().any( |k| k.keysym == Some( Keysym::Escape ) ) ); assert!( row.iter().any( |k| k.modifier == Some( Modifier::Ctrl ) ) ); assert!( row.iter().any( |k| k.text.as_deref() == Some( "|" ) ) ); assert!( bar.variants( "/" ).iter().any( |v| v == "\\" ) ); assert!( bar.variants( "-" ).iter().any( |v| v == "~" ) ); } /// Rows share the key unit of the widest one, so a layer wider than the /// base narrows every key in it and leaves gaps at the sides. #[ test ] fn shipped_layers_are_no_wider_than_their_base() { let units = |row: &Vec| row.iter().map( |k| k.width ).sum::(); for json in [ include_str!( "../../keyboards/en.json" ), include_str!( "../../keyboards/es.json" ) ] { let layout = Layout::from_json( json ).unwrap(); let base = layout.rows( BASE_LAYER ).iter().map( units ).fold( 0.0_f32, f32::max ); for ( name, rows ) in &layout.layers { for row in rows { assert!( units( row ) <= base, "{}: layer {name} has a row of {} units, the base {base}", layout.name, units( row ) ); } } } } #[ test ] fn a_layout_without_base_is_rejected() { assert!( Layout::from_json( r#"{ "name": "t", "layers": { "symbols": [ [ "1" ] ] } }"# ).is_err() ); } }