Text entry on a phone comes from `zwp_text_input_v3`: a focused field enables the protocol and the compositor's input method, squeekboard today, draws a keyboard and commits text through it. Eydos wants to drop that process, so the keyboard has to come from somewhere else, and the natural place is the toolkit that already draws everything else and knows which field has the focus and what it is for. This commit puts one in ltk, as data files plus a panel any host can drive: ltk's runtime shows it under a focused text field when no input method is there, and forge will draw the same panel in its chrome and deliver the keys to the focused client, which is the second half and is not in this commit. Along the way the demo that exercises it exposed two gaps in the widgets, fixed here too.
Layouts are data. `keyboards/<language>.json`, one file per layout, searched in `LTK_KEYBOARDS_DIR` when set, then `$XDG_DATA_HOME/ltk/keyboards` or `~/.local/share/ltk/keyboards`, then `/usr/share/ltk/keyboards`; `osk::layout::search_paths()`, `available()` and `load( name )` are the API, and the English layout is compiled in as the last resort. A file has `name`, a `label` for the layout-switch key, `layers` and `variants`. A layer is a list of rows; a row a list of keys; a key is a bare string for the text it types, or an object with an `action` (`shift`, `backspace`, `enter`, `space`, `layer`, `layout`, `hide`), the target `layer`, a `label`, and a `width` in key units, `1` by default, which share the row's width so a row of nine keys comes out centred under one of ten. `base` is mandatory; a missing `shift` layer is derived from it by uppercasing. `variants` maps a key's text to what a long press offers, and the shifted form of a key uses its base's, uppercased. A key may also be a named key, `{ "key": "Escape" }`, `Tab`, the arrows, `Home`, `End`, `Page_Up`, `Page_Down`, `Delete`, `Insert`, `F1` to `F12`, or a sticky modifier, `{ "modifier": "ctrl" }` or `alt`. `en.json` and `es.json` ship, with symbol layers and the accented variants each language needs. The files go into the registry copy through `make install` and into `ltk-theme-default` under `/usr/share/ltk/keyboards`.
The terminal row. A terminal needs Escape, Tab, Ctrl, Alt, the arrows and the shell's symbols, and no other application does, so those are not a layout but a row laid above any language's layout: `keyboards/bar-terminal.json`, made of the named keys and modifiers above plus `-`, `/`, `|`, `\` and `~` with their variants. Files named `bar-*.json` are rows, not layouts, and `available()` leaves them out, so the layout-switch key does not cycle onto one; the first version listed it and a tap on that key made the keyboard vanish behind a duplicated bar. Which applications get the row is decided by what the field says it is for: `TextEdit::purpose( InputPurpose::Terminal )`, snapshotted into the handlers like `secure`, which the built-in panel reads through `set_purpose` and which also goes out as the `terminal` content purpose of `zwp_text_input_v3`, the same one VTE-based terminals declare, so a compositor hosting the panel can pick the row from the protocol; for a client that declares nothing it can decide by `app_id`.
`osk::Panel` is a state machine a host drives. It holds the loaded layouts, the current layer, shift as off, one-shot or locked, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it with `set_rect( rect, scale )`, and the press in flight. The host feeds it `press_down`, `press_move`, `press_up` and, from its own timer, `tick`, each returning an `Event`: `Text`, `Backspace`, `Enter`, `Hide`, `Key( keysym, modifiers )` for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, or `Changed` when only the panel's look changed. A key types on release; a press held 450 ms opens its variants, which the finger slides onto and the release picks; backspace fires on press and repeats every 60 ms after the same delay; shift is one-shot and locks on a double tap within 350 ms; a modifier stays down for the next key and a character typed under it goes out as a `Key`, since text cannot carry Ctrl. `next_deadline()` says when `tick` next has work, `height( scale )` what the panel needs, `press_down` answers `None` for a press outside it so the host treats that as its own. `draw( canvas )` paints it with the active palette; the action keys show theme icons rather than glyphs, `keyboard/shift`, `keyboard/backspace`, `keyboard/enter`, `general/down-simple` for hide, `accessibility/languages` for the layout key, the `general` arrows for the terminal row, rasterised once per size and tint, because the first version drew `⇧` and `🌐` and the fallback chain has no glyph for the latter, which showed as a question mark; a layout key with no label of its own shows the active layout's. Nothing in `Panel` knows about text fields or Wayland, which is what lets a compositor host it. Fifteen tests cover the layout parser, the derived shift layer, variants following case, the bar filtering, and the panel's taps, one-shot and locked shift, variants, repeat, layer and layout switches and the terminal row.
The runtime shows it. `event_loop/osk.rs` puts a `Panel` on a surface when a text field takes the focus and `builtin_osk` holds: the compositor advertised no `zwp_text_input_manager_v3`, or `LTK_BUILTIN_OSK=1` is set, which is how to try it on a desktop that has an input method, or the app returns `true` from the new `App::builtin_keyboard()`. The panel is placed at the bottom of the surface by both renderers, and the widget tree is laid out in what is left above it, so the focused field is never covered. Presses, motion and releases from the pointer and from touch are offered to the panel before the gesture machine, a press on the already focused field brings a hidden panel back, and the run loop's timeout now takes the minimum of the long-press, tooltip and panel wakeups so variants and repeat fire on time. `Text` goes through the same insertion as a physical key, `Key` through the same dispatch, with Ctrl applied for its duration, `Enter` submits or inserts a newline in a multiline field, `Hide` keeps the focus and takes the panel away; losing the focus removes it. The layout is picked from `$LANG`. `ltk::InputPurpose` is exported, and `docs/architecture.md` gains the section that says all of this, with the compositor's part sketched.
The demo. `examples/inputs` becomes a notebook with the login page it had and a Shell page: one multiline field that holds a transcript, prompt included, declared a terminal, so the row appears; Enter runs the line under the last prompt (`echo`, `ls`, `pwd`, `clear`), the arrows move through the transcript, and a Ctrl combination interrupts the line the way a terminal shows it. Escape quits only from the login page, because a key the app answers never reaches the field, which is what first made the arrows dead.
Two widget gaps the demo found. `Notebook` gave its page the page's preferred height, so a page that fills its space, a scrolled list or a flex, got none and showed nothing: the active page is now wrapped in `flex` and gets the height under the strip. And the multiline field had no way to scroll: it chose its first visible line from the cursor alone on every draw, so the transcript ran under the keyboard and the wheel did nothing. `text_edit/scroll.rs` gives it a `TextScroll` the runtime keeps per field beside the cursor and the selection, in the frame state, the draw context and the `core` runtime alike: the first line asked for by hand and the cursor at that moment. The view holds there while the cursor stays put and, once it moves, follows it by the least it has to, as an editor does; without a request the result is exactly the old rule. `Window::resolve` computes the visible lines once for drawing, hit testing and the wheel, so they cannot disagree, and replaces the copy each of the first two had. In `on_pointer_axis` the vertical motion is offered to the multiline field under the pointer first, three lines per notch from `value120` or `discrete`, touchpad pixels accumulated until they add up to a line, and only when the field cannot move that way does it go to a `scroll` around it. `TextScroll` is public but opaque, since `Element::draw` is public and needs it; `Default` is the cursor-following behaviour. Six tests cover the hold, the follow, the clamping, the notch and the accumulation. A read-only field is not interactive and does not take the wheel; touch drags inside a field do not scroll it yet.
Not done. Forge does not host the panel yet, so on the phone squeekboard is still what appears. The panel's look is a first pass, sized in logical units with the theme's palette, to be restyled from mockups. Only `en` and `es` ship.
234 lines
9.6 KiB
Rust
234 lines
9.6 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! Per-frame drawing pipeline.
|
|
//!
|
|
//! The run loop hands each configured surface to [`draw_frame`] once per
|
|
//! vblank; this module walks it through a decision tree:
|
|
//!
|
|
//! * **Skip** — no content changed and no interaction state moved, so the
|
|
//! previously committed buffer is still correct.
|
|
//! * **Partial** — only focus / hover / pressed changed. Install a clip
|
|
//! mask covering the paint rects of the affected widgets, repaint only
|
|
//! under the clip, damage Wayland with exactly those rects.
|
|
//! * **Full** — something substantive changed (app message, animation
|
|
//! tick, configure, text edit, scroll, slider drag). Clear + redraw
|
|
//! the entire view, let damage tracking tighten the commit.
|
|
//!
|
|
//! Each of those paths has a software variant (CPU + SHM pool) and a
|
|
//! GLES variant (FBO + EGL swap). The four resulting functions live in
|
|
//! [`software`] and [`gles`]; routing and surface-level orchestration
|
|
//! live in [`crate::event_loop::frame`].
|
|
//!
|
|
//! # Submodule layout
|
|
//!
|
|
//! * [`software`] — `draw_surface_full` / `draw_surface_partial`
|
|
//! * [`gles`] — `draw_surface_full_gpu` / `draw_surface_partial_gpu`
|
|
//! * [`damage`] — `compute_interaction_dirty_rects`, `compute_damage`,
|
|
//! `clamp_rect_to`
|
|
//! * [`chrome`] — `draw_titlebar`, `apply_input_region`
|
|
//! * [`layout`] — `layout_and_draw` (the recursive element walker)
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use crate::render::Canvas;
|
|
use crate::types::{ Rect, WidgetId };
|
|
use crate::widget::LaidOutWidget;
|
|
|
|
pub( crate ) mod software;
|
|
pub( crate ) mod gles;
|
|
pub( crate ) mod damage;
|
|
pub( crate ) mod chrome;
|
|
pub( crate ) mod layout;
|
|
|
|
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
|
|
pub( crate ) use layout::layout_and_draw;
|
|
|
|
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures
|
|
/// the interaction snapshot (focus / hover / pressed), scratch space
|
|
/// for the widget-rect list the frame will produce, and the scroll
|
|
/// offsets / sub-canvases carried across frames.
|
|
///
|
|
/// `scroll_canvases` arrives populated (the previous frame's
|
|
/// sub-canvases) so `layout_and_draw` can re-use them for Scroll
|
|
/// viewports whose size did not change. `scroll_rects` and
|
|
/// `widget_rects` start empty and get filled as the element tree is
|
|
/// walked.
|
|
pub( crate ) struct DrawCtx<Msg: Clone>
|
|
{
|
|
pub focused_idx: Option<usize>,
|
|
pub hovered_idx: Option<usize>,
|
|
pub pressed_idx: Option<usize>,
|
|
pub cursor_state: HashMap<usize, usize>,
|
|
pub selection_anchor: HashMap<usize, usize>,
|
|
pub text_scroll: HashMap<usize, crate::widget::text_edit::TextScroll>,
|
|
pub widget_rects: Vec<LaidOutWidget<Msg>>,
|
|
pub debug_layout: bool,
|
|
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
|
|
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
|
|
pub scroll_canvases: HashMap<usize, Canvas>,
|
|
/// Sub-canvases of `Viewport` elements, keyed by their order of
|
|
/// appearance in the walk (a viewport owns no flat index of its own).
|
|
pub viewport_canvases: HashMap<usize, Canvas>,
|
|
pub viewport_seq: usize,
|
|
/// Per-scroll navigation map: list of `(flat_idx, content_y, height)`
|
|
/// for every interactive item the scroll's child laid out, in
|
|
/// document order, **including items currently scrolled off-screen**.
|
|
/// Keyboard arrow handlers read this to step the runtime's
|
|
/// `hovered_idx` item-by-item without needing to know how the popup
|
|
/// content was composed. The Y is in pre-translation, pre-offset
|
|
/// coordinates (i.e. relative to the start of the scroll's child
|
|
/// content) so the keyboard auto-scroll can compute the offset
|
|
/// needed to bring an item into view without depending on the
|
|
/// current scroll position.
|
|
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
|
|
/// Widgets carrying a [`WidgetId`] that a scroll viewport clipped out
|
|
/// of `widget_rects` this frame: `(id, scroll flat_idx, rect)` with the
|
|
/// rect in the scroll's content coordinates, so a focus request can
|
|
/// move that scroll until the widget is in view.
|
|
pub scroll_clipped: Vec<( WidgetId, usize, Rect )>,
|
|
/// Snapshot of the previous frame's `widget_rects`. Read by
|
|
/// [`crate::widget::anchored_overlay::AnchoredOverlay`] at draw time
|
|
/// to look up the rect of an anchor widget by [`crate::WidgetId`] and
|
|
/// re-position itself relative to that rect. Drivers populate this
|
|
/// before invoking the recursive layout / draw walk.
|
|
pub previous_widget_rects: Vec<LaidOutWidget<Msg>>,
|
|
/// Non-interactive widgets exposed only to the accessibility
|
|
/// tree (text labels, images, separators, progress bars).
|
|
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
|
|
/// Depth counter for containers marked `a11y_live`.
|
|
pub live_depth: u32,
|
|
}
|
|
|
|
/// Build the per-frame [`DrawCtx`] from the surface's [`FrameState`], moving the
|
|
/// carried maps (cursor / selection / scroll) out of `frame` into the context.
|
|
/// The interaction indices are passed by value (they live on `SurfaceState`, not
|
|
/// `frame`). `debug_layout` is `false` on the partial paths. Shared by all four
|
|
/// frame paths; [`commit_draw_ctx`] writes the produced state back.
|
|
pub( crate ) fn build_draw_ctx<Msg: Clone>(
|
|
frame: &mut crate::event_loop::FrameState<Msg>,
|
|
focused_idx: Option<usize>,
|
|
hovered_idx: Option<usize>,
|
|
pressed_idx: Option<usize>,
|
|
debug_layout: bool,
|
|
) -> DrawCtx<Msg>
|
|
{
|
|
DrawCtx
|
|
{
|
|
focused_idx,
|
|
hovered_idx,
|
|
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 ),
|
|
scroll_rects: Vec::new(),
|
|
scroll_canvases: std::mem::take( &mut frame.scroll_canvases ),
|
|
viewport_canvases: std::mem::take( &mut frame.viewport_canvases ),
|
|
viewport_seq: 0,
|
|
scroll_navigable_items: std::mem::take( &mut frame.scroll_navigable_items ),
|
|
scroll_clipped: Vec::new(),
|
|
previous_widget_rects: frame.widget_rects.clone(),
|
|
accessible_extras: Vec::new(),
|
|
live_depth: 0,
|
|
}
|
|
}
|
|
|
|
/// Write the finished frame's [`DrawCtx`] back into the [`FrameState`]: snapshot
|
|
/// the interaction indices into `prev_*` and move the produced rect lists and
|
|
/// carried maps back. Shared by all four frame paths; the caller must have
|
|
/// already consumed anything it needs from `ctx` (e.g. `compute_damage`) before
|
|
/// calling this, and resets `content_dirty` itself (it lives on `SurfaceState`).
|
|
pub( crate ) fn commit_draw_ctx<Msg: Clone>(
|
|
frame: &mut crate::event_loop::FrameState<Msg>,
|
|
focused_idx: Option<usize>,
|
|
hovered_idx: Option<usize>,
|
|
pressed_idx: Option<usize>,
|
|
mut ctx: DrawCtx<Msg>,
|
|
)
|
|
{
|
|
frame.prev_focused = focused_idx;
|
|
frame.prev_hovered = hovered_idx;
|
|
frame.prev_pressed = pressed_idx;
|
|
|
|
frame.widget_rects = ctx.widget_rects;
|
|
frame.scroll_rects = ctx.scroll_rects;
|
|
frame.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
|
|
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;
|
|
frame.scroll_clipped = ctx.scroll_clipped;
|
|
}
|
|
|
|
/// Paint the built-in Copy / Cut / Paste context menu on top of the
|
|
/// finished surface content. Called from the software and GLES draw
|
|
/// paths right before `present()` so the menu sits above everything
|
|
/// the widget tree painted, matching the convention every other
|
|
/// toolkit follows for runtime-internal popups.
|
|
pub( crate ) fn draw_context_menu(
|
|
canvas: &mut crate::render::Canvas,
|
|
menu: &crate::event_loop::context_menu::ContextMenu,
|
|
)
|
|
{
|
|
let palette = crate::theme::palette();
|
|
let bg = palette.surface;
|
|
let border = palette.divider;
|
|
let text = palette.text_primary;
|
|
let muted = palette.text_secondary;
|
|
let hi = palette.surface_alt;
|
|
|
|
let r = menu.rect;
|
|
canvas.fill_rect( r, bg, 8.0 );
|
|
canvas.stroke_rect( r, border, 1.0, 8.0 );
|
|
|
|
let ( ys, row_h ) = menu.row_ys();
|
|
// Row order: Copy / Cut / Paste / Delete. Labels go through
|
|
// `rust_i18n::t!()` so the menu picks up the active locale; the
|
|
// `enabled` flag mirrors the gating in `handle_context_menu_press`.
|
|
let labels: [ ( String, bool ); 4 ] =
|
|
[
|
|
( rust_i18n::t!( "context_menu.copy" ).to_string(), menu.has_selection ),
|
|
( rust_i18n::t!( "context_menu.cut" ).to_string(), menu.has_selection ),
|
|
( rust_i18n::t!( "context_menu.paste" ).to_string(), menu.can_paste ),
|
|
( rust_i18n::t!( "context_menu.delete" ).to_string(), menu.has_selection ),
|
|
];
|
|
|
|
// Subtle accent band on the row matching the *primary* action so
|
|
// the menu reads as "Paste is the default" when there is no
|
|
// selection (the common case for a paste-into-empty-field click)
|
|
// and "Copy is the default" when a selection is active. Just a
|
|
// hint, not a binding — every row still works on its own click.
|
|
let primary_idx = if menu.has_selection { 0 } else { 2 };
|
|
let primary_band = crate::types::Rect
|
|
{
|
|
x: r.x + 4.0, y: ys[ primary_idx ] + 2.0,
|
|
width: r.width - 8.0, height: row_h - 4.0,
|
|
};
|
|
canvas.fill_rect( primary_band, hi, 6.0 );
|
|
|
|
for ( i, ( label, enabled ) ) in labels.iter().enumerate()
|
|
{
|
|
let color = if *enabled { text } else { muted };
|
|
canvas.draw_text(
|
|
label,
|
|
r.x + 16.0,
|
|
ys[ i ] + row_h * 0.5 + 5.0,
|
|
14.0,
|
|
color,
|
|
);
|
|
}
|
|
|
|
// Thin separator between every pair of rows.
|
|
let sep_color = palette.divider;
|
|
for y in ys.iter().skip( 1 )
|
|
{
|
|
canvas.draw_line( r.x + 8.0, *y, r.x + r.width - 8.0, *y, sep_color, 1.0 );
|
|
}
|
|
}
|