Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect `glScissor` clip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break its `fill` / `clear_rects_transparent` scissor semantics. Adds software-backend pixel tests covering the snapping. Font resolution unification. The system-font candidate chain, `find_font_opt` and `load_default_font_bytes` lived in two copies (`render/helpers` and `gles_render/helpers`) that had already diverged — one resolved through `find_font_opt`, the other inlined the candidate loop — and now live once in `system_fonts`. The two per-backend `OnceLock` default-font caches and `primary_handle` collapse into a single `system_fonts::default_handle`. Module docs and the `font_registry` caller are updated accordingly. Shared image validation and rect inflation. `draw_image_data`'s dimension check and its one-line warning were byte-duplicated across both backends and are now `render::helpers::validate_rgba_dims`. The six manual symmetric `Rect`-inflate literals in the GLES primitives reuse the existing `Rect::expand`. FrameState and DrawCtx de-duplication. The eleven `SurfaceState` fields the draw pass owns and threads through `DrawCtx` — `widget_rects`, the cursor / selection maps, the scroll state, `accessible_extras`, `prev_focused` / `prev_hovered` / `prev_pressed` — move into a `FrameState` sub-struct. The per-frame `build_draw_ctx` / `commit_draw_ctx` helpers can then borrow `&mut ss.frame`, disjoint from `ss.canvas` and `ss.pool`, so the four frame paths (software / GLES × full / partial) replace their duplicated `DrawCtx` construction and write-back with a single helper call each. A whole-`SurfaceState` borrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct. `content_dirty` stays on `SurfaceState` — it is an invalidation flag, not frame state — and is reset at the call site. rich_text tests. Adds the previously-missing `tests.rs` for the `RichText` widget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, and `map_msg` range preservation — all headless against a software `Canvas`, with line counts forced by `\n` so they do not depend on any system font's measured width. Documentation. Fills the rustdoc gaps on the embedder-facing surface: `core::UiSurface` accessors, the `egl_context` public API, the `GlesCanvas` methods, `theme::typography` and `theme::error`, and the `RichText` / `Text` builders; adds a crate-level "Rendering backends" overview. `CHANGELOG.md` is added (0.2.0 / 0.1.0), and `docs/widgets.md` / `docs/cookbook.md` gain `rich_text`, `external`, and the CPU-draw / path-clip / externally-laid-out-tree recipes. `debian/changelog` gets the 0.2.0-1 entry. Private intra-doc links to `system_fonts` are demoted to code spans so `cargo doc` is warning-free. Packaging. The `libltk-dev` registry crate shipped a `Cargo.toml` declaring the `lookup` bench while the `Makefile` install copied only `src/`, so Cargo refused to parse the manifest over a missing `benches/lookup.rs`; the install now ships `benches/` as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library). `Cargo.toml` is bumped to 0.2.0 to match the package version and the `ltk-0.2.0` registry directory.
258 lines
8.0 KiB
Rust
258 lines
8.0 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.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 );
|
|
}
|
|
}
|