A terminal drawn by the application itself needed four things ltk did not give it: the selection the middle button pastes, a way to read the clipboard, the text a key types once dead keys are applied, and a text cursor over its text area that does not hide the window's resize cursors. Text fields gain the primary selection along the way, and the symbol fallback and a missed field in the previous commit are fixed. The primary selection. `event_loop/primary_selection.rs` binds `zwp_primary_selection_device_manager_v1` when the compositor offers it, creates the device with the data device in `new_seat`, and holds the manager, the device, our source while we own the selection, its text and the inbox the reader threads post to. A selection offered by another client is read on a thread of its own as the clipboard is; a peer asking for ours is served from the cached text, and a cancelled source is dropped. `data_device.rs` gives both the pieces they share: `pick_text_mime`, `read_selection` and `write_selection`, so the clipboard behaves as before. Text fields. Once a press ends, `sync_text_field` publishes a new selection in the focused text field as the primary selection; password fields never publish theirs. It remembers what it last published rather than comparing with the current primary selection, so a selection made in another application is not taken back. A middle-button press on a text field focuses it, puts the cursor where it landed and inserts the primary selection there. The application's side. `App::take_primary_request` makes a text the primary selection, as `take_clipboard_request` does for the clipboard. `App::take_paste_request` names a `SelectionKind`, `Clipboard` or `Primary`, and the runtime answers with `App::on_paste` and that selection's text, never with an empty one. `App::on_pointer_middle` reports a middle-button press anywhere but on a text field. All default to doing nothing. Raw keys. `App::on_raw_key_with_alt` gives way to `App::on_raw_key_event`, which takes a `RawKey` with the keysym, the keycode, the press, Ctrl, Shift and Alt, and `text`: what sctk reports the key types once dead keys and compose sequences are applied, `None` on release, halfway through a sequence and for keys that type nothing. A terminal that took the character from the keysym typed `a` for an acute accent followed by `a`. The default still forwards to `on_raw_key`, and prompt was the only application overriding the old hook. Every key press now also records its serial in `last_input_serial`, so a selection set from a keyboard shortcut carries a serial a strict compositor accepts rather than the last click's. The cursor. `App::cursor_at( x, y )` is the cursor the application wants at a point of the main surface. It comes after the resize edges and `cursor_override`, and before the cursor of the widget under the pointer, so a terminal shows the text cursor over its cells and the resize cursors still show at the window's edges, which a `cursor_override` would hide. Fonts. Noto Sans Symbols 2 and Noto Sans Symbols follow DejaVu Sans in the fallback chain, for the media controls, arrows and shapes DejaVu lacks, which drew as the missing glyph. `TextEdit::map_msg` now carries `pad_h` over: the previous commit added the field but left it out there, and ltk did not build.
281 lines
9.3 KiB
Rust
281 lines
9.3 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 );
|
|
self.app.on_pointer_secondary( pos.x, pos.y );
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Paste the primary selection into the text field under the pointer, or tell the app.
|
|
pub( super ) fn on_pointer_middle_press( &mut self, qh: &QueueHandle<Self>, event: &PointerEvent )
|
|
{
|
|
let PointerEventKind::Press { serial, .. } = event.kind else { return };
|
|
self.last_pointer_serial = serial;
|
|
self.last_input_serial = serial;
|
|
let focus = self.focus_for_surface( &event.surface ).unwrap_or( SurfaceFocus::Main );
|
|
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
|
self.pointer_pos = pos;
|
|
let rects = &self.surface( focus ).frame.widget_rects;
|
|
let field = find_widget_at( rects, pos ).filter( |&idx| find_handlers( rects, idx ).is_some_and( |h| h.is_text_input() ) );
|
|
let Some( idx ) = field else { return self.app.on_pointer_middle( pos.x, pos.y ) };
|
|
self.press_on_focused = self.surface( focus ).focused_idx == Some( idx );
|
|
if !self.press_on_focused { self.set_focus( focus, Some( idx ), qh ); }
|
|
self.handle_text_pointer_down( focus, idx, pos );
|
|
let text = self.primary.text.clone();
|
|
if !text.is_empty() { self.handle_text_insert( focus, &text ); }
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
if self.osk_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 );
|
|
self.osk_after_press( focus, outcome.hit_idx );
|
|
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 );
|
|
}
|
|
}
|