diff --git a/Makefile b/Makefile index 3699991..5c97c5b 100644 --- a/Makefile +++ b/Makefile @@ -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)/ diff --git a/README.md b/README.md index 904db49..0063906 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/debian/ltk-theme-default.install b/debian/ltk-theme-default.install index 5b3828a..c3679e7 100644 --- a/debian/ltk-theme-default.install +++ b/debian/ltk-theme-default.install @@ -1 +1,2 @@ themes/default usr/share/ltk/themes/ +keyboards usr/share/ltk/ diff --git a/docs/architecture.md b/docs/architecture.md index 8048a44..f6bf475 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` — pull-once focus retargeting. - `on_text_input_focused(active)` — surface IME state. - `external_text_input()` → `Option` 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: diff --git a/docs/widgets.md b/docs/widgets.md index b6e8a00..7f616ef 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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). diff --git a/examples/inputs.rs b/examples/inputs.rs index 4756389..4469bcc 100644 --- a/examples/inputs.rs +++ b/examples/inputs.rs @@ -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, } 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> { None } - fn restore_state( &mut self, _state: Vec ) {} - - fn view( &self ) -> Element + fn login_page( &self ) -> Element { 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 + { + column::() + .padding( 16.0 ) + .spacing( 0.0 ) + .push( flex( + text_edit::( "", &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> { None } + fn restore_state( &mut self, _state: Vec ) {} + + fn view( &self ) -> Element + { + notebook::() + .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 + fn take_focus_request( &mut self ) -> Option { 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 { - 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}" ) ) ) } } diff --git a/keyboards/bar-terminal.json b/keyboards/bar-terminal.json new file mode 100644 index 0000000..10009b5 --- /dev/null +++ b/keyboards/bar-terminal.json @@ -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": { + "-": [ "_", "–" ], + "/": [ "?" ], + "|": [ "&", "!" ], + "\\": [ "`", "^" ], + "~": [ "$", "#", "*" ] + } +} diff --git a/keyboards/en.json b/keyboards/en.json new file mode 100644 index 0000000..9be7ca7 --- /dev/null +++ b/keyboards/en.json @@ -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": [ "ÿ" ], + ".": [ "…", ",", "!", "?" ], + "-": [ "–", "—", "_" ], + "\"": [ "“", "”", "«", "»" ], + "'": [ "‘", "’" ], + "$": [ "€", "£", "¥" ] + } +} diff --git a/keyboards/es.json b/keyboards/es.json new file mode 100644 index 0000000..90db166 --- /dev/null +++ b/keyboards/es.json @@ -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": [ "ß" ], + ".": [ "…", ",", "!", "?", "¡", "¿" ], + "-": [ "–", "—", "_" ], + "\"": [ "«", "»", "“", "”" ], + "'": [ "‘", "’" ], + "€": [ "$", "£", "¥" ] + } +} diff --git a/src/app.rs b/src/app.rs index bc2c82f..1b41775 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 diff --git a/src/core.rs b/src/core.rs index 48e29a2..719727e 100644 --- a/src/core.rs +++ b/src/core.rs @@ -99,6 +99,7 @@ pub struct UiSurface widget_rects: Vec>, cursor_state: HashMap, selection_anchor: HashMap, + text_scroll: HashMap, scroll_offsets: HashMap, scroll_canvases: HashMap, viewport_canvases: HashMap, @@ -227,6 +228,7 @@ impl UiSurface 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 UiSurface 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 UiSurface 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 diff --git a/src/draw/gles.rs b/src/draw/gles.rs index 61aed50..b2765cb 100644 --- a/src/draw/gles.rs +++ b/src/draw/gles.rs @@ -83,7 +83,8 @@ pub( crate ) fn draw_surface_full_gpu( 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( } } + 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( 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( let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false ); layout_and_draw::( 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 ); diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 5fec040..9f814d8 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -505,6 +505,7 @@ pub( crate ) fn layout_and_draw( 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( 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(); diff --git a/src/draw/mod.rs b/src/draw/mod.rs index d3e93c7..6a45898 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -61,6 +61,7 @@ pub( crate ) struct DrawCtx pub pressed_idx: Option, pub cursor_state: HashMap, pub selection_anchor: HashMap, + pub text_scroll: HashMap, pub widget_rects: Vec>, pub debug_layout: bool, pub scroll_offsets: HashMap, @@ -119,6 +120,7 @@ pub( crate ) fn build_draw_ctx( 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( 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; diff --git a/src/draw/software.rs b/src/draw/software.rs index 0b56b0b..deca34e 100644 --- a/src/draw/software.rs +++ b/src/draw/software.rs @@ -80,7 +80,8 @@ pub( crate ) fn draw_surface_full( 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( } } + 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( 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( let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, false ); layout_and_draw::( 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 ); diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 07ad927..a70be9a 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -88,9 +88,12 @@ pub struct AppData pub current_cursor_shape: Option, pub text_input_manager: Option, pub text_input: Option, + /// 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, /// `xdg-activation-v1`. Present when the compositor advertises the diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs index 5e8ec9c..77015d9 100644 --- a/src/event_loop/context_menu.rs +++ b/src/event_loop/context_menu.rs @@ -89,9 +89,10 @@ impl AppData .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, ) ) } ); diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index 1d1c190..7d7f16a 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -103,6 +103,7 @@ impl AppData 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 AppData .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 AppData 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 AppData { // 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 ); } } diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index dde68a1..0cd8936 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -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; diff --git a/src/event_loop/osk.rs b/src/event_loop/osk.rs new file mode 100644 index 0000000..5867825 --- /dev/null +++ b/src/event_loop/osk.rs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, 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 AppData +{ + 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 ) + { + 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 + { + 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 ); + } + } +} diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index a44745a..cb83288 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -239,6 +239,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> }; let text_input_manager: Option = 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( 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( 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( 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(); diff --git a/src/event_loop/subsurface.rs b/src/event_loop/subsurface.rs index f9b0cf0..31df86b 100644 --- a/src/event_loop/subsurface.rs +++ b/src/event_loop/subsurface.rs @@ -314,6 +314,7 @@ fn empty_draw_ctx() -> DrawCtx 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(), diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index 79d1299..cb68b15 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -163,6 +163,8 @@ pub( crate ) struct FrameState /// 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, + /// Where each multiline field was last scrolled by hand, by `flat_idx`. + pub text_scroll: HashMap, pub accessible_extras: Vec, /// 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 FrameState 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 /// Built-in Copy / Cut / Paste context menu when shown on this /// surface. `None` means no menu is up. pub context_menu: Option, + /// 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, /// 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 SurfaceState primary_touch_id: None, touch_slots: HashMap::new(), context_menu: None, + osk: None, gesture: GestureState::new(), titlebar_height, titlebar_base: titlebar_height, diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index 6e6b570..e6a6897 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -13,10 +13,11 @@ use crate::widget::WidgetHandlers; impl AppData { - pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle, secure: bool ) + pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle, secure: bool, purpose: crate::osk::Purpose ) { - self.text_input_secure = secure; - let ( hint, purpose ) = content_type( secure ); + self.text_input_secure = 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 AppData ( 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 AppData { 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 AppData /// 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 ) } diff --git a/src/event_loop/text_editing/mod.rs b/src/event_loop/text_editing/mod.rs index ae315a0..e889895 100644 --- a/src/event_loop/text_editing/mod.rs +++ b/src/event_loop/text_editing/mod.rs @@ -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; diff --git a/src/event_loop/text_editing/scroll.rs b/src/event_loop/text_editing/scroll.rs new file mode 100644 index 0000000..6797002 --- /dev/null +++ b/src/event_loop/text_editing/scroll.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 AppData +{ + 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 + } +} diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs index ce58807..b19b3ed 100644 --- a/src/event_loop/text_editing/selection.rs +++ b/src/event_loop/text_editing/selection.rs @@ -98,11 +98,12 @@ impl AppData }; 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 AppData 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 AppData 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 ); diff --git a/src/input/pointer/motion.rs b/src/input/pointer/motion.rs index e09b7e7..019ddf7 100644 --- a/src/input/pointer/motion.rs +++ b/src/input/pointer/motion.rs @@ -29,6 +29,7 @@ impl AppData 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 diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index c388a52..5c097b2 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -109,6 +109,7 @@ impl AppData 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 AppData }; 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 ); diff --git a/src/input/pointer/release.rs b/src/input/pointer/release.rs index 64f766d..082d766 100644 --- a/src/input/pointer/release.rs +++ b/src/input/pointer/release.rs @@ -27,6 +27,7 @@ impl AppData 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 = diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs index 5a75ec7..58c2545 100644 --- a/src/input/pointer/scroll.rs +++ b/src/input/pointer/scroll.rs @@ -30,6 +30,13 @@ impl AppData 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 ); diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 9f92cb2..d20f45e 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -151,6 +151,7 @@ impl TouchHandler for AppData return; } } + if self.osk_press( focus, pos ) { return; } let outcome = { @@ -161,6 +162,7 @@ impl TouchHandler for AppData }; 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 TouchHandler for AppData } 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 = @@ -354,7 +357,12 @@ impl AppData /// wl_touch, so the last motion's position is the release point. fn release_primary( &mut self, focus: SurfaceFocus ) { - let pos = self.pointer_pos; + 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 AppData 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() { diff --git a/src/lib.rs b/src/lib.rs index 2caf886..40efce1 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 }; diff --git a/src/osk/draw.rs b/src/osk/draw.rs new file mode 100644 index 0000000..82c9bdf --- /dev/null +++ b/src/osk/draw.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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 + { + 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 ); +} diff --git a/src/osk/layout.rs b/src/osk/layout.rs new file mode 100644 index 0000000..e5c5dcf --- /dev/null +++ b/src/osk/layout.rs @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! A keyboard layout as a data file: layers of rows of keys, and the variants a long press offers. +//! +//! Files are JSON, one per layout, named by their language tag (`es.json`). A layer is a list of rows; a key is a bare string for the text it types, or an object with an `action`, a named `key` (`Escape`, `Left`…), a `modifier` (`ctrl`, `alt`), a `width` in key units (default 1) and a `label`. The `shift` layer is derived from `base` by uppercasing when a file has none. `variants` maps a key's text to the alternatives a long press offers. Files named `bar-*.json` are rows laid above any language's layout, not layouts of their own: `bar-terminal.json` is the row of keys a terminal needs. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::Deserialize; + +use crate::Keysym; + +pub const BASE_LAYER: &str = "base"; +pub const SHIFT_LAYER: &str = "shift"; +pub const TERMINAL_BAR: &str = "bar-terminal"; + +const FALLBACK_LAYOUT: &str = include_str!( "../../keyboards/en.json" ); +const FALLBACK_TERMINAL_BAR: &str = include_str!( "../../keyboards/bar-terminal.json" ); + +/// What a key does besides typing. +#[ derive( Clone, Debug, PartialEq, Eq, Deserialize ) ] +#[ serde( rename_all = "lowercase" ) ] +pub enum Action +{ + Shift, + Backspace, + Enter, + Space, + /// Switch to the named layer. + Layer, + /// Switch to the next installed layout. + Layout, + Hide, +} + +/// A sticky modifier: held for the next key, then released. +#[ derive( Clone, Copy, Debug, PartialEq, Eq, Deserialize ) ] +#[ serde( rename_all = "lowercase" ) ] +pub enum Modifier +{ + Ctrl, + Alt, +} + +#[ derive( Clone, Debug, Deserialize ) ] +#[ serde( untagged ) ] +enum KeySpec +{ + Text( String ), + Full + { + #[ serde( default ) ] + text: Option, + #[ serde( default ) ] + action: Option, + #[ serde( default ) ] + key: Option, + #[ serde( default ) ] + modifier: Option, + #[ serde( default ) ] + layer: Option, + #[ serde( default ) ] + label: Option, + #[ serde( default = "one" ) ] + width: f32, + }, +} + +fn one() -> f32 { 1.0 } + +#[ derive( Clone, Debug, Deserialize ) ] +struct LayoutFile +{ + name: String, + #[ serde( default ) ] + label: Option, + layers: HashMap>>, + #[ serde( default ) ] + variants: HashMap>, +} + +#[ derive( Clone, Debug, PartialEq ) ] +pub struct Key +{ + /// Typed when pressed, `None` for a key with an action only. + pub text: Option, + pub action: Option, + /// A key that is not text: an arrow, Escape, Tab. + pub keysym: Option, + pub modifier: Option, + /// The layer an `Action::Layer` key switches to. + pub layer: Option, + pub label: String, + /// In key units; a row's units share its width. + pub width: f32, +} + +#[ derive( Clone, Debug ) ] +pub struct Layout +{ + pub name: String, + /// Shown on the layout-switch key. + pub label: String, + layers: HashMap>>, + variants: HashMap>, +} + +impl Layout +{ + pub fn from_json( json: &str ) -> Result + { + let file: LayoutFile = serde_json::from_str( json ).map_err( |e| e.to_string() )?; + if !file.layers.contains_key( BASE_LAYER ) { return Err( format!( "layout {} has no `{BASE_LAYER}` layer", file.name ) ); } + let mut layers: HashMap>> = file.layers.iter() + .map( |( id, rows )| ( id.clone(), rows.iter().map( |row| row.iter().map( |k| Key::from_spec( k, id ) ).collect() ).collect() ) ) + .collect(); + if !layers.contains_key( SHIFT_LAYER ) + { + let shifted = layers[ BASE_LAYER ].iter() + .map( |row| row.iter().map( Key::uppercased ).collect() ) + .collect(); + layers.insert( SHIFT_LAYER.to_string(), shifted ); + } + let label = file.label.unwrap_or_else( || file.name.to_uppercase() ); + Ok( Self { name: file.name, label, layers, variants: file.variants } ) + } + + pub fn rows( &self, layer: &str ) -> &[ Vec ] + { + self.layers.get( layer ).or_else( || self.layers.get( BASE_LAYER ) ).map( Vec::as_slice ).unwrap_or( &[] ) + } + + pub fn has_layer( &self, layer: &str ) -> bool + { + self.layers.contains_key( layer ) + } + + /// Alternatives a long press on `text` offers; the shifted form of a key uses its base's, uppercased. + pub fn variants( &self, text: &str ) -> Vec + { + if let Some( v ) = self.variants.get( text ) { return v.clone(); } + let lower = text.to_lowercase(); + if lower != text + { + if let Some( v ) = self.variants.get( &lower ) { return v.iter().map( |s| s.to_uppercase() ).collect(); } + } + Vec::new() + } +} + +impl Key +{ + fn from_spec( spec: &KeySpec, layer: &str ) -> Self + { + match spec + { + KeySpec::Text( text ) => Self { text: Some( text.clone() ), action: None, keysym: None, modifier: None, layer: None, label: text.clone(), width: 1.0 }, + KeySpec::Full { text, action, key, modifier, layer: target, label, width } => + { + let keysym = key.as_deref().and_then( named_keysym ); + let label = label.clone() + .or_else( || text.clone() ) + .or_else( || key.clone().filter( |_| keysym.is_some() ) ) + .or_else( || modifier.map( |m| match m { Modifier::Ctrl => "Ctrl", Modifier::Alt => "Alt" }.to_string() ) ) + .unwrap_or_else( || default_label( action.as_ref(), target.as_deref(), layer ) ); + Self { text: text.clone(), action: action.clone(), keysym, modifier: *modifier, layer: target.clone(), label, width: *width } + } + } + } + + fn uppercased( &self ) -> Self + { + match &self.text + { + Some( t ) if self.action.is_none() => + { + let up = t.to_uppercase(); + Self { text: Some( up.clone() ), label: up, ..self.clone() } + } + _ => self.clone(), + } + } +} + +fn default_label( action: Option<&Action>, target: Option<&str>, layer: &str ) -> String +{ + match action + { + Some( Action::Shift ) => if layer == SHIFT_LAYER { "⇪" } else { "⇧" }.to_string(), + Some( Action::Backspace ) => "⌫".to_string(), + Some( Action::Enter ) => "↵".to_string(), + Some( Action::Space ) => String::new(), + Some( Action::Layer ) => target.map( |l| if l == BASE_LAYER { "ABC".to_string() } else { l.to_uppercase() } ).unwrap_or_default(), + Some( Action::Layout ) => String::new(), + Some( Action::Hide ) => "⌄".to_string(), + None => String::new(), + } +} + +/// The keys a layout file may name by their X keysym name. +fn named_keysym( name: &str ) -> Option +{ + Some( match name + { + "Escape" => Keysym::Escape, + "Tab" => Keysym::Tab, + "Left" => Keysym::Left, + "Right" => Keysym::Right, + "Up" => Keysym::Up, + "Down" => Keysym::Down, + "Home" => Keysym::Home, + "End" => Keysym::End, + "Page_Up" => Keysym::Page_Up, + "Page_Down" => Keysym::Page_Down, + "Delete" => Keysym::Delete, + "Insert" => Keysym::Insert, + "F1" => Keysym::F1, "F2" => Keysym::F2, "F3" => Keysym::F3, "F4" => Keysym::F4, + "F5" => Keysym::F5, "F6" => Keysym::F6, "F7" => Keysym::F7, "F8" => Keysym::F8, + "F9" => Keysym::F9, "F10" => Keysym::F10, "F11" => Keysym::F11, "F12" => Keysym::F12, + _ => return None, + } ) +} + +/// Directories searched for `.json`, highest priority first. +pub fn search_paths() -> Vec +{ + let mut out = Vec::new(); + if let Some( dev ) = std::env::var_os( "LTK_KEYBOARDS_DIR" ) { out.push( PathBuf::from( dev ) ); } + if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" ) + { + out.push( PathBuf::from( home ).join( "ltk/keyboards" ) ); + } else if let Some( home ) = std::env::var_os( "HOME" ) { + out.push( PathBuf::from( home ).join( ".local/share/ltk/keyboards" ) ); + } + out.push( PathBuf::from( "/usr/share/ltk/keyboards" ) ); + out +} + +/// The installed layout `name`, or `None` when no directory has it or it does not parse. +pub fn load( name: &str ) -> Option +{ + search_paths().into_iter() + .map( |dir| dir.join( format!( "{name}.json" ) ) ) + .find_map( |path| 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 +{ + let mut names: Vec = 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() ); + } +} diff --git a/src/osk/mod.rs b/src/osk/mod.rs new file mode 100644 index 0000000..944d747 --- /dev/null +++ b/src/osk/mod.rs @@ -0,0 +1,623 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, +} + +#[ derive( Clone, Debug ) ] +pub struct Popup +{ + pub key: usize, + pub variants: Vec, + pub rects: Vec, + pub bounds: Rect, + pub selected: Option, +} + +/// The keyboard's state and geometry. Coordinates are those the host passes in, physical pixels in ltk's runtime. +pub struct Panel +{ + layouts: Vec, + current: usize, + layer: String, + shift: Shift, + last_shift_at: Option, + purpose: Purpose, + /// The rows laid above the layout for the current purpose. + bar: Option, + modifiers: Modifiers, + rect: Rect, + scale: f32, + keys: Vec, + press: Option, + popup: Option, + /// Rasterised action icons by name, size and tint; `None` for one the theme lacks. + icons: HashMap<( &'static str, u32, [ u8; 4 ] ), Option>, +} + +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, 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::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 + { + 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 ] + { + 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> = self.bar_rows().to_vec(); + for row in &bar + { + let units: f32 = row.iter().map( |k| k.width ).sum::().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> = 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::() ) + .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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 ) -> 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 = ( 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 ); + } +} diff --git a/src/widget/element.rs b/src/widget/element.rs index b2186d6..d6b961f 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -92,12 +92,13 @@ impl Element 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 Element // 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, diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 308912a..aba6e19 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -46,6 +46,7 @@ pub enum WidgetHandlers /// 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 Clone for WidgetHandlers 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 Clone for WidgetHandlers 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 WidgetHandlers 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 diff --git a/src/widget/notebook/mod.rs b/src/widget/notebook/mod.rs index 68072f3..225c09f 100644 --- a/src/widget/notebook/mod.rs +++ b/src/widget/notebook/mod.rs @@ -138,10 +138,11 @@ impl Notebook .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() } } diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index bd5002c..b81d6c4 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -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 TextEdit /// 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 TextEdit 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 TextEdit 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 TextEdit 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 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 window = Window::resolve( canvas, rect, &self.value, self.font_size, cursor_pos, scroll ); + let m = window.metrics; + let line_h = m.line_h; + 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; diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs index e5ea0d8..c07879f 100644 --- a/src/widget/text_edit/hit_test.rs +++ b/src/widget/text_edit/hit_test.rs @@ -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, ) -> 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 { diff --git a/src/widget/text_edit/metrics.rs b/src/widget/text_edit/metrics.rs index 8a9ff68..aba3c76 100644 --- a/src/widget/text_edit/metrics.rs +++ b/src/widget/text_edit/metrics.rs @@ -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 - } - } } diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 95e2f14..9fe3c33 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -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 pub( crate ) color: Option, /// 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 TextEdit 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 TextEdit 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 TextEdit 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 TextEdit 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, diff --git a/src/widget/text_edit/scroll.rs b/src/widget/text_edit/scroll.rs new file mode 100644 index 0000000..e661aea --- /dev/null +++ b/src/widget/text_edit/scroll.rs @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + 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, + 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 ); + } +}