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
+1 -1
View File
@@ -39,7 +39,7 @@ doc:
install: doc
install -d $(REGISTRY)
cp -r src benches locales protocols Cargo.toml liberux.toml $(REGISTRY)/
cp -r src benches locales protocols keyboards Cargo.toml liberux.toml $(REGISTRY)/
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
install -d $(DOCDIR)
cp -r target/doc/* $(DOCDIR)/
+1
View File
@@ -225,6 +225,7 @@ More advanced APIs are available when needed:
- gesture hooks such as `on_swipe_*`
- `core::UiSurface`
- runtime theme APIs
- `osk::Panel` and its layout files, for a host that draws the on-screen keyboard itself
## Responsive Design
+1
View File
@@ -1 +1,2 @@
themes/default usr/share/ltk/themes/
keyboards usr/share/ltk/
+16
View File
@@ -37,6 +37,7 @@ Where things live under `src/`, one line each:
- `gles_render/` — GPU backend (EGL + GLES2 / GLES3).
- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch.
- `layout/` — composable arrangers for `Element` trees.
- `osk/` — the on-screen keyboard: layouts from JSON files, a host-driven `Panel`, and its drawing. See [On-screen keyboard](#on-screen-keyboard).
- `protocol/` — in-tree `wayland-scanner` bindings for protocols `wayland-protocols` does not generate yet (`xdg-session-management-v1`).
- `render/` — software rendering surface used by every widget.
- `secure_mem.rs` — volatile wipe of secret buffers behind `TextEdit`'s secure mode.
@@ -111,6 +112,7 @@ In practice, that model is easiest to adopt in three steps:
- `take_focus_request()` → `Option<WidgetId>` — pull-once focus retargeting.
- `on_text_input_focused(active)` — surface IME state.
- `external_text_input()` → `Option<u64>` with `on_external_text_commit` / `on_external_text_delete` — hold the input method for a text field ltk does not own (an editable in an embedded web view); a new serial renews the request.
- `builtin_keyboard()` → `bool` — draw ltk's own on-screen keyboard under a focused text field even when the compositor offers an input method. See [On-screen keyboard](#on-screen-keyboard).
**Implement for window / toplevel lifecycle**
@@ -160,6 +162,20 @@ Separate from overlays, `subsurfaces()` returns `SubsurfaceSpec` entries: input-
If your application does not need overlays or layer-shell, you can ignore this
entire section and stay in the `ltk::window` subset.
## On-screen keyboard
Text entry on a phone comes from `zwp_text_input_v3`: a focused `TextEdit` enables the protocol and the compositor's input method, a separate process such as squeekboard, draws a keyboard and commits text through it. ltk carries a keyboard of its own for the cases where that chain is not there, and for compositors that would rather draw it themselves. Everything lives in `ltk::osk`.
**Layouts are data.** A layout is one JSON file per language, `keyboards/es.json`, searched in `LTK_KEYBOARDS_DIR` (when set), `$XDG_DATA_HOME/ltk/keyboards` (or `~/.local/share/ltk/keyboards`) and `/usr/share/ltk/keyboards`, highest priority first; `ltk::osk::layout::search_paths()` returns the list, `available()` the names found, `load( name )` one layout. The English layout is compiled in as the last resort. A file has `name`, an optional `label` for the layout-switch key, `layers` and `variants`. A layer is a list of rows; a row is a list of keys; a key is a bare string for the text it types, or an object with `action` (`shift`, `backspace`, `enter`, `space`, `layer`, `layout`, `hide`), `layer` for the target of `layer`, `label`, and `width` in key units, `1` by default, which share the row's width. The `base` layer is mandatory; a missing `shift` layer is derived from `base` by uppercasing. `variants` maps a key's text to what a long press offers; the shifted form of a key uses its base's variants uppercased.
A key may also be a named key, `{ "key": "Escape" }`, `Tab`, the arrows, `Home`, `End`, `Page_Up`, `Page_Down`, `Delete`, `Insert` and `F1` to `F12`, or a sticky modifier, `{ "modifier": "ctrl" }` or `alt`. Those are what `keyboards/bar-terminal.json` is made of: the row a terminal needs, independent of the language, which the panel lays above the language's layout when its `Purpose` is `Terminal`. A `bar-*.json` file is a row, not a layout, and `available()` leaves it out of the layouts the layout-switch key cycles through.
**`Panel` is a state machine a host drives.** It holds the loaded layouts, the current layer, one-shot or locked shift, 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` the host acts on: `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 own look changed. `set_purpose` switches the extra row; the runtime derives the purpose from the focused field, and a compositor takes it from the `content_type` a client sets on `zwp_text_input_v3`, which has a `terminal` purpose that terminal emulators built on VTE declare; for one that does not, the compositor can decide by `app_id`. `next_deadline()` says when `tick` next has work: the variants popup opens 450 ms into a press, backspace repeats after the same delay. `height( scale )` is what the panel needs for its current layer; `draw( canvas )` paints it with the active palette. Nothing in `Panel` knows about text fields or Wayland, which is what lets a compositor host it.
**The runtime shows it under a focused text field** when the compositor advertises no `zwp_text_input_manager_v3`, when `LTK_BUILTIN_OSK=1` is set (useful to try it on a desktop that has an input method), or when the app returns `true` from `builtin_keyboard()`. The panel sits at the bottom of the surface; the widget tree is laid out in the space above it, so the focused field is never covered, and presses inside it never reach the gesture machine. `Text` goes through the same insertion path as a physical key, `Key` through the same dispatch as one, with Ctrl applied for its duration, `Enter` submits the field or inserts a newline in a multiline one, `Hide` keeps the focus and takes the panel away until the field is tapped again; losing the focus removes it.
**A compositor** builds a `Panel` for its own chrome, draws it with a `UiSurface` canvas, and turns the events into `zwp_text_input_v3` commits and deletes for `Text`, and into key events on its seat for `Key`, which is the only way an arrow or a Ctrl combination reaches a client, while it shrinks the focused toplevel by the panel's height. Forge is meant to be that host.
## Theming
`ltk::theme` exposes a process-wide active theme. Three layers:
+3 -1
View File
@@ -412,7 +412,7 @@ A text input with cursor, Backspace, Enter / Submit, clipboard
support, a `secure( true )` password mode that masks the visible
characters and zeroizes the buffer on drop, and a
`password_toggle( visible, on_toggle )` builder that pins a show /
hide-password eye icon to the right edge of the field. Single-line by default; `multiline( true )` switches to a multi-row editor whose visible height follows `rows( n )`.
hide-password eye icon to the right edge of the field. Single-line by default; `multiline( true )` switches to a multi-row editor whose visible height follows `rows( n )`. A multiline field whose text overflows scrolls with the wheel or a touchpad, three lines per notch, and takes the motion ahead of any `scroll` around it while it can still move that way; once the cursor moves again the view follows it, by the least it has to.
**When**: login fields, chat inputs, search boxes.
@@ -446,6 +446,8 @@ of `secure( true )` regardless of the current visibility, so
flipping the eye does not weaken the field's threat model at
runtime — only what the user sees on screen changes.
`purpose( InputPurpose::Terminal )` declares what the field is for: the compositor's input method is told through the text-input content type, and ltk's own on-screen keyboard, when it is the one shown, adds its row of Escape, Tab, Ctrl, Alt and arrows above the layout. The `inputs` example's Shell page is the reference; see [On-screen keyboard](./architecture.md#on-screen-keyboard).
**See also**: the password recipe in
[`docs/cookbook.md`](./cookbook.md#password-field-with-pam-submit).
+114 -24
View File
@@ -1,35 +1,55 @@
//! `cargo run --example inputs`
//!
//! Shows a plain username field and a secure password field with a
//! built-in show / hide-password eye toggle. Tap the eye to flip
//! the bullets ↔ plaintext rendering — the buffer keeps its
//! wipe-on-drop guarantee either way. Tab / Shift+Tab moves focus
//! between fields. Enter or the Login button submits. Esc exits.
//! Two pages. *Login* shows a plain username field and a secure
//! password field with a built-in show / hide-password eye toggle:
//! tap the eye to flip the bullets ↔ plaintext rendering — the buffer
//! keeps its wipe-on-drop guarantee either way. Tab / Shift+Tab moves
//! focus between fields, Enter or the Login button submits. *Shell*
//! is a mock terminal: one multiline field holds the whole
//! transcript, prompts included, and is declared a terminal, so the
//! on-screen keyboard adds its row of Escape, Tab, Ctrl, Alt and
//! arrows. Enter runs the line under the last prompt, arrows move
//! through the transcript, and a Ctrl combination interrupts the line
//! the way a terminal would show it. Run with `LTK_BUILTIN_OSK=1` on a
//! desktop to see the keyboard under a compositor that has its own.
//! Esc quits from the Login page.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::{ App, Element, Keysym, ButtonVariant, button, column, text, text_edit, spacer };
use ltk::{ App, ButtonVariant, Element, InputPurpose, Keysym, WidgetId, button, column, flex, notebook, spacer, text, text_edit };
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
SelectPage( usize ),
UsernameChanged( String ),
PasswordChanged( String ),
TogglePasswordVisibility,
Submit,
TranscriptChanged( String ),
Interrupt( String ),
}
const LOGIN: usize = 0;
const SHELL: usize = 1;
const SHELL_FIELD: WidgetId = WidgetId( "inputs/shell" );
const PROMPT: &str = "$ ";
// ── App state ─────────────────────────────────────────────────────────────────
struct InputsApp
{
page: usize,
username: String,
password: String,
password_visible: bool,
submitted: bool,
/// The whole shell transcript, the current prompt line last.
transcript: String,
focus_request: Option<WidgetId>,
}
impl InputsApp
@@ -38,25 +58,17 @@ impl InputsApp
{
Self
{
page: LOGIN,
username: String::new(),
password: String::new(),
password_visible: false,
submitted: false,
transcript: format!( "ltk mock shell — type a command and press Enter\n{PROMPT}" ),
focus_request: None,
}
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for InputsApp
{
type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.inputs" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Message>
fn login_page( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
@@ -103,24 +115,102 @@ impl App for InputsApp
.into()
}
/// The transcript takes whatever height is left above the keyboard and scrolls to keep the cursor's line in view.
fn shell_page( &self ) -> Element<Message>
{
column::<Message>()
.padding( 16.0 )
.spacing( 0.0 )
.push( flex(
text_edit::<Message>( "", &self.transcript )
.id( SHELL_FIELD )
.multiline( true )
.purpose( InputPurpose::Terminal )
.on_change( Message::TranscriptChanged ),
) )
.into()
}
/// A newline typed at the end of the transcript runs the line under the last prompt.
fn transcript_changed( &mut self, value: String )
{
let Some( body ) = value.strip_suffix( '\n' ) else { self.transcript = value; return };
let line = body.rsplit( '\n' ).next().unwrap_or( "" );
let command = line.strip_prefix( PROMPT ).unwrap_or( line ).trim().to_string();
self.transcript = body.to_string();
match command.as_str()
{
"" => {}
"clear" => self.transcript.clear(),
c =>
{
let reply = if let Some( rest ) = c.strip_prefix( "echo " ) { rest.to_string() }
else if c == "ls" { "Documents Downloads Music Pictures".to_string() }
else if c == "pwd" { "/home/user".to_string() }
else { format!( "{c}: command not found" ) };
self.transcript.push( '\n' );
self.transcript.push_str( &reply );
}
}
self.new_prompt();
}
fn new_prompt( &mut self )
{
if !self.transcript.is_empty() { self.transcript.push( '\n' ); }
self.transcript.push_str( PROMPT );
// Re-focusing the field parks the cursor at the end of the value.
self.focus_request = Some( SHELL_FIELD );
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for InputsApp
{
type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.inputs" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Message>
{
notebook::<Message>()
.page( "Login", self.login_page() )
.page( "Shell", self.shell_page() )
.selected( self.page )
.on_select( Message::SelectPage )
.build()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::SelectPage( page ) => self.page = page,
Message::UsernameChanged( v ) => self.username = v,
Message::PasswordChanged( v ) => self.password = v,
Message::TogglePasswordVisibility => self.password_visible = !self.password_visible,
Message::Submit => self.submitted = true,
Message::TranscriptChanged( v ) => self.transcript_changed( v ),
Message::Interrupt( key ) =>
{
self.transcript.push_str( &key );
self.new_prompt();
}
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
fn take_focus_request( &mut self ) -> Option<WidgetId> { self.focus_request.take() }
// Only Ctrl combinations are taken; a plain key that is answered here would never reach the field.
fn on_key_with_modifiers( &mut self, keysym: Keysym, ctrl: bool, _shift: bool ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
if self.page == LOGIN && keysym == Keysym::Escape { std::process::exit( 0 ); }
if self.page != SHELL || !ctrl { return None; }
let name = keysym.name().unwrap_or( "?" ).trim_start_matches( "XK_" ).to_uppercase();
Some( Message::Interrupt( format!( "^{name}" ) ) )
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "bar-terminal",
"label": "Terminal",
"layers": {
"base": [
[
{ "key": "Escape", "label": "Esc" },
{ "key": "Tab" },
{ "modifier": "ctrl" },
{ "modifier": "alt" },
{ "key": "Up" },
{ "key": "Down" },
{ "key": "Left" },
{ "key": "Right" },
"-",
"/",
"|",
"\\",
"~"
]
]
},
"variants": {
"-": [ "_", "–" ],
"/": [ "?" ],
"|": [ "&", "!" ],
"\\": [ "`", "^" ],
"~": [ "$", "#", "*" ]
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "en",
"label": "EN",
"layers": {
"base": [
[ "q", "w", "e", "r", "t", "y", "u", "i", "o", "p" ],
[ "a", "s", "d", "f", "g", "h", "j", "k", "l" ],
[ { "action": "shift", "width": 1.5 }, "z", "x", "c", "v", "b", "n", "m", { "action": "backspace", "width": 1.5 } ],
[ { "action": "layer", "layer": "symbols", "label": "?123", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ]
],
"symbols": [
[ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" ],
[ "@", "#", "$", "%", "&", "-", "+", "(", ")", "/" ],
[ { "action": "layer", "layer": "more", "label": "=\\<", "width": 1.5 }, "*", "\"", "'", ":", ";", "!", "?", { "action": "backspace", "width": 1.5 } ],
[ { "action": "layer", "layer": "base", "label": "ABC", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ]
],
"more": [
[ "~", "`", "|", "•", "√", "π", "÷", "×", "¶", "∆" ],
[ "£", "€", "¥", "^", "°", "=", "{", "}", "[", "]" ],
[ { "action": "layer", "layer": "symbols", "label": "?123", "width": 1.5 }, "\\", "©", "®", "™", "<", ">", "_", { "action": "backspace", "width": 1.5 } ],
[ { "action": "layer", "layer": "base", "label": "ABC", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ]
]
},
"variants": {
"a": [ "á", "à", "â", "ä", "ã", "å", "æ" ],
"e": [ "é", "è", "ê", "ë" ],
"i": [ "í", "ì", "î", "ï" ],
"o": [ "ó", "ò", "ô", "ö", "õ", "ø", "œ" ],
"u": [ "ú", "ù", "û", "ü" ],
"c": [ "ç" ],
"n": [ "ñ" ],
"s": [ "ß", "$" ],
"y": [ "ÿ" ],
".": [ "…", ",", "!", "?" ],
"-": [ "–", "—", "_" ],
"\"": [ "“", "”", "«", "»" ],
"'": [ "‘", "’" ],
"$": [ "€", "£", "¥" ]
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "es",
"label": "ES",
"layers": {
"base": [
[ "q", "w", "e", "r", "t", "y", "u", "i", "o", "p" ],
[ "a", "s", "d", "f", "g", "h", "j", "k", "l", "ñ" ],
[ { "action": "shift", "width": 1.5 }, "z", "x", "c", "v", "b", "n", "m", { "action": "backspace", "width": 1.5 } ],
[ { "action": "layer", "layer": "symbols", "label": "?123", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ]
],
"symbols": [
[ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" ],
[ "@", "#", "€", "%", "&", "-", "+", "(", ")", "/" ],
[ { "action": "layer", "layer": "more", "label": "=\\<", "width": 1.5 }, "*", "\"", "'", ":", ";", "¡", "!", "¿", "?", { "action": "backspace", "width": 1.5 } ],
[ { "action": "layer", "layer": "base", "label": "ABC", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ]
],
"more": [
[ "~", "`", "|", "•", "√", "π", "÷", "×", "¶", "∆" ],
[ "£", "$", "¥", "^", "°", "=", "{", "}", "[", "]" ],
[ { "action": "layer", "layer": "symbols", "label": "?123", "width": 1.5 }, "\\", "©", "®", "™", "<", ">", "_", { "action": "backspace", "width": 1.5 } ],
[ { "action": "layer", "layer": "base", "label": "ABC", "width": 1.5 }, { "action": "layout", "width": 1 }, ",", { "action": "space", "width": 4 }, ".", { "action": "enter", "width": 1.5 } ]
]
},
"variants": {
"a": [ "á", "à", "â", "ä", "ã", "ª" ],
"e": [ "é", "è", "ê", "ë" ],
"i": [ "í", "ì", "î", "ï" ],
"o": [ "ó", "ò", "ô", "ö", "õ", "º" ],
"u": [ "ú", "ù", "û", "ü" ],
"c": [ "ç" ],
"n": [ "ñ" ],
"s": [ "ß" ],
".": [ "…", ",", "!", "?", "¡", "¿" ],
"-": [ "–", "—", "_" ],
"\"": [ "«", "»", "“", "”" ],
"'": [ "‘", "’" ],
"€": [ "$", "£", "¥" ]
}
}
+3
View File
@@ -841,6 +841,9 @@ pub trait App: 'static
/// Delete around the external field's cursor, in UTF-8 bytes as `zwp_text_input_v3` counts.
fn on_external_text_delete( &mut self, _before: u32, _after: u32 ) {}
/// `true` to draw ltk's own on-screen keyboard under a focused text field even when the compositor offers an input method; it is drawn regardless when none is offered, or with `LTK_BUILTIN_OSK=1`.
fn builtin_keyboard( &self ) -> bool { false }
/// Translate a Wayland `ext-foreign-toplevel-list-v1` event into an
/// app message. The runtime binds the protocol globally and forwards
/// every open / close it sees here; apps that ignore window state
+4
View File
@@ -99,6 +99,7 @@ pub struct UiSurface<Msg: Clone>
widget_rects: Vec<LaidOutWidget<Msg>>,
cursor_state: HashMap<usize, usize>,
selection_anchor: HashMap<usize, usize>,
text_scroll: HashMap<usize, crate::widget::text_edit::TextScroll>,
scroll_offsets: HashMap<usize, ( f32, f32 )>,
scroll_canvases: HashMap<usize, Canvas>,
viewport_canvases: HashMap<usize, Canvas>,
@@ -227,6 +228,7 @@ impl<Msg: Clone> UiSurface<Msg>
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
text_scroll: HashMap::new(),
scroll_offsets: HashMap::new(),
scroll_canvases: HashMap::new(),
viewport_canvases: HashMap::new(),
@@ -398,6 +400,7 @@ impl<Msg: Clone> UiSurface<Msg>
pressed_idx: self.pressed_idx,
cursor_state: std::mem::take( &mut self.cursor_state ),
selection_anchor: std::mem::take( &mut self.selection_anchor ),
text_scroll: std::mem::take( &mut self.text_scroll ),
widget_rects: Vec::new(),
debug_layout: options.debug_layout && !use_partial,
scroll_offsets: std::mem::take( &mut self.scroll_offsets ),
@@ -445,6 +448,7 @@ impl<Msg: Clone> UiSurface<Msg>
self.widget_rects = ctx.widget_rects;
self.cursor_state = ctx.cursor_state;
self.selection_anchor = ctx.selection_anchor;
self.text_scroll = ctx.text_scroll;
self.scroll_offsets = ctx.scroll_offsets;
// `ctx.scroll_rects` is intentionally dropped here. The `Scroll` widget
// pushes its hit-test rects into the DrawCtx during the draw pass, but
+6 -2
View File
@@ -83,7 +83,8 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) };
let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf );
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); }
@@ -100,6 +101,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
}
}
if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); }
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
@@ -161,7 +163,8 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) };
let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf );
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); }
else
@@ -174,6 +177,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false );
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); }
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
+2 -1
View File
@@ -505,6 +505,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
let is_pressed = ctx.pressed_idx == Some( flat_idx );
let cursor_pos = ctx.cursor_state.get( &flat_idx ).copied().unwrap_or( 0 );
let sel_anchor = ctx.selection_anchor.get( &flat_idx ).copied().unwrap_or( cursor_pos );
let text_scroll = ctx.text_scroll.get( &flat_idx ).copied().unwrap_or_default();
let widget_id = match other
{
Element::Button( b ) => b.id,
@@ -516,7 +517,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
Element::WindowButton( b ) => b.id,
_ => None,
};
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor, text_scroll );
if other.is_interactive()
{
let mut handlers = other.handlers();
+3
View File
@@ -61,6 +61,7 @@ pub( crate ) struct DrawCtx<Msg: Clone>
pub pressed_idx: Option<usize>,
pub cursor_state: HashMap<usize, usize>,
pub selection_anchor: HashMap<usize, usize>,
pub text_scroll: HashMap<usize, crate::widget::text_edit::TextScroll>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub debug_layout: bool,
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
@@ -119,6 +120,7 @@ pub( crate ) fn build_draw_ctx<Msg: Clone>(
pressed_idx,
cursor_state: std::mem::take( &mut frame.cursor_state ),
selection_anchor: std::mem::take( &mut frame.selection_anchor ),
text_scroll: std::mem::take( &mut frame.text_scroll ),
widget_rects: Vec::new(),
debug_layout,
scroll_offsets: std::mem::take( &mut frame.scroll_offsets ),
@@ -157,6 +159,7 @@ pub( crate ) fn commit_draw_ctx<Msg: Clone>(
frame.viewport_canvases = std::mem::take( &mut ctx.viewport_canvases );
frame.cursor_state = ctx.cursor_state;
frame.selection_anchor = ctx.selection_anchor;
frame.text_scroll = ctx.text_scroll;
frame.scroll_offsets = ctx.scroll_offsets;
frame.accessible_extras = ctx.accessible_extras;
frame.scroll_navigable_items = ctx.scroll_navigable_items;
+6 -2
View File
@@ -80,7 +80,8 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) };
let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf );
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); }
@@ -97,6 +98,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
}
}
if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); }
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
@@ -189,7 +191,8 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) };
let osk_h = crate::event_loop::osk::place_osk( &mut ss.osk, pw, ph, sf );
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h - osk_h ).max( 0.0 ) };
// Repaint surface background under the clip so the dirty rects start from
// a known state instead of leaking the previous widget's pixels.
@@ -204,6 +207,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false );
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( panel ) = ss.osk.as_mut() { panel.draw( canvas ); }
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
+3
View File
@@ -88,9 +88,12 @@ pub struct AppData<A: App>
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// No input method to ask for: the runtime draws its own keyboard under a focused text field.
pub builtin_osk: bool,
/// Whether the currently focused text field is `secure` (password).
/// Drives the text-input-v3 content type.
pub text_input_secure: bool,
pub text_input_purpose: crate::osk::Purpose,
/// Serial of the [`App::external_text_input`] request the text-input is held for.
pub external_text_input: Option<u64>,
/// `xdg-activation-v1`. Present when the compositor advertises the
+2 -1
View File
@@ -89,9 +89,10 @@ impl<A: App> AppData<A>
.and_then( |( rect, value, multiline, secure, align, font_size )|
{
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &widget_idx ).copied().unwrap_or( 0 );
let scroll = self.text_scroll( focus, widget_idx );
let canvas = self.surface( focus ).canvas.as_ref()?;
Some( crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size,
) )
} );
+10 -2
View File
@@ -103,6 +103,7 @@ impl<A: App> AppData<A>
let was_text_input;
let is_text_input;
let secure;
let purpose;
let focus_msg;
{
let ss = self.surface_mut( focus );
@@ -124,6 +125,10 @@ impl<A: App> AppData<A>
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) )
.unwrap_or( false );
purpose = idx
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| h.input_purpose() )
.unwrap_or_default();
was_text_input = ss.focused_idx
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| h.is_text_input() )
@@ -181,12 +186,14 @@ impl<A: App> AppData<A>
if was_text_input && !is_text_input
{
self.deactivate_text_input();
self.hide_osk( focus );
self.app.on_text_input_focused( false );
self.dirty_caches();
}
else if is_text_input && !was_text_input
{
self.activate_text_input( qh, secure );
self.activate_text_input( qh, secure, purpose );
self.show_osk( focus );
self.app.on_text_input_focused( true );
self.dirty_caches();
}
@@ -194,7 +201,8 @@ impl<A: App> AppData<A>
{
// Focus moved between text fields: refresh the content type so a
// password field is flagged even without re-activating.
self.activate_text_input( qh, secure );
self.activate_text_input( qh, secure, purpose );
self.show_osk( focus );
}
}
+1
View File
@@ -9,6 +9,7 @@ pub( crate ) mod cursor_shape;
pub( crate ) mod drag;
pub( crate ) mod focus;
mod handlers;
pub( crate ) mod osk;
pub( crate ) mod perf;
pub( crate ) mod repeat;
pub( crate ) mod session;
+162
View File
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The built-in keyboard under a focused text field, for a session whose compositor offers no input method.
use std::time::{ Duration, Instant };
use smithay_client_toolkit::seat::keyboard::KeyEvent;
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::osk::{ Event, Modifiers, Panel };
use crate::Keysym;
use crate::tree::find_handlers;
use crate::types::{ Point, Rect };
/// Fit a surface's panel to the bottom of its `pw` by `ph` pixels; returns the height it takes.
pub( crate ) fn place_osk( osk: &mut Option<Panel>, pw: u32, ph: u32, sf: f32 ) -> f32
{
let Some( panel ) = osk.as_mut() else { return 0.0 };
let h = panel.height( sf ).min( ph as f32 );
panel.set_rect( Rect { x: 0.0, y: ph as f32 - h, width: pw as f32, height: h }, sf );
h
}
impl<A: App> AppData<A>
{
fn builtin_keyboard( &self ) -> bool
{
self.builtin_osk || self.app.builtin_keyboard()
}
pub( crate ) fn show_osk( &mut self, focus: SurfaceFocus )
{
if !self.builtin_keyboard() { return; }
let ss = self.surface_mut( focus );
let purpose = ss.focused_idx
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| h.input_purpose() )
.unwrap_or_default();
if ss.osk.is_none()
{
let lang = std::env::var( "LANG" ).unwrap_or_default();
ss.osk = Some( Panel::for_language( &lang ) );
}
if let Some( panel ) = ss.osk.as_mut() { panel.set_purpose( purpose ); }
let sf = ss.scale_factor.max( 1 ) as f32;
let ( pw, ph ) = ( ss.width * ss.scale_factor.max( 1 ) as u32, ss.height * ss.scale_factor.max( 1 ) as u32 );
place_osk( &mut ss.osk, pw, ph, sf );
ss.request_redraw();
}
pub( crate ) fn hide_osk( &mut self, focus: SurfaceFocus )
{
let ss = self.surface_mut( focus );
if ss.osk.take().is_some() { ss.request_redraw(); }
}
/// A press on the text field that already had the focus brings a hidden keyboard back.
pub( crate ) fn osk_after_press( &mut self, focus: SurfaceFocus, hit: Option<usize> )
{
let Some( idx ) = hit else { return };
let ss = self.surface( focus );
let is_text = find_handlers( &ss.frame.widget_rects, idx ).map( |h| h.is_text_input() ).unwrap_or( false );
if is_text && ss.osk.is_none() && self.press_on_focused { self.show_osk( focus ); }
}
/// `true` when the press landed on the panel and was consumed.
pub( crate ) fn osk_press( &mut self, focus: SurfaceFocus, pos: Point ) -> bool
{
let now = Instant::now();
let Some( event ) = self.surface_mut( focus ).osk.as_mut().and_then( |p| p.press_down( pos, now ) ) else { return false };
self.apply_osk_event( focus, event );
true
}
/// `true` while a key is held, so the motion is the panel's and not a drag.
pub( crate ) fn osk_move( &mut self, focus: SurfaceFocus, pos: Point ) -> bool
{
let ss = self.surface_mut( focus );
let Some( panel ) = ss.osk.as_mut().filter( |p| p.pressed().is_some() ) else { return false };
if panel.press_move( pos ) { ss.request_redraw(); }
true
}
/// `true` when the release ended a key press.
pub( crate ) fn osk_release( &mut self, focus: SurfaceFocus, pos: Point ) -> bool
{
let now = Instant::now();
let Some( event ) = self.surface_mut( focus ).osk.as_mut().and_then( |p| p.press_up( pos, now ) ) else { return false };
self.apply_osk_event( focus, event );
true
}
pub( crate ) fn cancel_osk_presses( &mut self )
{
for ss in std::iter::once( &mut self.main ).chain( self.overlays.values_mut() )
{
if let Some( panel ) = ss.osk.as_mut() { panel.cancel_press(); }
}
}
pub( crate ) fn next_osk_wakeup( &self ) -> Option<Duration>
{
let now = Instant::now();
std::iter::once( &self.main ).chain( self.overlays.values() )
.filter_map( |ss| ss.osk.as_ref().and_then( Panel::next_deadline ) )
.map( |deadline| deadline.saturating_duration_since( now ) )
.min()
}
pub( crate ) fn check_osk_deadlines( &mut self )
{
let now = Instant::now();
let mut due: Vec<( SurfaceFocus, Event )> = Vec::new();
if let Some( event ) = self.main.osk.as_mut().and_then( |p| p.tick( now ) ) { due.push( ( SurfaceFocus::Main, event ) ); }
for ( id, ss ) in self.overlays.iter_mut()
{
if let Some( event ) = ss.osk.as_mut().and_then( |p| p.tick( now ) ) { due.push( ( SurfaceFocus::Overlay( *id ), event ) ); }
}
for ( focus, event ) in due { self.apply_osk_event( focus, event ); }
}
fn apply_osk_event( &mut self, focus: SurfaceFocus, event: Event )
{
match event
{
Event::Text( text ) => self.handle_text_insert( focus, &text ),
Event::Backspace => self.handle_backspace( focus ),
Event::Enter => self.osk_enter( focus ),
Event::Key( keysym, mods ) => self.osk_key( focus, keysym, mods ),
Event::Hide => self.hide_osk( focus ),
Event::Changed => {}
}
self.surface_mut( focus ).request_redraw();
}
/// A key the panel cannot type as text goes through the same dispatch as a physical one.
fn osk_key( &mut self, focus: SurfaceFocus, keysym: Keysym, mods: Modifiers )
{
let utf8 = ( !mods.any() ).then( || keysym.key_char() ).flatten().map( String::from );
let event = KeyEvent { time: 0, raw_code: 0, keysym, utf8 };
let ctrl = std::mem::replace( &mut self.ctrl_pressed, mods.ctrl );
self.dispatch_key( focus, event );
self.ctrl_pressed = ctrl;
}
/// Enter as the physical key does it: a newline in a multiline field, otherwise the field's submit.
fn osk_enter( &mut self, focus: SurfaceFocus )
{
let ss = self.surface( focus );
let Some( idx ) = ss.focused_idx else { return };
let Some( handlers ) = find_handlers( &ss.frame.widget_rects, idx ) else { return };
if handlers.is_multiline_text_input()
{
self.handle_text_insert( focus, "\n" );
} else if let Some( msg ) = handlers.submit_msg().or_else( || handlers.press_msg() ) {
self.pending_msgs.push( msg );
}
}
}
+8 -5
View File
@@ -239,6 +239,7 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
let builtin_osk = text_input_manager.is_none() || std::env::var_os( "LTK_BUILTIN_OSK" ).is_some_and( |v| v == "1" );
// wl_data_device_manager: optional. Required for cross-application
// copy/paste; absence means the clipboard stays process-local.
let data_device_manager =
@@ -313,7 +314,9 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
current_cursor_shape: None,
text_input_manager,
text_input: None,
builtin_osk,
text_input_secure: false,
text_input_purpose: crate::osk::Purpose::Normal,
external_text_input: None,
activation_state,
activation_token_pending,
@@ -460,11 +463,10 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
// at a fixed rate. The one exception is a pending long-press:
// cap the wait at its deadline so a perfectly still press still
// fires on time.
let timeout = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
{
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
( a, b ) => a.or( b ),
};
let timeout = [ data.next_long_press_wakeup(), data.next_tooltip_wakeup(), data.next_osk_wakeup() ]
.into_iter()
.flatten()
.min();
match event_loop.dispatch( timeout, &mut data )
{
Ok( () ) => {},
@@ -525,6 +527,7 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
// of the gesture.
data.check_long_press_deadlines();
data.check_tooltip_deadline();
data.check_osk_deadlines();
// Poll external messages (immediate async results, e.g. PAM auth channel)
let ext: Vec<_> = data.app.poll_external();
+1
View File
@@ -314,6 +314,7 @@ fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
pressed_idx: None,
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
text_scroll: HashMap::new(),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: HashMap::new(),
+6
View File
@@ -163,6 +163,8 @@ pub( crate ) struct FrameState<Msg: Clone>
/// collapse the selection by setting anchor = cursor; Shift+arrow extends by
/// leaving anchor put while moving cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
/// Where each multiline field was last scrolled by hand, by `flat_idx`.
pub text_scroll: HashMap<usize, crate::widget::text_edit::TextScroll>,
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
/// Per-scroll-viewport offset, indexed by `flat_idx` of the `Scroll` widget.
/// Tuple is `(x, y)` in physical pixels: x is applied only when the widget's
@@ -196,6 +198,7 @@ impl<Msg: Clone> FrameState<Msg>
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
text_scroll: HashMap::new(),
accessible_extras: Vec::new(),
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
@@ -246,6 +249,8 @@ pub( crate ) struct SurfaceState<Msg: Clone>
/// Built-in Copy / Cut / Paste context menu when shown on this
/// surface. `None` means no menu is up.
pub context_menu: Option<super::context_menu::ContextMenu>,
/// The built-in on-screen keyboard while a text field of this surface has the focus and no input method serves it.
pub osk: Option<crate::osk::Panel>,
/// Per-surface gesture state machine. Owns the press →
/// (long-press? / drag? / scroll? / swipe?) → release lifecycle,
/// driven by both pointer and touch handlers. The input layer
@@ -331,6 +336,7 @@ impl<Msg: Clone> SurfaceState<Msg>
primary_touch_id: None,
touch_slots: HashMap::new(),
context_menu: None,
osk: None,
gesture: GestureState::new(),
titlebar_height,
titlebar_base: titlebar_height,
+14 -9
View File
@@ -13,10 +13,11 @@ use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, secure: bool )
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, secure: bool, purpose: crate::osk::Purpose )
{
self.text_input_secure = secure;
let ( hint, purpose ) = content_type( secure );
self.text_input_purpose = purpose;
let ( hint, purpose ) = content_type( secure, purpose );
match ( &self.text_input_manager, &self.text_input )
{
( Some( manager ), None ) =>
@@ -70,7 +71,7 @@ impl<A: App> AppData<A>
( Some( _ ), _ ) if self.text_input.is_none() =>
{
let qh = self.qh.clone();
self.activate_text_input( &qh, false );
self.activate_text_input( &qh, false, crate::osk::Purpose::Normal );
}
( Some( new ), Some( old ) ) if new != old => self.reenable_text_input(),
( None, Some( _ ) ) => self.deactivate_text_input(),
@@ -83,7 +84,7 @@ impl<A: App> AppData<A>
{
if let Some( ti ) = &self.text_input
{
let ( hint, purpose ) = content_type( self.text_input_secure );
let ( hint, purpose ) = content_type( self.text_input_secure, self.text_input_purpose );
ti.enable();
ti.set_content_type( hint, purpose );
ti.commit();
@@ -121,15 +122,19 @@ impl<A: App> AppData<A>
/// Map a field's `secure` flag to the text-input-v3 content type. Secure
/// fields are flagged `Password` with `SensitiveData | HiddenText` so the
/// IME/OSK skips prediction, autocorrect and storing the value.
fn content_type( secure: bool ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose )
fn content_type( secure: bool, purpose: crate::osk::Purpose ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose )
{
if secure
{
(
return (
zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText,
zwp_text_input_v3::ContentPurpose::Password,
)
} else {
( zwp_text_input_v3::ContentHint::None, zwp_text_input_v3::ContentPurpose::Normal )
);
}
let purpose = match purpose
{
crate::osk::Purpose::Normal => zwp_text_input_v3::ContentPurpose::Normal,
crate::osk::Purpose::Terminal => zwp_text_input_v3::ContentPurpose::Terminal,
};
( zwp_text_input_v3::ContentHint::None, purpose )
}
+1
View File
@@ -9,4 +9,5 @@
pub( crate ) mod cursor;
pub( crate ) mod insert_delete;
pub( crate ) mod selection;
pub( crate ) mod scroll;
pub( crate ) mod ime;
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_widget_at;
use crate::types::Point;
use crate::widget::text_edit::scroll::{ TextScroll, Window, WHEEL_LINES };
impl<A: App> AppData<A>
{
pub( crate ) fn text_scroll( &self, focus: SurfaceFocus, idx: usize ) -> TextScroll
{
self.surface( focus ).frame.text_scroll.get( &idx ).copied().unwrap_or_default()
}
/// Scroll the multiline field under `pos` by `notches` of a wheel
/// or, when there are none, by `logical_px` of touchpad motion.
/// `false` when no field is there or it cannot move that way, so
/// the caller can hand the motion to a viewport around it.
pub( crate ) fn scroll_text_under( &mut self, focus: SurfaceFocus, pos: Point, notches: f32, logical_px: f32 ) -> bool
{
let Some( idx ) = find_widget_at( &self.surface( focus ).frame.widget_rects, pos ) else { return false };
let Some( ( rect, value, true, false, _, font_size ) ) = self.text_input_geometry( focus, idx ) else { return false };
let ss = self.surface( focus );
let cursor_pos = ss.frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
let scroll = self.text_scroll( focus, idx );
let Some( canvas ) = ss.canvas.as_ref() else { return false };
let window = Window::resolve( canvas, rect, &value, font_size, cursor_pos, scroll );
let delta_px = if notches != 0.0
{
notches * WHEEL_LINES * window.metrics.line_h
} else {
logical_px * ss.scale_factor.max( 1 ) as f32
};
if !window.can_scroll( delta_px ) { return false; }
let ( next, moved ) = scroll.scrolled( window.first_line, window.max_first, cursor_pos, delta_px, window.metrics.line_h );
let ss = self.surface_mut( focus );
ss.frame.text_scroll.insert( idx, next );
if moved { ss.request_redraw(); }
true
}
}
+6 -3
View File
@@ -98,11 +98,12 @@ impl<A: App> AppData<A>
};
if value.is_empty() { return; }
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
let scroll = self.text_scroll( focus, idx );
let click_byte = {
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size,
)
};
let ( start, end ) = word_bounds_at( &value, click_byte );
@@ -134,12 +135,13 @@ impl<A: App> AppData<A>
None => return,
};
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
let scroll = self.text_scroll( focus, idx );
let byte_offset =
{
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size,
)
};
let ss = self.surface_mut( focus );
@@ -168,12 +170,13 @@ impl<A: App> AppData<A>
None => return,
};
let cursor_pos = self.surface( focus ).frame.cursor_state.get( &idx ).copied().unwrap_or( 0 );
let scroll = self.text_scroll( focus, idx );
let byte_offset =
{
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size,
)
};
let ss = self.surface_mut( focus );
+1
View File
@@ -29,6 +29,7 @@ impl<A: App> AppData<A>
let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pp;
self.app.on_pointer_move( pp.x, pp.y );
if self.osk_move( focus, pp ) { return; }
// Hover tracking — pointer-only (touch has no hover).
// Runs before the gesture motion so the cache-dirty
+2
View File
@@ -109,6 +109,7 @@ impl<A: App> AppData<A>
return;
}
}
if self.osk_press( focus, pos ) { return; }
// Client-side title bar interaction — pointer-only
// (touch never hits a titlebar; layer-shell surfaces
@@ -184,6 +185,7 @@ impl<A: App> AppData<A>
};
self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx;
self.set_focus( focus, outcome.hit_idx, qh );
self.osk_after_press( focus, outcome.hit_idx );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
+1
View File
@@ -27,6 +27,7 @@ impl<A: App> AppData<A>
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
self.app.on_pointer_button( pos.x, pos.y, false );
if self.osk_release( focus, pos ) { return; }
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
+7
View File
@@ -30,6 +30,13 @@ impl<A: App> AppData<A>
return;
};
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
let notches = if vertical.value120 != 0
{
vertical.value120 as f32 / 120.0
} else {
vertical.discrete as f32
};
if self.scroll_text_under( focus, pos, notches, vertical.absolute as f32 ) { return; }
let scroll_hit =
{
let ss = self.surface( focus );
+9
View File
@@ -151,6 +151,7 @@ impl<A: App> TouchHandler for AppData<A>
return;
}
}
if self.osk_press( focus, pos ) { return; }
let outcome =
{
@@ -161,6 +162,7 @@ impl<A: App> TouchHandler for AppData<A>
};
self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx;
self.set_focus( focus, outcome.hit_idx, qh );
self.osk_after_press( focus, outcome.hit_idx );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
@@ -300,6 +302,7 @@ impl<A: App> TouchHandler for AppData<A>
}
self.pointer_pos = pp;
if self.osk_move( focus, pp ) { return; }
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
@@ -355,6 +358,11 @@ impl<A: App> AppData<A>
fn release_primary( &mut self, focus: SurfaceFocus )
{
let pos = self.pointer_pos;
if self.osk_release( focus, pos )
{
self.surface_mut( focus ).primary_touch_id = None;
return;
}
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
@@ -394,6 +402,7 @@ impl<A: App> AppData<A>
ss.primary_touch_id = None;
ss.gesture.on_cancel();
};
self.cancel_osk_presses();
collect( &mut self.main, &mut aux_releases );
for ss in self.overlays.values_mut()
{
+2
View File
@@ -285,6 +285,7 @@ pub( crate ) static TEST_GLOBALS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::
pub mod types;
pub mod font_face;
pub mod osk;
pub( crate ) mod render;
pub( crate ) mod system_fonts;
pub( crate ) mod text_shaping;
@@ -346,6 +347,7 @@ pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use render::is_software_render;
pub use render::Canvas;
pub use font_face::{ FontFace, LineMetrics, Metrics, OutlineBounds };
pub use osk::Purpose as InputPurpose;
pub use wallpaper::{ WallpaperBundle, ImageData };
pub use nav_stack::NavStack;
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::Keysym;
use super::{ Action, Modifier, Panel };
const TEXT_SIZE: f32 = 20.0;
const ACTION_SIZE: f32 = 14.0;
const ICON_SIZE: f32 = 22.0;
const RADIUS: f32 = 6.0;
/// The theme icon a key shows; its label is the fallback when the theme lacks it.
fn icon_for( key: &super::Key ) -> Option<&'static str>
{
match ( &key.action, key.keysym )
{
( Some( Action::Shift ), _ ) => Some( "keyboard/shift" ),
( Some( Action::Backspace ), _ ) => Some( "keyboard/backspace" ),
( Some( Action::Enter ), _ ) => Some( "keyboard/enter" ),
( Some( Action::Hide ), _ ) => Some( "general/down-simple" ),
( Some( Action::Layout ), _ ) => Some( "accessibility/languages" ),
( _, Some( Keysym::Left ) ) => Some( "general/left" ),
( _, Some( Keysym::Right ) ) => Some( "general/right" ),
( _, Some( Keysym::Up ) ) => Some( "general/up" ),
( _, Some( Keysym::Down ) ) => Some( "general/down" ),
_ => None,
}
}
impl Panel
{
/// Paint the panel where [`Panel::set_rect`] put it.
pub fn draw( &mut self, canvas: &mut Canvas )
{
let palette = crate::theme::palette();
let scale = self.scale;
canvas.fill_rect( self.rect, palette.surface_alt, 0.0 );
for i in 0..self.keys.len()
{
let ( rect, key ) = ( self.keys[ i ].rect, self.keys[ i ].key.clone() );
let action = key.action.is_some() || key.keysym.is_some() || key.modifier.is_some();
let pressed = self.pressed() == Some( i );
let held = match key.modifier
{
Some( Modifier::Ctrl ) => self.modifiers().ctrl,
Some( Modifier::Alt ) => self.modifiers().alt,
None => key.action == Some( Action::Shift ) && self.shift_locked(),
};
let lit = pressed || held;
let bg = if lit { palette.accent } else if action { palette.divider } else { palette.surface };
let ink = if lit { palette.bg } else { palette.text_primary };
canvas.fill_rect( rect, bg, RADIUS * scale );
let icon = icon_for( &key ).and_then( |name| self.icon( name, ( ICON_SIZE * scale ) as u32, ink ) );
match icon
{
Some( ( rgba, w, h ) ) =>
{
let dest = Rect { x: rect.x + ( rect.width - w as f32 ) / 2.0, y: rect.y + ( rect.height - h as f32 ) / 2.0, width: w as f32, height: h as f32 };
canvas.draw_image_data( &rgba, w, h, dest, 1.0 );
}
None =>
{
let size = if action { ACTION_SIZE } else { TEXT_SIZE } * scale;
draw_centred( canvas, &key.label, rect, size, ink );
}
}
}
if let Some( popup ) = &self.popup
{
canvas.fill_rect( popup.bounds, palette.surface, RADIUS * scale );
canvas.stroke_rect( popup.bounds, palette.divider, 1.0, RADIUS * scale );
for ( i, ( text, rect ) ) in popup.variants.iter().zip( &popup.rects ).enumerate()
{
let lit = popup.selected == Some( i );
let bg = if lit { palette.accent } else { palette.surface };
let ink = if lit { palette.bg } else { palette.text_primary };
canvas.fill_rect( *rect, bg, RADIUS * scale );
draw_centred( canvas, text, *rect, TEXT_SIZE * scale, ink );
}
}
}
/// The theme icon `name` at `size`, tinted `ink`; rasterised once per combination, a missing icon remembered as such.
fn icon( &mut self, name: &'static str, size: u32, ink: Color ) -> Option<crate::ImageData>
{
let key = ( name, size, [ channel( ink.r ), channel( ink.g ), channel( ink.b ), channel( ink.a ) ] );
self.icons.entry( key )
.or_insert_with( || crate::theme_icon_tinted( name, size, ink ) )
.clone()
}
}
fn channel( v: f32 ) -> u8
{
( v.clamp( 0.0, 1.0 ) * 255.0 ).round() as u8
}
fn draw_centred( canvas: &mut Canvas, text: &str, rect: Rect, size: f32, color: Color )
{
if text.is_empty() { return; }
let w = canvas.measure_text( text, size );
canvas.draw_text( text, rect.x + ( rect.width - w ) / 2.0, rect.y + rect.height / 2.0 + size * 0.35, size, color );
}
+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() );
}
}
+623
View File
@@ -0,0 +1,623 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The on-screen keyboard: layouts from data files, and a [`Panel`] any host drives with presses and draws on a canvas.
//!
//! ltk's own runtime shows the panel under a focused text field when the compositor offers no input method; a compositor draws the same panel in its chrome and delivers the [`Event`]s to the focused client itself.
mod draw;
pub mod layout;
use std::collections::HashMap;
use std::time::{ Duration, Instant };
use crate::types::{ Point, Rect };
use crate::Keysym;
pub use layout::{ Action, Key, Layout, Modifier };
/// How long a key is held before its variants open, or backspace starts repeating.
pub const LONG_PRESS: Duration = Duration::from_millis( 450 );
const REPEAT_INTERVAL: Duration = Duration::from_millis( 60 );
const SHIFT_DOUBLE_TAP: Duration = Duration::from_millis( 350 );
/// Design sizes in logical pixels; the panel multiplies them by its scale.
const KEY_HEIGHT: f32 = 46.0;
const GAP: f32 = 6.0;
const PADDING: f32 = 6.0;
/// What the field being typed into is for; picks the extra row above the layout.
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Default ) ]
pub enum Purpose
{
#[ default ]
Normal,
/// Adds the row of keys a terminal needs: Escape, Tab, Ctrl, Alt, arrows and the shell's symbols.
Terminal,
}
/// Sticky modifiers held for the next key.
#[ derive( Clone, Copy, Debug, Default, PartialEq, Eq ) ]
pub struct Modifiers
{
pub ctrl: bool,
pub alt: bool,
}
impl Modifiers
{
pub fn any( &self ) -> bool
{
self.ctrl || self.alt
}
fn take( &mut self ) -> Self
{
std::mem::take( self )
}
}
/// What the host has to do with a press the panel handled.
#[ derive( Clone, Debug, PartialEq, Eq ) ]
pub enum Event
{
Text( String ),
Backspace,
Enter,
/// A key text cannot express: an arrow, Escape, Tab, or a character under Ctrl or Alt.
Key( Keysym, Modifiers ),
Hide,
/// The panel changed its own state and wants a redraw.
Changed,
}
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
enum Shift
{
Off,
/// Reverts after the next character.
Once,
Locked,
}
#[ derive( Clone, Debug ) ]
pub struct KeyRect
{
pub rect: Rect,
pub key: Key,
}
#[ derive( Clone, Debug ) ]
struct Press
{
key: usize,
at: Instant,
/// Backspace repeats fired so far, `None` for other keys.
repeats: Option<u32>,
}
#[ derive( Clone, Debug ) ]
pub struct Popup
{
pub key: usize,
pub variants: Vec<String>,
pub rects: Vec<Rect>,
pub bounds: Rect,
pub selected: Option<usize>,
}
/// The keyboard's state and geometry. Coordinates are those the host passes in, physical pixels in ltk's runtime.
pub struct Panel
{
layouts: Vec<Layout>,
current: usize,
layer: String,
shift: Shift,
last_shift_at: Option<Instant>,
purpose: Purpose,
/// The rows laid above the layout for the current purpose.
bar: Option<Layout>,
modifiers: Modifiers,
rect: Rect,
scale: f32,
keys: Vec<KeyRect>,
press: Option<Press>,
popup: Option<Popup>,
/// Rasterised action icons by name, size and tint; `None` for one the theme lacks.
icons: HashMap<( &'static str, u32, [ u8; 4 ] ), Option<crate::ImageData>>,
}
impl Panel
{
/// A panel over `layouts`, starting on the one named `preferred` when present. No layouts means the built-in one.
pub fn new( mut layouts: Vec<Layout>, preferred: &str ) -> Self
{
if layouts.is_empty() { layouts.push( layout::fallback() ); }
let current = layouts.iter().position( |l| l.name == preferred ).unwrap_or( 0 );
Self
{
layouts,
current,
layer: layout::BASE_LAYER.to_string(),
shift: Shift::Off,
last_shift_at: None,
purpose: Purpose::Normal,
bar: None,
modifiers: Modifiers::default(),
rect: Rect::default(),
scale: 1.0,
keys: Vec::new(),
press: None,
popup: None,
icons: HashMap::new(),
}
}
/// Every installed layout, the one for `lang` (`es`, `en-GB`…) first.
pub fn for_language( lang: &str ) -> Self
{
let preferred = lang.split( [ '-', '_', '.' ] ).next().unwrap_or( lang ).to_lowercase();
let layouts: Vec<Layout> = layout::available().iter().filter_map( |name| layout::load( name ) ).collect();
Self::new( layouts, &preferred )
}
pub fn layout( &self ) -> &Layout
{
&self.layouts[ self.current ]
}
pub fn layer( &self ) -> &str
{
&self.layer
}
pub fn rect( &self ) -> Rect
{
self.rect
}
pub fn keys( &self ) -> &[ KeyRect ]
{
&self.keys
}
pub fn popup( &self ) -> Option<&Popup>
{
self.popup.as_ref()
}
/// The key currently held, if any.
pub fn pressed( &self ) -> Option<usize>
{
self.press.as_ref().map( |p| p.key )
}
pub fn shift_locked( &self ) -> bool
{
self.shift == Shift::Locked
}
pub fn modifiers( &self ) -> Modifiers
{
self.modifiers
}
pub fn purpose( &self ) -> Purpose
{
self.purpose
}
/// Change what the panel types into; a terminal gets its row of keys above the layout.
pub fn set_purpose( &mut self, purpose: Purpose )
{
if purpose == self.purpose { return; }
self.purpose = purpose;
self.bar = ( purpose == Purpose::Terminal ).then( layout::terminal_bar );
self.modifiers = Modifiers::default();
self.relayout();
}
fn bar_rows( &self ) -> &[ Vec<Key> ]
{
self.bar.as_ref().map( |b| b.rows( layout::BASE_LAYER ) ).unwrap_or( &[] )
}
/// Height the panel needs at `scale` for its current layer and purpose.
pub fn height( &self, scale: f32 ) -> f32
{
let rows = ( self.bar_rows().len() + self.layout().rows( &self.layer ).len() ) as f32;
( rows * KEY_HEIGHT + ( rows - 1.0 ).max( 0.0 ) * GAP + 2.0 * PADDING ) * scale
}
/// Place the keys inside `rect`: the purpose's rows first, each spanning the width, then the layout's, which share one key unit and are centred when narrower than the widest.
pub fn set_rect( &mut self, rect: Rect, scale: f32 )
{
self.rect = rect;
self.scale = scale;
self.keys.clear();
let gap = GAP * scale;
let pad = PADDING * scale;
let key_h = KEY_HEIGHT * scale;
let avail = rect.width - 2.0 * pad;
let mut y = rect.y + pad;
let bar: Vec<Vec<Key>> = self.bar_rows().to_vec();
for row in &bar
{
let units: f32 = row.iter().map( |k| k.width ).sum::<f32>().max( 1.0 );
let unit = ( avail - gap * row.len().saturating_sub( 1 ) as f32 ) / units;
self.push_row( row, rect.x + pad, y, unit, key_h, gap );
y += key_h + gap;
}
let rows: Vec<Vec<Key>> = self.layout().rows( &self.layer ).iter()
.map( |row| row.iter().map( |k| self.labelled( k ) ).collect() )
.collect();
let widest = rows.iter()
.map( |row| row.iter().map( |k| k.width ).sum::<f32>() )
.fold( 0.0_f32, f32::max )
.max( 1.0 );
let unit = ( avail - gap * ( rows.iter().map( Vec::len ).max().unwrap_or( 1 ).saturating_sub( 1 ) ) as f32 ) / widest;
for row in &rows
{
let units: f32 = row.iter().map( |k| k.width ).sum();
let row_w = units * unit + ( row.len().saturating_sub( 1 ) ) as f32 * gap;
self.push_row( row, rect.x + pad + ( avail - row_w ) / 2.0, y, unit, key_h, gap );
y += key_h + gap;
}
self.popup = None;
}
fn push_row( &mut self, row: &[ Key ], mut x: f32, y: f32, unit: f32, key_h: f32, gap: f32 )
{
for key in row
{
let w = key.width * unit;
self.keys.push( KeyRect { rect: Rect { x, y, width: w, height: key_h }, key: key.clone() } );
x += w + gap;
}
}
/// A layout-switch key with no label of its own shows the active layout's.
fn labelled( &self, key: &Key ) -> Key
{
if key.action == Some( Action::Layout ) && key.label.is_empty()
{
return Key { label: self.layout().label.clone(), ..key.clone() };
}
key.clone()
}
fn key_at( &self, pos: Point ) -> Option<usize>
{
self.keys.iter().position( |k| k.rect.contains( pos ) )
}
/// A press at `pos`; `None` when it is outside the panel and the host should treat it as its own.
pub fn press_down( &mut self, pos: Point, now: Instant ) -> Option<Event>
{
if !self.rect.contains( pos ) { return None; }
let Some( idx ) = self.key_at( pos ) else { return Some( Event::Changed ) };
let backspace = self.keys[ idx ].key.action == Some( Action::Backspace );
self.press = Some( Press { key: idx, at: now, repeats: backspace.then_some( 0 ) } );
self.popup = None;
Some( if backspace { Event::Backspace } else { Event::Changed } )
}
/// The held finger or pointer moved; with the variants open, selects the one under it.
pub fn press_move( &mut self, pos: Point ) -> bool
{
let Some( popup ) = self.popup.as_mut() else { return false };
let selected = popup.rects.iter().position( |r| pos.x >= r.x && pos.x < r.x + r.width );
let changed = selected != popup.selected;
popup.selected = selected.or( popup.selected );
changed
}
/// The press ended: type the key, the chosen variant, or act.
pub fn press_up( &mut self, pos: Point, now: Instant ) -> Option<Event>
{
let press = self.press.take()?;
if let Some( popup ) = self.popup.take()
{
let chosen = popup.selected
.or_else( || popup.rects.iter().position( |r| r.contains( pos ) ) )
.and_then( |i| popup.variants.get( i ).cloned() );
return Some( match chosen
{
Some( text ) => self.typed( text ),
None => Event::Changed,
} );
}
if press.repeats.is_some() { return Some( Event::Changed ); }
let key = self.keys.get( press.key )?.key.clone();
Some( self.activate( &key, now ) )
}
/// Time passed with a key held: opens its variants, or repeats backspace.
pub fn tick( &mut self, now: Instant ) -> Option<Event>
{
let press = self.press.as_mut()?;
let held = now.duration_since( press.at );
if let Some( repeats ) = press.repeats.as_mut()
{
if held < LONG_PRESS { return None; }
let due = ( ( held - LONG_PRESS ).as_millis() / REPEAT_INTERVAL.as_millis() ) as u32 + 1;
if due <= *repeats { return None; }
*repeats = due;
return Some( Event::Backspace );
}
if self.popup.is_some() || held < LONG_PRESS { return None; }
let idx = press.key;
let text = self.keys[ idx ].key.text.clone()?;
let variants = self.layout().variants( &text );
if variants.is_empty() { return None; }
self.popup = Some( self.popup_for( idx, variants ) );
Some( Event::Changed )
}
/// When [`Panel::tick`] next has something to do.
pub fn next_deadline( &self ) -> Option<Instant>
{
let press = self.press.as_ref()?;
match press.repeats
{
Some( repeats ) => Some( press.at + LONG_PRESS + REPEAT_INTERVAL * repeats ),
None if self.popup.is_none() => Some( press.at + LONG_PRESS ),
None => None,
}
}
pub fn cancel_press( &mut self )
{
self.press = None;
self.popup = None;
}
fn popup_for( &self, idx: usize, variants: Vec<String> ) -> Popup
{
let key = self.keys[ idx ].rect;
let gap = GAP * self.scale;
let w = key.width.max( KEY_HEIGHT * self.scale );
let total = variants.len() as f32 * w + ( variants.len().saturating_sub( 1 ) ) as f32 * gap;
let x0 = ( key.x + key.width / 2.0 - total / 2.0 ).max( self.rect.x ).min( self.rect.x + self.rect.width - total );
let y = key.y - key.height - gap;
let rects: Vec<Rect> = ( 0..variants.len() )
.map( |i| Rect { x: x0 + i as f32 * ( w + gap ), y, width: w, height: key.height } )
.collect();
let bounds = Rect { x: x0 - gap, y: y - gap, width: total + 2.0 * gap, height: key.height + 2.0 * gap };
Popup { key: idx, variants, rects, bounds, selected: None }
}
fn activate( &mut self, key: &Key, now: Instant ) -> Event
{
if let Some( modifier ) = key.modifier
{
match modifier
{
Modifier::Ctrl => self.modifiers.ctrl = !self.modifiers.ctrl,
Modifier::Alt => self.modifiers.alt = !self.modifiers.alt,
}
return Event::Changed;
}
if let Some( keysym ) = key.keysym
{
return Event::Key( keysym, self.modifiers.take() );
}
match key.action
{
None =>
{
match &key.text
{
Some( t ) => self.typed( t.clone() ),
None => Event::Changed,
}
}
Some( Action::Space ) => self.typed( " ".to_string() ),
Some( Action::Backspace ) => Event::Backspace,
Some( Action::Enter ) => Event::Enter,
Some( Action::Hide ) => Event::Hide,
Some( Action::Shift ) =>
{
let double = self.last_shift_at.is_some_and( |t| now.duration_since( t ) < SHIFT_DOUBLE_TAP );
self.last_shift_at = Some( now );
self.shift = match ( self.shift, double )
{
( Shift::Once, true ) => Shift::Locked,
( Shift::Off, _ ) => Shift::Once,
_ => Shift::Off,
};
self.layer = if self.shift == Shift::Off { layout::BASE_LAYER } else { layout::SHIFT_LAYER }.to_string();
self.relayout();
Event::Changed
}
Some( Action::Layer ) =>
{
if let Some( target ) = key.layer.as_deref().filter( |l| self.layout().has_layer( l ) )
{
self.layer = target.to_string();
self.shift = Shift::Off;
self.relayout();
}
Event::Changed
}
Some( Action::Layout ) =>
{
self.current = ( self.current + 1 ) % self.layouts.len();
self.layer = layout::BASE_LAYER.to_string();
self.shift = Shift::Off;
self.relayout();
Event::Changed
}
}
}
/// A character under a sticky modifier goes out as a key, since text cannot carry Ctrl or Alt.
fn typed( &mut self, text: String ) -> Event
{
if self.shift == Shift::Once
{
self.shift = Shift::Off;
self.layer = layout::BASE_LAYER.to_string();
self.relayout();
}
if self.modifiers.any()
{
if let Some( ch ) = text.chars().next().filter( |_| text.chars().count() == 1 )
{
return Event::Key( Keysym::from_char( ch ), self.modifiers.take() );
}
}
Event::Text( text )
}
fn relayout( &mut self )
{
let ( rect, scale ) = ( self.rect, self.scale );
self.set_rect( rect, scale );
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn panel() -> Panel
{
let mut p = Panel::new( Vec::new(), "en" );
p.set_rect( Rect { x: 0.0, y: 0.0, width: 400.0, height: p.height( 1.0 ) }, 1.0 );
p
}
fn centre( p: &Panel, text: &str ) -> Point
{
let k = p.keys().iter().find( |k| k.key.text.as_deref() == Some( text ) ).expect( "key present" );
Point { x: k.rect.x + k.rect.width / 2.0, y: k.rect.y + k.rect.height / 2.0 }
}
fn action_centre( p: &Panel, action: Action ) -> Point
{
let k = p.keys().iter().find( |k| k.key.action == Some( action.clone() ) ).expect( "action key present" );
Point { x: k.rect.x + k.rect.width / 2.0, y: k.rect.y + k.rect.height / 2.0 }
}
#[ test ]
fn rows_fit_the_width_and_narrow_rows_are_centred()
{
let p = panel();
for k in p.keys() { assert!( k.rect.x >= 0.0 && k.rect.x + k.rect.width <= 400.0 + 0.01 ); }
let first_q = p.keys().iter().find( |k| k.key.text.as_deref() == Some( "q" ) ).unwrap().rect;
let first_a = p.keys().iter().find( |k| k.key.text.as_deref() == Some( "a" ) ).unwrap().rect;
assert!( first_a.x > first_q.x );
}
#[ test ]
fn a_tap_types_the_key_on_release()
{
let mut p = panel();
let now = Instant::now();
let at = centre( &p, "q" );
assert_eq!( p.press_down( at, now ), Some( Event::Changed ) );
assert_eq!( p.press_up( at, now ), Some( Event::Text( "q".into() ) ) );
}
#[ test ]
fn shift_is_one_shot_and_locks_on_a_double_tap()
{
let mut p = panel();
let now = Instant::now();
let shift = action_centre( &p, Action::Shift );
p.press_down( shift, now ); p.press_up( shift, now );
assert_eq!( p.layer(), layout::SHIFT_LAYER );
let q = centre( &p, "Q" );
p.press_down( q, now ); assert_eq!( p.press_up( q, now ), Some( Event::Text( "Q".into() ) ) );
assert_eq!( p.layer(), layout::BASE_LAYER );
let shift = action_centre( &p, Action::Shift );
p.press_down( shift, now ); p.press_up( shift, now );
p.press_down( shift, now + Duration::from_millis( 100 ) ); p.press_up( shift, now + Duration::from_millis( 100 ) );
assert!( p.shift_locked() );
let q = centre( &p, "Q" );
p.press_down( q, now ); p.press_up( q, now );
assert_eq!( p.layer(), layout::SHIFT_LAYER );
}
#[ test ]
fn a_long_press_opens_the_variants_and_the_release_picks_one()
{
let mut p = panel();
let now = Instant::now();
let e = centre( &p, "e" );
p.press_down( e, now );
assert_eq!( p.tick( now + Duration::from_millis( 100 ) ), None );
assert_eq!( p.tick( now + LONG_PRESS ), Some( Event::Changed ) );
let second = p.popup().unwrap().rects[ 1 ];
let inside = Point { x: second.x + 1.0, y: second.y + 1.0 };
assert!( p.press_move( inside ) );
assert_eq!( p.press_up( inside, now + LONG_PRESS ), Some( Event::Text( "è".into() ) ) );
assert!( p.popup().is_none() );
}
#[ test ]
fn backspace_fires_on_press_and_repeats_while_held()
{
let mut p = panel();
let now = Instant::now();
let bs = action_centre( &p, Action::Backspace );
assert_eq!( p.press_down( bs, now ), Some( Event::Backspace ) );
assert_eq!( p.tick( now + Duration::from_millis( 100 ) ), None );
assert_eq!( p.tick( now + LONG_PRESS + Duration::from_millis( 1 ) ), Some( Event::Backspace ) );
assert_eq!( p.tick( now + LONG_PRESS + Duration::from_millis( 1 ) ), None );
assert_eq!( p.tick( now + LONG_PRESS + REPEAT_INTERVAL * 2 ), Some( Event::Backspace ) );
assert_eq!( p.press_up( bs, now + LONG_PRESS * 2 ), Some( Event::Changed ) );
}
#[ test ]
fn layers_and_layouts_switch()
{
let mut p = panel();
let now = Instant::now();
let sym = action_centre( &p, Action::Layer );
p.press_down( sym, now ); p.press_up( sym, now );
assert_eq!( p.layer(), "symbols" );
assert!( p.keys().iter().any( |k| k.key.text.as_deref() == Some( "1" ) ) );
let switch = p.keys().iter().find( |k| k.key.action == Some( Action::Layout ) ).unwrap();
assert_eq!( switch.key.label, "EN" );
}
#[ test ]
fn a_terminal_gets_its_row_and_sticky_modifiers()
{
let mut p = panel();
let now = Instant::now();
let plain = p.height( 1.0 );
p.set_purpose( Purpose::Terminal );
assert!( p.height( 1.0 ) > plain );
p.set_rect( Rect { x: 0.0, y: 0.0, width: 400.0, height: p.height( 1.0 ) }, 1.0 );
let esc = p.keys().iter().find( |k| k.key.keysym == Some( Keysym::Escape ) ).unwrap().rect;
let at = Point { x: esc.x + 1.0, y: esc.y + 1.0 };
p.press_down( at, now );
assert_eq!( p.press_up( at, now ), Some( Event::Key( Keysym::Escape, Modifiers::default() ) ) );
let ctrl = p.keys().iter().find( |k| k.key.modifier == Some( Modifier::Ctrl ) ).unwrap().rect;
let at = Point { x: ctrl.x + 1.0, y: ctrl.y + 1.0 };
p.press_down( at, now ); p.press_up( at, now );
assert!( p.modifiers().ctrl );
let c = centre( &p, "c" );
p.press_down( c, now );
assert_eq!( p.press_up( c, now ), Some( Event::Key( Keysym::from_char( 'c' ), Modifiers { ctrl: true, alt: false } ) ) );
assert!( !p.modifiers().ctrl );
p.set_purpose( Purpose::Normal );
assert_eq!( p.height( 1.0 ), plain );
}
#[ test ]
fn a_press_outside_the_panel_is_not_consumed()
{
let mut p = panel();
assert_eq!( p.press_down( Point { x: 10.0, y: -10.0 }, Instant::now() ), None );
}
}
+3 -1
View File
@@ -92,12 +92,13 @@ impl<Msg: Clone> Element<Msg>
pressed: bool,
cursor_pos: usize,
selection_anchor: usize,
text_scroll: text_edit::TextScroll,
)
{
match self
{
Element::Button( b ) => b.draw( canvas, rect, focused, hovered, pressed ),
Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor ),
Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor, text_scroll ),
Element::Image( i ) => i.draw( canvas, rect ),
Element::Column( c ) => c.draw( canvas, rect, focused ),
Element::Row( r ) => r.draw( canvas, rect, focused ),
@@ -320,6 +321,7 @@ impl<Msg: Clone> Element<Msg>
// or without a toggle) opts in, regardless of
// the user's current visibility choice.
secure: t.secure || t.password_toggle.is_some(),
purpose: t.purpose,
multiline: t.is_multiline(),
align: t.align,
font_size: t.font_size,
+13 -1
View File
@@ -46,6 +46,7 @@ pub enum WidgetHandlers<Msg: Clone>
/// every per-frame handler snapshot so credential text does not
/// linger across multiple cloned `WidgetHandlers` allocations.
secure: bool,
purpose: crate::osk::Purpose,
/// `true` when the source `TextEdit` was built with
/// `.multiline( true )`. The keyboard dispatch reads this so
/// pressing Enter inserts a `\n` instead of firing
@@ -141,7 +142,7 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected },
WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() },
WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() },
WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } =>
WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, purpose, multiline, align, font_size, select_on_focus, password_toggle_msg } =>
{
WidgetHandlers::TextEdit
{
@@ -150,6 +151,7 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
on_submit: on_submit.clone(),
on_focus: on_focus.clone(),
secure: *secure,
purpose: *purpose,
multiline: *multiline,
align: *align,
font_size: *font_size,
@@ -188,6 +190,16 @@ impl<Msg: Clone> WidgetHandlers<Msg>
matches!( self, WidgetHandlers::TextEdit { .. } )
}
/// What a text field is for; `Normal` for anything else.
pub fn input_purpose( &self ) -> crate::osk::Purpose
{
match self
{
WidgetHandlers::TextEdit { purpose, .. } => *purpose,
_ => crate::osk::Purpose::Normal,
}
}
pub fn is_disabled( &self ) -> bool
{
match self
+2 -1
View File
@@ -138,10 +138,11 @@ impl<Msg: Clone + 'static> Notebook<Msg>
.view
};
// The page gets the height left under the strip, so a page that fills its space (a scrolled list, a flex) has some.
column()
.spacing( Length::widget( theme::SPACING ) )
.push( strip )
.push( active_view )
.push( super::flex::flex( active_view ) )
.into()
}
}
+11 -21
View File
@@ -6,9 +6,9 @@ use crate::types::Rect;
use super::TextEdit;
use super::hit_test::{ single_line_align_offset, single_line_scroll_x };
use super::metrics::{ single_line_pad_h, single_line_scale, MultilineMetrics };
use super::metrics::{ single_line_pad_h, single_line_scale };
use super::scroll::{ TextScroll, Window };
use super::theme;
use super::wrapping::compute_visual_lines;
/// Axis-aligned intersection of two rects. Returns `None` when the rects
/// do not overlap (zero-area / negative-extent results count as
@@ -79,6 +79,7 @@ impl<Msg: Clone> TextEdit<Msg>
/// from its persistent cursor state rather than from the widget itself.
/// `selection_anchor` is the other end of the selection range; when
/// `selection_anchor == cursor_pos` no highlight is painted.
/// `scroll` is where a multiline field was last scrolled by hand.
pub fn draw(
&self,
canvas: &mut Canvas,
@@ -86,11 +87,12 @@ impl<Msg: Clone> TextEdit<Msg>
focused: bool,
cursor_pos: usize,
selection_anchor: usize,
scroll: TextScroll,
)
{
if self.is_multiline()
{
self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor );
self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor, scroll );
return;
}
// Borderless fields skip the surface fill + border stroke
@@ -268,6 +270,7 @@ impl<Msg: Clone> TextEdit<Msg>
focused: bool,
cursor_pos: usize,
selection_anchor: usize,
scroll: TextScroll,
)
{
let border_c = if focused { theme::focus_border() } else { theme::border() };
@@ -275,26 +278,13 @@ impl<Msg: Clone> TextEdit<Msg>
canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI );
canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI );
let m = MultilineMetrics::resolve( canvas, self.font_size );
let window = Window::resolve( canvas, rect, &self.value, self.font_size, cursor_pos, scroll );
let m = window.metrics;
let line_h = m.line_h;
// Compute the visual-line layout once per draw. Iterators
// later go through this list; long logical lines are
// soft-wrapped at the last whitespace before the row would
// overflow without mutating the buffer.
let visual_lines = compute_visual_lines( canvas, &self.value, m.inner_width( rect ), m.font_size );
// Vertical auto-scroll: keep the cursor's visual line on
// screen. The cursor sits in the *first* visual line whose
// `end >= cursor` — at a wrap boundary that places it at the
// trailing edge of the previous line rather than the start of
// the next, matching the convention of every standard text
// editor.
let visual_lines = &window.lines;
let safe_cursor = cursor_pos.min( self.value.len() );
let cursor_visual_idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
let first_line = m.first_visible_line( rect, visual_lines.len(), cursor_visual_idx );
let cursor_visual_idx = window.cursor_line;
let first_line = window.first_line;
let baseline_0 = rect.y + m.pad_v + m.glyph_h;
+11 -20
View File
@@ -4,8 +4,8 @@
use crate::render::Canvas;
use crate::types::{ Length, Point, Rect };
use super::metrics::{ single_line_pad_h, MultilineMetrics };
use super::wrapping::compute_visual_lines;
use super::metrics::single_line_pad_h;
use super::scroll::{ TextScroll, Window };
/// Convert a pointer position inside the widget rect to the byte offset
/// in `value` the cursor should land on. Free function so the runtime
@@ -14,9 +14,9 @@ use super::wrapping::compute_visual_lines;
/// element tree to find the original [`super::TextEdit`] struct.
///
/// `multiline` and `secure` mirror the matching fields on the source
/// widget; `cursor_pos` is the current cursor — needed by the
/// multiline branch to reproduce the same vertical scroll the most
/// recent [`super::TextEdit::draw`] call computed.
/// widget; `cursor_pos` and `scroll` are the runtime's, so the
/// multiline branch sees the same lines the most recent
/// [`super::TextEdit::draw`] call showed.
pub( crate ) fn byte_offset_at(
canvas: &Canvas,
rect: Rect,
@@ -25,6 +25,7 @@ pub( crate ) fn byte_offset_at(
multiline: bool,
secure: bool,
cursor_pos: usize,
scroll: TextScroll,
align: crate::widget::text::TextAlign,
font_size: Option<Length>,
) -> usize
@@ -33,23 +34,13 @@ pub( crate ) fn byte_offset_at(
if multiline && !secure
{
let m = MultilineMetrics::resolve( canvas, font_size );
// Recompute the visual layout from current state. Same call
// as `draw_multiline` makes, so the click maps to the line /
// column the user actually sees.
let visual_lines = compute_visual_lines( canvas, value, m.inner_width( rect ), m.font_size );
let safe_cursor = cursor_pos.min( value.len() );
let cursor_visual_idx = visual_lines.iter()
.position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end )
.unwrap_or( visual_lines.len().saturating_sub( 1 ) );
let total_lines = visual_lines.len();
let first_line = m.first_visible_line( rect, total_lines, cursor_visual_idx );
let window = Window::resolve( canvas, rect, value, font_size, cursor_pos, scroll );
let m = window.metrics;
let total_lines = window.lines.len();
let visual_idx = ( ( pos.y - rect.y - m.pad_v ) / m.line_h )
.max( 0.0 ) as usize;
let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
let vl = visual_lines[ target_idx ];
let target_idx = ( window.first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) );
let vl = window.lines[ target_idx ];
byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, m.font_size )
} else {
-13
View File
@@ -66,17 +66,4 @@ impl MultilineMetrics
let inner_h = ( rect.height - self.pad_v * 2.0 ).max( self.line_h );
( inner_h / self.line_h ).floor().max( 1.0 ) as usize
}
/// First visual row on screen so that the caret's row stays visible.
pub( crate ) fn first_visible_line( &self, rect: Rect, total_lines: usize, cursor_line: usize ) -> usize
{
let visible = self.visible_lines( rect );
let max_first = total_lines.saturating_sub( visible );
if cursor_line + 1 > visible
{
( cursor_line + 1 - visible ).min( max_first )
} else {
0
}
}
}
+15 -1
View File
@@ -19,12 +19,14 @@ pub( crate ) mod wrapping;
pub( crate ) mod hit_test;
pub( crate ) mod cursor;
pub( crate ) mod metrics;
pub( crate ) mod scroll;
mod draw;
#[ cfg( test ) ]
mod tests;
pub use draw::password_toggle_hit_zone;
pub( crate ) use hit_test::byte_offset_at;
pub( crate ) use scroll::TextScroll;
pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up };
/// Resolve the font size into a concrete logical px. `Some( len )` resolves
@@ -111,6 +113,8 @@ pub struct TextEdit<Msg: Clone>
pub( crate ) color: Option<Color>,
/// When `true`, the value is rendered as bullet characters (password mode).
pub( crate ) secure: bool,
/// What the field is for; picks the on-screen keyboard's extra row and the input method's content purpose.
pub( crate ) purpose: crate::osk::Purpose,
/// When `true`, the widget renders as a multi-row text area: the
/// box grows to [`Self::rows`] visible rows, line breaks in the
/// value are honoured at draw time, and pressing Enter inserts a
@@ -201,6 +205,7 @@ impl<Msg: Clone> TextEdit<Msg>
on_focus: None,
color: None,
secure: false,
purpose: crate::osk::Purpose::Normal,
multiline: false,
rows: theme::ROWS_DEFAULT,
cursor_pos,
@@ -423,6 +428,13 @@ impl<Msg: Clone> TextEdit<Msg>
self
}
/// What the field is for. A `Terminal` field gets the on-screen keyboard's row of Escape, Tab, Ctrl, Alt and arrows, and tells the compositor's input method the same.
pub fn purpose( mut self, purpose: crate::osk::Purpose ) -> Self
{
self.purpose = purpose;
self
}
/// Emit `msg` when the field takes the keyboard focus.
pub fn on_focus( mut self, msg: Msg ) -> Self
{
@@ -492,10 +504,11 @@ impl<Msg: Clone> TextEdit<Msg>
rect: Rect,
pos: crate::types::Point,
cursor_pos: usize,
scroll: TextScroll,
) -> usize
{
byte_offset_at(
canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, self.align, self.font_size,
canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, self.font_size,
)
}
@@ -569,6 +582,7 @@ impl<Msg: Clone> TextEdit<Msg>
on_focus: self.on_focus.clone().map( |m| ( *f )( m ) ),
color: self.color,
secure: self.secure,
purpose: self.purpose,
multiline: self.multiline,
rows: self.rows,
cursor_pos: self.cursor_pos,
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Vertical scroll of a multiline field: the window of visual lines on
//! screen, and how the wheel moves it.
use crate::render::Canvas;
use crate::types::{ Length, Rect };
use super::metrics::MultilineMetrics;
use super::wrapping::{ compute_visual_lines, VisualLine };
/// Visual lines one wheel notch moves.
pub( crate ) const WHEEL_LINES: f32 = 3.0;
/// Where a multiline field was last scrolled by hand: the first visual
/// line asked for, and the cursor at that moment. The view holds there
/// while the cursor stays put and follows it again once it moves.
#[ derive( Clone, Copy, Debug, Default, PartialEq ) ]
pub struct TextScroll
{
pub( crate ) first_line: usize,
pub( crate ) cursor_pos: usize,
/// Touchpad motion short of a whole line, in pixels.
pub( crate ) remainder: f32,
}
impl TextScroll
{
/// The first visual line on screen for a field `visible` lines
/// tall holding `total_lines`, with the cursor on `cursor_line`.
pub( crate ) fn first_visible_line(
self,
visible: usize,
total_lines: usize,
cursor_line: usize,
cursor_pos: usize,
) -> usize
{
let max_first = total_lines.saturating_sub( visible );
let first = self.first_line.min( max_first );
if self.cursor_pos == cursor_pos
{
first
} else if cursor_line < first {
cursor_line
} else if cursor_line + 1 > first + visible {
cursor_line + 1 - visible
} else {
first
}
}
/// The request after a wheel or touchpad motion of `delta_px`
/// from a view whose first line is `first_line`, and whether the
/// view moved.
pub( crate ) fn scrolled(
self,
first_line: usize,
max_first: usize,
cursor_pos: usize,
delta_px: f32,
line_h: f32,
) -> ( Self, bool )
{
let px = self.remainder + delta_px;
let lines = ( px / line_h ).trunc();
let remainder = px - lines * line_h;
let target = ( first_line as i64 + lines as i64 ).clamp( 0, max_first as i64 ) as usize;
let next = Self { first_line: target, cursor_pos, remainder };
( next, target != first_line )
}
}
/// The lines a multiline field shows, resolved once so drawing, hit
/// testing and the wheel agree.
pub( crate ) struct Window
{
pub metrics: MultilineMetrics,
pub lines: Vec<VisualLine>,
pub cursor_line: usize,
pub first_line: usize,
pub max_first: usize,
}
impl Window
{
pub( crate ) fn resolve(
canvas: &Canvas,
rect: Rect,
value: &str,
font_size: Option<Length>,
cursor_pos: usize,
scroll: TextScroll,
) -> Self
{
let metrics = MultilineMetrics::resolve( canvas, font_size );
let lines = compute_visual_lines( canvas, value, metrics.inner_width( rect ), metrics.font_size );
let cursor_pos = cursor_pos.min( value.len() );
let cursor_line = lines.iter()
.position( |vl| cursor_pos >= vl.start && cursor_pos <= vl.end )
.unwrap_or( lines.len().saturating_sub( 1 ) );
let visible = metrics.visible_lines( rect );
let first_line = scroll.first_visible_line( visible, lines.len(), cursor_line, cursor_pos );
let max_first = lines.len().saturating_sub( visible );
Self { metrics, lines, cursor_line, first_line, max_first }
}
pub( crate ) fn can_scroll( &self, delta_px: f32 ) -> bool
{
( delta_px < 0.0 && self.first_line > 0 ) || ( delta_px > 0.0 && self.first_line < self.max_first )
}
}
#[ cfg( test ) ]
mod tests
{
use super::TextScroll;
fn at( first_line: usize, cursor_pos: usize ) -> TextScroll
{
TextScroll { first_line, cursor_pos, remainder: 0.0 }
}
#[ test ]
fn without_a_request_the_view_follows_the_cursor()
{
let s = TextScroll::default();
assert_eq!( s.first_visible_line( 10, 30, 4, 40 ), 0 );
assert_eq!( s.first_visible_line( 10, 30, 12, 130 ), 3 );
assert_eq!( s.first_visible_line( 10, 30, 29, 300 ), 20 );
}
#[ test ]
fn a_request_holds_while_the_cursor_stays()
{
let s = at( 15, 290 );
assert_eq!( s.first_visible_line( 10, 30, 29, 290 ), 15 );
}
#[ test ]
fn a_request_is_clamped_to_the_last_full_window()
{
assert_eq!( at( 50, 0 ).first_visible_line( 10, 30, 0, 0 ), 20 );
assert_eq!( at( 5, 0 ).first_visible_line( 10, 8, 0, 0 ), 0 );
}
#[ test ]
fn once_the_cursor_moves_the_view_shows_it_with_the_least_motion()
{
let s = at( 15, 290 );
assert_eq!( s.first_visible_line( 10, 30, 29, 291 ), 20 );
assert_eq!( s.first_visible_line( 10, 30, 2, 20 ), 2 );
assert_eq!( s.first_visible_line( 10, 30, 18, 180 ), 15 );
}
#[ test ]
fn a_wheel_notch_moves_three_lines_and_stops_at_the_ends()
{
let line_h = 20.0;
let ( s, moved ) = TextScroll::default().scrolled( 0, 20, 7, super::WHEEL_LINES * line_h, line_h );
assert!( moved );
assert_eq!( ( s.first_line, s.cursor_pos, s.remainder ), ( 3, 7, 0.0 ) );
let ( s, moved ) = s.scrolled( 19, 20, 7, super::WHEEL_LINES * line_h, line_h );
assert!( moved );
assert_eq!( s.first_line, 20 );
let ( s, moved ) = s.scrolled( 20, 20, 7, line_h, line_h );
assert!( !moved );
assert_eq!( s.first_line, 20 );
let ( s, moved ) = s.scrolled( 0, 20, 7, -line_h, line_h );
assert!( !moved );
assert_eq!( s.first_line, 0 );
}
#[ test ]
fn touchpad_motion_adds_up_to_whole_lines()
{
let line_h = 20.0;
let ( s, moved ) = TextScroll::default().scrolled( 0, 20, 0, 12.0, line_h );
assert!( !moved );
assert_eq!( s.remainder, 12.0 );
let ( s, moved ) = s.scrolled( 0, 20, 0, 12.0, line_h );
assert!( moved );
assert_eq!( ( s.first_line, s.remainder ), ( 1, 4.0 ) );
let ( s, moved ) = s.scrolled( 1, 20, 0, -24.0, line_h );
assert!( moved );
assert_eq!( s.first_line, 0 );
assert_eq!( s.remainder, 0.0 );
}
}