event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Five orthogonal capabilities land together because they share the same `try_run` plumbing: an optional global is bound at startup, a piece of state is added to `AppData`, the run-loop iteration drains an inbox / pushes a frame snapshot, and the public surface gains a small set of opt-in `App` hooks. Nothing here breaks an existing app — every new path degrades to a no-op when the compositor does not advertise the relevant global or when the platform adapter cannot start.
AT-SPI2 accessibility via AccessKit. A new `src/a11y/` module owns the platform adapter and the inbound `ActionRequest` channel. `A11yState::try_new` constructs an `accesskit_unix::Adapter`; when the AT-SPI2 daemon is not on the session bus (headless CI, locked-down compositors) the constructor returns `None` and the rest of the pipeline runs unchanged. After every successful `draw_frame`, the run loop builds a fresh `accesskit::TreeUpdate` from `widget_rects` and pushes it through the adapter — main surface plus every visible overlay, each translated to global coordinates via `surface_offset_for` so screen readers report positions in the same frame the user sees. Buttons / toggles / checkboxes / radios / list items / sliders / text edits map to the matching `Role`s; `Click` and `Focus` actions are advertised on every interactive node; inbound action requests are drained at the top of each iteration and translated into a synthetic press / focus on the matching widget. The integration is documented as best-effort in `docs/architecture.md` under "Known gaps and non-goals": hierarchical nesting, per-widget accessible names, live regions and `Action::SetValue` are listed as the natural follow-ups that the foundation now supports but does not yet wire.
Cross-application clipboard via `wl_data_device_manager`. A new `src/event_loop/data_device.rs` bridges the existing process-local `clipboard: String` to the Wayland selection. Outbound (Ctrl+C / Cut): after the local clipboard is populated, `publish_clipboard_selection` creates a `CopyPasteSource` offering `text/plain;charset=utf-8` and installs it as the seat's selection; `DataSourceHandler::send` writes the cached string into the fd the peer hands us. Inbound (Ctrl+V from another app): `DataDeviceHandler::selection` asks for the offered text via `WlDataOffer::receive`, spawns a tiny worker thread to drain the read pipe with a 16 MiB cap to prevent paste-bomb DoS, and posts the result back through an `mpsc::Sender` that the run loop drains each iteration into `data.clipboard`. The `clipboard:` field's doc-comment is updated to reflect the new behaviour: process-local when the compositor does not advertise the global, synchronised with the seat selection otherwise.
External drag-and-drop reception. The same `data_device` module handles `DragOffer` enter / motion / leave / drop_performed: `on_drop_motion( x, y )` fires while the drag hovers over the surface, `on_drop_leave()` when it withdraws without dropping, and `on_drop_received( x, y, mime, text )` when an external payload (`text/uri-list`, `text/plain`, …) is released on top of an ltk window. The receive path reuses the same worker-thread / channel pattern as the clipboard so the run loop never blocks on the read fd. Three new `App` hooks expose the events with no-op defaults; apps that ignore them get the previous behaviour.
`xdg-activation-v1`. The global is bound optionally; when it is present, `try_run` reads `$XDG_ACTIVATION_TOKEN` from the environment, removes it immediately (single-use; preventing leaks into child processes) and stashes it on `AppData::activation_token_pending`. After the first successful configure of the main surface — the earliest point at which `xdg_activation_v1.activate` is meaningful — the token is consumed once and the surface raised to focus. Compositors without the global leave `activation_state` as `None` and the inbound path silently degrades. An `App::request_activation_token` outbound path is reserved on the trait but not yet exercised here.
HarfBuzz shaping. A new `src/text_shaping.rs::shape_line` drives both renderers: the logical-order string is run through `unicode-bidi`, split into per-font sub-runs, and shaped through `rustybuzz`. Each `PositionedGlyph` carries the per-font `glyph_id`, the visual advance and the ink offsets — exactly what `fontdue::Font::rasterize_indexed` needs to render Arabic connected forms, Devanagari clusters and CJK shaped glyphs correctly. The GLES atlas is re-keyed on `(glyph_id, size_bits, font_id)` so glyphs from different fonts at the same size no longer collide, and the atlas format is selected per ES profile (`GL_R8` / `GL_RED` on ES3, `GL_LUMINANCE` on ES2) — the fragment shader samples `.r` for both, since `GL_LUMINANCE` replicates the coverage byte into `.r=.g=.b`. Software path follows the same key. New `Cargo.toml` deps: `unicode-bidi = "0.3"`, `rustybuzz = "0.14"`.
Multi-touch hooks. `App::on_touch_down / on_touch_move / on_touch_up( id, x, y )` expose the raw `wl_touch.id` of every secondary finger. The first finger to land remains the *primary slot* and is fed through the regular gesture machine (`on_pointer_*`, swipe, scroll, long-press, drag-and-drop). Every additional finger fires the new callbacks instead, leaving the existing single-slot behaviour untouched for apps that do not override them. This is the substrate for app-defined pinch-zoom / two-finger pan; the toolkit itself does not yet ship a built-in pinch gesture (called out in the same "Known gaps" doc section).
`event_loop::frame` extracted from `draw/mod.rs`. The `draw_frame` orchestrator and its per-format SHM helper (`pick_shm_format`) move into `src/event_loop/frame.rs`, leaving `draw/` strictly responsible for per-surface paint primitives. The import in `event_loop/run.rs` is rewritten accordingly; `draw/mod.rs` shrinks from 192-line orchestrator to a thin module index.
Overlay teardown safety. `AppData::discard_overlay( id )` synchronously removes a destroyed overlay from the map and rewrites every per-device focus that pointed at it (pointer, keyboard, every touch slot), migrating an in-flight long-press drag to the main surface the same way `reconcile_overlays` does. Used by the compositor-driven destruction paths (`PopupHandler::done`, `LayerShellHandler::closed`) where waiting for the next reconcile would leave a window in which `surface()` / `surface_mut()` panic. The non-panicking siblings `try_surface` / `try_surface_mut` are added for callers on async dispatch paths (IME `Done`, tooltip arm) that may race a teardown.
Miscellaneous. CI: `master` → `main` to match the actual default branch. `Makefile` adds `cargo run --example dialog` to the examples target. `src/lib.rs` re-exports `widget::scroll::ScrollAxis` so apps can configure a `scroll()` axis without reaching into a `pub(crate)` module. `Cargo.toml` adds `accesskit = "0.17"` and `accesskit_unix = "0.13"`. `docs/architecture.md` gains the "Known gaps and non-goals" section that enumerates the new capabilities, what still ships flat, and what is deferred (per-widget a11y labels, primary selection, intra-process multi-touch gestures, `wp_fractional_scale_v1`).
This commit is contained in:
2026-05-16 22:09:59 +02:00
parent 4aa3480b64
commit 4a80165428
48 changed files with 3088 additions and 645 deletions

View File

@@ -1,6 +1,6 @@
# Gitea Actions CI for ltk.
#
# Runs on every push and pull request against `master`. Two independent
# Runs on every push and pull request against `main`. Two independent
# jobs share the cache layer but otherwise execute in parallel:
#
# - `test` builds the workspace and runs the full test corpus.
@@ -20,9 +20,9 @@ name: CI
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
branches: [ master ]
branches: [ main ]
env:
CARGO_TERM_COLOR: always

View File

@@ -29,6 +29,8 @@ calloop = "0.14"
calloop-wayland-source = "0.4"
tiny-skia = "0.12"
fontdue = "0.9"
unicode-bidi = "0.3"
rustybuzz = "0.14"
image = { version = "=0.25.9", default-features = false, features = ["png", "jpeg", "webp"] }
resvg = "0.44"
rust-i18n = "3"
@@ -37,6 +39,17 @@ wayland-egl = "0.32"
khronos-egl = { version = "6", features = ["dynamic"] }
glow = "0.17"
raw-window-handle = "0.6"
# AT-SPI2 / accessibility is delegated to AccessKit so we get the same
# Node / Role / TreeUpdate model the rest of the Rust GUI ecosystem
# (egui, iced via the COSMIC fork, dioxus) is converging on. The
# Linux platform adapter (`accesskit_unix`) handles every D-Bus
# detail — registering against `org.a11y.Bus`, exposing the
# `org.a11y.atspi.Accessible` interface hierarchy, translating
# `Action`s back into our event loop, emitting state-changed signals
# — so this crate only has to construct an `accesskit::TreeUpdate`
# from `widget_rects` once per frame.
accesskit = "0.17"
accesskit_unix = "0.13"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

View File

@@ -43,6 +43,7 @@ examples:
LTK_THEMES_DIR=themes cargo run --release --example mini_shell
LTK_THEMES_DIR=themes cargo run --release --example widgets
LTK_THEMES_DIR=themes cargo run --release --example pickers
LTK_THEMES_DIR=themes cargo run --release --example dialog
clean:
dh_clean

View File

