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.
222 lines
7.8 KiB
Rust
222 lines
7.8 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge;
|
||
|
||
use super::app_data::AppData;
|
||
use super::surface::{ SurfaceFocus, SurfaceKind };
|
||
use crate::app::App;
|
||
use crate::types::{ CursorShape, Point };
|
||
|
||
/// Classify a pointer position against a surface of size `w × h` (in
|
||
/// physical pixels). Returns the resize edge if the pointer is inside
|
||
/// the `border`-thick grab band along any edge or corner.
|
||
pub( crate ) fn resize_edge_for_pos( pos: Point, w: f32, h: f32, border: f32 ) -> Option<ResizeEdge>
|
||
{
|
||
let on_left = pos.x < border;
|
||
let on_right = pos.x > w - border;
|
||
let on_top = pos.y < border;
|
||
let on_bottom = pos.y > h - border;
|
||
Some( match ( on_top, on_bottom, on_left, on_right )
|
||
{
|
||
( true, _, true, _ ) => ResizeEdge::TopLeft,
|
||
( true, _, _, true ) => ResizeEdge::TopRight,
|
||
( _, true, true, _ ) => ResizeEdge::BottomLeft,
|
||
( _, true, _, true ) => ResizeEdge::BottomRight,
|
||
( true, _, _, _ ) => ResizeEdge::Top,
|
||
( _, true, _, _ ) => ResizeEdge::Bottom,
|
||
( _, _, true, _ ) => ResizeEdge::Left,
|
||
( _, _, _, true ) => ResizeEdge::Right,
|
||
_ => return None,
|
||
} )
|
||
}
|
||
|
||
/// Map an [`ResizeEdge`] to the [`CursorShape`](crate::types::CursorShape)
|
||
/// to display while hovering / dragging it.
|
||
pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> CursorShape
|
||
{
|
||
use CursorShape::*;
|
||
match edge
|
||
{
|
||
ResizeEdge::Top => NResize,
|
||
ResizeEdge::Bottom => SResize,
|
||
ResizeEdge::Left => WResize,
|
||
ResizeEdge::Right => EResize,
|
||
ResizeEdge::TopLeft => NwResize,
|
||
ResizeEdge::TopRight => NeResize,
|
||
ResizeEdge::BottomLeft => SwResize,
|
||
ResizeEdge::BottomRight => SeResize,
|
||
_ => Default,
|
||
}
|
||
}
|
||
|
||
impl<A: App> AppData<A>
|
||
{
|
||
/// Convert a public [`CursorShape`](crate::types::CursorShape) into
|
||
/// the wire-protocol [`Shape`] enum the compositor expects.
|
||
fn cursor_shape_to_wp( shape: CursorShape )
|
||
-> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape
|
||
{
|
||
use CursorShape as C;
|
||
use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as S;
|
||
match shape
|
||
{
|
||
C::Default => S::Default,
|
||
C::ContextMenu => S::ContextMenu,
|
||
C::Help => S::Help,
|
||
C::Pointer => S::Pointer,
|
||
C::Progress => S::Progress,
|
||
C::Wait => S::Wait,
|
||
C::Cell => S::Cell,
|
||
C::Crosshair => S::Crosshair,
|
||
C::Text => S::Text,
|
||
C::VerticalText => S::VerticalText,
|
||
C::Alias => S::Alias,
|
||
C::Copy => S::Copy,
|
||
C::Move => S::Move,
|
||
C::NoDrop => S::NoDrop,
|
||
C::NotAllowed => S::NotAllowed,
|
||
C::Grab => S::Grab,
|
||
C::Grabbing => S::Grabbing,
|
||
C::EResize => S::EResize,
|
||
C::NResize => S::NResize,
|
||
C::NeResize => S::NeResize,
|
||
C::NwResize => S::NwResize,
|
||
C::SResize => S::SResize,
|
||
C::SeResize => S::SeResize,
|
||
C::SwResize => S::SwResize,
|
||
C::WResize => S::WResize,
|
||
C::EwResize => S::EwResize,
|
||
C::NsResize => S::NsResize,
|
||
C::NeswResize => S::NeswResize,
|
||
C::NwseResize => S::NwseResize,
|
||
C::ColResize => S::ColResize,
|
||
C::RowResize => S::RowResize,
|
||
C::AllScroll => S::AllScroll,
|
||
C::ZoomIn => S::ZoomIn,
|
||
C::ZoomOut => S::ZoomOut,
|
||
}
|
||
}
|
||
|
||
/// Compute the cursor shape that should be active right now and
|
||
/// push it to the compositor if it changed since the last
|
||
/// dispatch. Precedence:
|
||
///
|
||
/// 1. [`crate::App::cursor_override`] — application-driven busy
|
||
/// state, wins over everything.
|
||
/// 2. Active slider drag — `Grabbing` while the gesture machine
|
||
/// holds a `dragging_slider`.
|
||
/// 3. Cursor declared by the widget under the pointer
|
||
/// ([`crate::widget::LaidOutWidget::cursor`]).
|
||
/// 4. `Default` — pointer is over surface chrome / empty space.
|
||
pub( crate ) fn dispatch_cursor_shape( &mut self, focus: SurfaceFocus )
|
||
{
|
||
let target = {
|
||
let app_override = self.app.cursor_override();
|
||
let dragging = self.surface( focus ).gesture.dragging_slider.is_some();
|
||
let hover_cursor = self.surface( focus ).hovered_idx
|
||
.and_then( |idx| crate::tree::find_widget( &self.surface( focus ).frame.widget_rects, idx ) )
|
||
.map( |w| w.cursor )
|
||
.unwrap_or( CursorShape::Default );
|
||
let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor );
|
||
|
||
// Resize-edge hover wins over the widget cursor and over a
|
||
// `Default` app override, but loses to a non-default app
|
||
// override (so a "busy" / "wait" state still trumps chrome).
|
||
let allow_chrome = app_override.is_none()
|
||
|| app_override == Some( CursorShape::Default );
|
||
if let Some( c ) = resize_cursor.filter( |_| allow_chrome )
|
||
{
|
||
c
|
||
} else if let Some( o ) = app_override {
|
||
o
|
||
} else if dragging {
|
||
CursorShape::Grabbing
|
||
} else {
|
||
hover_cursor
|
||
}
|
||
};
|
||
// Skip when the compositor already shows the right shape.
|
||
// `current_cursor_shape == None` means "we have not pushed
|
||
// anything since the last `Enter`" — e.g. the pointer just
|
||
// arrived from another client's surface, where the prior
|
||
// client may have asked for a text I-beam. We MUST push
|
||
// unconditionally in that case so the user does not see the
|
||
// stranger cursor lingering until they hit a widget.
|
||
if self.current_cursor_shape == Some( target ) { return; }
|
||
|
||
if let Some( device ) = self.cursor_shape_device.as_ref()
|
||
{
|
||
device.set_shape( self.last_pointer_enter_serial, Self::cursor_shape_to_wp( target ) );
|
||
self.current_cursor_shape = Some( target );
|
||
}
|
||
}
|
||
|
||
/// Width (logical pixels) of the resize-grab band along each
|
||
/// edge of an xdg-toplevel surface. 6 px is the GTK / KWin
|
||
/// convention.
|
||
pub( crate ) const RESIZE_BORDER_LOGICAL: f32 = 6.0;
|
||
|
||
/// Return the [`ResizeEdge`] the pointer currently sits on, or
|
||
/// `None` if it is not in a resize-grab band. Only fires for the
|
||
/// main surface when it is an xdg-toplevel — layer-shell surfaces
|
||
/// and overlays cannot be resized by the user.
|
||
pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option<ResizeEdge>
|
||
{
|
||
if !matches!( focus, SurfaceFocus::Main ) { return None; }
|
||
if !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; }
|
||
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
|
||
let border = Self::RESIZE_BORDER_LOGICAL * sf;
|
||
let w = self.surface( focus ).physical_width() as f32;
|
||
let h = self.surface( focus ).physical_height() as f32;
|
||
resize_edge_for_pos( self.pointer_pos, w, h, border )
|
||
}
|
||
}
|
||
|
||
#[ cfg( test ) ]
|
||
mod resize_edge_tests
|
||
{
|
||
use super::{ resize_edge_for_pos, ResizeEdge };
|
||
use crate::types::Point;
|
||
|
||
const W: f32 = 800.0;
|
||
const H: f32 = 600.0;
|
||
const B: f32 = 6.0;
|
||
|
||
fn at( x: f32, y: f32 ) -> Option<ResizeEdge>
|
||
{
|
||
resize_edge_for_pos( Point { x, y }, W, H, B )
|
||
}
|
||
|
||
#[ test ]
|
||
fn middle_is_none()
|
||
{
|
||
assert!( at( 400.0, 300.0 ).is_none() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn just_inside_border_is_none()
|
||
{
|
||
// 6 px border; (6, 6) is one pixel inside on both axes.
|
||
assert!( at( 6.0, 6.0 ).is_none() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn corners_take_precedence_over_single_edges()
|
||
{
|
||
assert_eq!( at( 1.0, 1.0 ), Some( ResizeEdge::TopLeft ) );
|
||
assert_eq!( at( W - 1.0, 1.0 ), Some( ResizeEdge::TopRight ) );
|
||
assert_eq!( at( 1.0, H - 1.0 ), Some( ResizeEdge::BottomLeft ) );
|
||
assert_eq!( at( W - 1.0, H - 1.0 ), Some( ResizeEdge::BottomRight ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn straight_edges()
|
||
{
|
||
assert_eq!( at( 400.0, 1.0 ), Some( ResizeEdge::Top ) );
|
||
assert_eq!( at( 400.0, H - 1.0 ), Some( ResizeEdge::Bottom ) );
|
||
assert_eq!( at( 1.0, 300.0 ), Some( ResizeEdge::Left ) );
|
||
assert_eq!( at( W - 1.0, 300.0 ), Some( ResizeEdge::Right ) );
|
||
}
|
||
}
|