An on-screen keyboard of ltk's own, driven by layout files; TextEdit::purpose for the row a terminal needs; the wheel scrolls a multiline field; a notebook page fills its height
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Text entry on a phone comes from `zwp_text_input_v3`: a focused field enables the protocol and the compositor's input method, squeekboard today, draws a keyboard and commits text through it. Eydos wants to drop that process, so the keyboard has to come from somewhere else, and the natural place is the toolkit that already draws everything else and knows which field has the focus and what it is for. This commit puts one in ltk, as data files plus a panel any host can drive: ltk's runtime shows it under a focused text field when no input method is there, and forge will draw the same panel in its chrome and deliver the keys to the focused client, which is the second half and is not in this commit. Along the way the demo that exercises it exposed two gaps in the widgets, fixed here too.
Layouts are data. `keyboards/<language>.json`, one file per layout, searched in `LTK_KEYBOARDS_DIR` when set, then `$XDG_DATA_HOME/ltk/keyboards` or `~/.local/share/ltk/keyboards`, then `/usr/share/ltk/keyboards`; `osk::layout::search_paths()`, `available()` and `load( name )` are the API, and the English layout is compiled in as the last resort. A file has `name`, a `label` for the layout-switch key, `layers` and `variants`. A layer is a list of rows; a row a list of keys; a key is a bare string for the text it types, or an object with an `action` (`shift`, `backspace`, `enter`, `space`, `layer`, `layout`, `hide`), the target `layer`, a `label`, and a `width` in key units, `1` by default, which share the row's width so a row of nine keys comes out centred under one of ten. `base` is mandatory; a missing `shift` layer is derived from it by uppercasing. `variants` maps a key's text to what a long press offers, and the shifted form of a key uses its base's, uppercased. A key may also be a named key, `{ "key": "Escape" }`, `Tab`, the arrows, `Home`, `End`, `Page_Up`, `Page_Down`, `Delete`, `Insert`, `F1` to `F12`, or a sticky modifier, `{ "modifier": "ctrl" }` or `alt`. `en.json` and `es.json` ship, with symbol layers and the accented variants each language needs. The files go into the registry copy through `make install` and into `ltk-theme-default` under `/usr/share/ltk/keyboards`.
The terminal row. A terminal needs Escape, Tab, Ctrl, Alt, the arrows and the shell's symbols, and no other application does, so those are not a layout but a row laid above any language's layout: `keyboards/bar-terminal.json`, made of the named keys and modifiers above plus `-`, `/`, `|`, `\` and `~` with their variants. Files named `bar-*.json` are rows, not layouts, and `available()` leaves them out, so the layout-switch key does not cycle onto one; the first version listed it and a tap on that key made the keyboard vanish behind a duplicated bar. Which applications get the row is decided by what the field says it is for: `TextEdit::purpose( InputPurpose::Terminal )`, snapshotted into the handlers like `secure`, which the built-in panel reads through `set_purpose` and which also goes out as the `terminal` content purpose of `zwp_text_input_v3`, the same one VTE-based terminals declare, so a compositor hosting the panel can pick the row from the protocol; for a client that declares nothing it can decide by `app_id`.
`osk::Panel` is a state machine a host drives. It holds the loaded layouts, the current layer, shift as off, one-shot or locked, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it with `set_rect( rect, scale )`, and the press in flight. The host feeds it `press_down`, `press_move`, `press_up` and, from its own timer, `tick`, each returning an `Event`: `Text`, `Backspace`, `Enter`, `Hide`, `Key( keysym, modifiers )` for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, or `Changed` when only the panel's look changed. A key types on release; a press held 450 ms opens its variants, which the finger slides onto and the release picks; backspace fires on press and repeats every 60 ms after the same delay; shift is one-shot and locks on a double tap within 350 ms; a modifier stays down for the next key and a character typed under it goes out as a `Key`, since text cannot carry Ctrl. `next_deadline()` says when `tick` next has work, `height( scale )` what the panel needs, `press_down` answers `None` for a press outside it so the host treats that as its own. `draw( canvas )` paints it with the active palette; the action keys show theme icons rather than glyphs, `keyboard/shift`, `keyboard/backspace`, `keyboard/enter`, `general/down-simple` for hide, `accessibility/languages` for the layout key, the `general` arrows for the terminal row, rasterised once per size and tint, because the first version drew `⇧` and `🌐` and the fallback chain has no glyph for the latter, which showed as a question mark; a layout key with no label of its own shows the active layout's. Nothing in `Panel` knows about text fields or Wayland, which is what lets a compositor host it. Fifteen tests cover the layout parser, the derived shift layer, variants following case, the bar filtering, and the panel's taps, one-shot and locked shift, variants, repeat, layer and layout switches and the terminal row.
The runtime shows it. `event_loop/osk.rs` puts a `Panel` on a surface when a text field takes the focus and `builtin_osk` holds: the compositor advertised no `zwp_text_input_manager_v3`, or `LTK_BUILTIN_OSK=1` is set, which is how to try it on a desktop that has an input method, or the app returns `true` from the new `App::builtin_keyboard()`. The panel is placed at the bottom of the surface by both renderers, and the widget tree is laid out in what is left above it, so the focused field is never covered. Presses, motion and releases from the pointer and from touch are offered to the panel before the gesture machine, a press on the already focused field brings a hidden panel back, and the run loop's timeout now takes the minimum of the long-press, tooltip and panel wakeups so variants and repeat fire on time. `Text` goes through the same insertion as a physical key, `Key` through the same dispatch, with Ctrl applied for its duration, `Enter` submits or inserts a newline in a multiline field, `Hide` keeps the focus and takes the panel away; losing the focus removes it. The layout is picked from `$LANG`. `ltk::InputPurpose` is exported, and `docs/architecture.md` gains the section that says all of this, with the compositor's part sketched.
The demo. `examples/inputs` becomes a notebook with the login page it had and a Shell page: one multiline field that holds a transcript, prompt included, declared a terminal, so the row appears; Enter runs the line under the last prompt (`echo`, `ls`, `pwd`, `clear`), the arrows move through the transcript, and a Ctrl combination interrupts the line the way a terminal shows it. Escape quits only from the login page, because a key the app answers never reaches the field, which is what first made the arrows dead.
Two widget gaps the demo found. `Notebook` gave its page the page's preferred height, so a page that fills its space, a scrolled list or a flex, got none and showed nothing: the active page is now wrapped in `flex` and gets the height under the strip. And the multiline field had no way to scroll: it chose its first visible line from the cursor alone on every draw, so the transcript ran under the keyboard and the wheel did nothing. `text_edit/scroll.rs` gives it a `TextScroll` the runtime keeps per field beside the cursor and the selection, in the frame state, the draw context and the `core` runtime alike: the first line asked for by hand and the cursor at that moment. The view holds there while the cursor stays put and, once it moves, follows it by the least it has to, as an editor does; without a request the result is exactly the old rule. `Window::resolve` computes the visible lines once for drawing, hit testing and the wheel, so they cannot disagree, and replaces the copy each of the first two had. In `on_pointer_axis` the vertical motion is offered to the multiline field under the pointer first, three lines per notch from `value120` or `discrete`, touchpad pixels accumulated until they add up to a line, and only when the field cannot move that way does it go to a `scroll` around it. `TextScroll` is public but opaque, since `Element::draw` is public and needs it; `Default` is the cursor-following behaviour. Six tests cover the hold, the follow, the clamping, the notch and the accumulation. A read-only field is not interactive and does not take the wheel; touch drags inside a field do not scroll it yet.
Not done. Forge does not host the panel yet, so on the phone squeekboard is still what appears. The panel's look is a first pass, sized in logical units with the theme's palette, to be restyled from mockups. Only `en` and `es` ship.
This commit is contained in:
2026-09-25 12:50:58 +02:00
parent b591950f67
commit a1a4d51677
44 changed files with 1894 additions and 112 deletions
+362
View File
@@ -0,0 +1,362 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<String>,
#[ serde( default ) ]
action: Option<Action>,
#[ serde( default ) ]
key: Option<String>,
#[ serde( default ) ]
modifier: Option<Modifier>,
#[ serde( default ) ]
layer: Option<String>,
#[ serde( default ) ]
label: Option<String>,
#[ serde( default = "one" ) ]
width: f32,
},
}
fn one() -> f32 { 1.0 }
#[ derive( Clone, Debug, Deserialize ) ]
struct LayoutFile
{
name: String,
#[ serde( default ) ]
label: Option<String>,
layers: HashMap<String, Vec<Vec<KeySpec>>>,
#[ serde( default ) ]
variants: HashMap<String, Vec<String>>,
}
#[ derive( Clone, Debug, PartialEq ) ]
pub struct Key
{
/// Typed when pressed, `None` for a key with an action only.
pub text: Option<String>,
pub action: Option<Action>,
/// A key that is not text: an arrow, Escape, Tab.
pub keysym: Option<Keysym>,
pub modifier: Option<Modifier>,
/// The layer an `Action::Layer` key switches to.
pub layer: Option<String>,
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<String, Vec<Vec<Key>>>,
variants: HashMap<String, Vec<String>>,
}
impl Layout
{
pub fn from_json( json: &str ) -> Result<Self, String>
{
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<String, Vec<Vec<Key>>> = 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<Key> ]
{
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<String>
{
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<Keysym>
{
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 `<name>.json`, highest priority first.
pub fn search_paths() -> Vec<PathBuf>
{
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" ) );
}
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<Layout>
{
search_paths().into_iter()
.map( |dir| dir.join( format!( "{name}.json" ) ) )
.find_map( |path| std::fs::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<String>
{
let mut names: Vec<String> = search_paths().into_iter()
.filter_map( |dir| std::fs::read_dir( dir ).ok() )
.flatten()
.flatten()
.filter_map( |entry| entry.path().file_stem().and_then( |s| s.to_str() ).map( str::to_string ).filter( |_| entry.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( "|" ) ) );
}
#[ test ]
fn a_layout_without_base_is_rejected()
{
assert!( Layout::from_json( r#"{ "name": "t", "layers": { "symbols": [ [ "1" ] ] } }"# ).is_err() );
}
}