diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ff5d4..28497e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). - **`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). +- **`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 +- **`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 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. @@ -102,6 +110,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### 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 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. diff --git a/Cargo.toml b/Cargo.toml index 6bbcb01..5b44039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,8 +119,6 @@ web-sys = { version = "0.3", optional = true, features = [ "EventTarget", "HtmlCanvasElement", "HtmlElement", - "ImageBitmap", - "ImageBitmapRenderingContext", "ImageData", "KeyboardEvent", "MediaQueryList", diff --git a/benches/lookup.rs b/benches/lookup.rs index eef1d19..c46b5c4 100644 --- a/benches/lookup.rs +++ b/benches/lookup.rs @@ -41,6 +41,7 @@ fn build_widgets( n: usize ) -> Vec> paint_rect: rect, handlers, keyboard_focusable: true, + focus_on_press: true, cursor: ltk::CursorShape::Default, tooltip: None, accessible_label: None, diff --git a/docs/cookbook.md b/docs/cookbook.md index 80707df..9c10764 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -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 `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 defaults". No serde needed for small state. diff --git a/docs/onboarding.md b/docs/onboarding.md index f113253..3182f9e 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -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 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 -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`. Shell components (panels, lock screens) return `None` and no-op. See the cookbook recipe *Surviving relaunch: session state* for a worked example. diff --git a/src/app.rs b/src/app.rs index 55649d2..c4b28f9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -568,8 +568,9 @@ pub trait App: 'static /// [`run`](crate::run) / [`try_run`](crate::try_run), before the /// window is created and before the first [`view`](Self::view) — the /// first frame already shows the restored state. It is **not** - /// called on an ordinary launch. The runtime restores app state only - /// when + /// called on an ordinary launch unless + /// [`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 /// shell sets `LTK_SESSION_RESTORE=1` in the environment (the @@ -596,6 +597,66 @@ pub trait App: 'static /// worked example. fn restore_state( &mut self, state: Vec ); + /// 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, keep_tabs: bool, opened: Option } + /// + /// impl App for Browser + /// { + /// # type Message = Msg; + /// # fn app_id( &self ) -> &str { "net.example.Browser" } + /// # fn view( &self ) -> Element { text( "" ).into() } + /// # fn update( &mut self, _: Msg ) {} + /// fn save_state( &self ) -> Option> + /// { + /// Some( self.tabs.join( "\n" ).into_bytes() ) + /// } + /// + /// fn restore_state( &mut self, state: Vec ) + /// { + /// 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 /// result of [`update`](Self::update) being called with this message. /// diff --git a/src/draw/damage.rs b/src/draw/damage.rs index 0ff48f1..a0a1e80 100644 --- a/src/draw/damage.rs +++ b/src/draw/damage.rs @@ -232,6 +232,7 @@ mod tests paint_rect: paint, handlers: WidgetHandlers::None, keyboard_focusable: true, + focus_on_press: true, cursor: crate::types::CursorShape::Default, tooltip: None, accessible_label: None, diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 8f2235d..ac21777 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -188,6 +188,7 @@ pub( crate ) fn layout_and_draw( repeating: false, }, keyboard_focusable: false, + focus_on_press: p.focus_on_press, cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ), tooltip: None, accessible_label: None, @@ -541,6 +542,7 @@ pub( crate ) fn layout_and_draw( repeating: false, }, keyboard_focusable: false, + focus_on_press: true, cursor: crate::types::CursorShape::Pointer, tooltip: None, accessible_label: None, @@ -598,6 +600,7 @@ pub( crate ) fn layout_and_draw( paint_rect: other.paint_bounds( rect ), handlers, keyboard_focusable: other.is_focusable(), + focus_on_press: other.focus_on_press(), cursor: other.cursor_shape(), tooltip: other.tooltip().map( str::to_string ), accessible_label: other.accessible_label(), diff --git a/src/draw/paint.rs b/src/draw/paint.rs index b3f80b5..abe8def 100644 --- a/src/draw/paint.rs +++ b/src/draw/paint.rs @@ -160,7 +160,9 @@ fn paint_view( 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::( view, canvas, screen_rect, &mut ctx, 0 ); if ctx.debug_layout diff --git a/src/event_loop/focus.rs b/src/event_loop/focus.rs index 67bed31..3a6f76b 100644 --- a/src/event_loop/focus.rs +++ b/src/event_loop/focus.rs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +use std::collections::HashMap; + use super::app_data::AppData; use super::surface::SurfaceFocus; use crate::app::{ App, OverlayId }; -use crate::tree::find_handlers; +use crate::tree::{ find_handlers, find_widget }; use crate::types::{ Point, WidgetId }; use crate::widget::WidgetHandlers; use crate::widget::scroll::offset_into_view; @@ -97,6 +99,25 @@ impl AppData 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 ) + { + 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 ) { let was_text_input; @@ -229,6 +250,48 @@ impl AppData } } +/// 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( ss: &mut super::surface::SurfaceState ) -> 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( map: &mut HashMap, 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( ss: &mut super::surface::SurfaceState ) +{ + 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. pub( crate ) fn surface_focused_text_input( ss: &super::surface::SurfaceState ) -> bool { diff --git a/src/event_loop/frame.rs b/src/event_loop/frame.rs index c810e84..e639ed8 100644 --- a/src/event_loop/frame.rs +++ b/src/event_loop/frame.rs @@ -7,7 +7,7 @@ use crate::types::{ Color, Rect }; use crate::widget::Element; 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 crate::draw::{ paint_full, paint_partial }; use crate::draw::damage::compute_interaction_dirty_rects; @@ -33,7 +33,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b { text_focus_rebuilt.push( SurfaceFocus::Main ); } - draw_surface( + data.main.needs_redraw = draw_surface( platform, &mut data.main, main_view, @@ -43,7 +43,6 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b debug_layout, Some( SurfaceFocus::Main ), ); - data.main.needs_redraw = false; data.main.last_draw = crate::time::Instant::now(); if !data.first_frame_committed { @@ -65,8 +64,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b // No frame callback: these surfaces are static between invalidations, // so there is no per-frame pacing to keep — and a callback that never // gets requested again would leave `frame_pending` stuck. - draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None ); - ss.needs_redraw = false; + ss.needs_redraw = draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None ); ss.frame_pending = false; ss.last_draw = crate::time::Instant::now(); } @@ -83,7 +81,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b } let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 ); let opaque = data.app.overlay_opaque_region( spec.id ); - draw_surface( + ss.needs_redraw = draw_surface( platform, ss, &spec.view, @@ -93,7 +91,6 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b debug_layout, Some( focus ), ); - ss.needs_redraw = false; ss.last_draw = crate::time::Instant::now(); data.app.on_overlay_frame_committed( spec.id ); if std::mem::take( &mut ss.frame.blur_changed ) @@ -111,7 +108,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> b 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( platform: &P, ss: &mut SurfaceState, @@ -121,12 +118,12 @@ fn draw_surface( opaque_region: Option<&[Rect]>, debug_layout: bool, frame: Option, -) +) -> bool { let scale = ss.scale_factor; let w = ss.width; 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 canvas_ready = ss.canvas.as_ref() @@ -145,12 +142,12 @@ fn draw_surface( ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, 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(); 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 { Some( dirty_rects ) => paint_partial( ss, view, bg, dirty_rects, pw, ph, scale ), @@ -160,9 +157,12 @@ fn draw_surface( 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 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 ) { ss.frame_pending = true; @@ -170,4 +170,5 @@ fn draw_surface( // What was painted never reached the screen, so the next frame cannot build on it. ss.content_dirty = true; } + focus_moved } diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index 8ebb69d..6ca8b81 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -137,6 +137,8 @@ pub( crate ) struct SurfaceState pub last_draw: crate::time::Instant, pub focused_idx: Option, pub focused_id: Option, + /// 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, pub pending_text_values: HashMap, /// Per-frame draw state: laid-out rects, carried scroll / text-edit / a11y @@ -207,6 +209,7 @@ impl SurfaceState 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_id: None, + focus_visible: false, hovered_idx: None, pending_text_values: HashMap::new(), frame: FrameState::new(), diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index 7d55c4c..cc35454 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -42,6 +42,7 @@ fn button_full( paint_rect: r, handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false }, keyboard_focusable: true, + focus_on_press: true, cursor: crate::types::CursorShape::Default, tooltip: None, accessible_label: None, @@ -69,6 +70,7 @@ fn slider_widget( idx: usize, r: Rect, on_release: Option ) -> LaidOutWidge thumb_px: 0.0, }, keyboard_focusable: true, + focus_on_press: true, cursor: crate::types::CursorShape::Default, tooltip: None, accessible_label: None, diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs index 877107a..047ae3f 100644 --- a/src/input/keyboard/mod.rs +++ b/src/input/keyboard/mod.rs @@ -80,6 +80,7 @@ impl AppData pub( crate ) fn key_repeated( &mut self, key: KeyInput ) { self.stop_key_repeat(); + if key.keysym != Keysym::XF86_Back && self.app.claims_keyboard() { return; } let focus = self.keyboard_focus; self.dispatch_key( focus, key ); } diff --git a/src/input/keyboard/shortcuts.rs b/src/input/keyboard/shortcuts.rs index 4719586..5b7785d 100644 --- a/src/input/keyboard/shortcuts.rs +++ b/src/input/keyboard/shortcuts.rs @@ -72,6 +72,7 @@ impl AppData let next_idx = next_focusable_index( &ss.frame.widget_rects, ss.focused_idx, reverse ); if let Some( next_idx ) = next_idx { + self.show_focus_ring( focus, true ); self.set_focus( focus, Some( next_idx ) ); } } diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs index 40bd12d..b97a42c 100644 --- a/src/input/keyboard/text_keys.rs +++ b/src/input/keyboard/text_keys.rs @@ -3,7 +3,7 @@ use crate::app::App; use crate::event_loop::{ AppData, SurfaceFocus }; -use crate::tree::find_handlers; +use crate::tree::{ find_handlers, find_widget }; use crate::Keysym; use crate::platform::Platform; @@ -62,7 +62,9 @@ impl AppData { self.handle_text_insert( focus, "\n" ); } 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; if let Some( idx ) = target { diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index 3eaff67..e13fb5b 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -146,7 +146,7 @@ impl AppData result }; 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 ); if let Some( msg ) = outcome.initial_slider_msg { diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 73e4e93..98da425 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -153,7 +153,7 @@ impl AppData result }; 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 ); if let Some( msg ) = outcome.initial_slider_msg { diff --git a/src/platform/wayland/session.rs b/src/platform/wayland/session.rs index 3b0d342..b3808a4 100644 --- a/src/platform/wayland/session.rs +++ b/src/platform/wayland/session.rs @@ -78,7 +78,7 @@ impl SessionRuntime } 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() { diff --git a/src/platform/web/mod.rs b/src/platform/web/mod.rs index 3374dd4..2c3acfd 100644 --- a/src/platform/web/mod.rs +++ b/src/platform/web/mod.rs @@ -14,7 +14,7 @@ //! | `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 | //! | `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 | //! | `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 | diff --git a/src/platform/web/overlays.rs b/src/platform/web/overlays.rs index bd6922d..38e15ea 100644 --- a/src/platform/web/overlays.rs +++ b/src/platform/web/overlays.rs @@ -54,7 +54,6 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let scale = data.main.scale_factor; 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 }; for ( spec, &size ) in specs.iter().zip( &sizes ) { @@ -87,7 +86,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) 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 ); 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. diff --git a/src/platform/web/present.rs b/src/platform/web/present.rs index d20dfd3..42b4674 100644 --- a/src/platform/web/present.rs +++ b/src/platform/web/present.rs @@ -1,13 +1,13 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! 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 serde_json::json; use wasm_bindgen::Clamped; -use web_sys::ImageData; +use web_sys::{ ImageData, OffscreenCanvas }; use crate::app::App; use crate::draw::fit_canvas; @@ -54,23 +54,14 @@ pub( super ) fn present frame: &Presentation<'_>, ) -> 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(); - match offscreen.transfer_to_image_bitmap() - { - Ok( image ) => - { - bitmap.transfer_from_image_bitmap( &image ); - true - } - Err( _ ) => false, - } + present_copy( surface, offscreen ) } - ( _, _, Some( _ ) ) => present_software( surface, canvas ), - _ => false, + None => present_software( surface, canvas ), }; 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 ); } +/// 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 { let Some( ctx ) = surface.ctx2d.as_ref() else { return false }; diff --git a/src/platform/web/run.rs b/src/platform/web/run.rs index 576e92f..84fc8d5 100644 --- a/src/platform/web/run.rs +++ b/src/platform/web/run.rs @@ -178,7 +178,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> if own { let _ = target.focus(); } 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 ); // 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 ); diff --git a/src/platform/web/subsurfaces.rs b/src/platform/web/subsurfaces.rs index de67c27..25b4e92 100644 --- a/src/platform/web/subsurfaces.rs +++ b/src/platform/web/subsurfaces.rs @@ -123,7 +123,7 @@ pub( super ) fn reconcile_subsurfaces( data: &mut AppData ) { let id = platform.next_surface.get(); 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 ); } let slot = platform.subsurfaces.get_mut( &spec.id ).unwrap(); @@ -149,14 +149,14 @@ pub( super ) fn reconcile_subsurfaces( data: &mut AppData ) for id in painted { data.app.on_subsurface_committed( id ); } } -fn create( desktop: &Connection, id: u32, spec: &SubsurfaceSpec, parent: &Parent, gpu: bool ) -> Option +fn create( desktop: &Connection, id: u32, spec: &SubsurfaceSpec, parent: &Parent ) -> Option { 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. if let Err( e ) = desktop.request( &json!( { "type": "set_input_region", "surface": id, "rects": [] } ) ) { warn( spec.id, &e ); } Some( WebSubsurface { - surface: WebSurfaceState::new( canvas, input, id, gpu, Vec::new() ), + surface: WebSurfaceState::new( canvas, input, id, Vec::new() ), canvas: None, parent: spec.parent, parent_id: parent.id, @@ -184,9 +184,7 @@ fn paint( slot: &mut WebSubsurface, view: &crate::widget::Element { diff --git a/src/platform/web/surface.rs b/src/platform/web/surface.rs index 485dce2..67801a1 100644 --- a/src/platform/web/surface.rs +++ b/src/platform/web/surface.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. 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::event_loop::AppData; @@ -16,9 +16,7 @@ pub( crate ) struct WebSurfaceState pub canvas: HtmlCanvasElement, /// Takes the surface's input: the element forge/web gives above the canvas, or the canvas itself without a desktop. pub input: HtmlElement, - /// Takes the frames painted on the shared WebGL context. - pub bitmap: Option, - /// Takes the software renderer's frames, without WebGL2. + /// Takes every frame: a copy of the shared WebGL canvas, or the software renderer's pixels without WebGL2. pub ctx2d: Option, /// The software renderer's frame, unpremultiplied for `putImageData`. pub pixels: Vec, @@ -37,12 +35,10 @@ pub( crate ) struct WebSurfaceState impl WebSurfaceState { /// 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 ) -> Self + pub( super ) fn new( canvas: HtmlCanvasElement, input: HtmlElement, id: u32, listeners: Vec ) -> Self { - let context = |kind: &str| canvas.get_context( kind ).ok().flatten(); - let bitmap = if gpu { context( "bitmaprenderer" ).and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ) } else { None }; - 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 } + let ctx2d = canvas.get_context( "2d" ).ok().flatten().and_then( |c| wasm_bindgen::JsCast::dyn_into( c ).ok() ); + Self { canvas, input, ctx2d, pixels: Vec::new(), id, input_region: None, role: None, listeners, presented: false } } } diff --git a/src/tree.rs b/src/tree.rs index 2d1f3d8..34a2df8 100755 --- a/src/tree.rs +++ b/src/tree.rs @@ -112,6 +112,7 @@ mod tests paint_rect: paint, handlers: WidgetHandlers::None, keyboard_focusable: true, + focus_on_press: true, cursor: crate::types::CursorShape::Default, tooltip: None, accessible_label: None, diff --git a/src/types.rs b/src/types.rs index 813e42f..084deea 100644 --- a/src/types.rs +++ b/src/types.rs @@ -16,8 +16,8 @@ //! - [`Corners`] — per-corner radius for the //! [`Container`](crate::container()) widget and any other rounded //! surface; coerces from `f32` for the uniform case. -//! - [`WidgetId`] — a stable `&'static str` identifier for focus -//! management, paired with [`crate::App::take_focus_request`]. +//! - [`WidgetId`] — a stable identifier, a `&'static str` name and a key, +//! for focus management, paired with [`crate::App::take_focus_request`]. //! //! Every type is `Copy` (or `Clone`) so passing them by value is the //! 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 /// focus via [`App::take_focus_request`](crate::app::App::take_focus_request). -#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] -pub struct WidgetId( pub &'static str ); +/// The focus follows the widget with the focused id when a rebuild moves it, +/// 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 /// `wp_cursor_shape_v1` when the pointer enters a widget that diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index e6342d4..3cdc319 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -223,7 +223,10 @@ impl Button } /// 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 { self.focusable = yes; diff --git a/src/widget/combo/mod.rs b/src/widget/combo/mod.rs index 859ae70..620d346 100644 --- a/src/widget/combo/mod.rs +++ b/src/widget/combo/mod.rs @@ -778,10 +778,10 @@ impl Combo .radius( 32.0 ) .into(); - // Derive the OverlayId from the anchor_id's static-str so apps - // don't have to manually pick non-colliding ids for each combo. + // Derive the OverlayId from the anchor_id so apps don't have to + // manually pick non-colliding ids for each combo. 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 ); Some( OverlaySpec diff --git a/src/widget/element.rs b/src/widget/element.rs index 0800204..0f0ab92 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -214,6 +214,20 @@ impl Element } } + /// 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 { matches!( self, Element::TextEdit( t ) if !t.read_only ) diff --git a/src/widget/laid_out.rs b/src/widget/laid_out.rs index e1ba02e..fc1a96d 100644 --- a/src/widget/laid_out.rs +++ b/src/widget/laid_out.rs @@ -28,6 +28,8 @@ pub struct LaidOutWidget pub paint_rect: Rect, pub handlers: WidgetHandlers, 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 /// from [`super::Element::cursor_shape`] at layout time so the input /// dispatch can pick the right shape without re-walking the @@ -52,6 +54,7 @@ impl Clone for LaidOutWidget paint_rect: self.paint_rect, handlers: self.handlers.clone(), keyboard_focusable: self.keyboard_focusable, + focus_on_press: self.focus_on_press, cursor: self.cursor, tooltip: self.tooltip.clone(), accessible_label: self.accessible_label.clone(), diff --git a/src/widget/pressable/mod.rs b/src/widget/pressable/mod.rs index a6833d9..39e1874 100644 --- a/src/widget/pressable/mod.rs +++ b/src/widget/pressable/mod.rs @@ -74,6 +74,7 @@ pub struct Pressable /// when any handler is also set; in that case the handler determines /// the message and `swallow` is implicit. pub( crate ) swallow: bool, + pub( crate ) focus_on_press: bool, pub( crate ) id: Option, pub( crate ) cursor: Option, } @@ -90,6 +91,7 @@ impl Pressable on_drag_start: None, on_escape: None, swallow: false, + focus_on_press: true, id: None, cursor: None, } @@ -142,6 +144,14 @@ impl Pressable 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 { self.id = Some( id ); @@ -179,6 +189,7 @@ impl Pressable on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), on_escape: self.on_escape.map( |m| ( *f )( m ) ), swallow: self.swallow, + focus_on_press: self.focus_on_press, id: self.id, cursor: self.cursor, } diff --git a/src/widget/tab_strip/desktop.rs b/src/widget/tab_strip/desktop.rs index 57e7b11..b7d2877 100644 --- a/src/widget/tab_strip/desktop.rs +++ b/src/widget/tab_strip/desktop.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use crate::layout::row::row; -use crate::types::{ Corners, Length }; +use crate::types::{ Corners, Length, WidgetId }; use crate::widget::flex::flex; use crate::widget::pressable::pressable; use crate::widget::scroll::scroll; @@ -10,7 +10,7 @@ use crate::widget::viewport::viewport; use crate::widget::{ container, text, Element }; 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 }; /// `[leading] [tabs…][after tabs] clear [trailing]`, the tabs scrolling sideways when they do not fit. @@ -71,7 +71,7 @@ fn handle( .padding_left( Length::widget( 10.0 ) ) .padding_right( Length::widget( 4.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 { press = press.on_press( select( idx ) ); diff --git a/src/widget/tab_strip/overview.rs b/src/widget/tab_strip/overview.rs index b1ade16..5731840 100644 --- a/src/widget/tab_strip/overview.rs +++ b/src/widget/tab_strip/overview.rs @@ -9,13 +9,13 @@ use crate::layout::column::column; use crate::layout::row::row; use crate::layout::spacer::spacer; use crate::layout::stack::{ stack, HAlign, VAlign }; -use crate::types::Length; +use crate::types::{ Length, WidgetId }; use crate::widget::pressable::pressable; use crate::widget::scroll::scroll; use crate::widget::separator::separator; 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::{ parts, IndexMsg, TabItem, TabMotion, TabStripColors }; @@ -150,7 +150,7 @@ impl TabOverview let mut top = row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) ); 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() ); for button in std::mem::take( &mut self.actions ) @@ -198,7 +198,7 @@ fn card( .push( text( tab.label.clone() ).size( 13.0 ).color( ink ).align_center() ) .push( spacer().height( card_margin() ) ); 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 { press = press.on_press( select( idx ) ); diff --git a/src/widget/tab_strip/parts.rs b/src/widget/tab_strip/parts.rs index f7a2485..04e39c9 100644 --- a/src/widget/tab_strip/parts.rs +++ b/src/widget/tab_strip/parts.rs @@ -22,11 +22,11 @@ pub( super ) fn icon( tab: &TabItem, size: Length, ink: Color ) -> O pub( super ) fn close( msg: Msg, ink: Color, icon_size: f32 ) -> Element { - 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. -pub( super ) fn symbol_button( name: &str, fallback: &str, msg: Msg, ink: Color, size: f32, icon_size: f32 ) -> Element +pub( super ) fn symbol_button( name: &str, fallback: &str, msg: Msg, ink: Color, size: f32, icon_size: f32, focusable: bool ) -> Element { let base = match icons::tinted( name, ink ) { @@ -34,6 +34,7 @@ pub( super ) fn symbol_button( name: &str, fallback: &str, msg: Msg, None => button( fallback ), }; base.variant( ButtonVariant::Tertiary ) + .focusable( focusable ) .width( Length::widget( size ) ) .height( Length::widget( size ) ) .on_press( msg ) diff --git a/src/widget/tab_strip/theme.rs b/src/widget/tab_strip/theme.rs index 22ada1a..522be12 100644 --- a/src/widget/tab_strip/theme.rs +++ b/src/widget/tab_strip/theme.rs @@ -33,5 +33,8 @@ pub( crate ) const COLUMNS: usize = 2; pub( crate ) const CARD_RADIUS: f32 = 16.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 BACK_ICON: &str = "general/left"; diff --git a/src/widget/toast/mod.rs b/src/widget/toast/mod.rs index e8d05fc..e2e81c6 100644 --- a/src/widget/toast/mod.rs +++ b/src/widget/toast/mod.rs @@ -197,7 +197,7 @@ impl Toast use crate::layout::stack::stack; 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 bg = self.bg.unwrap_or_else( theme::bg ); diff --git a/src/widget/tooltip/mod.rs b/src/widget/tooltip/mod.rs index c12ae10..3066b45 100644 --- a/src/widget/tooltip/mod.rs +++ b/src/widget/tooltip/mod.rs @@ -121,7 +121,7 @@ impl Tooltip let mut hasher = DefaultHasher::new(); "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 bg = self.bg.unwrap_or_else( theme::bg ); diff --git a/src/widget/tooltip/tests.rs b/src/widget/tooltip/tests.rs index 8eda855..b1c5d17 100644 --- a/src/widget/tooltip/tests.rs +++ b/src/widget/tooltip/tests.rs @@ -8,7 +8,7 @@ fn defaults() { let t: Tooltip<()> = tooltip( "hi", WidgetId( "x" ) ); 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!( t.on_dismiss.is_none() ); } @@ -32,12 +32,12 @@ fn overlay_id_includes_tooltip_namespace() let tooltip_overlay_id = { let mut h = DefaultHasher::new(); "tooltip".hash( &mut h ); - id.0.hash( &mut h ); + id.hash( &mut h ); OverlayId( h.finish() as u32 ) }; let combo_style_id = { let mut h = DefaultHasher::new(); - id.0.hash( &mut h ); + id.hash( &mut h ); OverlayId( h.finish() as u32 ) }; assert_ne!( tooltip_overlay_id, combo_style_id ); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 9d72401..5b3c739 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -21,6 +21,7 @@ pub fn lw_none( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()> paint_rect: rect, handlers: WidgetHandlers::None, keyboard_focusable: true, + focus_on_press: true, cursor: CursorShape::Default, tooltip: None, accessible_label: None, @@ -39,6 +40,7 @@ pub fn lw_button( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()> paint_rect: rect, handlers: WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false }, keyboard_focusable: true, + focus_on_press: true, cursor: CursorShape::Pointer, tooltip: None, accessible_label: None, @@ -59,6 +61,7 @@ pub fn lw_chrome( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()> paint_rect: rect, handlers: WidgetHandlers::WindowButton { on_press: Some( () ) }, keyboard_focusable: false, + focus_on_press: false, cursor: CursorShape::Pointer, tooltip: None, accessible_label: None,