Files
ltk/src/event_loop/focus.rs
Pedro M. de Echanove Pasquin d4d7ee742e
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
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.
2026-06-25 12:43:40 +02:00

169 lines
5.2 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::client::QueueHandle;
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::{ App, OverlayId };
use crate::tree::find_handlers;
use crate::types::Point;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
/// Push `on_dismiss` for every active xdg-popup overlay whose
/// anchor widget was not hit by a press at `pos` (logical pixels
/// in main-surface space). Used to compensate for compositors
/// (notably Mutter) that route pointer button events to the main
/// surface while a popup grab is technically active, instead of
/// breaking the grab. The check skips presses on the trigger pill
/// itself so the trigger's own `toggle` message keeps owning that
/// transition.
pub( crate ) fn dismiss_main_outside_popups( &mut self, pos: Point )
{
if self.overlays.is_empty() { return; }
let specs = self.app.overlays();
for spec in specs
{
// Only xdg-popups need the main-side fallback: they grab
// the seat and rely on the compositor to break the grab on
// outside input, which Mutter does not do reliably. Layer-
// shell overlays own their own `input_region` and dispatch
// dismissal from the overlay tap path, so firing it again
// from a press on the main surface produces a duplicate
// dismiss that races other intents (e.g. forge's topbar
// taps that route a separate IPC into the same overlay).
let Some( anchor_id ) = spec.anchor_widget_id else { continue; };
let anchor_rect = self.main.frame.widget_rects.iter()
.find( | w | w.id == Some( anchor_id ) )
.map( | w | w.rect );
let on_anchor = anchor_rect
.map( | r | pos.x >= r.x && pos.x < r.x + r.width
&& pos.y >= r.y && pos.y < r.y + r.height )
.unwrap_or( false );
if !on_anchor
{
if let Some( msg ) = spec.on_dismiss
{
self.pending_msgs.push( msg );
}
}
}
}
/// Push `on_dismiss` for every active xdg-popup overlay
/// unconditionally (used by keyboard Escape handling — there is
/// no spatial test to do).
pub( crate ) fn dismiss_all_popups( &mut self )
{
if self.overlays.is_empty() { return; }
let specs = self.app.overlays();
for spec in specs
{
if spec.anchor_widget_id.is_some()
{
if let Some( msg ) = spec.on_dismiss
{
self.pending_msgs.push( msg );
}
}
}
}
/// Look up the dismiss message for an overlay (tap on empty area).
/// Returns `None` if the overlay has no `on_dismiss` set or was removed.
pub( crate ) fn overlay_dismiss_msg( &self, id: OverlayId ) -> Option<A::Message>
{
self.app.overlays().into_iter()
.find( |s| s.id == id )
.and_then( |s| s.on_dismiss )
}
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize>, qh: &QueueHandle<Self> )
{
let was_text_input;
let is_text_input;
let secure;
{
let ss = self.surface_mut( focus );
is_text_input = idx
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| h.is_text_input() )
.unwrap_or( false );
secure = idx
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| matches!( h, WidgetHandlers::TextEdit { secure: true, .. } ) )
.unwrap_or( false );
was_text_input = ss.focused_idx
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
.map( |h| h.is_text_input() )
.unwrap_or( false );
// Clear pending text value when losing focus
if let Some( prev_idx ) = ss.focused_idx
{
if idx != Some( prev_idx )
{
ss.pending_text_values.remove( &prev_idx );
}
}
ss.focused_idx = idx;
ss.focused_id = idx.and_then( |i|
{
ss.frame.widget_rects.iter()
.find( |w| w.flat_idx == i )
.and_then( |w| w.id )
} );
ss.request_redraw();
// Sync cursor to end of current value when focusing a text
// input. Default behaviour collapses the selection to the
// cursor — focus changes always discard any prior
// selection state. Fields built with `.select_on_focus(
// true )` instead anchor the selection at `0` so the
// whole value is highlighted, ready to be replaced by the
// next keystroke (typical for numeric pickers).
if is_text_input
{
if let Some( i ) = idx
{
let handler = find_handlers( &ss.frame.widget_rects, i );
let cursor = handler
.and_then( |h| h.current_value() )
.map( |v| v.len() )
.unwrap_or( 0 );
let anchor = match handler
{
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0,
_ => cursor,
};
ss.frame.cursor_state.insert( i, cursor );
ss.frame.selection_anchor.insert( i, anchor );
}
}
}
if was_text_input && !is_text_input
{
self.deactivate_text_input();
self.app.on_text_input_focused( false );
self.dirty_caches();
}
else if is_text_input && !was_text_input
{
self.activate_text_input( qh, secure );
self.app.on_text_input_focused( true );
self.dirty_caches();
}
else if is_text_input
{
// Focus moved between text fields: refresh the content type so a
// password field is flagged even without re-activating.
self.activate_text_input( qh, secure );
}
}
}