@@ -245,3 +245,28 @@ When a redraw feels sluggish: add a one-line print at the top of `view()` and co
| Theme on disk (slot-typed JSON) | `ltk/themes/default/theme.json`, `ltk::ThemeDocument::find` |
For a self-contained example that exercises overlays, theme switching, and animation in one ~300-line file, see `examples/mini_shell.rs`.
## Known gaps and non-goals
A short, honest list of what ltk does not currently provide. None of these are accidental — each is either deferred work or a deliberate non-goal. The list is here so that downstream consumers and audit reviewers know what to plan around without reading the source.
**AT-SPI2 / assistive technology bridge — wired through AccessKit, with composite widgets still flat.** Combo, Notebook tabs, DatePicker and TimePicker render as collections of inner widgets and currently expose those leaves individually (a combo trigger reads as "Button" + its caption, the popup items as `ListItem`s inside an overlay). Promoting them to their semantic roles (`ComboBoxMenuButton` with `Expanded` state, `TabList`/`Tab`/`TabPanel`, `Date`) needs each compound widget to declare an "outer rol hint" the layout pass can attach to the `LaidOutWidget` it pushes. Tracked separately.
ltk delegates the AT-SPI2 D-Bus protocol to [`accesskit_unix`]. After every layout pass, the runtime hands the platform adapter a fresh [`accesskit::TreeUpdate`] built from `widget_rects` (one accessible node per laid-out widget, plus a `Window` root). Buttons / toggles / checkboxes / radios / list items map to `Role::Button` / `Role::Switch` / `Role::CheckBox` / `Role::RadioButton` / `Role::ListItem`; sliders to `Role::Slider`; single- and multi-line text edits to `Role::TextInput` / `Role::MultilineTextInput`. Each interactive node advertises the `Click` and `Focus` actions, and inbound action requests (Orca pressing a button, switch-control moving focus) are translated into a synthetic press / focus on the matching widget the next iteration of the run loop.
The integration is best-effort: when the AT-SPI2 daemon is not on the session bus (headless CI runners, locked-down compositors) the adapter creation returns `None` and the rest of the pipeline runs unchanged. The current cut covers the common cases — buttons, lists, form fields, dialogs — and intentionally leaves room for follow-up:
- **Hierarchical nodes (groups, lists with explicit children)**: today the tree is flat. AccessKit supports nesting but the layout pass does not expose `Column` / `Row` / `Container` parents to the accessibility builder. Adding that requires either recording the nesting on `LaidOutWidget` or walking `Element` again from the a11y side.
- **Per-widget accessible label / description / `LabelledBy` relations**: the API to set them (`Button::accessible_name(...)`, etc.) is not exposed yet — labels currently fall back to the widget's tooltip text. Adding the builders is mechanical but touches every widget module.
- **Live regions**: status messages, notifications and OSDs that should announce themselves on appearance need `Live::{Polite, Assertive}` on the relevant nodes. Not wired in.
- **`Action::SetValue` for sliders and text inputs**: the inbound action handler does not yet translate these requests into the corresponding widget message. Adding them needs the same plumbing as `Click` / `Focus` but with payload extraction from `ActionData`.
Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 AA, EAA) should still treat the integration as a starting point that needs a real audit with assistive technology users — the foundation is in place but the per-widget metadata work is what determines whether Orca actually reads a useful announcement.
**Cross-application drag-and-drop — deferred.** The clipboard now bridges to the Wayland selection via `wl_data_device_manager` (see `event_loop/data_device.rs`), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (`zwp_primary_selection_v1`) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top.
**Multi-touch — deferred.** `input/touch/mod.rs` is single-slot by design today; a second finger overwrites the first. Pinch-zoom, two-finger scroll and gesture combos are out until the slot table lands. Tracked separately.
**HarfBuzz shaping — wired in.** `src/text_shaping.rs::shape_line` now drives both renderers: the line is BiDi-reordered, split into per-font sub-runs and shaped through rustybuzz. The glyph cache is keyed on `(glyph_id, size_bits, font_id)` and each glyph is rasterised by index via `fontdue::Font::rasterize_indexed`, so Arabic connected forms, Devanagari clusters and CJK shaped glyphs render correctly.
**xdg-activation-v1 and fractional scale — deferred.** Activation tokens (so an external launcher can raise an ltk window with focus) and `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) are tracked as upcoming protocol work.

155
src/a11y/mod.rs Normal file
View File

@@ -0,0 +1,155 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! AT-SPI2 accessibility via AccessKit.
//!
//! ltk does not implement the D-Bus AT-SPI2 protocol itself — that is
//! delegated to [`accesskit_unix`], which translates a backend-neutral
//! [`accesskit::TreeUpdate`] into the corresponding D-Bus calls,
//! registers the application against `org.a11y.Bus`, and emits the
//! property / state / focus change signals Orca and friends listen
//! for. Our job is two-way:
//!
//! 1. **Outbound**: every frame, after the layout pass has populated
//! `widget_rects`, build a fresh [`accesskit::TreeUpdate`] from
//! those rects and push it into the adapter through
//! [`A11yState::update`]. The runtime calls this from the same
//! place that runs `draw_frame`, so the accessible tree is always
//! in sync with what the user can see.
//!
//! 2. **Inbound**: an [`accesskit::ActionRequest`] from an assistive
//! technology (Orca pressing the "Default Action" on a button, a
//! switch-control device focusing the next node, …) lands on the
//! [`ActionForwarder`] running inside the adapter's worker
//! thread; the forwarder pushes it through an `mpsc::Sender` into
//! the run loop, which drains the channel each iteration and
//! translates the request into a synthetic press / focus on the
//! target widget.
//!
//! The adapter is constructed unconditionally — `accesskit_unix`
//! never refuses creation, it just stays inactive when no AT client
//! attaches to the session bus. Nothing else in the pipeline reads
//! the field except the per-frame tree update and the per-iteration
//! action inbox drain, so the rest of the runtime is unaware of
//! whether an AT is listening or not.
pub( crate ) mod tree;
use std::sync::mpsc;
use accesskit::{ ActionRequest, Rect as A11yRect, TreeUpdate };
/// Channel carrying inbound action requests from the AccessKit
/// adapter thread to the run loop.
pub( crate ) type ActionInbox = mpsc::Receiver<ActionRequest>;
/// Top-level accessibility state owned by `AppData`. Holds the
/// platform adapter alongside the receiver half of the action
/// channel.
pub( crate ) struct A11yState
{
adapter: accesskit_unix::Adapter,
pub( crate ) action_rx: ActionInbox,
}
impl A11yState
{
/// Bring up the AccessKit adapter. Returns `None` only when we
/// reserve the right to refuse creation in the future (currently
/// always `Some` — `accesskit_unix::Adapter::new` is infallible
/// and stays inactive when no AT client is attached). Keeping
/// the `Option` lets us toggle accessibility off at runtime via
/// an env var or feature flag without re-plumbing every call
/// site.
pub( crate ) fn try_new( _app_name: &str, _app_id: &str ) -> Option<Self>
{
let ( action_tx, action_rx ) = mpsc::channel();
let adapter = accesskit_unix::Adapter::new(
ActivationStub,
ActionForwarder { tx: action_tx },
DeactivationStub,
);
Some( Self { adapter, action_rx } )
}
/// Push a freshly-built tree into the adapter. The closure is
/// invoked synchronously **only** when AT-SPI2 is currently
/// observing the application (an AT client connected to the
/// daemon and queried us at least once), so the cost of
/// constructing the tree is paid only when something is actually
/// listening.
pub( crate ) fn update<F>( &mut self, factory: F )
where
F: FnOnce() -> TreeUpdate,
{
self.adapter.update_if_active( factory );
}
/// AT-SPI clients filter by window focus; without it Orca skips
/// the surface entirely.
pub( crate ) fn set_window_focus( &mut self, focused: bool )
{
self.adapter.update_window_focus_state( focused );
}
pub( crate ) fn set_window_bounds( &mut self, width: f64, height: f64 )
{
let r = A11yRect { x0: 0.0, y0: 0.0, x1: width, y1: height };
self.adapter.set_root_window_bounds( r, r );
}
#[ allow( dead_code ) ]
pub( crate ) fn notify_focus( &mut self, new_focus: accesskit::NodeId )
{
self.adapter.update_if_active( ||
{
TreeUpdate
{
nodes: vec![],
tree: None,
focus: new_focus,
}
} );
}
}
/// Activation handler the adapter calls when an assistive technology
/// first attaches and asks for the initial tree. We hand back an
/// empty root — the first proper update comes from the run loop on
/// the very next frame, which is the natural moment to materialise a
/// real tree (the widget_rects are populated by then).
struct ActivationStub;
impl accesskit::ActivationHandler for ActivationStub
{
fn request_initial_tree( &mut self ) -> Option<TreeUpdate>
{
Some( tree::empty_root() )
}
}
/// Action handler that simply forwards every incoming request to the
/// main event loop. The translation from `ActionRequest` to a
/// synthetic press / focus happens on the main thread so it can
/// touch the rest of `AppData` without locking.
struct ActionForwarder
{
tx: mpsc::Sender<ActionRequest>,
}
impl accesskit::ActionHandler for ActionForwarder
{
fn do_action( &mut self, request: ActionRequest )
{
let _ = self.tx.send( request );
}
}
/// Deactivation handler. AccessKit invokes this when every AT client
/// detaches; we have no per-client cleanup so it's a no-op.
struct DeactivationStub;
impl accesskit::DeactivationHandler for DeactivationStub
{
fn deactivate_accessibility( &mut self ) {}
}

384
src/a11y/tree.rs Normal file
View File

@@ -0,0 +1,384 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Construction of [`accesskit::TreeUpdate`] from the layout pass.
use std::collections::HashMap;
use accesskit::{ Action, Live, Node, NodeId, Rect as A11yRect, Role, TextPosition, TextSelection, Toggled, Tree, TreeUpdate };
use crate::types::Rect;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
#[ derive( Clone ) ]
pub( crate ) enum AccessibleExtraKind
{
Label,
Image,
Separator,
Progress( f32 ),
}
#[ derive( Clone ) ]
pub( crate ) struct AccessibleExtra
{
pub rect: Rect,
pub label: Option<String>,
pub live: bool,
pub kind: AccessibleExtraKind,
}
pub( crate ) const ROOT_ID: NodeId = NodeId( 1 );
const KIND_WIDGET: u8 = 0;
const KIND_EXTRA: u8 = 1;
const KIND_TEXT_RUN: u8 = 2;
const KIND_OVERLAY: u8 = 3;
const ID_BASE: u64 = 1 << 48;
#[ inline ]
fn make_id( focus: u8, kind: u8, idx: u32 ) -> NodeId
{
NodeId( ID_BASE | ( ( focus as u64 ) << 40 ) | ( ( kind as u64 ) << 32 ) | ( idx as u64 ) )
}
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub( crate ) struct NodeRef
{
pub focus: u8,
pub kind: u8,
pub idx: u32,
}
#[ inline ]
pub( crate ) fn parse_id( id: NodeId ) -> Option<NodeRef>
{
let v = id.0;
if v < ID_BASE { return None; }
Some( NodeRef
{
focus: ( ( v >> 40 ) & 0xFF ) as u8,
kind: ( ( v >> 32 ) & 0xFF ) as u8,
idx: ( v & 0xFFFF_FFFF ) as u32,
} )
}
#[ inline ]
pub( crate ) fn widget_id_for( focus: u8, flat_idx: usize ) -> NodeId
{
make_id( focus, KIND_WIDGET, flat_idx as u32 )
}
#[ inline ]
fn extra_id_for( focus: u8, idx: usize ) -> NodeId
{
make_id( focus, KIND_EXTRA, idx as u32 )
}
#[ inline ]
fn text_run_id_for( focus: u8, flat_idx: usize ) -> NodeId
{
make_id( focus, KIND_TEXT_RUN, flat_idx as u32 )
}
#[ inline ]
fn overlay_root_id( focus: u8 ) -> NodeId
{
make_id( focus, KIND_OVERLAY, 0 )
}
pub( crate ) fn empty_root() -> TreeUpdate
{
let mut root = Node::new( Role::Window );
root.set_label( "ltk" );
TreeUpdate
{
nodes: vec![ ( ROOT_ID, root ) ],
tree: Some( Tree::new( ROOT_ID ) ),
focus: ROOT_ID,
}
}
fn node_for<Msg: Clone>(
focus_id: u8,
w: &LaidOutWidget<Msg>,
is_focused: bool,
is_pressed: bool,
pending_text: Option<&String>,
cursor_byte: usize,
out_text_run: &mut Option<( NodeId, Node )>,
) -> Node
{
let role = match &w.handlers
{
WidgetHandlers::Button { .. } => Role::Button,
WidgetHandlers::Toggle { .. } => Role::Switch,
WidgetHandlers::Checkbox { .. } => Role::CheckBox,
WidgetHandlers::Radio { .. } => Role::RadioButton,
WidgetHandlers::ListItem { .. } => Role::ListItem,
WidgetHandlers::WindowButton { .. } => Role::Button,
WidgetHandlers::Slider { .. } => Role::Slider,
WidgetHandlers::TextEdit { multiline, .. } =>
if *multiline { Role::MultilineTextInput } else { Role::TextInput },
WidgetHandlers::None => Role::GenericContainer,
};
let mut node = Node::new( role );
node.set_bounds( convert_rect( w.rect ) );
if let Some( name ) = label_for( &w.handlers, w.accessible_label.as_deref(), w.tooltip.as_deref() )
{
node.set_label( name );
}
if w.handlers.is_disabled()
{
node.set_disabled();
}
match &w.handlers
{
WidgetHandlers::Button { .. } | WidgetHandlers::WindowButton { .. } | WidgetHandlers::ListItem { .. } =>
{
node.add_action( Action::Click );
node.add_action( Action::Focus );
if is_pressed { node.set_toggled( Toggled::True ); }
}
WidgetHandlers::Toggle { value, .. } =>
{
node.add_action( Action::Click );
node.add_action( Action::Focus );
node.set_toggled( if *value { Toggled::True } else { Toggled::False } );
}
WidgetHandlers::Checkbox { value, .. } =>
{
node.add_action( Action::Click );
node.add_action( Action::Focus );
node.set_toggled( if *value { Toggled::True } else { Toggled::False } );
}
WidgetHandlers::Radio { selected, .. } =>
{
node.add_action( Action::Click );
node.add_action( Action::Focus );
node.set_toggled( if *selected { Toggled::True } else { Toggled::False } );
}
WidgetHandlers::Slider { value, .. } =>
{
node.add_action( Action::Focus );
node.add_action( Action::SetValue );
node.add_action( Action::Increment );
node.add_action( Action::Decrement );
node.set_min_numeric_value( 0.0 );
node.set_max_numeric_value( 1.0 );
node.set_numeric_value_step( 0.05 );
node.set_numeric_value( *value as f64 );
}
WidgetHandlers::TextEdit { value, secure, .. } =>
{
node.add_action( Action::Focus );
node.add_action( Action::SetValue );
let exposed: String = if *secure
{
String::new()
}
else
{
pending_text.cloned().unwrap_or_else( || value.clone() )
};
node.set_value( exposed.as_str() );
let run_id = text_run_id_for( focus_id, w.flat_idx );
node.set_children( vec![ run_id ] );
let lengths: Vec<u8> = exposed.chars().map( |c| c.len_utf8() as u8 ).collect();
let safe_byte = cursor_byte.min( exposed.len() );
let cursor_char_idx = exposed[ ..safe_byte ].chars().count();
let pos = TextPosition { node: run_id, character_index: cursor_char_idx };
node.set_text_selection( TextSelection { anchor: pos, focus: pos } );
let mut run = Node::new( Role::TextRun );
run.set_bounds( convert_rect( w.rect ) );
run.set_value( exposed.as_str() );
run.set_character_lengths( lengths );
*out_text_run = Some( ( run_id, run ) );
}
_ => {}
}
if is_focused
{
node.add_action( Action::Focus );
}
if w.is_live_region
{
node.set_live( Live::Polite );
}
node
}
#[ inline ]
fn convert_rect( r: Rect ) -> A11yRect
{
A11yRect
{
x0: r.x as f64,
y0: r.y as f64,
x1: ( r.x + r.width ) as f64,
y1: ( r.y + r.height ) as f64,
}
}
fn label_for<Msg: Clone>( handlers: &WidgetHandlers<Msg>, visible: Option<&str>, tooltip: Option<&str> ) -> Option<String>
{
if let WidgetHandlers::TextEdit { secure: true, .. } = handlers
{
return Some( "password".to_string() );
}
visible.or( tooltip ).map( |s| s.to_string() )
}
pub( crate ) struct SurfaceView<'a, Msg: Clone>
{
pub focus_id: u8,
pub is_main: bool,
pub label: Option<&'a str>,
pub widget_rects: &'a [ LaidOutWidget<Msg> ],
pub extras: &'a [ AccessibleExtra ],
pub focused_idx: Option<usize>,
pub pressed_idx: Option<usize>,
pub pending_text_values: &'a HashMap<usize, String>,
pub cursor_state: &'a HashMap<usize, usize>,
pub width: f32,
pub height: f32,
pub offset_x: f32,
pub offset_y: f32,
}
pub( crate ) fn build_tree<Msg: Clone>(
surfaces: &[ SurfaceView<Msg> ],
keyboard_focus: u8,
app_name: &str,
) -> TreeUpdate
{
let main = surfaces.iter().find( |s| s.is_main );
let ( total_w, total_h ) = main.map( |m| ( m.width, m.height ) ).unwrap_or( ( 0.0, 0.0 ) );
let mut nodes = Vec::new();
let mut root = Node::new( Role::Window );
root.set_label( app_name );
root.set_bounds( A11yRect { x0: 0.0, y0: 0.0, x1: total_w as f64, y1: total_h as f64 } );
let mut root_children: Vec<NodeId> = Vec::new();
if let Some( m ) = main
{
for w in m.widget_rects { root_children.push( widget_id_for( m.focus_id, w.flat_idx ) ); }
for ( i, _ ) in m.extras.iter().enumerate() { root_children.push( extra_id_for( m.focus_id, i ) ); }
}
for s in surfaces.iter().filter( |s| !s.is_main )
{
root_children.push( overlay_root_id( s.focus_id ) );
}
root.set_children( root_children );
nodes.push( ( ROOT_ID, root ) );
let mut active_focus: Option<NodeId> = None;
for s in surfaces
{
if !s.is_main
{
let mut dlg = Node::new( Role::Dialog );
if let Some( l ) = s.label { dlg.set_label( l ); }
dlg.set_bounds( A11yRect
{
x0: s.offset_x as f64, y0: s.offset_y as f64,
x1: ( s.offset_x + s.width ) as f64, y1: ( s.offset_y + s.height ) as f64,
} );
let mut ch: Vec<NodeId> = Vec::new();
for w in s.widget_rects { ch.push( widget_id_for( s.focus_id, w.flat_idx ) ); }
for ( i, _ ) in s.extras.iter().enumerate() { ch.push( extra_id_for( s.focus_id, i ) ); }
dlg.set_children( ch );
nodes.push( ( overlay_root_id( s.focus_id ), dlg ) );
}
for w in s.widget_rects
{
let is_focused = s.focused_idx == Some( w.flat_idx );
let is_pressed = s.pressed_idx == Some( w.flat_idx );
let pending = s.pending_text_values.get( &w.flat_idx );
let cursor_byte = s.cursor_state.get( &w.flat_idx ).copied().unwrap_or( usize::MAX );
let mut text_run: Option<( NodeId, Node )> = None;
let mut node = node_for( s.focus_id, w, is_focused, is_pressed, pending, cursor_byte, &mut text_run );
if !s.is_main
{
let mut r = w.rect;
r.x += s.offset_x;
r.y += s.offset_y;
node.set_bounds( convert_rect( r ) );
}
let id = widget_id_for( s.focus_id, w.flat_idx );
if is_focused && s.focus_id == keyboard_focus
{
active_focus = Some( id );
}
nodes.push( ( id, node ) );
if let Some( ( rid, run ) ) = text_run { nodes.push( ( rid, run ) ); }
}
for ( i, e ) in s.extras.iter().enumerate()
{
let role = match e.kind
{
AccessibleExtraKind::Label => Role::Label,
AccessibleExtraKind::Image => Role::Image,
AccessibleExtraKind::Separator => Role::Splitter,
AccessibleExtraKind::Progress(_) => Role::ProgressIndicator,
};
let mut node = Node::new( role );
let mut r = e.rect;
if !s.is_main { r.x += s.offset_x; r.y += s.offset_y; }
node.set_bounds( convert_rect( r ) );
if let Some( ref l ) = e.label { node.set_label( l.as_str() ); }
if let AccessibleExtraKind::Progress( v ) = e.kind
{
node.set_min_numeric_value( 0.0 );
node.set_max_numeric_value( 1.0 );
node.set_numeric_value( v as f64 );
}
if e.live { node.set_live( Live::Polite ); }
nodes.push( ( extra_id_for( s.focus_id, i ), node ) );
}
}
let focus = active_focus.unwrap_or( ROOT_ID );
TreeUpdate
{
nodes,
tree: Some( Tree::new( ROOT_ID ) ),
focus,
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn id_round_trip()
{
for focus in [ 0u8, 1, 7, 200 ]
{
for idx in [ 0usize, 1, 42, 9999 ]
{
let id = widget_id_for( focus, idx );
let r = parse_id( id ).expect( "parses" );
assert_eq!( ( r.focus, r.kind, r.idx as usize ), ( focus, KIND_WIDGET, idx ) );
}
}
}
#[ test ]
fn root_is_not_a_widget()
{
assert!( parse_id( ROOT_ID ).is_none() );
assert!( parse_id( NodeId( 0 ) ).is_none() );
}
}

View File

@@ -464,6 +464,38 @@ pub trait App: 'static
/// owns a scrollable web page.
fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {}
/// Raw multi-touch callbacks. Default: no-op.
///
/// The first finger to land becomes the *primary slot* and is fed
/// through the regular gesture machine — `on_pointer_*`, swipe,
/// scroll, long-press, drag-and-drop all run from that slot. Every
/// additional finger fires `on_touch_down` / `on_touch_move` /
/// `on_touch_up` instead, with `id` matching the `wl_touch.id`
/// the compositor delivered.
///
/// Use this for app-defined multi-finger gestures (pinch-zoom,
/// two-finger pan in a canvas, parallel keyboard keys) that the
/// built-in single-slot machine cannot model. `(x, y)` are in
/// physical pixels, matching the coordinate space of
/// [`Self::on_pointer_move`].
fn on_touch_down( &mut self, _id: i64, _x: f32, _y: f32 ) {}
/// See [`Self::on_touch_down`].
fn on_touch_move( &mut self, _id: i64, _x: f32, _y: f32 ) {}
/// See [`Self::on_touch_down`]. `x`, `y` are the last known
/// position of the finger (`wl_touch.up` does not carry one).
fn on_touch_up( &mut self, _id: i64, _x: f32, _y: f32 ) {}
/// Pointer of a cross-application drag-and-drop entered or moved
/// inside the main surface. `(x, y)` is in logical pixels.
fn on_drop_motion( &mut self, _x: f32, _y: f32 ) {}
/// The drag left without dropping.
fn on_drop_leave( &mut self ) {}
/// External drop payload landed on the surface. `mime` is the
/// best mime the source offered (text/uri-list, text/plain, …)
/// and `text` is the decoded payload (UTF-8). `(x, y)` is the
/// release position in logical pixels.
fn on_drop_received( &mut self, _x: f32, _y: f32, _mime: &str, _text: &str ) {}
/// Called on every left-button mouse press / release before the
/// regular gesture machine fires. `pressed = true` for press,
/// `false` for release. `(x, y)` are in physical pixels (same

View File

@@ -99,7 +99,7 @@ pub struct UiSurface<Msg: Clone>
widget_rects: Vec<LaidOutWidget<Msg>>,
cursor_state: HashMap<usize, usize>,
selection_anchor: HashMap<usize, usize>,
scroll_offsets: HashMap<usize, f32>,
scroll_offsets: HashMap<usize, ( f32, f32 )>,
scroll_canvases: HashMap<usize, Canvas>,
scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
content_dirty: bool,
@@ -400,6 +400,8 @@ impl<Msg: Clone> UiSurface<Msg>
scroll_canvases: std::mem::take( &mut self.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ),
previous_widget_rects: self.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 );

View File

@@ -234,6 +234,8 @@ mod tests
keyboard_focusable: true,
cursor: crate::types::CursorShape::Default,
tooltip: None,
accessible_label: None,
is_live_region: false,
}
}

View File

@@ -102,6 +102,8 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
@@ -134,6 +136,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
@@ -206,6 +209,8 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
@@ -231,6 +236,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;

View File

@@ -130,6 +130,8 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
keyboard_focusable: false,
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
tooltip: None,
accessible_label: None,
is_live_region: ctx.live_depth > 0,
} );
}
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
@@ -138,6 +140,8 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
{
let saved_alpha = canvas.global_alpha();
canvas.set_global_alpha( saved_alpha * c.opacity );
let live_inc = if c.a11y_live { 1 } else { 0 };
ctx.live_depth += live_inc;
let rect = if let Some( mw ) = c.max_width
{
@@ -190,25 +194,33 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
};
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
ctx.live_depth -= live_inc;
canvas.set_global_alpha( saved_alpha );
result
}
Element::Scroll( s ) =>
{
let my_idx = flat_idx;
let offset_raw = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( 0.0 );
let child_h = s.child.preferred_size( rect.width, canvas ).1;
let offset = crate::widget::scroll::clamp_offset( offset_raw, child_h, rect.height );
// Write the clamped offset back so input handlers (wheel /
let axis = s.axis;
let ( ox_raw, oy_raw ) = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( ( 0.0, 0.0 ) );
// Width-aware preferred size: when the axis allows horizontal
// overflow we let the child report its natural width by
// asking with `f32::INFINITY`; otherwise the child stays
// clamped to the viewport so wraps still happen.
let child_w_max = if axis.allows_x() { f32::INFINITY } else { rect.width };
let ( child_w, child_h ) = s.child.preferred_size( child_w_max, canvas );
let ox = if axis.allows_x() { crate::widget::scroll::clamp_offset( ox_raw, child_w, rect.width ) } else { 0.0 };
let oy = if axis.allows_y() { crate::widget::scroll::clamp_offset( oy_raw, child_h, rect.height ) } else { 0.0 };
// Write the clamped offsets back so input handlers (wheel /
// drag) cannot accumulate past the content extents. Without
// this, repeated wheel ticks past the bottom keep growing
// `offset_raw` and the user has to "undo" that excess before
// the content scrolls back up. The clamp is idempotent for
// in-range values, so the only effect is collapsing
// out-of-range entries to the legitimate maximum.
if offset != offset_raw
// this, repeated wheel ticks past the edge keep growing
// `*_raw` and the user has to "undo" that excess before the
// content scrolls back. The clamp is idempotent for in-range
// values, so the only effect is collapsing out-of-range
// entries to the legitimate maximum.
if ox != ox_raw || oy != oy_raw
{
ctx.scroll_offsets.insert( my_idx, offset );
ctx.scroll_offsets.insert( my_idx, ( ox, oy ) );
}
// Reuse the sub-canvas from the previous frame if its size matches;
@@ -220,13 +232,15 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
.unwrap_or_else( || canvas.sub_canvas( sw, sh ) );
sub.clear();
// Child content fills at least the full viewport height so that
// children with VAlign::Bottom are positioned correctly when the
// content is shorter than the viewport.
let effective_h = child_h.max( rect.height );
// Shift child up by offset so scrolled content appears at y=0 in
// the sub-canvas.
let child_rect = Rect { x: 0.0, y: -offset, width: rect.width, height: effective_h };
// Child content fills at least the full viewport on each
// allowed axis so that children with `Bottom`/`Right`
// alignment are positioned correctly when the content is
// shorter than the viewport along that axis.
let effective_w = if axis.allows_x() { child_w.max( rect.width ) } else { rect.width };
let effective_h = if axis.allows_y() { child_h.max( rect.height ) } else { rect.height };
// Shift child by `(-ox, -oy)` so scrolled content appears at
// `(0, 0)` of the sub-canvas.
let child_rect = Rect { x: -ox, y: -oy, width: effective_w, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
@@ -245,11 +259,11 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
// caring about which items are currently scrolled into view.
// The recorded Y is in pre-translation, pre-offset coordinates
// (i.e. `child_y` relative to the start of the scroll content),
// recovered by undoing the `-offset` shift the child layout pass
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + offset`.
// recovered by undoing the `-oy` shift the child layout pass
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + oy`.
let navigable: Vec<( usize, f32, f32 )> = new_rects.iter()
.filter( |w| w.handlers.is_navigable_list_item() )
.map( |w| ( w.flat_idx, w.rect.y + offset, w.rect.height ) )
.map( |w| ( w.flat_idx, w.rect.y + oy, w.rect.height ) )
.collect();
if !navigable.is_empty()
{
@@ -264,8 +278,12 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
w.paint_rect.x += rect.x;
w.paint_rect.y += rect.y;
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
// Only keep widgets at least partially visible inside the viewport.
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
// Only keep widgets at least partially visible inside the
// viewport — now bi-axial because Scroll::horizontal /
// Scroll::both push content off-screen along x too.
let visible_y = w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height;
let visible_x = w.rect.x + w.rect.width > rect.x && w.rect.x < rect.x + rect.width;
if visible_x && visible_y
{
ctx.widget_rects.push( w );
}
@@ -278,20 +296,20 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
// (0, 0) of the surface. Clamp to the outer scroll rect so
// the inner scroll only captures wheel events inside its
// visible region.
let new_scroll_rects: Vec<( Rect, usize )> =
let new_scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx ) in new_scroll_rects
for ( mut r, idx, ax ) in new_scroll_rects
{
r.x += rect.x;
r.y += rect.y;
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx ) );
ctx.scroll_rects.push( ( clamped, idx, ax ) );
}
}
ctx.scroll_rects.push( ( rect, my_idx ) );
ctx.scroll_rects.push( ( rect, my_idx, axis ) );
canvas.blit( &sub, rect.x as i32, rect.y as i32 );
@@ -330,16 +348,16 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
// Translate scroll_rects pushed by nested scroll widgets
// from sub-canvas to surface space. Same reasoning as the
// scroll-inside-scroll case above.
let new_scroll_rects: Vec<( Rect, usize )> =
let new_scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx ) in new_scroll_rects
for ( mut r, idx, ax ) in new_scroll_rects
{
r.x += rect.x;
r.y += rect.y;
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx ) );
ctx.scroll_rects.push( ( clamped, idx, ax ) );
}
}
@@ -380,8 +398,50 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
keyboard_focusable: other.is_focusable(),
cursor: other.cursor_shape(),
tooltip: other.tooltip().map( str::to_string ),
accessible_label: other.accessible_label(),
is_live_region: ctx.live_depth > 0,
} );
}
else
{
let live = ctx.live_depth > 0;
match other
{
Element::Text( t ) if !t.content.is_empty() =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: Some( t.content.clone() ), live,
kind: crate::a11y::tree::AccessibleExtraKind::Label,
} );
}
Element::Image( _ ) =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: None, live,
kind: crate::a11y::tree::AccessibleExtraKind::Image,
} );
}
Element::Separator( _ ) =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: None, live,
kind: crate::a11y::tree::AccessibleExtraKind::Separator,
} );
}
Element::ProgressBar( p ) =>
{
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
{
rect, label: None, live,
kind: crate::a11y::tree::AccessibleExtraKind::Progress( p.value ),
} );
}
_ => {}
}
}
flat_idx + 1
}
}

View File

@@ -17,8 +17,8 @@
//!
//! Each of those paths has a software variant (CPU + SHM pool) and a
//! GLES variant (FBO + EGL swap). The four resulting functions live in
//! [`software`] and [`gles`]; this file is just the router plus the
//! small shared setup ([`DrawCtx`], [`pick_shm_format`]).
//! [`software`] and [`gles`]; routing and surface-level orchestration
//! live in [`crate::event_loop::frame`].
//!
//! # Submodule layout
//!
@@ -30,15 +30,10 @@
//! * [`layout`] — `layout_and_draw` (the recursive element walker)
use std::collections::HashMap;
use std::sync::Arc;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use smithay_client_toolkit::shm::Shm;
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::{ Element, LaidOutWidget };
use crate::types::Rect;
use crate::widget::LaidOutWidget;
pub( crate ) mod software;
pub( crate ) mod gles;
@@ -49,23 +44,6 @@ pub( crate ) mod layout;
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
pub( crate ) use layout::layout_and_draw;
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
///
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems, so we can
/// copy with a plain memcpy. If the compositor doesn't advertise it, fall back
/// to Argb8888 (mandatory per wl_shm) which requires a per-channel swap.
///
/// Returns `(format, swap_rb)`.
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
{
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
{
( wl_shm::Format::Abgr8888, false )
} else {
( wl_shm::Format::Argb8888, true )
}
}
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures
/// the interaction snapshot (focus / hover / pressed), scratch space
/// for the widget-rect list the frame will produce, and the scroll
@@ -85,8 +63,8 @@ pub( crate ) struct DrawCtx<Msg: Clone>
pub selection_anchor: HashMap<usize, usize>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub debug_layout: bool,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<(Rect, usize)>,
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll navigation map: list of `(flat_idx, content_y, height)`
/// for every interactive item the scroll's child laid out, in
@@ -105,6 +83,11 @@ pub( crate ) struct DrawCtx<Msg: Clone>
/// re-position itself relative to that rect. Drivers populate this
/// before invoking the recursive layout / draw walk.
pub previous_widget_rects: Vec<LaidOutWidget<Msg>>,
/// Non-interactive widgets exposed only to the accessibility
/// tree (text labels, images, separators, progress bars).
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
/// Depth counter for containers marked `a11y_live`.
pub live_depth: u32,
}
/// Paint the built-in Copy / Cut / Paste context menu on top of the
@@ -172,156 +155,3 @@ pub( crate ) fn draw_context_menu(
canvas.draw_line( r.x + 8.0, *y, r.x + r.width - 8.0, *y, sep_color, 1.0 );
}
}
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
{
// Caches were refreshed by the run loop just before calling us; pull them
// by reference instead of re-invoking `App::view` / `App::overlays` each
// frame. The two `expect`s document the run loop's contract.
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
let main_bg = data.app.background_color();
let main_region = data.app.input_region();
let debug_layout = data.debug_layout;
let ( format, swap_rb ) = pick_shm_format( &data.shm );
let egl_ctx = data.egl_context.as_ref();
let qh = &data.qh;
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
{
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
draw_surface::<A::Message>(
&mut data.main,
&data.compositor_state,
egl_ctx,
main_view,
main_bg,
main_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
}
for spec in overlays
{
if let Some( ss ) = data.overlays.get_mut( &spec.id )
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let focus = SurfaceFocus::Overlay( spec.id );
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
draw_surface::<A::Message>(
ss,
&data.compositor_state,
egl_ctx,
&spec.view,
bg,
spec.input_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
ss.needs_redraw = false;
ss.last_draw = std::time::Instant::now();
}
}
}
/// Render one surface's current view. Picks between software and GLES,
/// and within each picks between full and partial redraw based on what
/// changed since the last committed frame.
///
/// The decision:
/// * **Skip** — `content_dirty` is false and no interaction state
/// changed. Previously committed buffer stays on screen.
/// * **Partial** — `content_dirty` is false but focus / hover / pressed
/// transitioned. Canvas is preserved across frames, so clip to the
/// dirty widgets and repaint only under the clip.
/// * **Full** — `content_dirty` is true. Clear + redraw + damage.
///
/// Partial eligibility also bails out when the total dirty area >50%
/// of the surface: at that ratio per-region clipping is no faster than
/// a plain full redraw, so the code prefers one big damage rect over
/// several small ones.
fn draw_surface<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &smithay_client_toolkit::compositor::CompositorState,
egl_ctx: Option<&Arc<crate::egl_context::EglContext>>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
request_frame: &dyn Fn( &WlSurface ),
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
let w = ss.width;
let h = ss.height;
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
// Decide partial-redraw eligibility BEFORE allocating a buffer. If we end
// up skipping the frame, we want to avoid touching the SHM pool at all.
let canvas_ready = ss.canvas.as_ref()
.map( |c| c.size() == ( pw, ph ) )
.unwrap_or( false );
let partial_eligible = !ss.content_dirty
&& canvas_ready
&& !ss.widget_rects.is_empty();
if partial_eligible
{
let dirty_rects = compute_interaction_dirty_rects(
&ss.widget_rects,
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
pw, ph,
);
if dirty_rects.is_empty()
{
// Nothing visible changed — keep the previously committed buffer.
return;
}
// Total dirty area > 50% of screen: a full redraw is no slower than
// per-region clipping but emits a single damage rect.
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
if total < pw as f32 * ph as f32 * 0.5
{
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
gles::draw_surface_partial_gpu(
ss, compositor, ctx, view, bg, input_region,
dirty_rects, pw, ph, scale, request_frame,
);
} else {
software::draw_surface_partial(
ss, compositor, view, bg, input_region,
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
);
}
return;
}
}
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
gles::draw_surface_full_gpu(
ss, compositor, ctx, view, bg, input_region, debug_layout,
pw, ph, scale, request_frame,
);
} else {
software::draw_surface_full(
ss, compositor, view, bg, input_region, debug_layout,
shm_format, swap_rb, pw, ph, scale, request_frame,
);
}
}

View File

@@ -99,6 +99,8 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
@@ -146,6 +148,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
@@ -236,6 +239,8 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
@@ -263,6 +268,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.accessible_extras = ctx.accessible_extras;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;

View File

@@ -80,6 +80,56 @@ pub struct AppData<A: App>
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// `xdg-activation-v1`. Present when the compositor advertises the
/// global. Used on first configure to honour an incoming
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
/// main surface raised to focus) and exposed via
/// [`App::request_activation_token`] for outbound requests.
pub activation_state:
Option<smithay_client_toolkit::activation::ActivationState>,
/// Honour the activation token exactly once — after the first
/// successful configure of the main surface. Subsequent configures
/// (resizes, scale changes) must not re-activate.
pub activation_token_pending: Option<String>,
/// `wl_data_device_manager` binding. `None` when the compositor
/// does not advertise the global, in which case copy / paste stays
/// process-local and inbound selections from other clients are
/// invisible.
pub data_device_manager:
Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
/// Per-seat `wl_data_device` handle, created the first time a seat
/// with a keyboard or pointer capability appears. Required for
/// `set_selection` and to receive inbound `selection` events.
pub data_device:
Option<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
/// Currently-published outbound selection source. Held alive
/// across [`DataSourceHandler::send_request`] so the cached
/// clipboard text can be re-served on each paste from the peer.
pub clipboard_source:
Option<smithay_client_toolkit::data_device_manager::data_source::CopyPasteSource>,
/// Sender half of the cross-thread channel used to ferry inbound
/// selection bytes (a worker thread drains the read pipe).
pub clipboard_inbox_tx: std::sync::mpsc::Sender<String>,
/// Receiver half — drained once per run loop iteration.
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
/// Cross-application drag-and-drop: best mime negotiated on
/// `enter` and the last pointer position (in logical surface
/// coords). Cleared on `leave` and after `drop_performed`.
pub drop_position: Option<( f64, f64 )>,
pub drop_mime: Option<String>,
pub drop_inbox_tx: std::sync::mpsc::Sender<super::data_device::DropPayload>,
pub drop_inbox_rx: std::sync::mpsc::Receiver<super::data_device::DropPayload>,
/// AccessKit / AT-SPI2 adapter. `None` when the platform adapter
/// could not be created (no AT-SPI2 daemon on the session bus,
/// missing system libraries at runtime, headless CI). The
/// runtime carries on without an accessibility tree in that
/// case; nothing else in the pipeline reads this field except
/// the per-frame tree update and the per-iteration action
/// inbox drain.
pub a11y: Option<crate::a11y::A11yState>,
/// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the
/// global proxy plus the live list of open toplevels; SCTK fans
/// events through this object into our `ForeignToplevelListHandler`
@@ -113,12 +163,13 @@ pub struct AppData<A: App>
/// pointer leaving the surface, a gesture cancel, focus loss or
/// long-press promotion.
pub button_repeat: Option<ButtonRepeatState>,
/// Process-local clipboard buffer. Copy / Cut store the selected
/// text here; Paste retrieves from here. Cross-application
/// clipboard via `wl_data_device_manager` is intentionally not
/// wired up — most app workflows need a same-process buffer first
/// (move text between fields, undo a delete by paste-back) and
/// the Wayland integration is a sizeable separate piece.
/// Clipboard buffer. Copy / Cut store the selected text here;
/// Paste retrieves from here. Synchronised with the Wayland
/// selection through `wl_data_device_manager` when the compositor
/// advertises the global: outbound copies publish a
/// `CopyPasteSource`, and inbound selections from other clients
/// land here through a worker thread (see
/// [`super::data_device`]).
pub clipboard: String,
/// Timestamp of the previous press (pointer or touch). Combined
/// with [`Self::last_press_pos`] it lets the press handler
@@ -222,6 +273,18 @@ impl<A: App> AppData<A>
}
}
/// Non-panicking variant of [`surface`]. Returns `None` when `focus`
/// refers to an overlay that has already been removed — callers on
/// async dispatch paths (IME Done, tooltip arm) must use this.
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message>>
{
match focus
{
SurfaceFocus::Main => Some( &self.main ),
SurfaceFocus::Overlay( id ) => self.overlays.get( &id ),
}
}
/// Mutable counterpart of [`surface`].
#[allow( dead_code )]
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message>
@@ -234,12 +297,90 @@ impl<A: App> AppData<A>
}
}
/// Non-panicking variant of [`surface_mut`].
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message>>
{
match focus
{
SurfaceFocus::Main => Some( &mut self.main ),
SurfaceFocus::Overlay( id ) => self.overlays.get_mut( &id ),
}
}
/// Synchronous overlay teardown: removes the overlay from the map
/// and rewrites every per-device focus that pointed at it so the
/// next event in the same dispatch can no longer land on a freed
/// surface. Used by the compositor-driven destruction paths
/// (`PopupHandler::done`, `LayerShellHandler::closed`) where
/// waiting for the next `reconcile_overlays` would leave a window
/// in which `surface()` / `surface_mut()` panic. Migrates an
/// in-flight long-press drag to the main surface for the same
/// reason `reconcile_overlays` does.
pub( crate ) fn discard_overlay( &mut self, id: crate::app::OverlayId )
{
if let Some( ss ) = self.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
self.main.gesture.long_press_fired = true;
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
{
if fid == id { self.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( fid ) = self.keyboard_focus
{
if fid == id { self.keyboard_focus = SurfaceFocus::Main; }
}
for f in self.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( fid ) = *f
{
if fid == id { *f = SurfaceFocus::Main; }
}
}
if let Some( pending ) = self.tooltip_pending.as_ref()
{
if pending.focus == SurfaceFocus::Overlay( id ) { self.tooltip_pending = None; }
}
if let Some( visible ) = self.tooltip_visible.as_ref()
{
if visible.focus == SurfaceFocus::Overlay( id )
{
self.tooltip_visible = None;
self.overlays_dirty = true;
}
}
}
// Configure the main surface size, (re)allocate its rendering target, and
// request a redraw. Routes to the GPU or SHM path inside `SurfaceState`
// according to whether `self.egl_context` is available.
pub( crate ) fn on_configure( &mut self, w: u32, h: u32 )
{
self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
if let Some( ref mut a ) = self.a11y
{
let sf = self.main.scale_factor.max( 1 ) as f64;
a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf );
a.set_window_focus( true );
}
// First-configure latch: honour an incoming
// `XDG_ACTIVATION_TOKEN` exactly once, after the surface has
// been mapped (`xdg_activation_v1.activate` is only meaningful
// against a configured surface). Compositors that don't
// advertise the global leave `activation_state` as `None` and
// the call drops the token silently.
if let Some( token ) = self.activation_token_pending.take()
{
if let ( Some( ref activation ), Some( wl ) ) =
( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
}
}
// `on_resize` is documented to deliver **physical** pixels, matching the
// coordinate space that the layout passes and the pointer/touch
// callbacks (`on_drag_move`, `on_drop`) work in. Wayland's

View File

@@ -15,6 +15,7 @@ impl<A: App> AppData<A>
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
self.publish_clipboard_selection();
}
}
@@ -26,6 +27,7 @@ impl<A: App> AppData<A>
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
self.publish_clipboard_selection();
}
if let Some( new_value ) = self.delete_selection( focus )
{

View File

@@ -0,0 +1,251 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `wl_data_device_manager` integration — cross-application
//! clipboard.
//!
//! This module bridges ltk's process-local `clipboard: String` to the
//! Wayland selection so that:
//!
//! * **Outbound (Ctrl+C / Cut)** — after the local clipboard is
//! populated, [`AppData::publish_clipboard_selection`] creates a
//! [`CopyPasteSource`] offering `text/plain;charset=utf-8` and
//! installs it as the seat's selection. When a peer pastes,
//! [`DataSourceHandler::send`] writes the cached string into the
//! fd the peer hands us.
//!
//! * **Inbound (Ctrl+V from another app)** — when the compositor
//! advertises a new selection through
//! [`DataDeviceHandler::selection`], we ask for it as UTF-8 text
//! via `WlDataOffer::receive`, spawn a tiny thread to drain the
//! read pipe, and post the result back to the event loop through
//! the runtime's existing `ChannelSender`-style channel. The
//! message just updates `self.clipboard`; subsequent local pastes
//! pick the new value up the next time the user hits Ctrl+V.
//!
//! Drag-and-drop across applications uses the same protocol but
//! involves enter / leave / motion / drop_performed wiring with
//! widget hit-tests, surface coordinates and drop targets that are a
//! separate concern. The handlers in this file accept those events
//! but currently route them to no-ops; an explicit follow-up will
//! cover DnD targets.
use std::io::Read;
use std::sync::mpsc;
use smithay_client_toolkit::data_device_manager::data_device::{ DataDeviceData, DataDeviceHandler };
use smithay_client_toolkit::data_device_manager::data_offer::{ DataOfferHandler, DragOffer, SelectionOffer };
use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler;
use smithay_client_toolkit::reexports::client::
{
protocol::
{
wl_data_device::WlDataDevice,
wl_data_device_manager::DndAction,
wl_data_source::WlDataSource,
wl_surface::WlSurface,
},
Connection, Proxy, QueueHandle,
};
use crate::app::App;
use super::app_data::AppData;
/// MIME type we advertise on outbound copies and request on inbound
/// pastes. `text/plain;charset=utf-8` is the de-facto cross-toolkit
/// baseline (GTK, Qt, electron, Firefox all carry it); `UTF8_STRING`
/// is the X11-era legacy alias still emitted by some compositors.
pub( super ) const CLIPBOARD_MIMES: &[ &str ] = &[ "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
/// Mimes accepted for inter-application drag-and-drop. URI lists
/// (file managers, browsers) come first because they are the most
/// useful payload for typical desktop drops.
pub( super ) const DND_MIMES: &[ &str ] = &[ "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
/// Payload delivered from the DnD worker thread back to the run loop.
pub( crate ) struct DropPayload
{
pub mime: String,
pub text: String,
pub x: f64,
pub y: f64,
}
pub( crate ) fn drop_inbox() -> ( std::sync::mpsc::Sender<DropPayload>, std::sync::mpsc::Receiver<DropPayload> )
{
std::sync::mpsc::channel()
}
impl<A: App> AppData<A>
{
/// Publish the current `self.clipboard` value as the seat's
/// selection so other applications can paste it. Called by the
/// copy / cut handlers after they populate the local buffer.
/// No-op when the compositor does not advertise
/// `wl_data_device_manager` or when no seat has appeared yet.
pub( crate ) fn publish_clipboard_selection( &mut self )
{
let ( Some( ref ddm ), Some( ref dd ) ) = ( self.data_device_manager.as_ref(), self.data_device.as_ref() ) else { return };
let source = ddm.create_copy_paste_source( &self.qh, CLIPBOARD_MIMES.iter().copied() );
source.set_selection( dd, self.last_input_serial );
// Hold the source alive: the compositor only keeps the
// selection while the `WlDataSource` is not dropped. We also
// need to hang on to it so `DataSourceHandler::send` can find
// the cached text via `self.clipboard`.
self.clipboard_source = Some( source );
}
}
impl<A: App> DataDeviceHandler for AppData<A>
{
fn enter(
&mut self,
_c: &Connection,
_qh: &QueueHandle<Self>,
d: &WlDataDevice,
x: f64,
y: f64,
_s: &WlSurface,
)
{
let Some( data ) = d.data::<DataDeviceData>() else { return };
let Some( offer ) = data.drag_offer() else { return };
let mimes = offer.with_mime_types( |m| m.to_vec() );
let accepted = DND_MIMES.iter().copied()
.find( |c| mimes.iter().any( |m| m == *c ) )
.map( |s| s.to_string() );
offer.accept_mime_type( self.last_input_serial, accepted.clone() );
offer.set_actions( smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy,
smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy );
self.drop_position = Some( ( x, y ) );
self.drop_mime = accepted;
self.app.on_drop_motion( x as f32, y as f32 );
}
fn leave( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice )
{
if self.drop_position.is_some() { self.app.on_drop_leave(); }
self.drop_position = None;
self.drop_mime = None;
}
fn motion( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice, x: f64, y: f64 )
{
self.drop_position = Some( ( x, y ) );
self.app.on_drop_motion( x as f32, y as f32 );
}
fn drop_performed( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, d: &WlDataDevice )
{
let Some( data ) = d.data::<DataDeviceData>() else { return };
let Some( offer ) = data.drag_offer() else { return };
let Some( mime ) = self.drop_mime.clone() else { offer.finish(); return };
let ( x, y ) = self.drop_position.unwrap_or( ( 0.0, 0.0 ) );
let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return };
offer.finish();
let tx = self.drop_inbox_tx.clone();
std::thread::spawn( move ||
{
use std::io::Read;
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
let mut buf = String::new();
let mut bounded = ( &mut reader ).take( 16 * 1024 * 1024 );
let _ = bounded.read_to_string( &mut buf );
let _ = tx.send( DropPayload { mime, text: buf, x, y } );
} );
self.drop_position = None;
self.drop_mime = None;
}
/// A new selection arrived from another client. Negotiate
/// UTF-8 text on a background thread (the receive side of the
/// data offer is a blocking read pipe) and post the result back
/// into the event loop through `clipboard_inbox`. The main loop
/// drains the inbox once per iteration and copies the bytes
/// into `self.clipboard`.
fn selection( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, data_device: &WlDataDevice )
{
let Some( data ) = data_device.data::<DataDeviceData>() else { return };
let Some( offer ) = data.selection_offer() else { return };
let Some( mime ) = pick_mime( &offer ) else { return };
let Ok( pipe ) = offer.receive( mime ) else { return };
let tx = self.clipboard_inbox_tx.clone();
std::thread::spawn( move ||
{
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
let mut buf = String::new();
// 16 MiB cap. Compositors are free to keep typing into the
// pipe forever; a runaway selection should not exhaust
// memory or stall the worker thread indefinitely.
let mut bounded = (&mut reader).take( 16 * 1024 * 1024 );
let _ = bounded.read_to_string( &mut buf );
let _ = tx.send( buf );
} );
}
}
impl<A: App> DataOfferHandler for AppData<A>
{
fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
}
impl<A: App> DataSourceHandler for AppData<A>
{
fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _mime: Option<String> ) {}
fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource, _mime: String, fd: smithay_client_toolkit::data_device_manager::WritePipe )
{
// Only honour requests for our own active source — if a stale
// source is still hanging around (e.g. between a new copy and
// the compositor dropping the previous selection) the peer
// might still ask for its data; we just write nothing rather
// than leak the new clipboard contents through an old source.
let is_active = self.clipboard_source.as_ref()
.map( |s| s.inner() == source )
.unwrap_or( false );
if !is_active { return; }
// SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File`
// (no buffering — selection payloads are small) and write the
// cached clipboard string. Errors (peer closed early, broken
// pipe) are ignored: we cannot recover and the compositor
// will issue a fresh source on the next copy anyway.
use std::io::Write;
let mut writer = std::fs::File::from( std::os::fd::OwnedFd::from( fd ) );
let _ = writer.write_all( self.clipboard.as_bytes() );
}
fn cancelled( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource )
{
// Compositor dropped our source (another client claimed the
// selection). Drop the cached handle so a future copy creates
// a fresh one.
if self.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false )
{
self.clipboard_source = None;
}
}
fn dnd_dropped( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
fn action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _action: DndAction ) {}
}
/// Pick the best-supported MIME type from a selection offer. Order
/// matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text, then
/// the legacy `UTF8_STRING`.
fn pick_mime( offer: &SelectionOffer ) -> Option<String>
{
let mimes = offer.with_mime_types( |m| m.to_vec() );
CLIPBOARD_MIMES.iter()
.find( |c| mimes.iter().any( |m| m == *c ) )
.map( |s| s.to_string() )
}
/// Buffered channel pair used to ferry inbound-selection bytes from
/// the read-pipe worker thread to the event loop. The receiver is
/// drained once per iteration inside the main run loop.
pub( crate ) fn clipboard_inbox() -> ( mpsc::Sender<String>, mpsc::Receiver<String> )
{
mpsc::channel()
}

