Commit Graph
6 Commits
Author SHA1 Message Date
admin 85aa85a357 The GLES backend keeps one glyph atlas per GL context, split into pages, so a surface that is created again does not rasterise and upload its text again
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Every GLES canvas and sub-canvas had a glyph atlas of its own, a 2048×2048 texture created empty with the canvas, so every surface that was torn down and built again, and every scroll viewport inside it, rasterised and uploaded all of its glyphs on its first frame. crustace's app launcher pays this on every opening: the sliding subsurface and its scroll sub-canvas are destroyed when the launcher closes, and the interactive overlay that replaces the subsurface at the top has scroll sub-canvases of its own. With `LTK_PERF_RASTER=1` on a PinePhone Pro (RK3399, Mali-T860, panfrost) the first raster of the subsurface took 175 to 330 ms of CPU and the first full raster of the overlay 175 to 260 ms, on every opening, with the GPU done 3 ms later; the user saw a delay of two or three tenths of a second before the launcher followed the finger and a stall of about one more when it reached the top. On a Librem 5 (i.MX8MQ, GC7000L, etnaviv) the same rasters took 18 to 40 ms.
Panfrost makes each glyph upload expensive. The glyphs of a line are written into the atlas with `glTexSubImage2D` after earlier lines were drawn from it, and when a pending batch still samples a texture that is written to, Mesa 25.0 allocates a new buffer for the texture and copies the whole old one into it with the CPU (`panfrost_ptr_map` in `pan_resource.c`), 4 MiB for the old atlas, read from write-combined memory. Etnaviv flushes and waits for the GPU instead (`etna_transfer_map`), whose cost does not depend on the size of the texture.
The new `gles_render/glyph_atlas.rs` holds one `GlyphAtlas` per GL context, found through `shared_glyph_atlas` as the image cache is through `shared_image_cache`, and every canvas and sub-canvas keeps an `Rc` to it, so glyphs rasterised by one surface are resident for the next. The context is held as a `Weak`, so the atlas of a context that was dropped is forgotten instead of being found again by a new context allocated at the same address. The atlas is made of 512×512 pages, created as glyphs need them, up to 8 MiB in all; a glyph larger than a page gets a page of its own, up to the 2048 px of the old atlas, and those pages are freed when the atlas is reset, the regular ones kept and rewound. A write into a page that a pending draw samples then costs panfrost a copy of 256 KiB, not of 4 MiB. The glyph cache moves into the atlas with `GlyphEntry`, which records the page of each glyph, and its key; the cap of 8192 glyphs before a reset is now for the context as a whole, not for each canvas. `draw_text` uploads through the atlas and groups the quads of a line by page, one draw call per page instead of one per line, which only differs when the glyphs of a line are split across pages. `GlesCanvas::new` and `sub_canvas` no longer create an atlas texture each, and dropping a canvas no longer deletes one. `docs/architecture.md` says that glyphs are cached per GL context too and how the atlas is paged.
Measured with `LTK_PERF_RASTER=1`, opening and closing the launcher a dozen times. On the PinePhone Pro the subsurface raster of the first opening went from 330 to 95 ms and of the later ones from 175–215 ms to 2–10 ms, with peaks of 17 and 35 ms, and the first raster of the overlay from 175–260 ms to under 1.5 ms; the other frames stay at 2 to 13 ms. On the Librem 5 the subsurface raster went from 22.7 ms on average, 85.6 ms on the first opening and over 15 ms in 9 of 14 openings, to 13.2 ms, 69.7 ms and 3 of 14, and the other frames from 7.7 ms on average and 43 ms at most to 6.8 ms and 28.5 ms.
Not done. The first raster of a session still uploads its glyphs interleaved with the draws that sample them; uploading all of them before the first draw would need a second pass over the view on every frame. There are no tests for the atlas. The image cache still identifies its context by address only.
2026-10-15 22:34:03 +02:00
admin b591950f67 Glyphs on demand: FontFace replaces fontdue, DejaVu ahead of the CJK collection in the fallback chain; App::take_clipboard_request and App::on_pointer_secondary
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Every face fontdue opened was parsed whole: `Font::from_bytes` walks the `cmap` and builds the outline of every glyph before returning. For the theme's Sora that is a few hundred glyphs and nobody notices. For a fallback it is the whole file: the first string with one arrow or one bullet resolved through the chain to `NotoSansCJK-Regular.ttc`, and a compositor or a notification daemon that had drawn a dozen labels was suddenly holding 330 MB of outlines for 44 810 glyphs, after 1.7 s of CPU, to paint the hundred or so glyphs a screen ever shows. Measured on forge: 240 MB before the first app-switcher label, 542 MB after it, and never given back, because the table is live, not garbage.
`FontFace`. The new `src/font_face.rs` is the face type the whole crate now rasterises with, built on the `ttf-parser` that rustybuzz already brings in and on tiny-skia, which already fills paths. `from_bytes( data, index )` keeps the bytes and reads only what a face needs up front: units per em and the vertical line metrics. A `ttf_parser::Face` is rebuilt on every use rather than kept, since parsing one is table-offset arithmetic, in the noise next to the outline work it guards. Each glyph's outline is extracted the first time it is asked for, through `Outliner`, into a `tiny_skia::Path`, and cached for the process lifetime in a `HashMap<u16, Arc<Outline>>`; codepoint lookups are cached the same way. Rasterising fills that path into a `Mask` and hands back the 8-bit coverage, top row first, which is what both canvases stored before. The metrics keep fontdue's conventions, so no widget changed: sizes scale by `px / units_per_em`, `Metrics::xmin` / `ymin` are whole-pixel placements with the fractional remainder folded into the raster offset, `bounds` is the outline box in subpixels, `LineMetrics` carries ascent, descent, line gap and their sum. One thing is deliberately not what `glyf` declares: the box comes from the points the outline actually reaches, quadratic and cubic extrema included, because declared boxes may be loose and a box wider than the ink shifts the glyph on the pixel grid. Tests cover the turning-point arithmetic and the line-metric scaling. `FontFace`, `Metrics`, `LineMetrics` and `OutlineBounds` are exported, since `Canvas::font` and `font_for` hand them out.
What changed around it. `fontdue` leaves `Cargo.toml`. The two canvases, their text and setup modules, the registry, the rich text and text widgets swap `Arc<Font>` for `Arc<FontFace>` and nothing else; the glyph caches still key on `(glyph_id, size_bits, font_id)`. `FontRegistry` now reads a source into an `Arc<Vec<u8>>` and gives the face the bytes it will pull outlines from, so the registry no longer owns a second copy; its parse error names the file rather than a library message. `FontHandle` keeps `font` and `bytes` side by side, now naming the same buffer, so shaping needs no second read and the comment that explained the duplication is gone. `system_fonts` builds fallback faces from the bytes each slot already holds, and the slot stays two-staged: the bytes are read once to probe coverage with `ttf-parser`, the face is built only when a glyph in that slot is needed, and a face that is built no longer costs anything until it draws.
The chain. DejaVu Sans moves from last resort to just before the CJK collection. It owns the geometric shapes, arrows and box-drawing blocks, which are what a user interface most often needs outside its own family; behind the collection, every one of those pulled the 19 MB file in, since probing a slot for coverage reads it whole. The CJK entries stay last for the same reason.
The two hooks are for a host that embeds another UI, trust in this case, and complete the ones `App` already had. `take_clipboard_request` is the counterpart of nothing: an app could paste from the system clipboard through its text fields but had no way to put text into it. Return `Some( text )` once and the run loop stores it and publishes it as the seat's selection through the data device, the same path `handle_copy` takes for Ctrl+C in a text field, so other applications see it. `on_pointer_secondary` reports a right-button press that no widget of ltk's own answered, in physical pixels like `on_pointer_button`: `on_pointer_right_click` already dispatched a widget's `on_long_press` and the built-in text menu, and in the remaining case only dismissed an open menu; it now also tells the app, which can forward the press to an embedded view that has a context menu of its own.
Not changed. Variable fonts are read at their default instance, as before. Colour glyphs, `COLR` or bitmap, are not rendered by either rasteriser; the outline path draws what it can. The glyph caches are still unbounded per face, but they now hold what was drawn, not what the file contains.
2026-09-25 07:43:47 +02:00
admin 588a3f7e36 Fix vertically-flipped content in the GLES path-clip composite
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The clip-layer composite shader sampled the offscreen layer at the wrong vertical position, so path-clipped content (e.g. a circular avatar) came out upside down on the GLES backend.
`v_uv.y` runs bottom-to-top in screen space — the layer FBO has GL's lower-left origin, and `ortho_rect` plus the texture shader's flip establish that `v_uv.y == 1` is the top edge. The composite computed the fragment's screen Y as `bbox.y + v_uv.y * bbox.h`, which is the inverted Y, so it read the layer mirrored about the horizontal axis. Compute it as `bbox.y + (1 - v_uv.y) * bbox.h` instead. The mask sampling was already correct (and a symmetric circle hid the flip in the example; a photo reveals it). The software backend is unaffected — it clips through a coverage mask with no layer round-trip.
2026-06-19 00:04:24 +02:00
admin f8c45f0e30 Add Canvas::set_clip_path — anti-aliased arbitrary-path clipping on both backends
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Add `Canvas::set_clip_path(&[PathCmd])`, clipping subsequent draws to an arbitrary vector path with an anti-aliased edge, on both the software and GLES backends. It complements the existing rect clip (`set_clip_rects`) and is what an embedder needs to render a shaped clip — a circular avatar, a rounded card, a `VectorDrawable` mask — rather than a bounding box. Kept general rather than tied to any one consumer.
Software backend: rasterise the path into an anti-aliased tiny-skia coverage `Mask` (Winding fill) and install it as the active clip mask. Every software primitive already threads `clip_mask` through tiny-skia (fills, strokes, lines, paths, images, text, blit), so the path clip applies uniformly with smooth edges. `clip_bounds` reports the path's bounding box while it is active.
GLES backend: a 1-bit stencil would clip exactly but leave a hard, aliased edge, so instead the clipped draws are captured into an offscreen layer and composited back through an anti-aliased coverage mask. `set_clip_path` rasterises the path coverage (tiny-skia, anti-aliased), uploads it as a mask texture, allocates a full-canvas layer FBO on first use, and redirects subsequent draws to it via `activate_target`. Ending the clip (`clear_clip` / `set_clip_rects` / a new `set_clip_path`) composites the layer back onto the canvas FBO with a new two-sampler program (`CLIP_COMPOSITE_FRAG_SRC`) that multiplies the layer colour by the mask coverage and blends it premultiplied-over. The layer attaches to the canvas's own shadow FBO, so it needs no stencil bits in the EGL config; it is freed and reallocated on resize and freed on drop, and shared programs/uniforms are copied to sub-canvases like the rest.
Usage: a path clip is bracketed — `set_clip_path` then, after the clipped draws, `clear_clip` or `set_clip_rects` to flush it (on GLES this is when the layer is composited). Snapshot the prior clip with `clip_bounds` beforehand and restore it with `set_clip_rects` to compose with an outer clip without leaking state.
Add an `examples/clip_path.rs` demo (rounded rect, circle, triangle — same smooth result on both backends) and software-backend unit tests covering the bounding box, the empty-path clear, and a pixel-level check that a triangular clip masks a fill to the path silhouette rather than its bounding box. The GLES layer-composite path needs a live GL context and is exercised by the example.
Also fix three rustdoc intra-doc-link warnings surfaced along the way: a private-item link in `app.rs` (`scroll`) and the new GLES doc (`SoftwareCanvas::set_clip_path`) demoted to code spans, and a redundant explicit link target in `chassis.rs`.
2026-06-18 23:59:38 +02:00
admin 4a80165428 event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Five orthogonal capabilities land together because they share the same `try_run` plumbing: an optional global is bound at startup, a piece of state is added to `AppData`, the run-loop iteration drains an inbox / pushes a frame snapshot, and the public surface gains a small set of opt-in `App` hooks. Nothing here breaks an existing app — every new path degrades to a no-op when the compositor does not advertise the relevant global or when the platform adapter cannot start.
AT-SPI2 accessibility via AccessKit. A new `src/a11y/` module owns the platform adapter and the inbound `ActionRequest` channel. `A11yState::try_new` constructs an `accesskit_unix::Adapter`; when the AT-SPI2 daemon is not on the session bus (headless CI, locked-down compositors) the constructor returns `None` and the rest of the pipeline runs unchanged. After every successful `draw_frame`, the run loop builds a fresh `accesskit::TreeUpdate` from `widget_rects` and pushes it through the adapter — main surface plus every visible overlay, each translated to global coordinates via `surface_offset_for` so screen readers report positions in the same frame the user sees. Buttons / toggles / checkboxes / radios / list items / sliders / text edits map to the matching `Role`s; `Click` and `Focus` actions are advertised on every interactive node; inbound action requests are drained at the top of each iteration and translated into a synthetic press / focus on the matching widget. The integration is documented as best-effort in `docs/architecture.md` under "Known gaps and non-goals": hierarchical nesting, per-widget accessible names, live regions and `Action::SetValue` are listed as the natural follow-ups that the foundation now supports but does not yet wire.
Cross-application clipboard via `wl_data_device_manager`. A new `src/event_loop/data_device.rs` bridges the existing process-local `clipboard: String` to the Wayland selection. Outbound (Ctrl+C / Cut): after the local clipboard is populated, `publish_clipboard_selection` creates a `CopyPasteSource` offering `text/plain;charset=utf-8` and installs it as the seat's selection; `DataSourceHandler::send` writes the cached string into the fd the peer hands us. Inbound (Ctrl+V from another app): `DataDeviceHandler::selection` asks for the offered text via `WlDataOffer::receive`, spawns a tiny worker thread to drain the read pipe with a 16 MiB cap to prevent paste-bomb DoS, and posts the result back through an `mpsc::Sender` that the run loop drains each iteration into `data.clipboard`. The `clipboard:` field's doc-comment is updated to reflect the new behaviour: process-local when the compositor does not advertise the global, synchronised with the seat selection otherwise.
External drag-and-drop reception. The same `data_device` module handles `DragOffer` enter / motion / leave / drop_performed: `on_drop_motion( x, y )` fires while the drag hovers over the surface, `on_drop_leave()` when it withdraws without dropping, and `on_drop_received( x, y, mime, text )` when an external payload (`text/uri-list`, `text/plain`, …) is released on top of an ltk window. The receive path reuses the same worker-thread / channel pattern as the clipboard so the run loop never blocks on the read fd. Three new `App` hooks expose the events with no-op defaults; apps that ignore them get the previous behaviour.
`xdg-activation-v1`. The global is bound optionally; when it is present, `try_run` reads `$XDG_ACTIVATION_TOKEN` from the environment, removes it immediately (single-use; preventing leaks into child processes) and stashes it on `AppData::activation_token_pending`. After the first successful configure of the main surface — the earliest point at which `xdg_activation_v1.activate` is meaningful — the token is consumed once and the surface raised to focus. Compositors without the global leave `activation_state` as `None` and the inbound path silently degrades. An `App::request_activation_token` outbound path is reserved on the trait but not yet exercised here.
HarfBuzz shaping. A new `src/text_shaping.rs::shape_line` drives both renderers: the logical-order string is run through `unicode-bidi`, split into per-font sub-runs, and shaped through `rustybuzz`. Each `PositionedGlyph` carries the per-font `glyph_id`, the visual advance and the ink offsets — exactly what `fontdue::Font::rasterize_indexed` needs to render Arabic connected forms, Devanagari clusters and CJK shaped glyphs correctly. The GLES atlas is re-keyed on `(glyph_id, size_bits, font_id)` so glyphs from different fonts at the same size no longer collide, and the atlas format is selected per ES profile (`GL_R8` / `GL_RED` on ES3, `GL_LUMINANCE` on ES2) — the fragment shader samples `.r` for both, since `GL_LUMINANCE` replicates the coverage byte into `.r=.g=.b`. Software path follows the same key. New `Cargo.toml` deps: `unicode-bidi = "0.3"`, `rustybuzz = "0.14"`.
Multi-touch hooks. `App::on_touch_down / on_touch_move / on_touch_up( id, x, y )` expose the raw `wl_touch.id` of every secondary finger. The first finger to land remains the *primary slot* and is fed through the regular gesture machine (`on_pointer_*`, swipe, scroll, long-press, drag-and-drop). Every additional finger fires the new callbacks instead, leaving the existing single-slot behaviour untouched for apps that do not override them. This is the substrate for app-defined pinch-zoom / two-finger pan; the toolkit itself does not yet ship a built-in pinch gesture (called out in the same "Known gaps" doc section).
`event_loop::frame` extracted from `draw/mod.rs`. The `draw_frame` orchestrator and its per-format SHM helper (`pick_shm_format`) move into `src/event_loop/frame.rs`, leaving `draw/` strictly responsible for per-surface paint primitives. The import in `event_loop/run.rs` is rewritten accordingly; `draw/mod.rs` shrinks from 192-line orchestrator to a thin module index.
Overlay teardown safety. `AppData::discard_overlay( id )` synchronously removes a destroyed overlay from the map and rewrites every per-device focus that pointed at it (pointer, keyboard, every touch slot), migrating an in-flight long-press drag to the main surface the same way `reconcile_overlays` does. Used by the compositor-driven destruction paths (`PopupHandler::done`, `LayerShellHandler::closed`) where waiting for the next reconcile would leave a window in which `surface()` / `surface_mut()` panic. The non-panicking siblings `try_surface` / `try_surface_mut` are added for callers on async dispatch paths (IME `Done`, tooltip arm) that may race a teardown.
Miscellaneous. CI: `master` → `main` to match the actual default branch. `Makefile` adds `cargo run --example dialog` to the examples target. `src/lib.rs` re-exports `widget::scroll::ScrollAxis` so apps can configure a `scroll()` axis without reaching into a `pub(crate)` module. `Cargo.toml` adds `accesskit = "0.17"` and `accesskit_unix = "0.13"`. `docs/architecture.md` gains the "Known gaps and non-goals" section that enumerates the new capabilities, what still ships flat, and what is deferred (per-widget a11y labels, primary selection, intra-process multi-touch gestures, `wp_fractional_scale_v1`).
2026-05-16 22:09:59 +02:00
admin bbab5e238d First commit. Version 0.1.0 2026-05-10 09:58:23 +02:00