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.
146 lines
5.1 KiB
Rust
146 lines
5.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;
|
|
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 super::helpers::hover_affects_paint;
|
|
|
|
impl<A: App> AppData<A>
|
|
{
|
|
pub( super ) fn on_pointer_motion(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
_pointer: &WlPointer,
|
|
event: &PointerEvent,
|
|
)
|
|
{
|
|
let focus = self.focus_for_surface( &event.surface )
|
|
.unwrap_or( SurfaceFocus::Main );
|
|
let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
|
self.pointer_pos = pp;
|
|
self.app.on_pointer_move( pp.x, pp.y );
|
|
|
|
// Hover tracking — pointer-only (touch has no hover).
|
|
// Runs before the gesture motion so the cache-dirty
|
|
// below picks up any hover-dependent redraw request.
|
|
let new_hover = find_widget_at( &self.surface( focus ).frame.widget_rects, pp );
|
|
let old_hover = self.surface( focus ).hovered_idx;
|
|
if new_hover != old_hover
|
|
{
|
|
let redraw = hover_affects_paint( &self.surface( focus ).frame.widget_rects, old_hover )
|
|
|| hover_affects_paint( &self.surface( focus ).frame.widget_rects, new_hover );
|
|
{
|
|
let ss = self.surface_mut( focus );
|
|
ss.hovered_idx = new_hover;
|
|
if redraw { ss.needs_redraw = true; }
|
|
}
|
|
match new_hover
|
|
{
|
|
Some( idx ) => self.arm_tooltip( focus, idx ),
|
|
None => self.cancel_tooltip(),
|
|
}
|
|
}
|
|
|
|
// Mouse drag-promotion: a left-button press whose hit
|
|
// widget carries `on_drag_start` should arm a drag as
|
|
// soon as the cursor moves past the threshold, without
|
|
// waiting for the touch hold timer. Touch keeps its
|
|
// hold-then-drag path because scroll / swipe gestures
|
|
// need the in-between motion budget; mouse has a
|
|
// dedicated right-click for the menu so left-button
|
|
// can be drag-only.
|
|
//
|
|
// Threshold of 24 px (logical) sits comfortably above
|
|
// the gesture machine's 6 px long-press cancel
|
|
// tolerance and above crustace's 16 px drag-commit
|
|
// threshold, so by the time we promote the next
|
|
// motion sample is already past the app's commit
|
|
// distance and drag mode latches without flashing
|
|
// any half-state.
|
|
//
|
|
// Promotion is synchronous (`self.app.update(...)`
|
|
// directly) so the app's drag state is armed BEFORE
|
|
// the `on_drag_move` call below runs — otherwise the
|
|
// seed coords land on a `dragging_item = None`
|
|
// shell and get lost. We pay the cost of bypassing
|
|
// `invalidate_after` for this one msg, but the next
|
|
// frame will repaint everything anyway because the
|
|
// drag is in flight.
|
|
let promote = {
|
|
let ss = self.surface( focus );
|
|
match ( ss.gesture.long_press_origin, ss.gesture.drag_start_msg.is_some() )
|
|
{
|
|
( Some( o ), true ) => ( pp.x - o.x ).hypot( pp.y - o.y ) > 24.0,
|
|
_ => false,
|
|
}
|
|
};
|
|
if promote
|
|
{
|
|
let ( ds_msg, origin ) = {
|
|
let ss = self.surface_mut( focus );
|
|
let m = ss.gesture.drag_start_msg.take().expect( "promote checked is_some" );
|
|
let o = ss.gesture.long_press_origin.expect( "promote checked Some(origin)" );
|
|
ss.gesture.long_press_start = None;
|
|
ss.gesture.long_press_origin = None;
|
|
ss.gesture.long_press_msg = None;
|
|
ss.gesture.long_press_text_idx = None;
|
|
ss.gesture.long_press_fired = true;
|
|
ss.request_redraw();
|
|
( m, o )
|
|
};
|
|
self.app.update( ds_msg );
|
|
let ( ox, oy ) = self.surface_offset_for( focus );
|
|
self.app.on_drag_move( origin.x + ox, origin.y + oy );
|
|
self.dirty_caches();
|
|
self.stop_button_repeat();
|
|
}
|
|
|
|
// `global_drag` must be sampled AFTER the promotion
|
|
// above — promotion flips `long_press_fired` and we
|
|
// want the gesture machine to take the drag branch
|
|
// for the same motion event that triggered the
|
|
// promotion.
|
|
let global_drag = self.has_active_long_press_drag();
|
|
let swipe = self.swipe_config( focus );
|
|
let outcome =
|
|
{
|
|
let ss = self.surface_mut( focus );
|
|
ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag )
|
|
};
|
|
self.apply_move_outcome( focus, outcome );
|
|
|
|
// Drag-to-select inside a TextEdit. Runs after the
|
|
// gesture machine so the gesture's "did we leave
|
|
// the press's hit rect?" reasoning still applies
|
|
// to the press itself; for text fields the answer
|
|
// is "fine, keep selecting" because we only widen
|
|
// the selection while the pointer is still inside
|
|
// the same widget rect.
|
|
let pressed_text = self.surface( focus ).gesture.pressed_idx
|
|
.and_then( |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 { Some( idx ) } else { None }
|
|
} );
|
|
if let Some( idx ) = pressed_text
|
|
{
|
|
self.handle_text_pointer_drag( focus, idx, pp );
|
|
}
|
|
// Cursor shape: hover may have changed → push the
|
|
// new shape to the compositor (no-op when
|
|
// unchanged).
|
|
self.dispatch_cursor_shape( focus );
|
|
}
|
|
}
|