158
src/event_loop/frame.rs Normal file
View File

@@ -0,0 +1,158 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use smithay_client_toolkit::shm::Shm;
use crate::app::App;
use crate::egl_context::EglContext;
use crate::types::{ Color, Rect };
use crate::widget::Element;
use super::AppData;
use super::surface::{ SurfaceFocus, SurfaceState };
use crate::draw::gles::{ draw_surface_full_gpu, draw_surface_partial_gpu };
use crate::draw::software::{ draw_surface_full, draw_surface_partial };
use crate::draw::damage::compute_interaction_dirty_rects;
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems (plain
/// memcpy). Falls back to mandatory Argb8888 when not advertised.
/// Returns `(format, swap_rb)`.
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
{
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
{
( wl_shm::Format::Abgr8888, false )
} else {
( wl_shm::Format::Argb8888, true )
}
}
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
{
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
let main_bg = data.app.background_color();
let main_region = data.app.input_region();
let debug_layout = data.debug_layout;
let ( format, swap_rb ) = pick_shm_format( &data.shm );
let egl_ctx = data.egl_context.as_ref();
let qh = &data.qh;
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
{
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
draw_surface::<A::Message>(
&mut data.main,
&data.compositor_state,
egl_ctx,
main_view,
main_bg,
main_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
}
for spec in overlays
{
if let Some( ss ) = data.overlays.get_mut( &spec.id )
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let focus = SurfaceFocus::Overlay( spec.id );
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
draw_surface::<A::Message>(
ss,
&data.compositor_state,
egl_ctx,
&spec.view,
bg,
spec.input_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
ss.needs_redraw = false;
ss.last_draw = std::time::Instant::now();
}
}
}
fn draw_surface<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: Option<&Arc<EglContext>>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
request_frame: &dyn Fn( &WlSurface ),
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
let w = ss.width;
let h = ss.height;
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
let canvas_ready = ss.canvas.as_ref()
.map( |c| c.size() == ( pw, ph ) )
.unwrap_or( false );
let partial_eligible = !ss.content_dirty
&& canvas_ready
&& !ss.widget_rects.is_empty();
if partial_eligible
{
let dirty_rects = compute_interaction_dirty_rects(
&ss.widget_rects,
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
pw, ph,
);
if dirty_rects.is_empty() { return; }
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
if total < pw as f32 * ph as f32 * 0.5
{
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
draw_surface_partial_gpu(
ss, compositor, ctx, view, bg, input_region,
dirty_rects, pw, ph, scale, request_frame,
);
} else {
draw_surface_partial(
ss, compositor, view, bg, input_region,
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
);
}
return;
}
}
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
draw_surface_full_gpu(
ss, compositor, ctx, view, bg, input_region, debug_layout,
pw, ph, scale, request_frame,
);
} else {
draw_surface_full(
ss, compositor, view, bg, input_region, debug_layout,
shm_format, swap_rb, pw, ph, scale, request_frame,
);
}
}

