A text field keeps the cursor where an edit left it unless it asks to be pinned to the end, which only the time picker does, and a hook for a window that loses the keyboard
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

A field built with `select_on_focus( true )` kept its cursor at the end of the value after every insertion and deletion. That was meant for the time picker, whose `on_change` normalises what was typed, "2" becoming "02", so that a cursor placed at the end of the typed text would land in the middle of the normalised one. But the flag was also the only way to have a field select its whole value on focus, so every field that wanted that got the pinning too, and in an address bar it is a defect: tapping inside the address to correct a word, the cursor was placed where the finger was, and the first character typed sent it to the end, so the rest of the edit went to the wrong place.
The two behaviours are now separate. `select_on_focus` only selects the value when the field takes the focus. The new `TextEdit::pin_cursor_to_end( bool )`, `false` by default, keeps the cursor at the end of the displayed value through the `usize::MAX` sentinel, as before, for fields whose `on_change` rewrites the value. It reaches the runtime in a new `pin_cursor_to_end` field of `WidgetHandlers::TextEdit`, set where the snapshot is built in `Element` and copied in its `Clone`, and the four places in `insert_delete.rs` that anchored the cursor, for an insertion, a delete forward, the deletion of a selection and the move after a replacement, read it instead of `select_on_focus`. The time picker's value fields ask for it, so they behave as they did.
A field that uses `select_on_focus` only to select on focus now edits with an ordinary cursor. The one other user known is a dialog in `crate`, which gets the ordinary behaviour; if its `on_change` normalises the text it has to ask for `pin_cursor_to_end( true )` as the time picker does.
Documentation of the previous commit and of what building crate showed. `docs/widgets.md` says that a `pressable` loses the tap and the right click to an interactive widget inside it even when that widget has no handler, as a `list_item` without `on_press`, so its content should be built from pieces that take no input; and that the hint of `Button::tooltip` is an xdg-popup beside the button in a window, placed by the compositor and taking no grab, and an overlay spanning the output on a layer surface. `docs/theming.md` gains "Application icons": a desktop entry can point at an installed theme icon, the Figma glass effects resvg does not reproduce, a `backdrop-filter` in a `<foreignObject>` ignored and `plus-lighter` rewritten to `screen` by `decode_svg_bytes`, and the advice to flatten them; the table row of `decode_svg_bytes` mentions the rewrite and that its pixels come back premultiplied. `docs/cookbook.md` says that requesting the focus for the text field that already has it puts the cursor at the end of its value without a second `on_focus`, which is how an application moves the cursor after replacing the value, and that Tab and the vertical arrows reach `App::on_key_with_modifiers` before the field, while Escape reaches it after ltk has closed any transient UI and taken the focus off the field.
Losing the keyboard. An application that repeats keys on its own timer, as prompt does for its terminal, never heard that the window lost the keyboard, so a key held while the focus went elsewhere kept repeating until the next key, since its release went to the other window. The new `App::on_keyboard_leave()`, a no-op by default, is called from the keyboard's `leave`, right after ltk stops its own repeat, for the application to stop its repeat and forget the modifiers it kept.
Documentation of the previous commit's hooks and widgets. `docs/architecture.md` lists `on_mouse_button` with `MouseButtonEvent` and `MouseButton`, `on_modifiers` and `on_keyboard_leave` among the input hooks. `docs/widgets.md` describes `ListItem::ink( label, secondary )`, which draws a row's label, subtitle, trailing text and hover and press tints in colours of its own for a surface the theme does not know about, and says that `icon_button` keeps a bitmap's proportions, fitting one that is not square into its size and centring it rather than stretching it.
Not done. There is no test for the new flag, as `select_on_focus` has one in `text_edit/tests.rs` for its builder and its propagation to the handler, and the documentation of the widget does not mention it.
This commit is contained in:
2026-10-15 11:22:01 +02:00
parent 312fb7e0fd
commit a63e905a7a
11 changed files with 64 additions and 18 deletions
+3
View File
@@ -99,6 +99,9 @@ In practice, that model is easiest to adopt in three steps:
- `on_tap` — taps that miss every widget.
- `on_key` / `on_key_with_modifiers` — global hotkeys. Characters, Backspace and Delete stay in a focused text input and reach the app otherwise, a focused button or pressable included; Return and Space go to the focused widget's submit or press message when it has one; arrows are offered to the app first and fall back to the text cursor or list navigation.
- `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity.
- `on_mouse_button( &MouseButtonEvent )` — every press and release of the left, middle and right buttons (`MouseButton`), with the position in physical pixels and Ctrl, Shift and Alt, before ltk handles it: for an app that hands the mouse on, as a terminal does to a program that asks for it.
- `on_modifiers( ctrl, shift, alt )` — the modifiers held changed, for an app that needs them with the wheel or a pointer motion, which do not carry them.
- `on_keyboard_leave()` — the window lost the keyboard, so no release will come for the keys still held: an app that repeats keys on its own timer stops there, and forgets the modifiers it kept.
**Implement when the app has more than one place to be**
+3 -1
View File
@@ -726,8 +726,10 @@ be added to the view by the same `update` that requests it, and may sit
inside a `scroll` below the fold: the runtime waits a few frames for it
and moves the viewport so the widget is visible when it takes focus.
Requesting focus for the text field that already has it puts its cursor at the end of the value, without firing its `on_focus` again. That is how an application moves the cursor after replacing the value itself, for instance after completing a path on Tab: set the new value in `update` and return the field's id from `take_focus_request`. A field built with `select_on_focus( true )` selects its whole value instead.
Tab and Shift+Tab traverse focusable widgets in declaration order — no
opt-in needed beyond the widget being interactive.
opt-in needed beyond the widget being interactive. While a text field has the focus, Tab and the vertical arrows still reach `App::on_key_with_modifiers` first, so an application can claim them, for completion say: return a message for the key and ltk neither moves the focus nor the cursor. Escape reaches it too, after ltk has closed any transient UI and taken the focus off the field.
**See also**: [`WidgetId`](../src/types.rs),
[`tests/tab_navigation.rs`](../tests/tab_navigation.rs).
+12 -1
View File
@@ -518,7 +518,7 @@ fn view( &self ) -> Element<Msg>
| `theme_icon_path( "category/name" )` | `Option<PathBuf>` | Catalogue icon path (filled-then-line lookup). |
| `theme_icon_rgba( "category/name", size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. |
| `theme_icon_tinted( name, size, tint )` | `Option<ImageData>` | One-call rasterise + tint (`theme_icon_rgba` + `tint_symbolic`). |
| `decode_svg_bytes( bytes, size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. |
| `decode_svg_bytes( bytes, size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. Rewrites the CSS-only `plus-lighter` blend to `screen` first (see [Application icons](#application-icons)); the pixels come back premultiplied. |
| `active_document()` | `Arc<ThemeDocument>` | The whole loaded document, for slot-typed lookups the per-slot helpers do not cover (e.g. iterating slots). |
| `active_theme_id()` | `String` | Id of the active theme. |
| `is_fallback_active()` | `bool` | `true` when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user. |
@@ -728,6 +728,17 @@ Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`,
`features`, `feedback`, `general`, `hardware`, `keyboard`, `multimedia`,
`navigation`, `safety`, `session`, `system`, `window`.
### Application icons
An application's desktop entry can point at a theme icon by its installed path, `Icon=/usr/share/ltk/themes/default/icons/apps/files.svg`, which `ltk-theme-default` provides, instead of shipping an icon of its own.
The shipped app icons are exported from Figma with glass effects, and resvg, which rasterises every SVG in ltk, reproduces only part of them:
- A `<foreignObject>` carrying `backdrop-filter: blur(…)` is HTML, not SVG, and is ignored: the frosted panel comes out as a flat translucent one.
- `feBlend mode="plus-lighter"` is a CSS blend mode resvg does not know, and it would fall back to `normal`, painting the highlights as grey edges that cover whole pixels at dock size. `decode_svg_bytes` rewrites it to `screen`, the closest mode resvg supports, before parsing, so every path that rasterises an SVG through ltk gets it.
An icon meant to look the same everywhere is best drawn without these effects, or with them flattened into its geometry.
### Adding an icon
1. Drop a monochrome SVG into
+6
View File
@@ -82,6 +82,7 @@ icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch )
count; pass `Length::widget( n )` instead to follow the widget-scaling
mode, which is what stock icons (the [`list_item`](#list_item) disclosure
arrow) already do — use it when the two sit side by side and should match.
The bitmap keeps its proportions: one that is not square is scaled to fit that size and centred in it, never stretched.
Give it a `width( … )` or `height( … )` and it becomes a framed key: it
claims that size, paints the background and border of its `variant` like
@@ -132,6 +133,8 @@ pressable(
# }
```
A tap and a long press go to the deepest interactive widget under them, and a widget is interactive even without a handler: a `list_item` with no `on_press` inside a `pressable` takes the tap and the right click and does nothing with them, so the pressable never fires. Build the content from pieces that take no input (`container`, `row`, `column`, `text`, `img_widget`); keep an inner `button` only when it should win over the pressable, as a row's own trailing action does.
**See also**: [`button`](#button) when a plain text button is enough,
[`list_item`](#list_item) for the standard "label + subtitle + trailing"
pattern.
@@ -198,6 +201,7 @@ When both are set the text sits to the left of the icon.
`pad_h( px )` overrides the horizontal inset between the row edge and
its content (theme default 16 px) — lower it when the enclosing view's
own padding already provides the margin.
`ink( label, secondary )` draws the label in `label`, the subtitle and trailing text in `secondary`, and tints hover and press with `label`, instead of the theme's colours, for a row laid on a surface the theme does not know about, such as a menu drawn in an app's own colours.
`height( len )` overrides the theme row height (56 design px, 68 with a subtitle) and `font_size( len )` the primary-label size (16 design px; subtitle and trailing keep their theme sizes), both `impl Into<Length>`. Use them together where a dense list — a context menu, a compact picker — should trade the touch-target generosity of a settings list for row density; the resolved height is floored at the label's rendered height so the text never clips however aggressive the override.
@@ -797,6 +801,8 @@ The overlay anchors below the widget built with the matching
`.id( WidgetId )`. See [`docs/cookbook.md`](./cookbook.md) for the full
overlay wiring.
The hint `Button::tooltip` shows by itself is placed by the runtime. When the main surface is an xdg-shell window it is an xdg-popup child of the window, anchored to the button's rect: the compositor puts it above the button, 6 logical pixels away, flips it below or slides it sideways when it does not fit, and it takes no grab and no input, so the pointer stays with the button. A window does not know where it sits on screen, so this is the only way the hint lands beside the button. On a layer-shell surface (a shell, a panel) the hint is an overlay spanning the output, placed above the button or below it near the top edge.
**See also**: [`toast`](#toast) for transient self-dismissing
notifications.