State restored on an ordinary launch for apps that ask, focus that follows its widget's id, buttons that do not take the focus when pressed, a focus ring only after the keyboard, and a browser app's surfaces copied rather than transferred, which WebKit got wrong
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Restore on launch. `restore_state` follows Android's saved-instance-state contract: the saved bytes come back after a crash or a session restore, and a launch from the launcher starts fresh. Some apps keep state their users expect to find however the last run ended, as an Android browser keeps its tabs in its own storage even when closed from the app switcher. `App::restore_on_launch`, false by default, has the runtime restore on a plain launch too; `SessionRuntime::bootstrap` asks it before deciding. Saving, the file layout and the window geometry are the same either way. Its documentation explains when it is asked, that it is not when persistence is off, that `restore_state` must then merge what the launch itself asks for, such as a URL on the command line, and that what `save_state` returns now outlives every exit; it carries a compiled example. `restore_state`'s documentation, `docs/onboarding.md` and the cookbook's session state recipe mention it.
Focus on a press. `Button::focusable( false )` and `WindowButton` kept out of the Tab cycle but still took the focus when pressed, and Enter then pressed them again, which closed a browser's tab when the user pressed Enter in a page after closing another tab. `LaidOutWidget` gains `focus_on_press`, from `Element::focus_on_press`: a button's `focusable`, a `WindowButton`'s, a pressable's new `Pressable::focus_on_press`, true by default, and true for every other widget. `focus_pressed` replaces `set_focus` for pointer and touch presses and leaves the focus where it was when the widget does not take it. Enter's fallback to the widget under the pointer, which `hovered_idx` follows, skips such widgets too, and a rebuild that leaves the focus on one drops it (`drop_misplaced_focus`). The close buttons of `tab_strip`, on the desktop and on the phone's cards, are not focusable; `symbol_button` takes whether its button is.
Focus by id. The focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index. `WidgetId` becomes a name and a key, `WidgetId::keyed( name, key )` telling apart widgets of the same kind, such as the tabs of a strip; `WidgetId( "name" )` still builds one, through a `const fn` of that name, so the calls in every app compile unchanged, and only code that read `.0`, the toasts, the combo and the tooltip here, moves to the derived `Hash` or `name()`. After each layout `follow_focused_id` finds the focused id and moves the focus to its new index with the field's pending text, cursor, selection and scroll; it drops the focus when the widget is gone, keeps it when a scroll only clipped it, and leaves it when the widget at the old index still has the id. `draw_surface` returns whether the focus moved, and its callers draw the surface again rather than marking it done, since the frame just drawn showed the focus in the old place. Widgets without an id keep the index. Each tab and each card of `tab_strip` carries `WidgetId::keyed` with the tab's key, so a chosen tab keeps the focus while others close.
Focus ring. A press that focused a button, a toggle or a list item left its ring drawn, which browsers have not done since `:focus-visible`. `SurfaceState::focus_visible` is set by Tab and Shift+Tab and cleared by a press that moves the focus, `show_focus_ring` redrawing the whole surface when it changes, since the partial redraw only sees indices. The draw is given the focused index only while the ring shows or the widget is a text field, which draws its cursor however it was focused; the focus itself is unchanged, so Enter and Space still press the widget.
Key repeat. A key repeated by the platform rather than by ltk's own timer reached the focused widget even while `App::claims_keyboard` was true; `key_repeated` checks it as `key_press` does.
Browser surfaces. Every surface is painted through the app's one WebGL context on one `OffscreenCanvas`, and was handed to its own canvas, a `bitmaprenderer`, with `transferToImageBitmap`. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went in a WPE browser while Chromium showed them all. A test page with two canvases fed from one offscreen canvas showed their colours swapped in WPE and right with a copy. Each surface's canvas is now a `2d` one, and `present_copy` copies the offscreen canvas with `drawImage` in `copy` composition, set before every copy since resizing a canvas resets its context, so a transparent frame replaces the last rather than leaving trails behind an animation. Subsurfaces go the same way. `WebSurfaceState` loses `bitmap` and `new` its `gpu` argument, and web-sys loses the `ImageBitmap` and `ImageBitmapRenderingContext` features.
CHANGELOG records each of these.
Not done. The tests that build `LaidOutWidget` by hand set the new field, but no test covers a press on a non-focusable button, the focus following an id or the ring. For one frame after a rebuild moves the focused widget, its ring and its cursor may show in the old place.
This commit is contained in:
2026-11-01 09:19:23 +01:00
parent da34eee9e6
commit 02ab10b3ef
40 changed files with 292 additions and 81 deletions
+10
View File
@@ -76,9 +76,17 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
- **`make stylecheck` / `scripts/style-check.sh`** — mechanical checks for the grep-verifiable subset of the style guide (tab indentation, spaces inside attribute brackets), wired into CI. The whole tree was normalized to pass (82 attribute sites). - **`make stylecheck` / `scripts/style-check.sh`** — mechanical checks for the grep-verifiable subset of the style guide (tab indentation, spaces inside attribute brackets), wired into CI. The whole tree was normalized to pass (82 attribute sites).
- **`ltk::orientation()` / `viewport_size()` / `set_viewport_size`** and the `Orientation` enum — the runtime records the main surface's physical dimensions on every configure, so `view()` can branch a layout on portrait vs landscape (`match ltk::orientation() { … }`) without tracking `on_resize` by hand; the portrait/landscape rule matches `Length::orient` (square counts as portrait). Embedders driving `core::UiSurface` directly call `set_viewport_size` themselves. `examples/clip_path.rs` demonstrates it. - **`ltk::orientation()` / `viewport_size()` / `set_viewport_size`** and the `Orientation` enum — the runtime records the main surface's physical dimensions on every configure, so `view()` can branch a layout on portrait vs landscape (`match ltk::orientation() { … }`) without tracking `on_resize` by hand; the portrait/landscape rule matches `Length::orient` (square counts as portrait). Embedders driving `core::UiSurface` directly call `set_viewport_size` themselves. `examples/clip_path.rs` demonstrates it.
- **`test-support` Cargo feature** gates the `test_support` module so third-party builds never see it (ltk's own `make test` enables it). - **`test-support` Cargo feature** gates the `test_support` module so third-party builds never see it (ltk's own `make test` enables it).
- **`App::restore_on_launch`** — `true` hands the saved state to `restore_state` on an ordinary launch too, for an app whose users expect to find what they left open however the last run ended, as a browser's tabs; by default a plain launch still starts fresh.
- **`WidgetId::keyed( name, key )`** — the id of one of many widgets of the same kind, such as the tabs of a strip, told apart by a key that stays the widget's own. `WidgetId( name )` is the same with key 0.
- **`Pressable::focus_on_press( false )`** — a press on the pressable leaves the focus where it was.
### Changed ### Changed
- **`WidgetId` is a name and a key** — `WidgetId { name, key }` with private fields, read through `name()` and `key()`, in place of the tuple struct `WidgetId( pub &'static str )`. `WidgetId( "name" )` still builds one, through a `const fn` of that name, so existing calls compile unchanged; only code reading `.0` must use `name()`.
- **The focus follows its widget's id** — the focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index, such as the close button of the next tab after a tab closed. When the focused widget has a `WidgetId`, the focus, with the field's pending text, cursor, selection and scroll, now moves to wherever that id is after each layout, and is dropped when the widget is gone; a widget only scrolled out of view keeps it. The surface is drawn again when the focus moved. Widgets without an id keep the index as before.
- **The focus ring shows only after the keyboard moved the focus** — a press that focused a button, a toggle or a list item left its ring drawn, as a browser does not since `:focus-visible`. The ring now shows after Tab or Shift+Tab and goes with the next press; a text field still draws its cursor however it was focused. The widget keeps the focus either way, so Enter and Space still press it.
- **A button that is not focusable does not take the focus when pressed** — `Button::focusable( false )` and `WindowButton`, which is not focusable by default, kept out of the Tab cycle but still took the focus on a press, so a later Enter or Space pressed them again. A press on one now leaves the focus where it was, Enter no longer falls back to such a widget under the pointer, and a rebuild that leaves the focus on one drops it. The close buttons of `tab_strip` are not focusable; a tab takes the focus when chosen and keeps it by its key.
- **A key that types nothing reaches the app from a text field** — with a text field focused, a key without Ctrl that types no text, such as F3, was dropped; it now goes to `App::on_key_with_modifiers`, as it does with no field focused, so an editor can search again with F3 and back with Shift+F3. - **A key that types nothing reaches the app from a text field** — with a text field focused, a key without Ctrl that types no text, such as F3, was dropped; it now goes to `App::on_key_with_modifiers`, as it does with no field focused, so an editor can search again with F3 and back with Shift+F3.
- **A long multiline value no longer slows every frame** — wrapping measured each character by shaping it alone, on every draw, click and caret motion. Each character's advance is now measured once per font and size (`Canvas::measure_char`), and the last layouts are kept by the value, width and font, so a value is wrapped again only when one of them changes. - **A long multiline value no longer slows every frame** — wrapping measured each character by shaping it alone, on every draw, click and caret motion. Each character's advance is now measured once per font and size (`Canvas::measure_char`), and the last layouts are kept by the value, width and font, so a value is wrapped again only when one of them changes.
- **A surface's scale may be fractional.** `SurfaceState::scale_factor` is an `f32`, the physical pixels per logical one, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once (`physical_width`, `physical_height`), and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size. - **A surface's scale may be fractional.** `SurfaceState::scale_factor` is an `f32`, the physical pixels per logical one, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once (`physical_width`, `physical_height`), and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size.
@@ -102,6 +110,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Fixed ### Fixed
- **A browser app's surfaces in WebKit** — every surface is painted through one WebGL context on one `OffscreenCanvas` and was handed to its own canvas, a `bitmaprenderer`, with `transferToImageBitmap`. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went. Each surface's canvas is now a `2d` one that copies the offscreen canvas with `drawImage`, in `copy` composition so a transparent frame replaces the last; subsurfaces too. `ImageBitmap` and `ImageBitmapRenderingContext` leave web-sys's features.
- **`claims_keyboard` covers the platform's key repeat** — a key repeated by the platform rather than by ltk's own timer still reached the focused widget while `App::claims_keyboard` was `true`.
- **A close the app refuses shows what it asks** — an app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request now goes through `AppData::request_close`, which draws the view again when the app refused it. - **A close the app refuses shows what it asks** — an app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request now goes through `AppData::request_close`, which draws the view again when the app refused it.
- **A multiline field keeps the view it shows** — the first line on screen was worked out on every frame from the last one scrolled to by hand, or from the top, and never kept, so once the cursor had gone below the first screen it stayed on the last row: the arrows moved the text instead of the cursor, and a click brought the line clicked to the bottom. `TextEdit::draw` now returns the view it showed, which the frame keeps, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. A press, the wheel, the scroll bar and the page keys act on the lines on screen as the last frame showed them (`Window::shown`): a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. A long press that holds the scroll bar no longer opens the Copy / Cut / Paste menu. - **A multiline field keeps the view it shows** — the first line on screen was worked out on every frame from the last one scrolled to by hand, or from the top, and never kept, so once the cursor had gone below the first screen it stayed on the last row: the arrows moved the text instead of the cursor, and a click brought the line clicked to the bottom. `TextEdit::draw` now returns the view it showed, which the frame keeps, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. A press, the wheel, the scroll bar and the page keys act on the lines on screen as the last frame showed them (`Window::shown`): a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. A long press that holds the scroll bar no longer opens the Copy / Cut / Paste menu.
- **A multiline field scrolled by hand stays where it was taken** — focusing a field puts its cursor at the end through a sentinel past the value, and a scroll by the wheel kept that sentinel while the view compared it with the cursor taken back to the value's length: they never matched, so the view went back to the cursor at once and the wheel seemed to do nothing. The kept cursor is now taken back the same way. - **A multiline field scrolled by hand stays where it was taken** — focusing a field puts its cursor at the end through a sentinel past the value, and a scroll by the wheel kept that sentinel while the view compared it with the cursor taken back to the value's length: they never matched, so the view went back to the cursor at once and the wheel seemed to do nothing. The kept cursor is now taken back the same way.
-2
View File
@@ -119,8 +119,6 @@ web-sys = { version = "0.3", optional = true, features = [
"EventTarget", "EventTarget",
"HtmlCanvasElement", "HtmlCanvasElement",
"HtmlElement", "HtmlElement",
"ImageBitmap",
"ImageBitmapRenderingContext",
"ImageData", "ImageData",
"KeyboardEvent", "KeyboardEvent",
"MediaQueryList", "MediaQueryList",
+1
View File
@@ -41,6 +41,7 @@ fn build_widgets( n: usize ) -> Vec<LaidOutWidget<()>>
paint_rect: rect, paint_rect: rect,
handlers, handlers,
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: ltk::CursorShape::Default, cursor: ltk::CursorShape::Default,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
+2
View File
@@ -878,6 +878,8 @@ did not exit cleanly. A plain launch starts fresh, Android-style;
window size and position still come back on every launch through window size and position still come back on every launch through
`xdg-session-management-v1`. `xdg-session-management-v1`.
An app whose state the user expects back on every launch, however the last run ended, as a browser's tabs, returns `true` from `App::restore_on_launch`; the runtime then restores on a plain launch too. `restore_state` should then merge what the launch itself asked for, a URL or file on the command line, into the restored state instead of letting the restore replace it.
The bytes are yours: version them, and treat a parse failure as "keep The bytes are yours: version them, and treat a parse failure as "keep
the defaults". No serde needed for small state. the defaults". No serde needed for small state.
+2 -1
View File
@@ -447,7 +447,8 @@ changed, when the window closes and on `SIGTERM` / `SIGINT`. `restore_state`
gets those bytes back before the first frame — but only when the shell gets those bytes back before the first frame — but only when the shell
relaunches the app as part of a session restore (`LTK_SESSION_RESTORE=1`) or relaunches the app as part of a session restore (`LTK_SESSION_RESTORE=1`) or
when the previous run did not exit cleanly. Opening the app from the launcher when the previous run did not exit cleanly. Opening the app from the launcher
starts fresh; window size and position still come back on every launch starts fresh, unless `restore_on_launch` returns `true`, as a browser does to
bring its tabs back; window size and position still come back on every launch
because the compositor restores them through `xdg-session-management-v1`. because the compositor restores them through `xdg-session-management-v1`.
Shell components (panels, lock screens) return `None` and no-op. See the Shell components (panels, lock screens) return `None` and no-op. See the
cookbook recipe *Surviving relaunch: session state* for a worked example. cookbook recipe *Surviving relaunch: session state* for a worked example.
+63 -2
View File
@@ -568,8 +568,9 @@ pub trait App: 'static
/// [`run`](crate::run) / [`try_run`](crate::try_run), before the /// [`run`](crate::run) / [`try_run`](crate::try_run), before the
/// window is created and before the first [`view`](Self::view) — the /// window is created and before the first [`view`](Self::view) — the
/// first frame already shows the restored state. It is **not** /// first frame already shows the restored state. It is **not**
/// called on an ordinary launch. The runtime restores app state only /// called on an ordinary launch unless
/// when /// [`restore_on_launch`](Self::restore_on_launch) says so. The runtime
/// restores app state only when
/// ///
/// * the process is relaunched as part of a session restore: the /// * the process is relaunched as part of a session restore: the
/// shell sets `LTK_SESSION_RESTORE=1` in the environment (the /// shell sets `LTK_SESSION_RESTORE=1` in the environment (the
@@ -596,6 +597,66 @@ pub trait App: 'static
/// worked example. /// worked example.
fn restore_state( &mut self, state: Vec<u8> ); fn restore_state( &mut self, state: Vec<u8> );
/// Whether an ordinary launch also restores the saved state.
///
/// By default [`restore_state`](Self::restore_state) follows Android's
/// saved-instance-state contract: the state comes back after a crash
/// or a session restore, and a launch from the launcher starts fresh.
/// Some apps keep state that the user expects to find on every launch,
/// however the last run ended, as Android browsers keep their tabs in
/// their own storage even when closed from the app switcher. Such an
/// app returns `true`, and the runtime then hands the saved bytes to
/// `restore_state` on a plain launch too. Nothing else changes: saving,
/// the file layout and the window geometry are the same either way.
///
/// Asked once per process, inside [`run`](crate::run) /
/// [`try_run`](crate::try_run), right before `restore_state` would be
/// called and before the window exists, so the answer can only come
/// from the state the app was constructed with, for instance a user
/// setting read in `main`. It is not asked, and nothing is restored,
/// when persistence is off: no usable [`app_id`](Self::app_id),
/// another live instance holding the state directory, or a layer or
/// session-lock surface. With no state file, or after `save_state`
/// returned `None`, `restore_state` is not called either.
///
/// Since `restore_state` now runs on launches that carry their own
/// intent, such as a file or URL on the command line, it should merge
/// that intent into the restored state rather than let the restore
/// replace it. What `save_state` returns then outlives every exit, so
/// leave out what the user would expect to be forgotten on closing
/// the app (a private window, a half-typed password).
///
/// Default `false`.
///
/// ```rust
/// # use ltk::{ App, Element, text };
/// # #[ derive( Clone ) ] enum Msg {}
/// struct Browser { tabs: Vec<String>, keep_tabs: bool, opened: Option<String> }
///
/// impl App for Browser
/// {
/// # type Message = Msg;
/// # fn app_id( &self ) -> &str { "net.example.Browser" }
/// # fn view( &self ) -> Element<Msg> { text( "" ).into() }
/// # fn update( &mut self, _: Msg ) {}
/// fn save_state( &self ) -> Option<Vec<u8>>
/// {
/// Some( self.tabs.join( "\n" ).into_bytes() )
/// }
///
/// fn restore_state( &mut self, state: Vec<u8> )
/// {
/// let Ok( text ) = String::from_utf8( state ) else { return };
/// self.tabs = text.lines().map( String::from ).collect();
/// // The URL this launch was asked to open goes after the restored tabs.
/// self.tabs.extend( self.opened.take() );
/// }
///
/// fn restore_on_launch( &self ) -> bool { self.keep_tabs }
/// }
/// ```
fn restore_on_launch( &self ) -> bool { false }
/// Tell the runtime which surfaces *could* have changed visibly as a /// Tell the runtime which surfaces *could* have changed visibly as a
/// result of [`update`](Self::update) being called with this message. /// result of [`update`](Self::update) being called with this message.
/// ///
+1
View File
@@ -232,6 +232,7 @@ mod tests
paint_rect: paint, paint_rect: paint,
handlers: WidgetHandlers::None, handlers: WidgetHandlers::None,
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: crate::types::CursorShape::Default, cursor: crate::types::CursorShape::Default,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
+3
View File
@@ -188,6 +188,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
repeating: false, repeating: false,
}, },
keyboard_focusable: false, keyboard_focusable: false,
focus_on_press: p.focus_on_press,
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ), cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
@@ -541,6 +542,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
repeating: false, repeating: false,
}, },
keyboard_focusable: false, keyboard_focusable: false,
focus_on_press: true,
cursor: crate::types::CursorShape::Pointer, cursor: crate::types::CursorShape::Pointer,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
@@ -598,6 +600,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
paint_rect: other.paint_bounds( rect ), paint_rect: other.paint_bounds( rect ),
handlers, handlers,
keyboard_focusable: other.is_focusable(), keyboard_focusable: other.is_focusable(),
focus_on_press: other.focus_on_press(),
cursor: other.cursor_shape(), cursor: other.cursor_shape(),
tooltip: other.tooltip().map( str::to_string ), tooltip: other.tooltip().map( str::to_string ),
accessible_label: other.accessible_label(), accessible_label: other.accessible_label(),
+3 -1
View File
@@ -160,7 +160,9 @@ fn paint_view<Msg: Clone, S>(
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx = build_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout ); // A text field draws its cursor when focused however the focus came.
let drawn_focus = ss.focused_idx.filter( |&idx| ss.focus_visible || crate::tree::find_handlers( &ss.frame.widget_rects, idx ).is_some_and( |h| h.is_text_input() ) );
let mut ctx = build_draw_ctx( &mut ss.frame, drawn_focus, ss.hovered_idx, ss.gesture.pressed_idx, debug_layout );
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 ); layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout if ctx.debug_layout
+64 -1
View File
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashMap;
use super::app_data::AppData; use super::app_data::AppData;
use super::surface::SurfaceFocus; use super::surface::SurfaceFocus;
use crate::app::{ App, OverlayId }; use crate::app::{ App, OverlayId };
use crate::tree::find_handlers; use crate::tree::{ find_handlers, find_widget };
use crate::types::{ Point, WidgetId }; use crate::types::{ Point, WidgetId };
use crate::widget::WidgetHandlers; use crate::widget::WidgetHandlers;
use crate::widget::scroll::offset_into_view; use crate::widget::scroll::offset_into_view;
@@ -97,6 +99,25 @@ impl<A: App, P: Platform> AppData<A, P>
self.dirty_caches(); self.dirty_caches();
} }
/// Focus what a press landed on, unless that widget leaves the focus where it was.
pub( crate ) fn focus_pressed( &mut self, focus: SurfaceFocus, hit: Option<usize> )
{
let rects = &self.surface( focus ).frame.widget_rects;
if hit.and_then( |idx| find_widget( rects, idx ) ).is_some_and( |w| !w.focus_on_press ) { return; }
self.show_focus_ring( focus, false );
self.set_focus( focus, hit );
}
/// The ring is drawn only when the keyboard moved the focus, so its index alone does not tell the partial redraw.
pub( crate ) fn show_focus_ring( &mut self, focus: SurfaceFocus, visible: bool )
{
let ss = self.surface_mut( focus );
if ss.focus_visible == visible { return; }
ss.focus_visible = visible;
ss.content_dirty = true;
ss.request_redraw();
}
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize> ) pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize> )
{ {
let was_text_input; let was_text_input;
@@ -229,6 +250,48 @@ impl<A: App, P: Platform> AppData<A, P>
} }
} }
/// After a rebuild, move the focus and its text state to the focused id's new index, or drop it when that widget is gone; `true` when it changed.
pub( crate ) fn follow_focused_id<Msg: Clone, S>( ss: &mut super::surface::SurfaceState<Msg, S> ) -> bool
{
let ( Some( id ), Some( old ) ) = ( ss.focused_id, ss.focused_idx ) else { return false };
if find_widget( &ss.frame.widget_rects, old ).is_some_and( |w| w.id == Some( id ) ) { return false; }
let Some( new ) = ss.frame.widget_rects.iter().find( |w| w.id == Some( id ) ).map( |w| w.flat_idx ) else
{
if ss.frame.scroll_clipped.iter().any( |( clipped, .. )| *clipped == id ) { return false; }
ss.focused_idx = None;
ss.focused_id = None;
return true;
};
move_entry( &mut ss.pending_text_values, old, new );
move_entry( &mut ss.frame.cursor_state, old, new );
move_entry( &mut ss.frame.selection_anchor, old, new );
move_entry( &mut ss.frame.text_scroll, old, new );
ss.focused_idx = Some( new );
true
}
fn move_entry<V>( map: &mut HashMap<usize, V>, from: usize, to: usize )
{
match map.remove( &from )
{
Some( value ) => { map.insert( to, value ); }
None => { map.remove( &to ); }
}
}
/// The focus is held by index, so a rebuild can leave it on a widget that never takes it, where Enter would press it.
pub( crate ) fn drop_misplaced_focus<Msg: Clone, S>( ss: &mut super::surface::SurfaceState<Msg, S> )
{
let misplaced = ss.focused_idx
.and_then( |idx| find_widget( &ss.frame.widget_rects, idx ) )
.is_some_and( |w| !w.focus_on_press );
if misplaced
{
ss.focused_idx = None;
ss.focused_id = None;
}
}
/// Whether the surface's focused widget is a text input in its current frame. /// Whether the surface's focused widget is a text input in its current frame.
pub( crate ) fn surface_focused_text_input<Msg: Clone, S>( ss: &super::surface::SurfaceState<Msg, S> ) -> bool pub( crate ) fn surface_focused_text_input<Msg: Clone, S>( ss: &super::surface::SurfaceState<Msg, S> ) -> bool
{ {
+14 -13
View File
@@ -7,7 +7,7 @@ use crate::types::{ Color, Rect };
use crate::widget::Element; use crate::widget::Element;
use super::AppData; use super::AppData;
use super::focus::surface_focused_text_input; use super::focus::{ drop_misplaced_focus, follow_focused_id, surface_focused_text_input };
use super::surface::{ SurfaceFocus, SurfaceState }; use super::surface::{ SurfaceFocus, SurfaceState };
use crate::draw::{ paint_full, paint_partial }; use crate::draw::{ paint_full, paint_partial };
use crate::draw::damage::compute_interaction_dirty_rects; use crate::draw::damage::compute_interaction_dirty_rects;
@@ -33,7 +33,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
{ {
text_focus_rebuilt.push( SurfaceFocus::Main ); text_focus_rebuilt.push( SurfaceFocus::Main );
} }
draw_surface( data.main.needs_redraw = draw_surface(
platform, platform,
&mut data.main, &mut data.main,
main_view, main_view,
@@ -43,7 +43,6 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
debug_layout, debug_layout,
Some( SurfaceFocus::Main ), Some( SurfaceFocus::Main ),
); );
data.main.needs_redraw = false;
data.main.last_draw = crate::time::Instant::now(); data.main.last_draw = crate::time::Instant::now();
if !data.first_frame_committed if !data.first_frame_committed
{ {
@@ -65,8 +64,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
// No frame callback: these surfaces are static between invalidations, // No frame callback: these surfaces are static between invalidations,
// so there is no per-frame pacing to keep — and a callback that never // so there is no per-frame pacing to keep — and a callback that never
// gets requested again would leave `frame_pending` stuck. // gets requested again would leave `frame_pending` stuck.
draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None ); ss.needs_redraw = draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None );
ss.needs_redraw = false;
ss.frame_pending = false; ss.frame_pending = false;
ss.last_draw = crate::time::Instant::now(); ss.last_draw = crate::time::Instant::now();
} }
@@ -83,7 +81,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
} }
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 ); let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
let opaque = data.app.overlay_opaque_region( spec.id ); let opaque = data.app.overlay_opaque_region( spec.id );
draw_surface( ss.needs_redraw = draw_surface(
platform, platform,
ss, ss,
&spec.view, &spec.view,
@@ -93,7 +91,6 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
debug_layout, debug_layout,
Some( focus ), Some( focus ),
); );
ss.needs_redraw = false;
ss.last_draw = crate::time::Instant::now(); ss.last_draw = crate::time::Instant::now();
data.app.on_overlay_frame_committed( spec.id ); data.app.on_overlay_frame_committed( spec.id );
if std::mem::take( &mut ss.frame.blur_changed ) if std::mem::take( &mut ss.frame.blur_changed )
@@ -111,7 +108,7 @@ pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> b
first_commit_now first_commit_now
} }
/// Paint `ss` and present it, under the rects whose interaction state changed when they cover less than half of it, else all of it. `frame` is the surface the next frame callback is reported for, `None` for one not paced by frames. /// Paint `ss` and present it, under the rects whose interaction state changed when they cover less than half of it, else all of it. `frame` is the surface the next frame callback is reported for, `None` for one not paced by frames. Returns whether the surface must be drawn again, because the focus moved after the layout.
fn draw_surface<P: Platform, Msg: Clone>( fn draw_surface<P: Platform, Msg: Clone>(
platform: &P, platform: &P,
ss: &mut SurfaceState<Msg, P::Surface>, ss: &mut SurfaceState<Msg, P::Surface>,
@@ -121,12 +118,12 @@ fn draw_surface<P: Platform, Msg: Clone>(
opaque_region: Option<&[Rect]>, opaque_region: Option<&[Rect]>,
debug_layout: bool, debug_layout: bool,
frame: Option<SurfaceFocus>, frame: Option<SurfaceFocus>,
) ) -> bool
{ {
let scale = ss.scale_factor; let scale = ss.scale_factor;
let w = ss.width; let w = ss.width;
let h = ss.height; let h = ss.height;
if w == 0 || h == 0 { return; } if w == 0 || h == 0 { return false; }
let ( pw, ph ) = ( ss.physical_width(), ss.physical_height() ); let ( pw, ph ) = ( ss.physical_width(), ss.physical_height() );
let canvas_ready = ss.canvas.as_ref() let canvas_ready = ss.canvas.as_ref()
@@ -145,12 +142,12 @@ fn draw_surface<P: Platform, Msg: Clone>(
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
pw, ph, pw, ph,
); );
if dirty_rects.is_empty() { return; } if dirty_rects.is_empty() { return false; }
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum(); let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
if total < pw as f32 * ph as f32 * 0.5 { partial = Some( dirty_rects ); } if total < pw as f32 * ph as f32 * 0.5 { partial = Some( dirty_rects ); }
} }
if !platform.prepare_canvas( &mut ss.platform, &mut ss.canvas, pw, ph, scale ) { return; } if !platform.prepare_canvas( &mut ss.platform, &mut ss.canvas, pw, ph, scale ) { return false; }
let damage = match partial let damage = match partial
{ {
Some( dirty_rects ) => paint_partial( ss, view, bg, dirty_rects, pw, ph, scale ), Some( dirty_rects ) => paint_partial( ss, view, bg, dirty_rects, pw, ph, scale ),
@@ -160,9 +157,12 @@ fn draw_surface<P: Platform, Msg: Clone>(
paint_full( ss, view, bg, debug_layout, precise, pw, ph, scale ) paint_full( ss, view, bg, debug_layout, precise, pw, ph, scale )
} }
}; };
let focus_moved = follow_focused_id( ss );
drop_misplaced_focus( ss );
if focus_moved { ss.content_dirty = true; }
let presentation = Presentation { damage: &damage, bg, opaque_region, input_region, width: w, height: h, scale, frame }; let presentation = Presentation { damage: &damage, bg, opaque_region, input_region, width: w, height: h, scale, frame };
let Some( canvas ) = ss.canvas.as_mut() else { return }; let Some( canvas ) = ss.canvas.as_mut() else { return focus_moved };
if platform.present( &mut ss.platform, canvas, &presentation ) if platform.present( &mut ss.platform, canvas, &presentation )
{ {
ss.frame_pending = true; ss.frame_pending = true;
@@ -170,4 +170,5 @@ fn draw_surface<P: Platform, Msg: Clone>(
// What was painted never reached the screen, so the next frame cannot build on it. // What was painted never reached the screen, so the next frame cannot build on it.
ss.content_dirty = true; ss.content_dirty = true;
} }
focus_moved
} }
+3
View File
@@ -137,6 +137,8 @@ pub( crate ) struct SurfaceState<Msg: Clone, S = NativeSurface>
pub last_draw: crate::time::Instant, pub last_draw: crate::time::Instant,
pub focused_idx: Option<usize>, pub focused_idx: Option<usize>,
pub focused_id: Option<WidgetId>, pub focused_id: Option<WidgetId>,
/// The keyboard moved the focus last, so the focused widget shows its ring; a press does not.
pub focus_visible: bool,
pub hovered_idx: Option<usize>, pub hovered_idx: Option<usize>,
pub pending_text_values: HashMap<usize, String>, pub pending_text_values: HashMap<usize, String>,
/// Per-frame draw state: laid-out rects, carried scroll / text-edit / a11y /// Per-frame draw state: laid-out rects, carried scroll / text-edit / a11y
@@ -207,6 +209,7 @@ impl<Msg: Clone, S> SurfaceState<Msg, S>
last_draw: crate::time::Instant::now().checked_sub( std::time::Duration::from_millis( 100 ) ).unwrap_or_else( crate::time::Instant::now ), last_draw: crate::time::Instant::now().checked_sub( std::time::Duration::from_millis( 100 ) ).unwrap_or_else( crate::time::Instant::now ),
focused_idx: None, focused_idx: None,
focused_id: None, focused_id: None,
focus_visible: false,
hovered_idx: None, hovered_idx: None,
pending_text_values: HashMap::new(), pending_text_values: HashMap::new(),
frame: FrameState::new(), frame: FrameState::new(),
+2
View File
@@ -42,6 +42,7 @@ fn button_full(
paint_rect: r, paint_rect: r,
handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false }, handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false },
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: crate::types::CursorShape::Default, cursor: crate::types::CursorShape::Default,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
@@ -69,6 +70,7 @@ fn slider_widget( idx: usize, r: Rect, on_release: Option<Msg> ) -> LaidOutWidge
thumb_px: 0.0, thumb_px: 0.0,
}, },
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: crate::types::CursorShape::Default, cursor: crate::types::CursorShape::Default,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
+1
View File
@@ -80,6 +80,7 @@ impl<A: App, P: Platform> AppData<A, P>
pub( crate ) fn key_repeated( &mut self, key: KeyInput ) pub( crate ) fn key_repeated( &mut self, key: KeyInput )
{ {
self.stop_key_repeat(); self.stop_key_repeat();
if key.keysym != Keysym::XF86_Back && self.app.claims_keyboard() { return; }
let focus = self.keyboard_focus; let focus = self.keyboard_focus;
self.dispatch_key( focus, key ); self.dispatch_key( focus, key );
} }
+1
View File
@@ -72,6 +72,7 @@ impl<A: App, P: Platform> AppData<A, P>
let next_idx = next_focusable_index( &ss.frame.widget_rects, ss.focused_idx, reverse ); let next_idx = next_focusable_index( &ss.frame.widget_rects, ss.focused_idx, reverse );
if let Some( next_idx ) = next_idx if let Some( next_idx ) = next_idx
{ {
self.show_focus_ring( focus, true );
self.set_focus( focus, Some( next_idx ) ); self.set_focus( focus, Some( next_idx ) );
} }
} }
+4 -2
View File
@@ -3,7 +3,7 @@
use crate::app::App; use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus }; use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::find_handlers; use crate::tree::{ find_handlers, find_widget };
use crate::Keysym; use crate::Keysym;
use crate::platform::Platform; use crate::platform::Platform;
@@ -62,7 +62,9 @@ impl<A: App, P: Platform> AppData<A, P>
{ {
self.handle_text_insert( focus, "\n" ); self.handle_text_insert( focus, "\n" );
} else { } else {
let target = focused.or( self.surface( focus ).hovered_idx ); let ss = self.surface( focus );
let hovered = ss.hovered_idx.filter( |&idx| find_widget( &ss.frame.widget_rects, idx ).is_some_and( |w| w.focus_on_press ) );
let target = focused.or( hovered );
let mut handled = false; let mut handled = false;
if let Some( idx ) = target if let Some( idx ) = target
{ {
+1 -1
View File
@@ -146,7 +146,7 @@ impl<A: App, P: Platform> AppData<A, P>
result result
}; };
self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx; self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx;
self.set_focus( focus, outcome.hit_idx ); self.focus_pressed( focus, outcome.hit_idx );
self.osk_after_press( focus, outcome.hit_idx ); self.osk_after_press( focus, outcome.hit_idx );
if let Some( msg ) = outcome.initial_slider_msg if let Some( msg ) = outcome.initial_slider_msg
{ {
+1 -1
View File
@@ -153,7 +153,7 @@ impl<A: App, P: Platform> AppData<A, P>
result result
}; };
self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx; self.press_on_focused = outcome.hit_idx.is_some() && self.surface( focus ).focused_idx == outcome.hit_idx;
self.set_focus( focus, outcome.hit_idx ); self.focus_pressed( focus, outcome.hit_idx );
self.osk_after_press( focus, outcome.hit_idx ); self.osk_after_press( focus, outcome.hit_idx );
if let Some( msg ) = outcome.initial_slider_msg if let Some( msg ) = outcome.initial_slider_msg
{ {
+1 -1
View File
@@ -78,7 +78,7 @@ impl SessionRuntime
} }
Startup::Reason( reason ) => rt.reason = reason, Startup::Reason( reason ) => rt.reason = reason,
} }
if rt.reason != RestoreReason::Launch if rt.reason != RestoreReason::Launch || app.restore_on_launch()
{ {
if let Some( bytes ) = store.load_state() if let Some( bytes ) = store.load_state()
{ {
+1 -1
View File
@@ -14,7 +14,7 @@
//! | `log.rs` | Panics into the browser's console | //! | `log.rs` | Panics into the browser's console |
//! | `state.rs` | [`WebPlatform`]: the desktop, the one WebGL2 context on its `OffscreenCanvas`, and what the runtime asked of the page | //! | `state.rs` | [`WebPlatform`]: the desktop, the one WebGL2 context on its `OffscreenCanvas`, and what the runtime asked of the page |
//! | `surface.rs` | [`WebSurfaceState`]: a surface's canvas and its context; sizing the main surface | //! | `surface.rs` | [`WebSurfaceState`]: a surface's canvas and its context; sizing the main surface |
//! | `present.rs` | Putting a painted canvas on the page: through the shared WebGL context into a `bitmaprenderer`, or the software frame through `putImageData` | //! | `present.rs` | Putting a painted canvas on the page: a copy of the shared WebGL context's canvas through `drawImage`, or the software frame through `putImageData` |
//! | `input.rs`, `keys.rs` | A surface's pointer, wheel, key and focus events turned into [`crate::input::event`], `KeyboardEvent.key` into keysyms and `.code` into Linux input codes | //! | `input.rs`, `keys.rs` | A surface's pointer, wheel, key and focus events turned into [`crate::input::event`], `KeyboardEvent.key` into keysyms and `.code` into Linux input codes |
//! | `cursor.rs` | The cursor's shapes as CSS names them | //! | `cursor.rs` | The cursor's shapes as CSS names them |
//! | `clipboard.rs` | Copies to the system clipboard; pastes come from the page's `paste` event | //! | `clipboard.rs` | Copies to the system clipboard; pastes come from the page's `paste` event |
+1 -2
View File
@@ -54,7 +54,6 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
let scale = data.main.scale_factor; let scale = data.main.scale_factor;
let sizes: Vec<( u32, u32 )> = specs.iter().map( |s| data.overlay_size( &s.size ) ).collect(); let sizes: Vec<( u32, u32 )> = specs.iter().map( |s| data.overlay_size( &s.size ) ).collect();
let gpu = data.platform.offscreen.is_some();
let Some( desktop ) = data.platform.desktop.as_ref() else { return }; let Some( desktop ) = data.platform.desktop.as_ref() else { return };
for ( spec, &size ) in specs.iter().zip( &sizes ) for ( spec, &size ) in specs.iter().zip( &sizes )
{ {
@@ -87,7 +86,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
Vec::new() Vec::new()
} }
}; };
let mut platform = WebSurfaceState::new( canvas, input, id, gpu, listeners ); let mut platform = WebSurfaceState::new( canvas, input, id, listeners );
platform.role = Some( role ); platform.role = Some( role );
let mut ss = SurfaceState::new( platform, 0.0, String::new() ); let mut ss = SurfaceState::new( platform, 0.0, String::new() );
// The main surface's scale until forge/web configures this one, so the first frame lays out at the right size. // The main surface's scale until forge/web configures this one, so the first frame lays out at the right size.
+15 -15
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Putting a painted canvas on the page. On the GPU every surface is painted through the app's one WebGL context, on its `OffscreenCanvas`: the surface's FBO is blitted onto it, taken as an `ImageBitmap` and handed to the surface's own canvas, a `bitmaprenderer`, without a copy. In software the frame goes through `putImageData`. The browser may take the WebGL context away, and give it back, at any time. //! Putting a painted canvas on the page. On the GPU every surface is painted through the app's one WebGL context, on its `OffscreenCanvas`: the surface's FBO is blitted onto it and copied with `drawImage` onto the surface's own canvas. In software the frame goes through `putImageData`. The browser may take the WebGL context away, and give it back, at any time.
use std::sync::Arc; use std::sync::Arc;
use serde_json::json; use serde_json::json;
use wasm_bindgen::Clamped; use wasm_bindgen::Clamped;
use web_sys::ImageData; use web_sys::{ ImageData, OffscreenCanvas };
use crate::app::App; use crate::app::App;
use crate::draw::fit_canvas; use crate::draw::fit_canvas;
@@ -54,23 +54,14 @@ pub( super ) fn present<A: App>
frame: &Presentation<'_>, frame: &Presentation<'_>,
) -> bool ) -> bool
{ {
let shown = match ( platform.offscreen.as_ref(), surface.bitmap.as_ref(), surface.ctx2d.as_ref() ) let shown = match platform.offscreen.as_ref()
{ {
( Some( offscreen ), Some( bitmap ), _ ) => Some( offscreen ) =>
{ {
canvas.present(); canvas.present();
match offscreen.transfer_to_image_bitmap() present_copy( surface, offscreen )
{
Ok( image ) =>
{
bitmap.transfer_from_image_bitmap( &image );
true
}
Err( _ ) => false,
}
} }
( _, _, Some( _ ) ) => present_software( surface, canvas ), None => present_software( surface, canvas ),
_ => false,
}; };
if let Some( desktop ) = platform.desktop.as_ref() if let Some( desktop ) = platform.desktop.as_ref()
{ {
@@ -99,6 +90,15 @@ fn sync_input_region( desktop: &super::desktop::Connection, surface: &mut WebSur
surface.input_region = Some( rects ); surface.input_region = Some( rects );
} }
/// A copy, not `transferToImageBitmap` into a `bitmaprenderer`: WebKit reuses the buffer of a bitmap it was handed, so each surface showed another's frame.
pub( super ) fn present_copy( surface: &WebSurfaceState, offscreen: &OffscreenCanvas ) -> bool
{
let Some( ctx ) = surface.ctx2d.as_ref() else { return false };
// Resizing the canvas resets its context's state, so the frame would otherwise be blended over the last.
let _ = ctx.set_global_composite_operation( "copy" );
ctx.draw_image_with_offscreen_canvas( offscreen, 0.0, 0.0 ).is_ok()
}
pub( super ) fn present_software( surface: &mut WebSurfaceState, canvas: &Canvas ) -> bool pub( super ) fn present_software( surface: &mut WebSurfaceState, canvas: &Canvas ) -> bool
{ {
let Some( ctx ) = surface.ctx2d.as_ref() else { return false }; let Some( ctx ) = surface.ctx2d.as_ref() else { return false };
+1 -1
View File
@@ -178,7 +178,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
if own { let _ = target.focus(); } if own { let _ = target.focus(); }
let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() ); let coarse = window.match_media( "(pointer: coarse)" ).ok().flatten().is_some_and( |m| m.matches() );
let main = SurfaceState::new( WebSurfaceState::new( canvas, target.clone(), MAIN, offscreen.is_some(), input ), 0.0, String::new() ); let main = SurfaceState::new( WebSurfaceState::new( canvas, target.clone(), MAIN, input ), 0.0, String::new() );
let platform = WebPlatform::new( desktop, offscreen, target, listeners, inset_probe ); let platform = WebPlatform::new( desktop, offscreen, target, listeners, inset_probe );
// ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas. // ltk's own keyboard where the page is used by touch, since the page cannot raise the system's for a canvas.
let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None ); let mut data = AppData::new( app, platform, main, Sources::new( wake ), coarse, None );
+4 -6
View File
@@ -123,7 +123,7 @@ pub( super ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
{ {
let id = platform.next_surface.get(); let id = platform.next_surface.get();
platform.next_surface.set( id + 1 ); platform.next_surface.set( id + 1 );
let Some( slot ) = create( desktop, id, spec, parent, gpu.is_some() ) else { continue }; let Some( slot ) = create( desktop, id, spec, parent ) else { continue };
platform.subsurfaces.insert( spec.id, slot ); platform.subsurfaces.insert( spec.id, slot );
} }
let slot = platform.subsurfaces.get_mut( &spec.id ).unwrap(); let slot = platform.subsurfaces.get_mut( &spec.id ).unwrap();
@@ -149,14 +149,14 @@ pub( super ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
for id in painted { data.app.on_subsurface_committed( id ); } for id in painted { data.app.on_subsurface_committed( id ); }
} }
fn create<Msg: Clone>( desktop: &Connection, id: u32, spec: &SubsurfaceSpec<Msg>, parent: &Parent, gpu: bool ) -> Option<WebSubsurface> fn create<Msg: Clone>( desktop: &Connection, id: u32, spec: &SubsurfaceSpec<Msg>, parent: &Parent ) -> Option<WebSubsurface>
{ {
let ( canvas, input ) = desktop.create_surface( id, role( spec.x, spec.y, parent ) ).map_err( |e| warn( spec.id, &e ) ).ok()?; let ( canvas, input ) = desktop.create_surface( id, role( spec.x, spec.y, parent ) ).map_err( |e| warn( spec.id, &e ) ).ok()?;
// Input falls through to the parent, which keeps one gesture model for both. // Input falls through to the parent, which keeps one gesture model for both.
if let Err( e ) = desktop.request( &json!( { "type": "set_input_region", "surface": id, "rects": [] } ) ) { warn( spec.id, &e ); } if let Err( e ) = desktop.request( &json!( { "type": "set_input_region", "surface": id, "rects": [] } ) ) { warn( spec.id, &e ); }
Some( WebSubsurface Some( WebSubsurface
{ {
surface: WebSurfaceState::new( canvas, input, id, gpu, Vec::new() ), surface: WebSurfaceState::new( canvas, input, id, Vec::new() ),
canvas: None, canvas: None,
parent: spec.parent, parent: spec.parent,
parent_id: parent.id, parent_id: parent.id,
@@ -184,9 +184,7 @@ fn paint<Msg: Clone>( slot: &mut WebSubsurface, view: &crate::widget::Element<Ms
let canvas = fit_canvas( shared, pw, ph, parent.scale, || Canvas::new_gles( Arc::clone( gl ), GlesVersion::V3, pw, ph ) ); let canvas = fit_canvas( shared, pw, ph, parent.scale, || Canvas::new_gles( Arc::clone( gl ), GlesVersion::V3, pw, ph ) );
paint_detached( canvas, view, &mut slot.scroll_canvases, &mut slot.viewport_canvases ); paint_detached( canvas, view, &mut slot.scroll_canvases, &mut slot.viewport_canvases );
canvas.present(); canvas.present();
let ( Some( bitmap ), Ok( image ) ) = ( slot.surface.bitmap.as_ref(), offscreen.transfer_to_image_bitmap() ) else { return false }; super::present::present_copy( &slot.surface, offscreen )
bitmap.transfer_from_image_bitmap( &image );
true
} }
None => None =>
{ {
+5 -9
View File
@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use serde_json::Value; use serde_json::Value;
use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, HtmlElement, ImageBitmapRenderingContext }; use web_sys::{ CanvasRenderingContext2d, Element, HtmlCanvasElement, HtmlElement };
use crate::app::App; use crate::app::App;
use crate::event_loop::AppData; use crate::event_loop::AppData;
@@ -16,9 +16,7 @@ pub( crate ) struct WebSurfaceState
pub canvas: HtmlCanvasElement, pub canvas: HtmlCanvasElement,
/// Takes the surface's input: the element forge/web gives above the canvas, or the canvas itself without a desktop. /// Takes the surface's input: the element forge/web gives above the canvas, or the canvas itself without a desktop.
pub input: HtmlElement, pub input: HtmlElement,
/// Takes the frames painted on the shared WebGL context. /// Takes every frame: a copy of the shared WebGL canvas, or the software renderer's pixels without WebGL2.
pub bitmap: Option<ImageBitmapRenderingContext>,
/// Takes the software renderer's frames, without WebGL2.
pub ctx2d: Option<CanvasRenderingContext2d>, pub ctx2d: Option<CanvasRenderingContext2d>,
/// The software renderer's frame, unpremultiplied for `putImageData`. /// The software renderer's frame, unpremultiplied for `putImageData`.
pub pixels: Vec<u8>, pub pixels: Vec<u8>,
@@ -37,12 +35,10 @@ pub( crate ) struct WebSurfaceState
impl WebSurfaceState impl WebSurfaceState
{ {
/// The surface shown on `canvas`, through the shared WebGL context when there is one, else in software. /// The surface shown on `canvas`, through the shared WebGL context when there is one, else in software.
pub( super ) fn new( canvas: HtmlCanvasElement, input: HtmlElement, id: u32, gpu: bool, listeners: Vec<Listener> ) -> Self pub( super ) fn new( canvas: HtmlCanvasElement, input: HtmlElement, id: u32, listeners: Vec<Listener> ) -> Self
{ {
let context = |kind: &str| canvas.get_context( kind ).ok().flatten(); let ctx2d = canvas.get_context( "2d" ).ok().flatten().and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() );
let bitmap = if gpu { context( "bitmaprenderer" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) } else { None }; Self { canvas, input, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners, presented: false }
let ctx2d = if gpu { None } else { context( "2d" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) };
Self { canvas, input, bitmap, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners, presented: false }
} }
} }
+1
View File
@@ -112,6 +112,7 @@ mod tests
paint_rect: paint, paint_rect: paint,
handlers: WidgetHandlers::None, handlers: WidgetHandlers::None,
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: crate::types::CursorShape::Default, cursor: crate::types::CursorShape::Default,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
+31 -4
View File
@@ -16,8 +16,8 @@
//! - [`Corners`] — per-corner radius for the //! - [`Corners`] — per-corner radius for the
//! [`Container`](crate::container()) widget and any other rounded //! [`Container`](crate::container()) widget and any other rounded
//! surface; coerces from `f32` for the uniform case. //! surface; coerces from `f32` for the uniform case.
//! - [`WidgetId`] — a stable `&'static str` identifier for focus //! - [`WidgetId`] — a stable identifier, a `&'static str` name and a key,
//! management, paired with [`crate::App::take_focus_request`]. //! for focus management, paired with [`crate::App::take_focus_request`].
//! //!
//! Every type is `Copy` (or `Clone`) so passing them by value is the //! Every type is `Copy` (or `Clone`) so passing them by value is the
//! default. The crate root re-exports them all (`ltk::Color`, //! default. The crate root re-exports them all (`ltk::Color`,
@@ -314,8 +314,35 @@ pub enum CompositeMode
/// ///
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request /// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
/// focus via [`App::take_focus_request`](crate::app::App::take_focus_request). /// focus via [`App::take_focus_request`](crate::app::App::take_focus_request).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] /// The focus follows the widget with the focused id when a rebuild moves it,
pub struct WidgetId( pub &'static str ); /// so a widget among many of the same kind, such as a tab of a strip, takes
/// [`WidgetId::keyed`] with a key that stays its own while it exists.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub struct WidgetId
{
name: &'static str,
key: u64,
}
/// The id of a single widget, the same as [`WidgetId::keyed`] with key 0.
#[ allow( non_snake_case ) ]
pub const fn WidgetId( name: &'static str ) -> WidgetId
{
WidgetId { name, key: 0 }
}
impl WidgetId
{
/// One of many widgets named `name`, told apart by `key`.
pub const fn keyed( name: &'static str, key: u64 ) -> Self
{
Self { name, key }
}
pub const fn name( &self ) -> &'static str { self.name }
pub const fn key( &self ) -> u64 { self.key }
}
/// Pointer cursor shape, sent to the compositor via /// Pointer cursor shape, sent to the compositor via
/// `wp_cursor_shape_v1` when the pointer enters a widget that /// `wp_cursor_shape_v1` when the pointer enters a widget that
+4 -1
View File
@@ -223,7 +223,10 @@ impl<Msg: Clone> Button<Msg>
} }
/// Control whether this button receives keyboard focus (Tab navigation). /// Control whether this button receives keyboard focus (Tab navigation).
/// Set to `false` for purely decorative or status-indicator buttons. /// With `false` a press does not take the focus either, so Enter or Space
/// typed afterwards go to what had it. Set to `false` for purely decorative
/// or status-indicator buttons, or ones like a tab's close button that a
/// stray Enter must not fire.
pub fn focusable( mut self, yes: bool ) -> Self pub fn focusable( mut self, yes: bool ) -> Self
{ {
self.focusable = yes; self.focusable = yes;
+3 -3
View File
@@ -778,10 +778,10 @@ impl<Msg: Clone + 'static> Combo<Msg>
.radius( 32.0 ) .radius( 32.0 )
.into(); .into();
// Derive the OverlayId from the anchor_id's static-str so apps // Derive the OverlayId from the anchor_id so apps don't have to
// don't have to manually pick non-colliding ids for each combo. // manually pick non-colliding ids for each combo.
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
self.anchor_id.0.hash( &mut hasher ); self.anchor_id.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 ); let overlay_id = OverlayId( hasher.finish() as u32 );
Some( OverlaySpec Some( OverlaySpec
+14
View File
@@ -214,6 +214,20 @@ impl<Msg: Clone> Element<Msg>
} }
} }
/// Whether a press gives this widget the focus. A button that opted out of
/// keyboard focus leaves it where it was, so Enter or Space cannot press it
/// afterwards.
pub fn focus_on_press( &self ) -> bool
{
match self
{
Element::Button( b ) => b.focusable,
Element::WindowButton( b ) => b.focusable,
Element::Pressable( p ) => p.focus_on_press,
_ => true,
}
}
pub fn is_text_input( &self ) -> bool pub fn is_text_input( &self ) -> bool
{ {
matches!( self, Element::TextEdit( t ) if !t.read_only ) matches!( self, Element::TextEdit( t ) if !t.read_only )
+3
View File
@@ -28,6 +28,8 @@ pub struct LaidOutWidget<Msg: Clone>
pub paint_rect: Rect, pub paint_rect: Rect,
pub handlers: WidgetHandlers<Msg>, pub handlers: WidgetHandlers<Msg>,
pub keyboard_focusable: bool, pub keyboard_focusable: bool,
/// Whether a press moves the focus here; otherwise the focus stays where it was.
pub focus_on_press: bool,
/// Cursor shape this widget wants to show on hover. Snapshotted /// Cursor shape this widget wants to show on hover. Snapshotted
/// from [`super::Element::cursor_shape`] at layout time so the input /// from [`super::Element::cursor_shape`] at layout time so the input
/// dispatch can pick the right shape without re-walking the /// dispatch can pick the right shape without re-walking the
@@ -52,6 +54,7 @@ impl<Msg: Clone> Clone for LaidOutWidget<Msg>
paint_rect: self.paint_rect, paint_rect: self.paint_rect,
handlers: self.handlers.clone(), handlers: self.handlers.clone(),
keyboard_focusable: self.keyboard_focusable, keyboard_focusable: self.keyboard_focusable,
focus_on_press: self.focus_on_press,
cursor: self.cursor, cursor: self.cursor,
tooltip: self.tooltip.clone(), tooltip: self.tooltip.clone(),
accessible_label: self.accessible_label.clone(), accessible_label: self.accessible_label.clone(),
+11
View File
@@ -74,6 +74,7 @@ pub struct Pressable<Msg: Clone>
/// when any handler is also set; in that case the handler determines /// when any handler is also set; in that case the handler determines
/// the message and `swallow` is implicit. /// the message and `swallow` is implicit.
pub( crate ) swallow: bool, pub( crate ) swallow: bool,
pub( crate ) focus_on_press: bool,
pub( crate ) id: Option<WidgetId>, pub( crate ) id: Option<WidgetId>,
pub( crate ) cursor: Option<crate::types::CursorShape>, pub( crate ) cursor: Option<crate::types::CursorShape>,
} }
@@ -90,6 +91,7 @@ impl<Msg: Clone> Pressable<Msg>
on_drag_start: None, on_drag_start: None,
on_escape: None, on_escape: None,
swallow: false, swallow: false,
focus_on_press: true,
id: None, id: None,
cursor: None, cursor: None,
} }
@@ -142,6 +144,14 @@ impl<Msg: Clone> Pressable<Msg>
self self
} }
/// With `false` a press leaves the focus where it was, so Enter or Space
/// typed afterwards cannot fire `on_press`. Default `true`.
pub fn focus_on_press( mut self, yes: bool ) -> Self
{
self.focus_on_press = yes;
self
}
pub fn id( mut self, id: WidgetId ) -> Self pub fn id( mut self, id: WidgetId ) -> Self
{ {
self.id = Some( id ); self.id = Some( id );
@@ -179,6 +189,7 @@ impl<Msg: Clone> Pressable<Msg>
on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ),
on_escape: self.on_escape.map( |m| ( *f )( m ) ), on_escape: self.on_escape.map( |m| ( *f )( m ) ),
swallow: self.swallow, swallow: self.swallow,
focus_on_press: self.focus_on_press,
id: self.id, id: self.id,
cursor: self.cursor, cursor: self.cursor,
} }
+3 -3
View File
@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::layout::row::row; use crate::layout::row::row;
use crate::types::{ Corners, Length }; use crate::types::{ Corners, Length, WidgetId };
use crate::widget::flex::flex; use crate::widget::flex::flex;
use crate::widget::pressable::pressable; use crate::widget::pressable::pressable;
use crate::widget::scroll::scroll; use crate::widget::scroll::scroll;
@@ -10,7 +10,7 @@ use crate::widget::viewport::viewport;
use crate::widget::{ container, text, Element }; use crate::widget::{ container, text, Element };
use super::row::{ TabCells, TabRow }; use super::row::{ TabCells, TabRow };
use super::theme::{ EDGE_WIDTH, GAP, ICON_SIZE, LABEL_SIZE, TAB_CLOSE_ICON, TAB_RADIUS }; use super::theme::{ EDGE_WIDTH, GAP, ICON_SIZE, LABEL_SIZE, TAB_CLOSE_ICON, TAB_ID, TAB_RADIUS };
use super::{ parts, IndexMsg, TabItem, TabStrip, TabStripColors }; use super::{ parts, IndexMsg, TabItem, TabStrip, TabStripColors };
/// `[leading] [tabs…][after tabs] clear [trailing]`, the tabs scrolling sideways when they do not fit. /// `[leading] [tabs…][after tabs] clear [trailing]`, the tabs scrolling sideways when they do not fit.
@@ -71,7 +71,7 @@ fn handle<Msg: Clone + 'static>(
.padding_left( Length::widget( 10.0 ) ) .padding_left( Length::widget( 10.0 ) )
.padding_right( Length::widget( 4.0 ) ) .padding_right( Length::widget( 4.0 ) )
.padding_v( Length::widget( 3.0 ) ); .padding_v( Length::widget( 3.0 ) );
let mut press = pressable( face ); let mut press = pressable( face ).id( WidgetId::keyed( TAB_ID, tab.key ) );
if let Some( select ) = on_select if let Some( select ) = on_select
{ {
press = press.on_press( select( idx ) ); press = press.on_press( select( idx ) );
+4 -4
View File
@@ -9,13 +9,13 @@ use crate::layout::column::column;
use crate::layout::row::row; use crate::layout::row::row;
use crate::layout::spacer::spacer; use crate::layout::spacer::spacer;
use crate::layout::stack::{ stack, HAlign, VAlign }; use crate::layout::stack::{ stack, HAlign, VAlign };
use crate::types::Length; use crate::types::{ Length, WidgetId };
use crate::widget::pressable::pressable; use crate::widget::pressable::pressable;
use crate::widget::scroll::scroll; use crate::widget::scroll::scroll;
use crate::widget::separator::separator; use crate::widget::separator::separator;
use crate::widget::{ container, text, Element }; use crate::widget::{ container, text, Element };
use super::theme::{ BACK_ICON, BODY_WIDTH, BUTTON_ICON, BUTTON_SIZE, CARD_RADIUS, CLOSE_ICON_SIZE, COLUMNS, EDGE_WIDTH }; use super::theme::{ BACK_ICON, BODY_WIDTH, BUTTON_ICON, BUTTON_SIZE, CARD_ID, CARD_RADIUS, CLOSE_ICON_SIZE, COLUMNS, EDGE_WIDTH };
use super::cards::TabCards; use super::cards::TabCards;
use super::{ parts, IndexMsg, TabItem, TabMotion, TabStripColors }; use super::{ parts, IndexMsg, TabItem, TabMotion, TabStripColors };
@@ -150,7 +150,7 @@ impl<Msg: Clone + 'static> TabOverview<Msg>
let mut top = row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) ); let mut top = row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) );
if let Some( msg ) = self.back.take() if let Some( msg ) = self.back.take()
{ {
top = top.push( parts::symbol_button( BACK_ICON, "<", msg, ink, BUTTON_SIZE, BUTTON_ICON ) ); top = top.push( parts::symbol_button( BACK_ICON, "<", msg, ink, BUTTON_SIZE, BUTTON_ICON, true ) );
} }
top = top.push( text( std::mem::take( &mut self.title ) ).size( 20.0 ).weight( 600 ).color( ink ) ).push( spacer() ); top = top.push( text( std::mem::take( &mut self.title ) ).size( 20.0 ).weight( 600 ).color( ink ) ).push( spacer() );
for button in std::mem::take( &mut self.actions ) for button in std::mem::take( &mut self.actions )
@@ -198,7 +198,7 @@ fn card<Msg: Clone + 'static>(
.push( text( tab.label.clone() ).size( 13.0 ).color( ink ).align_center() ) .push( text( tab.label.clone() ).size( 13.0 ).color( ink ).align_center() )
.push( spacer().height( card_margin() ) ); .push( spacer().height( card_margin() ) );
let face = container( content ).background( fill ).border( edge, EDGE_WIDTH ).radius( CARD_RADIUS ); let face = container( content ).background( fill ).border( edge, EDGE_WIDTH ).radius( CARD_RADIUS );
let mut press = pressable( face ); let mut press = pressable( face ).id( WidgetId::keyed( CARD_ID, tab.key ) );
if let Some( select ) = on_select if let Some( select ) = on_select
{ {
press = press.on_press( select( idx ) ); press = press.on_press( select( idx ) );
+3 -2
View File
@@ -22,11 +22,11 @@ pub( super ) fn icon<Msg: Clone>( tab: &TabItem, size: Length, ink: Color ) -> O
pub( super ) fn close<Msg: Clone>( msg: Msg, ink: Color, icon_size: f32 ) -> Element<Msg> pub( super ) fn close<Msg: Clone>( msg: Msg, ink: Color, icon_size: f32 ) -> Element<Msg>
{ {
symbol_button( CLOSE_ICON, "x", msg, ink, CLOSE_SIZE, icon_size ) symbol_button( CLOSE_ICON, "x", msg, ink, CLOSE_SIZE, icon_size, false )
} }
/// A borderless button with a symbolic icon in `ink`, or `fallback` as text when the theme lacks the icon. /// A borderless button with a symbolic icon in `ink`, or `fallback` as text when the theme lacks the icon.
pub( super ) fn symbol_button<Msg: Clone>( name: &str, fallback: &str, msg: Msg, ink: Color, size: f32, icon_size: f32 ) -> Element<Msg> pub( super ) fn symbol_button<Msg: Clone>( name: &str, fallback: &str, msg: Msg, ink: Color, size: f32, icon_size: f32, focusable: bool ) -> Element<Msg>
{ {
let base = match icons::tinted( name, ink ) let base = match icons::tinted( name, ink )
{ {
@@ -34,6 +34,7 @@ pub( super ) fn symbol_button<Msg: Clone>( name: &str, fallback: &str, msg: Msg,
None => button( fallback ), None => button( fallback ),
}; };
base.variant( ButtonVariant::Tertiary ) base.variant( ButtonVariant::Tertiary )
.focusable( focusable )
.width( Length::widget( size ) ) .width( Length::widget( size ) )
.height( Length::widget( size ) ) .height( Length::widget( size ) )
.on_press( msg ) .on_press( msg )
+3
View File
@@ -33,5 +33,8 @@ pub( crate ) const COLUMNS: usize = 2;
pub( crate ) const CARD_RADIUS: f32 = 16.0; pub( crate ) const CARD_RADIUS: f32 = 16.0;
pub( crate ) const BODY_WIDTH: f32 = 760.0; pub( crate ) const BODY_WIDTH: f32 = 760.0;
pub( crate ) const TAB_ID: &str = "ltk-tab";
pub( crate ) const CARD_ID: &str = "ltk-tab-card";
pub( crate ) const CLOSE_ICON: &str = "navigation/close-small"; pub( crate ) const CLOSE_ICON: &str = "navigation/close-small";
pub( crate ) const BACK_ICON: &str = "general/left"; pub( crate ) const BACK_ICON: &str = "general/left";
+1 -1
View File
@@ -197,7 +197,7 @@ impl<Msg: Clone + 'static> Toast<Msg>
use crate::layout::stack::stack; use crate::layout::stack::stack;
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
self.id.0.hash( &mut hasher ); self.id.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 ); let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg ); let bg = self.bg.unwrap_or_else( theme::bg );
+1 -1
View File
@@ -121,7 +121,7 @@ impl<Msg: Clone + 'static> Tooltip<Msg>
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
"tooltip".hash( &mut hasher ); "tooltip".hash( &mut hasher );
self.anchor_id.0.hash( &mut hasher ); self.anchor_id.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 ); let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg ); let bg = self.bg.unwrap_or_else( theme::bg );
+3 -3
View File
@@ -8,7 +8,7 @@ fn defaults()
{ {
let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) ); let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) );
assert_eq!( t.message, "hi" ); assert_eq!( t.message, "hi" );
assert_eq!( t.anchor_id.0, "x" ); assert_eq!( t.anchor_id.name(), "x" );
assert_eq!( t.max_width, theme::MAX_W ); assert_eq!( t.max_width, theme::MAX_W );
assert!( t.on_dismiss.is_none() ); assert!( t.on_dismiss.is_none() );
} }
@@ -32,12 +32,12 @@ fn overlay_id_includes_tooltip_namespace()
let tooltip_overlay_id = { let tooltip_overlay_id = {
let mut h = DefaultHasher::new(); let mut h = DefaultHasher::new();
"tooltip".hash( &mut h ); "tooltip".hash( &mut h );
id.0.hash( &mut h ); id.hash( &mut h );
OverlayId( h.finish() as u32 ) OverlayId( h.finish() as u32 )
}; };
let combo_style_id = { let combo_style_id = {
let mut h = DefaultHasher::new(); let mut h = DefaultHasher::new();
id.0.hash( &mut h ); id.hash( &mut h );
OverlayId( h.finish() as u32 ) OverlayId( h.finish() as u32 )
}; };
assert_ne!( tooltip_overlay_id, combo_style_id ); assert_ne!( tooltip_overlay_id, combo_style_id );
+3
View File
@@ -21,6 +21,7 @@ pub fn lw_none( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
paint_rect: rect, paint_rect: rect,
handlers: WidgetHandlers::None, handlers: WidgetHandlers::None,
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: CursorShape::Default, cursor: CursorShape::Default,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
@@ -39,6 +40,7 @@ pub fn lw_button( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
paint_rect: rect, paint_rect: rect,
handlers: WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false }, handlers: WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false },
keyboard_focusable: true, keyboard_focusable: true,
focus_on_press: true,
cursor: CursorShape::Pointer, cursor: CursorShape::Pointer,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,
@@ -59,6 +61,7 @@ pub fn lw_chrome( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
paint_rect: rect, paint_rect: rect,
handlers: WidgetHandlers::WindowButton { on_press: Some( () ) }, handlers: WidgetHandlers::WindowButton { on_press: Some( () ) },
keyboard_focusable: false, keyboard_focusable: false,
focus_on_press: false,
cursor: CursorShape::Pointer, cursor: CursorShape::Pointer,
tooltip: None, tooltip: None,
accessible_label: None, accessible_label: None,