View File

@@ -143,10 +143,15 @@ impl<A: App> LayerShellHandler for AppData<A>
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
// Compositor asked us to destroy this overlay. Remove it;
// the next reconcile will not recreate it unless the app
// still returns its id from `App::overlays()`.
self.overlays.remove( &id );
// Compositor asked us to destroy this overlay. Drop it
// synchronously *and* rewrite every per-device focus that
// pointed at it — otherwise the next event in this same
// dispatch (touch up, IME done, pointer leave) lands on a
// freed surface and `surface()` / `surface_mut()` panic.
// `reconcile_overlays` will still run afterwards and skip
// re-creating the entry as long as the app stops returning
// its id from `overlays()`.
self.discard_overlay( id );
}
}
}
@@ -266,8 +271,23 @@ impl<A: App> SeatHandler for AppData<A>
}
fn new_seat(
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
) {}
&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: WlSeat,
)
{
// First seat → first data device. We only need one device
// regardless of how many seats appear (the typical desktop
// session has exactly one); additional seats reuse the same
// device-bound clipboard semantics by construction because
// `wl_data_device_manager.get_data_device` takes the seat as
// argument and the compositor manages routing.
if self.data_device.is_none()
{
if let Some( ref ddm ) = self.data_device_manager
{
self.data_device = Some( ddm.get_data_device( qh, &seat ) );
}
}
}
fn new_capability(
&mut self,
@@ -369,7 +389,9 @@ impl<A: App> PopupHandler for AppData<A>
{
self.pending_msgs.push( msg );
}
self.overlays.remove( &id );
// Synchronous teardown plus per-device focus cleanup. See
// `discard_overlay` for the panic case this prevents.
self.discard_overlay( id );
}
}
}
@@ -389,6 +411,27 @@ delegate_xdg_window!( @<A: App> AppData<A> );
delegate_xdg_popup!( @<A: App> AppData<A> );
delegate_foreign_toplevel_list!( @<A: App> AppData<A> );
delegate_registry!( @<A: App> AppData<A> );
smithay_client_toolkit::delegate_activation!( @<A: App> AppData<A> );
smithay_client_toolkit::delegate_data_device!( @<A: App> AppData<A> );
// --- `xdg-activation-v1` handler ---
//
// We only honour inbound activation here: when a token issued for our
// own request gets delivered, we activate the main surface. Outbound
// requests (so the app can pass a token to another app) are out of
// scope for now — adding them only requires a new public method on
// `AppData` and an extra trait method on `App`.
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
{
type RequestData = smithay_client_toolkit::activation::RequestData;
fn new_token( &mut self, token: String, _data: &Self::RequestData )
{
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
}
}
}
impl<A: App> ProvidesRegistryState for AppData<A>
{
@@ -581,16 +624,16 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
}
}
}
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, .. } =>
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } =>
{
for _ in 0..before_length
{
state.handle_backspace( focus );
}
state.handle_delete_surrounding( focus, before_length, after_length );
}
zwp_text_input_v3::Event::Done { .. } =>
{
state.surface_mut( focus ).request_redraw();
if let Some( ss ) = state.try_surface_mut( focus )
{
ss.request_redraw();
}
}
_ => {}
}

View File

@@ -3,6 +3,7 @@
pub( crate ) mod app_data;
pub( crate ) mod clipboard;
pub( crate ) mod data_device;
pub( crate ) mod context_menu;
pub( crate ) mod cursor_shape;
pub( crate ) mod drag;
@@ -14,6 +15,7 @@ pub( crate ) mod text_editing;
pub( crate ) mod tooltip;
pub( crate ) mod error;
pub( crate ) mod frame;
pub( crate ) mod run;
pub( crate ) mod invalidation;
pub( crate ) mod overlays_reconcile;

View File

@@ -26,11 +26,11 @@ use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use crate::app::{ App, InvalidationScope };
use crate::draw::draw_frame;
use crate::types::Point;
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use super::error::RunError;
use super::frame::draw_frame;
use super::invalidation::apply_invalidation;
use super::overlays_reconcile::reconcile_overlays;
@@ -184,6 +184,40 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
// wl_data_device_manager: optional. Required for cross-application
// copy/paste; absence means the clipboard stays process-local.
let data_device_manager =
smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok();
let ( clipboard_inbox_tx, clipboard_inbox_rx ) = super::data_device::clipboard_inbox();
let ( drop_inbox_tx, drop_inbox_rx ) = super::data_device::drop_inbox();
// AT-SPI2 / accessibility. `try_new` returns `None` when the
// platform adapter cannot be created (no daemon on the bus,
// headless CI, etc.) — the runtime then runs with no
// accessibility tree, which is the previous behaviour.
let a11y_app_name = "ltk-app";
let a11y_app_id = "net.liberux.ltk";
let a11y = crate::a11y::A11yState::try_new( a11y_app_name, a11y_app_id );
// xdg-activation-v1: optional. Compositors that don't carry the
// global leave `activation_state` as `None` and the inbound /
// outbound activation paths silently degrade to no-ops.
let activation_state =
smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok();
// `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an
// incoming activation token (the launcher that spawned us set it
// in the new process's environment). Honoured exactly once, after
// the main surface receives its first configure — that is the
// earliest moment we can call `xdg_activation_v1.activate`.
let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() );
// Avoid the token leaking into any child process the app spawns —
// it is single-use and would be invalid the second time anyway.
if activation_token_pending.is_some()
{
// SAFETY: removing an env var is sound only when no other thread
// is reading the environment concurrently. We are still in the
// init phase before `set_channel_sender`, so the app has had
// no opportunity to spawn worker threads yet.
unsafe { std::env::remove_var( "XDG_ACTIVATION_TOKEN" ); }
}
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
// bind + dispatch routing; absence of the global is fine, the inner
@@ -223,6 +257,18 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
current_cursor_shape: None,
text_input_manager,
text_input: None,
activation_state,
activation_token_pending,
data_device_manager,
data_device: None,
clipboard_source: None,
clipboard_inbox_tx,
clipboard_inbox_rx,
drop_position: None,
drop_mime: None,
drop_inbox_tx,
drop_inbox_rx,
a11y,
foreign_toplevel_list,
shift_pressed: false,
ctrl_pressed: false,
@@ -349,6 +395,19 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Drain any inbound clipboard payloads delivered by the
// data-device worker thread. Latest writer wins; the channel
// is unbounded but selection payloads are small (capped at
// 16 MiB inside the worker, see `data_device::DataDeviceHandler::selection`).
while let Ok( text ) = data.clipboard_inbox_rx.try_recv()
{
data.clipboard = text;
}
while let Ok( p ) = data.drop_inbox_rx.try_recv()
{
data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text );
}
// Process pending messages, folding their per-message invalidation
// scopes into a single decision before applying it.
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
@@ -437,6 +496,228 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
data.overlays_dirty = false;
}
draw_frame( &mut data );
// Push the freshly-laid-out widget tree to AccessKit. The
// closure only runs when an AT client is actually
// listening (the adapter no-ops `update_if_active`
// otherwise), so the build cost is paid on demand. Main
// surface only — overlays would require per-surface
// adapters, which the AT-SPI2 model is not built for.
let mut a11y_taken = data.a11y.take();
if let Some( ref mut a ) = a11y_taken
{
let main_w = data.main.width as f32;
let main_h = data.main.height as f32;
let mut overlay_meta: Vec<( u8, crate::app::OverlayId, f32, f32 )> = Vec::new();
let mut next_id: u8 = 1;
for ( id, ss ) in data.overlays.iter()
{
if next_id == 0 { break; }
let ( ox, oy ) = data.surface_offset_for( super::SurfaceFocus::Overlay( *id ) );
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; }
overlay_meta.push( ( next_id, *id, ox, oy ) );
next_id = next_id.saturating_add( 1 );
}
let kb_focus_id = match data.keyboard_focus
{
super::SurfaceFocus::Main => 0u8,
super::SurfaceFocus::Overlay( id ) =>
overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ),
};
let mut surfaces: Vec<crate::a11y::tree::SurfaceView<A::Message>> = Vec::new();
surfaces.push( crate::a11y::tree::SurfaceView
{
focus_id: 0,
is_main: true,
label: None,
widget_rects: &data.main.widget_rects,
extras: &data.main.accessible_extras,
focused_idx: data.main.focused_idx,
pressed_idx: data.main.gesture.pressed_idx,
pending_text_values: &data.main.pending_text_values,
cursor_state: &data.main.cursor_state,
width: main_w,
height: main_h,
offset_x: 0.0,
offset_y: 0.0,
} );
for ( fid, id, ox, oy ) in &overlay_meta
{
let Some( ss ) = data.overlays.get( id ) else { continue };
surfaces.push( crate::a11y::tree::SurfaceView
{
focus_id: *fid,
is_main: false,
label: None,
widget_rects: &ss.widget_rects,
extras: &ss.accessible_extras,
focused_idx: ss.focused_idx,
pressed_idx: ss.gesture.pressed_idx,
pending_text_values: &ss.pending_text_values,
cursor_state: &ss.cursor_state,
width: ss.width as f32,
height: ss.height as f32,
offset_x: *ox,
offset_y: *oy,
} );
}
let app_name = "ltk-app";
a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) );
}
data.a11y = a11y_taken;
}
// Drain inbound AT-SPI2 actions (Orca pressing a button,
// switch-control focusing a node, etc.) and translate each
// into a synthetic press / focus on the matching widget.
// Actions for widgets that disappeared between dispatch and
// drain are silently dropped — the action loop is best-effort.
let a11y_requests: Vec<accesskit::ActionRequest> =
if let Some( ref mut a ) = data.a11y { a.action_rx.try_iter().collect() } else { Vec::new() };
if !a11y_requests.is_empty()
{
let qh = data.qh.clone();
let overlay_keys: Vec<crate::app::OverlayId> = data.overlays.keys().copied().collect();
for req in a11y_requests
{
let Some( r ) = crate::a11y::tree::parse_id( req.target ) else { continue };
if r.kind != 0 { continue; }
let idx = r.idx as usize;
let focus_target = if r.focus == 0
{
SurfaceFocus::Main
}
else
{
let oi = ( r.focus as usize ).saturating_sub( 1 );
let Some( id ) = overlay_keys.get( oi ).copied() else { continue };
SurfaceFocus::Overlay( id )
};
let widget = match focus_target
{
SurfaceFocus::Main => data.main.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ),
};
match req.action
{
accesskit::Action::Click =>
{
if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() )
{
data.pending_msgs.push( msg );
}
}
accesskit::Action::Focus =>
{
data.set_focus( focus_target, Some( idx ), &qh );
}
accesskit::Action::SetValue =>
{
if let Some( w ) = widget
{
match ( &w.handlers, req.data )
{
( crate::widget::WidgetHandlers::Slider { .. }, Some( accesskit::ActionData::NumericValue( v ) ) ) =>
{
if let Some( msg ) = w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 )
{
data.pending_msgs.push( msg );
}
}
( crate::widget::WidgetHandlers::TextEdit { .. }, Some( accesskit::ActionData::Value( s ) ) ) =>
{
if let Some( msg ) = w.handlers.text_change_msg( &s )
{
data.pending_msgs.push( msg );
}
}
_ => {}
}
}
}
accesskit::Action::Increment | accesskit::Action::Decrement =>
{
if let Some( w ) = widget
{
if let crate::widget::WidgetHandlers::Slider { value, .. } = &w.handlers
{
let step = 0.05f32;
let delta = if matches!( req.action, accesskit::Action::Increment ) { step } else { -step };
let v = ( *value + delta ).clamp( 0.0, 1.0 );
if let Some( msg ) = w.handlers.slider_change_msg( v )
{
data.pending_msgs.push( msg );
}
}
}
}
accesskit::Action::ScrollUp | accesskit::Action::ScrollDown
| accesskit::Action::ScrollLeft | accesskit::Action::ScrollRight =>
{
const STEP: f32 = 80.0;
let ( dx, dy ) = match req.action
{
accesskit::Action::ScrollLeft => ( -STEP, 0.0 ),
accesskit::Action::ScrollRight => ( STEP, 0.0 ),
accesskit::Action::ScrollUp => ( 0.0, -STEP ),
accesskit::Action::ScrollDown => ( 0.0, STEP ),
_ => ( 0.0, 0.0 ),
};
let ss = match focus_target
{
SurfaceFocus::Main => Some( &mut data.main ),
SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
};
if let Some( ss ) = ss
{
if let Some( ( _, _, _ ) ) = ss.scroll_rects.last().copied()
{
let scroll_idx = ss.scroll_rects.last().unwrap().1;
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
entry.0 = ( entry.0 + dx ).max( 0.0 );
entry.1 = ( entry.1 + dy ).max( 0.0 );
ss.request_redraw();
}
}
}
accesskit::Action::ScrollIntoView =>
{
let Some( w ) = widget else { continue };
let ss = match focus_target
{
SurfaceFocus::Main => Some( &mut data.main ),
SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
};
if let Some( ss ) = ss
{
let target = w.rect;
let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 };
let container = ss.scroll_rects.iter().rev()
.find( |( r, _, _ )| r.contains( probe ) )
.copied();
if let Some( ( r, idx, _ ) ) = container
{
let entry = ss.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
if target.y < r.y { entry.1 -= r.y - target.y; }
if target.y + target.height > r.y + r.height
{
entry.1 += ( target.y + target.height ) - ( r.y + r.height );
}
if target.x < r.x { entry.0 -= r.x - target.x; }
if target.x + target.width > r.x + r.width
{
entry.0 += ( target.x + target.width ) - ( r.x + r.width );
}
entry.0 = entry.0.max( 0.0 );
entry.1 = entry.1.max( 0.0 );
ss.request_redraw();
}
}
}
_ => {}
}
}
}
// Focus the widget with the requested WidgetId if the app requests it.

View File

@@ -178,8 +178,14 @@ pub( crate ) struct SurfaceState<Msg: Clone>
/// cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
pub pending_text_values: HashMap<usize, String>,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<( Rect, usize )>,
/// Per-scroll-viewport offset, indexed by `flat_idx` of the
/// `Scroll` widget. Tuple is `(x, y)` in physical pixels: x is
/// applied only when the widget's axis allows horizontal scroll
/// (and stays at `0` otherwise), y mirrors the historic single-f32
/// behaviour for vertical scrolls.
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
/// every interactive item the scroll's child laid out, in document
@@ -197,6 +203,18 @@ pub( crate ) struct SurfaceState<Msg: Clone>
/// (`src/input/`) calls the lifecycle methods; the long-press
/// deadline poller reaches into `gesture.long_press_*` directly.
pub gesture: GestureState<Msg>,
/// `wl_touch.id` of the finger currently driving the single-slot
/// gesture machine. `None` when no finger is down on this surface.
/// Subsequent fingers landing while this is `Some` route through
/// the auxiliary multi-touch path ([`App::on_touch_down`] etc.)
/// instead of being absorbed into the primary slot.
pub primary_touch_id: Option<i32>,
/// Auxiliary touch slots: every non-primary finger's last known
/// position, keyed by `wl_touch.id`. Used by the touch handler to
/// generate `on_touch_up` with the correct release point (Wayland
/// `wl_touch.up` does not carry a position) and to keep state
/// across `motion` events between dispatchers.
pub touch_slots: HashMap<i32, crate::types::Point>,
/// Previous frame interaction state for damage tracking
pub prev_focused: Option<usize>,
pub prev_hovered: Option<usize>,
@@ -253,9 +271,12 @@ impl<Msg: Clone> SurfaceState<Msg>
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
pending_text_values: HashMap::new(),
accessible_extras: Vec::new(),
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_navigable_items: HashMap::new(),
primary_touch_id: None,
touch_slots: HashMap::new(),
context_menu: None,
scroll_canvases: HashMap::new(),
gesture: GestureState::new(),
@@ -311,11 +332,20 @@ impl<Msg: Clone> SurfaceState<Msg>
h: u32,
)
{
self.width = w;
self.height = h;
let scale = self.scale_factor.max( 1 ) as u32;
let pw = w * scale;
let ph = h * scale;
let scale = self.scale_factor.max( 1 ) as u32;
let pw = w.saturating_mul( scale );
let ph = h.saturating_mul( scale );
let pool_bytes = (pw as u64)
.saturating_mul( ph as u64 )
.saturating_mul( 4 );
const MAX_POOL: u64 = 256 * 1024 * 1024;
if pool_bytes > MAX_POOL || pw == 0 || ph == 0
{
eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" );
return;
}
let pool_bytes = pool_bytes as usize;
if self.egl_surface.is_some()
{
@@ -329,9 +359,15 @@ impl<Msg: Clone> SurfaceState<Msg>
}
} else if self.pool.is_some() {
// SHM path: reallocate the pool to fit the new buffer size.
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
match SlotPool::new( pool_bytes, shm )
{
Ok( pool ) => self.pool = Some( pool ),
Err( e ) =>
{
eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" );
return;
}
}
} else {
// First configure — pick the path. Try GPU when the bootstrap
// succeeded; fall back to SHM if surface creation fails.
@@ -353,12 +389,20 @@ impl<Msg: Clone> SurfaceState<Msg>
}
if !chose_gpu
{
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
match SlotPool::new( pool_bytes, shm )
{
Ok( pool ) => self.pool = Some( pool ),
Err( e ) =>
{
eprintln!( "[ltk] SlotPool init failed: {e}" );
return;
}
}
}
}
self.width = w;
self.height = h;
self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
self.configured = true;
self.needs_redraw = true;

View File

