From e3b76423fb4acf133da7d0be089fcd6786ee8b58 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Tue, 13 Oct 2026 20:36:29 +0200 Subject: [PATCH] A text field can take its horizontal padding from its host, the primary finger reaches the pointer hooks, and the screen probe left out of the previous commit is added The previous commit described the size hint fitted to the screen before the first commit and the pointer hooks being called for the primary finger, but left out the files that do it, so `run.rs` called an `output_probe` module that did not exist. They are here, together with a text field option that a host laying out its own padding needs. Text field padding. A single-line `TextEdit` insets its text by `PAD_H` scaled with the field height, so a tall and narrow field has almost no room left for its text: rustdroid's Android fields, which already subtract the padding the Android view asked for, showed a verification code digit in a 24 px wide gap minus ltk's own inset, scrolled it as if it overflowed and clipped half of it. `TextEdit::padding_h( px )` sets that inset in physical pixels; `0` lets the text use the whole rect. Without it nothing changes. `single_line_pad_h` takes the override and every single-line consumer passes it: the draw path, `single_line_scroll_x`, `single_line_align_offset` and `byte_offset_at`, so a click still lands on the glyph that is drawn. The runtime sees it through a new `pad_h` field in `WidgetHandlers::TextEdit`, read by `text_input_pad` where a click is turned into a text position (pointer down, word selection, drag selection and the context menu's paste offset). The password toggle zone keeps the theme inset. The screen probe. `output_probe::output_logical_size` reads the logical size of the first output on an event queue of its own, before any surface exists: `xdg_output`'s logical size when the compositor offers it, else the current mode over the scale; the objects it binds are destroyed or released before it returns. `window_fit::output_share` gives the part of an output a window may take, the same 90 % the fit after the first configure uses, which now goes through it too. When the size hint is unpinned on the first configure the minimum size is released along with the maximum, as the documentation of `window_resizable` says. Touch as pointer. The primary finger's down, motion and up are also reported through `App::on_pointer_move` and `App::on_pointer_button`, as the left mouse button is, so an application that handles the pointer gets the touch without a separate path. The module documentation says so. --- src/event_loop/context_menu.rs | 2 +- src/event_loop/handlers.rs | 1 + src/event_loop/mod.rs | 1 + src/event_loop/output_probe.rs | 118 +++++++++++++++++++++++ src/event_loop/text_editing/ime.rs | 10 ++ src/event_loop/text_editing/selection.rs | 6 +- src/event_loop/window_fit.rs | 9 +- src/input/touch/mod.rs | 8 +- src/widget/element.rs | 1 + src/widget/handlers.rs | 5 +- src/widget/text_edit/draw.rs | 10 +- src/widget/text_edit/hit_test.rs | 13 ++- src/widget/text_edit/metrics.rs | 4 +- src/widget/text_edit/mod.rs | 15 ++- 14 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 src/event_loop/output_probe.rs diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs index 77015d9..068eb2d 100644 --- a/src/event_loop/context_menu.rs +++ b/src/event_loop/context_menu.rs @@ -92,7 +92,7 @@ impl AppData let scroll = self.text_scroll( focus, widget_idx ); let canvas = self.surface( focus ).canvas.as_ref()?; Some( crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, widget_idx ), ) ) } ); diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 823ad62..ce2b902 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -295,6 +295,7 @@ impl WindowHandler for AppData } if self.pending_size_hint_unpin { + window.set_min_size( None ); window.set_max_size( None ); self.pending_size_hint_unpin = false; } diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 0cd8936..da0ed5d 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -10,6 +10,7 @@ pub( crate ) mod drag; pub( crate ) mod focus; mod handlers; pub( crate ) mod osk; +pub( crate ) mod output_probe; pub( crate ) mod perf; pub( crate ) mod repeat; pub( crate ) mod session; diff --git a/src/event_loop/output_probe.rs b/src/event_loop/output_probe.rs new file mode 100644 index 0000000..ae39605 --- /dev/null +++ b/src/event_loop/output_probe.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The screen's logical size before any surface exists, read on an event queue of its own. + +use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, Proxy, QueueHandle, WEnum }; +use smithay_client_toolkit::reexports::client::globals::GlobalList; +use smithay_client_toolkit::reexports::client::protocol::wl_output::{ self, WlOutput }; +use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1; +use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1::{ self, ZxdgOutputV1 }; + +struct Output +{ + mode: Option<( i32, i32 )>, + scale: i32, + logical: Option<( i32, i32 )>, +} + +struct Probe +{ + outputs: Vec, +} + +/// Logical size of the first output: `xdg_output`'s when the compositor offers it, else the current mode over the scale. +pub( crate ) fn output_logical_size( conn: &Connection, globals: &GlobalList ) -> Option<( u32, u32 )> +{ + let mut queue = conn.new_event_queue::(); + let qh = queue.handle(); + let registry = globals.registry(); + let ( outputs, manager ) = globals.contents().with_list( |list| + { + let outputs: Vec = list.iter() + .filter( |g| g.interface == WlOutput::interface().name ) + .enumerate() + .map( |( i, g )| registry.bind::( g.name, g.version.min( 4 ), &qh, i ) ) + .collect(); + let manager = list.iter() + .find( |g| g.interface == ZxdgOutputManagerV1::interface().name ) + .map( |g| registry.bind::( g.name, g.version.min( 3 ), &qh, () ) ); + ( outputs, manager ) + } ); + if outputs.is_empty() + { + return None; + } + + let mut xdg_outputs = Vec::new(); + if let Some( manager ) = &manager + { + for ( i, output ) in outputs.iter().enumerate() + { + xdg_outputs.push( manager.get_xdg_output( output, &qh, i ) ); + } + } + + let mut probe = Probe { outputs: outputs.iter().map( |_| Output { mode: None, scale: 1, logical: None } ).collect() }; + let answered = queue.roundtrip( &mut probe ).is_ok(); + + for xdg_output in xdg_outputs + { + xdg_output.destroy(); + } + if let Some( manager ) = manager + { + manager.destroy(); + } + for output in outputs + { + if output.version() >= 3 + { + output.release(); + } + } + if !answered + { + return None; + } + + probe.outputs.iter().find_map( |o| + { + let scale = o.scale.max( 1 ); + let ( w, h ) = o.logical.or_else( || o.mode.map( |( w, h )| ( w / scale, h / scale ) ) )?; + ( w > 0 && h > 0 ).then_some( ( w as u32, h as u32 ) ) + } ) +} + +impl Dispatch for Probe +{ + fn event( state: &mut Self, _: &WlOutput, event: wl_output::Event, index: &usize, _: &Connection, _: &QueueHandle ) + { + let Some( output ) = state.outputs.get_mut( *index ) else { return }; + match event + { + wl_output::Event::Mode { flags: WEnum::Value( flags ), width, height, .. } if flags.contains( wl_output::Mode::Current ) => + { + output.mode = Some( ( width, height ) ); + } + wl_output::Event::Scale { factor } => output.scale = factor, + _ => {} + } + } +} + +impl Dispatch for Probe +{ + fn event( _: &mut Self, _: &ZxdgOutputManagerV1, _: ::Event, _: &(), _: &Connection, _: &QueueHandle ) {} +} + +impl Dispatch for Probe +{ + fn event( state: &mut Self, _: &ZxdgOutputV1, event: zxdg_output_v1::Event, index: &usize, _: &Connection, _: &QueueHandle ) + { + if let ( Some( output ), zxdg_output_v1::Event::LogicalSize { width, height } ) = ( state.outputs.get_mut( *index ), event ) + { + output.logical = Some( ( width, height ) ); + } + } +} diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index 0da2dd6..e74d9c8 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -122,6 +122,16 @@ impl AppData .unwrap_or( value_handler ); Some( ( widget.rect, value, multiline, secure, align, font_size ) ) } + + /// The horizontal inner padding override of the text field at `idx`, as its hit-testing needs. + pub( crate ) fn text_input_pad( &self, focus: SurfaceFocus, idx: usize ) -> Option + { + match &find_widget( &self.surface( focus ).frame.widget_rects, idx )?.handlers + { + WidgetHandlers::TextEdit { pad_h, .. } => *pad_h, + _ => None, + } + } } /// Map a field's `secure` flag to the text-input-v3 content type. Secure diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs index b19b3ed..11edeac 100644 --- a/src/event_loop/text_editing/selection.rs +++ b/src/event_loop/text_editing/selection.rs @@ -103,7 +103,7 @@ impl AppData let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, idx ), ) }; let ( start, end ) = word_bounds_at( &value, click_byte ); @@ -141,7 +141,7 @@ impl AppData let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, idx ), ) }; let ss = self.surface_mut( focus ); @@ -176,7 +176,7 @@ impl AppData let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, idx ), ) }; let ss = self.surface_mut( focus ); diff --git a/src/event_loop/window_fit.rs b/src/event_loop/window_fit.rs index 61e0b2b..7751912 100644 --- a/src/event_loop/window_fit.rs +++ b/src/event_loop/window_fit.rs @@ -13,6 +13,12 @@ use super::AppData; /// Share of an output a window may take when the compositor sent no `configure_bounds`. pub( crate ) const OUTPUT_SHARE: f32 = 0.9; +/// The part of an output of logical size `output` a window may take. +pub( crate ) fn output_share( output: ( u32, u32 ) ) -> ( u32, u32 ) +{ + ( ( output.0 as f32 * OUTPUT_SHARE ) as u32, ( output.1 as f32 * OUTPUT_SHARE ) as u32 ) +} + /// `size` scaled down, keeping its aspect ratio, until it fits in `bounds`. pub( crate ) fn fit_within( size: ( u32, u32 ), bounds: ( u32, u32 ) ) -> ( u32, u32 ) { @@ -43,8 +49,7 @@ impl AppData fn output_bounds( &self ) -> Option<( u32, u32 )> { - let ( w, h ) = self.output_logical_size( None )?; - Some( ( ( w as f32 * OUTPUT_SHARE ) as u32, ( h as f32 * OUTPUT_SHARE ) as u32 ) ) + self.output_logical_size( None ).map( output_share ) } fn output_logical_size( &self, preferred: Option<&WlOutput> ) -> Option<( u32, u32 )> diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 2fbfc7a..7dcb26b 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -12,7 +12,8 @@ //! //! The first finger to land on a surface becomes its **primary slot** //! and drives the single-slot gesture machine (swipe, scroll, -//! long-press, drag). Any additional finger arriving while the +//! long-press, drag); like the left mouse button, it is also reported +//! through [`App::on_pointer_button`] / [`App::on_pointer_move`]. Any additional finger arriving while the //! primary is held bypasses the gesture machine and surfaces directly //! through [`App::on_touch_down`] / [`App::on_touch_move`] / //! [`App::on_touch_up`], so apps can implement pinch-zoom or @@ -153,6 +154,9 @@ impl TouchHandler for AppData } if self.osk_press( focus, pos ) { return; } + self.app.on_pointer_move( pos.x, pos.y ); + self.app.on_pointer_button( pos.x, pos.y, true ); + let outcome = { let ss = self.surface_mut( focus ); @@ -305,6 +309,7 @@ impl TouchHandler for AppData } self.pointer_pos = pp; + self.app.on_pointer_move( pp.x, pp.y ); if self.osk_move( focus, pp ) { return; } let global_drag = self.has_active_long_press_drag(); let swipe = self.swipe_config( focus ); @@ -367,6 +372,7 @@ impl AppData self.surface_mut( focus ).primary_touch_id = None; return; } + self.app.on_pointer_button( pos.x, pos.y, false ); let global_drag = self.has_active_long_press_drag(); let swipe = self.swipe_config( focus ); let events_out = diff --git a/src/widget/element.rs b/src/widget/element.rs index d6b961f..d5f7f32 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -325,6 +325,7 @@ impl Element multiline: t.is_multiline(), align: t.align, font_size: t.font_size, + pad_h: t.pad_h, select_on_focus: t.select_on_focus, 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 aba6e19..9fd6259 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -61,6 +61,8 @@ pub enum WidgetHandlers /// drew them. `None` for fields that do not call /// `.font_size( … )` (they follow the widget-scaling mode). font_size: Option, + /// Horizontal inner padding override snapshot, for the same hit-testing. + pad_h: Option, /// `true` when the source field opted into select-all-on- /// focus. The runtime reads this in `set_focus` to decide /// whether the new selection should anchor at `0` (replace @@ -142,7 +144,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, 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, password_toggle_msg } => { WidgetHandlers::TextEdit { @@ -155,6 +157,7 @@ impl Clone for WidgetHandlers multiline: *multiline, align: *align, font_size: *font_size, + pad_h: *pad_h, select_on_focus: *select_on_focus, password_toggle_msg: password_toggle_msg.clone(), } diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index b81d6c4..014abd2 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -61,7 +61,7 @@ fn password_toggle_reserve( rect: Rect ) -> f32 pub fn password_toggle_hit_zone( rect: Rect ) -> Rect { let zone_w = ( password_toggle_reserve( rect ) - + single_line_pad_h( rect ) * 0.5 ).min( rect.width ); + + single_line_pad_h( rect, None ) * 0.5 ).min( rect.width ); Rect { x: rect.x + rect.width - zone_w, @@ -123,14 +123,14 @@ impl TextEdit { 0.0 }; - let pad_h = single_line_pad_h( rect ); + let pad_h = single_line_pad_h( rect, self.pad_h ); let text_rect = Rect { width: ( rect.width - toggle_reserve ).max( pad_h * 2.0 ), ..rect }; - let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size ); - let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size ); + let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size, self.pad_h ); + let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size, self.pad_h ); // Clip text / cursor / selection to the inner band so the // scrolled-off portion does not bleed past the pill stroke. @@ -184,7 +184,7 @@ impl TextEdit // would, so an empty centred / right-aligned field still // reads with the placeholder where the digits will land. let placeholder_align_x = single_line_align_offset( - canvas, rect, &self.placeholder, false, self.align, font_size, + canvas, rect, &self.placeholder, false, self.align, font_size, self.pad_h, ); canvas.draw_text( &self.placeholder, diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs index c07879f..08eeb65 100644 --- a/src/widget/text_edit/hit_test.rs +++ b/src/widget/text_edit/hit_test.rs @@ -28,6 +28,7 @@ pub( crate ) fn byte_offset_at( scroll: TextScroll, align: crate::widget::text::TextAlign, font_size: Option, + pad_h: Option, ) -> usize { if value.is_empty() { return 0; } @@ -51,11 +52,11 @@ pub( crate ) fn byte_offset_at( // applies so a click lands on the glyph the user actually // sees, not on the byte offset that would correspond to an // unscrolled, left-aligned layout. - let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size ); - let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size ); + let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size, pad_h ); + let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size, pad_h ); byte_offset_in_line( canvas, value, 0, value.len(), - pos.x - rect.x - single_line_pad_h( rect ) - align_x + scroll_x, + pos.x - rect.x - single_line_pad_h( rect, pad_h ) - align_x + scroll_x, secure, font_size, ) @@ -86,9 +87,10 @@ pub( crate ) fn single_line_scroll_x( cursor: usize, secure: bool, font_size: f32, + pad_h: Option, ) -> f32 { - let inner_width = ( rect.width - single_line_pad_h( rect ) * 2.0 ).max( font_size ); + let inner_width = ( rect.width - single_line_pad_h( rect, pad_h ) * 2.0 ).max( font_size ); let safe_cursor = cursor.min( value.len() ); let display_value: String = if secure @@ -133,11 +135,12 @@ pub( crate ) fn single_line_align_offset( secure: bool, align: crate::widget::text::TextAlign, font_size: f32, + pad_h: Option, ) -> f32 { use crate::widget::text::TextAlign; if matches!( align, TextAlign::Left ) { return 0.0; } - let inner_width = ( rect.width - single_line_pad_h( rect ) * 2.0 ).max( font_size ); + let inner_width = ( rect.width - single_line_pad_h( rect, pad_h ) * 2.0 ).max( font_size ); let display_value: String = if secure { "\u{2022}".repeat( value.chars().count() ) diff --git a/src/widget/text_edit/metrics.rs b/src/widget/text_edit/metrics.rs index aba3c76..34c0482 100644 --- a/src/widget/text_edit/metrics.rs +++ b/src/widget/text_edit/metrics.rs @@ -12,9 +12,9 @@ pub( crate ) fn single_line_scale( rect: Rect ) -> f32 rect.height / theme::HEIGHT } -pub( crate ) fn single_line_pad_h( rect: Rect ) -> f32 +pub( crate ) fn single_line_pad_h( rect: Rect, pad_h: Option ) -> f32 { - theme::PAD_H * single_line_scale( rect ) + pad_h.unwrap_or_else( || theme::PAD_H * single_line_scale( rect ) ) } /// Resolved layout of a multiline field, shared by sizing, drawing, diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 9fe3c33..fef3fb9 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -153,6 +153,8 @@ pub struct TextEdit /// to fit a fixed number of glyphs (date / time pickers, inline /// numeric inputs). pub( crate ) fixed_width: Option, + /// Horizontal inner padding of the single-line field in physical pixels; `None` scales the theme padding with the field height. + pub( crate ) pad_h: Option, /// Font size in pixels for the single-line draw path. `0.0` (the /// Label font size. `None` follows the process [`crate::WidgetScaling`] /// mode at the theme default (via [`crate::Canvas::font_px`]); a @@ -214,6 +216,7 @@ impl TextEdit align: super::text::TextAlign::Left, borderless: false, fixed_width: None, + pad_h: None, font_size: None, height: None, select_on_focus: false, @@ -310,6 +313,16 @@ impl TextEdit self } + /// Set the horizontal inner padding of a single-line field, in + /// physical pixels, for a host that lays out its own (`0` lets the + /// text use the whole rect). Without it the padding follows the + /// field height. + pub fn padding_h( mut self, px: f32 ) -> Self + { + self.pad_h = Some( px.max( 0.0 ) ); + self + } + /// Override the preferred width reported to the parent layout. /// Accepts logical `f32` pixels or any [`Length`] — pair a /// [`Length::fluid`] width with a fluid font size so box and glyphs @@ -508,7 +521,7 @@ impl TextEdit ) -> usize { byte_offset_at( - canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, self.font_size, + canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, self.font_size, self.pad_h, ) }