diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8f28d1b..36c0822 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 00fe54e..fddc7c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/Makefile b/Makefile index 40822cc..a9ab503 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index f94faa8..6743b4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/src/a11y/mod.rs b/src/a11y/mod.rs new file mode 100644 index 0000000..54afd2d --- /dev/null +++ b/src/a11y/mod.rs @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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; + +/// 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 + { + 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( &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 + { + 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, +} + +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 ) {} +} diff --git a/src/a11y/tree.rs b/src/a11y/tree.rs new file mode 100644 index 0000000..8bdaa28 --- /dev/null +++ b/src/a11y/tree.rs @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + 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 +{ + 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( + focus_id: u8, + w: &LaidOutWidget, + 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 = 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( handlers: &WidgetHandlers, visible: Option<&str>, tooltip: Option<&str> ) -> Option +{ + 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 ], + pub extras: &'a [ AccessibleExtra ], + pub focused_idx: Option, + pub pressed_idx: Option, + pub pending_text_values: &'a HashMap, + pub cursor_state: &'a HashMap, + pub width: f32, + pub height: f32, + pub offset_x: f32, + pub offset_y: f32, +} + +pub( crate ) fn build_tree( + surfaces: &[ SurfaceView ], + 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 = 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 = 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 = 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() ); + } +} diff --git a/src/app.rs b/src/app.rs index b2f24a6..32200fd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 diff --git a/src/core.rs b/src/core.rs index 72e0f66..6b02c81 100644 --- a/src/core.rs +++ b/src/core.rs @@ -99,7 +99,7 @@ pub struct UiSurface widget_rects: Vec>, cursor_state: HashMap, selection_anchor: HashMap, - scroll_offsets: HashMap, + scroll_offsets: HashMap, scroll_canvases: HashMap, scroll_navigable_items: HashMap>, content_dirty: bool, @@ -400,6 +400,8 @@ impl UiSurface 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 ); diff --git a/src/draw/damage.rs b/src/draw/damage.rs index de53493..0ff48f1 100644 --- a/src/draw/damage.rs +++ b/src/draw/damage.rs @@ -234,6 +234,8 @@ mod tests keyboard_focusable: true, cursor: crate::types::CursorShape::Default, tooltip: None, + accessible_label: None, + is_live_region: false, } } diff --git a/src/draw/gles.rs b/src/draw/gles.rs index d3af437..0938993 100644 --- a/src/draw/gles.rs +++ b/src/draw/gles.rs @@ -102,6 +102,8 @@ pub( crate ) fn draw_surface_full_gpu( 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::( view, canvas, screen_rect, &mut ctx, 0 ); @@ -134,6 +136,7 @@ pub( crate ) fn draw_surface_full_gpu( 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( 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::( view, canvas, screen_rect, &mut ctx, 0 ); @@ -231,6 +236,7 @@ pub( crate ) fn draw_surface_partial_gpu( 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; diff --git a/src/draw/layout.rs b/src/draw/layout.rs index e0ba212..a625efd 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -130,6 +130,8 @@ pub( crate ) fn layout_and_draw( 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::( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 ) @@ -138,6 +140,8 @@ pub( crate ) fn layout_and_draw( { 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( }; let result = layout_and_draw::( 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( .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( // 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( 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( // (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( // 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( 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 } } diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 9dcb89b..8c2c627 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -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 pub selection_anchor: HashMap, pub widget_rects: Vec>, pub debug_layout: bool, - pub scroll_offsets: HashMap, - pub scroll_rects: Vec<(Rect, usize)>, + pub scroll_offsets: HashMap, + pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, pub scroll_canvases: HashMap, /// 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 /// re-position itself relative to that rect. Drivers populate this /// before invoking the recursive layout / draw walk. pub previous_widget_rects: Vec>, + /// Non-interactive widgets exposed only to the accessibility + /// tree (text labels, images, separators, progress bars). + pub accessible_extras: Vec, + /// 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( data: &mut AppData ) -{ - // 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::( - &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::( - 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( - ss: &mut SurfaceState, - compositor: &smithay_client_toolkit::compositor::CompositorState, - egl_ctx: Option<&Arc>, - view: &Element, - 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, - ); - } -} diff --git a/src/draw/software.rs b/src/draw/software.rs index 89a4df5..6ea9ee7 100644 --- a/src/draw/software.rs +++ b/src/draw/software.rs @@ -99,6 +99,8 @@ pub( crate ) fn draw_surface_full( 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::( view, canvas, screen_rect, &mut ctx, 0 ); @@ -146,6 +148,7 @@ pub( crate ) fn draw_surface_full( 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( 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::( view, canvas, screen_rect, &mut ctx, 0 ); @@ -263,6 +268,7 @@ pub( crate ) fn draw_surface_partial( 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; diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 0722d17..88bf2e4 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -80,6 +80,56 @@ pub struct AppData pub current_cursor_shape: Option, pub text_input_manager: Option, pub text_input: Option, + /// `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, + /// 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, + + /// `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, + /// 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, + /// 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, + /// 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, + /// Receiver half — drained once per run loop iteration. + pub clipboard_inbox_rx: std::sync::mpsc::Receiver, + + /// 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, + pub drop_inbox_tx: std::sync::mpsc::Sender, + pub drop_inbox_rx: std::sync::mpsc::Receiver, + + /// 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, /// 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 /// pointer leaving the surface, a gesture cancel, focus loss or /// long-press promotion. pub button_repeat: Option, - /// 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 AppData } } + /// 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> + { + 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 @@ -234,12 +297,90 @@ impl AppData } } + /// Non-panicking variant of [`surface_mut`]. + pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState> + { + 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::( &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 diff --git a/src/event_loop/clipboard.rs b/src/event_loop/clipboard.rs index 671a85c..95eeb5d 100644 --- a/src/event_loop/clipboard.rs +++ b/src/event_loop/clipboard.rs @@ -15,6 +15,7 @@ impl AppData if let Some( text ) = self.focused_selection_text( focus ) { self.clipboard = text; + self.publish_clipboard_selection(); } } @@ -26,6 +27,7 @@ impl AppData 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 ) { diff --git a/src/event_loop/data_device.rs b/src/event_loop/data_device.rs new file mode 100644 index 0000000..25349ec --- /dev/null +++ b/src/event_loop/data_device.rs @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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, std::sync::mpsc::Receiver ) +{ + std::sync::mpsc::channel() +} + +impl AppData +{ + /// 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 DataDeviceHandler for AppData +{ + fn enter( + &mut self, + _c: &Connection, + _qh: &QueueHandle, + d: &WlDataDevice, + x: f64, + y: f64, + _s: &WlSurface, + ) + { + let Some( data ) = d.data::() 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, _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, _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, d: &WlDataDevice ) + { + let Some( data ) = d.data::() 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, data_device: &WlDataDevice ) + { + let Some( data ) = data_device.data::() 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 DataOfferHandler for AppData +{ + fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle, _offer: &mut DragOffer, _actions: DndAction ) {} + fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle, _offer: &mut DragOffer, _actions: DndAction ) {} +} + +impl DataSourceHandler for AppData +{ + fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle, _source: &WlDataSource, _mime: Option ) {} + + fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle, 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, 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, _source: &WlDataSource ) {} + fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle, _source: &WlDataSource ) {} + fn action( &mut self, _c: &Connection, _qh: &QueueHandle, _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 +{ + 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, mpsc::Receiver ) +{ + mpsc::channel() +} diff --git a/src/event_loop/frame.rs b/src/event_loop/frame.rs new file mode 100644 index 0000000..c270579 --- /dev/null +++ b/src/event_loop/frame.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( data: &mut AppData ) +{ + 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::( + &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::( + 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( + ss: &mut SurfaceState, + compositor: &CompositorState, + egl_ctx: Option<&Arc>, + view: &Element, + 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, + ); + } +} diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index c181f61..57cbc65 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -143,10 +143,15 @@ impl LayerShellHandler for AppData } 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 SeatHandler for AppData } fn new_seat( - &mut self, _conn: &Connection, _qh: &QueueHandle, _seat: WlSeat, - ) {} + &mut self, _conn: &Connection, qh: &QueueHandle, 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 PopupHandler for AppData { 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!( @ AppData ); delegate_xdg_popup!( @ AppData ); delegate_foreign_toplevel_list!( @ AppData ); delegate_registry!( @ AppData ); +smithay_client_toolkit::delegate_activation!( @ AppData ); +smithay_client_toolkit::delegate_data_device!( @ AppData ); + +// --- `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 smithay_client_toolkit::activation::ActivationHandler for AppData +{ + 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::( &wl, token ); + } + } +} impl ProvidesRegistryState for AppData { @@ -581,16 +624,16 @@ impl Dispatch for AppData } } } - 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(); + } } _ => {} } diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index dce1520..81a2054 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -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; diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index bc0a113..406be74 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -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( app: A ) -> Result<(), RunError> }; let text_input_manager: Option = 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( 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( 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( 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> = 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 = + 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 = 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. diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index 42ae3d6..f0f70f2 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -178,8 +178,14 @@ pub( crate ) struct SurfaceState /// cursor; pointer drags set both ends. pub selection_anchor: HashMap, pub pending_text_values: HashMap, - pub scroll_offsets: HashMap, - 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, + pub scroll_offsets: HashMap, + pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, pub scroll_canvases: HashMap, /// 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 /// (`src/input/`) calls the lifecycle methods; the long-press /// deadline poller reaches into `gesture.long_press_*` directly. pub gesture: GestureState, + /// `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, + /// 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, /// Previous frame interaction state for damage tracking pub prev_focused: Option, pub prev_hovered: Option, @@ -253,9 +271,12 @@ impl SurfaceState 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 SurfaceState 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 SurfaceState } } 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 SurfaceState } 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; diff --git a/src/event_loop/text_editing/insert_delete.rs b/src/event_loop/text_editing/insert_delete.rs index f8a5dde..96e539f 100644 --- a/src/event_loop/text_editing/insert_delete.rs +++ b/src/event_loop/text_editing/insert_delete.rs @@ -130,6 +130,87 @@ impl AppData } } + /// 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 }; diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs index b33a140..fef0a25 100644 --- a/src/event_loop/tooltip.rs +++ b/src/event_loop/tooltip.rs @@ -30,7 +30,7 @@ impl AppData { 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 AppData { 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; diff --git a/src/gles_render/helpers.rs b/src/gles_render/helpers.rs index e2428e6..8adfc6f 100644 --- a/src/gles_render/helpers.rs +++ b/src/gles_render/helpers.rs @@ -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 ) ); diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index 7984efd..8b19af6 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -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` 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, + /// 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>, + /// 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` 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, // 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 ); diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index 4466f29..a219c09 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -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> = OnceLock::new(); +static DEFAULT_FONT_GLES: OnceLock = OnceLock::new(); -fn default_font_gles() -> Arc +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, 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(), diff --git a/src/gles_render/shaders.rs b/src/gles_render/shaders.rs index fe89431..c7d1efd 100644 --- a/src/gles_render/shaders.rs +++ b/src/gles_render/shaders.rs @@ -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); } diff --git a/src/gles_render/text.rs b/src/gles_render/text.rs index 87dda4f..ebb7394 100644 --- a/src/gles_render/text.rs +++ b/src/gles_render/text.rs @@ -1,27 +1,29 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! 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 ) { 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> ) { 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 ) = 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 + { + 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` for + // rasterization. The `shape_line` step kept the id as the + // `Arc::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 )> = 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 = 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 ) -> f32 { - text.chars().map( |ch| + self.measure_inner( text, size, Some( font ) ) + } + + fn measure_inner( &self, text: &str, size: f32, font: Option<&Arc> ) -> 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 ) - { - 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 + { + 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; + } + } diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 56a5b65..9a63ced 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -67,7 +67,7 @@ pub struct GestureState pub pressed_idx: Option, /// Index of the Scroll viewport that owns the current gesture, if /// the press landed inside one. - pub scrolling_widget: Option, + 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 GestureState &mut self, pos: Point, widget_rects: &[LaidOutWidget], - scroll_rects: &[( Rect, usize )], + scroll_rects: &[( Rect, usize, crate::widget::scroll::ScrollAxis )], ) -> PressOutcome { let hit = find_widget_at( widget_rects, pos ); @@ -176,8 +176,8 @@ impl GestureState 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 GestureState &mut self, pos: Point, widget_rects: &[LaidOutWidget], - scroll_offsets: &mut HashMap, + scroll_offsets: &mut HashMap, swipe: &SwipeConfig, global_drag: bool, ) -> MoveOutcome @@ -292,15 +292,23 @@ impl GestureState // 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; } diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index 866f9f6..24b395f 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -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> = 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::::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> = 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::::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> = 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::::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> = 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::::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> = vec![]; let mut g = GestureState::::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::::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; diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs index bb95512..a3f35cf 100644 --- a/src/input/keyboard/mod.rs +++ b/src/input/keyboard/mod.rs @@ -47,6 +47,7 @@ impl KeyboardHandler for AppData 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 KeyboardHandler for AppData { 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( diff --git a/src/input/keyboard/nav.rs b/src/input/keyboard/nav.rs index 9b4df53..0575cf9 100644 --- a/src/input/keyboard/nav.rs +++ b/src/input/keyboard/nav.rs @@ -22,7 +22,7 @@ impl AppData 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 AppData }; 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 } diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs index e00877a..c482f3b 100644 --- a/src/input/pointer/scroll.rs +++ b/src/input/pointer/scroll.rs @@ -30,24 +30,45 @@ impl AppData 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 — diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 9782e77..0360263 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -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` 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 TouchHandler for AppData 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 TouchHandler for AppData ) { 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 TouchHandler for AppData 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 TouchHandler for AppData { 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 TouchHandler for AppData fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle, _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| { 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, 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(); } diff --git a/src/lib.rs b/src/lib.rs index c12977b..ee3eb1b 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 }; diff --git a/src/render/mod.rs b/src/render/mod.rs index e9dbd45..d96ff63 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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` 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, + /// 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>, + /// 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` diff --git a/src/render/setup.rs b/src/render/setup.rs index 1cbb1a7..b9400b3 100644 --- a/src/render/setup.rs +++ b/src/render/setup.rs @@ -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> = OnceLock::new(); +/// Process-wide cache of the default font face. The handle keeps the +/// raw bytes (`Arc>`) alongside the fontdue `Arc` 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 = OnceLock::new(); -fn default_font() -> Arc +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`. 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(); diff --git a/src/render/text.rs b/src/render/text.rs index e519806..3cb794d 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -1,10 +1,14 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! 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`: 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 ) -> 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`] so - /// callers can hold the handle across `&mut self` borrows of the - /// glyph cache. - fn default_font_for_char( &self, ch: char ) -> ( usize, Arc ) - { - 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 ) -> ( usize, Arc ) - { - 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 ) -> &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, 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 + { + 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` for + // rasterization — we keep a per-font-id index into a small + // vec of `Arc` so the rasterizer step does not have to + // re-walk the resolve fn (mut self borrow conflicts). + let mut fonts: Vec<( usize, Arc )> = 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 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 ) -> 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> ) -> 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 + { + 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, + } } } diff --git a/src/system_fonts.rs b/src/system_fonts.rs index 89f20c4..51f849e 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -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, + pub bytes: Arc>, + 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>> ] +fn slots() -> &'static [ OnceLock> ] { - static SLOTS: OnceLock>>>> = OnceLock::new(); + static SLOTS: OnceLock>>> = OnceLock::new(); SLOTS.get_or_init( || { ( 0..FALLBACK_FONT_CANDIDATES.len() ) @@ -90,9 +106,11 @@ fn slots() -> &'static [ OnceLock>> ] } /// Try to load and parse the fallback font at slot `idx`. Each -/// `OnceLock` wraps `Option>` so a missing or malformed -/// file is recorded as `None` and never re-attempted. -fn slot_font( idx: usize ) -> Option> +/// `OnceLock` wraps `Option` 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>` can be +/// handed to rustybuzz for shaping without re-reading the file. +fn slot_handle( idx: usize ) -> Option { let slot = &slots()[ idx ]; slot.get_or_init( || @@ -100,10 +118,15 @@ fn slot_font( idx: usize ) -> Option> 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> /// scripts (Devanagari, Arabic, …) whose total weight is a fraction /// of the CJK pack everyone was paying for unconditionally. pub fn lookup( ch: char ) -> Option> +{ + 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 { 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 diff --git a/src/text_shaping.rs b/src/text_shaping.rs new file mode 100644 index 0000000..c771799 --- /dev/null +++ b/src/text_shaping.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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( text: &str, px: f32, mut resolve_font: F ) -> Vec +where + F: FnMut( char ) -> Option, +{ + 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 = 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 = None; + let mut sub_run = String::new(); + let flush = | handle_opt: &Option, sub_run: &mut String, out: &mut Vec | + { + 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 ( ¤t_handle, &handle ) + { + ( Some( a ), Some( b ) ) => std::sync::Arc::ptr_eq( &a.font, &b.font ), + ( None, None ) => true, + _ => false, + }; + if !same + { + flush( ¤t_handle, &mut sub_run, &mut out ); + current_handle = handle.clone(); + } + sub_run.push( ch ); + } + flush( ¤t_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 +{ + 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() +} diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs index 31b0efe..0ebd754 100644 --- a/src/widget/container/mod.rs +++ b/src/widget/container/mod.rs @@ -92,6 +92,11 @@ pub struct Container /// Mirrors the same flag on [`Column`](crate::layout::column::Column) /// and [`Row`](crate::layout::row::Row). pub max_width: Option, + /// 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 Container @@ -111,9 +116,16 @@ impl Container 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 Container opacity: self.opacity, border: self.border, max_width: self.max_width, + a11y_live: self.a11y_live, } } } diff --git a/src/widget/element.rs b/src/widget/element.rs index d6bee19..8587c1c 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -251,6 +251,25 @@ impl Element /// /// Containers and layouts that delegate to children return /// [`WidgetHandlers::None`] — only leaf widgets actually carry payload. + pub( crate ) fn accessible_label( &self ) -> Option + { + 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 { match self @@ -271,9 +290,9 @@ impl Element 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 Element { on_change: s.on_change.clone(), axis: slider::SliderAxis::Horizontal, + value: s.value, } } Element::VSlider( s ) => @@ -309,6 +329,7 @@ impl Element { on_change: s.on_change.clone(), axis: slider::SliderAxis::Vertical, + value: s.value, } } _ => WidgetHandlers::None, diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 1b9a12d..80dbf10 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -30,9 +30,9 @@ pub enum WidgetHandlers on_escape: Option, repeating: bool, }, - Toggle { on_toggle: Option }, - Checkbox { on_toggle: Option }, - Radio { on_select: Option }, + Toggle { on_toggle: Option, value: bool }, + Checkbox { on_toggle: Option, value: bool }, + Radio { on_select: Option, selected: bool }, ListItem { on_press: Option }, WindowButton { on_press: Option }, TextEdit @@ -77,6 +77,7 @@ pub enum WidgetHandlers { on_change: Option Msg>>, axis: slider::SliderAxis, + value: f32, }, } @@ -117,9 +118,9 @@ impl Clone for WidgetHandlers 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 Clone for WidgetHandlers 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 WidgetHandlers 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 WidgetHandlers 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, diff --git a/src/widget/laid_out.rs b/src/widget/laid_out.rs index e8eb77a..e1ba02e 100644 --- a/src/widget/laid_out.rs +++ b/src/widget/laid_out.rs @@ -34,6 +34,10 @@ pub struct LaidOutWidget /// element tree. pub cursor: crate::types::CursorShape, pub tooltip: Option, + /// Visible text of the widget, captured for the accessibility + /// tree. `None` for non-textual widgets (icons, dividers). + pub accessible_label: Option, + pub is_live_region: bool, } impl Clone for LaidOutWidget @@ -50,6 +54,8 @@ impl Clone for LaidOutWidget 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, } } } diff --git a/src/widget/scroll/mod.rs b/src/widget/scroll/mod.rs index 0a49410..16fcd13 100644 --- a/src/widget/scroll/mod.rs +++ b/src/widget/scroll/mod.rs @@ -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 pub child: Box>, /// Optional stable identifier — used as scroll state key. pub id: Option, + /// Which axis (or both) this viewport scrolls along. + pub axis: ScrollAxis, } impl Scroll @@ -46,9 +77,25 @@ impl Scroll 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 Scroll { child: Box::new( self.child.map_arc( f ) ), id: self.id, + axis: self.axis, } } } @@ -79,19 +127,24 @@ impl From> for Element } } -/// 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( child: impl Into> ) -> Scroll { - Scroll { child: Box::new( child.into() ), id: None } + Scroll { child: Box::new( child.into() ), id: None, axis: ScrollAxis::Vertical } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5b17d8a..9d72401 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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, } } diff --git a/tests/element_map.rs b/tests/element_map.rs index 527cb4b..969f113 100644 --- a/tests/element_map.rs +++ b/tests/element_map.rs @@ -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. diff --git a/tests/widget_dispatch.rs b/tests/widget_dispatch.rs index b7bdece..9e79dba 100644 --- a/tests/widget_dispatch.rs +++ b/tests/widget_dispatch.rs @@ -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 ) ); }