@@ -130,6 +130,87 @@ impl<A: App> AppData<A>
}
}
/// Respond to `zwp_text_input_v3.delete_surrounding_text`. Both
/// arguments are **UTF-8 byte counts** measured from the cursor.
/// IMEs (Mozc, Anthy, IBus-pinyin) emit this routinely to commit a
/// preedit on top of pre-existing surrounding text. The previous
/// implementation iterated `before_length` calls to `handle_backspace`,
/// treating the byte count as a number of characters — corrupting
/// any non-ASCII input. This method does a single bytes-aware
/// replace_range that snaps to UTF-8 char boundaries.
pub( crate ) fn handle_delete_surrounding( &mut self, focus: SurfaceFocus, before_bytes: u32, after_bytes: u32 )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
let _ = self.delete_selection( focus );
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( current_value.len() );
let safe_cursor = cursor_val.min( current_value.len() );
// `before_bytes` of UTF-8 walked back from the cursor, snapping
// to char boundaries — if the byte count lands mid-codepoint we
// expand outward to swallow the whole codepoint (matches what
// GTK / Qt do under the same protocol).
let start_byte =
{
let mut pos = safe_cursor;
for ( i, _ ) in current_value[..safe_cursor].char_indices().rev()
{
if ( safe_cursor - pos ) as u32 >= before_bytes { break; }
pos = i;
}
pos
};
let end_byte =
{
let mut pos = safe_cursor;
for ( i, ch ) in current_value[safe_cursor..].char_indices()
{
if ( pos - safe_cursor ) as u32 >= after_bytes { break; }
pos = safe_cursor + i + ch.len_utf8();
}
pos
};
if start_byte >= end_byte { return; }
let mut new_value = current_value.clone();
new_value.replace_range( start_byte..end_byte, "" );
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { start_byte };
{
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
pub( crate ) fn handle_backspace( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };

View File

@@ -30,7 +30,7 @@ impl<A: App> AppData<A>
{
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
{
let ss = self.surface( focus );
let Some( ss ) = self.try_surface( focus ) else { return };
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
let Some( text ) = w.tooltip.clone() else
{
@@ -89,7 +89,7 @@ impl<A: App> AppData<A>
{
let v = self.tooltip_visible.as_ref()?;
let ( ox, oy ) = self.surface_offset_for( v.focus );
let scale = self.surface( v.focus ).scale_factor as f32;
let scale = self.try_surface( v.focus )?.scale_factor as f32;
let anchor_x = ox + v.anchor.x / scale;
let anchor_y = oy + v.anchor.y / scale;
let anchor_w = v.anchor.width / scale;

View File

@@ -153,53 +153,14 @@ pub( super ) unsafe fn alloc_fbo_tex( gl: &glow::Context, version: GlesVersion,
}
}
pub( super ) fn upload_alpha_texture( gl: &glow::Context, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
// SAFETY: caller's GL context is current. Caller is responsible for the
// `data.len() == w * h` invariant — `upload_alpha_texture` is only called
// from the glyph atlas path on bitmaps fontdue produced at the same
// dimensions. UNPACK_ALIGNMENT is set to 1 for the upload (1 byte/pixel)
// and restored to the GL default of 4 immediately after, so subsequent
// uploads are not affected.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
// NEAREST, not LINEAR. Glyph atlases are drawn 1:1 with their bitmap
// (dest size = texture size, integer-aligned position). Mathematically
// LINEAR at an exact texel center collapses to the texel value, but
// mediump precision in the fragment shader (and the `1 - v_uv.y` flip)
// can drift the sample point a fraction of a texel off-center, and the
// LINEAR filter then blends with neighbours — visible as soft, washed
// stems, especially at small sizes. NEAREST snaps to the correct texel
// every time, matching the software path's pixel-perfect bitmap copy.
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
// GL_LUMINANCE is 1 byte/pixel; default UNPACK_ALIGNMENT (4) misreads
// any row whose width is not a multiple of 4, scrambling those glyphs.
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, glow::LUMINANCE as i32,
w, h, 0, glow::LUMINANCE, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( data ) ),
);
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &str ) -> glow::Program
{
// SAFETY: caller's GL context is current. The shaders are compiled and
// linked inside this block; on success `program` is a fresh, fully linked
// GL program object. Vertex / fragment shaders are released with
// `delete_shader` after attach + link — they are flagged for deletion and
// freed when the program is deleted, which is the canonical pattern.
// Compile / link asserts panic with the driver's info log instead of
// returning a half-broken program (callers cannot recover anyway).
compile_program_with_attribs( gl, vert_src, frag_src, &[ ( 0, "a_pos" ) ] )
}
pub( super ) fn compile_program_with_attribs( gl: &glow::Context, vert_src: &str, frag_src: &str, attribs: &[ ( u32, &str ) ] ) -> glow::Program
{
unsafe
{
let program = gl.create_program().unwrap();
@@ -215,7 +176,10 @@ pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &
gl.attach_shader( program, vs );
gl.attach_shader( program, fs );
gl.bind_attrib_location( program, 0, "a_pos" );
for ( loc, name ) in attribs
{
gl.bind_attrib_location( program, *loc, name );
}
gl.link_program( program );
assert!( gl.get_program_link_status( program ), "Link: {}", gl.get_program_info_log( program ) );

View File

@@ -29,7 +29,7 @@
//! * `primitives` — `GlesCanvas::{fill_rect, fill_linear_gradient_rect,
//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset,
//! stroke_rect, draw_line}`.
//! * `text` — `GlesCanvas::{draw_text, measure_text, draw_glyph_texture}`.
//! * `text` — `GlesCanvas::{draw_text, measure_text}`.
//! * `image` — `GlesCanvas::draw_image_data`.
//! * `shaders` — GLSL ES 1.00 shader sources (const strings).
//! * `helpers` — free functions: `ortho_rect`, `compile_program`,
@@ -101,15 +101,25 @@ pub struct BorrowedGlesTexture
pub y_inverted: bool,
}
/// Cached glyph: pre-rasterized bitmap uploaded as a GL texture.
/// Cached glyph: rasterized bitmap placed in the shared atlas.
pub ( super ) struct GlyphEntry
{
pub ( super ) texture: glow::Texture,
pub ( super ) metrics: fontdue::Metrics,
pub ( super ) tex_w: i32,
pub ( super ) tex_h: i32,
pub ( super ) atlas_x: u32,
pub ( super ) atlas_y: u32,
}
/// Cache key for the GLES glyph atlas — the GPU-side mirror of the
/// software canvas's `GlyphKey`. `glyph_id` is the per-font index
/// returned by HarfBuzz shaping; `size_bits` is `f32::to_bits` of
/// `size * dpi_scale`; `font_id` is the address of the
/// `Arc<fontdue::Font>` used for rasterisation.
pub( super ) type GlyphAtlasKey = ( u16, u32, usize );
pub( super ) const ATLAS_SIZE: u32 = 2048;
// ─── GlesCanvas ──────────────────────────────────────────────────────────────
/// GPU-accelerated canvas using EGL + GLES2/3.
@@ -127,6 +137,13 @@ pub struct GlesCanvas
/// Kept as a fallback for callers that do not route through the
/// theme registry.
pub font: Arc<Font>,
/// Raw bytes of the default font. Required by rustybuzz for
/// HarfBuzz shaping (see [`crate::text_shaping`]). Kept on the
/// canvas so the shape pipeline has direct access without a
/// global lookup.
pub font_bytes: Arc<Vec<u8>>,
/// TTC sub-face index for the default font (0 for non-`.ttc` files).
pub font_face: u32,
/// Optional theme font registry. Populated by the runtime after
/// theme load; until then it is `None` and [`Self::font_for`]
/// falls back to [`Self::font`].
@@ -167,10 +184,19 @@ pub struct GlesCanvas
u_tex_sampler: glow::UniformLocation,
// Uniform locations for glyph shader
u_glyph_mvp: glow::UniformLocation,
u_glyph_color: glow::UniformLocation,
u_glyph_opacity: glow::UniformLocation,
u_glyph_sampler: glow::UniformLocation,
u_glyph_mvp: glow::UniformLocation,
u_glyph_color: glow::UniformLocation,
u_glyph_opacity: glow::UniformLocation,
u_glyph_sampler: glow::UniformLocation,
u_glyph_uv_offset: glow::UniformLocation,
u_glyph_uv_scale: glow::UniformLocation,
glyph_batch_program: glow::Program,
u_glyph_batch_color: glow::UniformLocation,
u_glyph_batch_opacity: glow::UniformLocation,
u_glyph_batch_sampler: glow::UniformLocation,
pub ( super ) glyph_batch_vao: glow::VertexArray,
pub ( super ) glyph_batch_vbo: glow::Buffer,
// Uniform location for blit shader
u_blit_sampler: glow::UniformLocation,
@@ -289,11 +315,23 @@ pub struct GlesCanvas
u_bd_fc_radii: glow::UniformLocation,
u_bd_fc_tint: glow::UniformLocation,
// Glyph cache: (char, size_key, font_id) → GlyphEntry. The
// `font_id` is the address of the `Arc<Font>` used for the
// rasterisation, so distinct weights / families of the same
// (char, size) keep separate atlas entries.
glyph_cache: HashMap<(char, u32, usize), GlyphEntry>,
atlas_texture: glow::Texture,
/// Upload format of `atlas_texture`. On GLES3 we use the modern
/// single-channel `GL_RED` over `GL_R8`; on GLES2 we keep the
/// legacy `GL_LUMINANCE` because `GL_R8` / `GL_RED` did not exist
/// before ES3. The fragment shader samples `.r` and is identical
/// for both — `GL_LUMINANCE` replicates the single channel into
/// `.r=.g=.b`, so `.r` carries the coverage either way.
pub ( super ) atlas_format: u32,
atlas_cursor_x: u32,
atlas_cursor_y: u32,
atlas_row_height: u32,
// (glyph_id, size_key, font_id) → GlyphEntry. `glyph_id` is the
// per-font glyph index returned by HarfBuzz shaping; the cache
// therefore persists Arabic / Devanagari / CJK shaped forms
// independently of the source codepoints.
glyph_cache: HashMap<GlyphAtlasKey, GlyphEntry>,
// Reusable texture cache for images. Keyed by
// `(width, height, content fingerprint)` rather than the source
@@ -370,10 +408,8 @@ impl Drop for GlesCanvas
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
for ( _, entry ) in self.glyph_cache.drain()
{
self.gl.delete_texture( entry.texture );
}
self.glyph_cache.clear();
self.gl.delete_texture( self.atlas_texture );
for ( _, ( tex, _, _ ) ) in self.image_cache.drain()
{
self.gl.delete_texture( tex );

View File

@@ -19,13 +19,13 @@ use glow::HasContext;
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, load_default_font_bytes };
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, compile_program_with_attribs, load_default_font_bytes };
use super::shaders::
{
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
BACKDROP_FAST_BLUR_H_FRAG_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC,
BLIT_FRAG_SRC, BLIT_VERT_SRC,
GLYPH_FRAG_SRC,
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_FRAG_SRC,
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
@@ -39,28 +39,37 @@ use super::{ GlesCanvas, GlesVersion };
/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …)
/// is owned by the crate-private system-fonts module and loaded
/// lazily per codepoint, not per canvas.
static DEFAULT_FONT_GLES: OnceLock<Arc<Font>> = OnceLock::new();
static DEFAULT_FONT_GLES: OnceLock<crate::system_fonts::FontHandle> = OnceLock::new();
fn default_font_gles() -> Arc<Font>
fn default_handle_gles() -> crate::system_fonts::FontHandle
{
Arc::clone( DEFAULT_FONT_GLES.get_or_init( ||
DEFAULT_FONT_GLES.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
Arc::new( font )
} ) )
crate::system_fonts::FontHandle
{
font: Arc::new( font ),
bytes: Arc::new( bytes ),
face: 0,
}
} ).clone()
}
impl GlesCanvas
{
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
{
let font = default_font_gles();
let font_handle = default_handle_gles();
let font = font_handle.font.clone();
let font_bytes = font_handle.bytes.clone();
let font_face = font_handle.face;
let rect_program = compile_program( &gl, VERT_SRC, RECT_FRAG_SRC );
let tex_program = compile_program( &gl, VERT_SRC, TEX_FRAG_SRC );
let glyph_program = compile_program( &gl, VERT_SRC, GLYPH_FRAG_SRC );
let glyph_batch_program = compile_program_with_attribs( &gl, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC, &[ ( 0, "a_pos" ), ( 1, "a_uv" ) ] );
let blit_program = compile_program( &gl, BLIT_VERT_SRC, BLIT_FRAG_SRC );
let sub_blit_program = compile_program( &gl, VERT_SRC, SUB_BLIT_FRAG_SRC );
let linear_gradient_program = compile_program( &gl, VERT_SRC, LINEAR_GRADIENT_FRAG_SRC );
@@ -83,6 +92,7 @@ impl GlesCanvas
u_rect_mvp, u_rect_color, u_rect_size, u_rect_radii, u_rect_stroke, u_rect_pad,
u_tex_mvp, u_tex_opacity, u_tex_sampler,
u_glyph_mvp, u_glyph_color, u_glyph_opacity, u_glyph_sampler,
u_glyph_uv_offset, u_glyph_uv_scale,
u_blit_sampler,
u_subblit_mvp, u_subblit_sampler, u_subblit_opacity, u_subblit_fade_bottom, u_subblit_height_px,
u_lingrad_mvp, u_lingrad_lut, u_lingrad_dir, u_lingrad_size, u_lingrad_line_length,
@@ -114,7 +124,9 @@ impl GlesCanvas
gl.get_uniform_location( glyph_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_color" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_uv_offset" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_uv_scale" ).unwrap(),
gl.get_uniform_location( blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_sampler" ).unwrap(),
@@ -192,6 +204,27 @@ impl GlesCanvas
gl.get_uniform_location( backdrop_fast_composite_program, "u_tint" ).unwrap(),
)};
let ( u_glyph_batch_color, u_glyph_batch_opacity, u_glyph_batch_sampler ) = unsafe
{(
gl.get_uniform_location( glyph_batch_program, "u_color" ).unwrap(),
gl.get_uniform_location( glyph_batch_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
)};
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
{
let vao = gl.create_vertex_array().unwrap();
let vbo = gl.create_buffer().unwrap();
gl.bind_vertex_array( Some( vao ) );
gl.bind_buffer( glow::ARRAY_BUFFER, Some( vbo ) );
gl.enable_vertex_attrib_array( 0 );
gl.vertex_attrib_pointer_f32( 0, 2, glow::FLOAT, false, 16, 0 );
gl.enable_vertex_attrib_array( 1 );
gl.vertex_attrib_pointer_f32( 1, 2, glow::FLOAT, false, 16, 8 );
gl.bind_vertex_array( None );
( vao, vbo )
};
let quad_vertices: [f32; 12] = [
0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
@@ -265,11 +298,46 @@ impl GlesCanvas
( fbo, fbo_tex )
};
// Atlas format picked per ES profile: GL_R8 / GL_RED on ES3
// (GL_LUMINANCE is deprecated in ES3 core and some mobile drivers
// — Mali, certain Adreno — return GL_INVALID_ENUM for it), legacy
// GL_LUMINANCE on ES2 where the modern single-channel formats do
// not exist. The glyph fragment shader samples `.r` and works for
// both: GL_RED puts the byte in .r directly, GL_LUMINANCE
// replicates it into .r=.g=.b, so reading `.r` gives the same
// coverage value either way.
let ( atlas_internal, atlas_format ) = match version
{
GlesVersion::V3 => ( glow::R8 as i32, glow::RED ),
GlesVersion::V2 => ( glow::LUMINANCE as i32, glow::LUMINANCE ),
};
let atlas_texture = unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, atlas_internal,
super::ATLAS_SIZE as i32, super::ATLAS_SIZE as i32, 0,
atlas_format, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( None ),
);
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
};
Self
{
gl,
version,
font,
font_bytes,
font_face,
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
@@ -300,6 +368,14 @@ impl GlesCanvas
u_glyph_color,
u_glyph_opacity,
u_glyph_sampler,
u_glyph_uv_offset,
u_glyph_uv_scale,
glyph_batch_program,
u_glyph_batch_color,
u_glyph_batch_opacity,
u_glyph_batch_sampler,
glyph_batch_vao,
glyph_batch_vbo,
u_blit_sampler,
u_subblit_mvp,
u_subblit_sampler,
@@ -379,6 +455,11 @@ impl GlesCanvas
u_bd_fc_padding,
u_bd_fc_radii,
u_bd_fc_tint,
atlas_texture,
atlas_format,
atlas_cursor_x: 0,
atlas_cursor_y: 0,
atlas_row_height: 0,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
@@ -425,11 +506,39 @@ impl GlesCanvas
( fbo, fbo_tex )
};
let atlas_format = self.atlas_format;
let atlas_internal = match self.version
{
GlesVersion::V3 => glow::R8 as i32,
GlesVersion::V2 => glow::LUMINANCE as i32,
};
let atlas_texture = unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, atlas_internal,
super::ATLAS_SIZE as i32, super::ATLAS_SIZE as i32, 0,
atlas_format, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( None ),
);
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
};
GlesCanvas
{
gl,
version: self.version,
font: Arc::clone( &self.font ),
font_bytes: Arc::clone( &self.font_bytes ),
font_face: self.font_face,
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
@@ -460,10 +569,18 @@ impl GlesCanvas
u_tex_mvp: self.u_tex_mvp,
u_tex_opacity: self.u_tex_opacity,
u_tex_sampler: self.u_tex_sampler,
u_glyph_mvp: self.u_glyph_mvp,
u_glyph_color: self.u_glyph_color,
u_glyph_opacity: self.u_glyph_opacity,
u_glyph_sampler: self.u_glyph_sampler,
u_glyph_mvp: self.u_glyph_mvp,
u_glyph_color: self.u_glyph_color,
u_glyph_opacity: self.u_glyph_opacity,
u_glyph_sampler: self.u_glyph_sampler,
u_glyph_uv_offset: self.u_glyph_uv_offset,
u_glyph_uv_scale: self.u_glyph_uv_scale,
glyph_batch_program: self.glyph_batch_program,
u_glyph_batch_color: self.u_glyph_batch_color,
u_glyph_batch_opacity: self.u_glyph_batch_opacity,
u_glyph_batch_sampler: self.u_glyph_batch_sampler,
glyph_batch_vao: self.glyph_batch_vao,
glyph_batch_vbo: self.glyph_batch_vbo,
u_blit_sampler: self.u_blit_sampler,
u_subblit_mvp: self.u_subblit_mvp,
u_subblit_sampler: self.u_subblit_sampler,
@@ -539,6 +656,11 @@ impl GlesCanvas
u_bd_fc_padding: self.u_bd_fc_padding,
u_bd_fc_radii: self.u_bd_fc_radii,
u_bd_fc_tint: self.u_bd_fc_tint,
atlas_texture,
atlas_format,
atlas_cursor_x: 0,
atlas_cursor_y: 0,
atlas_row_height: 0,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),

View File

