Two gaps found while using trust's address box on a phone, both about what happens to a text field once it holds the focus. Dropping the focus. An app could ask for the focus of a widget, through `App::take_focus_request`, but had no way to give it up. A browser needs to: once an address is submitted the field has done its job, yet it kept the focus, so the on-screen keyboard stayed over the page that had just been asked for, and came back whenever the window was raised, since ltk enables the input method again on the text-input `enter` that follows while a text field holds the focus. `App::take_blur_request` is the counterpart: return `true` once and the run loop calls `set_focus( keyboard_focus, None )`, the same call the back request already uses to blur a text field, so the text-input disable and `on_text_input_focused( false )` come with it. It is handled just before the focus request, so an app that asks for both in one cycle ends up focused, which is what "open a new tab and type" needs; a pending `focus_retry` from an earlier cycle is cleared, or it would hand the focus straight back to the widget it was taken from. Positioning the cursor in a `select_on_focus` field. `handle_text_pointer_down` and `handle_text_pointer_drag` returned at once for a field built with `.select_on_focus( true )`, so that the press that focuses it keeps the whole-value selection `set_focus` has just installed. The guard did not distinguish that press from a later one: in a field that already had the focus, no press ever placed the cursor and no drag ever selected a range, with a finger or with a mouse. That suits a numeric picker poorly and an address box not at all, where correcting one character of a long URL meant retyping it. `AppData` gains `press_on_focused`, computed in both press paths, touch and pointer, right before their `set_focus`: whether the press landed on the widget that already held the focus. The two guards now apply only when it did not. The first press on such a field selects everything, as before; a press on it while focused collapses the selection to the byte offset under it, and a drag from there extends the selection. Not changed. `handle_text_select_word` keeps its `select_on_focus` guard, so a double press on such a field still leaves the whole value selected rather than a word. The right-button path, which opens the context menu without touching the selection, never reached these functions and is unaffected. `set_focus` still moves the cursor to the end and reselects when it is called for the widget that already has the focus; a programmatic focus request on a focused `select_on_focus` field therefore selects everything again, which is what an address-box shortcut wants.
259 lines
8.1 KiB
Rust
259 lines
8.1 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind };
|
|
use smithay_client_toolkit::reexports::client::
|
|
{
|
|
protocol::wl_pointer::WlPointer,
|
|
Connection, QueueHandle,
|
|
};
|
|
|
|
use crate::app::App;
|
|
use crate::event_loop::{ AppData, SurfaceFocus };
|
|
use crate::tree::{ find_handlers, find_widget_at };
|
|
use crate::widget::WidgetHandlers;
|
|
|
|
impl<A: App> AppData<A>
|
|
{
|
|
pub( super ) fn on_pointer_right_click(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
qh: &QueueHandle<Self>,
|
|
_pointer: &WlPointer,
|
|
event: &PointerEvent,
|
|
)
|
|
{
|
|
let focus = self.focus_for_surface( &event.surface )
|
|
.unwrap_or( SurfaceFocus::Main );
|
|
// Right-click: the desktop equivalent of a touch
|
|
// long-press. Three cases on the press target:
|
|
//
|
|
// * Widget with `on_long_press` (Button or
|
|
// Pressable that opted in) — fire the message.
|
|
// The drag-arm slot (`on_drag_start`) is NOT
|
|
// consumed: right-click never enters drag mode,
|
|
// so an icon's context menu opens but no drag
|
|
// is armed.
|
|
// * TextEdit with no user `on_long_press` — open
|
|
// the built-in Copy / Cut / Paste menu near the
|
|
// click. Selection is preserved (we deliberately
|
|
// skip `handle_text_pointer_down`) so the common
|
|
// "select text → right-click → Copy" flow works.
|
|
// * Anywhere else — dismiss any already-open menu.
|
|
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
|
self.pointer_pos = pos;
|
|
self.app.on_pointer_move( pos.x, pos.y );
|
|
let hit_idx = find_widget_at( &self.surface( focus ).frame.widget_rects, pos );
|
|
let lp_msg = hit_idx.and_then( |idx|
|
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
|
.and_then( |h| h.long_press_msg() ) );
|
|
if let Some( msg ) = lp_msg
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
self.surface_mut( focus ).request_redraw();
|
|
} else {
|
|
let is_text = hit_idx.and_then( |idx|
|
|
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
|
.map( |h| h.is_text_input() ) ).unwrap_or( false );
|
|
if is_text
|
|
{
|
|
let idx = hit_idx.unwrap();
|
|
if self.surface( focus ).focused_idx != Some( idx )
|
|
{
|
|
self.set_focus( focus, hit_idx, qh );
|
|
}
|
|
self.show_context_menu( focus, idx, pos );
|
|
} else {
|
|
self.hide_context_menu( focus );
|
|
}
|
|
}
|
|
}
|
|
|
|
pub( super ) fn on_pointer_left_press(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
qh: &QueueHandle<Self>,
|
|
_pointer: &WlPointer,
|
|
event: &PointerEvent,
|
|
)
|
|
{
|
|
let focus = self.focus_for_surface( &event.surface )
|
|
.unwrap_or( SurfaceFocus::Main );
|
|
let serial = if let PointerEventKind::Press { serial, .. } = event.kind
|
|
{
|
|
serial
|
|
} else {
|
|
return;
|
|
};
|
|
self.last_pointer_serial = serial;
|
|
self.last_input_serial = serial;
|
|
self.cancel_tooltip();
|
|
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
|
self.pointer_pos = pos;
|
|
self.app.on_pointer_move( pos.x, pos.y );
|
|
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
|
|
{
|
|
self.dismiss_main_outside_popups( pos );
|
|
}
|
|
|
|
// Built-in context menu intercepts the press
|
|
// before the regular gesture machine. Either an
|
|
// item activates (Copy / Cut / Paste) or the
|
|
// click is outside the menu and dismisses it —
|
|
// in both cases we consume the event.
|
|
if self.surface( focus ).context_menu.is_some()
|
|
{
|
|
if self.handle_context_menu_press( focus, pos )
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Client-side title bar interaction — pointer-only
|
|
// (touch never hits a titlebar; layer-shell surfaces
|
|
// have titlebar_height == 0).
|
|
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
|
|
let tb_h = self.surface( focus ).titlebar_height * sf;
|
|
if tb_h > 0.0 && pos.y < tb_h
|
|
{
|
|
let close_rect = self.surface( focus ).titlebar_close_rect;
|
|
if close_rect.contains( pos )
|
|
{
|
|
if self.app.on_close_requested()
|
|
{
|
|
self.exit_requested = true;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Resize-edge interception. Wins over the titlebar
|
|
// drag-move (top-left / top-right corners overlap
|
|
// the titlebar) and over the gesture machine.
|
|
if let Some( edge ) = self.resize_edge_under_pointer( focus )
|
|
{
|
|
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
|
|
{
|
|
let seats: Vec<_> = self.seat_state.seats().collect();
|
|
if let Some( seat ) = seats.into_iter().next()
|
|
{
|
|
window.resize( &seat, serial, edge );
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if tb_h > 0.0 && pos.y < tb_h
|
|
{
|
|
// Drag to move the window.
|
|
if matches!( focus, SurfaceFocus::Main )
|
|
{
|
|
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
|
|
{
|
|
let seats: Vec<_> = self.seat_state.seats().collect();
|
|
if let Some( seat ) = seats.into_iter().next()
|
|
{
|
|
window.move_( &seat, serial );
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Past every chrome interception — surface the
|
|
// press to the app so embeddings (e.g. an
|
|
// embedded WPE WebView) see real button-down
|
|
// events that were not consumed by the window
|
|
// frame.
|
|
self.app.on_pointer_button( pos.x, pos.y, true );
|
|
|
|
let outcome =
|
|
{
|
|
let ss = self.surface_mut( focus );
|
|
let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects );
|
|
// Mark this gesture as mouse-driven so the
|
|
// gesture machine's 6 px stray-cancel skips
|
|
// the drag-start / long-press slots — mouse
|
|
// motion is intentional and should never
|
|
// drop the candidate before the pointer-side
|
|
// promotion at 24 px gets to fire.
|
|
ss.gesture.mouse_press = true;
|
|
ss.needs_redraw = true;
|
|
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, qh );
|
|
if let Some( msg ) = outcome.initial_slider_msg
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
}
|
|
// Press-and-hold repeat: when the hit is a button
|
|
// that opted into `.repeating( true )`, fire the
|
|
// `on_press` message immediately and arm the
|
|
// repeat timer. The release handler in
|
|
// `gesture.rs` knows to suppress the regular tap-
|
|
// on-release fire for repeating buttons so a
|
|
// quick click still counts as exactly one press.
|
|
// The timer re-reads `on_press` from the live
|
|
// widget tree on every tick — see
|
|
// `start_button_repeat` for why.
|
|
if let Some( idx ) = outcome.hit_idx
|
|
{
|
|
let immediate = {
|
|
let handlers = find_handlers( &self.surface( focus ).frame.widget_rects, idx );
|
|
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
|
|
{
|
|
handlers.and_then( |h| h.press_msg() )
|
|
} else { None }
|
|
};
|
|
if let Some( msg ) = immediate
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
self.start_button_repeat( focus, idx );
|
|
}
|
|
}
|
|
// Click-to-position the text cursor when the press
|
|
// landed on a TextEdit. `set_focus` above moves the
|
|
// cursor to the end of the value; this overrides
|
|
// it with the byte offset under the pointer and
|
|
// collapses the selection there so a subsequent
|
|
// drag widens the selection from the click point.
|
|
//
|
|
// Double-click on a TextEdit selects the word
|
|
// under the cursor instead of just positioning.
|
|
if let Some( idx ) = outcome.hit_idx
|
|
{
|
|
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
|
|
.map( |h| h.is_text_input() ).unwrap_or( false );
|
|
if is_text
|
|
{
|
|
// Eye icon hit on a password field
|
|
// short-circuits the text-edit
|
|
// dispatch — fire the toggle msg and
|
|
// skip cursor placement.
|
|
if self.handle_password_toggle_press( focus, idx, pos )
|
|
{
|
|
let _ = self.note_press_for_double_click( pos );
|
|
}
|
|
else
|
|
{
|
|
let is_double = self.note_press_for_double_click( pos );
|
|
if is_double
|
|
{
|
|
self.handle_text_select_word( focus, idx, pos );
|
|
} else {
|
|
self.handle_text_pointer_down( focus, idx, pos );
|
|
}
|
|
}
|
|
} else {
|
|
let _ = self.note_press_for_double_click( pos );
|
|
}
|
|
} else {
|
|
let _ = self.note_press_for_double_click( pos );
|
|
}
|
|
// Slider press → drag may have started; refresh
|
|
// the cursor shape so it switches to `Grabbing`.
|
|
self.dispatch_cursor_shape( focus );
|
|
}
|
|
}
|