event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
Five orthogonal capabilities land together because they share the same `try_run` plumbing: an optional global is bound at startup, a piece of state is added to `AppData`, the run-loop iteration drains an inbox / pushes a frame snapshot, and the public surface gains a small set of opt-in `App` hooks. Nothing here breaks an existing app — every new path degrades to a no-op when the compositor does not advertise the relevant global or when the platform adapter cannot start. AT-SPI2 accessibility via AccessKit. A new `src/a11y/` module owns the platform adapter and the inbound `ActionRequest` channel. `A11yState::try_new` constructs an `accesskit_unix::Adapter`; when the AT-SPI2 daemon is not on the session bus (headless CI, locked-down compositors) the constructor returns `None` and the rest of the pipeline runs unchanged. After every successful `draw_frame`, the run loop builds a fresh `accesskit::TreeUpdate` from `widget_rects` and pushes it through the adapter — main surface plus every visible overlay, each translated to global coordinates via `surface_offset_for` so screen readers report positions in the same frame the user sees. Buttons / toggles / checkboxes / radios / list items / sliders / text edits map to the matching `Role`s; `Click` and `Focus` actions are advertised on every interactive node; inbound action requests are drained at the top of each iteration and translated into a synthetic press / focus on the matching widget. The integration is documented as best-effort in `docs/architecture.md` under "Known gaps and non-goals": hierarchical nesting, per-widget accessible names, live regions and `Action::SetValue` are listed as the natural follow-ups that the foundation now supports but does not yet wire. Cross-application clipboard via `wl_data_device_manager`. A new `src/event_loop/data_device.rs` bridges the existing process-local `clipboard: String` to the Wayland selection. Outbound (Ctrl+C / Cut): after the local clipboard is populated, `publish_clipboard_selection` creates a `CopyPasteSource` offering `text/plain;charset=utf-8` and installs it as the seat's selection; `DataSourceHandler::send` writes the cached string into the fd the peer hands us. Inbound (Ctrl+V from another app): `DataDeviceHandler::selection` asks for the offered text via `WlDataOffer::receive`, spawns a tiny worker thread to drain the read pipe with a 16 MiB cap to prevent paste-bomb DoS, and posts the result back through an `mpsc::Sender` that the run loop drains each iteration into `data.clipboard`. The `clipboard:` field's doc-comment is updated to reflect the new behaviour: process-local when the compositor does not advertise the global, synchronised with the seat selection otherwise. External drag-and-drop reception. The same `data_device` module handles `DragOffer` enter / motion / leave / drop_performed: `on_drop_motion( x, y )` fires while the drag hovers over the surface, `on_drop_leave()` when it withdraws without dropping, and `on_drop_received( x, y, mime, text )` when an external payload (`text/uri-list`, `text/plain`, …) is released on top of an ltk window. The receive path reuses the same worker-thread / channel pattern as the clipboard so the run loop never blocks on the read fd. Three new `App` hooks expose the events with no-op defaults; apps that ignore them get the previous behaviour. `xdg-activation-v1`. The global is bound optionally; when it is present, `try_run` reads `$XDG_ACTIVATION_TOKEN` from the environment, removes it immediately (single-use; preventing leaks into child processes) and stashes it on `AppData::activation_token_pending`. After the first successful configure of the main surface — the earliest point at which `xdg_activation_v1.activate` is meaningful — the token is consumed once and the surface raised to focus. Compositors without the global leave `activation_state` as `None` and the inbound path silently degrades. An `App::request_activation_token` outbound path is reserved on the trait but not yet exercised here. HarfBuzz shaping. A new `src/text_shaping.rs::shape_line` drives both renderers: the logical-order string is run through `unicode-bidi`, split into per-font sub-runs, and shaped through `rustybuzz`. Each `PositionedGlyph` carries the per-font `glyph_id`, the visual advance and the ink offsets — exactly what `fontdue::Font::rasterize_indexed` needs to render Arabic connected forms, Devanagari clusters and CJK shaped glyphs correctly. The GLES atlas is re-keyed on `(glyph_id, size_bits, font_id)` so glyphs from different fonts at the same size no longer collide, and the atlas format is selected per ES profile (`GL_R8` / `GL_RED` on ES3, `GL_LUMINANCE` on ES2) — the fragment shader samples `.r` for both, since `GL_LUMINANCE` replicates the coverage byte into `.r=.g=.b`. Software path follows the same key. New `Cargo.toml` deps: `unicode-bidi = "0.3"`, `rustybuzz = "0.14"`. Multi-touch hooks. `App::on_touch_down / on_touch_move / on_touch_up( id, x, y )` expose the raw `wl_touch.id` of every secondary finger. The first finger to land remains the *primary slot* and is fed through the regular gesture machine (`on_pointer_*`, swipe, scroll, long-press, drag-and-drop). Every additional finger fires the new callbacks instead, leaving the existing single-slot behaviour untouched for apps that do not override them. This is the substrate for app-defined pinch-zoom / two-finger pan; the toolkit itself does not yet ship a built-in pinch gesture (called out in the same "Known gaps" doc section). `event_loop::frame` extracted from `draw/mod.rs`. The `draw_frame` orchestrator and its per-format SHM helper (`pick_shm_format`) move into `src/event_loop/frame.rs`, leaving `draw/` strictly responsible for per-surface paint primitives. The import in `event_loop/run.rs` is rewritten accordingly; `draw/mod.rs` shrinks from 192-line orchestrator to a thin module index. Overlay teardown safety. `AppData::discard_overlay( id )` synchronously removes a destroyed overlay from the map and rewrites every per-device focus that pointed at it (pointer, keyboard, every touch slot), migrating an in-flight long-press drag to the main surface the same way `reconcile_overlays` does. Used by the compositor-driven destruction paths (`PopupHandler::done`, `LayerShellHandler::closed`) where waiting for the next reconcile would leave a window in which `surface()` / `surface_mut()` panic. The non-panicking siblings `try_surface` / `try_surface_mut` are added for callers on async dispatch paths (IME `Done`, tooltip arm) that may race a teardown. Miscellaneous. CI: `master` → `main` to match the actual default branch. `Makefile` adds `cargo run --example dialog` to the examples target. `src/lib.rs` re-exports `widget::scroll::ScrollAxis` so apps can configure a `scroll()` axis without reaching into a `pub(crate)` module. `Cargo.toml` adds `accesskit = "0.17"` and `accesskit_unix = "0.13"`. `docs/architecture.md` gains the "Known gaps and non-goals" section that enumerates the new capabilities, what still ships flat, and what is deferred (per-widget a11y labels, primary selection, intra-process multi-touch gestures, `wp_fractional_scale_v1`).
This commit is contained in:
155
src/a11y/mod.rs
Normal file
155
src/a11y/mod.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! AT-SPI2 accessibility via AccessKit.
|
||||
//!
|
||||
//! ltk does not implement the D-Bus AT-SPI2 protocol itself — that is
|
||||
//! delegated to [`accesskit_unix`], which translates a backend-neutral
|
||||
//! [`accesskit::TreeUpdate`] into the corresponding D-Bus calls,
|
||||
//! registers the application against `org.a11y.Bus`, and emits the
|
||||
//! property / state / focus change signals Orca and friends listen
|
||||
//! for. Our job is two-way:
|
||||
//!
|
||||
//! 1. **Outbound**: every frame, after the layout pass has populated
|
||||
//! `widget_rects`, build a fresh [`accesskit::TreeUpdate`] from
|
||||
//! those rects and push it into the adapter through
|
||||
//! [`A11yState::update`]. The runtime calls this from the same
|
||||
//! place that runs `draw_frame`, so the accessible tree is always
|
||||
//! in sync with what the user can see.
|
||||
//!
|
||||
//! 2. **Inbound**: an [`accesskit::ActionRequest`] from an assistive
|
||||
//! technology (Orca pressing the "Default Action" on a button, a
|
||||
//! switch-control device focusing the next node, …) lands on the
|
||||
//! [`ActionForwarder`] running inside the adapter's worker
|
||||
//! thread; the forwarder pushes it through an `mpsc::Sender` into
|
||||
//! the run loop, which drains the channel each iteration and
|
||||
//! translates the request into a synthetic press / focus on the
|
||||
//! target widget.
|
||||
//!
|
||||
//! The adapter is constructed unconditionally — `accesskit_unix`
|
||||
//! never refuses creation, it just stays inactive when no AT client
|
||||
//! attaches to the session bus. Nothing else in the pipeline reads
|
||||
//! the field except the per-frame tree update and the per-iteration
|
||||
//! action inbox drain, so the rest of the runtime is unaware of
|
||||
//! whether an AT is listening or not.
|
||||
|
||||
pub( crate ) mod tree;
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use accesskit::{ ActionRequest, Rect as A11yRect, TreeUpdate };
|
||||
|
||||
/// Channel carrying inbound action requests from the AccessKit
|
||||
/// adapter thread to the run loop.
|
||||
pub( crate ) type ActionInbox = mpsc::Receiver<ActionRequest>;
|
||||
|
||||
/// Top-level accessibility state owned by `AppData`. Holds the
|
||||
/// platform adapter alongside the receiver half of the action
|
||||
/// channel.
|
||||
pub( crate ) struct A11yState
|
||||
{
|
||||
adapter: accesskit_unix::Adapter,
|
||||
pub( crate ) action_rx: ActionInbox,
|
||||
}
|
||||
|
||||
impl A11yState
|
||||
{
|
||||
/// Bring up the AccessKit adapter. Returns `None` only when we
|
||||
/// reserve the right to refuse creation in the future (currently
|
||||
/// always `Some` — `accesskit_unix::Adapter::new` is infallible
|
||||
/// and stays inactive when no AT client is attached). Keeping
|
||||
/// the `Option` lets us toggle accessibility off at runtime via
|
||||
/// an env var or feature flag without re-plumbing every call
|
||||
/// site.
|
||||
pub( crate ) fn try_new( _app_name: &str, _app_id: &str ) -> Option<Self>
|
||||
{
|
||||
let ( action_tx, action_rx ) = mpsc::channel();
|
||||
let adapter = accesskit_unix::Adapter::new(
|
||||
ActivationStub,
|
||||
ActionForwarder { tx: action_tx },
|
||||
DeactivationStub,
|
||||
);
|
||||
Some( Self { adapter, action_rx } )
|
||||
}
|
||||
|
||||
/// Push a freshly-built tree into the adapter. The closure is
|
||||
/// invoked synchronously **only** when AT-SPI2 is currently
|
||||
/// observing the application (an AT client connected to the
|
||||
/// daemon and queried us at least once), so the cost of
|
||||
/// constructing the tree is paid only when something is actually
|
||||
/// listening.
|
||||
pub( crate ) fn update<F>( &mut self, factory: F )
|
||||
where
|
||||
F: FnOnce() -> TreeUpdate,
|
||||
{
|
||||
self.adapter.update_if_active( factory );
|
||||
}
|
||||
|
||||
/// AT-SPI clients filter by window focus; without it Orca skips
|
||||
/// the surface entirely.
|
||||
pub( crate ) fn set_window_focus( &mut self, focused: bool )
|
||||
{
|
||||
self.adapter.update_window_focus_state( focused );
|
||||
}
|
||||
|
||||
pub( crate ) fn set_window_bounds( &mut self, width: f64, height: f64 )
|
||||
{
|
||||
let r = A11yRect { x0: 0.0, y0: 0.0, x1: width, y1: height };
|
||||
self.adapter.set_root_window_bounds( r, r );
|
||||
}
|
||||
|
||||
#[ allow( dead_code ) ]
|
||||
pub( crate ) fn notify_focus( &mut self, new_focus: accesskit::NodeId )
|
||||
{
|
||||
self.adapter.update_if_active( ||
|
||||
{
|
||||
TreeUpdate
|
||||
{
|
||||
nodes: vec![],
|
||||
tree: None,
|
||||
focus: new_focus,
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
/// Activation handler the adapter calls when an assistive technology
|
||||
/// first attaches and asks for the initial tree. We hand back an
|
||||
/// empty root — the first proper update comes from the run loop on
|
||||
/// the very next frame, which is the natural moment to materialise a
|
||||
/// real tree (the widget_rects are populated by then).
|
||||
struct ActivationStub;
|
||||
|
||||
impl accesskit::ActivationHandler for ActivationStub
|
||||
{
|
||||
fn request_initial_tree( &mut self ) -> Option<TreeUpdate>
|
||||
{
|
||||
Some( tree::empty_root() )
|
||||
}
|
||||
}
|
||||
|
||||
/// Action handler that simply forwards every incoming request to the
|
||||
/// main event loop. The translation from `ActionRequest` to a
|
||||
/// synthetic press / focus happens on the main thread so it can
|
||||
/// touch the rest of `AppData` without locking.
|
||||
struct ActionForwarder
|
||||
{
|
||||
tx: mpsc::Sender<ActionRequest>,
|
||||
}
|
||||
|
||||
impl accesskit::ActionHandler for ActionForwarder
|
||||
{
|
||||
fn do_action( &mut self, request: ActionRequest )
|
||||
{
|
||||
let _ = self.tx.send( request );
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivation handler. AccessKit invokes this when every AT client
|
||||
/// detaches; we have no per-client cleanup so it's a no-op.
|
||||
struct DeactivationStub;
|
||||
|
||||
impl accesskit::DeactivationHandler for DeactivationStub
|
||||
{
|
||||
fn deactivate_accessibility( &mut self ) {}
|
||||
}
|
||||
384
src/a11y/tree.rs
Normal file
384
src/a11y/tree.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Construction of [`accesskit::TreeUpdate`] from the layout pass.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use accesskit::{ Action, Live, Node, NodeId, Rect as A11yRect, Role, TextPosition, TextSelection, Toggled, Tree, TreeUpdate };
|
||||
|
||||
use crate::types::Rect;
|
||||
use crate::widget::{ LaidOutWidget, WidgetHandlers };
|
||||
|
||||
#[ derive( Clone ) ]
|
||||
pub( crate ) enum AccessibleExtraKind
|
||||
{
|
||||
Label,
|
||||
Image,
|
||||
Separator,
|
||||
Progress( f32 ),
|
||||
}
|
||||
|
||||
#[ derive( Clone ) ]
|
||||
pub( crate ) struct AccessibleExtra
|
||||
{
|
||||
pub rect: Rect,
|
||||
pub label: Option<String>,
|
||||
pub live: bool,
|
||||
pub kind: AccessibleExtraKind,
|
||||
}
|
||||
|
||||
pub( crate ) const ROOT_ID: NodeId = NodeId( 1 );
|
||||
|
||||
const KIND_WIDGET: u8 = 0;
|
||||
const KIND_EXTRA: u8 = 1;
|
||||
const KIND_TEXT_RUN: u8 = 2;
|
||||
const KIND_OVERLAY: u8 = 3;
|
||||
const ID_BASE: u64 = 1 << 48;
|
||||
|
||||
#[ inline ]
|
||||
fn make_id( focus: u8, kind: u8, idx: u32 ) -> NodeId
|
||||
{
|
||||
NodeId( ID_BASE | ( ( focus as u64 ) << 40 ) | ( ( kind as u64 ) << 32 ) | ( idx as u64 ) )
|
||||
}
|
||||
|
||||
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||
pub( crate ) struct NodeRef
|
||||
{
|
||||
pub focus: u8,
|
||||
pub kind: u8,
|
||||
pub idx: u32,
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
pub( crate ) fn parse_id( id: NodeId ) -> Option<NodeRef>
|
||||
{
|
||||
let v = id.0;
|
||||
if v < ID_BASE { return None; }
|
||||
Some( NodeRef
|
||||
{
|
||||
focus: ( ( v >> 40 ) & 0xFF ) as u8,
|
||||
kind: ( ( v >> 32 ) & 0xFF ) as u8,
|
||||
idx: ( v & 0xFFFF_FFFF ) as u32,
|
||||
} )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
pub( crate ) fn widget_id_for( focus: u8, flat_idx: usize ) -> NodeId
|
||||
{
|
||||
make_id( focus, KIND_WIDGET, flat_idx as u32 )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn extra_id_for( focus: u8, idx: usize ) -> NodeId
|
||||
{
|
||||
make_id( focus, KIND_EXTRA, idx as u32 )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn text_run_id_for( focus: u8, flat_idx: usize ) -> NodeId
|
||||
{
|
||||
make_id( focus, KIND_TEXT_RUN, flat_idx as u32 )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn overlay_root_id( focus: u8 ) -> NodeId
|
||||
{
|
||||
make_id( focus, KIND_OVERLAY, 0 )
|
||||
}
|
||||
|
||||
pub( crate ) fn empty_root() -> TreeUpdate
|
||||
{
|
||||
let mut root = Node::new( Role::Window );
|
||||
root.set_label( "ltk" );
|
||||
TreeUpdate
|
||||
{
|
||||
nodes: vec![ ( ROOT_ID, root ) ],
|
||||
tree: Some( Tree::new( ROOT_ID ) ),
|
||||
focus: ROOT_ID,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_for<Msg: Clone>(
|
||||
focus_id: u8,
|
||||
w: &LaidOutWidget<Msg>,
|
||||
is_focused: bool,
|
||||
is_pressed: bool,
|
||||
pending_text: Option<&String>,
|
||||
cursor_byte: usize,
|
||||
out_text_run: &mut Option<( NodeId, Node )>,
|
||||
) -> Node
|
||||
{
|
||||
let role = match &w.handlers
|
||||
{
|
||||
WidgetHandlers::Button { .. } => Role::Button,
|
||||
WidgetHandlers::Toggle { .. } => Role::Switch,
|
||||
WidgetHandlers::Checkbox { .. } => Role::CheckBox,
|
||||
WidgetHandlers::Radio { .. } => Role::RadioButton,
|
||||
WidgetHandlers::ListItem { .. } => Role::ListItem,
|
||||
WidgetHandlers::WindowButton { .. } => Role::Button,
|
||||
WidgetHandlers::Slider { .. } => Role::Slider,
|
||||
WidgetHandlers::TextEdit { multiline, .. } =>
|
||||
if *multiline { Role::MultilineTextInput } else { Role::TextInput },
|
||||
WidgetHandlers::None => Role::GenericContainer,
|
||||
};
|
||||
let mut node = Node::new( role );
|
||||
node.set_bounds( convert_rect( w.rect ) );
|
||||
if let Some( name ) = label_for( &w.handlers, w.accessible_label.as_deref(), w.tooltip.as_deref() )
|
||||
{
|
||||
node.set_label( name );
|
||||
}
|
||||
if w.handlers.is_disabled()
|
||||
{
|
||||
node.set_disabled();
|
||||
}
|
||||
match &w.handlers
|
||||
{
|
||||
WidgetHandlers::Button { .. } | WidgetHandlers::WindowButton { .. } | WidgetHandlers::ListItem { .. } =>
|
||||
{
|
||||
node.add_action( Action::Click );
|
||||
node.add_action( Action::Focus );
|
||||
if is_pressed { node.set_toggled( Toggled::True ); }
|
||||
}
|
||||
WidgetHandlers::Toggle { value, .. } =>
|
||||
{
|
||||
node.add_action( Action::Click );
|
||||
node.add_action( Action::Focus );
|
||||
node.set_toggled( if *value { Toggled::True } else { Toggled::False } );
|
||||
}
|
||||
WidgetHandlers::Checkbox { value, .. } =>
|
||||
{
|
||||
node.add_action( Action::Click );
|
||||
node.add_action( Action::Focus );
|
||||
node.set_toggled( if *value { Toggled::True } else { Toggled::False } );
|
||||
}
|
||||
WidgetHandlers::Radio { selected, .. } =>
|
||||
{
|
||||
node.add_action( Action::Click );
|
||||
node.add_action( Action::Focus );
|
||||
node.set_toggled( if *selected { Toggled::True } else { Toggled::False } );
|
||||
}
|
||||
WidgetHandlers::Slider { value, .. } =>
|
||||
{
|
||||
node.add_action( Action::Focus );
|
||||
node.add_action( Action::SetValue );
|
||||
node.add_action( Action::Increment );
|
||||
node.add_action( Action::Decrement );
|
||||
node.set_min_numeric_value( 0.0 );
|
||||
node.set_max_numeric_value( 1.0 );
|
||||
node.set_numeric_value_step( 0.05 );
|
||||
node.set_numeric_value( *value as f64 );
|
||||
}
|
||||
WidgetHandlers::TextEdit { value, secure, .. } =>
|
||||
{
|
||||
node.add_action( Action::Focus );
|
||||
node.add_action( Action::SetValue );
|
||||
let exposed: String = if *secure
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
else
|
||||
{
|
||||
pending_text.cloned().unwrap_or_else( || value.clone() )
|
||||
};
|
||||
node.set_value( exposed.as_str() );
|
||||
|
||||
let run_id = text_run_id_for( focus_id, w.flat_idx );
|
||||
node.set_children( vec![ run_id ] );
|
||||
|
||||
let lengths: Vec<u8> = exposed.chars().map( |c| c.len_utf8() as u8 ).collect();
|
||||
let safe_byte = cursor_byte.min( exposed.len() );
|
||||
let cursor_char_idx = exposed[ ..safe_byte ].chars().count();
|
||||
let pos = TextPosition { node: run_id, character_index: cursor_char_idx };
|
||||
node.set_text_selection( TextSelection { anchor: pos, focus: pos } );
|
||||
|
||||
let mut run = Node::new( Role::TextRun );
|
||||
run.set_bounds( convert_rect( w.rect ) );
|
||||
run.set_value( exposed.as_str() );
|
||||
run.set_character_lengths( lengths );
|
||||
*out_text_run = Some( ( run_id, run ) );
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if is_focused
|
||||
{
|
||||
node.add_action( Action::Focus );
|
||||
}
|
||||
if w.is_live_region
|
||||
{
|
||||
node.set_live( Live::Polite );
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn convert_rect( r: Rect ) -> A11yRect
|
||||
{
|
||||
A11yRect
|
||||
{
|
||||
x0: r.x as f64,
|
||||
y0: r.y as f64,
|
||||
x1: ( r.x + r.width ) as f64,
|
||||
y1: ( r.y + r.height ) as f64,
|
||||
}
|
||||
}
|
||||
|
||||
fn label_for<Msg: Clone>( handlers: &WidgetHandlers<Msg>, visible: Option<&str>, tooltip: Option<&str> ) -> Option<String>
|
||||
{
|
||||
if let WidgetHandlers::TextEdit { secure: true, .. } = handlers
|
||||
{
|
||||
return Some( "password".to_string() );
|
||||
}
|
||||
visible.or( tooltip ).map( |s| s.to_string() )
|
||||
}
|
||||
|
||||
pub( crate ) struct SurfaceView<'a, Msg: Clone>
|
||||
{
|
||||
pub focus_id: u8,
|
||||
pub is_main: bool,
|
||||
pub label: Option<&'a str>,
|
||||
pub widget_rects: &'a [ LaidOutWidget<Msg> ],
|
||||
pub extras: &'a [ AccessibleExtra ],
|
||||
pub focused_idx: Option<usize>,
|
||||
pub pressed_idx: Option<usize>,
|
||||
pub pending_text_values: &'a HashMap<usize, String>,
|
||||
pub cursor_state: &'a HashMap<usize, usize>,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub offset_x: f32,
|
||||
pub offset_y: f32,
|
||||
}
|
||||
|
||||
pub( crate ) fn build_tree<Msg: Clone>(
|
||||
surfaces: &[ SurfaceView<Msg> ],
|
||||
keyboard_focus: u8,
|
||||
app_name: &str,
|
||||
) -> TreeUpdate
|
||||
{
|
||||
let main = surfaces.iter().find( |s| s.is_main );
|
||||
let ( total_w, total_h ) = main.map( |m| ( m.width, m.height ) ).unwrap_or( ( 0.0, 0.0 ) );
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
|
||||
let mut root = Node::new( Role::Window );
|
||||
root.set_label( app_name );
|
||||
root.set_bounds( A11yRect { x0: 0.0, y0: 0.0, x1: total_w as f64, y1: total_h as f64 } );
|
||||
let mut root_children: Vec<NodeId> = Vec::new();
|
||||
|
||||
if let Some( m ) = main
|
||||
{
|
||||
for w in m.widget_rects { root_children.push( widget_id_for( m.focus_id, w.flat_idx ) ); }
|
||||
for ( i, _ ) in m.extras.iter().enumerate() { root_children.push( extra_id_for( m.focus_id, i ) ); }
|
||||
}
|
||||
for s in surfaces.iter().filter( |s| !s.is_main )
|
||||
{
|
||||
root_children.push( overlay_root_id( s.focus_id ) );
|
||||
}
|
||||
root.set_children( root_children );
|
||||
nodes.push( ( ROOT_ID, root ) );
|
||||
|
||||
let mut active_focus: Option<NodeId> = None;
|
||||
|
||||
for s in surfaces
|
||||
{
|
||||
if !s.is_main
|
||||
{
|
||||
let mut dlg = Node::new( Role::Dialog );
|
||||
if let Some( l ) = s.label { dlg.set_label( l ); }
|
||||
dlg.set_bounds( A11yRect
|
||||
{
|
||||
x0: s.offset_x as f64, y0: s.offset_y as f64,
|
||||
x1: ( s.offset_x + s.width ) as f64, y1: ( s.offset_y + s.height ) as f64,
|
||||
} );
|
||||
let mut ch: Vec<NodeId> = Vec::new();
|
||||
for w in s.widget_rects { ch.push( widget_id_for( s.focus_id, w.flat_idx ) ); }
|
||||
for ( i, _ ) in s.extras.iter().enumerate() { ch.push( extra_id_for( s.focus_id, i ) ); }
|
||||
dlg.set_children( ch );
|
||||
nodes.push( ( overlay_root_id( s.focus_id ), dlg ) );
|
||||
}
|
||||
|
||||
for w in s.widget_rects
|
||||
{
|
||||
let is_focused = s.focused_idx == Some( w.flat_idx );
|
||||
let is_pressed = s.pressed_idx == Some( w.flat_idx );
|
||||
let pending = s.pending_text_values.get( &w.flat_idx );
|
||||
let cursor_byte = s.cursor_state.get( &w.flat_idx ).copied().unwrap_or( usize::MAX );
|
||||
let mut text_run: Option<( NodeId, Node )> = None;
|
||||
let mut node = node_for( s.focus_id, w, is_focused, is_pressed, pending, cursor_byte, &mut text_run );
|
||||
if !s.is_main
|
||||
{
|
||||
let mut r = w.rect;
|
||||
r.x += s.offset_x;
|
||||
r.y += s.offset_y;
|
||||
node.set_bounds( convert_rect( r ) );
|
||||
}
|
||||
let id = widget_id_for( s.focus_id, w.flat_idx );
|
||||
if is_focused && s.focus_id == keyboard_focus
|
||||
{
|
||||
active_focus = Some( id );
|
||||
}
|
||||
nodes.push( ( id, node ) );
|
||||
if let Some( ( rid, run ) ) = text_run { nodes.push( ( rid, run ) ); }
|
||||
}
|
||||
|
||||
for ( i, e ) in s.extras.iter().enumerate()
|
||||
{
|
||||
let role = match e.kind
|
||||
{
|
||||
AccessibleExtraKind::Label => Role::Label,
|
||||
AccessibleExtraKind::Image => Role::Image,
|
||||
AccessibleExtraKind::Separator => Role::Splitter,
|
||||
AccessibleExtraKind::Progress(_) => Role::ProgressIndicator,
|
||||
};
|
||||
let mut node = Node::new( role );
|
||||
let mut r = e.rect;
|
||||
if !s.is_main { r.x += s.offset_x; r.y += s.offset_y; }
|
||||
node.set_bounds( convert_rect( r ) );
|
||||
if let Some( ref l ) = e.label { node.set_label( l.as_str() ); }
|
||||
if let AccessibleExtraKind::Progress( v ) = e.kind
|
||||
{
|
||||
node.set_min_numeric_value( 0.0 );
|
||||
node.set_max_numeric_value( 1.0 );
|
||||
node.set_numeric_value( v as f64 );
|
||||
}
|
||||
if e.live { node.set_live( Live::Polite ); }
|
||||
nodes.push( ( extra_id_for( s.focus_id, i ), node ) );
|
||||
}
|
||||
}
|
||||
|
||||
let focus = active_focus.unwrap_or( ROOT_ID );
|
||||
|
||||
TreeUpdate
|
||||
{
|
||||
nodes,
|
||||
tree: Some( Tree::new( ROOT_ID ) ),
|
||||
focus,
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn id_round_trip()
|
||||
{
|
||||
for focus in [ 0u8, 1, 7, 200 ]
|
||||
{
|
||||
for idx in [ 0usize, 1, 42, 9999 ]
|
||||
{
|
||||
let id = widget_id_for( focus, idx );
|
||||
let r = parse_id( id ).expect( "parses" );
|
||||
assert_eq!( ( r.focus, r.kind, r.idx as usize ), ( focus, KIND_WIDGET, idx ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn root_is_not_a_widget()
|
||||
{
|
||||
assert!( parse_id( ROOT_ID ).is_none() );
|
||||
assert!( parse_id( NodeId( 0 ) ).is_none() );
|
||||
}
|
||||
}
|
||||
32
src/app.rs
32
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
|
||||
|
||||
@@ -99,7 +99,7 @@ pub struct UiSurface<Msg: Clone>
|
||||
widget_rects: Vec<LaidOutWidget<Msg>>,
|
||||
cursor_state: HashMap<usize, usize>,
|
||||
selection_anchor: HashMap<usize, usize>,
|
||||
scroll_offsets: HashMap<usize, f32>,
|
||||
scroll_offsets: HashMap<usize, ( f32, f32 )>,
|
||||
scroll_canvases: HashMap<usize, Canvas>,
|
||||
scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
|
||||
content_dirty: bool,
|
||||
@@ -400,6 +400,8 @@ impl<Msg: Clone> UiSurface<Msg>
|
||||
scroll_canvases: std::mem::take( &mut self.scroll_canvases ),
|
||||
scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ),
|
||||
previous_widget_rects: self.widget_rects.clone(),
|
||||
accessible_extras: Vec::new(),
|
||||
live_depth: 0,
|
||||
};
|
||||
|
||||
draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 );
|
||||
|
||||
@@ -234,6 +234,8 @@ mod tests
|
||||
keyboard_focusable: true,
|
||||
cursor: crate::types::CursorShape::Default,
|
||||
tooltip: None,
|
||||
accessible_label: None,
|
||||
is_live_region: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
|
||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
||||
previous_widget_rects: ss.widget_rects.clone(),
|
||||
accessible_extras: Vec::new(),
|
||||
live_depth: 0,
|
||||
};
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
|
||||
@@ -134,6 +136,7 @@ pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
|
||||
ss.cursor_state = ctx.cursor_state;
|
||||
ss.selection_anchor = ctx.selection_anchor;
|
||||
ss.scroll_offsets = ctx.scroll_offsets;
|
||||
ss.accessible_extras = ctx.accessible_extras;
|
||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
||||
ss.content_dirty = false;
|
||||
|
||||
@@ -206,6 +209,8 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
|
||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
||||
previous_widget_rects: ss.widget_rects.clone(),
|
||||
accessible_extras: Vec::new(),
|
||||
live_depth: 0,
|
||||
};
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
|
||||
@@ -231,6 +236,7 @@ pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
|
||||
ss.cursor_state = ctx.cursor_state;
|
||||
ss.selection_anchor = ctx.selection_anchor;
|
||||
ss.scroll_offsets = ctx.scroll_offsets;
|
||||
ss.accessible_extras = ctx.accessible_extras;
|
||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
||||
ss.content_dirty = false;
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
keyboard_focusable: false,
|
||||
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
|
||||
tooltip: None,
|
||||
accessible_label: None,
|
||||
is_live_region: ctx.live_depth > 0,
|
||||
} );
|
||||
}
|
||||
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
|
||||
@@ -138,6 +140,8 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
{
|
||||
let saved_alpha = canvas.global_alpha();
|
||||
canvas.set_global_alpha( saved_alpha * c.opacity );
|
||||
let live_inc = if c.a11y_live { 1 } else { 0 };
|
||||
ctx.live_depth += live_inc;
|
||||
|
||||
let rect = if let Some( mw ) = c.max_width
|
||||
{
|
||||
@@ -190,25 +194,33 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
};
|
||||
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
|
||||
|
||||
ctx.live_depth -= live_inc;
|
||||
canvas.set_global_alpha( saved_alpha );
|
||||
result
|
||||
}
|
||||
Element::Scroll( s ) =>
|
||||
{
|
||||
let my_idx = flat_idx;
|
||||
let offset_raw = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( 0.0 );
|
||||
let child_h = s.child.preferred_size( rect.width, canvas ).1;
|
||||
let offset = crate::widget::scroll::clamp_offset( offset_raw, child_h, rect.height );
|
||||
// Write the clamped offset back so input handlers (wheel /
|
||||
let axis = s.axis;
|
||||
let ( ox_raw, oy_raw ) = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( ( 0.0, 0.0 ) );
|
||||
// Width-aware preferred size: when the axis allows horizontal
|
||||
// overflow we let the child report its natural width by
|
||||
// asking with `f32::INFINITY`; otherwise the child stays
|
||||
// clamped to the viewport so wraps still happen.
|
||||
let child_w_max = if axis.allows_x() { f32::INFINITY } else { rect.width };
|
||||
let ( child_w, child_h ) = s.child.preferred_size( child_w_max, canvas );
|
||||
let ox = if axis.allows_x() { crate::widget::scroll::clamp_offset( ox_raw, child_w, rect.width ) } else { 0.0 };
|
||||
let oy = if axis.allows_y() { crate::widget::scroll::clamp_offset( oy_raw, child_h, rect.height ) } else { 0.0 };
|
||||
// Write the clamped offsets back so input handlers (wheel /
|
||||
// drag) cannot accumulate past the content extents. Without
|
||||
// this, repeated wheel ticks past the bottom keep growing
|
||||
// `offset_raw` and the user has to "undo" that excess before
|
||||
// the content scrolls back up. The clamp is idempotent for
|
||||
// in-range values, so the only effect is collapsing
|
||||
// out-of-range entries to the legitimate maximum.
|
||||
if offset != offset_raw
|
||||
// this, repeated wheel ticks past the edge keep growing
|
||||
// `*_raw` and the user has to "undo" that excess before the
|
||||
// content scrolls back. The clamp is idempotent for in-range
|
||||
// values, so the only effect is collapsing out-of-range
|
||||
// entries to the legitimate maximum.
|
||||
if ox != ox_raw || oy != oy_raw
|
||||
{
|
||||
ctx.scroll_offsets.insert( my_idx, offset );
|
||||
ctx.scroll_offsets.insert( my_idx, ( ox, oy ) );
|
||||
}
|
||||
|
||||
// Reuse the sub-canvas from the previous frame if its size matches;
|
||||
@@ -220,13 +232,15 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
.unwrap_or_else( || canvas.sub_canvas( sw, sh ) );
|
||||
sub.clear();
|
||||
|
||||
// Child content fills at least the full viewport height so that
|
||||
// children with VAlign::Bottom are positioned correctly when the
|
||||
// content is shorter than the viewport.
|
||||
let effective_h = child_h.max( rect.height );
|
||||
// Shift child up by offset so scrolled content appears at y=0 in
|
||||
// the sub-canvas.
|
||||
let child_rect = Rect { x: 0.0, y: -offset, width: rect.width, height: effective_h };
|
||||
// Child content fills at least the full viewport on each
|
||||
// allowed axis so that children with `Bottom`/`Right`
|
||||
// alignment are positioned correctly when the content is
|
||||
// shorter than the viewport along that axis.
|
||||
let effective_w = if axis.allows_x() { child_w.max( rect.width ) } else { rect.width };
|
||||
let effective_h = if axis.allows_y() { child_h.max( rect.height ) } else { rect.height };
|
||||
// Shift child by `(-ox, -oy)` so scrolled content appears at
|
||||
// `(0, 0)` of the sub-canvas.
|
||||
let child_rect = Rect { x: -ox, y: -oy, width: effective_w, height: effective_h };
|
||||
|
||||
let rects_before = ctx.widget_rects.len();
|
||||
let scroll_rects_before = ctx.scroll_rects.len();
|
||||
@@ -245,11 +259,11 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
// caring about which items are currently scrolled into view.
|
||||
// The recorded Y is in pre-translation, pre-offset coordinates
|
||||
// (i.e. `child_y` relative to the start of the scroll content),
|
||||
// recovered by undoing the `-offset` shift the child layout pass
|
||||
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + offset`.
|
||||
// recovered by undoing the `-oy` shift the child layout pass
|
||||
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + oy`.
|
||||
let navigable: Vec<( usize, f32, f32 )> = new_rects.iter()
|
||||
.filter( |w| w.handlers.is_navigable_list_item() )
|
||||
.map( |w| ( w.flat_idx, w.rect.y + offset, w.rect.height ) )
|
||||
.map( |w| ( w.flat_idx, w.rect.y + oy, w.rect.height ) )
|
||||
.collect();
|
||||
if !navigable.is_empty()
|
||||
{
|
||||
@@ -264,8 +278,12 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
w.paint_rect.x += rect.x;
|
||||
w.paint_rect.y += rect.y;
|
||||
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
|
||||
// Only keep widgets at least partially visible inside the viewport.
|
||||
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
|
||||
// Only keep widgets at least partially visible inside the
|
||||
// viewport — now bi-axial because Scroll::horizontal /
|
||||
// Scroll::both push content off-screen along x too.
|
||||
let visible_y = w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height;
|
||||
let visible_x = w.rect.x + w.rect.width > rect.x && w.rect.x < rect.x + rect.width;
|
||||
if visible_x && visible_y
|
||||
{
|
||||
ctx.widget_rects.push( w );
|
||||
}
|
||||
@@ -278,20 +296,20 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
// (0, 0) of the surface. Clamp to the outer scroll rect so
|
||||
// the inner scroll only captures wheel events inside its
|
||||
// visible region.
|
||||
let new_scroll_rects: Vec<( Rect, usize )> =
|
||||
let new_scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )> =
|
||||
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
|
||||
for ( mut r, idx ) in new_scroll_rects
|
||||
for ( mut r, idx, ax ) in new_scroll_rects
|
||||
{
|
||||
r.x += rect.x;
|
||||
r.y += rect.y;
|
||||
let clamped = clamp_rect_to( r, rect );
|
||||
if clamped.width > 0.0 && clamped.height > 0.0
|
||||
{
|
||||
ctx.scroll_rects.push( ( clamped, idx ) );
|
||||
ctx.scroll_rects.push( ( clamped, idx, ax ) );
|
||||
}
|
||||
}
|
||||
|
||||
ctx.scroll_rects.push( ( rect, my_idx ) );
|
||||
ctx.scroll_rects.push( ( rect, my_idx, axis ) );
|
||||
|
||||
canvas.blit( &sub, rect.x as i32, rect.y as i32 );
|
||||
|
||||
@@ -330,16 +348,16 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
// Translate scroll_rects pushed by nested scroll widgets
|
||||
// from sub-canvas to surface space. Same reasoning as the
|
||||
// scroll-inside-scroll case above.
|
||||
let new_scroll_rects: Vec<( Rect, usize )> =
|
||||
let new_scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )> =
|
||||
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
|
||||
for ( mut r, idx ) in new_scroll_rects
|
||||
for ( mut r, idx, ax ) in new_scroll_rects
|
||||
{
|
||||
r.x += rect.x;
|
||||
r.y += rect.y;
|
||||
let clamped = clamp_rect_to( r, rect );
|
||||
if clamped.width > 0.0 && clamped.height > 0.0
|
||||
{
|
||||
ctx.scroll_rects.push( ( clamped, idx ) );
|
||||
ctx.scroll_rects.push( ( clamped, idx, ax ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,8 +398,50 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
keyboard_focusable: other.is_focusable(),
|
||||
cursor: other.cursor_shape(),
|
||||
tooltip: other.tooltip().map( str::to_string ),
|
||||
accessible_label: other.accessible_label(),
|
||||
is_live_region: ctx.live_depth > 0,
|
||||
} );
|
||||
}
|
||||
else
|
||||
{
|
||||
let live = ctx.live_depth > 0;
|
||||
match other
|
||||
{
|
||||
Element::Text( t ) if !t.content.is_empty() =>
|
||||
{
|
||||
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
|
||||
{
|
||||
rect, label: Some( t.content.clone() ), live,
|
||||
kind: crate::a11y::tree::AccessibleExtraKind::Label,
|
||||
} );
|
||||
}
|
||||
Element::Image( _ ) =>
|
||||
{
|
||||
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
|
||||
{
|
||||
rect, label: None, live,
|
||||
kind: crate::a11y::tree::AccessibleExtraKind::Image,
|
||||
} );
|
||||
}
|
||||
Element::Separator( _ ) =>
|
||||
{
|
||||
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
|
||||
{
|
||||
rect, label: None, live,
|
||||
kind: crate::a11y::tree::AccessibleExtraKind::Separator,
|
||||
} );
|
||||
}
|
||||
Element::ProgressBar( p ) =>
|
||||
{
|
||||
ctx.accessible_extras.push( crate::a11y::tree::AccessibleExtra
|
||||
{
|
||||
rect, label: None, live,
|
||||
kind: crate::a11y::tree::AccessibleExtraKind::Progress( p.value ),
|
||||
} );
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flat_idx + 1
|
||||
}
|
||||
}
|
||||
|
||||
192
src/draw/mod.rs
192
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<Msg: Clone>
|
||||
pub selection_anchor: HashMap<usize, usize>,
|
||||
pub widget_rects: Vec<LaidOutWidget<Msg>>,
|
||||
pub debug_layout: bool,
|
||||
pub scroll_offsets: HashMap<usize, f32>,
|
||||
pub scroll_rects: Vec<(Rect, usize)>,
|
||||
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
|
||||
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
|
||||
pub scroll_canvases: HashMap<usize, Canvas>,
|
||||
/// Per-scroll navigation map: list of `(flat_idx, content_y, height)`
|
||||
/// for every interactive item the scroll's child laid out, in
|
||||
@@ -105,6 +83,11 @@ pub( crate ) struct DrawCtx<Msg: Clone>
|
||||
/// re-position itself relative to that rect. Drivers populate this
|
||||
/// before invoking the recursive layout / draw walk.
|
||||
pub previous_widget_rects: Vec<LaidOutWidget<Msg>>,
|
||||
/// Non-interactive widgets exposed only to the accessibility
|
||||
/// tree (text labels, images, separators, progress bars).
|
||||
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
|
||||
/// Depth counter for containers marked `a11y_live`.
|
||||
pub live_depth: u32,
|
||||
}
|
||||
|
||||
/// Paint the built-in Copy / Cut / Paste context menu on top of the
|
||||
@@ -172,156 +155,3 @@ pub( crate ) fn draw_context_menu(
|
||||
canvas.draw_line( r.x + 8.0, *y, r.x + r.width - 8.0, *y, sep_color, 1.0 );
|
||||
}
|
||||
}
|
||||
|
||||
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
|
||||
{
|
||||
// Caches were refreshed by the run loop just before calling us; pull them
|
||||
// by reference instead of re-invoking `App::view` / `App::overlays` each
|
||||
// frame. The two `expect`s document the run loop's contract.
|
||||
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
|
||||
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
|
||||
|
||||
let main_bg = data.app.background_color();
|
||||
let main_region = data.app.input_region();
|
||||
let debug_layout = data.debug_layout;
|
||||
let ( format, swap_rb ) = pick_shm_format( &data.shm );
|
||||
let egl_ctx = data.egl_context.as_ref();
|
||||
let qh = &data.qh;
|
||||
|
||||
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
|
||||
{
|
||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
|
||||
draw_surface::<A::Message>(
|
||||
&mut data.main,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
main_view,
|
||||
main_bg,
|
||||
main_region.as_deref(),
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
);
|
||||
data.main.needs_redraw = false;
|
||||
data.main.last_draw = std::time::Instant::now();
|
||||
}
|
||||
|
||||
for spec in overlays
|
||||
{
|
||||
if let Some( ss ) = data.overlays.get_mut( &spec.id )
|
||||
{
|
||||
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
|
||||
let focus = SurfaceFocus::Overlay( spec.id );
|
||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
|
||||
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
|
||||
draw_surface::<A::Message>(
|
||||
ss,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
&spec.view,
|
||||
bg,
|
||||
spec.input_region.as_deref(),
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
);
|
||||
ss.needs_redraw = false;
|
||||
ss.last_draw = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one surface's current view. Picks between software and GLES,
|
||||
/// and within each picks between full and partial redraw based on what
|
||||
/// changed since the last committed frame.
|
||||
///
|
||||
/// The decision:
|
||||
/// * **Skip** — `content_dirty` is false and no interaction state
|
||||
/// changed. Previously committed buffer stays on screen.
|
||||
/// * **Partial** — `content_dirty` is false but focus / hover / pressed
|
||||
/// transitioned. Canvas is preserved across frames, so clip to the
|
||||
/// dirty widgets and repaint only under the clip.
|
||||
/// * **Full** — `content_dirty` is true. Clear + redraw + damage.
|
||||
///
|
||||
/// Partial eligibility also bails out when the total dirty area >50%
|
||||
/// of the surface: at that ratio per-region clipping is no faster than
|
||||
/// a plain full redraw, so the code prefers one big damage rect over
|
||||
/// several small ones.
|
||||
fn draw_surface<Msg: Clone>(
|
||||
ss: &mut SurfaceState<Msg>,
|
||||
compositor: &smithay_client_toolkit::compositor::CompositorState,
|
||||
egl_ctx: Option<&Arc<crate::egl_context::EglContext>>,
|
||||
view: &Element<Msg>,
|
||||
bg: Color,
|
||||
input_region: Option<&[Rect]>,
|
||||
debug_layout: bool,
|
||||
shm_format: wl_shm::Format,
|
||||
swap_rb: bool,
|
||||
request_frame: &dyn Fn( &WlSurface ),
|
||||
)
|
||||
{
|
||||
let scale = ss.scale_factor.max( 1 ) as u32;
|
||||
let w = ss.width;
|
||||
let h = ss.height;
|
||||
if w == 0 || h == 0 { return; }
|
||||
let pw = w * scale;
|
||||
let ph = h * scale;
|
||||
|
||||
// Decide partial-redraw eligibility BEFORE allocating a buffer. If we end
|
||||
// up skipping the frame, we want to avoid touching the SHM pool at all.
|
||||
let canvas_ready = ss.canvas.as_ref()
|
||||
.map( |c| c.size() == ( pw, ph ) )
|
||||
.unwrap_or( false );
|
||||
let partial_eligible = !ss.content_dirty
|
||||
&& canvas_ready
|
||||
&& !ss.widget_rects.is_empty();
|
||||
|
||||
if partial_eligible
|
||||
{
|
||||
let dirty_rects = compute_interaction_dirty_rects(
|
||||
&ss.widget_rects,
|
||||
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
|
||||
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
|
||||
pw, ph,
|
||||
);
|
||||
if dirty_rects.is_empty()
|
||||
{
|
||||
// Nothing visible changed — keep the previously committed buffer.
|
||||
return;
|
||||
}
|
||||
// Total dirty area > 50% of screen: a full redraw is no slower than
|
||||
// per-region clipping but emits a single damage rect.
|
||||
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
|
||||
if total < pw as f32 * ph as f32 * 0.5
|
||||
{
|
||||
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
|
||||
{
|
||||
gles::draw_surface_partial_gpu(
|
||||
ss, compositor, ctx, view, bg, input_region,
|
||||
dirty_rects, pw, ph, scale, request_frame,
|
||||
);
|
||||
} else {
|
||||
software::draw_surface_partial(
|
||||
ss, compositor, view, bg, input_region,
|
||||
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
|
||||
{
|
||||
gles::draw_surface_full_gpu(
|
||||
ss, compositor, ctx, view, bg, input_region, debug_layout,
|
||||
pw, ph, scale, request_frame,
|
||||
);
|
||||
} else {
|
||||
software::draw_surface_full(
|
||||
ss, compositor, view, bg, input_region, debug_layout,
|
||||
shm_format, swap_rb, pw, ph, scale, request_frame,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
|
||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
||||
previous_widget_rects: ss.widget_rects.clone(),
|
||||
accessible_extras: Vec::new(),
|
||||
live_depth: 0,
|
||||
};
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
|
||||
@@ -146,6 +148,7 @@ pub( crate ) fn draw_surface_full<Msg: Clone>(
|
||||
ss.cursor_state = ctx.cursor_state;
|
||||
ss.selection_anchor = ctx.selection_anchor;
|
||||
ss.scroll_offsets = ctx.scroll_offsets;
|
||||
ss.accessible_extras = ctx.accessible_extras;
|
||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
||||
ss.content_dirty = false;
|
||||
|
||||
@@ -236,6 +239,8 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
|
||||
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
|
||||
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
|
||||
previous_widget_rects: ss.widget_rects.clone(),
|
||||
accessible_extras: Vec::new(),
|
||||
live_depth: 0,
|
||||
};
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
|
||||
@@ -263,6 +268,7 @@ pub( crate ) fn draw_surface_partial<Msg: Clone>(
|
||||
ss.cursor_state = ctx.cursor_state;
|
||||
ss.selection_anchor = ctx.selection_anchor;
|
||||
ss.scroll_offsets = ctx.scroll_offsets;
|
||||
ss.accessible_extras = ctx.accessible_extras;
|
||||
ss.scroll_navigable_items = ctx.scroll_navigable_items;
|
||||
ss.content_dirty = false;
|
||||
|
||||
|
||||
@@ -80,6 +80,56 @@ pub struct AppData<A: App>
|
||||
pub current_cursor_shape: Option<crate::types::CursorShape>,
|
||||
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
||||
pub text_input: Option<ZwpTextInputV3>,
|
||||
/// `xdg-activation-v1`. Present when the compositor advertises the
|
||||
/// global. Used on first configure to honour an incoming
|
||||
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
|
||||
/// main surface raised to focus) and exposed via
|
||||
/// [`App::request_activation_token`] for outbound requests.
|
||||
pub activation_state:
|
||||
Option<smithay_client_toolkit::activation::ActivationState>,
|
||||
/// Honour the activation token exactly once — after the first
|
||||
/// successful configure of the main surface. Subsequent configures
|
||||
/// (resizes, scale changes) must not re-activate.
|
||||
pub activation_token_pending: Option<String>,
|
||||
|
||||
/// `wl_data_device_manager` binding. `None` when the compositor
|
||||
/// does not advertise the global, in which case copy / paste stays
|
||||
/// process-local and inbound selections from other clients are
|
||||
/// invisible.
|
||||
pub data_device_manager:
|
||||
Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
|
||||
/// Per-seat `wl_data_device` handle, created the first time a seat
|
||||
/// with a keyboard or pointer capability appears. Required for
|
||||
/// `set_selection` and to receive inbound `selection` events.
|
||||
pub data_device:
|
||||
Option<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
|
||||
/// Currently-published outbound selection source. Held alive
|
||||
/// across [`DataSourceHandler::send_request`] so the cached
|
||||
/// clipboard text can be re-served on each paste from the peer.
|
||||
pub clipboard_source:
|
||||
Option<smithay_client_toolkit::data_device_manager::data_source::CopyPasteSource>,
|
||||
/// Sender half of the cross-thread channel used to ferry inbound
|
||||
/// selection bytes (a worker thread drains the read pipe).
|
||||
pub clipboard_inbox_tx: std::sync::mpsc::Sender<String>,
|
||||
/// Receiver half — drained once per run loop iteration.
|
||||
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
|
||||
|
||||
/// Cross-application drag-and-drop: best mime negotiated on
|
||||
/// `enter` and the last pointer position (in logical surface
|
||||
/// coords). Cleared on `leave` and after `drop_performed`.
|
||||
pub drop_position: Option<( f64, f64 )>,
|
||||
pub drop_mime: Option<String>,
|
||||
pub drop_inbox_tx: std::sync::mpsc::Sender<super::data_device::DropPayload>,
|
||||
pub drop_inbox_rx: std::sync::mpsc::Receiver<super::data_device::DropPayload>,
|
||||
|
||||
/// AccessKit / AT-SPI2 adapter. `None` when the platform adapter
|
||||
/// could not be created (no AT-SPI2 daemon on the session bus,
|
||||
/// missing system libraries at runtime, headless CI). The
|
||||
/// runtime carries on without an accessibility tree in that
|
||||
/// case; nothing else in the pipeline reads this field except
|
||||
/// the per-frame tree update and the per-iteration action
|
||||
/// inbox drain.
|
||||
pub a11y: Option<crate::a11y::A11yState>,
|
||||
/// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the
|
||||
/// global proxy plus the live list of open toplevels; SCTK fans
|
||||
/// events through this object into our `ForeignToplevelListHandler`
|
||||
@@ -113,12 +163,13 @@ pub struct AppData<A: App>
|
||||
/// pointer leaving the surface, a gesture cancel, focus loss or
|
||||
/// long-press promotion.
|
||||
pub button_repeat: Option<ButtonRepeatState>,
|
||||
/// Process-local clipboard buffer. Copy / Cut store the selected
|
||||
/// text here; Paste retrieves from here. Cross-application
|
||||
/// clipboard via `wl_data_device_manager` is intentionally not
|
||||
/// wired up — most app workflows need a same-process buffer first
|
||||
/// (move text between fields, undo a delete by paste-back) and
|
||||
/// the Wayland integration is a sizeable separate piece.
|
||||
/// Clipboard buffer. Copy / Cut store the selected text here;
|
||||
/// Paste retrieves from here. Synchronised with the Wayland
|
||||
/// selection through `wl_data_device_manager` when the compositor
|
||||
/// advertises the global: outbound copies publish a
|
||||
/// `CopyPasteSource`, and inbound selections from other clients
|
||||
/// land here through a worker thread (see
|
||||
/// [`super::data_device`]).
|
||||
pub clipboard: String,
|
||||
/// Timestamp of the previous press (pointer or touch). Combined
|
||||
/// with [`Self::last_press_pos`] it lets the press handler
|
||||
@@ -222,6 +273,18 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-panicking variant of [`surface`]. Returns `None` when `focus`
|
||||
/// refers to an overlay that has already been removed — callers on
|
||||
/// async dispatch paths (IME Done, tooltip arm) must use this.
|
||||
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message>>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
SurfaceFocus::Main => Some( &self.main ),
|
||||
SurfaceFocus::Overlay( id ) => self.overlays.get( &id ),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable counterpart of [`surface`].
|
||||
#[allow( dead_code )]
|
||||
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message>
|
||||
@@ -234,12 +297,90 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-panicking variant of [`surface_mut`].
|
||||
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message>>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
SurfaceFocus::Main => Some( &mut self.main ),
|
||||
SurfaceFocus::Overlay( id ) => self.overlays.get_mut( &id ),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous overlay teardown: removes the overlay from the map
|
||||
/// and rewrites every per-device focus that pointed at it so the
|
||||
/// next event in the same dispatch can no longer land on a freed
|
||||
/// surface. Used by the compositor-driven destruction paths
|
||||
/// (`PopupHandler::done`, `LayerShellHandler::closed`) where
|
||||
/// waiting for the next `reconcile_overlays` would leave a window
|
||||
/// in which `surface()` / `surface_mut()` panic. Migrates an
|
||||
/// in-flight long-press drag to the main surface for the same
|
||||
/// reason `reconcile_overlays` does.
|
||||
pub( crate ) fn discard_overlay( &mut self, id: crate::app::OverlayId )
|
||||
{
|
||||
if let Some( ss ) = self.overlays.remove( &id )
|
||||
{
|
||||
if ss.gesture.long_press_fired
|
||||
{
|
||||
self.main.gesture.long_press_fired = true;
|
||||
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
|
||||
}
|
||||
}
|
||||
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
|
||||
{
|
||||
if fid == id { self.pointer_focus = SurfaceFocus::Main; }
|
||||
}
|
||||
if let SurfaceFocus::Overlay( fid ) = self.keyboard_focus
|
||||
{
|
||||
if fid == id { self.keyboard_focus = SurfaceFocus::Main; }
|
||||
}
|
||||
for f in self.touch_focus.values_mut()
|
||||
{
|
||||
if let SurfaceFocus::Overlay( fid ) = *f
|
||||
{
|
||||
if fid == id { *f = SurfaceFocus::Main; }
|
||||
}
|
||||
}
|
||||
if let Some( pending ) = self.tooltip_pending.as_ref()
|
||||
{
|
||||
if pending.focus == SurfaceFocus::Overlay( id ) { self.tooltip_pending = None; }
|
||||
}
|
||||
if let Some( visible ) = self.tooltip_visible.as_ref()
|
||||
{
|
||||
if visible.focus == SurfaceFocus::Overlay( id )
|
||||
{
|
||||
self.tooltip_visible = None;
|
||||
self.overlays_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the main surface size, (re)allocate its rendering target, and
|
||||
// request a redraw. Routes to the GPU or SHM path inside `SurfaceState`
|
||||
// according to whether `self.egl_context` is available.
|
||||
pub( crate ) fn on_configure( &mut self, w: u32, h: u32 )
|
||||
{
|
||||
self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
if let Some( ref mut a ) = self.a11y
|
||||
{
|
||||
let sf = self.main.scale_factor.max( 1 ) as f64;
|
||||
a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf );
|
||||
a.set_window_focus( true );
|
||||
}
|
||||
// First-configure latch: honour an incoming
|
||||
// `XDG_ACTIVATION_TOKEN` exactly once, after the surface has
|
||||
// been mapped (`xdg_activation_v1.activate` is only meaningful
|
||||
// against a configured surface). Compositors that don't
|
||||
// advertise the global leave `activation_state` as `None` and
|
||||
// the call drops the token silently.
|
||||
if let Some( token ) = self.activation_token_pending.take()
|
||||
{
|
||||
if let ( Some( ref activation ), Some( wl ) ) =
|
||||
( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
||||
{
|
||||
activation.activate::<Self>( &wl, token );
|
||||
}
|
||||
}
|
||||
// `on_resize` is documented to deliver **physical** pixels, matching the
|
||||
// coordinate space that the layout passes and the pointer/touch
|
||||
// callbacks (`on_drag_move`, `on_drop`) work in. Wayland's
|
||||
|
||||
@@ -15,6 +15,7 @@ impl<A: App> AppData<A>
|
||||
if let Some( text ) = self.focused_selection_text( focus )
|
||||
{
|
||||
self.clipboard = text;
|
||||
self.publish_clipboard_selection();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ impl<A: App> AppData<A>
|
||||
if let Some( text ) = self.focused_selection_text( focus )
|
||||
{
|
||||
self.clipboard = text;
|
||||
self.publish_clipboard_selection();
|
||||
}
|
||||
if let Some( new_value ) = self.delete_selection( focus )
|
||||
{
|
||||
|
||||
251
src/event_loop/data_device.rs
Normal file
251
src/event_loop/data_device.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `wl_data_device_manager` integration — cross-application
|
||||
//! clipboard.
|
||||
//!
|
||||
//! This module bridges ltk's process-local `clipboard: String` to the
|
||||
//! Wayland selection so that:
|
||||
//!
|
||||
//! * **Outbound (Ctrl+C / Cut)** — after the local clipboard is
|
||||
//! populated, [`AppData::publish_clipboard_selection`] creates a
|
||||
//! [`CopyPasteSource`] offering `text/plain;charset=utf-8` and
|
||||
//! installs it as the seat's selection. When a peer pastes,
|
||||
//! [`DataSourceHandler::send`] writes the cached string into the
|
||||
//! fd the peer hands us.
|
||||
//!
|
||||
//! * **Inbound (Ctrl+V from another app)** — when the compositor
|
||||
//! advertises a new selection through
|
||||
//! [`DataDeviceHandler::selection`], we ask for it as UTF-8 text
|
||||
//! via `WlDataOffer::receive`, spawn a tiny thread to drain the
|
||||
//! read pipe, and post the result back to the event loop through
|
||||
//! the runtime's existing `ChannelSender`-style channel. The
|
||||
//! message just updates `self.clipboard`; subsequent local pastes
|
||||
//! pick the new value up the next time the user hits Ctrl+V.
|
||||
//!
|
||||
//! Drag-and-drop across applications uses the same protocol but
|
||||
//! involves enter / leave / motion / drop_performed wiring with
|
||||
//! widget hit-tests, surface coordinates and drop targets that are a
|
||||
//! separate concern. The handlers in this file accept those events
|
||||
//! but currently route them to no-ops; an explicit follow-up will
|
||||
//! cover DnD targets.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use smithay_client_toolkit::data_device_manager::data_device::{ DataDeviceData, DataDeviceHandler };
|
||||
use smithay_client_toolkit::data_device_manager::data_offer::{ DataOfferHandler, DragOffer, SelectionOffer };
|
||||
use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler;
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::
|
||||
{
|
||||
wl_data_device::WlDataDevice,
|
||||
wl_data_device_manager::DndAction,
|
||||
wl_data_source::WlDataSource,
|
||||
wl_surface::WlSurface,
|
||||
},
|
||||
Connection, Proxy, QueueHandle,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use super::app_data::AppData;
|
||||
|
||||
/// MIME type we advertise on outbound copies and request on inbound
|
||||
/// pastes. `text/plain;charset=utf-8` is the de-facto cross-toolkit
|
||||
/// baseline (GTK, Qt, electron, Firefox all carry it); `UTF8_STRING`
|
||||
/// is the X11-era legacy alias still emitted by some compositors.
|
||||
pub( super ) const CLIPBOARD_MIMES: &[ &str ] = &[ "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
|
||||
|
||||
/// Mimes accepted for inter-application drag-and-drop. URI lists
|
||||
/// (file managers, browsers) come first because they are the most
|
||||
/// useful payload for typical desktop drops.
|
||||
pub( super ) const DND_MIMES: &[ &str ] = &[ "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
|
||||
|
||||
/// Payload delivered from the DnD worker thread back to the run loop.
|
||||
pub( crate ) struct DropPayload
|
||||
{
|
||||
pub mime: String,
|
||||
pub text: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
pub( crate ) fn drop_inbox() -> ( std::sync::mpsc::Sender<DropPayload>, std::sync::mpsc::Receiver<DropPayload> )
|
||||
{
|
||||
std::sync::mpsc::channel()
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
/// Publish the current `self.clipboard` value as the seat's
|
||||
/// selection so other applications can paste it. Called by the
|
||||
/// copy / cut handlers after they populate the local buffer.
|
||||
/// No-op when the compositor does not advertise
|
||||
/// `wl_data_device_manager` or when no seat has appeared yet.
|
||||
pub( crate ) fn publish_clipboard_selection( &mut self )
|
||||
{
|
||||
let ( Some( ref ddm ), Some( ref dd ) ) = ( self.data_device_manager.as_ref(), self.data_device.as_ref() ) else { return };
|
||||
let source = ddm.create_copy_paste_source( &self.qh, CLIPBOARD_MIMES.iter().copied() );
|
||||
source.set_selection( dd, self.last_input_serial );
|
||||
// Hold the source alive: the compositor only keeps the
|
||||
// selection while the `WlDataSource` is not dropped. We also
|
||||
// need to hang on to it so `DataSourceHandler::send` can find
|
||||
// the cached text via `self.clipboard`.
|
||||
self.clipboard_source = Some( source );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> DataDeviceHandler for AppData<A>
|
||||
{
|
||||
fn enter(
|
||||
&mut self,
|
||||
_c: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
d: &WlDataDevice,
|
||||
x: f64,
|
||||
y: f64,
|
||||
_s: &WlSurface,
|
||||
)
|
||||
{
|
||||
let Some( data ) = d.data::<DataDeviceData>() else { return };
|
||||
let Some( offer ) = data.drag_offer() else { return };
|
||||
let mimes = offer.with_mime_types( |m| m.to_vec() );
|
||||
let accepted = DND_MIMES.iter().copied()
|
||||
.find( |c| mimes.iter().any( |m| m == *c ) )
|
||||
.map( |s| s.to_string() );
|
||||
offer.accept_mime_type( self.last_input_serial, accepted.clone() );
|
||||
offer.set_actions( smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy,
|
||||
smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy );
|
||||
self.drop_position = Some( ( x, y ) );
|
||||
self.drop_mime = accepted;
|
||||
self.app.on_drop_motion( x as f32, y as f32 );
|
||||
}
|
||||
|
||||
fn leave( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice )
|
||||
{
|
||||
if self.drop_position.is_some() { self.app.on_drop_leave(); }
|
||||
self.drop_position = None;
|
||||
self.drop_mime = None;
|
||||
}
|
||||
|
||||
fn motion( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice, x: f64, y: f64 )
|
||||
{
|
||||
self.drop_position = Some( ( x, y ) );
|
||||
self.app.on_drop_motion( x as f32, y as f32 );
|
||||
}
|
||||
|
||||
fn drop_performed( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, d: &WlDataDevice )
|
||||
{
|
||||
let Some( data ) = d.data::<DataDeviceData>() else { return };
|
||||
let Some( offer ) = data.drag_offer() else { return };
|
||||
let Some( mime ) = self.drop_mime.clone() else { offer.finish(); return };
|
||||
let ( x, y ) = self.drop_position.unwrap_or( ( 0.0, 0.0 ) );
|
||||
let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return };
|
||||
offer.finish();
|
||||
let tx = self.drop_inbox_tx.clone();
|
||||
std::thread::spawn( move ||
|
||||
{
|
||||
use std::io::Read;
|
||||
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
|
||||
let mut buf = String::new();
|
||||
let mut bounded = ( &mut reader ).take( 16 * 1024 * 1024 );
|
||||
let _ = bounded.read_to_string( &mut buf );
|
||||
let _ = tx.send( DropPayload { mime, text: buf, x, y } );
|
||||
} );
|
||||
self.drop_position = None;
|
||||
self.drop_mime = None;
|
||||
}
|
||||
|
||||
/// A new selection arrived from another client. Negotiate
|
||||
/// UTF-8 text on a background thread (the receive side of the
|
||||
/// data offer is a blocking read pipe) and post the result back
|
||||
/// into the event loop through `clipboard_inbox`. The main loop
|
||||
/// drains the inbox once per iteration and copies the bytes
|
||||
/// into `self.clipboard`.
|
||||
fn selection( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, data_device: &WlDataDevice )
|
||||
{
|
||||
let Some( data ) = data_device.data::<DataDeviceData>() else { return };
|
||||
let Some( offer ) = data.selection_offer() else { return };
|
||||
let Some( mime ) = pick_mime( &offer ) else { return };
|
||||
let Ok( pipe ) = offer.receive( mime ) else { return };
|
||||
let tx = self.clipboard_inbox_tx.clone();
|
||||
std::thread::spawn( move ||
|
||||
{
|
||||
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
|
||||
let mut buf = String::new();
|
||||
// 16 MiB cap. Compositors are free to keep typing into the
|
||||
// pipe forever; a runaway selection should not exhaust
|
||||
// memory or stall the worker thread indefinitely.
|
||||
let mut bounded = (&mut reader).take( 16 * 1024 * 1024 );
|
||||
let _ = bounded.read_to_string( &mut buf );
|
||||
let _ = tx.send( buf );
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> DataOfferHandler for AppData<A>
|
||||
{
|
||||
fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
|
||||
fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
|
||||
}
|
||||
|
||||
impl<A: App> DataSourceHandler for AppData<A>
|
||||
{
|
||||
fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _mime: Option<String> ) {}
|
||||
|
||||
fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource, _mime: String, fd: smithay_client_toolkit::data_device_manager::WritePipe )
|
||||
{
|
||||
// Only honour requests for our own active source — if a stale
|
||||
// source is still hanging around (e.g. between a new copy and
|
||||
// the compositor dropping the previous selection) the peer
|
||||
// might still ask for its data; we just write nothing rather
|
||||
// than leak the new clipboard contents through an old source.
|
||||
let is_active = self.clipboard_source.as_ref()
|
||||
.map( |s| s.inner() == source )
|
||||
.unwrap_or( false );
|
||||
if !is_active { return; }
|
||||
// SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File`
|
||||
// (no buffering — selection payloads are small) and write the
|
||||
// cached clipboard string. Errors (peer closed early, broken
|
||||
// pipe) are ignored: we cannot recover and the compositor
|
||||
// will issue a fresh source on the next copy anyway.
|
||||
use std::io::Write;
|
||||
let mut writer = std::fs::File::from( std::os::fd::OwnedFd::from( fd ) );
|
||||
let _ = writer.write_all( self.clipboard.as_bytes() );
|
||||
}
|
||||
|
||||
fn cancelled( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource )
|
||||
{
|
||||
// Compositor dropped our source (another client claimed the
|
||||
// selection). Drop the cached handle so a future copy creates
|
||||
// a fresh one.
|
||||
if self.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false )
|
||||
{
|
||||
self.clipboard_source = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn dnd_dropped( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
|
||||
fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
|
||||
fn action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _action: DndAction ) {}
|
||||
}
|
||||
|
||||
/// Pick the best-supported MIME type from a selection offer. Order
|
||||
/// matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text, then
|
||||
/// the legacy `UTF8_STRING`.
|
||||
fn pick_mime( offer: &SelectionOffer ) -> Option<String>
|
||||
{
|
||||
let mimes = offer.with_mime_types( |m| m.to_vec() );
|
||||
CLIPBOARD_MIMES.iter()
|
||||
.find( |c| mimes.iter().any( |m| m == *c ) )
|
||||
.map( |s| s.to_string() )
|
||||
}
|
||||
|
||||
/// Buffered channel pair used to ferry inbound-selection bytes from
|
||||
/// the read-pipe worker thread to the event loop. The receiver is
|
||||
/// drained once per iteration inside the main run loop.
|
||||
pub( crate ) fn clipboard_inbox() -> ( mpsc::Sender<String>, mpsc::Receiver<String> )
|
||||
{
|
||||
mpsc::channel()
|
||||
}
|
||||
158
src/event_loop/frame.rs
Normal file
158
src/event_loop/frame.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use std::sync::Arc;
|
||||
use smithay_client_toolkit::compositor::CompositorState;
|
||||
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
|
||||
use smithay_client_toolkit::shm::Shm;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::egl_context::EglContext;
|
||||
use crate::types::{ Color, Rect };
|
||||
use crate::widget::Element;
|
||||
|
||||
use super::AppData;
|
||||
use super::surface::{ SurfaceFocus, SurfaceState };
|
||||
use crate::draw::gles::{ draw_surface_full_gpu, draw_surface_partial_gpu };
|
||||
use crate::draw::software::{ draw_surface_full, draw_surface_partial };
|
||||
use crate::draw::damage::compute_interaction_dirty_rects;
|
||||
|
||||
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
|
||||
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems (plain
|
||||
/// memcpy). Falls back to mandatory Argb8888 when not advertised.
|
||||
/// Returns `(format, swap_rb)`.
|
||||
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
|
||||
{
|
||||
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
|
||||
{
|
||||
( wl_shm::Format::Abgr8888, false )
|
||||
} else {
|
||||
( wl_shm::Format::Argb8888, true )
|
||||
}
|
||||
}
|
||||
|
||||
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
|
||||
{
|
||||
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
|
||||
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
|
||||
|
||||
let main_bg = data.app.background_color();
|
||||
let main_region = data.app.input_region();
|
||||
let debug_layout = data.debug_layout;
|
||||
let ( format, swap_rb ) = pick_shm_format( &data.shm );
|
||||
let egl_ctx = data.egl_context.as_ref();
|
||||
let qh = &data.qh;
|
||||
|
||||
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
|
||||
{
|
||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
|
||||
draw_surface::<A::Message>(
|
||||
&mut data.main,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
main_view,
|
||||
main_bg,
|
||||
main_region.as_deref(),
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
);
|
||||
data.main.needs_redraw = false;
|
||||
data.main.last_draw = std::time::Instant::now();
|
||||
}
|
||||
|
||||
for spec in overlays
|
||||
{
|
||||
if let Some( ss ) = data.overlays.get_mut( &spec.id )
|
||||
{
|
||||
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
|
||||
let focus = SurfaceFocus::Overlay( spec.id );
|
||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
|
||||
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
|
||||
draw_surface::<A::Message>(
|
||||
ss,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
&spec.view,
|
||||
bg,
|
||||
spec.input_region.as_deref(),
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
);
|
||||
ss.needs_redraw = false;
|
||||
ss.last_draw = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_surface<Msg: Clone>(
|
||||
ss: &mut SurfaceState<Msg>,
|
||||
compositor: &CompositorState,
|
||||
egl_ctx: Option<&Arc<EglContext>>,
|
||||
view: &Element<Msg>,
|
||||
bg: Color,
|
||||
input_region: Option<&[Rect]>,
|
||||
debug_layout: bool,
|
||||
shm_format: wl_shm::Format,
|
||||
swap_rb: bool,
|
||||
request_frame: &dyn Fn( &WlSurface ),
|
||||
)
|
||||
{
|
||||
let scale = ss.scale_factor.max( 1 ) as u32;
|
||||
let w = ss.width;
|
||||
let h = ss.height;
|
||||
if w == 0 || h == 0 { return; }
|
||||
let pw = w * scale;
|
||||
let ph = h * scale;
|
||||
|
||||
let canvas_ready = ss.canvas.as_ref()
|
||||
.map( |c| c.size() == ( pw, ph ) )
|
||||
.unwrap_or( false );
|
||||
let partial_eligible = !ss.content_dirty
|
||||
&& canvas_ready
|
||||
&& !ss.widget_rects.is_empty();
|
||||
|
||||
if partial_eligible
|
||||
{
|
||||
let dirty_rects = compute_interaction_dirty_rects(
|
||||
&ss.widget_rects,
|
||||
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
|
||||
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
|
||||
pw, ph,
|
||||
);
|
||||
if dirty_rects.is_empty() { return; }
|
||||
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
|
||||
if total < pw as f32 * ph as f32 * 0.5
|
||||
{
|
||||
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
|
||||
{
|
||||
draw_surface_partial_gpu(
|
||||
ss, compositor, ctx, view, bg, input_region,
|
||||
dirty_rects, pw, ph, scale, request_frame,
|
||||
);
|
||||
} else {
|
||||
draw_surface_partial(
|
||||
ss, compositor, view, bg, input_region,
|
||||
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
|
||||
{
|
||||
draw_surface_full_gpu(
|
||||
ss, compositor, ctx, view, bg, input_region, debug_layout,
|
||||
pw, ph, scale, request_frame,
|
||||
);
|
||||
} else {
|
||||
draw_surface_full(
|
||||
ss, compositor, view, bg, input_region, debug_layout,
|
||||
shm_format, swap_rb, pw, ph, scale, request_frame,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -143,10 +143,15 @@ impl<A: App> LayerShellHandler for AppData<A>
|
||||
}
|
||||
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
||||
{
|
||||
// Compositor asked us to destroy this overlay. Remove it;
|
||||
// the next reconcile will not recreate it unless the app
|
||||
// still returns its id from `App::overlays()`.
|
||||
self.overlays.remove( &id );
|
||||
// Compositor asked us to destroy this overlay. Drop it
|
||||
// synchronously *and* rewrite every per-device focus that
|
||||
// pointed at it — otherwise the next event in this same
|
||||
// dispatch (touch up, IME done, pointer leave) lands on a
|
||||
// freed surface and `surface()` / `surface_mut()` panic.
|
||||
// `reconcile_overlays` will still run afterwards and skip
|
||||
// re-creating the entry as long as the app stops returning
|
||||
// its id from `overlays()`.
|
||||
self.discard_overlay( id );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,8 +271,23 @@ impl<A: App> SeatHandler for AppData<A>
|
||||
}
|
||||
|
||||
fn new_seat(
|
||||
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
|
||||
) {}
|
||||
&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: WlSeat,
|
||||
)
|
||||
{
|
||||
// First seat → first data device. We only need one device
|
||||
// regardless of how many seats appear (the typical desktop
|
||||
// session has exactly one); additional seats reuse the same
|
||||
// device-bound clipboard semantics by construction because
|
||||
// `wl_data_device_manager.get_data_device` takes the seat as
|
||||
// argument and the compositor manages routing.
|
||||
if self.data_device.is_none()
|
||||
{
|
||||
if let Some( ref ddm ) = self.data_device_manager
|
||||
{
|
||||
self.data_device = Some( ddm.get_data_device( qh, &seat ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_capability(
|
||||
&mut self,
|
||||
@@ -369,7 +389,9 @@ impl<A: App> PopupHandler for AppData<A>
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
self.overlays.remove( &id );
|
||||
// Synchronous teardown plus per-device focus cleanup. See
|
||||
// `discard_overlay` for the panic case this prevents.
|
||||
self.discard_overlay( id );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,6 +411,27 @@ delegate_xdg_window!( @<A: App> AppData<A> );
|
||||
delegate_xdg_popup!( @<A: App> AppData<A> );
|
||||
delegate_foreign_toplevel_list!( @<A: App> AppData<A> );
|
||||
delegate_registry!( @<A: App> AppData<A> );
|
||||
smithay_client_toolkit::delegate_activation!( @<A: App> AppData<A> );
|
||||
smithay_client_toolkit::delegate_data_device!( @<A: App> AppData<A> );
|
||||
|
||||
// --- `xdg-activation-v1` handler ---
|
||||
//
|
||||
// We only honour inbound activation here: when a token issued for our
|
||||
// own request gets delivered, we activate the main surface. Outbound
|
||||
// requests (so the app can pass a token to another app) are out of
|
||||
// scope for now — adding them only requires a new public method on
|
||||
// `AppData` and an extra trait method on `App`.
|
||||
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
|
||||
{
|
||||
type RequestData = smithay_client_toolkit::activation::RequestData;
|
||||
fn new_token( &mut self, token: String, _data: &Self::RequestData )
|
||||
{
|
||||
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
||||
{
|
||||
activation.activate::<Self>( &wl, token );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> ProvidesRegistryState for AppData<A>
|
||||
{
|
||||
@@ -581,16 +624,16 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
|
||||
}
|
||||
}
|
||||
}
|
||||
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, .. } =>
|
||||
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } =>
|
||||
{
|
||||
for _ in 0..before_length
|
||||
{
|
||||
state.handle_backspace( focus );
|
||||
}
|
||||
state.handle_delete_surrounding( focus, before_length, after_length );
|
||||
}
|
||||
zwp_text_input_v3::Event::Done { .. } =>
|
||||
{
|
||||
state.surface_mut( focus ).request_redraw();
|
||||
if let Some( ss ) = state.try_surface_mut( focus )
|
||||
{
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,11 +26,11 @@ use calloop::timer::{ Timer, TimeoutAction };
|
||||
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
|
||||
|
||||
use crate::app::{ App, InvalidationScope };
|
||||
use crate::draw::draw_frame;
|
||||
use crate::types::Point;
|
||||
|
||||
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
use super::error::RunError;
|
||||
use super::frame::draw_frame;
|
||||
use super::invalidation::apply_invalidation;
|
||||
use super::overlays_reconcile::reconcile_overlays;
|
||||
|
||||
@@ -184,6 +184,40 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
};
|
||||
|
||||
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
|
||||
// wl_data_device_manager: optional. Required for cross-application
|
||||
// copy/paste; absence means the clipboard stays process-local.
|
||||
let data_device_manager =
|
||||
smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok();
|
||||
let ( clipboard_inbox_tx, clipboard_inbox_rx ) = super::data_device::clipboard_inbox();
|
||||
let ( drop_inbox_tx, drop_inbox_rx ) = super::data_device::drop_inbox();
|
||||
// AT-SPI2 / accessibility. `try_new` returns `None` when the
|
||||
// platform adapter cannot be created (no daemon on the bus,
|
||||
// headless CI, etc.) — the runtime then runs with no
|
||||
// accessibility tree, which is the previous behaviour.
|
||||
let a11y_app_name = "ltk-app";
|
||||
let a11y_app_id = "net.liberux.ltk";
|
||||
let a11y = crate::a11y::A11yState::try_new( a11y_app_name, a11y_app_id );
|
||||
// xdg-activation-v1: optional. Compositors that don't carry the
|
||||
// global leave `activation_state` as `None` and the inbound /
|
||||
// outbound activation paths silently degrade to no-ops.
|
||||
let activation_state =
|
||||
smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok();
|
||||
// `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an
|
||||
// incoming activation token (the launcher that spawned us set it
|
||||
// in the new process's environment). Honoured exactly once, after
|
||||
// the main surface receives its first configure — that is the
|
||||
// earliest moment we can call `xdg_activation_v1.activate`.
|
||||
let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() );
|
||||
// Avoid the token leaking into any child process the app spawns —
|
||||
// it is single-use and would be invalid the second time anyway.
|
||||
if activation_token_pending.is_some()
|
||||
{
|
||||
// SAFETY: removing an env var is sound only when no other thread
|
||||
// is reading the environment concurrently. We are still in the
|
||||
// init phase before `set_channel_sender`, so the app has had
|
||||
// no opportunity to spawn worker threads yet.
|
||||
unsafe { std::env::remove_var( "XDG_ACTIVATION_TOKEN" ); }
|
||||
}
|
||||
|
||||
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
|
||||
// bind + dispatch routing; absence of the global is fine, the inner
|
||||
@@ -223,6 +257,18 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
current_cursor_shape: None,
|
||||
text_input_manager,
|
||||
text_input: None,
|
||||
activation_state,
|
||||
activation_token_pending,
|
||||
data_device_manager,
|
||||
data_device: None,
|
||||
clipboard_source: None,
|
||||
clipboard_inbox_tx,
|
||||
clipboard_inbox_rx,
|
||||
drop_position: None,
|
||||
drop_mime: None,
|
||||
drop_inbox_tx,
|
||||
drop_inbox_rx,
|
||||
a11y,
|
||||
foreign_toplevel_list,
|
||||
shift_pressed: false,
|
||||
ctrl_pressed: false,
|
||||
@@ -349,6 +395,19 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
let ext: Vec<_> = data.app.poll_external();
|
||||
data.pending_msgs.extend( ext );
|
||||
|
||||
// Drain any inbound clipboard payloads delivered by the
|
||||
// data-device worker thread. Latest writer wins; the channel
|
||||
// is unbounded but selection payloads are small (capped at
|
||||
// 16 MiB inside the worker, see `data_device::DataDeviceHandler::selection`).
|
||||
while let Ok( text ) = data.clipboard_inbox_rx.try_recv()
|
||||
{
|
||||
data.clipboard = text;
|
||||
}
|
||||
while let Ok( p ) = data.drop_inbox_rx.try_recv()
|
||||
{
|
||||
data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text );
|
||||
}
|
||||
|
||||
// Process pending messages, folding their per-message invalidation
|
||||
// scopes into a single decision before applying it.
|
||||
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
|
||||
@@ -437,6 +496,228 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
data.overlays_dirty = false;
|
||||
}
|
||||
draw_frame( &mut data );
|
||||
|
||||
// Push the freshly-laid-out widget tree to AccessKit. The
|
||||
// closure only runs when an AT client is actually
|
||||
// listening (the adapter no-ops `update_if_active`
|
||||
// otherwise), so the build cost is paid on demand. Main
|
||||
// surface only — overlays would require per-surface
|
||||
// adapters, which the AT-SPI2 model is not built for.
|
||||
let mut a11y_taken = data.a11y.take();
|
||||
if let Some( ref mut a ) = a11y_taken
|
||||
{
|
||||
let main_w = data.main.width as f32;
|
||||
let main_h = data.main.height as f32;
|
||||
let mut overlay_meta: Vec<( u8, crate::app::OverlayId, f32, f32 )> = Vec::new();
|
||||
let mut next_id: u8 = 1;
|
||||
for ( id, ss ) in data.overlays.iter()
|
||||
{
|
||||
if next_id == 0 { break; }
|
||||
let ( ox, oy ) = data.surface_offset_for( super::SurfaceFocus::Overlay( *id ) );
|
||||
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
|
||||
if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; }
|
||||
overlay_meta.push( ( next_id, *id, ox, oy ) );
|
||||
next_id = next_id.saturating_add( 1 );
|
||||
}
|
||||
let kb_focus_id = match data.keyboard_focus
|
||||
{
|
||||
super::SurfaceFocus::Main => 0u8,
|
||||
super::SurfaceFocus::Overlay( id ) =>
|
||||
overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ),
|
||||
};
|
||||
let mut surfaces: Vec<crate::a11y::tree::SurfaceView<A::Message>> = Vec::new();
|
||||
surfaces.push( crate::a11y::tree::SurfaceView
|
||||
{
|
||||
focus_id: 0,
|
||||
is_main: true,
|
||||
label: None,
|
||||
widget_rects: &data.main.widget_rects,
|
||||
extras: &data.main.accessible_extras,
|
||||
focused_idx: data.main.focused_idx,
|
||||
pressed_idx: data.main.gesture.pressed_idx,
|
||||
pending_text_values: &data.main.pending_text_values,
|
||||
cursor_state: &data.main.cursor_state,
|
||||
width: main_w,
|
||||
height: main_h,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
} );
|
||||
for ( fid, id, ox, oy ) in &overlay_meta
|
||||
{
|
||||
let Some( ss ) = data.overlays.get( id ) else { continue };
|
||||
surfaces.push( crate::a11y::tree::SurfaceView
|
||||
{
|
||||
focus_id: *fid,
|
||||
is_main: false,
|
||||
label: None,
|
||||
widget_rects: &ss.widget_rects,
|
||||
extras: &ss.accessible_extras,
|
||||
focused_idx: ss.focused_idx,
|
||||
pressed_idx: ss.gesture.pressed_idx,
|
||||
pending_text_values: &ss.pending_text_values,
|
||||
cursor_state: &ss.cursor_state,
|
||||
width: ss.width as f32,
|
||||
height: ss.height as f32,
|
||||
offset_x: *ox,
|
||||
offset_y: *oy,
|
||||
} );
|
||||
}
|
||||
let app_name = "ltk-app";
|
||||
a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) );
|
||||
}
|
||||
data.a11y = a11y_taken;
|
||||
}
|
||||
|
||||
// Drain inbound AT-SPI2 actions (Orca pressing a button,
|
||||
// switch-control focusing a node, etc.) and translate each
|
||||
// into a synthetic press / focus on the matching widget.
|
||||
// Actions for widgets that disappeared between dispatch and
|
||||
// drain are silently dropped — the action loop is best-effort.
|
||||
let a11y_requests: Vec<accesskit::ActionRequest> =
|
||||
if let Some( ref mut a ) = data.a11y { a.action_rx.try_iter().collect() } else { Vec::new() };
|
||||
if !a11y_requests.is_empty()
|
||||
{
|
||||
let qh = data.qh.clone();
|
||||
let overlay_keys: Vec<crate::app::OverlayId> = data.overlays.keys().copied().collect();
|
||||
for req in a11y_requests
|
||||
{
|
||||
let Some( r ) = crate::a11y::tree::parse_id( req.target ) else { continue };
|
||||
if r.kind != 0 { continue; }
|
||||
let idx = r.idx as usize;
|
||||
let focus_target = if r.focus == 0
|
||||
{
|
||||
SurfaceFocus::Main
|
||||
}
|
||||
else
|
||||
{
|
||||
let oi = ( r.focus as usize ).saturating_sub( 1 );
|
||||
let Some( id ) = overlay_keys.get( oi ).copied() else { continue };
|
||||
SurfaceFocus::Overlay( id )
|
||||
};
|
||||
let widget = match focus_target
|
||||
{
|
||||
SurfaceFocus::Main => data.main.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
|
||||
SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ),
|
||||
};
|
||||
match req.action
|
||||
{
|
||||
accesskit::Action::Click =>
|
||||
{
|
||||
if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() )
|
||||
{
|
||||
data.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
accesskit::Action::Focus =>
|
||||
{
|
||||
data.set_focus( focus_target, Some( idx ), &qh );
|
||||
}
|
||||
accesskit::Action::SetValue =>
|
||||
{
|
||||
if let Some( w ) = widget
|
||||
{
|
||||
match ( &w.handlers, req.data )
|
||||
{
|
||||
( crate::widget::WidgetHandlers::Slider { .. }, Some( accesskit::ActionData::NumericValue( v ) ) ) =>
|
||||
{
|
||||
if let Some( msg ) = w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 )
|
||||
{
|
||||
data.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
( crate::widget::WidgetHandlers::TextEdit { .. }, Some( accesskit::ActionData::Value( s ) ) ) =>
|
||||
{
|
||||
if let Some( msg ) = w.handlers.text_change_msg( &s )
|
||||
{
|
||||
data.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
accesskit::Action::Increment | accesskit::Action::Decrement =>
|
||||
{
|
||||
if let Some( w ) = widget
|
||||
{
|
||||
if let crate::widget::WidgetHandlers::Slider { value, .. } = &w.handlers
|
||||
{
|
||||
let step = 0.05f32;
|
||||
let delta = if matches!( req.action, accesskit::Action::Increment ) { step } else { -step };
|
||||
let v = ( *value + delta ).clamp( 0.0, 1.0 );
|
||||
if let Some( msg ) = w.handlers.slider_change_msg( v )
|
||||
{
|
||||
data.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accesskit::Action::ScrollUp | accesskit::Action::ScrollDown
|
||||
| accesskit::Action::ScrollLeft | accesskit::Action::ScrollRight =>
|
||||
{
|
||||
const STEP: f32 = 80.0;
|
||||
let ( dx, dy ) = match req.action
|
||||
{
|
||||
accesskit::Action::ScrollLeft => ( -STEP, 0.0 ),
|
||||
accesskit::Action::ScrollRight => ( STEP, 0.0 ),
|
||||
accesskit::Action::ScrollUp => ( 0.0, -STEP ),
|
||||
accesskit::Action::ScrollDown => ( 0.0, STEP ),
|
||||
_ => ( 0.0, 0.0 ),
|
||||
};
|
||||
let ss = match focus_target
|
||||
{
|
||||
SurfaceFocus::Main => Some( &mut data.main ),
|
||||
SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
|
||||
};
|
||||
if let Some( ss ) = ss
|
||||
{
|
||||
if let Some( ( _, _, _ ) ) = ss.scroll_rects.last().copied()
|
||||
{
|
||||
let scroll_idx = ss.scroll_rects.last().unwrap().1;
|
||||
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
entry.0 = ( entry.0 + dx ).max( 0.0 );
|
||||
entry.1 = ( entry.1 + dy ).max( 0.0 );
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
accesskit::Action::ScrollIntoView =>
|
||||
{
|
||||
let Some( w ) = widget else { continue };
|
||||
let ss = match focus_target
|
||||
{
|
||||
SurfaceFocus::Main => Some( &mut data.main ),
|
||||
SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
|
||||
};
|
||||
if let Some( ss ) = ss
|
||||
{
|
||||
let target = w.rect;
|
||||
let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 };
|
||||
let container = ss.scroll_rects.iter().rev()
|
||||
.find( |( r, _, _ )| r.contains( probe ) )
|
||||
.copied();
|
||||
if let Some( ( r, idx, _ ) ) = container
|
||||
{
|
||||
let entry = ss.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
if target.y < r.y { entry.1 -= r.y - target.y; }
|
||||
if target.y + target.height > r.y + r.height
|
||||
{
|
||||
entry.1 += ( target.y + target.height ) - ( r.y + r.height );
|
||||
}
|
||||
if target.x < r.x { entry.0 -= r.x - target.x; }
|
||||
if target.x + target.width > r.x + r.width
|
||||
{
|
||||
entry.0 += ( target.x + target.width ) - ( r.x + r.width );
|
||||
}
|
||||
entry.0 = entry.0.max( 0.0 );
|
||||
entry.1 = entry.1.max( 0.0 );
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focus the widget with the requested WidgetId if the app requests it.
|
||||
|
||||
@@ -178,8 +178,14 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
||||
/// cursor; pointer drags set both ends.
|
||||
pub selection_anchor: HashMap<usize, usize>,
|
||||
pub pending_text_values: HashMap<usize, String>,
|
||||
pub scroll_offsets: HashMap<usize, f32>,
|
||||
pub scroll_rects: Vec<( Rect, usize )>,
|
||||
/// Per-scroll-viewport offset, indexed by `flat_idx` of the
|
||||
/// `Scroll` widget. Tuple is `(x, y)` in physical pixels: x is
|
||||
/// applied only when the widget's axis allows horizontal scroll
|
||||
/// (and stays at `0` otherwise), y mirrors the historic single-f32
|
||||
/// behaviour for vertical scrolls.
|
||||
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
|
||||
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
|
||||
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
|
||||
pub scroll_canvases: HashMap<usize, Canvas>,
|
||||
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
|
||||
/// every interactive item the scroll's child laid out, in document
|
||||
@@ -197,6 +203,18 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
||||
/// (`src/input/`) calls the lifecycle methods; the long-press
|
||||
/// deadline poller reaches into `gesture.long_press_*` directly.
|
||||
pub gesture: GestureState<Msg>,
|
||||
/// `wl_touch.id` of the finger currently driving the single-slot
|
||||
/// gesture machine. `None` when no finger is down on this surface.
|
||||
/// Subsequent fingers landing while this is `Some` route through
|
||||
/// the auxiliary multi-touch path ([`App::on_touch_down`] etc.)
|
||||
/// instead of being absorbed into the primary slot.
|
||||
pub primary_touch_id: Option<i32>,
|
||||
/// Auxiliary touch slots: every non-primary finger's last known
|
||||
/// position, keyed by `wl_touch.id`. Used by the touch handler to
|
||||
/// generate `on_touch_up` with the correct release point (Wayland
|
||||
/// `wl_touch.up` does not carry a position) and to keep state
|
||||
/// across `motion` events between dispatchers.
|
||||
pub touch_slots: HashMap<i32, crate::types::Point>,
|
||||
/// Previous frame interaction state for damage tracking
|
||||
pub prev_focused: Option<usize>,
|
||||
pub prev_hovered: Option<usize>,
|
||||
@@ -253,9 +271,12 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
cursor_state: HashMap::new(),
|
||||
selection_anchor: HashMap::new(),
|
||||
pending_text_values: HashMap::new(),
|
||||
accessible_extras: Vec::new(),
|
||||
scroll_offsets: HashMap::new(),
|
||||
scroll_rects: Vec::new(),
|
||||
scroll_navigable_items: HashMap::new(),
|
||||
primary_touch_id: None,
|
||||
touch_slots: HashMap::new(),
|
||||
context_menu: None,
|
||||
scroll_canvases: HashMap::new(),
|
||||
gesture: GestureState::new(),
|
||||
@@ -311,11 +332,20 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
h: u32,
|
||||
)
|
||||
{
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
let scale = self.scale_factor.max( 1 ) as u32;
|
||||
let pw = w * scale;
|
||||
let ph = h * scale;
|
||||
let scale = self.scale_factor.max( 1 ) as u32;
|
||||
let pw = w.saturating_mul( scale );
|
||||
let ph = h.saturating_mul( scale );
|
||||
let pool_bytes = (pw as u64)
|
||||
.saturating_mul( ph as u64 )
|
||||
.saturating_mul( 4 );
|
||||
|
||||
const MAX_POOL: u64 = 256 * 1024 * 1024;
|
||||
if pool_bytes > MAX_POOL || pw == 0 || ph == 0
|
||||
{
|
||||
eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" );
|
||||
return;
|
||||
}
|
||||
let pool_bytes = pool_bytes as usize;
|
||||
|
||||
if self.egl_surface.is_some()
|
||||
{
|
||||
@@ -329,9 +359,15 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
}
|
||||
} else if self.pool.is_some() {
|
||||
// SHM path: reallocate the pool to fit the new buffer size.
|
||||
self.pool = Some(
|
||||
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
|
||||
);
|
||||
match SlotPool::new( pool_bytes, shm )
|
||||
{
|
||||
Ok( pool ) => self.pool = Some( pool ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" );
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First configure — pick the path. Try GPU when the bootstrap
|
||||
// succeeded; fall back to SHM if surface creation fails.
|
||||
@@ -353,12 +389,20 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
}
|
||||
if !chose_gpu
|
||||
{
|
||||
self.pool = Some(
|
||||
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
|
||||
);
|
||||
match SlotPool::new( pool_bytes, shm )
|
||||
{
|
||||
Ok( pool ) => self.pool = Some( pool ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!( "[ltk] SlotPool init failed: {e}" );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
|
||||
self.configured = true;
|
||||
self.needs_redraw = true;
|
||||
|
||||
@@ -130,6 +130,87 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
|
||||
/// Respond to `zwp_text_input_v3.delete_surrounding_text`. Both
|
||||
/// arguments are **UTF-8 byte counts** measured from the cursor.
|
||||
/// IMEs (Mozc, Anthy, IBus-pinyin) emit this routinely to commit a
|
||||
/// preedit on top of pre-existing surrounding text. The previous
|
||||
/// implementation iterated `before_length` calls to `handle_backspace`,
|
||||
/// treating the byte count as a number of characters — corrupting
|
||||
/// any non-ASCII input. This method does a single bytes-aware
|
||||
/// replace_range that snaps to UTF-8 char boundaries.
|
||||
pub( crate ) fn handle_delete_surrounding( &mut self, focus: SurfaceFocus, before_bytes: u32, after_bytes: u32 )
|
||||
{
|
||||
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
|
||||
|
||||
let _ = self.delete_selection( focus );
|
||||
|
||||
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
|
||||
{
|
||||
pending.clone()
|
||||
} else {
|
||||
find_handlers( &self.surface( focus ).widget_rects, idx )
|
||||
.and_then( |h| h.current_value() )
|
||||
.map( |s| s.to_string() )
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
|
||||
.unwrap_or( current_value.len() );
|
||||
let safe_cursor = cursor_val.min( current_value.len() );
|
||||
|
||||
// `before_bytes` of UTF-8 walked back from the cursor, snapping
|
||||
// to char boundaries — if the byte count lands mid-codepoint we
|
||||
// expand outward to swallow the whole codepoint (matches what
|
||||
// GTK / Qt do under the same protocol).
|
||||
let start_byte =
|
||||
{
|
||||
let mut pos = safe_cursor;
|
||||
for ( i, _ ) in current_value[..safe_cursor].char_indices().rev()
|
||||
{
|
||||
if ( safe_cursor - pos ) as u32 >= before_bytes { break; }
|
||||
pos = i;
|
||||
}
|
||||
pos
|
||||
};
|
||||
|
||||
let end_byte =
|
||||
{
|
||||
let mut pos = safe_cursor;
|
||||
for ( i, ch ) in current_value[safe_cursor..].char_indices()
|
||||
{
|
||||
if ( pos - safe_cursor ) as u32 >= after_bytes { break; }
|
||||
pos = safe_cursor + i + ch.len_utf8();
|
||||
}
|
||||
pos
|
||||
};
|
||||
|
||||
if start_byte >= end_byte { return; }
|
||||
|
||||
let mut new_value = current_value.clone();
|
||||
new_value.replace_range( start_byte..end_byte, "" );
|
||||
|
||||
let select_on_focus = matches!(
|
||||
find_handlers( &self.surface( focus ).widget_rects, idx ),
|
||||
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
|
||||
);
|
||||
let next_cursor = if select_on_focus { usize::MAX } else { start_byte };
|
||||
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
|
||||
ss.pending_text_values.insert( idx, new_value.clone() );
|
||||
ss.request_redraw();
|
||||
}
|
||||
|
||||
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
|
||||
.and_then( |h| h.text_change_msg( &new_value ) );
|
||||
if let Some( m ) = msg
|
||||
{
|
||||
self.pending_msgs.push( m );
|
||||
}
|
||||
}
|
||||
|
||||
pub( crate ) fn handle_backspace( &mut self, focus: SurfaceFocus )
|
||||
{
|
||||
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
|
||||
|
||||
@@ -30,7 +30,7 @@ impl<A: App> AppData<A>
|
||||
{
|
||||
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
|
||||
{
|
||||
let ss = self.surface( focus );
|
||||
let Some( ss ) = self.try_surface( focus ) else { return };
|
||||
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
|
||||
let Some( text ) = w.tooltip.clone() else
|
||||
{
|
||||
@@ -89,7 +89,7 @@ impl<A: App> AppData<A>
|
||||
{
|
||||
let v = self.tooltip_visible.as_ref()?;
|
||||
let ( ox, oy ) = self.surface_offset_for( v.focus );
|
||||
let scale = self.surface( v.focus ).scale_factor as f32;
|
||||
let scale = self.try_surface( v.focus )?.scale_factor as f32;
|
||||
let anchor_x = ox + v.anchor.x / scale;
|
||||
let anchor_y = oy + v.anchor.y / scale;
|
||||
let anchor_w = v.anchor.width / scale;
|
||||
|
||||
@@ -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 ) );
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
//! * `primitives` — `GlesCanvas::{fill_rect, fill_linear_gradient_rect,
|
||||
//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset,
|
||||
//! stroke_rect, draw_line}`.
|
||||
//! * `text` — `GlesCanvas::{draw_text, measure_text, draw_glyph_texture}`.
|
||||
//! * `text` — `GlesCanvas::{draw_text, measure_text}`.
|
||||
//! * `image` — `GlesCanvas::draw_image_data`.
|
||||
//! * `shaders` — GLSL ES 1.00 shader sources (const strings).
|
||||
//! * `helpers` — free functions: `ortho_rect`, `compile_program`,
|
||||
@@ -101,15 +101,25 @@ pub struct BorrowedGlesTexture
|
||||
pub y_inverted: bool,
|
||||
}
|
||||
|
||||
/// Cached glyph: pre-rasterized bitmap uploaded as a GL texture.
|
||||
/// Cached glyph: rasterized bitmap placed in the shared atlas.
|
||||
pub ( super ) struct GlyphEntry
|
||||
{
|
||||
pub ( super ) texture: glow::Texture,
|
||||
pub ( super ) metrics: fontdue::Metrics,
|
||||
pub ( super ) tex_w: i32,
|
||||
pub ( super ) tex_h: i32,
|
||||
pub ( super ) atlas_x: u32,
|
||||
pub ( super ) atlas_y: u32,
|
||||
}
|
||||
|
||||
/// Cache key for the GLES glyph atlas — the GPU-side mirror of the
|
||||
/// software canvas's `GlyphKey`. `glyph_id` is the per-font index
|
||||
/// returned by HarfBuzz shaping; `size_bits` is `f32::to_bits` of
|
||||
/// `size * dpi_scale`; `font_id` is the address of the
|
||||
/// `Arc<fontdue::Font>` used for rasterisation.
|
||||
pub( super ) type GlyphAtlasKey = ( u16, u32, usize );
|
||||
|
||||
pub( super ) const ATLAS_SIZE: u32 = 2048;
|
||||
|
||||
// ─── GlesCanvas ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// GPU-accelerated canvas using EGL + GLES2/3.
|
||||
@@ -127,6 +137,13 @@ pub struct GlesCanvas
|
||||
/// Kept as a fallback for callers that do not route through the
|
||||
/// theme registry.
|
||||
pub font: Arc<Font>,
|
||||
/// Raw bytes of the default font. Required by rustybuzz for
|
||||
/// HarfBuzz shaping (see [`crate::text_shaping`]). Kept on the
|
||||
/// canvas so the shape pipeline has direct access without a
|
||||
/// global lookup.
|
||||
pub font_bytes: Arc<Vec<u8>>,
|
||||
/// TTC sub-face index for the default font (0 for non-`.ttc` files).
|
||||
pub font_face: u32,
|
||||
/// Optional theme font registry. Populated by the runtime after
|
||||
/// theme load; until then it is `None` and [`Self::font_for`]
|
||||
/// falls back to [`Self::font`].
|
||||
@@ -167,10 +184,19 @@ pub struct GlesCanvas
|
||||
u_tex_sampler: glow::UniformLocation,
|
||||
|
||||
// Uniform locations for glyph shader
|
||||
u_glyph_mvp: glow::UniformLocation,
|
||||
u_glyph_color: glow::UniformLocation,
|
||||
u_glyph_opacity: glow::UniformLocation,
|
||||
u_glyph_sampler: glow::UniformLocation,
|
||||
u_glyph_mvp: glow::UniformLocation,
|
||||
u_glyph_color: glow::UniformLocation,
|
||||
u_glyph_opacity: glow::UniformLocation,
|
||||
u_glyph_sampler: glow::UniformLocation,
|
||||
u_glyph_uv_offset: glow::UniformLocation,
|
||||
u_glyph_uv_scale: glow::UniformLocation,
|
||||
|
||||
glyph_batch_program: glow::Program,
|
||||
u_glyph_batch_color: glow::UniformLocation,
|
||||
u_glyph_batch_opacity: glow::UniformLocation,
|
||||
u_glyph_batch_sampler: glow::UniformLocation,
|
||||
pub ( super ) glyph_batch_vao: glow::VertexArray,
|
||||
pub ( super ) glyph_batch_vbo: glow::Buffer,
|
||||
|
||||
// Uniform location for blit shader
|
||||
u_blit_sampler: glow::UniformLocation,
|
||||
@@ -289,11 +315,23 @@ pub struct GlesCanvas
|
||||
u_bd_fc_radii: glow::UniformLocation,
|
||||
u_bd_fc_tint: glow::UniformLocation,
|
||||
|
||||
// Glyph cache: (char, size_key, font_id) → GlyphEntry. The
|
||||
// `font_id` is the address of the `Arc<Font>` used for the
|
||||
// rasterisation, so distinct weights / families of the same
|
||||
// (char, size) keep separate atlas entries.
|
||||
glyph_cache: HashMap<(char, u32, usize), GlyphEntry>,
|
||||
atlas_texture: glow::Texture,
|
||||
/// Upload format of `atlas_texture`. On GLES3 we use the modern
|
||||
/// single-channel `GL_RED` over `GL_R8`; on GLES2 we keep the
|
||||
/// legacy `GL_LUMINANCE` because `GL_R8` / `GL_RED` did not exist
|
||||
/// before ES3. The fragment shader samples `.r` and is identical
|
||||
/// for both — `GL_LUMINANCE` replicates the single channel into
|
||||
/// `.r=.g=.b`, so `.r` carries the coverage either way.
|
||||
pub ( super ) atlas_format: u32,
|
||||
atlas_cursor_x: u32,
|
||||
atlas_cursor_y: u32,
|
||||
atlas_row_height: u32,
|
||||
|
||||
// (glyph_id, size_key, font_id) → GlyphEntry. `glyph_id` is the
|
||||
// per-font glyph index returned by HarfBuzz shaping; the cache
|
||||
// therefore persists Arabic / Devanagari / CJK shaped forms
|
||||
// independently of the source codepoints.
|
||||
glyph_cache: HashMap<GlyphAtlasKey, GlyphEntry>,
|
||||
|
||||
// Reusable texture cache for images. Keyed by
|
||||
// `(width, height, content fingerprint)` rather than the source
|
||||
@@ -370,10 +408,8 @@ impl Drop for GlesCanvas
|
||||
self.gl.delete_framebuffer( fbo );
|
||||
self.gl.delete_texture( tex );
|
||||
}
|
||||
for ( _, entry ) in self.glyph_cache.drain()
|
||||
{
|
||||
self.gl.delete_texture( entry.texture );
|
||||
}
|
||||
self.glyph_cache.clear();
|
||||
self.gl.delete_texture( self.atlas_texture );
|
||||
for ( _, ( tex, _, _ ) ) in self.image_cache.drain()
|
||||
{
|
||||
self.gl.delete_texture( tex );
|
||||
|
||||
@@ -19,13 +19,13 @@ use glow::HasContext;
|
||||
|
||||
use crate::theme::{ FontRegistry, FontStyle };
|
||||
|
||||
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, load_default_font_bytes };
|
||||
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, compile_program_with_attribs, load_default_font_bytes };
|
||||
use super::shaders::
|
||||
{
|
||||
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
|
||||
BACKDROP_FAST_BLUR_H_FRAG_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC,
|
||||
BLIT_FRAG_SRC, BLIT_VERT_SRC,
|
||||
GLYPH_FRAG_SRC,
|
||||
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
|
||||
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
|
||||
RECT_FRAG_SRC,
|
||||
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
|
||||
@@ -39,28 +39,37 @@ use super::{ GlesCanvas, GlesVersion };
|
||||
/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …)
|
||||
/// is owned by the crate-private system-fonts module and loaded
|
||||
/// lazily per codepoint, not per canvas.
|
||||
static DEFAULT_FONT_GLES: OnceLock<Arc<Font>> = OnceLock::new();
|
||||
static DEFAULT_FONT_GLES: OnceLock<crate::system_fonts::FontHandle> = OnceLock::new();
|
||||
|
||||
fn default_font_gles() -> Arc<Font>
|
||||
fn default_handle_gles() -> crate::system_fonts::FontHandle
|
||||
{
|
||||
Arc::clone( DEFAULT_FONT_GLES.get_or_init( ||
|
||||
DEFAULT_FONT_GLES.get_or_init( ||
|
||||
{
|
||||
let bytes = load_default_font_bytes();
|
||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
||||
.expect( "bad font" );
|
||||
Arc::new( font )
|
||||
} ) )
|
||||
crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::new( font ),
|
||||
bytes: Arc::new( bytes ),
|
||||
face: 0,
|
||||
}
|
||||
} ).clone()
|
||||
}
|
||||
|
||||
impl GlesCanvas
|
||||
{
|
||||
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
|
||||
{
|
||||
let font = default_font_gles();
|
||||
let font_handle = default_handle_gles();
|
||||
let font = font_handle.font.clone();
|
||||
let font_bytes = font_handle.bytes.clone();
|
||||
let font_face = font_handle.face;
|
||||
|
||||
let rect_program = compile_program( &gl, VERT_SRC, RECT_FRAG_SRC );
|
||||
let tex_program = compile_program( &gl, VERT_SRC, TEX_FRAG_SRC );
|
||||
let glyph_program = compile_program( &gl, VERT_SRC, GLYPH_FRAG_SRC );
|
||||
let glyph_batch_program = compile_program_with_attribs( &gl, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC, &[ ( 0, "a_pos" ), ( 1, "a_uv" ) ] );
|
||||
let blit_program = compile_program( &gl, BLIT_VERT_SRC, BLIT_FRAG_SRC );
|
||||
let sub_blit_program = compile_program( &gl, VERT_SRC, SUB_BLIT_FRAG_SRC );
|
||||
let linear_gradient_program = compile_program( &gl, VERT_SRC, LINEAR_GRADIENT_FRAG_SRC );
|
||||
@@ -83,6 +92,7 @@ impl GlesCanvas
|
||||
u_rect_mvp, u_rect_color, u_rect_size, u_rect_radii, u_rect_stroke, u_rect_pad,
|
||||
u_tex_mvp, u_tex_opacity, u_tex_sampler,
|
||||
u_glyph_mvp, u_glyph_color, u_glyph_opacity, u_glyph_sampler,
|
||||
u_glyph_uv_offset, u_glyph_uv_scale,
|
||||
u_blit_sampler,
|
||||
u_subblit_mvp, u_subblit_sampler, u_subblit_opacity, u_subblit_fade_bottom, u_subblit_height_px,
|
||||
u_lingrad_mvp, u_lingrad_lut, u_lingrad_dir, u_lingrad_size, u_lingrad_line_length,
|
||||
@@ -114,7 +124,9 @@ impl GlesCanvas
|
||||
gl.get_uniform_location( glyph_program, "u_mvp" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_program, "u_color" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_program, "u_opacity" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_program, "u_uv_offset" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_program, "u_uv_scale" ).unwrap(),
|
||||
gl.get_uniform_location( blit_program, "u_sampler" ).unwrap(),
|
||||
gl.get_uniform_location( sub_blit_program, "u_mvp" ).unwrap(),
|
||||
gl.get_uniform_location( sub_blit_program, "u_sampler" ).unwrap(),
|
||||
@@ -192,6 +204,27 @@ impl GlesCanvas
|
||||
gl.get_uniform_location( backdrop_fast_composite_program, "u_tint" ).unwrap(),
|
||||
)};
|
||||
|
||||
let ( u_glyph_batch_color, u_glyph_batch_opacity, u_glyph_batch_sampler ) = unsafe
|
||||
{(
|
||||
gl.get_uniform_location( glyph_batch_program, "u_color" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_batch_program, "u_opacity" ).unwrap(),
|
||||
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
|
||||
)};
|
||||
|
||||
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
|
||||
{
|
||||
let vao = gl.create_vertex_array().unwrap();
|
||||
let vbo = gl.create_buffer().unwrap();
|
||||
gl.bind_vertex_array( Some( vao ) );
|
||||
gl.bind_buffer( glow::ARRAY_BUFFER, Some( vbo ) );
|
||||
gl.enable_vertex_attrib_array( 0 );
|
||||
gl.vertex_attrib_pointer_f32( 0, 2, glow::FLOAT, false, 16, 0 );
|
||||
gl.enable_vertex_attrib_array( 1 );
|
||||
gl.vertex_attrib_pointer_f32( 1, 2, glow::FLOAT, false, 16, 8 );
|
||||
gl.bind_vertex_array( None );
|
||||
( vao, vbo )
|
||||
};
|
||||
|
||||
let quad_vertices: [f32; 12] = [
|
||||
0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
|
||||
1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
|
||||
@@ -265,11 +298,46 @@ impl GlesCanvas
|
||||
( fbo, fbo_tex )
|
||||
};
|
||||
|
||||
// Atlas format picked per ES profile: GL_R8 / GL_RED on ES3
|
||||
// (GL_LUMINANCE is deprecated in ES3 core and some mobile drivers
|
||||
// — Mali, certain Adreno — return GL_INVALID_ENUM for it), legacy
|
||||
// GL_LUMINANCE on ES2 where the modern single-channel formats do
|
||||
// not exist. The glyph fragment shader samples `.r` and works for
|
||||
// both: GL_RED puts the byte in .r directly, GL_LUMINANCE
|
||||
// replicates it into .r=.g=.b, so reading `.r` gives the same
|
||||
// coverage value either way.
|
||||
let ( atlas_internal, atlas_format ) = match version
|
||||
{
|
||||
GlesVersion::V3 => ( glow::R8 as i32, glow::RED ),
|
||||
GlesVersion::V2 => ( glow::LUMINANCE as i32, glow::LUMINANCE ),
|
||||
};
|
||||
let atlas_texture = unsafe
|
||||
{
|
||||
let tex = gl.create_texture().unwrap();
|
||||
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
|
||||
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
|
||||
gl.tex_image_2d(
|
||||
glow::TEXTURE_2D, 0, atlas_internal,
|
||||
super::ATLAS_SIZE as i32, super::ATLAS_SIZE as i32, 0,
|
||||
atlas_format, glow::UNSIGNED_BYTE,
|
||||
glow::PixelUnpackData::Slice( None ),
|
||||
);
|
||||
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
|
||||
gl.bind_texture( glow::TEXTURE_2D, None );
|
||||
tex
|
||||
};
|
||||
|
||||
Self
|
||||
{
|
||||
gl,
|
||||
version,
|
||||
font,
|
||||
font_bytes,
|
||||
font_face,
|
||||
font_registry: None,
|
||||
dpi_scale: 1.0,
|
||||
global_alpha: 1.0,
|
||||
@@ -300,6 +368,14 @@ impl GlesCanvas
|
||||
u_glyph_color,
|
||||
u_glyph_opacity,
|
||||
u_glyph_sampler,
|
||||
u_glyph_uv_offset,
|
||||
u_glyph_uv_scale,
|
||||
glyph_batch_program,
|
||||
u_glyph_batch_color,
|
||||
u_glyph_batch_opacity,
|
||||
u_glyph_batch_sampler,
|
||||
glyph_batch_vao,
|
||||
glyph_batch_vbo,
|
||||
u_blit_sampler,
|
||||
u_subblit_mvp,
|
||||
u_subblit_sampler,
|
||||
@@ -379,6 +455,11 @@ impl GlesCanvas
|
||||
u_bd_fc_padding,
|
||||
u_bd_fc_radii,
|
||||
u_bd_fc_tint,
|
||||
atlas_texture,
|
||||
atlas_format,
|
||||
atlas_cursor_x: 0,
|
||||
atlas_cursor_y: 0,
|
||||
atlas_row_height: 0,
|
||||
glyph_cache: HashMap::new(),
|
||||
image_cache: HashMap::new(),
|
||||
gradient_lut_cache: HashMap::new(),
|
||||
@@ -425,11 +506,39 @@ impl GlesCanvas
|
||||
( fbo, fbo_tex )
|
||||
};
|
||||
|
||||
let atlas_format = self.atlas_format;
|
||||
let atlas_internal = match self.version
|
||||
{
|
||||
GlesVersion::V3 => glow::R8 as i32,
|
||||
GlesVersion::V2 => glow::LUMINANCE as i32,
|
||||
};
|
||||
let atlas_texture = unsafe
|
||||
{
|
||||
let tex = gl.create_texture().unwrap();
|
||||
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
|
||||
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
|
||||
gl.tex_image_2d(
|
||||
glow::TEXTURE_2D, 0, atlas_internal,
|
||||
super::ATLAS_SIZE as i32, super::ATLAS_SIZE as i32, 0,
|
||||
atlas_format, glow::UNSIGNED_BYTE,
|
||||
glow::PixelUnpackData::Slice( None ),
|
||||
);
|
||||
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
|
||||
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
|
||||
gl.bind_texture( glow::TEXTURE_2D, None );
|
||||
tex
|
||||
};
|
||||
|
||||
GlesCanvas
|
||||
{
|
||||
gl,
|
||||
version: self.version,
|
||||
font: Arc::clone( &self.font ),
|
||||
font_bytes: Arc::clone( &self.font_bytes ),
|
||||
font_face: self.font_face,
|
||||
font_registry: self.font_registry.as_ref().map( Arc::clone ),
|
||||
dpi_scale: self.dpi_scale,
|
||||
global_alpha: self.global_alpha,
|
||||
@@ -460,10 +569,18 @@ impl GlesCanvas
|
||||
u_tex_mvp: self.u_tex_mvp,
|
||||
u_tex_opacity: self.u_tex_opacity,
|
||||
u_tex_sampler: self.u_tex_sampler,
|
||||
u_glyph_mvp: self.u_glyph_mvp,
|
||||
u_glyph_color: self.u_glyph_color,
|
||||
u_glyph_opacity: self.u_glyph_opacity,
|
||||
u_glyph_sampler: self.u_glyph_sampler,
|
||||
u_glyph_mvp: self.u_glyph_mvp,
|
||||
u_glyph_color: self.u_glyph_color,
|
||||
u_glyph_opacity: self.u_glyph_opacity,
|
||||
u_glyph_sampler: self.u_glyph_sampler,
|
||||
u_glyph_uv_offset: self.u_glyph_uv_offset,
|
||||
u_glyph_uv_scale: self.u_glyph_uv_scale,
|
||||
glyph_batch_program: self.glyph_batch_program,
|
||||
u_glyph_batch_color: self.u_glyph_batch_color,
|
||||
u_glyph_batch_opacity: self.u_glyph_batch_opacity,
|
||||
u_glyph_batch_sampler: self.u_glyph_batch_sampler,
|
||||
glyph_batch_vao: self.glyph_batch_vao,
|
||||
glyph_batch_vbo: self.glyph_batch_vbo,
|
||||
u_blit_sampler: self.u_blit_sampler,
|
||||
u_subblit_mvp: self.u_subblit_mvp,
|
||||
u_subblit_sampler: self.u_subblit_sampler,
|
||||
@@ -539,6 +656,11 @@ impl GlesCanvas
|
||||
u_bd_fc_padding: self.u_bd_fc_padding,
|
||||
u_bd_fc_radii: self.u_bd_fc_radii,
|
||||
u_bd_fc_tint: self.u_bd_fc_tint,
|
||||
atlas_texture,
|
||||
atlas_format,
|
||||
atlas_cursor_x: 0,
|
||||
atlas_cursor_y: 0,
|
||||
atlas_row_height: 0,
|
||||
glyph_cache: HashMap::new(),
|
||||
image_cache: HashMap::new(),
|
||||
gradient_lut_cache: HashMap::new(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Text rendering for [`GlesCanvas`]: glyph-atlas cache + per-glyph
|
||||
//! draw call. Each glyph is rasterised once via fontdue, uploaded as
|
||||
//! a one-off `GL_LUMINANCE` texture, and stored on the canvas; the
|
||||
//! per-frame hot path picks positions and issues one draw call per
|
||||
//! glyph.
|
||||
//! Text rendering for [`GlesCanvas`]: shelf-packed glyph atlas +
|
||||
//! per-glyph draw call. The line is shaped through
|
||||
//! [`crate::text_shaping::shape_line`] (Unicode BiDi + per-sub-run
|
||||
//! rustybuzz / HarfBuzz) and each shaped glyph is rasterised by
|
||||
//! glyph index via `fontdue::Font::rasterize_indexed`, so the cache
|
||||
//! is keyed on `(glyph_id, size_bits, font_id)` and Arabic
|
||||
//! connected forms / Devanagari clusters / CJK shaped glyphs are
|
||||
//! cached correctly without colliding with the source codepoints.
|
||||
//!
|
||||
//! `GL_LUMINANCE` is deliberate — `GL_ALPHA` has inconsistent
|
||||
//! handling across Mesa GLES3 drivers (sampled `.a` returns near-zero
|
||||
//! in glyph interiors, leaving only antialias edges visible), and
|
||||
//! luminance's `.r = .g = .b = data` mapping is well-supported
|
||||
//! across ES2/ES3.
|
||||
//! The atlas format is picked per ES profile
|
||||
//! ([`GlesCanvas::atlas_format`]): `GL_R8` / `GL_RED` on ES3,
|
||||
//! `GL_LUMINANCE` on ES2. The fragment shader samples `.r` and is
|
||||
//! identical for both — `GL_RED` puts the coverage byte in `.r`
|
||||
//! directly, `GL_LUMINANCE` replicates it into `.r=.g=.b`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use glow::HasContext;
|
||||
|
||||
use crate::types::{ Color, Rect };
|
||||
use crate::types::Color;
|
||||
|
||||
use super::helpers::{ ortho_rect, upload_alpha_texture };
|
||||
use super::{ GlesCanvas, GlyphEntry };
|
||||
use super::{ ATLAS_SIZE, GlesCanvas, GlyphEntry };
|
||||
|
||||
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
|
||||
|
||||
@@ -37,9 +39,6 @@ impl GlesCanvas
|
||||
self.draw_text_inner( text, x, y, size, color, None );
|
||||
}
|
||||
|
||||
/// Draw `text` using `font` instead of the canvas default + lazy
|
||||
/// system-font fallback chain. Glyphs from this font live under
|
||||
/// their own atlas keys.
|
||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
|
||||
{
|
||||
self.draw_text_inner( text, x, y, size, color, Some( font ) );
|
||||
@@ -48,129 +47,330 @@ impl GlesCanvas
|
||||
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
|
||||
{
|
||||
self.activate_target();
|
||||
let scaled = size * self.dpi_scale;
|
||||
let mut cursor_x = x;
|
||||
let scaled = size * self.dpi_scale;
|
||||
let size_key = ( scaled * 10.0 ) as u32;
|
||||
|
||||
for ch in text.chars()
|
||||
unsafe
|
||||
{
|
||||
let size_key = (scaled * 10.0) as u32;
|
||||
let ( id, font_arc ): ( usize, Arc<Font> ) = match font
|
||||
{
|
||||
Some( f ) =>
|
||||
{
|
||||
if f.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
( font_id( f ), Arc::clone( f ) )
|
||||
}
|
||||
else
|
||||
{
|
||||
self.font_id_for_char( ch )
|
||||
}
|
||||
}
|
||||
None => self.font_id_for_char( ch ),
|
||||
};
|
||||
let key = ( ch, size_key, id );
|
||||
self.gl.active_texture( glow::TEXTURE0 );
|
||||
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.atlas_texture ) );
|
||||
}
|
||||
|
||||
if !self.glyph_cache.contains_key( &key )
|
||||
// Resolve every glyph + its font up front so we can decide
|
||||
// whether the atlas needs a single reset before we start
|
||||
// emitting quads. Doing the reset mid-draw would orphan the
|
||||
// `(atlas_x, atlas_y)` recorded in `glyph_cache` for glyphs
|
||||
// already pushed earlier in this same call.
|
||||
let canvas_handle = self.font_handle();
|
||||
let prefer_handle = font.cloned().map( |f|
|
||||
{
|
||||
if Arc::ptr_eq( &f, &canvas_handle.font )
|
||||
{
|
||||
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
|
||||
canvas_handle.clone()
|
||||
}
|
||||
else
|
||||
{
|
||||
// Caller-supplied face without bytes — leave bytes
|
||||
// empty so the resolve function below knows it
|
||||
// cannot be shaped through HarfBuzz and must fall
|
||||
// back to the system chain.
|
||||
crate::system_fonts::FontHandle
|
||||
{
|
||||
self.evict_glyph_cache_half();
|
||||
}
|
||||
let ( metrics, bitmap ) = font_arc.rasterize( ch, scaled );
|
||||
if metrics.width > 0 && metrics.height > 0
|
||||
{
|
||||
let tex = upload_alpha_texture( &self.gl, &bitmap, metrics.width as i32, metrics.height as i32 );
|
||||
self.glyph_cache.insert( key, GlyphEntry
|
||||
{
|
||||
texture: tex,
|
||||
metrics,
|
||||
tex_w: metrics.width as i32,
|
||||
tex_h: metrics.height as i32,
|
||||
} );
|
||||
} else {
|
||||
cursor_x += metrics.advance_width;
|
||||
continue;
|
||||
font: f,
|
||||
bytes: Arc::new( Vec::new() ),
|
||||
face: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some( entry ) = self.glyph_cache.get( &key )
|
||||
} ).unwrap_or_else( || canvas_handle.clone() );
|
||||
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
|
||||
{
|
||||
if prefer_handle.font.lookup_glyph_index( ch ) != 0 && !prefer_handle.bytes.is_empty()
|
||||
{
|
||||
let gx = ( cursor_x + entry.metrics.xmin as f32 ).round();
|
||||
let gy = ( y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
|
||||
let dest = Rect
|
||||
return Some( prefer_handle.clone() );
|
||||
}
|
||||
crate::system_fonts::lookup_handle( ch ).or_else( ||
|
||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||
)
|
||||
};
|
||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
||||
if shaped.is_empty() { return; }
|
||||
|
||||
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
|
||||
// rasterization. The `shape_line` step kept the id as the
|
||||
// `Arc<Font>::as_ptr` of the resolved handle, so we walk
|
||||
// every codepoint and ask the same resolver — exactly once
|
||||
// per distinct id thanks to the dedup loop.
|
||||
let mut fonts: Vec<( usize, Arc<Font> )> = Vec::new();
|
||||
if !canvas_handle.bytes.is_empty()
|
||||
{
|
||||
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
|
||||
}
|
||||
for g in &shaped
|
||||
{
|
||||
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
||||
let mut found = None;
|
||||
for ch in text.chars()
|
||||
{
|
||||
if let Some( h ) = crate::system_fonts::lookup_handle( ch )
|
||||
{
|
||||
x: gx,
|
||||
y: gy,
|
||||
width: entry.tex_w as f32,
|
||||
height: entry.tex_h as f32,
|
||||
};
|
||||
self.draw_glyph_texture( entry.texture, dest, color, self.global_alpha );
|
||||
cursor_x += entry.metrics.advance_width;
|
||||
if font_id( &h.font ) == g.font_id { found = Some( h.font ); break; }
|
||||
}
|
||||
}
|
||||
if let Some( f ) = found
|
||||
{
|
||||
fonts.push( ( g.font_id, f ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1 — rasterise every missing glyph into the atlas.
|
||||
// Permitted to reset the atlas (and re-rasterise everything we
|
||||
// already inserted in this same pass) at most once: a single
|
||||
// string longer than the atlas capacity falls back to skipping
|
||||
// the tail rather than oscillating.
|
||||
let mut reset_used = false;
|
||||
let mut i = 0;
|
||||
while i < shaped.len()
|
||||
{
|
||||
let g = &shaped[ i ];
|
||||
let glyph_id = g.glyph_id as u16;
|
||||
let key = ( glyph_id, size_key, g.font_id );
|
||||
if self.glyph_cache.contains_key( &key )
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let ( metrics, bitmap ) = font_arc.rasterize_indexed( glyph_id, scaled );
|
||||
if metrics.width == 0 || metrics.height == 0
|
||||
{
|
||||
self.glyph_cache.insert( key, GlyphEntry
|
||||
{
|
||||
metrics,
|
||||
tex_w: 0,
|
||||
tex_h: 0,
|
||||
atlas_x: 0,
|
||||
atlas_y: 0,
|
||||
} );
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let ( w, h ) = ( metrics.width as u32, metrics.height as u32 );
|
||||
|
||||
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP && !reset_used
|
||||
{
|
||||
self.atlas_reset();
|
||||
reset_used = true;
|
||||
i = 0;
|
||||
continue;
|
||||
}
|
||||
let pos = match self.atlas_alloc( w, h )
|
||||
{
|
||||
Some( p ) => p,
|
||||
None if !reset_used =>
|
||||
{
|
||||
self.atlas_reset();
|
||||
reset_used = true;
|
||||
i = 0;
|
||||
continue;
|
||||
}
|
||||
None =>
|
||||
{
|
||||
self.glyph_cache.insert( key, GlyphEntry
|
||||
{
|
||||
metrics,
|
||||
tex_w: 0,
|
||||
tex_h: 0,
|
||||
atlas_x: 0,
|
||||
atlas_y: 0,
|
||||
} );
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
unsafe
|
||||
{
|
||||
self.gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
|
||||
self.gl.tex_sub_image_2d(
|
||||
glow::TEXTURE_2D, 0,
|
||||
pos.0 as i32, pos.1 as i32,
|
||||
w as i32, h as i32,
|
||||
self.atlas_format, glow::UNSIGNED_BYTE,
|
||||
glow::PixelUnpackData::Slice( Some( &bitmap ) ),
|
||||
);
|
||||
self.gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
|
||||
}
|
||||
self.glyph_cache.insert( key, GlyphEntry
|
||||
{
|
||||
metrics,
|
||||
tex_w: w as i32,
|
||||
tex_h: h as i32,
|
||||
atlas_x: pos.0,
|
||||
atlas_y: pos.1,
|
||||
} );
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Phase 2 — collect every quad's vertices in one buffer and
|
||||
// flush them with a single draw call. Saves N-1
|
||||
// program/uniform/draw round-trips per text run.
|
||||
let atlas_size = ATLAS_SIZE as f32;
|
||||
let surface_w = self.width as f32;
|
||||
let surface_h = self.height as f32;
|
||||
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
|
||||
let mut cx = x;
|
||||
for g in &shaped
|
||||
{
|
||||
let glyph_id = g.glyph_id as u16;
|
||||
let key = ( glyph_id, size_key, g.font_id );
|
||||
let Some( entry ) = self.glyph_cache.get( &key ) else
|
||||
{
|
||||
cx += g.x_advance;
|
||||
continue;
|
||||
};
|
||||
if entry.tex_w == 0 || entry.tex_h == 0
|
||||
{
|
||||
cx += g.x_advance;
|
||||
continue;
|
||||
}
|
||||
let pen_x = cx + g.x_offset;
|
||||
let pen_y = y - g.y_offset;
|
||||
let gx = ( pen_x + entry.metrics.xmin as f32 ).round();
|
||||
let gy = ( pen_y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
|
||||
let gw = entry.tex_w as f32;
|
||||
let gh = entry.tex_h as f32;
|
||||
|
||||
let x0_ndc = gx * 2.0 / surface_w - 1.0;
|
||||
let x1_ndc = ( gx + gw ) * 2.0 / surface_w - 1.0;
|
||||
let y0_ndc = 1.0 - gy * 2.0 / surface_h;
|
||||
let y1_ndc = 1.0 - ( gy + gh ) * 2.0 / surface_h;
|
||||
|
||||
let u0 = entry.atlas_x as f32 / atlas_size;
|
||||
let v0 = entry.atlas_y as f32 / atlas_size;
|
||||
let u1 = ( entry.atlas_x as f32 + gw ) / atlas_size;
|
||||
let v1 = ( entry.atlas_y as f32 + gh ) / atlas_size;
|
||||
|
||||
verts.extend_from_slice( &[
|
||||
x0_ndc, y0_ndc, u0, v0,
|
||||
x1_ndc, y0_ndc, u1, v0,
|
||||
x0_ndc, y1_ndc, u0, v1,
|
||||
x1_ndc, y0_ndc, u1, v0,
|
||||
x1_ndc, y1_ndc, u1, v1,
|
||||
x0_ndc, y1_ndc, u0, v1,
|
||||
] );
|
||||
|
||||
cx += g.x_advance;
|
||||
}
|
||||
|
||||
if !verts.is_empty()
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
self.gl.use_program( Some( self.glyph_batch_program ) );
|
||||
self.gl.uniform_4_f32( Some( &self.u_glyph_batch_color ), color.r, color.g, color.b, color.a );
|
||||
self.gl.uniform_1_f32( Some( &self.u_glyph_batch_opacity ), self.global_alpha );
|
||||
self.gl.uniform_1_i32( Some( &self.u_glyph_batch_sampler ), 0 );
|
||||
self.gl.bind_vertex_array( Some( self.glyph_batch_vao ) );
|
||||
self.gl.bind_buffer( glow::ARRAY_BUFFER, Some( self.glyph_batch_vbo ) );
|
||||
self.gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, super::helpers::bytemuck_cast_slice( &verts ), glow::STREAM_DRAW );
|
||||
let vertex_count = ( verts.len() / 4 ) as i32;
|
||||
self.gl.draw_arrays( glow::TRIANGLES, 0, vertex_count );
|
||||
self.gl.bind_vertex_array( None );
|
||||
}
|
||||
}
|
||||
|
||||
unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); }
|
||||
}
|
||||
|
||||
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
|
||||
{
|
||||
text.chars().map( |ch|
|
||||
{
|
||||
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
|
||||
} ).sum()
|
||||
self.measure_inner( text, size, None )
|
||||
}
|
||||
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
|
||||
{
|
||||
text.chars().map( |ch|
|
||||
self.measure_inner( text, size, Some( font ) )
|
||||
}
|
||||
|
||||
fn measure_inner( &self, text: &str, size: f32, font: Option<&Arc<Font>> ) -> f32
|
||||
{
|
||||
let scaled = size * self.dpi_scale;
|
||||
let canvas_handle = self.font_handle();
|
||||
let prefer_handle = font.cloned().map( |f|
|
||||
{
|
||||
let f = if font.lookup_glyph_index( ch ) != 0
|
||||
if Arc::ptr_eq( &f, &canvas_handle.font )
|
||||
{
|
||||
Arc::clone( font )
|
||||
canvas_handle.clone()
|
||||
}
|
||||
else
|
||||
{
|
||||
self.font_for_char( ch )
|
||||
};
|
||||
f.metrics( ch, size * self.dpi_scale ).advance_width
|
||||
} ).sum()
|
||||
}
|
||||
|
||||
fn font_id_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
|
||||
{
|
||||
let font = self.font_for_char( ch );
|
||||
let id = Arc::as_ptr( &font ) as usize;
|
||||
( id, font )
|
||||
}
|
||||
|
||||
fn evict_glyph_cache_half( &mut self )
|
||||
{
|
||||
let drop_n = self.glyph_cache.len() / 2;
|
||||
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
|
||||
for key in victims
|
||||
{
|
||||
if let Some( entry ) = self.glyph_cache.remove( &key )
|
||||
{
|
||||
unsafe { self.gl.delete_texture( entry.texture ); }
|
||||
crate::system_fonts::FontHandle
|
||||
{
|
||||
font: f,
|
||||
bytes: Arc::new( Vec::new() ),
|
||||
face: 0,
|
||||
}
|
||||
}
|
||||
} ).unwrap_or_else( || canvas_handle.clone() );
|
||||
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
|
||||
{
|
||||
if prefer_handle.font.lookup_glyph_index( ch ) != 0 && !prefer_handle.bytes.is_empty()
|
||||
{
|
||||
return Some( prefer_handle.clone() );
|
||||
}
|
||||
crate::system_fonts::lookup_handle( ch ).or_else( ||
|
||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||
)
|
||||
};
|
||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
||||
if shaped.is_empty()
|
||||
{
|
||||
return text.chars().map( |ch|
|
||||
{
|
||||
let f = font.cloned().unwrap_or_else( || self.font_for_char( ch ) );
|
||||
f.metrics( ch, scaled ).advance_width
|
||||
} ).sum();
|
||||
}
|
||||
shaped.iter().map( |g| g.x_advance ).sum()
|
||||
}
|
||||
|
||||
fn font_handle( &self ) -> crate::system_fonts::FontHandle
|
||||
{
|
||||
crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::clone( &self.font ),
|
||||
bytes: Arc::clone( &self.font_bytes ),
|
||||
face: self.font_face,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_glyph_texture( &self, texture: glow::Texture, dest: Rect, color: Color, opacity: f32 )
|
||||
fn atlas_alloc( &mut self, w: u32, h: u32 ) -> Option<( u32, u32 )>
|
||||
{
|
||||
let mvp = ortho_rect( self.width, self.height, dest );
|
||||
unsafe
|
||||
const PAD: u32 = 1;
|
||||
if w + PAD > ATLAS_SIZE || h + PAD > ATLAS_SIZE { return None; }
|
||||
if self.atlas_cursor_x + w + PAD > ATLAS_SIZE
|
||||
{
|
||||
self.gl.use_program( Some( self.glyph_program ) );
|
||||
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_glyph_mvp ), false, &mvp );
|
||||
self.gl.uniform_4_f32( Some( &self.u_glyph_color ), color.r, color.g, color.b, color.a );
|
||||
self.gl.uniform_1_f32( Some( &self.u_glyph_opacity ), opacity );
|
||||
self.gl.active_texture( glow::TEXTURE0 );
|
||||
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
|
||||
self.gl.uniform_1_i32( Some( &self.u_glyph_sampler ), 0 );
|
||||
self.gl.bind_vertex_array( Some( self.quad_vao ) );
|
||||
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
|
||||
self.gl.bind_vertex_array( None );
|
||||
self.gl.bind_texture( glow::TEXTURE_2D, None );
|
||||
self.atlas_cursor_x = 0;
|
||||
self.atlas_cursor_y += self.atlas_row_height;
|
||||
self.atlas_row_height = 0;
|
||||
}
|
||||
if self.atlas_cursor_y + h + PAD > ATLAS_SIZE { return None; }
|
||||
let x = self.atlas_cursor_x;
|
||||
let y = self.atlas_cursor_y;
|
||||
self.atlas_cursor_x += w + PAD;
|
||||
if h + PAD > self.atlas_row_height { self.atlas_row_height = h + PAD; }
|
||||
Some( ( x, y ) )
|
||||
}
|
||||
|
||||
fn atlas_reset( &mut self )
|
||||
{
|
||||
self.glyph_cache.clear();
|
||||
self.atlas_cursor_x = 0;
|
||||
self.atlas_cursor_y = 0;
|
||||
self.atlas_row_height = 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub struct GestureState<Msg: Clone>
|
||||
pub pressed_idx: Option<usize>,
|
||||
/// Index of the Scroll viewport that owns the current gesture, if
|
||||
/// the press landed inside one.
|
||||
pub scrolling_widget: Option<usize>,
|
||||
pub scrolling_widget: Option<( usize, crate::widget::scroll::ScrollAxis )>,
|
||||
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
|
||||
/// release will be consumed as a scroll instead of a tap.
|
||||
pub scroll_drag_started: bool,
|
||||
@@ -161,7 +161,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
&mut self,
|
||||
pos: Point,
|
||||
widget_rects: &[LaidOutWidget<Msg>],
|
||||
scroll_rects: &[( Rect, usize )],
|
||||
scroll_rects: &[( Rect, usize, crate::widget::scroll::ScrollAxis )],
|
||||
) -> PressOutcome<Msg>
|
||||
{
|
||||
let hit = find_widget_at( widget_rects, pos );
|
||||
@@ -176,8 +176,8 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
|
||||
self.start = Some( pos );
|
||||
self.scrolling_widget = scroll_rects.iter().rev()
|
||||
.find( |( r, _ )| r.contains( pos ) )
|
||||
.map( |( _, idx )| *idx );
|
||||
.find( |( r, _, _ )| r.contains( pos ) )
|
||||
.map( |( _, idx, ax )| ( *idx, *ax ) );
|
||||
self.scroll_drag_started = false;
|
||||
self.horizontal_drag_started = false;
|
||||
self.vertical_drag_started = false;
|
||||
@@ -238,7 +238,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
&mut self,
|
||||
pos: Point,
|
||||
widget_rects: &[LaidOutWidget<Msg>],
|
||||
scroll_offsets: &mut HashMap<usize, f32>,
|
||||
scroll_offsets: &mut HashMap<usize, ( f32, f32 )>,
|
||||
swipe: &SwipeConfig,
|
||||
global_drag: bool,
|
||||
) -> MoveOutcome<Msg>
|
||||
@@ -292,15 +292,23 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
// Scroll viewport drag: mutate offset in place and advance the
|
||||
// gesture origin so the next delta is frame-to-frame, not
|
||||
// press-to-now (otherwise the first 8 px trip the threshold
|
||||
// and the entire scroll gets absorbed into one delta).
|
||||
if let Some( scroll_idx ) = self.scrolling_widget
|
||||
// and the entire scroll gets absorbed into one delta). Both
|
||||
// axes are routed independently; an axis the viewport does not
|
||||
// allow is just ignored (delta still consumed by the gesture,
|
||||
// so the swipe handler does not fight the scroll for it).
|
||||
if let Some( ( scroll_idx, axis ) ) = self.scrolling_widget
|
||||
{
|
||||
if let Some( start ) = self.start
|
||||
{
|
||||
let dx = pos.x - start.x;
|
||||
let dy = pos.y - start.y;
|
||||
let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
|
||||
*entry = ( *entry - dy ).max( 0.0 );
|
||||
if dy.abs() > 8.0 { self.scroll_drag_started = true; }
|
||||
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
|
||||
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }
|
||||
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
|
||||
else if axis.allows_x() { dx.abs() }
|
||||
else { dy.abs() };
|
||||
if moved > 8.0 { self.scroll_drag_started = true; }
|
||||
self.start = Some( pos );
|
||||
return MoveOutcome::Scroll;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::widget::{ LaidOutWidget, WidgetHandlers };
|
||||
use crate::widget::scroll::ScrollAxis;
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||||
enum Msg
|
||||
@@ -42,6 +43,8 @@ fn button_full(
|
||||
keyboard_focusable: true,
|
||||
cursor: crate::types::CursorShape::Default,
|
||||
tooltip: None,
|
||||
accessible_label: None,
|
||||
is_live_region: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +159,10 @@ fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire()
|
||||
fn press_identifies_scroll_target()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls );
|
||||
assert_eq!( g.scrolling_widget, Some( 42 ) );
|
||||
assert_eq!( g.scrolling_widget, Some( ( 42, ScrollAxis::Vertical ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
@@ -230,14 +233,14 @@ fn move_with_global_drag_returns_drag()
|
||||
fn move_inside_scroll_widget_mutates_offset_in_place()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
// Drag finger upward → content scrolls down → offset increases.
|
||||
let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert!( matches!( out, MoveOutcome::Scroll ) );
|
||||
assert_eq!( offsets.get( &7 ).copied(), Some( 40.0 ) );
|
||||
assert_eq!( offsets.get( &7 ).copied(), Some( ( 0.0, 40.0 ) ) );
|
||||
assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" );
|
||||
}
|
||||
|
||||
@@ -245,20 +248,20 @@ fn move_inside_scroll_widget_mutates_offset_in_place()
|
||||
fn move_inside_scroll_widget_clamps_offset_at_zero()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
// Drag finger downward → content tries to scroll up past origin → clamp at 0.
|
||||
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert_eq!( offsets.get( &9 ).copied(), Some( 0.0 ) );
|
||||
assert_eq!( offsets.get( &9 ).copied(), Some( ( 0.0, 0.0 ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn move_below_eight_pixels_does_not_arm_scroll_drag()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
@@ -430,7 +433,7 @@ fn release_after_consumed_scroll_emits_no_events()
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
g.start = Some( pt( 50.0, 50.0 ) );
|
||||
g.scrolling_widget = Some( 1 );
|
||||
g.scrolling_widget = Some( ( 1, ScrollAxis::Vertical ) );
|
||||
g.scroll_drag_started = true;
|
||||
let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||
assert!( events.is_empty() );
|
||||
@@ -446,7 +449,7 @@ fn cancel_resets_every_field()
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
g.start = Some( pt( 1.0, 2.0 ) );
|
||||
g.pressed_idx = Some( 99 );
|
||||
g.scrolling_widget = Some( 99 );
|
||||
g.scrolling_widget = Some( ( 99, ScrollAxis::Vertical ) );
|
||||
g.scroll_drag_started = true;
|
||||
g.horizontal_drag_started = true;
|
||||
g.vertical_drag_started = true;
|
||||
|
||||
@@ -47,6 +47,7 @@ impl<A: App> KeyboardHandler for AppData<A>
|
||||
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
|
||||
self.keyboard_focus = focus;
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
if let Some( ref mut a ) = self.a11y { a.set_window_focus( true ); }
|
||||
}
|
||||
|
||||
fn leave(
|
||||
@@ -60,6 +61,7 @@ impl<A: App> KeyboardHandler for AppData<A>
|
||||
{
|
||||
self.stop_key_repeat();
|
||||
self.keyboard_focus = SurfaceFocus::Main;
|
||||
if let Some( ref mut a ) = self.a11y { a.set_window_focus( false ); }
|
||||
}
|
||||
|
||||
fn press_key(
|
||||
|
||||
@@ -22,7 +22,7 @@ impl<A: App> AppData<A>
|
||||
let scroll_meta = {
|
||||
let ss = self.surface( focus );
|
||||
ss.scroll_rects.iter().rev()
|
||||
.find_map( |( rect, idx )|
|
||||
.find_map( |( rect, idx, _ax )|
|
||||
{
|
||||
ss.scroll_navigable_items.get( idx )
|
||||
.filter( |list| !list.is_empty() )
|
||||
@@ -42,30 +42,32 @@ impl<A: App> AppData<A>
|
||||
};
|
||||
let ( new_idx, content_y, content_h ) = items[ next_pos ];
|
||||
|
||||
// Auto-scroll to bring the new item fully into view. Item Y is
|
||||
// in pre-offset content coordinates, so the offset that places
|
||||
// the item flush with the top of the viewport is `content_y`,
|
||||
// Auto-scroll on the Y axis to bring the new item fully into view.
|
||||
// Item Y is in pre-offset content coordinates, so the offset that
|
||||
// places the item flush with the top of the viewport is `content_y`,
|
||||
// and flush with the bottom is `content_y + content_h - viewport_h`.
|
||||
// Keyboard navigation only steps on the navigable-item axis (vertical
|
||||
// list-style); the X offset is preserved as-is.
|
||||
let viewport_h = scroll_rect.height;
|
||||
let current_offset = self.surface( focus )
|
||||
let ( current_x, current_y ) = self.surface( focus )
|
||||
.scroll_offsets.get( &scroll_idx )
|
||||
.copied().unwrap_or( 0.0 );
|
||||
let new_offset = if content_y < current_offset
|
||||
.copied().unwrap_or( ( 0.0, 0.0 ) );
|
||||
let new_y = if content_y < current_y
|
||||
{
|
||||
content_y
|
||||
}
|
||||
else if content_y + content_h > current_offset + viewport_h
|
||||
else if content_y + content_h > current_y + viewport_h
|
||||
{
|
||||
( content_y + content_h - viewport_h ).max( 0.0 )
|
||||
}
|
||||
else
|
||||
{
|
||||
current_offset
|
||||
current_y
|
||||
};
|
||||
|
||||
let ss = self.surface_mut( focus );
|
||||
ss.hovered_idx = Some( new_idx );
|
||||
ss.scroll_offsets.insert( scroll_idx, new_offset );
|
||||
ss.scroll_offsets.insert( scroll_idx, ( current_x, new_y ) );
|
||||
ss.request_redraw();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -30,24 +30,45 @@ impl<A: App> AppData<A>
|
||||
return;
|
||||
};
|
||||
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
||||
let scroll_idx_opt =
|
||||
let scroll_hit =
|
||||
{
|
||||
let ss = self.surface( focus );
|
||||
ss.scroll_rects.iter().rev()
|
||||
.find( |( r, _ )| r.contains( pos ) )
|
||||
.map( |( _, idx )| *idx )
|
||||
.find( |( r, _, _ )| r.contains( pos ) )
|
||||
.map( |( _, idx, ax )| ( *idx, *ax ) )
|
||||
};
|
||||
if let Some( scroll_idx ) = scroll_idx_opt
|
||||
if let Some( ( scroll_idx, axis ) ) = scroll_hit
|
||||
{
|
||||
let multiplier = match source
|
||||
{
|
||||
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let step = vertical.absolute as f32 * multiplier;
|
||||
let ss = self.surface_mut( focus );
|
||||
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
|
||||
*entry = ( *entry + step ).max( 0.0 );
|
||||
let step_x = horizontal.absolute as f32 * multiplier;
|
||||
let step_y = vertical.absolute as f32 * multiplier;
|
||||
let ss = self.surface_mut( focus );
|
||||
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
// Wheels report on a single axis at a time; route to
|
||||
// whichever axis the viewport allows. A pure horizontal
|
||||
// viewport translates a vertical wheel into horizontal
|
||||
// motion (the conventional shift-less wheel-to-strip UX);
|
||||
// `Both` keeps each axis independent.
|
||||
match axis
|
||||
{
|
||||
crate::widget::scroll::ScrollAxis::Vertical =>
|
||||
{
|
||||
entry.1 = ( entry.1 + step_y ).max( 0.0 );
|
||||
}
|
||||
crate::widget::scroll::ScrollAxis::Horizontal =>
|
||||
{
|
||||
entry.0 = ( entry.0 + step_x + step_y ).max( 0.0 );
|
||||
}
|
||||
crate::widget::scroll::ScrollAxis::Both =>
|
||||
{
|
||||
entry.0 = ( entry.0 + step_x ).max( 0.0 );
|
||||
entry.1 = ( entry.1 + step_y ).max( 0.0 );
|
||||
}
|
||||
}
|
||||
ss.request_redraw();
|
||||
} else {
|
||||
// No LTK scroll viewport under the cursor —
|
||||
|
||||
@@ -10,14 +10,22 @@
|
||||
//! the two handlers share `apply_move_outcome` /
|
||||
//! `apply_release_events` in `dispatch.rs`.
|
||||
//!
|
||||
//! The only touch-specific state is `AppData::touch_focus`, a
|
||||
//! `HashMap<touch_id, SurfaceFocus>` so a finger that landed on an
|
||||
//! overlay continues to route to that overlay even if it drifts over
|
||||
//! the main surface. Multi-finger tracking is not yet modelled — the
|
||||
//! gesture machine is single-gesture; a second finger arriving while
|
||||
//! the first is pressed overwrites the same slot. Good enough for
|
||||
//! sliders, swipes and taps; a proper multi-touch rewrite is a
|
||||
//! separate refactor.
|
||||
//! The first finger to land on a surface becomes its **primary slot**
|
||||
//! and drives the single-slot gesture machine (swipe, scroll,
|
||||
//! long-press, drag). Any additional finger arriving while the
|
||||
//! primary is held bypasses the gesture machine and surfaces directly
|
||||
//! through [`App::on_touch_down`] / [`App::on_touch_move`] /
|
||||
//! [`App::on_touch_up`], so apps can implement pinch-zoom or
|
||||
//! two-finger pan without losing the built-in gestures. Slot
|
||||
//! ownership is recorded on `SurfaceState::primary_touch_id` plus
|
||||
//! `SurfaceState::touch_slots` for the auxiliary fingers' last
|
||||
//! position (Wayland `wl_touch.up` does not carry one, so the slot
|
||||
//! cache supplies it).
|
||||
//!
|
||||
//! The cross-surface routing map `AppData::touch_focus` is still
|
||||
//! per touch id, so an auxiliary slot that landed on an overlay
|
||||
//! keeps reporting through that overlay's `App` callback even if
|
||||
//! the finger drifts over the main surface.
|
||||
|
||||
use smithay_client_toolkit::seat::touch::TouchHandler;
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
@@ -49,6 +57,32 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
self.touch_focus.insert( id, focus );
|
||||
let pos = self.surface( focus ).to_physical( position.0, position.1 );
|
||||
self.pointer_pos = pos;
|
||||
|
||||
// Auxiliary slot path: a second (or third…) finger arriving
|
||||
// while another finger already drives the primary slot stays
|
||||
// out of the single-slot gesture machine entirely. The app
|
||||
// gets a raw `on_touch_down` and can drive its own pinch /
|
||||
// pan from the per-id stream.
|
||||
let is_primary =
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
if ss.primary_touch_id.is_none()
|
||||
{
|
||||
ss.primary_touch_id = Some( id );
|
||||
true
|
||||
}
|
||||
else
|
||||
{
|
||||
ss.touch_slots.insert( id, pos );
|
||||
false
|
||||
}
|
||||
};
|
||||
if !is_primary
|
||||
{
|
||||
self.app.on_touch_down( id as i64, pos.x, pos.y );
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
|
||||
{
|
||||
self.dismiss_main_outside_popups( pos );
|
||||
@@ -136,6 +170,26 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
)
|
||||
{
|
||||
let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main );
|
||||
// Auxiliary release: not the primary slot → recover the last
|
||||
// recorded position for the up callback (Wayland's `wl_touch.up`
|
||||
// does not carry one) and bail before reaching the gesture
|
||||
// machine.
|
||||
let is_primary =
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
ss.primary_touch_id == Some( id )
|
||||
};
|
||||
if !is_primary
|
||||
{
|
||||
let pos =
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
ss.touch_slots.remove( &id ).unwrap_or( self.pointer_pos )
|
||||
};
|
||||
self.app.on_touch_up( id as i64, pos.x, pos.y );
|
||||
return;
|
||||
}
|
||||
|
||||
// Touch-up does not carry a position in wl_touch — the last
|
||||
// motion's position is the release point.
|
||||
let pos = self.pointer_pos;
|
||||
@@ -144,7 +198,8 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
let events_out =
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
ss.needs_redraw = true;
|
||||
ss.needs_redraw = true;
|
||||
ss.primary_touch_id = None;
|
||||
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
|
||||
};
|
||||
self.apply_release_events( focus, events_out );
|
||||
@@ -163,8 +218,25 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
{
|
||||
let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main );
|
||||
let pp = self.surface( focus ).to_physical( position.0, position.1 );
|
||||
self.pointer_pos = pp;
|
||||
|
||||
let is_primary =
|
||||
{
|
||||
let ss = self.surface( focus );
|
||||
ss.primary_touch_id == Some( id )
|
||||
};
|
||||
if !is_primary
|
||||
{
|
||||
// Auxiliary slot: cache the latest position so `up`'s
|
||||
// release point is accurate, then surface the raw motion.
|
||||
{
|
||||
let ss = self.surface_mut( focus );
|
||||
ss.touch_slots.insert( id, pp );
|
||||
}
|
||||
self.app.on_touch_move( id as i64, pp.x, pp.y );
|
||||
return;
|
||||
}
|
||||
|
||||
self.pointer_pos = pp;
|
||||
let global_drag = self.has_active_long_press_drag();
|
||||
let swipe = self.swipe_config( focus );
|
||||
let outcome =
|
||||
@@ -209,14 +281,25 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
|
||||
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
|
||||
{
|
||||
self.touch_focus.clear();
|
||||
// The compositor is stealing every active touch — drop all
|
||||
// in-flight gesture state across every surface.
|
||||
let clear = |ss: &mut SurfaceState<A::Message>| { ss.gesture.on_cancel(); };
|
||||
clear( &mut self.main );
|
||||
// Snapshot every auxiliary slot's last position so the app
|
||||
// can be notified with a meaningful release point, then drop
|
||||
// every per-surface slot in one pass.
|
||||
let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new();
|
||||
let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
|
||||
{
|
||||
for ( id, pos ) in ss.touch_slots.drain() { out.push( ( id, pos ) ); }
|
||||
ss.primary_touch_id = None;
|
||||
ss.gesture.on_cancel();
|
||||
};
|
||||
collect( &mut self.main, &mut aux_releases );
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
clear( ss );
|
||||
collect( ss, &mut aux_releases );
|
||||
}
|
||||
self.touch_focus.clear();
|
||||
for ( id, pos ) in aux_releases
|
||||
{
|
||||
self.app.on_touch_up( id as i64, pos.x, pos.y );
|
||||
}
|
||||
self.stop_button_repeat();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
//! has_clip, strip_intersects_clip, clear_rects_transparent}`.
|
||||
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
|
||||
//! stroke_rect, draw_line}`.
|
||||
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text,
|
||||
//! rasterize_cached}`.
|
||||
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`.
|
||||
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
|
||||
//! write_to_wayland_buf}`.
|
||||
//! * [`helpers`] — free functions: `build_rounded_rect`,
|
||||
@@ -81,11 +80,15 @@ pub fn is_software_render() -> bool
|
||||
/// Cache key for a rasterized glyph. `size_bits` is the f32 bit
|
||||
/// pattern of `size * dpi_scale`; `font_id` is the address of the
|
||||
/// `Arc<Font>` used for the rasterisation, so distinct weights /
|
||||
/// families of the same `(char, size)` do not collide on the cache.
|
||||
/// families of the same `(glyph_id, size)` do not collide on the
|
||||
/// cache. `glyph_id` is the per-font glyph index returned by
|
||||
/// HarfBuzz shaping, so cached entries persist across script
|
||||
/// transitions and Arabic / Devanagari / CJK forms cluster
|
||||
/// independently of the `char` codepoint that produced them.
|
||||
#[ derive( Hash, PartialEq, Eq, Clone, Copy ) ]
|
||||
pub ( super ) struct GlyphKey
|
||||
{
|
||||
pub ( super ) ch: char,
|
||||
pub ( super ) glyph_id: u16,
|
||||
pub ( super ) size_bits: u32,
|
||||
pub ( super ) font_id: usize,
|
||||
}
|
||||
@@ -118,6 +121,13 @@ pub struct SoftwareCanvas
|
||||
/// working. Populated from
|
||||
/// [`crate::render::helpers::find_font`] at construction time.
|
||||
pub font: Arc<Font>,
|
||||
/// Raw bytes of the default font. Kept alongside `font` so the
|
||||
/// HarfBuzz shaper (rustybuzz) can be invoked without re-reading
|
||||
/// the file — fontdue does not expose its internal byte buffer.
|
||||
pub font_bytes: Arc<Vec<u8>>,
|
||||
/// TTC sub-face index for the default font (0 for single-face
|
||||
/// files; collection index for `.ttc` archives).
|
||||
pub font_face: u32,
|
||||
/// Optional theme font registry. When present,
|
||||
/// [`SoftwareCanvas::font_for`] consults it before falling back
|
||||
/// to `font`. Populated by the caller once the theme's `fonts`
|
||||
|
||||
@@ -10,29 +10,36 @@ use std::sync::{ Arc, OnceLock };
|
||||
use fontdue::{ Font, FontSettings };
|
||||
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
|
||||
|
||||
use crate::system_fonts::FontHandle;
|
||||
use crate::theme::{ FontRegistry, FontStyle };
|
||||
|
||||
use super::helpers::load_default_font_bytes;
|
||||
use super::SoftwareCanvas;
|
||||
|
||||
/// Process-wide cache of the default font face. Avoids re-reading +
|
||||
/// re-parsing the file on every surface bring-up. Sora is small
|
||||
/// (~50 KB) so the cost was minor in absolute terms — but a layer
|
||||
/// shell that brings up a launcher overlay, a QS panel, a calendar
|
||||
/// popup and a handful of toast surfaces would still pay the parse
|
||||
/// cost a dozen times in a single session, all of which is wasted
|
||||
/// work.
|
||||
static DEFAULT_FONT: OnceLock<Arc<Font>> = OnceLock::new();
|
||||
/// Process-wide cache of the default font face. The handle keeps the
|
||||
/// raw bytes (`Arc<Vec<u8>>`) alongside the fontdue `Arc<Font>` so
|
||||
/// the HarfBuzz shaper can be invoked without re-reading the file.
|
||||
/// Sora is small (~50 KB) so the cost was minor in absolute terms —
|
||||
/// but a layer shell that brings up a launcher overlay, a QS panel,
|
||||
/// a calendar popup and a handful of toast surfaces would still pay
|
||||
/// the parse cost a dozen times in a single session, all of which
|
||||
/// is wasted work.
|
||||
static DEFAULT_FONT: OnceLock<FontHandle> = OnceLock::new();
|
||||
|
||||
fn default_font() -> Arc<Font>
|
||||
fn default_handle() -> FontHandle
|
||||
{
|
||||
Arc::clone( DEFAULT_FONT.get_or_init( ||
|
||||
DEFAULT_FONT.get_or_init( ||
|
||||
{
|
||||
let bytes = load_default_font_bytes();
|
||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
||||
.expect( "bad font" );
|
||||
Arc::new( font )
|
||||
} ) )
|
||||
FontHandle
|
||||
{
|
||||
font: Arc::new( font ),
|
||||
bytes: Arc::new( bytes ),
|
||||
face: 0,
|
||||
}
|
||||
} ).clone()
|
||||
}
|
||||
|
||||
impl SoftwareCanvas
|
||||
@@ -47,10 +54,13 @@ impl SoftwareCanvas
|
||||
/// process reuses the cached `Arc<Font>`.
|
||||
pub fn new( width: u32, height: u32 ) -> Self
|
||||
{
|
||||
let handle = default_handle();
|
||||
Self
|
||||
{
|
||||
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
|
||||
font: default_font(),
|
||||
font: handle.font.clone(),
|
||||
font_bytes: handle.bytes.clone(),
|
||||
font_face: handle.face,
|
||||
font_registry: None,
|
||||
dpi_scale: 1.0,
|
||||
global_alpha: 1.0,
|
||||
@@ -67,6 +77,8 @@ impl SoftwareCanvas
|
||||
{
|
||||
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
|
||||
font: Arc::clone( &self.font ),
|
||||
font_bytes: Arc::clone( &self.font_bytes ),
|
||||
font_face: self.font_face,
|
||||
font_registry: self.font_registry.as_ref().map( Arc::clone ),
|
||||
dpi_scale: self.dpi_scale,
|
||||
global_alpha: self.global_alpha,
|
||||
@@ -110,6 +122,33 @@ impl SoftwareCanvas
|
||||
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
|
||||
}
|
||||
|
||||
/// Bytes-aware variant of [`Self::font_for_char`]. Returns the
|
||||
/// full `FontHandle` so callers that invoke the HarfBuzz shaper
|
||||
/// can hand the raw bytes directly to rustybuzz. The primary
|
||||
/// font supplies the bytes from
|
||||
/// [`Self::font_bytes`]; fallback chars borrow the bytes that
|
||||
/// were loaded into the system fallback cache.
|
||||
pub fn font_handle_for_char( &self, ch: char ) -> crate::system_fonts::FontHandle
|
||||
{
|
||||
if self.font.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
return crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::clone( &self.font ),
|
||||
bytes: Arc::clone( &self.font_bytes ),
|
||||
face: self.font_face,
|
||||
};
|
||||
}
|
||||
crate::system_fonts::lookup_handle( ch ).unwrap_or_else( ||
|
||||
crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::clone( &self.font ),
|
||||
bytes: Arc::clone( &self.font_bytes ),
|
||||
face: self.font_face,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn blit( &mut self, src: &SoftwareCanvas, dest_x: i32, dest_y: i32 )
|
||||
{
|
||||
let paint = PixmapPaint::default();
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Text rendering for [`SoftwareCanvas`]. fontdue rasterises each
|
||||
//! glyph once into the persistent [`super::GlyphEntry`] cache; the
|
||||
//! per-frame hot path just lays out positions and blends cached
|
||||
//! bitmaps into the pixmap with the active clip mask + global alpha.
|
||||
//! Text rendering for [`SoftwareCanvas`]. The line is shaped through
|
||||
//! [`crate::text_shaping::shape_line`] (BiDi reordering + per-sub-run
|
||||
//! rustybuzz / HarfBuzz shaping) and each shaped glyph is rasterised
|
||||
//! by glyph index via `fontdue::Font::rasterize_indexed`. The cache
|
||||
//! is keyed on `(glyph_id, size, font_id)` so Arabic connected
|
||||
//! forms, Devanagari clusters, and CJK shaped glyphs cluster
|
||||
//! correctly without colliding with the per-codepoint cache the
|
||||
//! old path used.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,68 +20,8 @@ use super::{ GlyphEntry, GlyphKey, SoftwareCanvas };
|
||||
|
||||
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
|
||||
|
||||
/// Stable identifier for an `Arc<Font>`: the address of the font's
|
||||
/// allocation. Different reweights / families always live in
|
||||
/// distinct allocations, so the address is enough to disambiguate
|
||||
/// glyph cache entries.
|
||||
fn font_id( font: &Arc<Font> ) -> usize
|
||||
{
|
||||
Arc::as_ptr( font ) as usize
|
||||
}
|
||||
|
||||
impl SoftwareCanvas
|
||||
{
|
||||
/// Per-glyph default font lookup. Routes through
|
||||
/// [`Self::font_for_char`], which already consults the lazy
|
||||
/// system-font fallback chain. Returns an owned [`Arc<Font>`] so
|
||||
/// callers can hold the handle across `&mut self` borrows of the
|
||||
/// glyph cache.
|
||||
fn default_font_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
|
||||
{
|
||||
let font = self.font_for_char( ch );
|
||||
let id = Arc::as_ptr( &font ) as usize;
|
||||
( id, font )
|
||||
}
|
||||
|
||||
pub ( super ) fn rasterize_cached( &mut self, ch: char, scaled: f32 ) -> &GlyphEntry
|
||||
{
|
||||
let ( id, font ) = self.default_font_for_char( ch );
|
||||
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
|
||||
self.evict_if_full( &key );
|
||||
if !self.glyph_cache.contains_key( &key )
|
||||
{
|
||||
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
|
||||
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
|
||||
}
|
||||
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
|
||||
}
|
||||
|
||||
/// Pick the font to use for `ch` given a "preferred" override.
|
||||
/// Falls through to the canvas default + Noto chain when the
|
||||
/// preferred font does not own the glyph — so Sora Bold rendering
|
||||
/// of CJK / Devanagari / etc. still works.
|
||||
fn font_for_char_with_pref( &self, ch: char, pref: &Arc<Font> ) -> ( usize, Arc<Font> )
|
||||
{
|
||||
if pref.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
return ( font_id( pref ), Arc::clone( pref ) );
|
||||
}
|
||||
self.default_font_for_char( ch )
|
||||
}
|
||||
|
||||
fn rasterize_cached_with( &mut self, ch: char, scaled: f32, pref: &Arc<Font> ) -> &GlyphEntry
|
||||
{
|
||||
let ( id, font ) = self.font_for_char_with_pref( ch, pref );
|
||||
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
|
||||
self.evict_if_full( &key );
|
||||
if !self.glyph_cache.contains_key( &key )
|
||||
{
|
||||
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
|
||||
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
|
||||
}
|
||||
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
|
||||
}
|
||||
|
||||
fn evict_if_full( &mut self, key: &GlyphKey )
|
||||
{
|
||||
if !self.glyph_cache.contains_key( key )
|
||||
@@ -92,6 +36,22 @@ impl SoftwareCanvas
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise glyph `glyph_id` in `font` at `scaled` px, caching
|
||||
/// the result under `(glyph_id, size_bits, font_id)`. Used by
|
||||
/// both the public `draw_text` and `draw_text_with_font` paths
|
||||
/// after shaping has resolved every codepoint to a glyph index.
|
||||
fn rasterize_indexed_cached( &mut self, font: &Arc<Font>, glyph_id: u16, scaled: f32, font_id: usize ) -> &GlyphEntry
|
||||
{
|
||||
let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id };
|
||||
self.evict_if_full( &key );
|
||||
if !self.glyph_cache.contains_key( &key )
|
||||
{
|
||||
let ( metrics, bitmap ) = font.rasterize_indexed( glyph_id, scaled );
|
||||
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
|
||||
}
|
||||
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
|
||||
}
|
||||
|
||||
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
|
||||
{
|
||||
self.draw_text_inner( text, x, y, size, color, None );
|
||||
@@ -117,28 +77,96 @@ impl SoftwareCanvas
|
||||
return;
|
||||
}
|
||||
|
||||
let mut layout: Vec<( GlyphKey, f32 )> = Vec::with_capacity( text.chars().count() );
|
||||
// Resolve the font handle (font + raw bytes + face index) per
|
||||
// codepoint. When the caller supplied a preferred font (a
|
||||
// theme-resolved bold / italic), we still consult the
|
||||
// fallback chain whenever the preferred face does not own
|
||||
// the requested glyph, so a Sora-Bold label that includes a
|
||||
// CJK character still picks up Noto Sans CJK for that one
|
||||
// codepoint.
|
||||
let canvas_handle =
|
||||
{
|
||||
let mut cursor_x = x;
|
||||
let h = self.font_handle();
|
||||
match font
|
||||
{
|
||||
Some( f ) if Arc::ptr_eq( f, &h.font ) => h,
|
||||
Some( f ) => crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::clone( f ),
|
||||
bytes: Arc::new( Vec::new() ),
|
||||
face: 0,
|
||||
},
|
||||
None => h,
|
||||
}
|
||||
};
|
||||
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
|
||||
{
|
||||
if canvas_handle.font.lookup_glyph_index( ch ) != 0 && !canvas_handle.bytes.is_empty()
|
||||
{
|
||||
return Some( canvas_handle.clone() );
|
||||
}
|
||||
crate::system_fonts::lookup_handle( ch ).or_else( ||
|
||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||
)
|
||||
};
|
||||
|
||||
// `shape_line` returns glyphs in visual order with HarfBuzz
|
||||
// advance widths and offsets. An empty result (rustybuzz
|
||||
// refused the font, missing bytes for the preferred face)
|
||||
// falls through to no-op — we'd rather not paint than risk
|
||||
// a corrupted line.
|
||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
||||
if shaped.is_empty() { return; }
|
||||
|
||||
// Resolve every glyph's font into an `Arc<Font>` for
|
||||
// rasterization — we keep a per-font-id index into a small
|
||||
// vec of `Arc<Font>` so the rasterizer step does not have to
|
||||
// re-walk the resolve fn (mut self borrow conflicts).
|
||||
let mut fonts: Vec<( usize, Arc<Font> )> = Vec::new();
|
||||
let primary_id = Arc::as_ptr( &canvas_handle.font ) as usize;
|
||||
if !canvas_handle.bytes.is_empty()
|
||||
{
|
||||
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
|
||||
}
|
||||
for g in &shaped
|
||||
{
|
||||
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
||||
// Walk the fallback chain to find an Arc<Font> with this
|
||||
// id — this only ever runs once per (font, line) pair
|
||||
// because we cache by id in `fonts`.
|
||||
let mut found = None;
|
||||
// Try every char in the text — the resolve fn is monotone
|
||||
// per char so checking the chars yields every distinct
|
||||
// font that the shaper saw.
|
||||
for ch in text.chars()
|
||||
{
|
||||
let ( id, advance ) = match font
|
||||
if let Some( h ) = crate::system_fonts::lookup_handle( ch )
|
||||
{
|
||||
Some( f ) =>
|
||||
{
|
||||
let id = self.font_for_char_with_pref( ch, f ).0;
|
||||
let advance = self.rasterize_cached_with( ch, scaled, f ).metrics.advance_width;
|
||||
( id, advance )
|
||||
}
|
||||
None =>
|
||||
{
|
||||
let id = self.default_font_for_char( ch ).0;
|
||||
let advance = self.rasterize_cached( ch, scaled ).metrics.advance_width;
|
||||
( id, advance )
|
||||
}
|
||||
let id = Arc::as_ptr( &h.font ) as usize;
|
||||
if id == g.font_id { found = Some( h.font ); break; }
|
||||
}
|
||||
}
|
||||
if let Some( f ) = found
|
||||
{
|
||||
fonts.push( ( g.font_id, f ) );
|
||||
}
|
||||
}
|
||||
|
||||
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
|
||||
{
|
||||
let mut cursor_x = x;
|
||||
for g in &shaped
|
||||
{
|
||||
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
|
||||
{
|
||||
cursor_x += g.x_advance;
|
||||
continue;
|
||||
};
|
||||
layout.push( ( GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }, cursor_x ) );
|
||||
cursor_x += advance;
|
||||
let glyph_id = g.glyph_id as u16;
|
||||
let _ = self.rasterize_indexed_cached( font_arc, glyph_id, scaled, g.font_id );
|
||||
let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id: g.font_id };
|
||||
layout.push( ( key, cursor_x + g.x_offset, g.y_offset ) );
|
||||
cursor_x += g.x_advance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,16 +181,17 @@ impl SoftwareCanvas
|
||||
let cache = &self.glyph_cache;
|
||||
let mask_data = self.clip_mask.as_ref().map( |m| ( m.data(), m.width() as i32 ) );
|
||||
|
||||
for ( key, cursor_x ) in layout
|
||||
for ( key, cursor_x, glyph_y_offset ) in layout
|
||||
{
|
||||
let entry = cache.get( &key ).expect( "warmed above" );
|
||||
let metrics = &entry.metrics;
|
||||
let bitmap = &entry.bitmap;
|
||||
if metrics.width == 0 || metrics.height == 0 { continue; }
|
||||
for ( i, &alpha ) in bitmap.iter().enumerate()
|
||||
{
|
||||
if alpha == 0 { continue; }
|
||||
let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32;
|
||||
let py = y as i32
|
||||
let py = ( y - glyph_y_offset ) as i32
|
||||
- metrics.ymin as i32
|
||||
- metrics.height as i32
|
||||
+ 1
|
||||
@@ -186,18 +215,65 @@ impl SoftwareCanvas
|
||||
|
||||
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
|
||||
{
|
||||
text.chars().map( |ch|
|
||||
{
|
||||
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
|
||||
} ).sum()
|
||||
self.measure_with_font( text, size, None )
|
||||
}
|
||||
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
|
||||
{
|
||||
text.chars().map( |ch|
|
||||
self.measure_with_font( text, size, Some( font ) )
|
||||
}
|
||||
|
||||
fn measure_with_font( &self, text: &str, size: f32, font: Option<&Arc<Font>> ) -> f32
|
||||
{
|
||||
let scaled = size * self.dpi_scale;
|
||||
let canvas_handle =
|
||||
{
|
||||
let ( _, picked ) = self.font_for_char_with_pref( ch, font );
|
||||
picked.metrics( ch, size * self.dpi_scale ).advance_width
|
||||
} ).sum()
|
||||
let h = self.font_handle();
|
||||
match font
|
||||
{
|
||||
Some( f ) if Arc::ptr_eq( f, &h.font ) => h,
|
||||
Some( f ) => crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::clone( f ),
|
||||
bytes: Arc::new( Vec::new() ),
|
||||
face: 0,
|
||||
},
|
||||
None => h,
|
||||
}
|
||||
};
|
||||
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
|
||||
{
|
||||
if canvas_handle.font.lookup_glyph_index( ch ) != 0 && !canvas_handle.bytes.is_empty()
|
||||
{
|
||||
return Some( canvas_handle.clone() );
|
||||
}
|
||||
crate::system_fonts::lookup_handle( ch ).or_else( ||
|
||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||
)
|
||||
};
|
||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
||||
if shaped.is_empty()
|
||||
{
|
||||
// Fallback: rustybuzz could not shape (no bytes for the
|
||||
// preferred font, no fallback covers the codepoints).
|
||||
// Sum per-codepoint advances so layout still makes a
|
||||
// vaguely useful decision.
|
||||
return text.chars().map( |ch|
|
||||
{
|
||||
let f = font.map( Arc::clone ).unwrap_or_else( || self.font_for_char( ch ) );
|
||||
f.metrics( ch, scaled ).advance_width
|
||||
} ).sum();
|
||||
}
|
||||
shaped.iter().map( |g| g.x_advance ).sum()
|
||||
}
|
||||
|
||||
fn font_handle( &self ) -> crate::system_fonts::FontHandle
|
||||
{
|
||||
crate::system_fonts::FontHandle
|
||||
{
|
||||
font: Arc::clone( &self.font ),
|
||||
bytes: Arc::clone( &self.font_bytes ),
|
||||
face: self.font_face,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,22 @@ use std::sync::{ Arc, OnceLock };
|
||||
|
||||
use fontdue::{ Font, FontSettings };
|
||||
|
||||
/// Bytes-aware font handle. The `font` is what fontdue rasterises
|
||||
/// against; `bytes` is the raw OpenType / TrueType buffer that
|
||||
/// rustybuzz (HarfBuzz) requires for shaping. They are owned
|
||||
/// separately because fontdue does not expose its internal byte
|
||||
/// buffer — we keep both alive in lockstep instead.
|
||||
///
|
||||
/// `face` is the TTC sub-face index (0 for single-face files, the
|
||||
/// face index for collections like Noto Sans CJK).
|
||||
#[ derive( Clone ) ]
|
||||
pub struct FontHandle
|
||||
{
|
||||
pub font: Arc<Font>,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub face: u32,
|
||||
}
|
||||
|
||||
/// Per-script fallback font path with the TrueType-collection face
|
||||
/// index fontdue should load (most files are single-face → 0; CJK
|
||||
/// `.ttc` archives carry many faces, see the SC face on the canonical
|
||||
@@ -78,9 +94,9 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
||||
/// doesn't drag the CJK pack into memory. `None` inside a resolved
|
||||
/// slot means the file was missing or fontdue rejected it — a sticky
|
||||
/// negative result so subsequent misses skip the slot in O(1).
|
||||
fn slots() -> &'static [ OnceLock<Option<Arc<Font>>> ]
|
||||
fn slots() -> &'static [ OnceLock<Option<FontHandle>> ]
|
||||
{
|
||||
static SLOTS: OnceLock<Vec<OnceLock<Option<Arc<Font>>>>> = OnceLock::new();
|
||||
static SLOTS: OnceLock<Vec<OnceLock<Option<FontHandle>>>> = OnceLock::new();
|
||||
SLOTS.get_or_init( ||
|
||||
{
|
||||
( 0..FALLBACK_FONT_CANDIDATES.len() )
|
||||
@@ -90,9 +106,11 @@ fn slots() -> &'static [ OnceLock<Option<Arc<Font>>> ]
|
||||
}
|
||||
|
||||
/// Try to load and parse the fallback font at slot `idx`. Each
|
||||
/// `OnceLock` wraps `Option<Arc<Font>>` so a missing or malformed
|
||||
/// file is recorded as `None` and never re-attempted.
|
||||
fn slot_font( idx: usize ) -> Option<Arc<Font>>
|
||||
/// `OnceLock` wraps `Option<FontHandle>` so a missing or malformed
|
||||
/// file is recorded as `None` and never re-attempted. The raw bytes
|
||||
/// are preserved inside the handle so the same `Arc<Vec<u8>>` can be
|
||||
/// handed to rustybuzz for shaping without re-reading the file.
|
||||
fn slot_handle( idx: usize ) -> Option<FontHandle>
|
||||
{
|
||||
let slot = &slots()[ idx ];
|
||||
slot.get_or_init( ||
|
||||
@@ -100,10 +118,15 @@ fn slot_font( idx: usize ) -> Option<Arc<Font>>
|
||||
let spec = &FALLBACK_FONT_CANDIDATES[ idx ];
|
||||
let bytes = std::fs::read( spec.path ).ok()?;
|
||||
let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() };
|
||||
Font::from_bytes( bytes.as_slice(), opts ).ok().map( Arc::new )
|
||||
let font = Font::from_bytes( bytes.as_slice(), opts ).ok()?;
|
||||
Some( FontHandle
|
||||
{
|
||||
font: Arc::new( font ),
|
||||
bytes: Arc::new( bytes ),
|
||||
face: spec.face,
|
||||
} )
|
||||
} )
|
||||
.as_ref()
|
||||
.map( Arc::clone )
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Find the first fallback font that has a non-zero glyph index for
|
||||
@@ -120,13 +143,22 @@ fn slot_font( idx: usize ) -> Option<Arc<Font>>
|
||||
/// scripts (Devanagari, Arabic, …) whose total weight is a fraction
|
||||
/// of the CJK pack everyone was paying for unconditionally.
|
||||
pub fn lookup( ch: char ) -> Option<Arc<Font>>
|
||||
{
|
||||
lookup_handle( ch ).map( |h| h.font )
|
||||
}
|
||||
|
||||
/// Bytes-aware variant of [`lookup`]. Returns the full
|
||||
/// [`FontHandle`] (fontdue handle + raw bytes + face index) so
|
||||
/// callers that need to invoke a HarfBuzz-style shaper can do so
|
||||
/// without re-reading the font file.
|
||||
pub fn lookup_handle( ch: char ) -> Option<FontHandle>
|
||||
{
|
||||
for idx in 0..FALLBACK_FONT_CANDIDATES.len()
|
||||
{
|
||||
let Some( font ) = slot_font( idx ) else { continue };
|
||||
if font.lookup_glyph_index( ch ) != 0
|
||||
let Some( handle ) = slot_handle( idx ) else { continue };
|
||||
if handle.font.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
return Some( font );
|
||||
return Some( handle );
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
171
src/text_shaping.rs
Normal file
171
src/text_shaping.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Unicode text processing: BiDi visual reordering folded into
|
||||
//! HarfBuzz shaping.
|
||||
//!
|
||||
//! [`shape_line`] is the main entry point. It takes a logical-order
|
||||
//! string, a font resolver ("for this codepoint, which
|
||||
//! `FontHandle` should we use?") and the pixel size, runs the
|
||||
//! Unicode Bidirectional Algorithm over the input, splits each
|
||||
//! BiDi run into sub-runs that share a font, calls [`shape_run`]
|
||||
//! per sub-run, and returns a [`PositionedGlyph`] sequence in
|
||||
//! visual order. Renderers consume that sequence directly: each
|
||||
//! `PositionedGlyph` carries the per-font `glyph_id`, the visual
|
||||
//! advance, and ink offsets, which is exactly what
|
||||
//! `fontdue::Font::rasterize_indexed` needs to render Arabic
|
||||
//! connected forms, Devanagari clusters and CJK shaped glyphs
|
||||
//! correctly.
|
||||
//!
|
||||
//! [`shape_run`] is the lower-level entry point — useful in tests
|
||||
//! and when a caller has already done its own bidi / sub-run
|
||||
//! splitting.
|
||||
|
||||
/// Single shaped glyph returned by [`shape_run`]. Horizontal-only —
|
||||
/// vertical-script metrics (`y_advance`) and the source-string
|
||||
/// `cluster` index that rustybuzz also returns are dropped because
|
||||
/// no caller in the crate consumes them; add them back when a
|
||||
/// caller actually needs vertical layout or text-edit
|
||||
/// cluster-aware caret navigation.
|
||||
pub struct ShapedGlyph
|
||||
{
|
||||
/// Glyph index in the font (not a Unicode codepoint).
|
||||
pub glyph_id: u32,
|
||||
/// Horizontal advance in 26.6 fixed-point font units, already scaled
|
||||
/// to the requested pixel size.
|
||||
pub x_advance: f32,
|
||||
/// Horizontal glyph offset from the pen position.
|
||||
pub x_offset: f32,
|
||||
/// Vertical glyph offset from the baseline.
|
||||
pub y_offset: f32,
|
||||
}
|
||||
|
||||
/// Per-glyph entry returned by [`shape_line`]. Carries the font that
|
||||
/// owns the glyph (so the rasterizer caches per font, not just per
|
||||
/// glyph index) and the visual position relative to the start of the
|
||||
/// line.
|
||||
pub struct PositionedGlyph
|
||||
{
|
||||
pub glyph_id: u32,
|
||||
pub font_id: usize,
|
||||
pub x_advance: f32,
|
||||
pub x_offset: f32,
|
||||
pub y_offset: f32,
|
||||
}
|
||||
|
||||
/// Shape `text` for visual rendering. Runs the Unicode Bidirectional
|
||||
/// Algorithm over the input, then asks `resolve_font(ch)` for the
|
||||
/// font handle that owns each codepoint, groups consecutive
|
||||
/// codepoints sharing a font / direction into sub-runs, and
|
||||
/// calls [`shape_run`] per sub-run. Output is in visual order:
|
||||
/// concatenating `x_advance` of each glyph yields the rendered line
|
||||
/// width.
|
||||
///
|
||||
/// `resolve_font` is the per-character fallback resolver (typically
|
||||
/// "primary font if it has the glyph, otherwise walk the system
|
||||
/// fallback chain"). Returning `None` lets the caller skip the
|
||||
/// codepoint entirely.
|
||||
pub fn shape_line<F>( text: &str, px: f32, mut resolve_font: F ) -> Vec<PositionedGlyph>
|
||||
where
|
||||
F: FnMut( char ) -> Option<crate::system_fonts::FontHandle>,
|
||||
{
|
||||
if text.is_empty() { return vec![]; }
|
||||
let bidi = unicode_bidi::BidiInfo::new( text, None );
|
||||
if bidi.paragraphs.is_empty() { return vec![]; }
|
||||
let para = &bidi.paragraphs[0];
|
||||
let ( levels, runs ) = bidi.visual_runs( para, para.range.clone() );
|
||||
|
||||
let mut out: Vec<PositionedGlyph> = Vec::with_capacity( text.chars().count() );
|
||||
for run in runs
|
||||
{
|
||||
let is_rtl = levels.get( run.start ).map( |l| l.is_rtl() ).unwrap_or( false );
|
||||
let run_text = &text[ run ];
|
||||
// Split the BiDi run into sub-runs that share a single font.
|
||||
// `resolve_font` is asked once per codepoint; consecutive
|
||||
// codepoints whose handle has the same `font` pointer are
|
||||
// shaped together.
|
||||
let mut current_handle: Option<crate::system_fonts::FontHandle> = None;
|
||||
let mut sub_run = String::new();
|
||||
let flush = | handle_opt: &Option<crate::system_fonts::FontHandle>, sub_run: &mut String, out: &mut Vec<PositionedGlyph> |
|
||||
{
|
||||
if sub_run.is_empty() { return; }
|
||||
let Some( h ) = handle_opt.as_ref() else { sub_run.clear(); return };
|
||||
let glyphs = shape_run( sub_run, &h.bytes, h.face, px, is_rtl );
|
||||
let font_id = std::sync::Arc::as_ptr( &h.font ) as usize;
|
||||
for g in glyphs
|
||||
{
|
||||
out.push( PositionedGlyph
|
||||
{
|
||||
glyph_id: g.glyph_id,
|
||||
font_id,
|
||||
x_advance: g.x_advance,
|
||||
x_offset: g.x_offset,
|
||||
y_offset: g.y_offset,
|
||||
} );
|
||||
}
|
||||
sub_run.clear();
|
||||
};
|
||||
for ch in run_text.chars()
|
||||
{
|
||||
let handle = resolve_font( ch );
|
||||
let same = match ( ¤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<ShapedGlyph>
|
||||
{
|
||||
use rustybuzz::{ UnicodeBuffer, Direction };
|
||||
|
||||
if text.is_empty() { return vec![]; }
|
||||
let Some( face ) = rustybuzz::Face::from_slice( font_bytes, face_index ) else
|
||||
{
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let units_per_em = face.units_per_em() as f32;
|
||||
if units_per_em <= 0.0 { return vec![]; }
|
||||
let scale = px / units_per_em;
|
||||
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.push_str( text );
|
||||
buf.set_direction( if rtl { Direction::RightToLeft } else { Direction::LeftToRight } );
|
||||
|
||||
let output = rustybuzz::shape( &face, &[], buf );
|
||||
let infos = output.glyph_infos();
|
||||
let pos = output.glyph_positions();
|
||||
|
||||
infos.iter().zip( pos.iter() ).map( |( info, p )|
|
||||
{
|
||||
ShapedGlyph
|
||||
{
|
||||
glyph_id: info.glyph_id,
|
||||
x_advance: p.x_advance as f32 * scale,
|
||||
x_offset: p.x_offset as f32 * scale,
|
||||
y_offset: p.y_offset as f32 * scale,
|
||||
}
|
||||
} ).collect()
|
||||
}
|
||||
@@ -92,6 +92,11 @@ pub struct Container<Msg: Clone>
|
||||
/// Mirrors the same flag on [`Column`](crate::layout::column::Column)
|
||||
/// and [`Row`](crate::layout::row::Row).
|
||||
pub max_width: Option<f32>,
|
||||
/// When true, the contents of this container are announced by
|
||||
/// assistive technologies as a `Live::Polite` region — useful for
|
||||
/// toasts, status banners and OSDs that need to be read on
|
||||
/// appearance even when the user has not navigated to them.
|
||||
pub a11y_live: bool,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Container<Msg>
|
||||
@@ -111,9 +116,16 @@ impl<Msg: Clone> Container<Msg>
|
||||
opacity: 1.0,
|
||||
border: None,
|
||||
max_width: None,
|
||||
a11y_live: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn live_region( mut self, live: bool ) -> Self
|
||||
{
|
||||
self.a11y_live = live;
|
||||
self
|
||||
}
|
||||
|
||||
/// Paint a rounded-rect stroke around the container with the given
|
||||
/// colour and pixel width. Useful for input fields, popovers and
|
||||
/// any chrome the design system specifies as outlined rather than
|
||||
@@ -285,6 +297,7 @@ impl<Msg: Clone> Container<Msg>
|
||||
opacity: self.opacity,
|
||||
border: self.border,
|
||||
max_width: self.max_width,
|
||||
a11y_live: self.a11y_live,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,25 @@ impl<Msg: Clone> Element<Msg>
|
||||
///
|
||||
/// Containers and layouts that delegate to children return
|
||||
/// [`WidgetHandlers::None`] — only leaf widgets actually carry payload.
|
||||
pub( crate ) fn accessible_label( &self ) -> Option<String>
|
||||
{
|
||||
match self
|
||||
{
|
||||
Element::Button( b ) => match &b.content
|
||||
{
|
||||
super::button::ButtonContent::Text( s ) => Some( s.clone() ),
|
||||
super::button::ButtonContent::Icon { .. } => b.tooltip.clone(),
|
||||
},
|
||||
Element::Toggle( t ) => t.label.clone(),
|
||||
Element::Checkbox( c ) => c.label.clone(),
|
||||
Element::Radio( r ) => r.label.clone(),
|
||||
Element::ListItem( l ) => Some( l.label.clone() ).filter( |s| !s.is_empty() ),
|
||||
Element::WindowButton( _ ) => None,
|
||||
Element::Text( t ) => Some( t.content.clone() ).filter( |s| !s.is_empty() ),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub( crate ) fn handlers( &self ) -> WidgetHandlers<Msg>
|
||||
{
|
||||
match self
|
||||
@@ -271,9 +290,9 @@ impl<Msg: Clone> Element<Msg>
|
||||
on_escape: p.on_escape.clone(),
|
||||
repeating: false,
|
||||
},
|
||||
Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone() },
|
||||
Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone() },
|
||||
Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone() },
|
||||
Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone(), value: t.value },
|
||||
Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone(), value: c.checked },
|
||||
Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone(), selected: r.selected },
|
||||
Element::ListItem( l ) => WidgetHandlers::ListItem { on_press: l.on_press.clone() },
|
||||
Element::WindowButton( b ) => WidgetHandlers::WindowButton { on_press: b.on_press.clone() },
|
||||
Element::TextEdit( t ) =>
|
||||
@@ -301,6 +320,7 @@ impl<Msg: Clone> Element<Msg>
|
||||
{
|
||||
on_change: s.on_change.clone(),
|
||||
axis: slider::SliderAxis::Horizontal,
|
||||
value: s.value,
|
||||
}
|
||||
}
|
||||
Element::VSlider( s ) =>
|
||||
@@ -309,6 +329,7 @@ impl<Msg: Clone> Element<Msg>
|
||||
{
|
||||
on_change: s.on_change.clone(),
|
||||
axis: slider::SliderAxis::Vertical,
|
||||
value: s.value,
|
||||
}
|
||||
}
|
||||
_ => WidgetHandlers::None,
|
||||
|
||||
@@ -30,9 +30,9 @@ pub enum WidgetHandlers<Msg: Clone>
|
||||
on_escape: Option<Msg>,
|
||||
repeating: bool,
|
||||
},
|
||||
Toggle { on_toggle: Option<Msg> },
|
||||
Checkbox { on_toggle: Option<Msg> },
|
||||
Radio { on_select: Option<Msg> },
|
||||
Toggle { on_toggle: Option<Msg>, value: bool },
|
||||
Checkbox { on_toggle: Option<Msg>, value: bool },
|
||||
Radio { on_select: Option<Msg>, selected: bool },
|
||||
ListItem { on_press: Option<Msg> },
|
||||
WindowButton { on_press: Option<Msg> },
|
||||
TextEdit
|
||||
@@ -77,6 +77,7 @@ pub enum WidgetHandlers<Msg: Clone>
|
||||
{
|
||||
on_change: Option<Arc<dyn Fn( f32 ) -> Msg>>,
|
||||
axis: slider::SliderAxis,
|
||||
value: f32,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -117,9 +118,9 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
|
||||
on_escape: on_escape.clone(),
|
||||
repeating: *repeating,
|
||||
},
|
||||
WidgetHandlers::Toggle { on_toggle } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone() },
|
||||
WidgetHandlers::Checkbox { on_toggle } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone() },
|
||||
WidgetHandlers::Radio { on_select } => WidgetHandlers::Radio { on_select: on_select.clone() },
|
||||
WidgetHandlers::Toggle { on_toggle, value } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone(), value: *value },
|
||||
WidgetHandlers::Checkbox { on_toggle, value } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone(), value: *value },
|
||||
WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected },
|
||||
WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() },
|
||||
WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() },
|
||||
WidgetHandlers::TextEdit { value, on_change, on_submit, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } =>
|
||||
@@ -137,9 +138,9 @@ impl<Msg: Clone> Clone for WidgetHandlers<Msg>
|
||||
password_toggle_msg: password_toggle_msg.clone(),
|
||||
}
|
||||
}
|
||||
WidgetHandlers::Slider { on_change, axis } =>
|
||||
WidgetHandlers::Slider { on_change, axis, value } =>
|
||||
{
|
||||
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis }
|
||||
WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +153,23 @@ impl<Msg: Clone> WidgetHandlers<Msg>
|
||||
matches!( self, WidgetHandlers::TextEdit { .. } )
|
||||
}
|
||||
|
||||
pub fn is_disabled( &self ) -> bool
|
||||
{
|
||||
match self
|
||||
{
|
||||
WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape, .. } =>
|
||||
on_press.is_none() && on_long_press.is_none() && on_drag_start.is_none() && on_escape.is_none(),
|
||||
WidgetHandlers::Toggle { on_toggle, .. } => on_toggle.is_none(),
|
||||
WidgetHandlers::Checkbox { on_toggle, .. } => on_toggle.is_none(),
|
||||
WidgetHandlers::Radio { on_select, .. } => on_select.is_none(),
|
||||
WidgetHandlers::ListItem { on_press } => on_press.is_none(),
|
||||
WidgetHandlers::WindowButton { on_press } => on_press.is_none(),
|
||||
WidgetHandlers::Slider { on_change, .. } => on_change.is_none(),
|
||||
WidgetHandlers::TextEdit { on_change, on_submit, .. } => on_change.is_none() && on_submit.is_none(),
|
||||
WidgetHandlers::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when this is a [`WidgetHandlers::TextEdit`] whose source
|
||||
/// widget was built with `.multiline( true )`. The keyboard
|
||||
/// dispatch reads this so pressing Enter inserts a `\n` instead of
|
||||
@@ -185,9 +203,9 @@ impl<Msg: Clone> WidgetHandlers<Msg>
|
||||
match self
|
||||
{
|
||||
WidgetHandlers::Button { on_press, .. } => on_press.clone(),
|
||||
WidgetHandlers::Toggle { on_toggle } => on_toggle.clone(),
|
||||
WidgetHandlers::Checkbox { on_toggle } => on_toggle.clone(),
|
||||
WidgetHandlers::Radio { on_select } => on_select.clone(),
|
||||
WidgetHandlers::Toggle { on_toggle, .. } => on_toggle.clone(),
|
||||
WidgetHandlers::Checkbox { on_toggle, .. } => on_toggle.clone(),
|
||||
WidgetHandlers::Radio { on_select, .. } => on_select.clone(),
|
||||
WidgetHandlers::ListItem { on_press } => on_press.clone(),
|
||||
WidgetHandlers::WindowButton { on_press } => on_press.clone(),
|
||||
_ => None,
|
||||
|
||||
@@ -34,6 +34,10 @@ pub struct LaidOutWidget<Msg: Clone>
|
||||
/// element tree.
|
||||
pub cursor: crate::types::CursorShape,
|
||||
pub tooltip: Option<String>,
|
||||
/// Visible text of the widget, captured for the accessibility
|
||||
/// tree. `None` for non-textual widgets (icons, dividers).
|
||||
pub accessible_label: Option<String>,
|
||||
pub is_live_region: bool,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Clone for LaidOutWidget<Msg>
|
||||
@@ -50,6 +54,8 @@ impl<Msg: Clone> Clone for LaidOutWidget<Msg>
|
||||
keyboard_focusable: self.keyboard_focusable,
|
||||
cursor: self.cursor,
|
||||
tooltip: self.tooltip.clone(),
|
||||
accessible_label: self.accessible_label.clone(),
|
||||
is_live_region: self.is_live_region,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,36 @@ use crate::widget::Element;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// A vertically scrollable viewport that clips its child to its allocated rect.
|
||||
/// Which axes a [`Scroll`] viewport allows to move along. Determines
|
||||
/// whether the layout grows the child past the viewport width, the
|
||||
/// viewport height, or both, and which axis gesture / wheel deltas
|
||||
/// route to.
|
||||
///
|
||||
/// `Vertical` is the default (matches the historic behaviour) so
|
||||
/// existing call sites do not need a builder call to migrate.
|
||||
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
|
||||
pub enum ScrollAxis
|
||||
{
|
||||
/// Standard list-style scroll: child grows vertically, child width
|
||||
/// is clamped to the viewport.
|
||||
Vertical,
|
||||
/// Strip / timeline scroll: child grows horizontally, child height
|
||||
/// is clamped to the viewport.
|
||||
Horizontal,
|
||||
/// Two-axis pan (large tables, code views, maps). Child may exceed
|
||||
/// the viewport on either axis.
|
||||
Both,
|
||||
}
|
||||
|
||||
impl ScrollAxis
|
||||
{
|
||||
#[ inline ]
|
||||
pub fn allows_x( self ) -> bool { matches!( self, ScrollAxis::Horizontal | ScrollAxis::Both ) }
|
||||
#[ inline ]
|
||||
pub fn allows_y( self ) -> bool { matches!( self, ScrollAxis::Vertical | ScrollAxis::Both ) }
|
||||
}
|
||||
|
||||
/// A scrollable viewport that clips its child to its allocated rect.
|
||||
///
|
||||
/// The child can be any element — typically a [`Column`](crate::layout::column::Column)
|
||||
/// for lists or a [`WrapGrid`](crate::layout::wrap_grid::WrapGrid) for icon grids.
|
||||
@@ -35,6 +64,8 @@ pub struct Scroll<Msg: Clone>
|
||||
pub child: Box<Element<Msg>>,
|
||||
/// Optional stable identifier — used as scroll state key.
|
||||
pub id: Option<WidgetId>,
|
||||
/// Which axis (or both) this viewport scrolls along.
|
||||
pub axis: ScrollAxis,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Scroll<Msg>
|
||||
@@ -46,9 +77,25 @@ impl<Msg: Clone> Scroll<Msg>
|
||||
self
|
||||
}
|
||||
|
||||
/// Switch this viewport to horizontal scrolling. The child is laid
|
||||
/// out at its preferred width (potentially larger than the
|
||||
/// viewport) and clipped on the X axis.
|
||||
pub fn horizontal( mut self ) -> Self
|
||||
{
|
||||
self.axis = ScrollAxis::Horizontal;
|
||||
self
|
||||
}
|
||||
|
||||
/// Allow both axes. Use for tables / code views / maps.
|
||||
pub fn both( mut self ) -> Self
|
||||
{
|
||||
self.axis = ScrollAxis::Both;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in
|
||||
/// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer).
|
||||
/// The actual viewport height is determined at render time from the rect the
|
||||
/// The actual viewport size is determined at render time from the rect the
|
||||
/// parent assigns.
|
||||
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
|
||||
{
|
||||
@@ -67,6 +114,7 @@ impl<Msg: Clone> Scroll<Msg>
|
||||
{
|
||||
child: Box::new( self.child.map_arc( f ) ),
|
||||
id: self.id,
|
||||
axis: self.axis,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,19 +127,24 @@ impl<Msg: Clone + 'static> From<Scroll<Msg>> for Element<Msg>
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the clamped scroll offset given raw offset, content height, and viewport height.
|
||||
/// Clamp a raw 1-D scroll offset to `[0, max(content - viewport, 0)]`.
|
||||
///
|
||||
/// Extracted as a pure function so it can be unit-tested without a Wayland surface.
|
||||
/// Extracted as a pure function so it can be unit-tested without a
|
||||
/// Wayland surface. Used for both axes by passing in the relevant
|
||||
/// content / viewport dimensions.
|
||||
pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f32
|
||||
{
|
||||
let max = (content_h - viewport_h).max( 0.0 );
|
||||
offset.clamp( 0.0, max )
|
||||
}
|
||||
|
||||
/// Create a scrollable viewport wrapping `child`.
|
||||
/// Create a scrollable viewport wrapping `child`. Defaults to vertical
|
||||
/// scrolling; chain [`Scroll::horizontal`] or [`Scroll::both`] to
|
||||
/// switch axes.
|
||||
///
|
||||
/// The parent layout controls the viewport size by assigning a rect to this widget.
|
||||
/// Content that overflows vertically is scrolled via drag gesture.
|
||||
/// The parent layout controls the viewport size by assigning a rect to
|
||||
/// this widget. Content that overflows along the allowed axis is
|
||||
/// scrolled via drag gestures or the scroll wheel.
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use ltk::{ column, scroll, text, Element };
|
||||
@@ -103,5 +156,5 @@ pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f3
|
||||
/// ```
|
||||
pub fn scroll<Msg: Clone>( child: impl Into<Element<Msg>> ) -> Scroll<Msg>
|
||||
{
|
||||
Scroll { child: Box::new( child.into() ), id: None }
|
||||
Scroll { child: Box::new( child.into() ), id: None, axis: ScrollAxis::Vertical }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user