@@ -147,24 +147,60 @@ void main()
}
"##;
// Fragment shader for single-channel glyph textures with color tint. Same
// Y-flip rationale as `TEX_FRAG_SRC`. The texture is `GL_LUMINANCE`, which
// replicates the uploaded byte into `.r`, `.g`, `.b` (with `.a = 1`), so the
// coverage value is read from `.r`. We deliberately avoid `GL_ALPHA` here:
// some Mesa GLES3 paths handle the legacy alpha-only format inconsistently
// (sampled `.a` returns near-zero in glyph interiors, leaving only the
// antialias edges visible text appears as thin faded outlines instead of
// solid strokes). `LUMINANCE` is also a legacy format but its mapping to
// `.r=.g=.b=data` is well-supported across ES2/ES3 drivers.
// Fragment shader for single-channel glyph textures with color tint.
//
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`
// map the unit quad's (0,0)(1,1) UV range into the glyph's sub-region:
// tex_uv = vec2(v_uv.x, 1 - v_uv.y) * u_uv_scale + u_uv_offset
// The Y-flip (`1 - v_uv.y`) corrects for fontdue bitmaps being stored
// top-row-first while GL tex coords have V=0 at the bottom.
//
// The atlas is GL_R8 on ES3 and GL_LUMINANCE on ES2 (see
// `gles_render/text.rs` module doc and `GlesCanvas::atlas_format`).
// Both formats put the coverage byte in `.r` when sampled, so this
// shader is identical for both.
pub( super ) const GLYPH_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform vec4 u_color;
uniform float u_opacity;
uniform vec2 u_uv_offset;
uniform vec2 u_uv_scale;
void main()
{
vec2 uv = vec2(v_uv.x, 1.0 - v_uv.y) * u_uv_scale + u_uv_offset;
float coverage = texture2D(u_sampler, uv).r;
float a = u_color.a * coverage * u_opacity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Batched glyph shader. `a_pos` is pre-transformed into NDC by the
// CPU side (we know the surface size) so the shader has no MVP to
// apply — that lets us upload one VBO and draw every glyph of the
// text run in a single `glDrawArrays`. `a_uv` carries the per-vertex
// atlas coordinate ready to sample.
pub( super ) const GLYPH_BATCH_VERT_SRC: &str = r#"
attribute vec2 a_pos;
attribute vec2 a_uv;
varying vec2 v_uv;
void main()
{
v_uv = a_uv;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
"#;
pub( super ) const GLYPH_BATCH_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform vec4 u_color;
uniform float u_opacity;
void main()
{
float coverage = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)).r;
float coverage = texture2D(u_sampler, v_uv).r;
float a = u_color.a * coverage * u_opacity;
gl_FragColor = vec4(u_color.rgb * a, a);
}

View File

@@ -1,27 +1,29 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`GlesCanvas`]: glyph-atlas cache + per-glyph
//! draw call. Each glyph is rasterised once via fontdue, uploaded as
//! a one-off `GL_LUMINANCE` texture, and stored on the canvas; the
//! per-frame hot path picks positions and issues one draw call per
//! glyph.
//! Text rendering for [`GlesCanvas`]: shelf-packed glyph atlas +
//! per-glyph draw call. The line is shaped through
//! [`crate::text_shaping::shape_line`] (Unicode BiDi + per-sub-run
//! rustybuzz / HarfBuzz) and each shaped glyph is rasterised by
//! glyph index via `fontdue::Font::rasterize_indexed`, so the cache
//! is keyed on `(glyph_id, size_bits, font_id)` and Arabic
//! connected forms / Devanagari clusters / CJK shaped glyphs are
//! cached correctly without colliding with the source codepoints.
//!
//! `GL_LUMINANCE` is deliberate — `GL_ALPHA` has inconsistent
//! handling across Mesa GLES3 drivers (sampled `.a` returns near-zero
//! in glyph interiors, leaving only antialias edges visible), and
//! luminance's `.r = .g = .b = data` mapping is well-supported
//! across ES2/ES3.
//! The atlas format is picked per ES profile
//! ([`GlesCanvas::atlas_format`]): `GL_R8` / `GL_RED` on ES3,
//! `GL_LUMINANCE` on ES2. The fragment shader samples `.r` and is
//! identical for both — `GL_RED` puts the coverage byte in `.r`
//! directly, `GL_LUMINANCE` replicates it into `.r=.g=.b`.
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::types::{ Color, Rect };
use crate::types::Color;
use super::helpers::{ ortho_rect, upload_alpha_texture };
use super::{ GlesCanvas, GlyphEntry };
use super::{ ATLAS_SIZE, GlesCanvas, GlyphEntry };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
@@ -37,9 +39,6 @@ impl GlesCanvas
self.draw_text_inner( text, x, y, size, color, None );
}
/// Draw `text` using `font` instead of the canvas default + lazy
/// system-font fallback chain. Glyphs from this font live under
/// their own atlas keys.
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
{
self.draw_text_inner( text, x, y, size, color, Some( font ) );
@@ -48,129 +47,330 @@ impl GlesCanvas
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
{
self.activate_target();
let scaled = size * self.dpi_scale;
let mut cursor_x = x;
let scaled = size * self.dpi_scale;
let size_key = ( scaled * 10.0 ) as u32;
for ch in text.chars()
unsafe
{
let size_key = (scaled * 10.0) as u32;
let ( id, font_arc ): ( usize, Arc<Font> ) = match font
{
Some( f ) =>
{
if f.lookup_glyph_index( ch ) != 0
{
( font_id( f ), Arc::clone( f ) )
}
else
{
self.font_id_for_char( ch )
}
}
None => self.font_id_for_char( ch ),
};
let key = ( ch, size_key, id );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.atlas_texture ) );
}
if !self.glyph_cache.contains_key( &key )
// Resolve every glyph + its font up front so we can decide
// whether the atlas needs a single reset before we start
// emitting quads. Doing the reset mid-draw would orphan the
// `(atlas_x, atlas_y)` recorded in `glyph_cache` for glyphs
// already pushed earlier in this same call.
let canvas_handle = self.font_handle();
let prefer_handle = font.cloned().map( |f|
{
if Arc::ptr_eq( &f, &canvas_handle.font )
{
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
canvas_handle.clone()
}
else
{
// Caller-supplied face without bytes — leave bytes
// empty so the resolve function below knows it
// cannot be shaped through HarfBuzz and must fall
// back to the system chain.
crate::system_fonts::FontHandle
{
self.evict_glyph_cache_half();
}
let ( metrics, bitmap ) = font_arc.rasterize( ch, scaled );
if metrics.width > 0 && metrics.height > 0
{
let tex = upload_alpha_texture( &self.gl, &bitmap, metrics.width as i32, metrics.height as i32 );
self.glyph_cache.insert( key, GlyphEntry
{
texture: tex,
metrics,
tex_w: metrics.width as i32,
tex_h: metrics.height as i32,
} );
} else {
cursor_x += metrics.advance_width;
continue;
font: f,
bytes: Arc::new( Vec::new() ),
face: 0,
}
}
if let Some( entry ) = self.glyph_cache.get( &key )
} ).unwrap_or_else( || canvas_handle.clone() );
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
{
if prefer_handle.font.lookup_glyph_index( ch ) != 0 && !prefer_handle.bytes.is_empty()
{
let gx = ( cursor_x + entry.metrics.xmin as f32 ).round();
let gy = ( y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
let dest = Rect
return Some( prefer_handle.clone() );
}
crate::system_fonts::lookup_handle( ch ).or_else( ||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
if shaped.is_empty() { return; }
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
// rasterization. The `shape_line` step kept the id as the
// `Arc<Font>::as_ptr` of the resolved handle, so we walk
// every codepoint and ask the same resolver — exactly once
// per distinct id thanks to the dedup loop.
let mut fonts: Vec<( usize, Arc<Font> )> = Vec::new();
if !canvas_handle.bytes.is_empty()
{
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
}
for g in &shaped
{
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
let mut found = None;
for ch in text.chars()
{
if let Some( h ) = crate::system_fonts::lookup_handle( ch )
{
x: gx,
y: gy,
width: entry.tex_w as f32,
height: entry.tex_h as f32,
};
self.draw_glyph_texture( entry.texture, dest, color, self.global_alpha );
cursor_x += entry.metrics.advance_width;
if font_id( &h.font ) == g.font_id { found = Some( h.font ); break; }
}
}
if let Some( f ) = found
{
fonts.push( ( g.font_id, f ) );
}
}
// Phase 1 — rasterise every missing glyph into the atlas.
// Permitted to reset the atlas (and re-rasterise everything we
// already inserted in this same pass) at most once: a single
// string longer than the atlas capacity falls back to skipping
// the tail rather than oscillating.
let mut reset_used = false;
let mut i = 0;
while i < shaped.len()
{
let g = &shaped[ i ];
let glyph_id = g.glyph_id as u16;
let key = ( glyph_id, size_key, g.font_id );
if self.glyph_cache.contains_key( &key )
{
i += 1;
continue;
}
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
{
i += 1;
continue;
};
let ( metrics, bitmap ) = font_arc.rasterize_indexed( glyph_id, scaled );
if metrics.width == 0 || metrics.height == 0
{
self.glyph_cache.insert( key, GlyphEntry
{
metrics,
tex_w: 0,
tex_h: 0,
atlas_x: 0,
atlas_y: 0,
} );
i += 1;
continue;
}
let ( w, h ) = ( metrics.width as u32, metrics.height as u32 );
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP && !reset_used
{
self.atlas_reset();
reset_used = true;
i = 0;
continue;
}
let pos = match self.atlas_alloc( w, h )
{
Some( p ) => p,
None if !reset_used =>
{
self.atlas_reset();
reset_used = true;
i = 0;
continue;
}
None =>
{
self.glyph_cache.insert( key, GlyphEntry
{
metrics,
tex_w: 0,
tex_h: 0,
atlas_x: 0,
atlas_y: 0,
} );
i += 1;
continue;
}
};
unsafe
{
self.gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
self.gl.tex_sub_image_2d(
glow::TEXTURE_2D, 0,
pos.0 as i32, pos.1 as i32,
w as i32, h as i32,
self.atlas_format, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( &bitmap ) ),
);
self.gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
}
self.glyph_cache.insert( key, GlyphEntry
{
metrics,
tex_w: w as i32,
tex_h: h as i32,
atlas_x: pos.0,
atlas_y: pos.1,
} );
i += 1;
}
// Phase 2 — collect every quad's vertices in one buffer and
// flush them with a single draw call. Saves N-1
// program/uniform/draw round-trips per text run.
let atlas_size = ATLAS_SIZE as f32;
let surface_w = self.width as f32;
let surface_h = self.height as f32;
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
let mut cx = x;
for g in &shaped
{
let glyph_id = g.glyph_id as u16;
let key = ( glyph_id, size_key, g.font_id );
let Some( entry ) = self.glyph_cache.get( &key ) else
{
cx += g.x_advance;
continue;
};
if entry.tex_w == 0 || entry.tex_h == 0
{
cx += g.x_advance;
continue;
}
let pen_x = cx + g.x_offset;
let pen_y = y - g.y_offset;
let gx = ( pen_x + entry.metrics.xmin as f32 ).round();
let gy = ( pen_y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
let gw = entry.tex_w as f32;
let gh = entry.tex_h as f32;
let x0_ndc = gx * 2.0 / surface_w - 1.0;
let x1_ndc = ( gx + gw ) * 2.0 / surface_w - 1.0;
let y0_ndc = 1.0 - gy * 2.0 / surface_h;
let y1_ndc = 1.0 - ( gy + gh ) * 2.0 / surface_h;
let u0 = entry.atlas_x as f32 / atlas_size;
let v0 = entry.atlas_y as f32 / atlas_size;
let u1 = ( entry.atlas_x as f32 + gw ) / atlas_size;
let v1 = ( entry.atlas_y as f32 + gh ) / atlas_size;
verts.extend_from_slice( &[
x0_ndc, y0_ndc, u0, v0,
x1_ndc, y0_ndc, u1, v0,
x0_ndc, y1_ndc, u0, v1,
x1_ndc, y0_ndc, u1, v0,
x1_ndc, y1_ndc, u1, v1,
x0_ndc, y1_ndc, u0, v1,
] );
cx += g.x_advance;
}
if !verts.is_empty()
{
unsafe
{
self.gl.use_program( Some( self.glyph_batch_program ) );
self.gl.uniform_4_f32( Some( &self.u_glyph_batch_color ), color.r, color.g, color.b, color.a );
self.gl.uniform_1_f32( Some( &self.u_glyph_batch_opacity ), self.global_alpha );
self.gl.uniform_1_i32( Some( &self.u_glyph_batch_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.glyph_batch_vao ) );
self.gl.bind_buffer( glow::ARRAY_BUFFER, Some( self.glyph_batch_vbo ) );
self.gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, super::helpers::bytemuck_cast_slice( &verts ), glow::STREAM_DRAW );
let vertex_count = ( verts.len() / 4 ) as i32;
self.gl.draw_arrays( glow::TRIANGLES, 0, vertex_count );
self.gl.bind_vertex_array( None );
}
}
unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); }
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
self.measure_inner( text, size, None )
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
self.measure_inner( text, size, Some( font ) )
}
fn measure_inner( &self, text: &str, size: f32, font: Option<&Arc<Font>> ) -> f32
{
let scaled = size * self.dpi_scale;
let canvas_handle = self.font_handle();
let prefer_handle = font.cloned().map( |f|
{
let f = if font.lookup_glyph_index( ch ) != 0
if Arc::ptr_eq( &f, &canvas_handle.font )
{
Arc::clone( font )
canvas_handle.clone()
}
else
{
self.font_for_char( ch )
};
f.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
fn font_id_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
fn evict_glyph_cache_half( &mut self )
{
let drop_n = self.glyph_cache.len() / 2;
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
for key in victims
{
if let Some( entry ) = self.glyph_cache.remove( &key )
{
unsafe { self.gl.delete_texture( entry.texture ); }
crate::system_fonts::FontHandle
{
font: f,
bytes: Arc::new( Vec::new() ),
face: 0,
}
}
} ).unwrap_or_else( || canvas_handle.clone() );
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
{
if prefer_handle.font.lookup_glyph_index( ch ) != 0 && !prefer_handle.bytes.is_empty()
{
return Some( prefer_handle.clone() );
}
crate::system_fonts::lookup_handle( ch ).or_else( ||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
if shaped.is_empty()
{
return text.chars().map( |ch|
{
let f = font.cloned().unwrap_or_else( || self.font_for_char( ch ) );
f.metrics( ch, scaled ).advance_width
} ).sum();
}
shaped.iter().map( |g| g.x_advance ).sum()
}
fn font_handle( &self ) -> crate::system_fonts::FontHandle
{
crate::system_fonts::FontHandle
{
font: Arc::clone( &self.font ),
bytes: Arc::clone( &self.font_bytes ),
face: self.font_face,
}
}
fn draw_glyph_texture( &self, texture: glow::Texture, dest: Rect, color: Color, opacity: f32 )
fn atlas_alloc( &mut self, w: u32, h: u32 ) -> Option<( u32, u32 )>
{
let mvp = ortho_rect( self.width, self.height, dest );
unsafe
const PAD: u32 = 1;
if w + PAD > ATLAS_SIZE || h + PAD > ATLAS_SIZE { return None; }
if self.atlas_cursor_x + w + PAD > ATLAS_SIZE
{
self.gl.use_program( Some( self.glyph_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_glyph_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_glyph_color ), color.r, color.g, color.b, color.a );
self.gl.uniform_1_f32( Some( &self.u_glyph_opacity ), opacity );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_glyph_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.atlas_cursor_x = 0;
self.atlas_cursor_y += self.atlas_row_height;
self.atlas_row_height = 0;
}
if self.atlas_cursor_y + h + PAD > ATLAS_SIZE { return None; }
let x = self.atlas_cursor_x;
let y = self.atlas_cursor_y;
self.atlas_cursor_x += w + PAD;
if h + PAD > self.atlas_row_height { self.atlas_row_height = h + PAD; }
Some( ( x, y ) )
}
fn atlas_reset( &mut self )
{
self.glyph_cache.clear();
self.atlas_cursor_x = 0;
self.atlas_cursor_y = 0;
self.atlas_row_height = 0;
}
}

View File

@@ -67,7 +67,7 @@ pub struct GestureState<Msg: Clone>
pub pressed_idx: Option<usize>,
/// Index of the Scroll viewport that owns the current gesture, if
/// the press landed inside one.
pub scrolling_widget: Option<usize>,
pub scrolling_widget: Option<( usize, crate::widget::scroll::ScrollAxis )>,
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
/// release will be consumed as a scroll instead of a tap.
pub scroll_drag_started: bool,
@@ -161,7 +161,7 @@ impl<Msg: Clone> GestureState<Msg>
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
scroll_rects: &[( Rect, usize )],
scroll_rects: &[( Rect, usize, crate::widget::scroll::ScrollAxis )],
) -> PressOutcome<Msg>
{
let hit = find_widget_at( widget_rects, pos );
@@ -176,8 +176,8 @@ impl<Msg: Clone> GestureState<Msg>
self.start = Some( pos );
self.scrolling_widget = scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx );
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) );
self.scroll_drag_started = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
@@ -238,7 +238,7 @@ impl<Msg: Clone> GestureState<Msg>
&mut self,
pos: Point,
widget_rects: &[LaidOutWidget<Msg>],
scroll_offsets: &mut HashMap<usize, f32>,
scroll_offsets: &mut HashMap<usize, ( f32, f32 )>,
swipe: &SwipeConfig,
global_drag: bool,
) -> MoveOutcome<Msg>
@@ -292,15 +292,23 @@ impl<Msg: Clone> GestureState<Msg>
// Scroll viewport drag: mutate offset in place and advance the
// gesture origin so the next delta is frame-to-frame, not
// press-to-now (otherwise the first 8 px trip the threshold
// and the entire scroll gets absorbed into one delta).
if let Some( scroll_idx ) = self.scrolling_widget
// and the entire scroll gets absorbed into one delta). Both
// axes are routed independently; an axis the viewport does not
// allow is just ignored (delta still consumed by the gesture,
// so the swipe handler does not fight the scroll for it).
if let Some( ( scroll_idx, axis ) ) = self.scrolling_widget
{
if let Some( start ) = self.start
{
let dx = pos.x - start.x;
let dy = pos.y - start.y;
let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry - dy ).max( 0.0 );
if dy.abs() > 8.0 { self.scroll_drag_started = true; }
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
else if axis.allows_x() { dx.abs() }
else { dy.abs() };
if moved > 8.0 { self.scroll_drag_started = true; }
self.start = Some( pos );
return MoveOutcome::Scroll;
}

View File

@@ -3,6 +3,7 @@
use super::*;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
use crate::widget::scroll::ScrollAxis;
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
@@ -42,6 +43,8 @@ fn button_full(
keyboard_focusable: true,
cursor: crate::types::CursorShape::Default,
tooltip: None,
accessible_label: None,
is_live_region: false,
}
}
@@ -156,10 +159,10 @@ fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire()
fn press_identifies_scroll_target()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls );
assert_eq!( g.scrolling_widget, Some( 42 ) );
assert_eq!( g.scrolling_widget, Some( ( 42, ScrollAxis::Vertical ) ) );
}
#[ test ]
@@ -230,14 +233,14 @@ fn move_with_global_drag_returns_drag()
fn move_inside_scroll_widget_mutates_offset_in_place()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Drag finger upward → content scrolls down → offset increases.
let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Scroll ) );
assert_eq!( offsets.get( &7 ).copied(), Some( 40.0 ) );
assert_eq!( offsets.get( &7 ).copied(), Some( ( 0.0, 40.0 ) ) );
assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" );
}
@@ -245,20 +248,20 @@ fn move_inside_scroll_widget_mutates_offset_in_place()
fn move_inside_scroll_widget_clamps_offset_at_zero()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
// Drag finger downward → content tries to scroll up past origin → clamp at 0.
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( offsets.get( &9 ).copied(), Some( 0.0 ) );
assert_eq!( offsets.get( &9 ).copied(), Some( ( 0.0, 0.0 ) ) );
}
#[ test ]
fn move_below_eight_pixels_does_not_arm_scroll_drag()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ];
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1, ScrollAxis::Vertical ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
let mut offsets = HashMap::new();
@@ -430,7 +433,7 @@ fn release_after_consumed_scroll_emits_no_events()
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 50.0, 50.0 ) );
g.scrolling_widget = Some( 1 );
g.scrolling_widget = Some( ( 1, ScrollAxis::Vertical ) );
g.scroll_drag_started = true;
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.is_empty() );
@@ -446,7 +449,7 @@ fn cancel_resets_every_field()
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 1.0, 2.0 ) );
g.pressed_idx = Some( 99 );
g.scrolling_widget = Some( 99 );
g.scrolling_widget = Some( ( 99, ScrollAxis::Vertical ) );
g.scroll_drag_started = true;
g.horizontal_drag_started = true;
g.vertical_drag_started = true;

View File

@@ -47,6 +47,7 @@ impl<A: App> KeyboardHandler for AppData<A>
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
self.keyboard_focus = focus;
self.surface_mut( focus ).request_redraw();
if let Some( ref mut a ) = self.a11y { a.set_window_focus( true ); }
}
fn leave(
@@ -60,6 +61,7 @@ impl<A: App> KeyboardHandler for AppData<A>
{
self.stop_key_repeat();
self.keyboard_focus = SurfaceFocus::Main;
if let Some( ref mut a ) = self.a11y { a.set_window_focus( false ); }
}
fn press_key(

View File

@@ -22,7 +22,7 @@ impl<A: App> AppData<A>
let scroll_meta = {
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find_map( |( rect, idx )|
.find_map( |( rect, idx, _ax )|
{
ss.scroll_navigable_items.get( idx )
.filter( |list| !list.is_empty() )
@@ -42,30 +42,32 @@ impl<A: App> AppData<A>
};
let ( new_idx, content_y, content_h ) = items[ next_pos ];
// Auto-scroll to bring the new item fully into view. Item Y is
// in pre-offset content coordinates, so the offset that places
// the item flush with the top of the viewport is `content_y`,
// Auto-scroll on the Y axis to bring the new item fully into view.
// Item Y is in pre-offset content coordinates, so the offset that
// places the item flush with the top of the viewport is `content_y`,
// and flush with the bottom is `content_y + content_h - viewport_h`.
// Keyboard navigation only steps on the navigable-item axis (vertical
// list-style); the X offset is preserved as-is.
let viewport_h = scroll_rect.height;
let current_offset = self.surface( focus )
let ( current_x, current_y ) = self.surface( focus )
.scroll_offsets.get( &scroll_idx )
.copied().unwrap_or( 0.0 );
let new_offset = if content_y < current_offset
.copied().unwrap_or( ( 0.0, 0.0 ) );
let new_y = if content_y < current_y
{
content_y
}
else if content_y + content_h > current_offset + viewport_h
else if content_y + content_h > current_y + viewport_h
{
( content_y + content_h - viewport_h ).max( 0.0 )
}
else
{
current_offset
current_y
};
let ss = self.surface_mut( focus );
ss.hovered_idx = Some( new_idx );
ss.scroll_offsets.insert( scroll_idx, new_offset );
ss.scroll_offsets.insert( scroll_idx, ( current_x, new_y ) );
ss.request_redraw();
true
}

View File

@@ -30,24 +30,45 @@ impl<A: App> AppData<A>
return;
};
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
let scroll_idx_opt =
let scroll_hit =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx )
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) )
};
if let Some( scroll_idx ) = scroll_idx_opt
if let Some( ( scroll_idx, axis ) ) = scroll_hit
{
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let step = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry + step ).max( 0.0 );
let step_x = horizontal.absolute as f32 * multiplier;
let step_y = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
// Wheels report on a single axis at a time; route to
// whichever axis the viewport allows. A pure horizontal
// viewport translates a vertical wheel into horizontal
// motion (the conventional shift-less wheel-to-strip UX);
// `Both` keeps each axis independent.
match axis
{
crate::widget::scroll::ScrollAxis::Vertical =>
{
entry.1 = ( entry.1 + step_y ).max( 0.0 );
}
crate::widget::scroll::ScrollAxis::Horizontal =>
{
entry.0 = ( entry.0 + step_x + step_y ).max( 0.0 );
}
crate::widget::scroll::ScrollAxis::Both =>
{
entry.0 = ( entry.0 + step_x ).max( 0.0 );
entry.1 = ( entry.1 + step_y ).max( 0.0 );
}
}
ss.request_redraw();
} else {
// No LTK scroll viewport under the cursor —

View File

@@ -10,14 +10,22 @@
//! the two handlers share `apply_move_outcome` /
//! `apply_release_events` in `dispatch.rs`.
//!
//! The only touch-specific state is `AppData::touch_focus`, a
//! `HashMap<touch_id, SurfaceFocus>` so a finger that landed on an
//! overlay continues to route to that overlay even if it drifts over
//! the main surface. Multi-finger tracking is not yet modelled — the
//! gesture machine is single-gesture; a second finger arriving while
//! the first is pressed overwrites the same slot. Good enough for
//! sliders, swipes and taps; a proper multi-touch rewrite is a
//! separate refactor.
//! The first finger to land on a surface becomes its **primary slot**
//! and drives the single-slot gesture machine (swipe, scroll,
//! long-press, drag). Any additional finger arriving while the
//! primary is held bypasses the gesture machine and surfaces directly
//! through [`App::on_touch_down`] / [`App::on_touch_move`] /
//! [`App::on_touch_up`], so apps can implement pinch-zoom or
//! two-finger pan without losing the built-in gestures. Slot
//! ownership is recorded on `SurfaceState::primary_touch_id` plus
//! `SurfaceState::touch_slots` for the auxiliary fingers' last
//! position (Wayland `wl_touch.up` does not carry one, so the slot
//! cache supplies it).
//!
//! The cross-surface routing map `AppData::touch_focus` is still
//! per touch id, so an auxiliary slot that landed on an overlay
//! keeps reporting through that overlay's `App` callback even if
//! the finger drifts over the main surface.
use smithay_client_toolkit::seat::touch::TouchHandler;
use smithay_client_toolkit::reexports::client::
@@ -49,6 +57,32 @@ impl<A: App> TouchHandler for AppData<A>
self.touch_focus.insert( id, focus );
let pos = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pos;
// Auxiliary slot path: a second (or third…) finger arriving
// while another finger already drives the primary slot stays
// out of the single-slot gesture machine entirely. The app
// gets a raw `on_touch_down` and can drive its own pinch /
// pan from the per-id stream.
let is_primary =
{
let ss = self.surface_mut( focus );
if ss.primary_touch_id.is_none()
{
ss.primary_touch_id = Some( id );
true
}
else
{
ss.touch_slots.insert( id, pos );
false
}
};
if !is_primary
{
self.app.on_touch_down( id as i64, pos.x, pos.y );
return;
}
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
@@ -136,6 +170,26 @@ impl<A: App> TouchHandler for AppData<A>
)
{
let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main );
// Auxiliary release: not the primary slot → recover the last
// recorded position for the up callback (Wayland's `wl_touch.up`
// does not carry one) and bail before reaching the gesture
// machine.
let is_primary =
{
let ss = self.surface_mut( focus );
ss.primary_touch_id == Some( id )
};
if !is_primary
{
let pos =
{
let ss = self.surface_mut( focus );
ss.touch_slots.remove( &id ).unwrap_or( self.pointer_pos )
};
self.app.on_touch_up( id as i64, pos.x, pos.y );
return;
}
// Touch-up does not carry a position in wl_touch — the last
// motion's position is the release point.
let pos = self.pointer_pos;
@@ -144,7 +198,8 @@ impl<A: App> TouchHandler for AppData<A>
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.needs_redraw = true;
ss.primary_touch_id = None;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
@@ -163,8 +218,25 @@ impl<A: App> TouchHandler for AppData<A>
{
let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main );
let pp = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pp;
let is_primary =
{
let ss = self.surface( focus );
ss.primary_touch_id == Some( id )
};
if !is_primary
{
// Auxiliary slot: cache the latest position so `up`'s
// release point is accurate, then surface the raw motion.
{
let ss = self.surface_mut( focus );
ss.touch_slots.insert( id, pp );
}
self.app.on_touch_move( id as i64, pp.x, pp.y );
return;
}
self.pointer_pos = pp;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
@@ -209,14 +281,25 @@ impl<A: App> TouchHandler for AppData<A>
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
{
self.touch_focus.clear();
// The compositor is stealing every active touch — drop all
// in-flight gesture state across every surface.
let clear = |ss: &mut SurfaceState<A::Message>| { ss.gesture.on_cancel(); };
clear( &mut self.main );
// Snapshot every auxiliary slot's last position so the app
// can be notified with a meaningful release point, then drop
// every per-surface slot in one pass.
let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new();
let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
{
for ( id, pos ) in ss.touch_slots.drain() { out.push( ( id, pos ) ); }
ss.primary_touch_id = None;
ss.gesture.on_cancel();
};
collect( &mut self.main, &mut aux_releases );
for ss in self.overlays.values_mut()
{
clear( ss );
collect( ss, &mut aux_releases );
}
self.touch_focus.clear();
for ( id, pos ) in aux_releases
{
self.app.on_touch_up( id as i64, pos.x, pos.y );
}
self.stop_button_repeat();
}

View File

@@ -149,6 +149,8 @@ rust_i18n::i18n!( "locales", fallback = "en" );
pub mod types;
pub( crate ) mod render;
pub( crate ) mod system_fonts;
pub( crate ) mod text_shaping;
pub( crate ) mod a11y;
pub( crate ) mod widget;
pub( crate ) mod layout;
pub( crate ) mod app;
@@ -240,7 +242,7 @@ pub use layout::row::{ Row, row };
pub use layout::stack::{ Stack, stack, HAlign, VAlign };
// push_aligned_margin is available as a method on Stack — no separate re-export needed.
pub use layout::wrap_grid::{ WrapGrid, grid };
pub use widget::scroll::scroll;
pub use widget::scroll::{ scroll, ScrollAxis };
pub use widget::viewport::{ Viewport, viewport };
pub use app::run;
pub use app::{ try_run, RunError };

View File

@@ -23,8 +23,7 @@
//! has_clip, strip_intersects_clip, clear_rects_transparent}`.
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
//! stroke_rect, draw_line}`.
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text,
//! rasterize_cached}`.
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`.
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
//! write_to_wayland_buf}`.
//! * [`helpers`] — free functions: `build_rounded_rect`,
@@ -81,11 +80,15 @@ pub fn is_software_render() -> bool
/// Cache key for a rasterized glyph. `size_bits` is the f32 bit
/// pattern of `size * dpi_scale`; `font_id` is the address of the
/// `Arc<Font>` used for the rasterisation, so distinct weights /
/// families of the same `(char, size)` do not collide on the cache.
/// families of the same `(glyph_id, size)` do not collide on the
/// cache. `glyph_id` is the per-font glyph index returned by
/// HarfBuzz shaping, so cached entries persist across script
/// transitions and Arabic / Devanagari / CJK forms cluster
/// independently of the `char` codepoint that produced them.
#[ derive( Hash, PartialEq, Eq, Clone, Copy ) ]
pub ( super ) struct GlyphKey
{
pub ( super ) ch: char,
pub ( super ) glyph_id: u16,
pub ( super ) size_bits: u32,
pub ( super ) font_id: usize,
}
@@ -118,6 +121,13 @@ pub struct SoftwareCanvas
/// working. Populated from
/// [`crate::render::helpers::find_font`] at construction time.
pub font: Arc<Font>,
/// Raw bytes of the default font. Kept alongside `font` so the
/// HarfBuzz shaper (rustybuzz) can be invoked without re-reading
/// the file — fontdue does not expose its internal byte buffer.
pub font_bytes: Arc<Vec<u8>>,
/// TTC sub-face index for the default font (0 for single-face
/// files; collection index for `.ttc` archives).
pub font_face: u32,
/// Optional theme font registry. When present,
/// [`SoftwareCanvas::font_for`] consults it before falling back
/// to `font`. Populated by the caller once the theme's `fonts`

