diff --git a/docs/architecture.md b/docs/architecture.md index e4a596c..f4a2598 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,6 +99,9 @@ In practice, that model is easiest to adopt in three steps: - `on_tap` — taps that miss every widget. - `on_key` / `on_key_with_modifiers` — global hotkeys. Characters, Backspace and Delete stay in a focused text input and reach the app otherwise, a focused button or pressable included; Return and Space go to the focused widget's submit or press message when it has one; arrows are offered to the app first and fall back to the text cursor or list navigation. - `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity. +- `on_mouse_button( &MouseButtonEvent )` — every press and release of the left, middle and right buttons (`MouseButton`), with the position in physical pixels and Ctrl, Shift and Alt, before ltk handles it: for an app that hands the mouse on, as a terminal does to a program that asks for it. +- `on_modifiers( ctrl, shift, alt )` — the modifiers held changed, for an app that needs them with the wheel or a pointer motion, which do not carry them. +- `on_keyboard_leave()` — the window lost the keyboard, so no release will come for the keys still held: an app that repeats keys on its own timer stops there, and forgets the modifiers it kept. **Implement when the app has more than one place to be** diff --git a/docs/cookbook.md b/docs/cookbook.md index af7ea75..989b9a4 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -726,8 +726,10 @@ be added to the view by the same `update` that requests it, and may sit inside a `scroll` below the fold: the runtime waits a few frames for it and moves the viewport so the widget is visible when it takes focus. +Requesting focus for the text field that already has it puts its cursor at the end of the value, without firing its `on_focus` again. That is how an application moves the cursor after replacing the value itself, for instance after completing a path on Tab: set the new value in `update` and return the field's id from `take_focus_request`. A field built with `select_on_focus( true )` selects its whole value instead. + Tab and Shift+Tab traverse focusable widgets in declaration order — no -opt-in needed beyond the widget being interactive. +opt-in needed beyond the widget being interactive. While a text field has the focus, Tab and the vertical arrows still reach `App::on_key_with_modifiers` first, so an application can claim them, for completion say: return a message for the key and ltk neither moves the focus nor the cursor. Escape reaches it too, after ltk has closed any transient UI and taken the focus off the field. **See also**: [`WidgetId`](../src/types.rs), [`tests/tab_navigation.rs`](../tests/tab_navigation.rs). diff --git a/docs/theming.md b/docs/theming.md index d6ad923..79341cc 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -518,7 +518,7 @@ fn view( &self ) -> Element | `theme_icon_path( "category/name" )` | `Option` | Catalogue icon path (filled-then-line lookup). | | `theme_icon_rgba( "category/name", size )` | `Option<( Arc>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. | | `theme_icon_tinted( name, size, tint )` | `Option` | One-call rasterise + tint (`theme_icon_rgba` + `tint_symbolic`). | -| `decode_svg_bytes( bytes, size )` | `Option<( Arc>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. | +| `decode_svg_bytes( bytes, size )` | `Option<( Arc>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. Rewrites the CSS-only `plus-lighter` blend to `screen` first (see [Application icons](#application-icons)); the pixels come back premultiplied. | | `active_document()` | `Arc` | The whole loaded document, for slot-typed lookups the per-slot helpers do not cover (e.g. iterating slots). | | `active_theme_id()` | `String` | Id of the active theme. | | `is_fallback_active()` | `bool` | `true` when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user. | @@ -728,6 +728,17 @@ Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`, `features`, `feedback`, `general`, `hardware`, `keyboard`, `multimedia`, `navigation`, `safety`, `session`, `system`, `window`. +### Application icons + +An application's desktop entry can point at a theme icon by its installed path, `Icon=/usr/share/ltk/themes/default/icons/apps/files.svg`, which `ltk-theme-default` provides, instead of shipping an icon of its own. + +The shipped app icons are exported from Figma with glass effects, and resvg, which rasterises every SVG in ltk, reproduces only part of them: + +- A `` carrying `backdrop-filter: blur(…)` is HTML, not SVG, and is ignored: the frosted panel comes out as a flat translucent one. +- `feBlend mode="plus-lighter"` is a CSS blend mode resvg does not know, and it would fall back to `normal`, painting the highlights as grey edges that cover whole pixels at dock size. `decode_svg_bytes` rewrites it to `screen`, the closest mode resvg supports, before parsing, so every path that rasterises an SVG through ltk gets it. + +An icon meant to look the same everywhere is best drawn without these effects, or with them flattened into its geometry. + ### Adding an icon 1. Drop a monochrome SVG into diff --git a/docs/widgets.md b/docs/widgets.md index e62e76f..e7e46e1 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -82,6 +82,7 @@ icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch ) count; pass `Length::widget( n )` instead to follow the widget-scaling mode, which is what stock icons (the [`list_item`](#list_item) disclosure arrow) already do — use it when the two sit side by side and should match. +The bitmap keeps its proportions: one that is not square is scaled to fit that size and centred in it, never stretched. Give it a `width( … )` or `height( … )` and it becomes a framed key: it claims that size, paints the background and border of its `variant` like @@ -132,6 +133,8 @@ pressable( # } ``` +A tap and a long press go to the deepest interactive widget under them, and a widget is interactive even without a handler: a `list_item` with no `on_press` inside a `pressable` takes the tap and the right click and does nothing with them, so the pressable never fires. Build the content from pieces that take no input (`container`, `row`, `column`, `text`, `img_widget`); keep an inner `button` only when it should win over the pressable, as a row's own trailing action does. + **See also**: [`button`](#button) when a plain text button is enough, [`list_item`](#list_item) for the standard "label + subtitle + trailing" pattern. @@ -198,6 +201,7 @@ When both are set the text sits to the left of the icon. `pad_h( px )` overrides the horizontal inset between the row edge and its content (theme default 16 px) — lower it when the enclosing view's own padding already provides the margin. +`ink( label, secondary )` draws the label in `label`, the subtitle and trailing text in `secondary`, and tints hover and press with `label`, instead of the theme's colours, for a row laid on a surface the theme does not know about, such as a menu drawn in an app's own colours. `height( len )` overrides the theme row height (56 design px, 68 with a subtitle) and `font_size( len )` the primary-label size (16 design px; subtitle and trailing keep their theme sizes), both `impl Into`. Use them together where a dense list — a context menu, a compact picker — should trade the touch-target generosity of a settings list for row density; the resolved height is floored at the label's rendered height so the text never clips however aggressive the override. @@ -797,6 +801,8 @@ The overlay anchors below the widget built with the matching `.id( WidgetId )`. See [`docs/cookbook.md`](./cookbook.md) for the full overlay wiring. +The hint `Button::tooltip` shows by itself is placed by the runtime. When the main surface is an xdg-shell window it is an xdg-popup child of the window, anchored to the button's rect: the compositor puts it above the button, 6 logical pixels away, flips it below or slides it sideways when it does not fit, and it takes no grab and no input, so the pointer stays with the button. A window does not know where it sits on screen, so this is the only way the hint lands beside the button. On a layer-shell surface (a shell, a panel) the hint is an overlay spanning the output, placed above the button or below it near the top edge. + **See also**: [`toast`](#toast) for transient self-dismissing notifications. diff --git a/src/app.rs b/src/app.rs index 9b9a99b..01f382d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1048,6 +1048,9 @@ pub trait App: 'static /// The modifiers held changed, for an app that needs them with the wheel or a motion, which do not carry them. Default: no-op. fn on_modifiers( &mut self, _ctrl: bool, _shift: bool, _alt: bool ) {} + /// The window lost the keyboard, so no release will come for the keys held: for an app that repeats keys itself. Default: no-op. + fn on_keyboard_leave( &mut self ) {} + /// Called on every keyboard event (press *and* release) before the /// regular focus-aware dispatch ([`Self::on_key`] / /// [`Self::on_key_with_modifiers`]) runs. Default: no-op. diff --git a/src/event_loop/text_editing/insert_delete.rs b/src/event_loop/text_editing/insert_delete.rs index 2cda99d..9a47321 100644 --- a/src/event_loop/text_editing/insert_delete.rs +++ b/src/event_loop/text_editing/insert_delete.rs @@ -28,7 +28,7 @@ impl AppData .unwrap_or_default() }; - // `select_on_focus` fields keep the cursor pinned to the end + // `pin_cursor_to_end` fields keep the cursor pinned to the end // of the *displayed* value. The pending value the user just // typed (e.g. "2") may render shorter than the post-update // display value (e.g. "02" after a `format!("{:02}")` @@ -40,9 +40,9 @@ impl AppData // whatever value we eventually render". Without this, typing // a second digit would insert into the middle of the // normalised string ("0|2" + "3" → "032" → 32 instead of 23). - let select_on_focus = matches!( + let pin_to_end = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), - Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + Some( WidgetHandlers::TextEdit { pin_cursor_to_end: true, .. } ), ); let new_value; @@ -52,7 +52,7 @@ impl AppData let safe_cursor = (*cursor).min( current_value.len() ); let mut v = current_value.clone(); v.insert_str( safe_cursor, text ); - let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor + text.len() }; + let next_cursor = if pin_to_end { usize::MAX } else { safe_cursor + text.len() }; *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; *ss.frame.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; ss.pending_text_values.insert( idx, v.clone() ); @@ -104,14 +104,14 @@ impl AppData let step = match next_char { Some( c ) => c.len_utf8(), None => return }; let mut new_value = current_value.clone(); new_value.replace_range( safe_cursor_pre..safe_cursor_pre + step, "" ); - // `select_on_focus` fields keep the cursor at the end of the + // `pin_cursor_to_end` fields keep the cursor at the end of the // post-update displayed value via the `usize::MAX` sentinel — // see `handle_text_insert` for the reasoning. - let select_on_focus = matches!( + let pin_to_end = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), - Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + Some( WidgetHandlers::TextEdit { pin_cursor_to_end: true, .. } ), ); - let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre }; + let next_cursor = if pin_to_end { usize::MAX } else { safe_cursor_pre }; { let ss = self.surface_mut( focus ); // Cursor stays put — the char to its right is gone, so the @@ -189,11 +189,11 @@ impl AppData let mut new_value = current_value.clone(); new_value.replace_range( start_byte..end_byte, "" ); - let select_on_focus = matches!( + let pin_to_end = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), - Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + Some( WidgetHandlers::TextEdit { pin_cursor_to_end: true, .. } ), ); - let next_cursor = if select_on_focus { usize::MAX } else { start_byte }; + let next_cursor = if pin_to_end { usize::MAX } else { start_byte }; { let ss = self.surface_mut( focus ); @@ -249,14 +249,14 @@ impl AppData let new_cursor = safe_cursor - removed_char.len_utf8(); let mut new_value = current_value.clone(); new_value.remove( new_cursor ); - // `select_on_focus` fields keep the cursor at the end of the + // `pin_cursor_to_end` fields keep the cursor at the end of the // post-update displayed value via the `usize::MAX` sentinel — // see `handle_text_insert` for the reasoning. - let select_on_focus = matches!( + let pin_to_end = matches!( find_handlers( &self.surface( focus ).frame.widget_rects, idx ), - Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + Some( WidgetHandlers::TextEdit { pin_cursor_to_end: true, .. } ), ); - let next_cursor = if select_on_focus { usize::MAX } else { new_cursor }; + let next_cursor = if pin_to_end { usize::MAX } else { new_cursor }; { let ss = self.surface_mut( focus ); *ss.frame.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs index 47a020a..5c507d3 100644 --- a/src/input/keyboard/mod.rs +++ b/src/input/keyboard/mod.rs @@ -80,6 +80,7 @@ impl KeyboardHandler for AppData { self.stop_key_repeat(); self.keyboard_focus = SurfaceFocus::Main; + self.app.on_keyboard_leave(); if let Some( ref mut a ) = self.a11y { a.set_window_focus( false ); } } diff --git a/src/widget/element.rs b/src/widget/element.rs index f5dc2e0..5d59d11 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -336,6 +336,7 @@ impl Element font_size: t.font_size, pad_h: t.pad_h, select_on_focus: t.select_on_focus, + pin_cursor_to_end: t.pin_cursor_to_end, password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ), } } diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 9fd6259..79a87d6 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -68,6 +68,8 @@ pub enum WidgetHandlers /// whether the new selection should anchor at `0` (replace /// on next keystroke) or at the cursor (insert). select_on_focus: bool, + /// `true` when edits keep the cursor at the end of the displayed value. + pin_cursor_to_end: bool, /// Snapshot of the /// [`crate::widget::text_edit::TextEdit::password_toggle`] /// callback. When `Some`, pointer / touch dispatch checks @@ -144,7 +146,7 @@ impl Clone for WidgetHandlers WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected }, WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() }, WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() }, - WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, purpose, multiline, align, font_size, pad_h, select_on_focus, password_toggle_msg } => + WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, purpose, multiline, align, font_size, pad_h, select_on_focus, pin_cursor_to_end, password_toggle_msg } => { WidgetHandlers::TextEdit { @@ -159,6 +161,7 @@ impl Clone for WidgetHandlers font_size: *font_size, pad_h: *pad_h, select_on_focus: *select_on_focus, + pin_cursor_to_end: *pin_cursor_to_end, password_toggle_msg: password_toggle_msg.clone(), } } diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 83da2bf..6b2cdd4 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -176,6 +176,10 @@ pub struct TextEdit /// retype rather than edit. Default `false` — long-form fields /// keep the cursor at the end on focus. pub( crate ) select_on_focus: bool, + /// When `true`, editing keeps the cursor at the end of the displayed + /// value, whatever the app rewrites it to in `on_change`. Default + /// `false` — the cursor stays where the edit left it. + pub( crate ) pin_cursor_to_end: bool, /// Self-managed "show / hide password" eye affordance — when /// `Some( ( visible, on_toggle ) )` the field renders an /// `actions/visible` ↔ `actions/invisible` icon at its right @@ -220,6 +224,7 @@ impl TextEdit font_size: None, height: None, select_on_focus: false, + pin_cursor_to_end: false, password_toggle: None, read_only: false, } @@ -296,6 +301,15 @@ impl TextEdit self } + /// Keep the cursor at the end of the displayed value while editing, + /// for fields whose `on_change` normalises what was typed ("2" → "02"). + /// Default `false`. + pub fn pin_cursor_to_end( mut self, on: bool ) -> Self + { + self.pin_cursor_to_end = on; + self + } + /// Set the horizontal alignment of the displayed text. Default /// [`TextAlign::Left`](super::text::TextAlign::Left). pub fn align( mut self, a: super::text::TextAlign ) -> Self @@ -608,6 +622,7 @@ impl TextEdit font_size: self.font_size, height: self.height, select_on_focus: self.select_on_focus, + pin_cursor_to_end: self.pin_cursor_to_end, password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), read_only: self.read_only, } diff --git a/src/widget/time_picker/mod.rs b/src/widget/time_picker/mod.rs index c1b9689..3adaca7 100644 --- a/src/widget/time_picker/mod.rs +++ b/src/widget/time_picker/mod.rs @@ -321,6 +321,7 @@ impl TimePicker .font_size_fluid( theme::VAL_FS ) .align( TextAlign::Center ) .select_on_focus( true ) + .pin_cursor_to_end( true ) .on_change( move |s: String| { let new = parser( &s ).unwrap_or( snapshot );