View File

@@ -10,29 +10,36 @@ use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings };
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use crate::system_fonts::FontHandle;
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::load_default_font_bytes;
use super::SoftwareCanvas;
/// Process-wide cache of the default font face. Avoids re-reading +
/// re-parsing the file on every surface bring-up. Sora is small
/// (~50 KB) so the cost was minor in absolute terms — but a layer
/// shell that brings up a launcher overlay, a QS panel, a calendar
/// popup and a handful of toast surfaces would still pay the parse
/// cost a dozen times in a single session, all of which is wasted
/// work.
static DEFAULT_FONT: OnceLock<Arc<Font>> = OnceLock::new();
/// Process-wide cache of the default font face. The handle keeps the
/// raw bytes (`Arc<Vec<u8>>`) alongside the fontdue `Arc<Font>` so
/// the HarfBuzz shaper can be invoked without re-reading the file.
/// Sora is small (~50 KB) so the cost was minor in absolute terms —
/// but a layer shell that brings up a launcher overlay, a QS panel,
/// a calendar popup and a handful of toast surfaces would still pay
/// the parse cost a dozen times in a single session, all of which
/// is wasted work.
static DEFAULT_FONT: OnceLock<FontHandle> = OnceLock::new();
fn default_font() -> Arc<Font>
fn default_handle() -> FontHandle
{
Arc::clone( DEFAULT_FONT.get_or_init( ||
DEFAULT_FONT.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
Arc::new( font )
} ) )
FontHandle
{
font: Arc::new( font ),
bytes: Arc::new( bytes ),
face: 0,
}
} ).clone()
}
impl SoftwareCanvas
@@ -47,10 +54,13 @@ impl SoftwareCanvas
/// process reuses the cached `Arc<Font>`.
pub fn new( width: u32, height: u32 ) -> Self
{
let handle = default_handle();
Self
{
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
font: default_font(),
font: handle.font.clone(),
font_bytes: handle.bytes.clone(),
font_face: handle.face,
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
@@ -67,6 +77,8 @@ impl SoftwareCanvas
{
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
font: Arc::clone( &self.font ),
font_bytes: Arc::clone( &self.font_bytes ),
font_face: self.font_face,
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
@@ -110,6 +122,33 @@ impl SoftwareCanvas
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
}
/// Bytes-aware variant of [`Self::font_for_char`]. Returns the
/// full `FontHandle` so callers that invoke the HarfBuzz shaper
/// can hand the raw bytes directly to rustybuzz. The primary
/// font supplies the bytes from
/// [`Self::font_bytes`]; fallback chars borrow the bytes that
/// were loaded into the system fallback cache.
pub fn font_handle_for_char( &self, ch: char ) -> crate::system_fonts::FontHandle
{
if self.font.lookup_glyph_index( ch ) != 0
{
return crate::system_fonts::FontHandle
{
font: Arc::clone( &self.font ),
bytes: Arc::clone( &self.font_bytes ),
face: self.font_face,
};
}
crate::system_fonts::lookup_handle( ch ).unwrap_or_else( ||
crate::system_fonts::FontHandle
{
font: Arc::clone( &self.font ),
bytes: Arc::clone( &self.font_bytes ),
face: self.font_face,
}
)
}
pub fn blit( &mut self, src: &SoftwareCanvas, dest_x: i32, dest_y: i32 )
{
let paint = PixmapPaint::default();

View File

@@ -1,10 +1,14 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`SoftwareCanvas`]. fontdue rasterises each
//! glyph once into the persistent [`super::GlyphEntry`] cache; the
//! per-frame hot path just lays out positions and blends cached
//! bitmaps into the pixmap with the active clip mask + global alpha.
//! Text rendering for [`SoftwareCanvas`]. The line is shaped through
//! [`crate::text_shaping::shape_line`] (BiDi reordering + per-sub-run
//! rustybuzz / HarfBuzz shaping) and each shaped glyph is rasterised
//! by glyph index via `fontdue::Font::rasterize_indexed`. The cache
//! is keyed on `(glyph_id, size, font_id)` so Arabic connected
//! forms, Devanagari clusters, and CJK shaped glyphs cluster
//! correctly without colliding with the per-codepoint cache the
//! old path used.
use std::sync::Arc;
@@ -16,68 +20,8 @@ use super::{ GlyphEntry, GlyphKey, SoftwareCanvas };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
/// Stable identifier for an `Arc<Font>`: the address of the font's
/// allocation. Different reweights / families always live in
/// distinct allocations, so the address is enough to disambiguate
/// glyph cache entries.
fn font_id( font: &Arc<Font> ) -> usize
{
Arc::as_ptr( font ) as usize
}
impl SoftwareCanvas
{
/// Per-glyph default font lookup. Routes through
/// [`Self::font_for_char`], which already consults the lazy
/// system-font fallback chain. Returns an owned [`Arc<Font>`] so
/// callers can hold the handle across `&mut self` borrows of the
/// glyph cache.
fn default_font_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
pub ( super ) fn rasterize_cached( &mut self, ch: char, scaled: f32 ) -> &GlyphEntry
{
let ( id, font ) = self.default_font_for_char( ch );
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
/// Pick the font to use for `ch` given a "preferred" override.
/// Falls through to the canvas default + Noto chain when the
/// preferred font does not own the glyph — so Sora Bold rendering
/// of CJK / Devanagari / etc. still works.
fn font_for_char_with_pref( &self, ch: char, pref: &Arc<Font> ) -> ( usize, Arc<Font> )
{
if pref.lookup_glyph_index( ch ) != 0
{
return ( font_id( pref ), Arc::clone( pref ) );
}
self.default_font_for_char( ch )
}
fn rasterize_cached_with( &mut self, ch: char, scaled: f32, pref: &Arc<Font> ) -> &GlyphEntry
{
let ( id, font ) = self.font_for_char_with_pref( ch, pref );
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
fn evict_if_full( &mut self, key: &GlyphKey )
{
if !self.glyph_cache.contains_key( key )
@@ -92,6 +36,22 @@ impl SoftwareCanvas
}
}
/// Rasterise glyph `glyph_id` in `font` at `scaled` px, caching
/// the result under `(glyph_id, size_bits, font_id)`. Used by
/// both the public `draw_text` and `draw_text_with_font` paths
/// after shaping has resolved every codepoint to a glyph index.
fn rasterize_indexed_cached( &mut self, font: &Arc<Font>, glyph_id: u16, scaled: f32, font_id: usize ) -> &GlyphEntry
{
let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize_indexed( glyph_id, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
{
self.draw_text_inner( text, x, y, size, color, None );
@@ -117,28 +77,96 @@ impl SoftwareCanvas
return;
}
let mut layout: Vec<( GlyphKey, f32 )> = Vec::with_capacity( text.chars().count() );
// Resolve the font handle (font + raw bytes + face index) per
// codepoint. When the caller supplied a preferred font (a
// theme-resolved bold / italic), we still consult the
// fallback chain whenever the preferred face does not own
// the requested glyph, so a Sora-Bold label that includes a
// CJK character still picks up Noto Sans CJK for that one
// codepoint.
let canvas_handle =
{
let mut cursor_x = x;
let h = self.font_handle();
match font
{
Some( f ) if Arc::ptr_eq( f, &h.font ) => h,
Some( f ) => crate::system_fonts::FontHandle
{
font: Arc::clone( f ),
bytes: Arc::new( Vec::new() ),
face: 0,
},
None => h,
}
};
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
{
if canvas_handle.font.lookup_glyph_index( ch ) != 0 && !canvas_handle.bytes.is_empty()
{
return Some( canvas_handle.clone() );
}
crate::system_fonts::lookup_handle( ch ).or_else( ||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
// `shape_line` returns glyphs in visual order with HarfBuzz
// advance widths and offsets. An empty result (rustybuzz
// refused the font, missing bytes for the preferred face)
// falls through to no-op — we'd rather not paint than risk
// a corrupted line.
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
if shaped.is_empty() { return; }
// Resolve every glyph's font into an `Arc<Font>` for
// rasterization — we keep a per-font-id index into a small
// vec of `Arc<Font>` so the rasterizer step does not have to
// re-walk the resolve fn (mut self borrow conflicts).
let mut fonts: Vec<( usize, Arc<Font> )> = Vec::new();
let primary_id = Arc::as_ptr( &canvas_handle.font ) as usize;
if !canvas_handle.bytes.is_empty()
{
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
}
for g in &shaped
{
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
// Walk the fallback chain to find an Arc<Font> with this
// id — this only ever runs once per (font, line) pair
// because we cache by id in `fonts`.
let mut found = None;
// Try every char in the text — the resolve fn is monotone
// per char so checking the chars yields every distinct
// font that the shaper saw.
for ch in text.chars()
{
let ( id, advance ) = match font
if let Some( h ) = crate::system_fonts::lookup_handle( ch )
{
Some( f ) =>
{
let id = self.font_for_char_with_pref( ch, f ).0;
let advance = self.rasterize_cached_with( ch, scaled, f ).metrics.advance_width;
( id, advance )
}
None =>
{
let id = self.default_font_for_char( ch ).0;
let advance = self.rasterize_cached( ch, scaled ).metrics.advance_width;
( id, advance )
}
let id = Arc::as_ptr( &h.font ) as usize;
if id == g.font_id { found = Some( h.font ); break; }
}
}
if let Some( f ) = found
{
fonts.push( ( g.font_id, f ) );
}
}
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
{
let mut cursor_x = x;
for g in &shaped
{
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
{
cursor_x += g.x_advance;
continue;
};
layout.push( ( GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }, cursor_x ) );
cursor_x += advance;
let glyph_id = g.glyph_id as u16;
let _ = self.rasterize_indexed_cached( font_arc, glyph_id, scaled, g.font_id );
let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id: g.font_id };
layout.push( ( key, cursor_x + g.x_offset, g.y_offset ) );
cursor_x += g.x_advance;
}
}
@@ -153,16 +181,17 @@ impl SoftwareCanvas
let cache = &self.glyph_cache;
let mask_data = self.clip_mask.as_ref().map( |m| ( m.data(), m.width() as i32 ) );
for ( key, cursor_x ) in layout
for ( key, cursor_x, glyph_y_offset ) in layout
{
let entry = cache.get( &key ).expect( "warmed above" );
let metrics = &entry.metrics;
let bitmap = &entry.bitmap;
if metrics.width == 0 || metrics.height == 0 { continue; }
for ( i, &alpha ) in bitmap.iter().enumerate()
{
if alpha == 0 { continue; }
let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32;
let py = y as i32
let py = ( y - glyph_y_offset ) as i32
- metrics.ymin as i32
- metrics.height as i32
+ 1
@@ -186,18 +215,65 @@ impl SoftwareCanvas
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
self.measure_with_font( text, size, None )
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
self.measure_with_font( text, size, Some( font ) )
}
fn measure_with_font( &self, text: &str, size: f32, font: Option<&Arc<Font>> ) -> f32
{
let scaled = size * self.dpi_scale;
let canvas_handle =
{
let ( _, picked ) = self.font_for_char_with_pref( ch, font );
picked.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
let h = self.font_handle();
match font
{
Some( f ) if Arc::ptr_eq( f, &h.font ) => h,
Some( f ) => crate::system_fonts::FontHandle
{
font: Arc::clone( f ),
bytes: Arc::new( Vec::new() ),
face: 0,
},
None => h,
}
};
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
{
if canvas_handle.font.lookup_glyph_index( ch ) != 0 && !canvas_handle.bytes.is_empty()
{
return Some( canvas_handle.clone() );
}
crate::system_fonts::lookup_handle( ch ).or_else( ||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
)
};
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
if shaped.is_empty()
{
// Fallback: rustybuzz could not shape (no bytes for the
// preferred font, no fallback covers the codepoints).
// Sum per-codepoint advances so layout still makes a
// vaguely useful decision.
return text.chars().map( |ch|
{
let f = font.map( Arc::clone ).unwrap_or_else( || self.font_for_char( ch ) );
f.metrics( ch, scaled ).advance_width
} ).sum();
}
shaped.iter().map( |g| g.x_advance ).sum()
}
fn font_handle( &self ) -> crate::system_fonts::FontHandle
{
crate::system_fonts::FontHandle
{
font: Arc::clone( &self.font ),
bytes: Arc::clone( &self.font_bytes ),
face: self.font_face,
}
}
}

View File

@@ -26,6 +26,22 @@ use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings };
/// Bytes-aware font handle. The `font` is what fontdue rasterises
/// against; `bytes` is the raw OpenType / TrueType buffer that
/// rustybuzz (HarfBuzz) requires for shaping. They are owned
/// separately because fontdue does not expose its internal byte
/// buffer — we keep both alive in lockstep instead.
///
/// `face` is the TTC sub-face index (0 for single-face files, the
/// face index for collections like Noto Sans CJK).
#[ derive( Clone ) ]
pub struct FontHandle
{
pub font: Arc<Font>,
pub bytes: Arc<Vec<u8>>,
pub face: u32,
}
/// Per-script fallback font path with the TrueType-collection face
/// index fontdue should load (most files are single-face → 0; CJK
/// `.ttc` archives carry many faces, see the SC face on the canonical
@@ -78,9 +94,9 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
/// doesn't drag the CJK pack into memory. `None` inside a resolved
/// slot means the file was missing or fontdue rejected it — a sticky
/// negative result so subsequent misses skip the slot in O(1).
fn slots() -> &'static [ OnceLock<Option<Arc<Font>>> ]
fn slots() -> &'static [ OnceLock<Option<FontHandle>> ]
{
static SLOTS: OnceLock<Vec<OnceLock<Option<Arc<Font>>>>> = OnceLock::new();
static SLOTS: OnceLock<Vec<OnceLock<Option<FontHandle>>>> = OnceLock::new();
SLOTS.get_or_init( ||
{
( 0..FALLBACK_FONT_CANDIDATES.len() )
@@ -90,9 +106,11 @@ fn slots() -> &'static [ OnceLock<Option<Arc<Font>>> ]
}
/// Try to load and parse the fallback font at slot `idx`. Each
/// `OnceLock` wraps `Option<Arc<Font>>` so a missing or malformed
/// file is recorded as `None` and never re-attempted.
fn slot_font( idx: usize ) -> Option<Arc<Font>>
/// `OnceLock` wraps `Option<FontHandle>` so a missing or malformed
/// file is recorded as `None` and never re-attempted. The raw bytes
/// are preserved inside the handle so the same `Arc<Vec<u8>>` can be
/// handed to rustybuzz for shaping without re-reading the file.
fn slot_handle( idx: usize ) -> Option<FontHandle>
{
let slot = &slots()[ idx ];
slot.get_or_init( ||
@@ -100,10 +118,15 @@ fn slot_font( idx: usize ) -> Option<Arc<Font>>
let spec = &FALLBACK_FONT_CANDIDATES[ idx ];
let bytes = std::fs::read( spec.path ).ok()?;
let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() };
Font::from_bytes( bytes.as_slice(), opts ).ok().map( Arc::new )
let font = Font::from_bytes( bytes.as_slice(), opts ).ok()?;
Some( FontHandle
{
font: Arc::new( font ),
bytes: Arc::new( bytes ),
face: spec.face,
} )
} )
.as_ref()
.map( Arc::clone )
.clone()
}
/// Find the first fallback font that has a non-zero glyph index for
@@ -120,13 +143,22 @@ fn slot_font( idx: usize ) -> Option<Arc<Font>>
/// scripts (Devanagari, Arabic, …) whose total weight is a fraction
/// of the CJK pack everyone was paying for unconditionally.
pub fn lookup( ch: char ) -> Option<Arc<Font>>
{
lookup_handle( ch ).map( |h| h.font )
}
/// Bytes-aware variant of [`lookup`]. Returns the full
/// [`FontHandle`] (fontdue handle + raw bytes + face index) so
/// callers that need to invoke a HarfBuzz-style shaper can do so
/// without re-reading the font file.
pub fn lookup_handle( ch: char ) -> Option<FontHandle>
{
for idx in 0..FALLBACK_FONT_CANDIDATES.len()
{
let Some( font ) = slot_font( idx ) else { continue };
if font.lookup_glyph_index( ch ) != 0
let Some( handle ) = slot_handle( idx ) else { continue };
if handle.font.lookup_glyph_index( ch ) != 0
{
return Some( font );
return Some( handle );
}
}
None

171
src/text_shaping.rs Normal file
View File

@@ -0,0 +1,171 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Unicode text processing: BiDi visual reordering folded into
//! HarfBuzz shaping.
//!
//! [`shape_line`] is the main entry point. It takes a logical-order
//! string, a font resolver ("for this codepoint, which
//! `FontHandle` should we use?") and the pixel size, runs the
//! Unicode Bidirectional Algorithm over the input, splits each
//! BiDi run into sub-runs that share a font, calls [`shape_run`]
//! per sub-run, and returns a [`PositionedGlyph`] sequence in
//! visual order. Renderers consume that sequence directly: each
//! `PositionedGlyph` carries the per-font `glyph_id`, the visual
//! advance, and ink offsets, which is exactly what
//! `fontdue::Font::rasterize_indexed` needs to render Arabic
//! connected forms, Devanagari clusters and CJK shaped glyphs
//! correctly.
//!
//! [`shape_run`] is the lower-level entry point — useful in tests
//! and when a caller has already done its own bidi / sub-run
//! splitting.
/// Single shaped glyph returned by [`shape_run`]. Horizontal-only —
/// vertical-script metrics (`y_advance`) and the source-string
/// `cluster` index that rustybuzz also returns are dropped because
/// no caller in the crate consumes them; add them back when a
/// caller actually needs vertical layout or text-edit
/// cluster-aware caret navigation.
pub struct ShapedGlyph
{
/// Glyph index in the font (not a Unicode codepoint).
pub glyph_id: u32,
/// Horizontal advance in 26.6 fixed-point font units, already scaled
/// to the requested pixel size.
pub x_advance: f32,
/// Horizontal glyph offset from the pen position.
pub x_offset: f32,
/// Vertical glyph offset from the baseline.
pub y_offset: f32,
}
/// Per-glyph entry returned by [`shape_line`]. Carries the font that
/// owns the glyph (so the rasterizer caches per font, not just per
/// glyph index) and the visual position relative to the start of the
/// line.
pub struct PositionedGlyph
{
pub glyph_id: u32,
pub font_id: usize,
pub x_advance: f32,
pub x_offset: f32,
pub y_offset: f32,
}
/// Shape `text` for visual rendering. Runs the Unicode Bidirectional
/// Algorithm over the input, then asks `resolve_font(ch)` for the
/// font handle that owns each codepoint, groups consecutive
/// codepoints sharing a font / direction into sub-runs, and
/// calls [`shape_run`] per sub-run. Output is in visual order:
/// concatenating `x_advance` of each glyph yields the rendered line
/// width.
///
/// `resolve_font` is the per-character fallback resolver (typically
/// "primary font if it has the glyph, otherwise walk the system
/// fallback chain"). Returning `None` lets the caller skip the
/// codepoint entirely.
pub fn shape_line<F>( text: &str, px: f32, mut resolve_font: F ) -> Vec<PositionedGlyph>
where
F: FnMut( char ) -> Option<crate::system_fonts::FontHandle>,
{
if text.is_empty() { return vec![]; }
let bidi = unicode_bidi::BidiInfo::new( text, None );
if bidi.paragraphs.is_empty() { return vec![]; }
let para = &bidi.paragraphs[0];
let ( levels, runs ) = bidi.visual_runs( para, para.range.clone() );
let mut out: Vec<PositionedGlyph> = Vec::with_capacity( text.chars().count() );
for run in runs
{
let is_rtl = levels.get( run.start ).map( |l| l.is_rtl() ).unwrap_or( false );
let run_text = &text[ run ];
// Split the BiDi run into sub-runs that share a single font.
// `resolve_font` is asked once per codepoint; consecutive
// codepoints whose handle has the same `font` pointer are
// shaped together.
let mut current_handle: Option<crate::system_fonts::FontHandle> = None;
let mut sub_run = String::new();
let flush = | handle_opt: &Option<crate::system_fonts::FontHandle>, sub_run: &mut String, out: &mut Vec<PositionedGlyph> |
{
if sub_run.is_empty() { return; }
let Some( h ) = handle_opt.as_ref() else { sub_run.clear(); return };
let glyphs = shape_run( sub_run, &h.bytes, h.face, px, is_rtl );
let font_id = std::sync::Arc::as_ptr( &h.font ) as usize;
for g in glyphs
{
out.push( PositionedGlyph
{
glyph_id: g.glyph_id,
font_id,
x_advance: g.x_advance,
x_offset: g.x_offset,
y_offset: g.y_offset,
} );
}
sub_run.clear();
};
for ch in run_text.chars()
{
let handle = resolve_font( ch );
let same = match ( &current_handle, &handle )
{
( Some( a ), Some( b ) ) => std::sync::Arc::ptr_eq( &a.font, &b.font ),
( None, None ) => true,
_ => false,
};
if !same
{
flush( &current_handle, &mut sub_run, &mut out );
current_handle = handle.clone();
}
sub_run.push( ch );
}
flush( &current_handle, &mut sub_run, &mut out );
}
out
}
/// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and
/// return the glyph sequence with ink positions.
///
/// `font_bytes` must be the raw bytes of the font file (TrueType or
/// OpenType); `face_index` is 0 for single-face files and the TTC
/// sub-face index otherwise. `px` is the point size in physical pixels.
/// `rtl` selects right-to-left shaping (set from the BiDi run level).
///
/// Returns an empty vec if the font cannot be parsed or the string is
/// empty.
pub fn shape_run( text: &str, font_bytes: &[u8], face_index: u32, px: f32, rtl: bool ) -> Vec<ShapedGlyph>
{
use rustybuzz::{ UnicodeBuffer, Direction };
if text.is_empty() { return vec![]; }
let Some( face ) = rustybuzz::Face::from_slice( font_bytes, face_index ) else
{
return vec![];
};
let units_per_em = face.units_per_em() as f32;
if units_per_em <= 0.0 { return vec![]; }
let scale = px / units_per_em;
let mut buf = UnicodeBuffer::new();
buf.push_str( text );
buf.set_direction( if rtl { Direction::RightToLeft } else { Direction::LeftToRight } );
let output = rustybuzz::shape( &face, &[], buf );
let infos = output.glyph_infos();
let pos = output.glyph_positions();
infos.iter().zip( pos.iter() ).map( |( info, p )|
{
ShapedGlyph
{
glyph_id: info.glyph_id,
x_advance: p.x_advance as f32 * scale,
x_offset: p.x_offset as f32 * scale,
y_offset: p.y_offset as f32 * scale,
}
} ).collect()
}

View File

@@ -92,6 +92,11 @@ pub struct Container<Msg: Clone>
/// Mirrors the same flag on [`Column`](crate::layout::column::Column)
/// and [`Row`](crate::layout::row::Row).
pub max_width: Option<f32>,
/// When true, the contents of this container are announced by
/// assistive technologies as a `Live::Polite` region — useful for
/// toasts, status banners and OSDs that need to be read on
/// appearance even when the user has not navigated to them.
pub a11y_live: bool,
}
impl<Msg: Clone> Container<Msg>
@@ -111,9 +116,16 @@ impl<Msg: Clone> Container<Msg>
opacity: 1.0,
border: None,
max_width: None,
a11y_live: false,
}
}
pub fn live_region( mut self, live: bool ) -> Self
{
self.a11y_live = live;
self
}
/// Paint a rounded-rect stroke around the container with the given
/// colour and pixel width. Useful for input fields, popovers and
/// any chrome the design system specifies as outlined rather than
@@ -285,6 +297,7 @@ impl<Msg: Clone> Container<Msg>
opacity: self.opacity,
border: self.border,
max_width: self.max_width,
a11y_live: self.a11y_live,
}
}
}

View File

@@ -251,6 +251,25 @@ impl<Msg: Clone> Element<Msg>
///
/// Containers and layouts that delegate to children return
/// [`WidgetHandlers::None`] — only leaf widgets actually carry payload.
pub( crate ) fn accessible_label( &self ) -> Option<String>
{
match self
{
Element::Button( b ) => match &b.content
{
super::button::ButtonContent::Text( s ) => Some( s.clone() ),
super::button::ButtonContent::Icon { .. } => b.tooltip.clone(),
},
Element::Toggle( t ) => t.label.clone(),
Element::Checkbox( c ) => c.label.clone(),
Element::Radio( r ) => r.label.clone(),
Element::ListItem( l ) => Some( l.label.clone() ).filter( |s| !s.is_empty() ),
Element::WindowButton( _ ) => None,
Element::Text( t ) => Some( t.content.clone() ).filter( |s| !s.is_empty() ),
_ => None,
}
}
pub( crate ) fn handlers( &self ) -> WidgetHandlers<Msg>
{
match self
@@ -271,9 +290,9 @@ impl<Msg: Clone> Element<Msg>
on_escape: p.on_escape.clone(),
repeating: false,
},
Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone() },
Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone() },
Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone() },
Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone(), value: t.value },
Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone(), value: c.checked },
Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone(), selected: r.selected },
Element::ListItem( l ) => WidgetHandlers::ListItem { on_press: l.on_press.clone() },
Element::WindowButton( b ) => WidgetHandlers::WindowButton { on_press: b.on_press.clone() },
Element::TextEdit( t ) =>
@@ -301,6 +320,7 @@ impl<Msg: Clone> Element<Msg>
{
on_change: s.on_change.clone(),
axis: slider::SliderAxis::Horizontal,
value: s.value,
}
}
Element::VSlider( s ) =>
@@ -309,6 +329,7 @@ impl<Msg: Clone> Element<Msg>
{
on_change: s.on_change.clone(),
axis: slider::SliderAxis::Vertical,
value: s.value,
}
}
_ => WidgetHandlers::None,

View File

@@ -30,9 +30,9 @@ pub enum WidgetHandlers<Msg: Clone>
on_escape: Option<Msg>,
repeating: bool,
},
Toggle { on_toggle: Option<Msg> },
Checkbox { on_toggle: Option<Msg> },
Radio { on_select: Option<Msg> },
Toggle { on_toggle: Option<Msg>, value: bool },
Checkbox { on_toggle: Option<Msg>, value: bool },
Radio { on_select: Option<Msg>, selected: bool },
ListItem { on_press: Option<Msg> },
WindowButton { on_press: Option<Msg> },
TextEdit
@@ -77,6 +77,7 @@ pub enum WidgetHandlers<Msg: Clone>
{
on_change: Option<Arc<dyn Fn( f32 ) -> Msg>>,
axis: slider::SliderAxis,
value: f32,
},
}
@@ -117,9 +118,9 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
on_escape: on_escape.clone(),
repeating: *repeating,
},
WidgetHandlers::Toggle { on_toggle } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone() },
WidgetHandlers::Checkbox { on_toggle } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone() },
WidgetHandlers::Radio { on_select } => WidgetHandlers::Radio { on_select: on_select.clone() },
WidgetHandlers::Toggle { on_toggle, value } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone(), value: *value },
WidgetHandlers::Checkbox { on_toggle, value } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone(), value: *value },
WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected },
WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() },
WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() },
WidgetHandlers::TextEdit { value, on_change, on_submit, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } =>
@@ -137,9 +138,9 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
password_toggle_msg: password_toggle_msg.clone(),
}
}
WidgetHandlers::Slider { on_change, axis } =>
WidgetHandlers::Slider { on_change, axis, value } =>
{
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis }
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value }
}
}
}
@@ -152,6 +153,23 @@ impl<Msg: Clone> WidgetHandlers<Msg>
matches!( self, WidgetHandlers::TextEdit { .. } )
}
pub fn is_disabled( &self ) -> bool
{
match self
{
WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape, .. } =>
on_press.is_none() && on_long_press.is_none() && on_drag_start.is_none() && on_escape.is_none(),
WidgetHandlers::Toggle { on_toggle, .. } => on_toggle.is_none(),
WidgetHandlers::Checkbox { on_toggle, .. } => on_toggle.is_none(),
WidgetHandlers::Radio { on_select, .. } => on_select.is_none(),
WidgetHandlers::ListItem { on_press } => on_press.is_none(),
WidgetHandlers::WindowButton { on_press } => on_press.is_none(),
WidgetHandlers::Slider { on_change, .. } => on_change.is_none(),
WidgetHandlers::TextEdit { on_change, on_submit, .. } => on_change.is_none() && on_submit.is_none(),
WidgetHandlers::None => true,
}
}
/// `true` when this is a [`WidgetHandlers::TextEdit`] whose source
/// widget was built with `.multiline( true )`. The keyboard
/// dispatch reads this so pressing Enter inserts a `\n` instead of
@@ -185,9 +203,9 @@ impl<Msg: Clone> WidgetHandlers<Msg>
match self
{
WidgetHandlers::Button { on_press, .. } => on_press.clone(),
WidgetHandlers::Toggle { on_toggle } => on_toggle.clone(),
WidgetHandlers::Checkbox { on_toggle } => on_toggle.clone(),
WidgetHandlers::Radio { on_select } => on_select.clone(),
WidgetHandlers::Toggle { on_toggle, .. } => on_toggle.clone(),
WidgetHandlers::Checkbox { on_toggle, .. } => on_toggle.clone(),
WidgetHandlers::Radio { on_select, .. } => on_select.clone(),
WidgetHandlers::ListItem { on_press } => on_press.clone(),
WidgetHandlers::WindowButton { on_press } => on_press.clone(),
_ => None,

View File

@@ -34,6 +34,10 @@ pub struct LaidOutWidget<Msg: Clone>
/// element tree.
pub cursor: crate::types::CursorShape,
pub tooltip: Option<String>,
/// Visible text of the widget, captured for the accessibility
/// tree. `None` for non-textual widgets (icons, dividers).
pub accessible_label: Option<String>,
pub is_live_region: bool,
}
impl<Msg: Clone> Clone for LaidOutWidget<Msg>
@@ -50,6 +54,8 @@ impl<Msg: Clone> Clone for LaidOutWidget<Msg>
keyboard_focusable: self.keyboard_focusable,
cursor: self.cursor,
tooltip: self.tooltip.clone(),
accessible_label: self.accessible_label.clone(),
is_live_region: self.is_live_region,
}
}
}

View File

@@ -8,7 +8,36 @@ use crate::widget::Element;
#[cfg(test)]
mod tests;
/// A vertically scrollable viewport that clips its child to its allocated rect.
/// Which axes a [`Scroll`] viewport allows to move along. Determines
/// whether the layout grows the child past the viewport width, the
/// viewport height, or both, and which axis gesture / wheel deltas
/// route to.
///
/// `Vertical` is the default (matches the historic behaviour) so
/// existing call sites do not need a builder call to migrate.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum ScrollAxis
{
/// Standard list-style scroll: child grows vertically, child width
/// is clamped to the viewport.
Vertical,
/// Strip / timeline scroll: child grows horizontally, child height
/// is clamped to the viewport.
Horizontal,
/// Two-axis pan (large tables, code views, maps). Child may exceed
/// the viewport on either axis.
Both,
}
impl ScrollAxis
{
#[ inline ]
pub fn allows_x( self ) -> bool { matches!( self, ScrollAxis::Horizontal | ScrollAxis::Both ) }
#[ inline ]
pub fn allows_y( self ) -> bool { matches!( self, ScrollAxis::Vertical | ScrollAxis::Both ) }
}
/// A scrollable viewport that clips its child to its allocated rect.
///
/// The child can be any element — typically a [`Column`](crate::layout::column::Column)
/// for lists or a [`WrapGrid`](crate::layout::wrap_grid::WrapGrid) for icon grids.
@@ -35,6 +64,8 @@ pub struct Scroll<Msg: Clone>
pub child: Box<Element<Msg>>,
/// Optional stable identifier — used as scroll state key.
pub id: Option<WidgetId>,
/// Which axis (or both) this viewport scrolls along.
pub axis: ScrollAxis,
}
impl<Msg: Clone> Scroll<Msg>
@@ -46,9 +77,25 @@ impl<Msg: Clone> Scroll<Msg>
self
}
/// Switch this viewport to horizontal scrolling. The child is laid
/// out at its preferred width (potentially larger than the
/// viewport) and clipped on the X axis.
pub fn horizontal( mut self ) -> Self
{
self.axis = ScrollAxis::Horizontal;
self
}
/// Allow both axes. Use for tables / code views / maps.
pub fn both( mut self ) -> Self
{
self.axis = ScrollAxis::Both;
self
}
/// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in
/// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer).
/// The actual viewport height is determined at render time from the rect the
/// The actual viewport size is determined at render time from the rect the
/// parent assigns.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
{
@@ -67,6 +114,7 @@ impl<Msg: Clone> Scroll<Msg>
{
child: Box::new( self.child.map_arc( f ) ),
id: self.id,
axis: self.axis,
}
}
}
@@ -79,19 +127,24 @@ impl<Msg: Clone + 'static> From<Scroll<Msg>> for Element<Msg>
}
}
/// Compute the clamped scroll offset given raw offset, content height, and viewport height.
/// Clamp a raw 1-D scroll offset to `[0, max(content - viewport, 0)]`.
///
/// Extracted as a pure function so it can be unit-tested without a Wayland surface.
/// Extracted as a pure function so it can be unit-tested without a
/// Wayland surface. Used for both axes by passing in the relevant
/// content / viewport dimensions.
pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f32
{
let max = (content_h - viewport_h).max( 0.0 );
offset.clamp( 0.0, max )
}
/// Create a scrollable viewport wrapping `child`.
/// Create a scrollable viewport wrapping `child`. Defaults to vertical
/// scrolling; chain [`Scroll::horizontal`] or [`Scroll::both`] to
/// switch axes.
///
/// The parent layout controls the viewport size by assigning a rect to this widget.
/// Content that overflows vertically is scrolled via drag gesture.
/// The parent layout controls the viewport size by assigning a rect to
/// this widget. Content that overflows along the allowed axis is
/// scrolled via drag gestures or the scroll wheel.
///
/// ```rust,no_run
/// # use ltk::{ column, scroll, text, Element };
@@ -103,5 +156,5 @@ pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f3
/// ```
pub fn scroll<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Scroll<Msg>
{
Scroll { child: Box::new( child.into() ), id: None }
Scroll { child: Box::new( child.into() ), id: None, axis: ScrollAxis::Vertical }
}

View File

@@ -23,6 +23,8 @@ pub fn lw_none( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
keyboard_focusable: true,
cursor: CursorShape::Default,
tooltip: None,
accessible_label: None,
is_live_region: false,
}
}
@@ -39,6 +41,8 @@ pub fn lw_button( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
keyboard_focusable: true,
cursor: CursorShape::Pointer,
tooltip: None,
accessible_label: None,
is_live_region: false,
}
}
@@ -57,6 +61,8 @@ pub fn lw_chrome( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
keyboard_focusable: false,
cursor: CursorShape::Pointer,
tooltip: None,
accessible_label: None,
is_live_region: false,
}
}

View File

@@ -102,12 +102,12 @@ fn toggle_and_checkbox_messages_are_remapped()
{
match h
{
WidgetHandlers::Toggle { on_toggle: Some( m ) } =>
WidgetHandlers::Toggle { on_toggle: Some( m ), .. } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Toggled ) );
toggle_seen = true;
}
WidgetHandlers::Checkbox { on_toggle: Some( m ) } =>
WidgetHandlers::Checkbox { on_toggle: Some( m ), .. } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Toggled ) );
checkbox_seen = true;
@@ -129,7 +129,7 @@ fn radio_and_list_item_messages_are_remapped()
{
match h
{
WidgetHandlers::Radio { on_select: Some( m ) } =>
WidgetHandlers::Radio { on_select: Some( m ), .. } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Selected ) );
radio_seen = true;
@@ -154,7 +154,7 @@ fn slider_callbacks_run_through_the_mapper()
let mut v_axis = false;
for_each_handler( &surface, |h|
{
if let WidgetHandlers::Slider { on_change: Some( cb ), axis } = h
if let WidgetHandlers::Slider { on_change: Some( cb ), axis, .. } = h
{
// `cb` binds by-ref through default match ergonomics, so
// reach the trait object via `**cb` to invoke it.

View File

@@ -53,7 +53,7 @@ fn toggle_handler_variant_is_toggle()
.into();
let surface = render_view( view );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert!( matches!( h, WidgetHandlers::Toggle { on_toggle: Some( _ ) } ) );
assert!( matches!( h, WidgetHandlers::Toggle { on_toggle: Some( _ ), .. } ) );
}
#[ test ]
@@ -79,7 +79,7 @@ fn checkbox_handler_carries_on_toggle_message()
let idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( idx ).unwrap();
assert!( matches!( h, WidgetHandlers::Checkbox { on_toggle: Some( _ ) } ) );
assert!( matches!( h, WidgetHandlers::Checkbox { on_toggle: Some( _ ), .. } ) );
assert_eq!( h.press_msg(), Some( Msg::TermsAccepted ) );
}
@@ -112,7 +112,7 @@ fn radio_handler_carries_on_select_message()
let idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( idx ).unwrap();
assert!( matches!( h, WidgetHandlers::Radio { on_select: Some( _ ) } ) );
assert!( matches!( h, WidgetHandlers::Radio { on_select: Some( _ ), .. } ) );
assert_eq!( h.press_msg(), Some( Msg::PriorityHigh ) );
}