Commit Graph
121 Commits
Author SHA1 Message Date
admin cd1a233b2e A browser app joins the page's desktop: on a page with forge/web its main surface, overlays and subsurfaces are surfaces of that desktop, all painted through one WebGL context
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The browser backend ran an app alone on a canvas over the page. An Eydos desktop in a browser needs crustace as its shell and other ltk apps as its windows on one page, managed by forge/web (forge's web/ directory), which publishes `globalThis.forgeWeb` and stacks, places and focuses the surfaces its clients ask for (forge/web/DESIGN.md).
The desktop's client. On a page where `globalThis.forgeWeb` exists and `set_canvas` named no canvas, `platform/web/desktop.rs` connects as the app's `app_id` and asks for the main surface in the role the app's hooks give, as a Wayland compositor reads them: `shell_mode`, and for a layer `layer_anchor`, `layer_size`, `exclusive_zone` and `keyboard_exclusive`, for a window `app_id` and `window_size_hint`, and for `SessionLock` a full overlay that holds the keyboard. The app draws into the canvas forge/web gives and listens on the input element above it, takes its size and scale from forge/web's `configure` rather than from the window's `resize`, sends its input region in logical pixels when it changes, and leaves the keyboard focus to the desktop, which honours the surfaces that hold it exclusively. A `close` of the main surface reaches `App::on_close_requested`; on exit the surface is destroyed and the connection closed. The new `RunError::Desktop` says forge/web refused the main surface. Fullscreen inside a desktop does nothing yet. A page without a desktop, or one that named a canvas, runs the app as before.
One WebGL context. A page holds few WebGL contexts, and the browser loses the oldest beyond them, while a shell has many surfaces. Every surface of an app now paints through one WebGL2 context on an `OffscreenCanvas`: the surface's FBO is blitted onto it, taken as an `ImageBitmap` and handed to the surface's own canvas, a `bitmaprenderer`, without a copy. Without WebGL2 each surface draws in software on a 2D context of its own. The context's loss is heard on the `OffscreenCanvas`, and now drops the GPU canvases of every surface, not only the main one's.
Overlays. `platform/web/overlays.rs` keeps the app's overlays in step with `App::overlays()`, as the Wayland backend's `overlays_reconcile.rs` does with layer-shell: a new one becomes a layer surface or, for `Layer::Window`, a window, with its size and exclusive zone in logical pixels; a changed one gets an `update_role`; one that went is destroyed; forge/web's `close` of one delivers its `on_dismiss`. Popups anchored to a widget and the tooltip wait for a popup role forge/web does not have, and without a desktop an app still shows no overlays. What every backend does as overlays come and go, resolving an `OverlaySpec::size` and moving a drag and the focuses off an overlay that goes, moved out of the Wayland backend into `event_loop/overlays.rs` (`AppData::overlay_size`, `AppData::remove_overlay`); Wayland's behaviour is unchanged.
Subsurfaces. `platform/web/subsurfaces.rs` makes each of `App::subsurfaces()` a surface of the desktop sized to its parent with an empty input region, painted on one shared GPU canvas only when it appears, its size changes or its `content_version` does, its frame then staying on its own canvas, and otherwise moved with an `update_role`, which the browser composes without painting. `App::on_subsurface_committed` follows each paint. A subsurface whose parent overlay was destroyed and made again is made again, since forge/web destroys a subsurface with its parent. `frame_presented` now knows whether a surface hosts subsurfaces, so while the app animates by subsurface motion only its frames keep coming without painting the parent, as Wayland keeps a parent's frame callbacks. Painting a view with no focus, hover or press on a canvas of its own, which both backends' subsurfaces do, moved out of the Wayland backend into `draw::paint_detached`.
Input per surface. `input::listen` takes the surface's `SurfaceFocus`, which pointer, touch and keyboard focus events carry; a surface's blur no longer takes the keyboard from the surface focused after it. Listeners remove themselves when dropped, and a surface's live in its `WebSurfaceState`, so they go with it. The frames owed at the next animation frame are a list of surfaces instead of one, which with several surfaces left the main one waiting on a frame that never came.
`examples/web_shell.rs` is a shell for the desktop: a background, a bar in the top layer with an exclusive zone, a panel that takes the keyboard, and a card on a subsurface that slides. `make web-example` builds it with web_hello (`WEB_EXAMPLES`); forge/web's `make hello` serves both at /shell.html. web-sys gains `OffscreenCanvas`, `ImageBitmap` and `ImageBitmapRenderingContext`. `CHANGELOG.md` describes the change.
Not done. Popups and the tooltip in a desktop, overlays and subsurfaces without one, fullscreen and window moves through forge/web, and the cursor's shape over an overlay, which is still set on the main surface's element. The offscreen canvas is resized for each surface it paints, which reallocates its drawing buffer when the surfaces differ in size. There are no tests for the browser backend.
2026-10-19 22:42:12 +02:00
admin 95beeda66a Image caches that find an entry in O(log n), so drawing many small images no longer slows down as the caches fill
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Prompt's window had come to spend 35 ms of CPU on every frame after some hours open, most of it in `GlesCanvas::draw_image_data`. Every cache hit looked its key up in the LRU order with `VecDeque::iter().position()` and moved it to the back with `remove`, both linear in the number of entries. A terminal draws each glyph as an image, one per colour it is drawn in, so the cache filled towards its 32 MiB with thousands of small textures, 7573 in the window measured, and every glyph of every frame walked all of them. The software canvas's premultiplied cache and the GLES surface cache did the same.
`render/lru.rs` is a least-recently-used cache bounded by a byte budget. Each entry keeps the tick it was last used at and a `BTreeMap` orders the keys by it, so a hit, an insertion and an eviction cost O(log n). `insert` returns the values it evicted, for the GLES caches to delete their textures, and never evicts the one just inserted, so a value larger than the whole budget is still kept; inserting a key again returns its old value. Tests cover the eviction order, a value over the budget and a replaced key.
`ImageCache` holds its images and its surfaces in one `Lru` each, with the same budgets of 32 and 16 MiB; `ImageCache::image` uploads a texture only when it is not cached, and `evict_overflow` and the two linear lookups are gone. The software canvas keeps its premultiplied images in an `Lru` in place of `PremulCache`. What is cached and evicted is unchanged.
2026-10-19 22:39:34 +02:00
admin 1a39c43762 Composite modes on the GLES canvas: Canvas::set_composite_mode and CompositeMode, completing the blend state the previous commit already applies
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A canvas only composited source-over. A host that draws Porter-Duff modes, as Android's Canvas does with an xfermode, had to read the pixels back, combine them on the CPU and upload them again, which on the GLES backend meant a glReadPixels and a fresh texture for every such draw.
The new `CompositeMode` in types.rs, exported from the crate root, names the Porter-Duff operators (Clear, Src, Dst, SrcOver, DstOver, SrcIn, DstIn, SrcOut, DstOut, SrcAtop, DstAtop, Xor) and the separable modes with an exact premultiplied form (Plus, Multiply, Screen); SrcOver is the default. Darken and Lighten are left out, since GL can only approximate them where alpha is below one.
`Canvas::set_composite_mode` sets how the following draws, blits included, combine with the canvas. On a GLES canvas it stores the mode, which `activate_target` turns into the blend function before every draw: each canvas carries its own, because the GL blend state is global and canvases share one context. A sub-canvas starts in SrcOver and does not inherit its parent's mode. A software canvas ignores the call and keeps compositing source-over. The field, its initialisation and `apply_composite` landed with the previous commit; this one adds the type and the public entry point they use, without which that commit does not build.
The inset shadow, which switches the blend for one draw, now restores the canvas's mode instead of always going back to source-over.
2026-10-19 22:16:17 +02:00
admin 20dc21448e A browser backend: ltk runs on a page's canvas through WebGL2, on a core that builds for wasm32, and a surface's scale may be fractional
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
ltk ran on Wayland and Android only. Running crustace and its apps in a browser needs, first, ltk apps running there; a window manager for the page is the next step. The runtime was already split from Wayland by the platform layer, so what kept it out of a browser was what the core and the runtime took from the operating system: calloop's epoll loop, EGL, a monotonic clock, a file system, threads and environment variables.
The core builds for wasm32-unknown-unknown. `ltk::ChannelSender` is ltk's own type instead of a re-export of calloop's `Sender`, with the same `send` and `Clone`, so apps that only send and clone it build unchanged. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`: calloop's natively, and in a browser a queue the backend runs, which takes the due timers out, runs them on `AppData` and hands them back, and does not arm again one removed meanwhile. calloop, khronos-egl and raw-window-handle are dependencies off wasm32 only, where `egl_context.rs`, `UiSurface::try_new_gles` and `from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` are left out; `sub_canvas` clones the uniform locations, which are `Copy` natively and only `Clone` on wasm. The monotonic clock is `crate::time::Instant`, `web_time`'s on wasm32, where `chrono` takes its `wasmbind` feature; the one subtraction that could go below the clock's origin, a surface's first `last_draw`, saturates. Themes, fonts, keyboard layouts and images are read through `vfs.rs`, the file system natively and on wasm32 files held in memory, so a wallpaper or an `Image::from_path` is now decoded from the file's bytes and its format told from its contents rather than its extension. The text-scale watcher, which runs gsettings on a thread, is left out on wasm32, and the built-in keyboard takes its language from `platform::language()`, `LANG` or the page's. criterion is a dev-dependency off wasm32 only, since rayon does not build there.
The browser backend is the `web` feature, `platform/web/`, with wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys. `build.rs` refuses it with another backend or for another target. A page's main thread must not block, so `ltk::run` sets the app up and returns, and the browser drives the loop: `AppData` lives in a closure a thread-local loop holds, which a `setTimeout` runs for messages, timers and page events and a `requestAnimationFrame` runs to draw. Event listeners and `ChannelSender::send` only queue and schedule, never touching `AppData`, so nothing they set off runs inside an iteration. The app draws on a canvas laid over the whole page, or the one `ltk::web::set_canvas` gives, through WebGL2 with the GLES canvas unchanged, its frame kept in its FBO, or without WebGL2 through `putImageData`. A lost context drops the GPU canvases and draws nothing until it is restored on a new `glow::Context`, so the glyph atlas and the caches of the old one are not reused. The main surface's logical size is the canvas's CSS size; the window's `resize` refits it, and on ltk's own canvas a hidden element padded by `env( safe-area-inset-* )` gives the insets. Input comes from Pointer Events, a mouse as the pointer and fingers and pens as touches, the wheel, in detents when it scrolls lines, and keys, whose keysym comes from `KeyboardEvent.key` and code from `.code` as the Linux input code Wayland reports; keys repeat on the browser's clock, AltGr types its character, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen those `navigator.keyboard.lock()` adds in Chromium. Copies go to `navigator.clipboard.writeText`; a page reads the clipboard only from a `paste` event, so Ctrl+V is let through to the browser and held until its `paste` has put the text in `AppData::clipboard`, and a paste from the browser's menu comes with a Ctrl+V of its own. The cursor's shape is the canvas's CSS `cursor`. The built-in keyboard serves text where the page is used by touch. Panics go to the browser's console. When the app exits, the listeners are removed, the canvas receives an `ltk-exit` event and the app is dropped. The new `RunError::NoDocument` is returned where there is no page, as in a worker.
Resources are the tree an Android package carries. `scripts/web-assets.sh` builds it through `android-assets.sh`, and `ltk::web::load_resources( base ).await` fetches its `MANIFEST` and every file it lists, with the manifest's hash in their URLs so a changed tree is not served from the browser's cache, into memory under `/ltk`, which becomes the resource directory. The per-script fallback fonts are read through `resources::read_font`, so a path that does not exist is found by file name in the resource directory's `fonts/`, where a page bundles them.
A surface's scale may be fractional. `SurfaceState::scale_factor` is an `f32`, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once, and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`, so the canvas holds the display's own pixels at any zoom. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size.
The fallback-theme banner placed its text as if `draw_text` took the top of the line rather than its baseline, so most of the text fell above the banner, out of the surface; it is now centred as the title bar's is.
Building. `make web-check` typechecks the backend. `make web-deps` installs Debian's std for wasm32 and wasm-bindgen-cli, whose version must be the one of the wasm-bindgen crate in `Cargo.lock`: the crate is pinned to 0.2.117, the last whose CLI builds with Debian's rustc 1.85, and since the lock the CLI was published with needs a newer rustc and `cargo install` ignores rust-version when it resolves, the CLI is built from its sources with a lock resolved for the rust-version it declares, into `target/tools`, where the Makefile finds it. `make web-assets` builds the resources with the in-tree default theme and the fallback fonts `WEB_FONTS` names. `examples/web_hello.rs`, a counter and a text field, and `web/index.html` are the smoke test: `make web-example` builds them into `target/web/` and `make web-serve` serves them. `make install` now copies `web/` into the registry. `README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the backend, and `docs/cookbook.md` points to `ChannelSender`'s new home.
Not done. In a browser there are no overlays, secondary surfaces or subsurfaces, no dead keys or input methods, no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. Pasting relies on the browser sending `paste` to a focused canvas, which Chrome and Firefox do. The page fetches its whole resource tree, about 7 MB, before the app starts. There are no tests for the browser backend, `Sources`' queue or `vfs.rs`'s in-memory files, and fractional scales may leave one-pixel artefacts where edges fall between pixels.
2026-10-19 20:20:12 +02:00
admin 4c7e73d492 Text and RichText take a font face the host loaded itself, for hosts that resolve their own system families
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
`Text` and `RichText` could only draw in a theme family, named by `(family, weight, style)` and resolved through the active theme's font registry, or in the canvas default. A host that finds its fonts elsewhere could not use them: rustdroid draws Android's sans-serif, which is Roboto looked up through fontconfig, so that an app's text keeps the widths it was designed with, and those faces are not in any ltk theme.
Both widgets gain `face( Arc<FontFace> )`. A face given this way takes precedence over `font` in `resolve_font`, so it is used for drawing and for measuring alike, and `RichText::map` carries it over. Without it nothing changes.
.gitignore lists what a Debian package build leaves under debian/: the debhelper directories and logs, the staged package trees of libltk-dev, libltk-doc and ltk-theme-default, the build stamp, debian/files and the substvars.
2026-10-18 18:01:18 +02:00
admin ef6847b12c Rectangles with square corners are filled with hard edges on the GLES backend, so rectangles that share an edge leave no seam between them
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
On the GLES backend, Prompt's selection showed a dark line between every two of its rows. `GlesCanvas::fill_rect` always grew the quad by 1 px and passed `u_pad = 1`, so the rect shader never took its solid path, which only runs with no radius, no stroke and no pad, and antialiased every rectangle over a band from −1 to +1 px around its edge. A pixel along an edge, its centre half a pixel inside, was covered at 84 % even when the edge lay on a whole pixel, so two rectangles that met there each left 16 % of what lay beneath showing through, which over a dark background reads as a line. The software backend does not show it, since tiny-skia covers a pixel-aligned edge fully.
A rectangle with square corners now gets no pad, which takes the shader's hard-edged path, as tiny-skia fills it. Rounded rectangles keep the pad and their antialiased edges. A square rectangle whose edges fall between pixels now has a hard edge where it had a soft one.
2026-10-17 20:04:40 +02:00
admin cacf871ddd An Android backend, a platform layer that keeps Wayland out of the runtime, the built-in keyboard for an app's own text input, and APKs packaged with Debian's own tools
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
ltk only ran on a Wayland compositor: the runtime, the input handlers and the draw paths named smithay-client-toolkit's types throughout, so no other windowing system could be added without touching all of them. Prompt, the Eydos terminal, has to run on an Android phone, with its sessions and its sharing, and needs a toolkit that runs there.
The platform layer. Input reaches the runtime as platform-neutral events: `input/event.rs` defines `KeyInput`, `PointerInput` and `TouchInput`, and the pointer, touch and keyboard handlers take them through a few entry points (`pointer_input`, `touch_input`, `key_press`, `key_release`, `key_repeated`, `modifiers_changed`, `keyboard_enter`, `keyboard_leave`, `set_repeat_info`). The sctk handler implementations moved to `platform/wayland/input.rs`, which records the serials later requests need, resolves surfaces and translates. What the runtime asks of the windowing system (moving, resizing, minimising and fullscreening the window, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection, preparing a canvas, presenting it and its damage) forms the `platform::Platform` trait, with its surface type as `Platform::Surface`. The protocol state moved from `AppData` into `platform::wayland::WaylandPlatform`, held as `AppData::platform`, and each surface's Wayland half (its kind, SHM pool, EGL window surface and what the compositor was last told) from `SurfaceState` into `WaylandSurfaceState`, held as `SurfaceState::platform`. The compositor, shell, seat and output handlers, the data device, the primary selection, the run loop, overlay and subsurface reconciliation, session management, output probing and window fitting moved from `event_loop/` to `platform/wayland/`. Drawing is split from presenting: `draw/paint.rs` paints a surface in full or under its dirty rects, the same for the software and GPU canvases, in place of the four near-identical paths of `draw/software.rs` and `draw/gles.rs`, and the backend presents it (`platform/wayland/present.rs`, which also took the opaque and input regions and `pick_shm_format`). The runtime is generic over the platform, `AppData<A, P: Platform = NativePlatform<A>>` and `SurfaceState<Msg, S = NativeSurface>`, resolved at compile time. The backend-neutral half of a loop iteration is `event_loop/step.rs` (`process_messages`, `draw_due`, `next_wakeup`, `before_draw`, `after_draw`), and `event_loop/setup.rs` builds the state and the event sources every backend has; the accessibility tree and its actions moved to `event_loop/a11y.rs`. `platform/mock.rs` is a platform that logs what is asked of it, and `event_loop/tests.rs` drives the runtime on it through input, update, painting and presentation. The behaviour on Wayland is unchanged, except that a frame whose presentation fails leaves the next one to repaint in full. `ltk::Keysym` now comes from `xkeysym`, the crate sctk re-exported it from, so it is the same type.
Features. `wayland`, on by default, brings the Wayland backend and its dependencies, now optional, AT-SPI2 accessibility among them. `android` brings android-activity 0.6, ndk 0.9 and jni 0.21. A new `build.rs` refuses both at once and sets `cfg( backend )` when one is on, which builds the runtime behind `run` and `try_run`. Without a backend only the runtime-free core builds: `UiSurface`, widgets, layout, theming and rendering.
The Android backend. `ltk::run` runs inside a NativeActivity once `android_main` has handed its `AndroidApp`, re-exported as `ltk::AndroidApp`, to the new `ltk::set_android_app`; without it `run` fails with `RunError::NoAndroidApp`. The first call in the process sends standard output and error, panics included, to logcat, sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, and unpacks the bundled resources; every call keeps the latest activity, since Android starts the next one in the same process. The backend draws through EGL on the activity's window, or in software into a locked window buffer, and sizes the main surface at the display's density in whole multiples of 160 dpi. Fingers and styluses become touches, a mouse the pointer, and key events keysyms and text through the device's character map. calloop's descriptor rides on the activity's `ALooper`, so the app's channel and timers keep working. Text is typed on ltk's own keyboard, since a NativeActivity gets only key events from the system's. Through JNI on the activity (`platform/android/java.rs`): copies go to `ClipboardManager` and its text is read back whenever the app gains the focus; back with nothing left to go back to minimises the app with `moveTaskToBack` or closes it, as `App::back_at_root` says, which the new `Platform::back_leaves` decides per backend; and the window's insets come from `getRootWindowInsets()` on Android 11 and later, the status bar and any cutout on top and only bars that take taps on the other edges, so there is no gap above the view nor below it in gesture navigation, and a bar of buttons still keeps its room; earlier releases fall back to the content rect. When the app's last surface closes, ltk calls `finishAndRemoveTask` and waits for the activity to be destroyed, since a second activity started in a process whose `android_main` has returned never came up; an app should end the process after `run` returns. `ltk::keep_running( Some( text ) )` starts a foreground service with `text` on an ongoing notification, so Android does not freeze the app once it leaves the screen, and `None` stops it.
Surface insets. `SurfaceState::insets` holds the logical pixels the system covers along each edge; the view is laid out inside them while the background fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too, above a bar of navigation buttons. On Wayland they stay zero.
Resources in a package. `ltk::set_resource_dir( dir )` names an app's own copy of ltk's resources and `ltk::resource_dir()` returns it, so an app also finds its own bundled files there. Its `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, its `keyboards/` before `/usr/share`, and a font a theme names by an absolute path that does not exist is read from its `fonts/` by file name, so a theme written against Debian's paths works unchanged. On Android `set_android_app` unpacks `assets/ltk/` into the internal storage and uses it as the resource directory; `assets/ltk/MANIFEST` lists the tree, since the asset manager cannot list subdirectories, and its first line, a hash of the contents, decides whether to unpack again. On Android the per-script fallback fonts come from `/system/fonts` and the primary face is the embedded Sora.
APKs. `scripts/android-assets.sh` builds that tree from installed themes, without their cursors, with the fonts each `theme.json` names and ltk's keyboard layouts. `scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]` packages an app built as a `cdylib` with Debian's aapt, zipalign and apksigner, with no Gradle and no SDK download: it writes the manifest, with the library loaded as `libmain.so`, optional permissions and a theme without an action bar, which the content rect would otherwise leave room for; links it against `android-framework-res`; adds the assets and the library; aligns and signs, creating a debug keystore when there is none. `ICON` takes an SVG, rasterised with `rsvg-convert` for every density, or a PNG. `KEEP_ALIVE=1` compiles `java/net/liberux/ltk/KeepAlive.java` with javac and `dx` from art-standalone against the platform 34 `android.jar`, against which aapt then links too, since Debian's framework-res predates the `specialUse` service type, and declares the service with `KEEP_ALIVE_USE` as its reason and the permissions it needs. `examples/android_hello.rs` is the smoke test: `make android-check` typechecks the backend with Debian's rustc, `rust-src` and NDK through `-Zbuild-std`, `make android-apk` and `make android-install` package and install the example, `make deps` and `make android-deps` install what the targets need, and `make install` now copies `examples`, `scripts`, `java` and `build.rs` into the registry.
The built-in keyboard for an app's own text input. While `App::external_text_input` asks for text and no ltk text field has the focus, the built-in keyboard shows with `App::external_text_purpose`, so a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows, comes back when the serial changes and hides when the request ends. Its keys reach the app as physical ones, pressed and released through `App::on_raw_key_event` with the panel's Ctrl and Alt held for them. Before, it only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method.
Clippy. `make clippy` is clean again: derived `Default`s replace the hand-written ones, `div_ceil`, `then_some`, struct update syntax for strokes, the map entry API in the text renderer, function paths in place of closures in the examples and tests, and fewer casts and explicit lifetimes.
`README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the features, the backend, the events and their units, and what stays with a backend.
Not done. On Android there are no overlays, secondary surfaces or subsurfaces, no dead keys from a physical keyboard and no saved state for the activity. Why a second activity in the same process never comes up was not found. The keep-alive service does not ask for the notification permission, so on Android 13 and later its notification may not show until it is granted in the app's settings.
2026-10-16 23:46:50 +02:00
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 3edd822918 Toplevel events name the window, apps hear the pointer hover a surface and where their blurred cards were laid out, and rows can align to the bottom
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A shell could not tell the windows of an app apart, follow the mouse over one of its overlays, or tell its compositor where the translucent cards of a surface ended up. crustace's floating dock needs all three, to activate and close single windows, to magnify the icons under the pointer and to have forge blur behind its pill and its Quick Settings cards.
`ToplevelEvent::Opened` carries the handle's `identifier`, the compositor's token for that one window, and its `title`, both from `ForeignToplevelInfo`; `toplevel_opened` builds the event for the new and the updated handle. Matches on `Opened { id, app_id }` have to add the fields or `..`.
`App::on_pointer_hover( SurfaceTarget, Option<( x, y )> )` is called on every mouse motion over a surface, in its physical pixels, and with `None` from the pointer's `leave`, which ltk did not handle before. `SurfaceFocus::target` gives the `SurfaceTarget` of a focus.
`Container::blur_behind()` marks a container whose rect and largest corner radius, capped at half its shorter side, the layout pass records in surface coordinates through the `DrawCtx::origin` offset of the sub-canvases, clipped by the viewports it sits in. `commit_draw_ctx` keeps them in `FrameState`, and after a frame where they changed `draw_frame` hands them to `App::on_blur_regions( SurfaceTarget, &[ ( Rect, f32 ) ] )`, for the main surface and each overlay.
`Row::align_bottom()` pins the children to the bottom edge, so taller siblings grow upward. `docs/architecture.md` lists `on_pointer_hover` among the input hooks.
Not done. `on_blur_regions`, `blur_behind` and `align_bottom` are not in the documentation, and there are no tests for them. The regions are reported after the frame is committed, so a compositor that receives them over IPC is one frame behind a surface whose cards move.
2026-10-15 14:49:25 +02:00
admin 35ab8ac2b0 The tab strip example in make examples
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
`make examples` ran every example but `tab_strip`, added with the widget, so it never came up among the others. It now runs last, after `clip_path`, with the repository's themes as the rest.
2026-10-15 11:25:03 +02:00
admin a63e905a7a A text field keeps the cursor where an edit left it unless it asks to be pinned to the end, which only the time picker does, and a hook for a window that loses the keyboard
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A field built with `select_on_focus( true )` kept its cursor at the end of the value after every insertion and deletion. That was meant for the time picker, whose `on_change` normalises what was typed, "2" becoming "02", so that a cursor placed at the end of the typed text would land in the middle of the normalised one. But the flag was also the only way to have a field select its whole value on focus, so every field that wanted that got the pinning too, and in an address bar it is a defect: tapping inside the address to correct a word, the cursor was placed where the finger was, and the first character typed sent it to the end, so the rest of the edit went to the wrong place.
The two behaviours are now separate. `select_on_focus` only selects the value when the field takes the focus. The new `TextEdit::pin_cursor_to_end( bool )`, `false` by default, keeps the cursor at the end of the displayed value through the `usize::MAX` sentinel, as before, for fields whose `on_change` rewrites the value. It reaches the runtime in a new `pin_cursor_to_end` field of `WidgetHandlers::TextEdit`, set where the snapshot is built in `Element` and copied in its `Clone`, and the four places in `insert_delete.rs` that anchored the cursor, for an insertion, a delete forward, the deletion of a selection and the move after a replacement, read it instead of `select_on_focus`. The time picker's value fields ask for it, so they behave as they did.
A field that uses `select_on_focus` only to select on focus now edits with an ordinary cursor. The one other user known is a dialog in `crate`, which gets the ordinary behaviour; if its `on_change` normalises the text it has to ask for `pin_cursor_to_end( true )` as the time picker does.
Documentation of the previous commit and of what building crate showed. `docs/widgets.md` says that a `pressable` loses the tap and the right click to an interactive widget inside it even when that widget has no handler, as a `list_item` without `on_press`, so its content should be built from pieces that take no input; and that the hint of `Button::tooltip` is an xdg-popup beside the button in a window, placed by the compositor and taking no grab, and an overlay spanning the output on a layer surface. `docs/theming.md` gains "Application icons": a desktop entry can point at an installed theme icon, the Figma glass effects resvg does not reproduce, a `backdrop-filter` in a `<foreignObject>` ignored and `plus-lighter` rewritten to `screen` by `decode_svg_bytes`, and the advice to flatten them; the table row of `decode_svg_bytes` mentions the rewrite and that its pixels come back premultiplied. `docs/cookbook.md` says that requesting the focus for the text field that already has it puts the cursor at the end of its value without a second `on_focus`, which is how an application moves the cursor after replacing the value, and that Tab and the vertical arrows reach `App::on_key_with_modifiers` before the field, while Escape reaches it after ltk has closed any transient UI and taken the focus off the field.
Losing the keyboard. An application that repeats keys on its own timer, as prompt does for its terminal, never heard that the window lost the keyboard, so a key held while the focus went elsewhere kept repeating until the next key, since its release went to the other window. The new `App::on_keyboard_leave()`, a no-op by default, is called from the keyboard's `leave`, right after ltk stops its own repeat, for the application to stop its repeat and forget the modifiers it kept.
Documentation of the previous commit's hooks and widgets. `docs/architecture.md` lists `on_mouse_button` with `MouseButtonEvent` and `MouseButton`, `on_modifiers` and `on_keyboard_leave` among the input hooks. `docs/widgets.md` describes `ListItem::ink( label, secondary )`, which draws a row's label, subtitle, trailing text and hover and press tints in colours of its own for a surface the theme does not know about, and says that `icon_button` keeps a bitmap's proportions, fitting one that is not square into its size and centring it rather than stretching it.
Not done. There is no test for the new flag, as `select_on_focus` has one in `text_edit/tests.rs` for its builder and its propagation to the handler, and the documentation of the widget does not mention it.
2026-10-15 11:22:01 +02:00
admin 312fb7e0fd Tooltips in a window are popups beside their widget, theme SVGs with plus-lighter highlights are no longer painted grey, and the folder-files icon is shaped as a folder
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A tooltip was a layer-shell surface spanning the output, placed at the coordinates of its widget in the main surface. In a layer-shell shell the two share an origin, but an xdg-shell window does not know where it sits on screen, so in a window the tooltip appeared shifted by the window's position, wherever that put it. The application icons of the default theme, exported from Figma, light their glass with `feBlend mode="plus-lighter"`, which resvg does not know and paints as `normal`, so the highlights came out as grey edges; at dock size those edges cover whole pixels and the icon looks dirty. And `archives/folder-files`, the icon applications show for a folder, was a clipboard and a sheet rather than a folder.
Tooltips. `tooltip_popup` tells when the tooltip must be an xdg-popup, that is when the main surface is a window and the widget is on it, and gives its anchor rect and logical size. `reconcile_overlays` then creates the tooltip's overlay as a popup child of the window, anchored to the widget's rect, above it with a 6 px gap (`TOOLTIP_GAP`), flipped below or slid sideways when it does not fit, so the compositor puts it beside the widget. It takes no grab and keeps the empty input region, so it never takes the pointer from the widget it describes. Its view is the pill centred in the popup. Layer-shell surfaces keep the previous placement, now in `tooltip_on_screen`. `tooltip_id` and `tooltip_size` hold the id and the estimated size both paths share. `OverlaySpec` is unchanged, so applications that build overlays need no change.
Blend modes. `decode_svg_bytes` replaces `plus-lighter` with `screen`, the closest mode resvg supports, before parsing; a document without it is parsed as it comes, without a copy. A test covers both cases.
The folder icon. Both variants of `archives/folder-files` are now the outline of `folder-empty` with the sheet of the previous icon inside, placed and sized as the lock of `folder-locked`: drawn in strokes in `line`, cut out of the filled folder in `filled`.
2026-10-15 10:32:50 +02:00
admin dcb1c7dff3 A tab strip that scrolls to the selected tab and while a tab is dragged against its edge, and hooks for every mouse button and for the modifiers, for an application that hands the mouse on
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A strip with more tabs than fit left the selected one wherever it was, so a tab opened at the end or chosen with the keyboard could stay out of sight, and a tab could only be dragged as far as the visible part reached. A terminal that passes the mouse to the program in it, as xterm does, also needs every click with its modifiers, which ltk kept to itself apart from the middle button, and the modifiers held while the wheel turns, which the wheel does not carry.
Scrolling to the selected tab. `TabRow` now knows the key of the selected tab, and in the layout it asks the `TabMotion` where the scroll of the tabs should go before drawing it; the answer is written into the scroll's own offset, which is keyed by the same index. When the selection changes the strip scrolls just enough to show that tab, through the scroll's `offset_into_view`, and keeps following it while it grows from nothing, so a new tab at the end is shown whole once it has opened. After that the strip is left where the user scrolls it.
Dragging against the edge. While a tab is dragged and its lifted copy touches either edge of the visible part, the strip scrolls 8 widget pixels further that way on each frame (`EDGE_SCROLL`), and every layout places the dragged tab under the pointer again, so it travels with the strip although the pointer holds still and drops among the tabs that come into view.
Pointer coordinates. A scroll and a viewport draw their child on a canvas of their own, so the tabs were laid out in coordinates that no longer matched the pointer once the strip scrolled. `DrawCtx.origin` now holds where the canvas being drawn on sits on the surface, set around the child of every `Scroll` and `Viewport`, and `TabCells` and `TabCards` hand it to the `TabMotion`, which takes it off the pointer in `drag_move`.
The mouse. `App::on_mouse_button( &MouseButtonEvent )` reports every press and release of the left, middle and right buttons, with the position in physical pixels and Ctrl, Shift and Alt, before ltk handles it; `MouseButton` and `MouseButtonEvent` are exported. `App::on_modifiers( ctrl, shift, alt )` is called whenever the modifiers held change. Both do nothing by default.
Tests cover a selected tab scrolled into view once and back, and a tab dragged against the edge scrolling the strip. The documentation of `tab_strip` describes the scrolling.
2026-10-15 10:25:31 +02:00
admin ce98c7b82c TabStrip, the open documents of an application as a browser or a terminal shows them, with tabs that grow, shrink and can be dragged, an overview of cards for a phone, a widget anchored under another one and list items in colours of their own
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Trust and prompt each drew their own strip of tabs, their own phone bar with a counter and their own grid of cards, and neither animated anything nor let a tab move. `tab_strip` does all of it once, in colours the application chooses, so the same widget serves a black terminal and a browser that follows the theme. Two things the menus of those applications needed come with it: a way to place a menu under the button that opens it, and list items that are not drawn in the theme's colours.
The widget. `tab_strip( tabs )` takes `TabItem`s, made with `tab( key, label )`, where the key stays the same while the tab is open, plus `.image( ImageData )` for a page's icon, `.symbol( name )` for a theme icon drawn in the tab's ink, and `.closable( bool )`. The builder sets the `selected` tab, `on_select`, `on_close`, `on_overview`, the buttons before the tabs (`leading`), right after the last one (`after_tabs`) and at the far right (`trailing`), the phone bar's `mobile_center`, the `TabStripColors`, the `TabLayout`, `reorderable` and the `TabMotion`. `TabStripColors` sets the strip's background and ink and the fill, edge and ink of the active tab and of the others; the default follows the theme.
On a desktop. `[leading] [tabs…][after_tabs]  clear  [trailing]`. The tabs share the width between 90 widget pixels and a fifth of the screen's width and scroll sideways when even the minimum does not fit, and 7.5 % of the width between the leading and trailing buttons is always left clear, also while they scroll. A horizontal scroll measures its child with no width limit, so the tabs inside it cannot see how much room there is; two new elements solve that. `TabRow`, outside the scroll, works the tab width out of its own rect, less the clear part and the buttons after the tabs, and hands it to the `TabMotion`; `TabCells`, inside the scroll, places the tabs, the gaps of closing ones and the buttons where the motion says. Each tab is clipped by a `viewport`, so it can grow from nothing and shrink away.
On a phone. `TabLayout::Mobile` is a bar with the leading buttons, the active tab's icon and title or the application's `mobile_center`, a counter that sends `on_overview` and the trailing buttons. `tab_overview( tabs )` is the grid of cards it opens, with a `title`, a `back` message and header `action`s: `build` gives the whole page, header and scrolling cards, and `cards` the grid alone for an application that lays its page out itself. The cards are placed by a third new element, `TabCards`, every one as tall as the tallest, so a dragged card can follow the finger.
The motion. `TabMotion` is the only state, kept by the application, handed to every view and reported from `App::is_animating`; it is an `Arc<Mutex>` because `view` only gets `&self`, and clones share it. A tab that opens grows from nothing while the others narrow, and one that closes leaves a gap that shrinks away while the others keep their width for a second, so the close button of the tab that slides in lands under the pointer; another close starts the second again. Changes take 180 ms, ease-out. The first layout and a resize alone place the tabs without moving them.
Reordering. With `reorderable( true )` each tab, and each card of the overview, carries its select message as `on_drag_start`, so it is dragged after 24 px of mouse motion or a long press, and dragging a tab selects it. The drag reaches the application, which forwards `App::on_drag_move` to `TabMotion::drag_move` and answers `App::on_drop` with the `( from, to )` that `TabMotion::drag_end` reports. The motion remembers where it drew every tab: the first move takes the tab under it and returns `false` when there is none, the tab then follows the pointer within the tabs and is drawn on top, and the others slide out of its way as its centre passes theirs, or as it enters another cell of the overview. On release it slides into its place. A strip and its overview are never shown at once and may share one motion.
Anchored. `AnchoredOverlay`, which only the combo used, is public as `anchored( child, id )`: it draws its child under the widget built with that `WidgetId`, as it was laid out on the previous frame. `align` lines it up with the anchor's left edge, its right edge or its middle, `gap` sets the space under it, and the child is now kept within the rect its parent gives it, which also keeps a combo's list inside the window.
List items and icons. `ListItem::ink( label, secondary )` draws the label, the subtitle and the trailing text in colours of the application's own and tints hover and press with the label's, for rows on a surface that is not the theme's. A button's icon is now fitted into its square without being stretched: a wide icon such as `actions/remove`, a 25 by 4 minus, was drawn as a block as tall as it was wide.
Documentation, example and tests. `docs/widgets.md` describes `tab_strip` and `anchored` and the new `ink`. `cargo run --example tab_strip` opens three documents that can be added, closed and dragged, on a desktop strip or a phone bar with its overview. The tests cover the share of the width, its limits and the clear part, the buttons after the tabs, the drawing order of a dragged tab, the builder's defaults, the overview's rows, and in `motion.rs` the growth, the gap, the wait and its restart, a resize, and dragging a tab and a card; `anchored` and the icon fit have tests of their own.
Not done. A strip that scrolls does not scroll to the active tab or follow a tab dragged towards its edge, and the overview does not scroll while a card is dragged to the edge of the screen.
2026-10-15 08:22:41 +02:00
admin 99b195ea13 The primary selection is kept in step with other applications, an application can paste either selection and hear the middle button, raw keys carry the text dead keys compose, and an application can ask for a cursor where the pointer is
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A terminal drawn by the application itself needed four things ltk did not give it: the selection the middle button pastes, a way to read the clipboard, the text a key types once dead keys are applied, and a text cursor over its text area that does not hide the window's resize cursors. Text fields gain the primary selection along the way, and the symbol fallback and a missed field in the previous commit are fixed.
The primary selection. `event_loop/primary_selection.rs` binds `zwp_primary_selection_device_manager_v1` when the compositor offers it, creates the device with the data device in `new_seat`, and holds the manager, the device, our source while we own the selection, its text and the inbox the reader threads post to. A selection offered by another client is read on a thread of its own as the clipboard is; a peer asking for ours is served from the cached text, and a cancelled source is dropped. `data_device.rs` gives both the pieces they share: `pick_text_mime`, `read_selection` and `write_selection`, so the clipboard behaves as before.
Text fields. Once a press ends, `sync_text_field` publishes a new selection in the focused text field as the primary selection; password fields never publish theirs. It remembers what it last published rather than comparing with the current primary selection, so a selection made in another application is not taken back. A middle-button press on a text field focuses it, puts the cursor where it landed and inserts the primary selection there.
The application's side. `App::take_primary_request` makes a text the primary selection, as `take_clipboard_request` does for the clipboard. `App::take_paste_request` names a `SelectionKind`, `Clipboard` or `Primary`, and the runtime answers with `App::on_paste` and that selection's text, never with an empty one. `App::on_pointer_middle` reports a middle-button press anywhere but on a text field. All default to doing nothing.
Raw keys. `App::on_raw_key_with_alt` gives way to `App::on_raw_key_event`, which takes a `RawKey` with the keysym, the keycode, the press, Ctrl, Shift and Alt, and `text`: what sctk reports the key types once dead keys and compose sequences are applied, `None` on release, halfway through a sequence and for keys that type nothing. A terminal that took the character from the keysym typed `a` for an acute accent followed by `a`. The default still forwards to `on_raw_key`, and prompt was the only application overriding the old hook. Every key press now also records its serial in `last_input_serial`, so a selection set from a keyboard shortcut carries a serial a strict compositor accepts rather than the last click's.
The cursor. `App::cursor_at( x, y )` is the cursor the application wants at a point of the main surface. It comes after the resize edges and `cursor_override`, and before the cursor of the widget under the pointer, so a terminal shows the text cursor over its cells and the resize cursors still show at the window's edges, which a `cursor_override` would hide.
Fonts. Noto Sans Symbols 2 and Noto Sans Symbols follow DejaVu Sans in the fallback chain, for the media controls, arrows and shapes DejaVu lacks, which drew as the missing glyph.
`TextEdit::map_msg` now carries `pad_h` over: the previous commit added the field but left it out there, and ltk did not build.
2026-10-14 16:41:54 +02:00
admin e3b76423fb A text field can take its horizontal padding from its host, the primary finger reaches the pointer hooks, and the screen probe left out of the previous commit is added
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The previous commit described the size hint fitted to the screen before the first commit and the pointer hooks being called for the primary finger, but left out the files that do it, so `run.rs` called an `output_probe` module that did not exist. They are here, together with a text field option that a host laying out its own padding needs.
Text field padding. A single-line `TextEdit` insets its text by `PAD_H` scaled with the field height, so a tall and narrow field has almost no room left for its text: rustdroid's Android fields, which already subtract the padding the Android view asked for, showed a verification code digit in a 24 px wide gap minus ltk's own inset, scrolled it as if it overflowed and clipped half of it. `TextEdit::padding_h( px )` sets that inset in physical pixels; `0` lets the text use the whole rect. Without it nothing changes. `single_line_pad_h` takes the override and every single-line consumer passes it: the draw path, `single_line_scroll_x`, `single_line_align_offset` and `byte_offset_at`, so a click still lands on the glyph that is drawn. The runtime sees it through a new `pad_h` field in `WidgetHandlers::TextEdit`, read by `text_input_pad` where a click is turned into a text position (pointer down, word selection, drag selection and the context menu's paste offset). The password toggle zone keeps the theme inset.
The screen probe. `output_probe::output_logical_size` reads the logical size of the first output on an event queue of its own, before any surface exists: `xdg_output`'s logical size when the compositor offers it, else the current mode over the scale; the objects it binds are destroyed or released before it returns. `window_fit::output_share` gives the part of an output a window may take, the same 90 % the fit after the first configure uses, which now goes through it too. When the size hint is unpinned on the first configure the minimum size is released along with the maximum, as the documentation of `window_resizable` says.
Touch as pointer. The primary finger's down, motion and up are also reported through `App::on_pointer_move` and `App::on_pointer_button`, as the left mouse button is, so an application that handles the pointer gets the touch without a separate path. The module documentation says so.
2026-10-13 20:36:29 +02:00
admin c385605de0 An application can keep the keyboard to itself and see Alt, an external field declares what it is for, and the size hint is fitted to the screen before the first commit
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A terminal takes every key and turns it into bytes for its program, which ltk's key handling got in the way of in three ways. Enter and Space pressed the widget that had the focus, or the one hovered, as well as reaching the application: in prompt a tap on the extra keys row's up arrow left that button focused, so the next Enter ran the command and then pressed the arrow again, bringing the command back on the prompt. The field held by `external_text_input` was always enabled with the normal purpose, so the on-screen keyboard never offered its row of terminal keys to an application that draws its own text area. And `on_raw_key` reports Ctrl and Shift but not Alt, which a terminal sends as a prefix or a parameter.
Keeping the keyboard. `App::claims_keyboard`, false by default, makes `press_key` stop after `on_raw_key` while it is true: the key is not dispatched to the widgets, so neither Enter nor Space presses anything and arrows do not move the keyboard hover, and ltk does not start its own repeat; a repeat already running is stopped as before. `XF86_Back` still goes through the dispatch and `on_back`. An application returns it while its own surface has the keys, and false when one of its menus is up, which then behaves as before.
The external field's purpose. `App::external_text_purpose`, `Purpose::Normal` by default, is what `sync_external_text_input` enables the text-input with, and what it renews it with when the serial changes, so `Purpose::Terminal` reaches the compositor as `ContentPurpose::Terminal` and forge shows the terminal row above the layout.
Alt. `AppData` gains `alt_pressed`, kept by `update_modifiers` with the other two. The runtime now calls `App::on_raw_key_with_alt`, on press and on release, with the same arguments as `on_raw_key` plus Alt; its default forwards to `on_raw_key`, so the applications that implement only that one see no change, and an application that needs Alt overrides the new one instead.
The size hint. A window size hint bigger than the screen used to be fitted only after the first configure, so the first commit asked the compositor for a size it could not give, and a mobile compositor would rescale the window to honour it. Before the first commit `try_run` now reads the logical size of the first output through `output_probe`, on an event queue of its own, and fits the hint within `window_fit::output_share` of it, the same 90 % the later fit uses; that size is what the toplevel is pinned to and what `fitted_size_hint` starts with, and the fit after the first configure is skipped when it was done. The documentation of `window_resizable` now says both the minimum and the maximum are released from the first configure, since a minimum left in place has the same effect on a mobile compositor.
Pointer hooks. The documentation of `on_pointer_move` and of the left-button hook now says they are also called for the primary finger's touch down, motion and up.
Not done. A key held when the window loses the keyboard is stopped by ltk's own repeat but not by an application that repeats keys itself, since there is no hook for the keyboard leaving. Logo and AltGr are still not reported to `on_raw_key_with_alt`.
2026-10-13 20:31:51 +02:00
admin 6e4bf83c91 The default theme declares Noto Serif as a font family
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
`theme.json` gains a `noto-serif` family with its Light 300, Regular 400, Medium 500 and Bold 700 weights, read from the `fonts-noto-core` files that `ltk-theme-default` already depends on, and falling back to Sora. Only the families declared in the theme resolve through `Text::font`, so a widget that wants a serif face, such as the crustace clock with its bold time over a regular date, needs the family to be listed here.
2026-10-11 16:26:01 +02:00
admin 0d492f7c1d Text measuring for hosts, colour emoji, caller-supplied faces, aligned rich text and a wheel hook in detents
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A host that lays text out with its own measurement (rustdroid sizes Android views from it before ltk draws them) needs ltk to measure exactly as it renders. Several gaps made the two disagree; this closes them and adds what such a host needs from the pointer and the paragraph widgets.
Shaping. `shape_line` shaped the first bidi paragraph only, so any text after a line break measured zero width; it now shapes every paragraph. Shaped glyphs keep the cluster rustybuzz reports (`ShapedGlyph::cluster`, and `PositionedGlyph::cluster` as a byte offset into the line), which `caret_offsets` uses to give one x per char boundary, splitting a cluster's advance evenly across its chars. `caret_offsets`, `caret_offsets_with_font` (the same with a given face leading the resolver) and `line_metrics` are exported. The per-codepoint resolver shared by `measure_text` and the new functions is `default_resolver`: primary font, then the system fallback chain, then the primary again for `.notdef`, the same choice the renderers make.
Caller-supplied faces. `draw_text_with_font` and `measure_text_with_font` built a `FontHandle` with empty bytes for a face that was not the canvas default, so HarfBuzz could not shape it and the text silently fell back to the system chain. `FontFace` now exposes the file it was parsed from (`data`, `face_index`) and `FontHandle::of` builds a complete handle from a face; both renderers use it. The GLES renderer also registers the preferred face in its glyph font list so its glyphs are rasterised.
Colour emoji. `FontFace::color_glyph` decodes, once per glyph, the PNG strike a CBDT or sbix font draws a glyph with, as a `ColorGlyph` (straight RGBA plus its box in ems). Both renderers draw such glyphs as images at the pen position, scaled by the text size and faded by the text colour's alpha, instead of rasterising an empty outline. NotoColorEmoji joins the fallback chain after DejaVu Sans. `ColorGlyph` is exported.
Rich text alignment. `RichText` gains `align( TextAlign )`, applied per wrapped line; link hit rects follow the aligned line, so a centred paragraph with links stays tappable where it is drawn.
Wrap pad. A wrapping `Text` wraps its lines to the rect less 4 px and insets a left-aligned line by it. The constant is now public as `ltk::TEXT_WRAP_PAD`, documented as the extra width a host must add when it sizes a label's rect from its own measurement; without it a label sized to its text wrapped its last word.
Wheel. `App::on_pointer_wheel( x, y, notches_x, notches_y ) -> bool` is called before `on_pointer_axis` for a scroll outside any ltk viewport that carries discrete detents (`value120`, or `discrete` on older compositors), with the number of detents per axis, fractional on high-resolution wheels. Returning `true` consumes the event; the default `false` keeps the previous behaviour, where `on_pointer_axis` receives the raw axis value scaled by 10 for wheels. A host that maps detents to its own unit (Android's AXIS_VSCROLL is one unit per detent) no longer has to guess the compositor's axis scale.
debian/changelog gains the 0.3.0-4 entry describing the shaping, caret and caller-supplied face changes.
2026-10-08 22:03:10 +02:00
admin 4a6ec797a2 Wrapped text breaks a word wider than the line instead of letting it overflow
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
`wrap_lines` broke lines only on whitespace, so a word wider than `max_width` (a long path, URL or file name) was laid out whole on its own line and painted past the right edge of its widget. In the notifier it ran out of the notification card. Such a word is now broken between characters into pieces that fit the line, each piece continuing on the next line. The last piece stays the current line, so the following word can still share it. Breaking on `char` boundaries keeps multi-byte UTF-8 sequences intact. `preferred_size` already reported the height of every resulting line, so the widget grows by as many lines as the break produces.
The wrap width also leaves room for the left inset. `draw` indents each wrapped line by `4.0_f32.min( slack )`, which drops to zero for a line that fills the full width, so such a line started a few pixels left of its neighbours. Breaking long words makes full-width lines common. `preferred_size` and `draw` now wrap to the width minus the new `WRAP_PAD` constant (4 px), and `draw` takes the inset from the same constant, so every line keeps it. As a consequence, every wrapped `text` has 4 px less usable width per line.
The new test `wrap_breaks_words_wider_than_the_line` checks that a word wider than the line is split over several lines, that no line is wider than the wrap width, and that the lines together hold the whole text.
2026-10-08 12:48:29 +02:00
admin 17e649dd32 The on-screen keyboard is capped at 40 % of the screen height, and the runtime publishes the logical size of the output the main surface is on
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The keyboard. The keys of the on-screen panel are as tall as one unit of the widest row is wide, so the panel's height follows its width. On a portrait phone that is about a quarter of the screen; turned to landscape the width becomes the long side, the keys grow with it and the six rows of a terminal keyboard take more than the screen. `Panel::height_within( width, scale, screen_height )` keeps `height` within 40 % of the screen height, the new `MAX_HEIGHT_FRACTION`, and `place_osk` uses it for the keyboard of a surface of ltk's own. `set_rect` no longer assumes it was given the natural height: the key height is the smaller of the unit and what the rectangle left after the padding and the gaps shares among the rows, so a capped panel gets wide and low keys, as the keyboards of other platforms have in landscape, while the key widths and the centring of the narrower rows are as before. A portrait screen is not affected, the natural height being below the cap there. A host that lays the panel out itself, as a compositor does, calls `height_within` for the height it reserves and `set_rect` with the rectangle it gets, so both agree.
The output's size. `ltk::output_logical_size()` returns the logical size of the screen the main surface is on, or `None` before the compositor has described one, and `set_output_logical_size` records it. `viewport_size` follows the window, which is the wrong reference for a thing that has to be sized relative to the display, such as a window that must not be wider than the screen. The runtime now publishes the size when an output appears, changes or goes away, and when the main surface enters one, preferring the output of the main surface to the first one listed, and taking the current mode over the scale when the compositor sent no logical size. `window_fit` shares that lookup, `output_logical_size`, with what it did before, so the bounds it keeps a window size hint inside are unchanged.
Not done. The 40 % is a constant and not a setting, the key labels are not scaled to the lower keys, and nothing offers the keyboard at the side of the screen in landscape, which would give the application more room on a wide display.
2026-10-06 23:09:43 +02:00
admin 1ad9898f66 A subsurface maps with the main surface's first buffer, not a frame after it
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The run loop drew the main surface first and reconciled the subsurfaces afterwards, in every iteration including the first. On the first frame the main surface was therefore committed and mapped on its own, and the subsurfaces only got created, rastered and placed in a later parent commit. The lockscreen shows the problem: its main surface always holds the unlock form, and the idle clock panel that covers it lives on a subsurface, so at greeter startup and whenever a forge session locked, the form was visible for a moment before the idle panel appeared on top. In the greeter, `on_first_frame_committed` also sent the daemon its readiness byte with that frame, so the VT switch showed the form.
Until the main surface has committed its first frame, `reconcile_subsurfaces` now also runs before `draw_frame`. It creates each subsurface, rasters it and commits its buffer, which a desynchronised subsurface applies on its own commit, and sets its position, which waits for the parent. In that pass it no longer commits a `Main` parent itself: a bufferless commit is a protocol error on an ext-session-lock surface, and the main surface's first buffer commit, made right after by `draw_frame`, applies the placement anyway, so the main surface and its subsurfaces reach the screen in the same commit. Overlay parents and the reconcile after the draw are unchanged, and once the first frame is out the loop behaves as before.
2026-10-02 18:30:29 +02:00
admin 0fd23acd2b A Length::dp font size keeps its logical size on a scaled output instead of growing with the scale
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
On a 14.1" 2560x1440 laptop, where forge picks a 2.25 output scale and ltk draws at buffer scale 3, the title of every settings screen but the first came out about three times its design size, while on a 1920x1080 laptop at scale 1 the same title was smaller than the 28 px heading of the home screen. That title is a `list_item` with `.font_size( Length::widget( 20.0 ) )`, which in `WidgetScaling::Physical` is `Length::dp( 20.0 )`.
`Canvas::resolve_font` resolved a `Dp` length with the canvas' density, which the runtime sets to the output scale, and returned it as a font size. Font sizes are logical: the raster path multiplies them by `dpi_scale`. A `Dp` font size was therefore scaled twice, 20 × 3 = 60 logical px and 180 physical, against 20 at scale 1. Geometry did not have the problem, because `resolve_geom` returns physical pixels that nothing scales again, and neither did a stock widget without an override, because `font_px` already divided by `dpi_scale` in Physical mode. The same `dp` value thus gave one size through `font_px` and another through an explicit override.
`resolve_font` now resolves `Dp` with `density / dpi_scale`, so that `× dpi_scale` at raster yields `n × density` physical pixels, the same as `resolve_geom` gives the same length. Pixel, viewport and fluid lengths are untouched, since they do not depend on the density. The Physical branch of `font_px` now delegates to `resolve_font( Length::dp( design_px ) )`, which is the formula it carried on its own. Every caller of `resolve_font` is affected: the `font_size` overrides of `button`, `text_edit` and `list_item`, and the `size` of `text` and `rich_text` when given a `Dp` or, in Physical mode, a `Length::widget`. At scale 1 nothing changes.
The new test `dp_font_matches_dp_geometry_on_hidpi` checks at scale 3 that a 20 dp font resolves to 20 logical px, equals `font_px( 20 )` for `Length::widget`, and times the scale equals the same length resolved as geometry.
2026-10-02 07:55:23 +02:00
admin 88f69ebffe A horizontal swipe commits on a fling; App::overlay_namespace; flat Quick Settings slots in the default theme; the on-screen keyboard's rows twice as far apart, with presses between keys kept; LTK_PERF_RASTER
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Flings. A horizontal swipe committed only past `swipe_horizontal_threshold` of the surface width, so a quick flick that stopped short, the usual way to turn a page on a phone, snapped back. The new `input/gesture/fling.rs` holds a `FlingTracker` that measures the finger's speed along one axis over the last 100 ms from the events' own timestamps, so a finger that rested before lifting reads as still. `GestureState` keeps one for the x axis, `fling_x`, cleared on every press; the touch handler feeds it on down, motion and up with the `wl_touch` time, which it used to ignore. On release a horizontal swipe also commits when the finger leaves at 500 logical px/s or more in the direction of travel after at least 24 px, and does not commit when it leaves that fast the other way, whatever the distance; both thresholds scale with the surface. Pointer drags and the vertical swipes keep the distance rule, and the gesture tests, which never feed the tracker, are unchanged. Two tests cover the speed and a rest before lifting.
`App::overlay_namespace`. Every layer overlay was created with the namespace `ltk-overlay`, so a compositor could not tell one panel from another. The new method returns the namespace of overlay `id`, read when its surface is created, and `None`, the default, keeps `ltk-overlay`. It follows `overlay_title` and `overlay_opaque_region` and breaks no app. Crustace names its Quick Settings and launcher overlays with it, and forge draws a blurred backdrop behind them.
Flat Quick Settings slots. The default theme gains `surface-qs-tile`, `surface-qs-tile-active`, `surface-qs-slider-track` and `surface-qs-slider-fill` in both modes: a solid translucent fill, white or navy for the tiles and tracks and cyan for the active tile and the fill, with no shadows, no inset shadows and no gradients. The glass slots the panel used put a blurred shadow, one to five inset shadows, some with blend modes, and a gradient under each of its nineteen surfaces, all rasterised on every frame of its slide. The shared slots are untouched, so the dock and other applications look as before.
The on-screen keyboard. The rows are now `ROW_GAP`, twice `GAP`, apart, 12 logical px against the 6 between keys; `height` counts it, so the panel grows by 18 to 24 px depending on its rows and a host that reserves its height follows. A press between keys, which the wider gap would have made more common, no longer goes nowhere: `key_at` gives it to the nearest key within one row gap.
`LTK_PERF_RASTER`. With it set, every GLES raster, full, partial or subsurface, logs its surface size, the time to submit and, after a `glFinish`, the time until the GPU finished. The `glFinish` stalls the pipeline, so it is for measuring only; it gave the cost of the old Quick Settings panel, 27 to 40 ms a raster on the Librem 5 with peaks of 57 to 143 ms.
2026-09-30 10:17:22 +02:00
admin a0ceef51ef WallpaperBundle::blurred; the on-screen keyboard at the right size on a scaled output, keys as tall as they are wide, a terminal row that fits and a Spanish symbols layer that does not squeeze the rest
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The keyboard of the previous commit was drawn and tried on a desktop at scale 1. On the Librem 5, at scale 2 and hosted by forge, it showed what that could not: letters twice their size, a terminal row whose modifier labels did not fit, keys shorter than they were wide, and a Spanish numbers layer with a gap at each side. This fixes those, and adds the wallpaper helper the shell and the compositor now use for their frosted panels.
A frosted wallpaper. Crustace decoded the theme's lockscreen image at full size a second time for the launcher and the quick settings, and forge decoded it again for the app switcher, blurred it with a floating-point Gaussian at 1920 px and composed a copy per panel size; the switcher's decode alone left some 60 MB in a thread arena that glibc never gives back. A backdrop that is blurred needs no resolution. `WallpaperBundle::blurred( divisor, sigma )` returns a bundle `divisor` times smaller on each side, reduced with a triangle filter and blurred with `imageops::fast_blur`, the box approximation that works in place on the 8-bit pixels; the renderer scales it back up with linear filtering, and the blur hides what the reduction lost. It is an ordinary bundle, so `for_size` crops it for portrait as it does the original. Two tests: the size is divided and never reaches zero, and a flat colour stays that colour. `docs/architecture.md` mentions it next to `for_size`.
Text twice its size. `Panel::draw` passed the canvas a font size already multiplied by the output scale, and the canvas multiplies every font size by its `dpi_scale` once more, as it must for widget text, which is sized in font space. At scale 1 the two agree; at 2 the labels came out at four times the area. The keys and the variants popup now pass `TEXT_SIZE` and `ACTION_SIZE` unscaled, and `draw_centred` computes the baseline offset from the size in physical pixels, `size * dpi_scale()`, so the label stays centred on the key. Radii, key geometry and icons were already right: they are physical pixels and never go through `dpi_scale`. The runtime's built-in panel and forge's both draw through `Panel::draw`, so both had it.
Keys as tall as they are wide. Rows were a fixed 46 px high whatever the width, so on a phone the keys came out wide and flat. The new `key_unit( width, scale )` is the width one key unit takes when the widest row of the layer fills the panel, the width of a `q`; `set_rect` uses it as both the width unit and the height of every row, the terminal row included, and the variants popup, which was already as tall as its key, follows. The panel's height therefore depends on its width: `height( scale )` becomes `height( width, scale )`, `place_osk` passes the surface width, and `KEY_HEIGHT` goes. The architecture document says so. In landscape the keys grow with the screen's width; a cap on the height has not been needed yet.
The terminal row. Esc, Tab, Ctrl and Alt were one unit wide and their labels did not fit. They are 1.5 units now, and to keep the row at 13 units `\` and `~` leave it: the row is Esc, Tab, Ctrl, Alt, the four arrows, `-`, `/` and `|`. Neither is lost: `\` is a long press on `/`, next to `?`, and `~` one on `-`, next to `_` and `–`; `|` takes over the variants `\` had, `` ` ``, `^`, `$`, `#` and `*`, beside its own `&` and `!`. The arrows are drawn at 14 instead of the 22 of the other action icons, which filled the narrow key. The built-in bar's test checks `|` in the row and `\` and `~` among the variants.
The Spanish symbols layer. Its third row carried `¡`, `!`, `¿` and `?`, 12 units against the 10 of every other row; since all rows share the key unit of the widest, the numbers and `@ # € %` rows came out narrower and centred, with a gap at each side, and switching to English and back looked right only because that layout's row has 10. `¡` and `¿` leave the row and become long presses on `!` and `?`, as on Android, and stay among the variants of `.` where they already were. The new test `shipped_layers_are_no_wider_than_their_base` reads `en.json` and `es.json` and fails when any row of any layer is wider than the widest row of the base layer, so a layout file cannot bring this back unnoticed.
2026-09-27 21:43:33 +02:00
admin a1a4d51677 An on-screen keyboard of ltk's own, driven by layout files; TextEdit::purpose for the row a terminal needs; the wheel scrolls a multiline field; a notebook page fills its height
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Text entry on a phone comes from `zwp_text_input_v3`: a focused field enables the protocol and the compositor's input method, squeekboard today, draws a keyboard and commits text through it. Eydos wants to drop that process, so the keyboard has to come from somewhere else, and the natural place is the toolkit that already draws everything else and knows which field has the focus and what it is for. This commit puts one in ltk, as data files plus a panel any host can drive: ltk's runtime shows it under a focused text field when no input method is there, and forge will draw the same panel in its chrome and deliver the keys to the focused client, which is the second half and is not in this commit. Along the way the demo that exercises it exposed two gaps in the widgets, fixed here too.
Layouts are data. `keyboards/<language>.json`, one file per layout, searched in `LTK_KEYBOARDS_DIR` when set, then `$XDG_DATA_HOME/ltk/keyboards` or `~/.local/share/ltk/keyboards`, then `/usr/share/ltk/keyboards`; `osk::layout::search_paths()`, `available()` and `load( name )` are the API, and the English layout is compiled in as the last resort. A file has `name`, a `label` for the layout-switch key, `layers` and `variants`. A layer is a list of rows; a row a list of keys; a key is a bare string for the text it types, or an object with an `action` (`shift`, `backspace`, `enter`, `space`, `layer`, `layout`, `hide`), the target `layer`, a `label`, and a `width` in key units, `1` by default, which share the row's width so a row of nine keys comes out centred under one of ten. `base` is mandatory; a missing `shift` layer is derived from it by uppercasing. `variants` maps a key's text to what a long press offers, and the shifted form of a key uses its base's, uppercased. A key may also be a named key, `{ "key": "Escape" }`, `Tab`, the arrows, `Home`, `End`, `Page_Up`, `Page_Down`, `Delete`, `Insert`, `F1` to `F12`, or a sticky modifier, `{ "modifier": "ctrl" }` or `alt`. `en.json` and `es.json` ship, with symbol layers and the accented variants each language needs. The files go into the registry copy through `make install` and into `ltk-theme-default` under `/usr/share/ltk/keyboards`.
The terminal row. A terminal needs Escape, Tab, Ctrl, Alt, the arrows and the shell's symbols, and no other application does, so those are not a layout but a row laid above any language's layout: `keyboards/bar-terminal.json`, made of the named keys and modifiers above plus `-`, `/`, `|`, `\` and `~` with their variants. Files named `bar-*.json` are rows, not layouts, and `available()` leaves them out, so the layout-switch key does not cycle onto one; the first version listed it and a tap on that key made the keyboard vanish behind a duplicated bar. Which applications get the row is decided by what the field says it is for: `TextEdit::purpose( InputPurpose::Terminal )`, snapshotted into the handlers like `secure`, which the built-in panel reads through `set_purpose` and which also goes out as the `terminal` content purpose of `zwp_text_input_v3`, the same one VTE-based terminals declare, so a compositor hosting the panel can pick the row from the protocol; for a client that declares nothing it can decide by `app_id`.
`osk::Panel` is a state machine a host drives. It holds the loaded layouts, the current layer, shift as off, one-shot or locked, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it with `set_rect( rect, scale )`, and the press in flight. The host feeds it `press_down`, `press_move`, `press_up` and, from its own timer, `tick`, each returning an `Event`: `Text`, `Backspace`, `Enter`, `Hide`, `Key( keysym, modifiers )` for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, or `Changed` when only the panel's look changed. A key types on release; a press held 450 ms opens its variants, which the finger slides onto and the release picks; backspace fires on press and repeats every 60 ms after the same delay; shift is one-shot and locks on a double tap within 350 ms; a modifier stays down for the next key and a character typed under it goes out as a `Key`, since text cannot carry Ctrl. `next_deadline()` says when `tick` next has work, `height( scale )` what the panel needs, `press_down` answers `None` for a press outside it so the host treats that as its own. `draw( canvas )` paints it with the active palette; the action keys show theme icons rather than glyphs, `keyboard/shift`, `keyboard/backspace`, `keyboard/enter`, `general/down-simple` for hide, `accessibility/languages` for the layout key, the `general` arrows for the terminal row, rasterised once per size and tint, because the first version drew `⇧` and `🌐` and the fallback chain has no glyph for the latter, which showed as a question mark; a layout key with no label of its own shows the active layout's. Nothing in `Panel` knows about text fields or Wayland, which is what lets a compositor host it. Fifteen tests cover the layout parser, the derived shift layer, variants following case, the bar filtering, and the panel's taps, one-shot and locked shift, variants, repeat, layer and layout switches and the terminal row.
The runtime shows it. `event_loop/osk.rs` puts a `Panel` on a surface when a text field takes the focus and `builtin_osk` holds: the compositor advertised no `zwp_text_input_manager_v3`, or `LTK_BUILTIN_OSK=1` is set, which is how to try it on a desktop that has an input method, or the app returns `true` from the new `App::builtin_keyboard()`. The panel is placed at the bottom of the surface by both renderers, and the widget tree is laid out in what is left above it, so the focused field is never covered. Presses, motion and releases from the pointer and from touch are offered to the panel before the gesture machine, a press on the already focused field brings a hidden panel back, and the run loop's timeout now takes the minimum of the long-press, tooltip and panel wakeups so variants and repeat fire on time. `Text` goes through the same insertion as a physical key, `Key` through the same dispatch, with Ctrl applied for its duration, `Enter` submits or inserts a newline in a multiline field, `Hide` keeps the focus and takes the panel away; losing the focus removes it. The layout is picked from `$LANG`. `ltk::InputPurpose` is exported, and `docs/architecture.md` gains the section that says all of this, with the compositor's part sketched.
The demo. `examples/inputs` becomes a notebook with the login page it had and a Shell page: one multiline field that holds a transcript, prompt included, declared a terminal, so the row appears; Enter runs the line under the last prompt (`echo`, `ls`, `pwd`, `clear`), the arrows move through the transcript, and a Ctrl combination interrupts the line the way a terminal shows it. Escape quits only from the login page, because a key the app answers never reaches the field, which is what first made the arrows dead.
Two widget gaps the demo found. `Notebook` gave its page the page's preferred height, so a page that fills its space, a scrolled list or a flex, got none and showed nothing: the active page is now wrapped in `flex` and gets the height under the strip. And the multiline field had no way to scroll: it chose its first visible line from the cursor alone on every draw, so the transcript ran under the keyboard and the wheel did nothing. `text_edit/scroll.rs` gives it a `TextScroll` the runtime keeps per field beside the cursor and the selection, in the frame state, the draw context and the `core` runtime alike: the first line asked for by hand and the cursor at that moment. The view holds there while the cursor stays put and, once it moves, follows it by the least it has to, as an editor does; without a request the result is exactly the old rule. `Window::resolve` computes the visible lines once for drawing, hit testing and the wheel, so they cannot disagree, and replaces the copy each of the first two had. In `on_pointer_axis` the vertical motion is offered to the multiline field under the pointer first, three lines per notch from `value120` or `discrete`, touchpad pixels accumulated until they add up to a line, and only when the field cannot move that way does it go to a `scroll` around it. `TextScroll` is public but opaque, since `Element::draw` is public and needs it; `Default` is the cursor-following behaviour. Six tests cover the hold, the follow, the clamping, the notch and the accumulation. A read-only field is not interactive and does not take the wheel; touch drags inside a field do not scroll it yet.
Not done. Forge does not host the panel yet, so on the phone squeekboard is still what appears. The panel's look is a first pass, sized in logical units with the theme's palette, to be restyled from mockups. Only `en` and `es` ship.
2026-09-25 12:50:58 +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 494fb26f9e README: ltk is not mobile-first, its surfaces adapt to any screen
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Two lines carried the mobile-first framing that Eydos is dropping across its components. "mobile-first Linux shells" appeared among the cases the toolkit is especially relevant for, and "attention to mobile power usage" among the priorities its origin explains. Neither describes what ltk does. There is no mobile edition and no desktop edition of anything built with it: the same widgets are designed to work at any size and with any input, touch or pointer, and the layout is chosen per screen at run time. A reader who takes "mobile-first" at face value concludes that a desktop application is a secondary use of the toolkit, which is the opposite of the intent. The same framing is being dropped from forge and crustace.
The power usage line is imprecise for a second reason. What makes a toolkit care about wakeups, redraws and idle cost is that the device runs on a battery, not that it is a phone: a laptop has the same constraint and a board on mains power has neither. "attention to power usage on battery-powered devices" states what the priority is actually about and still covers the phones it was written for.
Documentation only. No code and no public API is touched, and these were the only two occurrences of the word in the file.
2026-09-23 20:55:07 +02:00
admin 919df60681 App::take_blur_request, and a press on a focused select_on_focus field positions the cursor
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Two gaps found while using trust's address box on a phone, both about what happens to a text field once it holds the focus.
Dropping the focus. An app could ask for the focus of a widget, through `App::take_focus_request`, but had no way to give it up. A browser needs to: once an address is submitted the field has done its job, yet it kept the focus, so the on-screen keyboard stayed over the page that had just been asked for, and came back whenever the window was raised, since ltk enables the input method again on the text-input `enter` that follows while a text field holds the focus. `App::take_blur_request` is the counterpart: return `true` once and the run loop calls `set_focus( keyboard_focus, None )`, the same call the back request already uses to blur a text field, so the text-input disable and `on_text_input_focused( false )` come with it. It is handled just before the focus request, so an app that asks for both in one cycle ends up focused, which is what "open a new tab and type" needs; a pending `focus_retry` from an earlier cycle is cleared, or it would hand the focus straight back to the widget it was taken from.
Positioning the cursor in a `select_on_focus` field. `handle_text_pointer_down` and `handle_text_pointer_drag` returned at once for a field built with `.select_on_focus( true )`, so that the press that focuses it keeps the whole-value selection `set_focus` has just installed. The guard did not distinguish that press from a later one: in a field that already had the focus, no press ever placed the cursor and no drag ever selected a range, with a finger or with a mouse. That suits a numeric picker poorly and an address box not at all, where correcting one character of a long URL meant retyping it. `AppData` gains `press_on_focused`, computed in both press paths, touch and pointer, right before their `set_focus`: whether the press landed on the widget that already held the focus. The two guards now apply only when it did not. The first press on such a field selects everything, as before; a press on it while focused collapses the selection to the byte offset under it, and a drag from there extends the selection.
Not changed. `handle_text_select_word` keeps its `select_on_focus` guard, so a double press on such a field still leaves the whole value selected rather than a word. The right-button path, which opens the context menu without touching the selection, never reached these functions and is unaffected. `set_focus` still moves the cursor to the end and reselects when it is called for the widget that already has the focus; a programmatic focus request on a focused `select_on_focus` field therefore selects everything again, which is what an address-box shortcut wants.
2026-09-21 22:45:16 +02:00
admin 1db7a026c9 Default theme: navigation/close-small drew nothing, in either variant
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Both `close-small.svg` files, `line` and `filled`, held the two diagonals of the cross as bare segments, `M785 3221L799 3235` and its mirror, painted with `fill="#000000"` and no stroke. A straight segment encloses no area, so filling it paints no pixel, and `stroke` defaults to `none`: the file is valid SVG that renders as a fully transparent image, in ltk's rasteriser and in Inkscape alike. The rest of the catalogue is drawn as closed outlines, which is what lets an icon be filled and tinted without depending on a line width; this one looks like it was exported with its strokes never converted to paths. A scan of the 432 icons of the catalogue for the same pattern, no stroke and no closed subpath, turns up no other icon with the defect.
Each diagonal is now a closed shape: two parallel sides 1.5 units apart joined by a half circle at each end, the rounded ends `close.svg` has. The width is a little heavier in proportion than that icon's (2 units on a 23.6 box), since this one exists to be drawn small. The `viewBox`, each variant's own `translate` and the visual extent of the original drawing, 0.5 to 14.5, are kept: the axes are inset by the half width so the caps end where the tips of the segments were. Both diagonals are subpaths of a single path filled with the default `nonzero` rule, on purpose: the `evenodd` that `close.svg` carries would punch a hole where they overlap. `line` and `filled` share the shape, as they do for `close.svg`.
The outlines use elliptical arc commands, which no other icon of the catalogue does; they were checked by rasterising both files with Inkscape.
2026-09-21 13:53:49 +02:00
admin 2af281520b External text input for fields ltk does not own; TextEdit::color and TextEdit::on_focus
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Two additions driven by apps that host another UI inside an ltk surface: trust, whose pages have their own form fields, and rustdroid, which projects Android views into ltk widgets.
External text input. ltk only ever enabled `zwp_text_input_v3` for its own `TextEdit`s, so nothing told the compositor that an editable inside an embedded web view had the focus: the on-screen keyboard never came up inside a page, which makes a browser unusable on a phone without a hardware keyboard. Three hooks on `App` close that. `external_text_input() -> Option<u64>` asks for the input method on behalf of such a field: while it returns `Some( serial )` and no ltk text widget holds the focus, the runtime keeps a text-input enabled, and the compositor raises its keyboard. `on_external_text_commit( text )` and `on_external_text_delete( before, after )` receive what the input method sends, the latter in UTF-8 bytes as the protocol counts them. Keys such as Backspace and Enter keep arriving as key events, which hosts already forward.
The request is a serial rather than a `bool` so that it can be renewed. A compositor that folds its keyboard while the field keeps the focus, which forge does on back, has nothing to observe when the user taps that same field again, because the text-input never stopped being active; a change of serial makes the runtime send `enable` again through `reenable_text_input`, the same signal ltk now sends for its own fields. The host chooses what bumps it; trust counts taps on the page, so dragging to scroll does not bring a folded keyboard back.
`AppData::sync_external_text_input`, in `text_editing/ime.rs`, is polled after every batch of `update`s next to `sync_fullscreen`. An ltk text widget always wins: while one has the focus the function only clears its record, and `set_focus` manages the text-input as before. When that widget loses the focus `set_focus` destroys the text-input, and the next poll finds the request still standing with no text-input and enables a new one, so the two owners hand it back and forth without either knowing about the other. The `zwp_text_input_v3` dispatch routes `commit_string` and `delete_surrounding_text` to the app only when the external request is held and no ltk text field is focused; that second check covers the one iteration between a widget taking the focus and the next poll. The empty-string filter on `commit_string` is folded into the `Option` it already lived in.
`TextEdit::color( c )`. The colour of a field's value always came from the theme. A field placed over a surface the theme knows nothing about, an Android `EditText` with its own text colour on its own background, could end up unreadable. `color` sets the value's colour in both draw paths, single-line and multiline; the placeholder keeps the theme's, and a field without it draws exactly as before.
`TextEdit::on_focus( msg )`. `App::on_text_input_focused( bool )` says that some text field is focused, not which. An app that mirrors focus into another model needs to know which: rustdroid keeps Android's single focused view in step, and could only learn the field from its first `on_change`, so `View.isFocused()` stayed false until the user typed. A `WidgetId` cannot carry that, being a `&'static str` while such handles are created at run time. A per-widget message can, since the app builds it and puts in it whatever it needs. It rides the `WidgetHandlers::TextEdit` snapshot, and `set_focus` pushes it when the focus actually moves to the field, from a press, Tab or `App::take_focus_request`, and not again for a press on the field that already holds it. Every other match on that handler variant uses `..`, so the new field touches its definition, its clone and the snapshot in `element.rs`.
Documented in the `App` rustdoc, the trait surface list of `docs/architecture.md`, the `text_edit` entry of `docs/widgets.md` and the changelog. Neither path has a test: both need a live Wayland connection and a compositor to exercise.
2026-09-21 12:45:09 +02:00
admin 96a9e88051 Back as a navigation request: App::on_back, BackAtRoot, NavStack and docs/navigation.md; renew the text-input enable on a press; fit an oversized window_size_hint to the screen
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Three changes that came out of making the shell's back button work across Eydos the way it does on Android. The first is the feature; the other two are gaps it exposed.
Back is no longer Escape. forge turns its back gesture and button into the `XF86Back` key for the focused client, and ltk treated that key as an alias of `Escape`: close transient UI, else drop the focus and reach `on_key_with_modifiers`. That is "cancel", and it is all an app could get: nothing navigated, and nothing ever left the app. `XF86Back` is now a back request with its own order, one layer per request, top-most first: transient UI the runtime owns (open xdg-popup overlays, the built-in context menu, a text selection, the top-most pressable carrying an `on_escape`, i.e. a dialog's cancel), then text-input focus, then the new `App::on_back`, then `on_key_with_modifiers( XF86_Back )` so an app that bound the key itself keeps receiving it (crustace does, for its launcher), then `App::back_at_root`. The first step is what Escape already did; it moves out of `handle_key_escape` into `AppData::dismiss_transient` in the new `input/keyboard/back.rs`, which both paths call, so Escape behaves exactly as before. Text focus comes before the app on purpose: with a keyboard covering half the screen, back means "put the keyboard away", and navigating instead would discard what the user was typing. A compositor that folds its keyboard itself, as forge now does, only lets the next request through, which makes this step the fallback for the ones that do not.
`App::on_back` returns a `BackResponse`: `Message( msg )` with the message that undoes the top-most piece of navigation state, `Unhandled` at the root, or `Consumed` for a host whose back stack lives elsewhere and cannot be inspected ahead of time. `Consumed` exists for rustdroid: an Android app keeps its activities, fragments and back callbacks in its own code, so the host can only hand the request over. `App::back_at_root` then picks `Minimize` (the default), `Close` (through `on_close_requested`, which can still veto) or `Ignore`. Minimising is what a root activity does on Android since version 12 and what suits an app the user comes back to: a browser keeps its tabs, a call goes on. It is also the single point where the two stacks meet: the navigation stack belongs to the app's model, the task stack to the compositor, and `xdg_toplevel.set_minimized` is the one standard request between them, so no custom protocol carries navigation state out of the process and clients that are not ltk apps keep working. Only `ShellMode::Window` surfaces react at the root; a panel or a lock screen that minimised itself would be a bug.
Three consequences of back being a navigation request rather than input: the key leaves the default `App::key_repeats` set, since a held button would empty the whole stack; it is no longer reported through `App::on_raw_key`, so a host forwarding raw keys into an embedded web view or guest toolkit stops leaking it as a keystroke; and the mouse's back button (`BTN_SIDE` / `BTN_BACK`) enters through the same `handle_back`. `focused_text_input` becomes `pub( crate )` for the new module.
The answer is derived, not recorded, and that is the design decision worth writing down. The runtime keeps no navigation stack of its own. Its entries would have to be messages, which are not serialisable (the trait puts no serde bound on `Message`, by design), so it could not survive a relaunch; and a list of "things to undo" drifts the first time the user leaves a screen some other way, through its own close button or a link, leaving a back press that does nothing visible. An answer computed from the model cannot disagree with it. This is also what ties back to the session persistence added earlier: navigation state is both what `on_back` reads and what `save_state` should persist, and keeping the two the same is what makes back retrace the same places after a session restore or a crash, with nothing extra to serialise. Persist less and a restored app opens on the right screen with nothing behind it; persist more and it restores a menu the user had closed.
`NavStack<R>` packages that rule for the common case, an app made of screens. It is a plain value the app keeps in its model, never seen by the runtime: `push`, `pop`, `replace` (the sideways move back should not retrace), `pop_to_root`, `reset`, `current`, `current_mut`, `iter`, `depth`, `can_pop`, and `back( msg )`, which answers `on_back` directly. It is stored as a root plus the routes above it rather than as a `Vec`, so "never empty" holds by construction, including after deserialising an old or damaged state file, and it derives serde whenever `R` does. Six unit tests cover it, a serde round trip among them, and its rustdoc example is a complete `App` where one object answers `on_back`, `save_state` and `restore_state`.
Documentation, since the point is for app authors to take navigation state into account. The new `docs/navigation.md` is the reference: transient UI versus navigation state and the test that separates them, the back order and the reason for each step, why the answer is derived, how it lines up with session state, `NavStack`, apps that embed a web view (whose page history is navigation state too, to be persisted with ltk-webkit's `session_state`), hosts with a foreign back stack, the root, who owns which stack, and an author's checklist. `architecture.md` gains a group in the trait surface and a paragraph in the state patterns, `onboarding.md` and the README index link to it, and in the cookbook the multi-screen recipe now uses `NavStack` with `on_back` and persists the stack without serde, which keeps it compiled by the Markdown doctest harness (that harness links `ltk` alone, so the serde example in `navigation.md` is marked `ignore` and compiled as `NavStack`'s own doctest instead). `BackResponse`, `BackAtRoot` and `NavStack` are exported at the crate root and in the `ltk::window` facade.
Behaviour change for existing apps, also recorded under Changed: a window app that implements neither `on_back` nor the key is now minimised on back at its root, where nothing happened before; `BackAtRoot::Ignore` restores that. With a text input focused, back only drops the focus and the app sees the next request, where it used to get the key in the same press.
Renew the text-input enable on a press. Moving the focus between two text fields, or pressing the one already focused, only committed the refreshed content type on the existing `zwp_text_input_v3`. `activate_text_input` now sends `enable` first, as GTK does when a focused entry is clicked. A compositor that folded its on-screen keyboard while the field kept the focus, which is what back now does, has no other way to learn that the user wants it again: the text-input never stopped being active, so there was nothing for it to observe. ltk sends neither surrounding text nor a cursor rectangle, so the only state `enable` resets is the content type set right after it.
Fit an oversized `window_size_hint` to the screen. The hint is pinned as `min_size == max_size` before the first commit, when no output is known yet, so an app asking for a phone-shaped 720x1440 window on a 1080p desktop got a toplevel taller than the screen; rustdroid carried a dead branch that tried to avoid this from a screen size nothing could give it. The first configure is the earliest point the hint can be checked, and the protocol has the right input for it: `xdg_toplevel.configure_bounds`, exposed by sctk as `WindowConfigure::suggested_bounds` and ignored until now. The new `event_loop/window_fit.rs` holds `fit_within`, which scales a size down keeping its aspect ratio until it fits and leaves one that fits untouched (four unit tests), and `AppData::fit_size_hint`, run once behind the new `pending_size_hint_fit` latch: it uses the compositor's bounds when sent and otherwise 90 % of the first output's logical size, since panels and decorations are unknown from the client side. When the hint has to shrink, `min_size` (and `max_size` for a non-resizable window) is re-pinned to the fitted size, which is kept in `fitted_size_hint` and replaces the app's hint as the fallback for later configures that carry no size; without that the window would grow back on the next one. On that one configure the fitted size wins over the compositor's `new_size`, which was derived from the oversized pin. `App::window_size_hint` documents the rule with the worked example (720x1440 opens as 486x972 on a 1080p output).
The back order needs a live Wayland connection to exercise and has no test; the unit tests cover `NavStack` and `fit_within`.
2026-09-20 23:23:26 +02:00
admin 2252a63f16 App: claim raw touch per press with claims_raw_touch_at, and toggle fullscreen at runtime with fullscreen
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Two additions to the `App` trait, both needed by an application that embeds a self-contained input consumer next to regular widgets. The case at hand is trust, the Eydos browser: an ltk-webkit `WebView` below a toolbar built from stock widgets, with an F11 fullscreen toggle.
Raw touch per press. `App::claims_raw_touch` is all or nothing: once it returns `true` every finger, the primary included, bypasses the gesture machine, so widget presses, taps and swipes stop working on touch. That suits an app whose whole surface is one WebView (the eydos-settings portal), but not one that shows the view under its own chrome: claiming raw touch made the address box and the toolbar buttons dead to the finger, and not claiming it fed the page a mouse-like primary slot, so a drag selected text instead of scrolling and pinch-zoom was impossible. The new `App::claims_raw_touch_at( x, y )` is consulted on `wl_touch.down` with the press position in physical pixels of the main surface; a finger landing where it returns `true` goes through the raw `on_touch_down` / `on_touch_move` / `on_touch_up` stream while fingers landing elsewhere keep driving the gesture machine. The default is `false`, so existing apps are unaffected, and it is only consulted for the main surface: overlays have their own coordinate space and the hook carries no surface argument.
The implementation reuses the auxiliary-slot path instead of adding a second notion of raw ownership. In `TouchHandler::down` the position is now resolved before the takeover check; a claimed finger is recorded in `touch_slots` without becoming `primary_touch_id`, which is precisely the state of a second finger today, so `motion`, `up` and `reset_touch_state` already route it to the app and report its release point with no change. A claimed press skips the plain-press takeover (it must not commit a tap in progress on the chrome) and calls `set_focus( focus, None, qh )`, mirroring what a pointer press outside any widget does: without it a focused text field would keep the text-input protocol, the on-screen keyboard and the key stream after the user had moved on to the page. `pointer_pos` is still assigned after the takeover, since `release_primary` uses it as the release point of the finger being committed.
Runtime fullscreen. `App::start_fullscreen` only covers the first commit; there was no way to enter or leave fullscreen afterwards. `App::fullscreen() -> Option<bool>` declares the desired state and is polled after every batch of `update`s, next to `requested_exit`. `AppData::sync_fullscreen` compares it with `applied_fullscreen`, the last value sent, and calls `xdg_toplevel.set_fullscreen( None )` or `unset_fullscreen` only on a change; `None`, the default, leaves the window state alone. `applied_fullscreen` is seeded with `start_fullscreen()`, so an app that starts fullscreen and reports `Some( true )` does not send a redundant request, and one that reports `Some( false )` is not pushed out of a fullscreen it asked for a moment earlier. It is a no-op for layer-shell and session-lock surfaces, which have no toplevel.
The `configure` handler now also zeroes `titlebar_height` while `WindowConfigure::is_fullscreen()` holds, as it already did for server-side decorations. The built-in titlebar was otherwise painted over a fullscreen surface, which is what the `start_fullscreen` doc comment described as happening "unless the app opts out" without there being a way to opt out. The height follows the compositor's reported state rather than the app's request, so it stays correct when the compositor refuses the request or leaves fullscreen on its own.
Both hooks are documented on the trait and listed in the changelog under Unreleased.
2026-09-20 08:22:46 +02:00
admin eaf88754f3 text_edit: scale the single-line horizontal padding with the field height
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The previous commit put the password eye, the caret and the whole multiline layout in physical pixels, but the single-line variant still inset its text by the raw `theme::PAD_H`: 20 physical px whatever the scale. On a scale-2 panel a field 88 px tall, drawn as a pill of radius 44, kept a 20 px inset, so the first glyph sat inside the rounded end while everything else in the field had grown.
The padding now follows the field height, like the eye already did: `metrics::single_line_pad_h( rect )` is `PAD_H × rect.height / theme::HEIGHT`, built on `single_line_scale`, which replaces the private `password_toggle_scale` of `draw.rs`. The height is the right reference rather than `canvas.geom_px( PAD_H )`: the inset exists to clear the pill's rounded end, whose radius is half the height. With the default height the two agree, since that height is itself `geom_px( HEIGHT )`; when the app pins the height the padding follows that choice instead of the fluid mode, which on a large surface would push it to 1.5 times the design value next to a field the app deliberately kept smaller. It also needs no canvas, so `password_toggle_hit_zone( rect )` keeps its signature.
`draw` resolves the padding once and uses it for the narrowed text rect, the inner clip, the selection band, the placeholder, the text and the caret; `password_toggle_hit_zone` uses it for the half padding beside the eye. `single_line_scroll_x`, `single_line_align_offset` and the single-line branch of `byte_offset_at` use the same helper, so a click still maps to the glyph that was drawn. `hit_test.rs` no longer references `theme` and drops the import. A field `HEIGHT` tall at scale 1 keeps exactly the previous geometry.
2026-09-19 16:14:21 +02:00
admin 495968fca5 text_edit: size the password eye and the caret in physical pixels, and resolve the multiline layout through the canvas
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Canvas coordinates are physical pixels while font sizes are handed to the raster path in font space and multiplied by `dpi_scale` there. `TextEdit` mixed both: on a 2560×1440 panel at scale 2 a field 44 logical px tall measures 88 physical px and its text 32, yet the eye of `password_toggle` was rasterised at the 20 px of `PASSWORD_TOGGLE_SIZE` and the caret was `font_size + 4` = 20 px tall and 2 px wide, so both came out at less than half their intended size next to a field that otherwise scaled correctly.
Password toggle. The icon, the slop around it and the column reserved for it on the right edge now follow the field height, which is already physical: `password_toggle_scale` is `rect.height / theme::HEIGHT` and `password_toggle_reserve` applies it to the icon plus its slop. `password_toggle_hit_zone( rect )` keeps its signature and is built from the same helpers, so the zone that `handle_password_toggle_press` tests still matches what `draw` paints. A field `HEIGHT` tall at scale 1 gives exactly the previous geometry, which is what the existing hit-zone tests cover.
Caret. The single-line caret multiplies its height and width by `canvas.dpi_scale()`, the same factor the glyphs are rasterised with. The literals become `theme::CARET_W` and `theme::CARET_OVERHANG`, shared with the multiline variant.
Multiline. The multiline variant had the same defect throughout: `preferred_size`, `draw_multiline`, the multiline branch of `byte_offset_at` and the `cursor_visual_*` helpers all used `theme::FONT_SIZE`, `LINE_H_MULT`, `PAD_V_MULTI` and `PAD_H` as raw numbers. The text was rasterised at `16 × dpi_scale` but the line pitch stayed at 22.4 physical px, so at scale 2 the rows overlapped, the first one was clipped at the top, the caret was half height and the box claimed half the height its rows needed. It also ignored `.font_size( … )` and the widget-scaling mode, which the single-line variant honours. The new `metrics.rs` holds `MultilineMetrics`, resolved once per call from the canvas and the field's `font_size`: the font through `resolve_font_size`, the glyph and line heights from it times `dpi_scale`, the paddings through `canvas.geom_px` like the other stock widgets, and the caret size. It also owns the inner width, the visible row count and the first visible row, which `draw_multiline` and `byte_offset_at` each computed on their own. Sizing, drawing, hit-testing and vertical caret navigation now agree by construction. `cursor_visual_up`, `cursor_visual_down`, `cursor_visual_home` and `cursor_visual_end` gain a `font_size: Option<Length>` parameter; their callers in `event_loop/text_editing/cursor.rs` already received it from `text_input_geometry` and discarded it.
One visible change at scale 1: the multiline paddings and the default multiline font now go through `geom_px` and `font_px`, so in fluid mode they scale with the surface between 0.7 and 1.5 times the design value instead of staying at 20 / 12 / 16 px. The horizontal padding of the single-line variant is still the raw `PAD_H`.
2026-09-19 16:00:58 +02:00
admin 53e7ccc46e Keep the GLES path when the scale arrives first, stop re-rendering what does not change, and let the compositor skip what the surface covers
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Measured on a Librem 5 (Cortex-A53, GC7000Lite, 720×1440 at scale 2) with a frame trace on both sides of the socket: the shell's main surface repainted in 110 to 136 ms, quick settings drew at 20 fps with the GPU saturated, and every panel open uploaded its wallpaper and icons again. Most of it was the toolkit doing work the frame did not need.
Backend choice. When the compositor announces the output scale before the first `configure`, `scale_factor_changed` allocated an SHM pool for the surface and `on_configure` took that pool as the sign that the software path had been chosen, locking the surface to tiny-skia for its whole life. Every ltk main surface on a scale-2 phone ran in software while its overlays, which inherit the scale from the main and never receive that event, ran on the GPU. The scale handler now only reallocates a pool that already exists; before the first configure neither path is chosen.
Wallpapers. `WallpaperBundle::for_size` returned the crop at the source resolution, a 1103×2160 slice of a 4K image for a 720×1440 panel, 10 MB uploaded per texture and a hundred milliseconds each time. The crop is now downscaled to the surface (bilinear, once per size, cached), also for landscape surfaces smaller than the source.
Viewport. `Element::Viewport` created a fresh sub-canvas every frame, on GLES a framebuffer, a 2048² glyph atlas and empty image and glyph caches, so a panel clipped to a height that animates re-uploaded its wallpaper and re-rasterised every glyph on every frame. Without a bottom fade the clip is now a scissor on the parent canvas (a mask on the software backend): no offscreen pass, no blit, and the child's textures stay in the parent's caches. `local_viewport` is honoured through a temporary override of the layout viewport. Viewports with a fade keep their sub-canvas, which, like the scroll sub-canvases, is now kept across frames and resized in place instead of reallocated when its size changes.
Caches. The image texture cache moves from the canvas to the GL context: every canvas and sub-canvas shares one content-keyed LRU (32 MiB), so an overlay or subsurface that is destroyed and recreated finds its images resident. A GLES canvas dropped with an overlay is pooled in `AppData::canvas_pool` and handed to the next overlay, with its compiled programs and glyph atlas, which took 130 to 290 ms to build on that GPU. Subsurface slots keep their scroll and viewport sub-canvases between rasters.
Surfaces. A `container.surface()` with shadows costs a Gaussian drop-shadow pass, a fill and one pass per inset shadow, all full-quad fragment shaders; a dozen glass cards were 40 ms of GPU per frame for chrome that never changes. `Canvas::fill_surface` on GLES now renders the chrome once per size, corners and style into a texture held by the context cache (`gles_render/surface_cache.rs`, 16 MiB LRU) and blits it, drawing directly only for surfaces with `PlusLighter` or `Overlay` insets, which depend on the backdrop.
Opaque regions. ltk never called `wl_surface.set_opaque_region`, so the compositor blended every ltk surface and repainted everything underneath a full-screen homescreen. A surface whose `background_color` is opaque now declares itself opaque, and the new `App::overlay_opaque_region( id )` lets an overlay declare the physical rects it paints opaque, such as a panel column over an opaque wallpaper. `sync_opaque_region` sends the rects only when they change.
Frame pacing. `eglSwapBuffers` blocked 13 to 37 ms per frame waiting for Mesa's own throttle callback; ltk paces itself on `wl_surface.frame`, so `make_current` sets the swap interval to 0. A subsurface move is now applied only when its parent has no frame callback outstanding, and the parent commit that applies it requests the next callback: a drag commits once per vblank with the latest finger position instead of once per input sample, without the app throttling samples itself. While the app reports `subsurface_motion_only`, the main's callback, and the callback of an overlay that hosts a subsurface, no longer force a full repaint; they mark the surface and the reconcile keeps the cadence with a bare commit when nothing moved. Swipe releases arm a settle the same way instead of dirtying every cache. Two hooks, `on_overlay_frame_committed` and `on_subsurface_committed`, let an app hand a panel over between its host and a sliding subsurface on real commits rather than loop ticks.
Lines. A diagonal `draw_line` on GLES stamped one `fill_rect` per pixel of length, 190 draw calls for a spinner and over 2000 for a pattern-lock stroke; it is now a single rotated quad through the rect shader, with round caps and antialiased edges.
A pixel test covers the scissor viewport, `for_size` gets a downscale test, and `docs/architecture.md`, `docs/widgets.md` and `docs/backends.md` describe the new hook, the viewport paths, the shared caches and the line rasteriser.
2026-09-18 09:55:09 +02:00
admin 03bb7eeede button: elide the label against a minimal inset instead of the horizontal padding
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
`draw_text_button` elided the label against the granted width less twice `PAD_H`. That padding is 24 design px resolved through the widget-scaling mode, up to 36 logical px in fluid mode, so on a scale-2 laptop each side took 72 physical px off the label area. A PIN key pinned at 80 logical px kept 16 px for its digit: "1" fitted, "2" onwards did not, and `elide` returned "..." although that did not fit either. loginmanager's keys, at 56 dp, ended with a negative area and drew no digit at all. Icon buttons never went through that cut, which is why backspace and OK still showed.
The padding belongs to the preferred size of an auto-width button, whose granted rect already contains it, so it has nothing to protect at draw time. A pinned width, or a row shrunk below its preferred width, may leave less than the padding; the label now keeps only `LABEL_INSET`, 4 design px, off the frame and uses the rest of the rect before eliding.
2026-09-16 22:12:14 +02:00
admin 58b8dd7b90 Focus requests reach widgets clipped by a scroll viewport, and stop retrying after a few frames
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A scroll viewport keeps in `widget_rects` only the widgets at least partly inside its visible area (`draw/layout.rs`, the translation of the sub-canvas rects to surface space). A focus request whose target sits below the fold therefore never finds it: `App::take_focus_request` was honoured only for widgets already laid out on screen. The Wi-Fi page of settings is one such case by construction: tapping a secured network inserts the password form under the row and asks for focus on the field, so for a row in the lower part of the list the field is born outside the viewport and the request misses. Worse, the miss branch parked the id in `focus_retry`, marked the view dirty and requested a redraw on every iteration, so the application rebuilt and repainted at the compositor's frame rate for as long as the widget stayed hidden.
The layout pass now records the widgets it drops for carrying a `WidgetId`: `DrawCtx::scroll_clipped` holds `(id, scroll flat_idx, rect)` with the rect in the scroll's content coordinates, the same frame the navigable-item list uses, and `draw_frame` hands it to `FrameState`. When a focus request misses, `AppData::reveal_clipped_widget` looks the id up in that list on the main surface and the overlays, finds the viewport in `scroll_rects`, and moves its offset on the axes the scroll allows so the rect lands inside; the next frame lays the widget out and the retry focuses it. Nested scrolls resolve one viewport per frame.
The rule that computes the new offset is `scroll::offset_into_view`, extracted from the keyboard navigation in `input/keyboard/nav.rs`, which now calls it instead of carrying its own copy; unit tests cover the three cases.
`focus_retry` carries a budget of drawn frames (`FOCUS_RETRY_FRAMES`, four), decremented only on iterations that actually drew, so a request made while a frame callback is pending does not lose its chance. A fresh request from the application replaces a pending retry with a full budget. When the budget runs out the request is dropped, which ends the redraw loop for a widget that never appears.
Documented in the `take_focus_request` doc comment, the cookbook and the changelog; Debian revision 0.3.0-3.
2026-09-14 11:50:27 +02:00
admin 5b8ae35830 [ltk — src/input/gesture/mod.rs, src/input/gesture/tests.rs, src/widget/handlers.rs, src/input/keyboard/dispatch.rs, src/render/image.rs, docs/architecture.md, CHANGELOG.md] Keep a tap on a button while the finger rolls inside it, treat XF86Back like Escape, and convert ARGB8888 by whole pixels
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Any travel past the slop turned a press into a vertical or horizontal drag and its release skipped the button, so a thumb that rolled on a large key lost the tap. `GestureState::on_move` now stays idle while the pressed widget fires on press (the new `WidgetHandlers::has_press`) and the finger is still inside its rect; the swipe machinery takes over from the original press point once the finger leaves. Presses inside a scroll viewport are unaffected, since a list of full-width rows is scrolled by dragging on them. Two tests cover the tap that survives motion inside the button and the swipe that arms once the finger leaves it; the architecture notes and the changelog describe the rule.
The compositor now sends XF86Back for the Android back gesture, an inward swipe from either side edge. With a text input focused that keysym fell into `handle_key_default` and was swallowed, so an app never saw it. `XF86Back` now dispatches through `handle_key_escape`, so every ltk app gets the back gesture with Escape's layering: open popups, then the context menu, then a selection, then a dialog's cancel, and finally the focus drop and `on_key_with_modifiers` with the keysym, where an app can act on it.
`SoftwareCanvas::write_to_wayland_buf` swapped red and blue with a byte-by-byte loop, unrolled by hand over sixteen bytes. It now reads each pixel as a `u32`, exchanges the two channels with masks and shifts, and leaves the vectorisation to the compiler. Compositors that offer `ABGR8888` never took this path.
2026-09-14 10:57:10 +02:00
admin 75be800390 input, render: keep fast touch typing whole, fire pattern nodes once, and make the software image path cheap
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Two ways a tap was lost on a phone. The gesture machine serves one finger: a second finger landing while the first was still down went to the auxiliary slot and never reached it, so with two thumbs every overlapping tap on a keypad vanished. A finger landing on a primary that is still a plain press (GestureState::is_plain_press: no drag, scroll, swipe or slider in flight) now commits that press as a tap at its last position and takes the primary slot; the earlier finger's release reports through on_touch_up, and a second finger during a pattern stroke or a slider drag stays out as before. The touch-up body moves to release_primary so both paths share it. The other loss needed one finger: the 8 px travel that turns a press into a scroll, swipe or drag, and the 6 px stray that cancels a touch long-press, were compared against physical coordinates, so at buffer scale 2 they were 4 and 3 logical px and an ordinary thumb tap became a vertical drag whose release skipped the button. SwipeConfig carries the surface's scale and the machine reads the thresholds through slop() and stray(), which are logical.
Pattern grids fired on_node on every motion event within a node's snap radius, and the Slider move outcome requested a repaint each time, so drawing a pattern rebuilt and re-rastered the surface at the touchscreen's motion rate. The machine remembers the node under the finger (last_pattern_node) and emits only on change; the handlers split pattern_node_at from pattern_msg, with pattern_node_msg kept on top of them; a move outcome without a message no longer requests a redraw.
SoftwareCanvas::draw_image_data premultiplied the whole source in floating point on every call, so a full-surface backdrop paid a pass over every pixel per frame before the blit. Premultiplied copies now live in a 32 MiB least-recently-drawn cache keyed by size, content fingerprint and alpha, the key the GLES texture cache already used; fingerprint_rgba moves to render::helpers and both backends share it. The entry records whether the source was fully opaque, and an opaque image drawn 1:1 with no clip and full alpha is copied row by row into the canvas instead of going through tiny-skia's bilinear pipeline.
docs/architecture.md describes the primary-slot hand-over, docs/widgets.md the once-per-node contract, and the CHANGELOG lists the five fixes.
2026-09-12 10:43:51 +02:00
admin 0a4afd8f81 tree, text_edit, render: hit-test only the visible part of scrolled widgets; keep the clip when a text field's interior misses the dirty region
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Two partial-repaint bugs seen in Settings.
A row scrolled half out of a `scroll` viewport kept its full layout rect for hit testing: only `paint_rect` is clamped to the viewport, and `find_widget_at` tested `rect` alone. The row whose bottom sliver showed just below the search field therefore owned, for the pointer, its whole height above the viewport, so hovering the field showed the hand cursor and highlighted that row, and a click opened it. `find_widget_at` now requires the point to lie in `paint_rect` as well. Inside a `scroll` or `viewport` that is the visible part; elsewhere `paint_rect` always encloses `rect`, so nothing else changes. `rect` is left intact because sliders, focus scrolling, the IME rect and tooltips need the full geometry. Two tests cover a clipped row and an unclipped button.
The search field's placeholder darkened as the pointer moved over the list, until a full repaint restored it. `TextEdit::draw` composes its interior clip as the intersection of the parent clip with the inner rect; during a hover repaint of a row the intersection is empty, and `set_clip_rects` with an empty list means "no clip", so the placeholder was painted unclipped over the previous pixels on every partial frame. With `text_secondary` being `text_primary` at 0.6 alpha, two or three passes read as bold. `interior_clip` now returns a zero-area rect for an empty intersection, shared by the single-line and multiline paths, which the GLES backend already turns into a zero scissor; the software backend gains the matching rule, keeping an all-zero mask when the rect list is non-empty but has no area instead of dropping the clip. A test pins the three cases.
2026-09-11 19:24:49 +02:00
admin 92b090a441 App: fix the on_main_frame_committed doc, the hook orders a swap, it does not make it atomic
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The comment promised that a subsurface mounted or retired from the hook lands in the same flush as the main frame. It does not: the main buffer goes out with its swap, which flushes the connection at once, and the subsurfaces are reconciled afterwards. What the hook guarantees is ordering, the main frame is already sent when it fires, which is enough to retire a subsurface safely but not to mount one without a gap.
2026-09-10 22:57:30 +02:00
admin 87f52565c3 App: add on_main_frame_committed, called after every main-surface commit
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
A host that hands part of its content to input-transparent subsurfaces has no way to know when the main frame that replaces that content has actually been sent: `view()` runs when the tree is dirty, but the draw can be deferred by a pending frame callback, so a subsurface dropped on the strength of the rebuild alone can vanish a frame before the main paints its replacement. The new hook fires in `draw_frame` right after the main buffer is rendered and committed, before the subsurfaces are reconciled for the same iteration, so the host can retire a subsurface knowing the main frame behind it is already out. `on_first_frame_committed` keeps its one-shot role.
2026-09-10 22:54:27 +02:00
admin c9ae5438f8 pattern_grid: explicit side through PatternGrid::size
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The grid's preferred size was the theme's fluid constant (264 px at the reference surface) capped by the width the parent allocates, so a caller could only shrink it: a lock screen that wants the grid to span most of a phone's width had no way to ask for it, and a container's max_width did nothing past the default. PatternGrid gains a size field with a size( impl Into<Length> ) builder; preferred_size resolves it through Canvas::resolve_geom like the other geometry overrides and still caps it at max_width, and map_msg carries it across. Without the builder the fluid default applies as before.
docs/widgets.md describes the builder next to hide_stroke and error; CHANGELOG lists it under Unreleased.
2026-09-10 21:33:55 +02:00
admin 7c1ef86afe Resolve fluid geometry bounds in logical space and let the runtime set the density from the buffer scale
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Stock widgets size themselves with `Length::fluid`, whose auto-clamp `[0.7·px, 1.5·px]` is documented as logical px, yet `Canvas::resolve_geom` resolved it against the physical viewport and applied the bounds to the physical value. On any HiDPI surface whose physical short side exceeds 618 px (412 × 1.5) the cap engaged: a 1080 × 1920 phone at scale 2 got its geometry at 1.5 physical px per design px, that is 0.75 logical, while fonts, resolved by `font_px` against the logical viewport and scaled at raster time, kept growing to 2.62. Rows, paddings and icons came out at 60 % of their intended size with the text overflowing them; at scale 1 physical and logical coincide, so a winit session never showed it.
`Length::resolve_physical` takes the buffer scale and multiplies `min_px` / `max_px` by it before clamping; `resolve_with_density` delegates to it with scale 1. `Canvas::resolve_geom` passes its `dpi_scale`, so every stock-widget geometry shares the surface factor of the fonts, and `overlays_reconcile` does the same for `OverlaySpec::size`, which had the same mix. A zero scale falls back to unscaled bounds.
The event loop now calls `set_density( sf )` right before `App::on_scale_changed`, both on `configure` and on `scale_factor_changed` of the main surface, so `Length::dp( n )` means n logical px in every app without plumbing; an app wanting a DPI-derived density still overrides it from that callback, and crustace already sets the same value there. Two tests cover the clamp at scale 2 and the geometry/font parity on a 1080 × 2460 canvas.
2026-09-10 21:18:24 +02:00
admin 2cee9a1c57 event_loop: drop the text-input focus when the focused field vanishes from a rebuilt view
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
A view rebuild that no longer contains the focused text field left `focused_idx` pointing at a stale index and the `zwp_text_input_v3` object enabled. The compositor kept seeing an active text-input, so the on-screen keyboard stayed up over a screen with nothing to type into. The lockscreen's inactivity return from the password form to the idle screen is the visible case: the form disappears, the keyboard does not.
`focus.rs` gains `surface_focused_text_input`, which resolves the focused widget by id when it has one and by index otherwise and reports whether it is a text input, and `reconcile_text_focus`, which clears the focus, deactivates the text-input and calls `on_text_input_focused( false )` when it no longer resolves to one. `draw_frame` notes, before drawing the main surface and each overlay, whether its focus was a text input, and reconciles those surfaces once every draw is done and no view borrow is live. Focus that lands on the field again through `take_focus_request` re-enables the text-input as before.
2026-09-10 21:12:45 +02:00
admin 492553829e widgets, input: PatternGrid for pattern unlock, framed icon buttons, and text keys that bubble past a focused non-text widget; Debian 0.3.0-2
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
PatternGrid is a 3×3 drag widget for the unlock pattern. It is stateless: the app owns the visited nodes and passes them back in for drawing; a press or a drag that reaches a node within the snap radius fires on_node with the index, 0 to 8 row-major, and release fires on_release, so the crossing and minimum-length rules stay in the app. Geometry lives in widget/pattern_grid/theme.rs (size, dot and node radii, stroke width, snap radius, ring alpha), and the snap radius is resolved through the widget-scaling mode at layout time like the slider thumb (set_pattern_snap_px). The gesture machine reuses the slider's drag ownership: WidgetHandlers::is_drag_target covers both, so hover painting, pointer capture and release behave the same, and hover_affects_paint no longer special-cases sliders. The a11y tree maps it to GenericContainer, since a drawn gesture has no assistive equivalent and the app must offer another method. Exported as pattern_grid / PatternGrid, documented in widgets.md, theming.md and architecture.md, with tests for node centres, a wide rect, snapping, the builders, the handler snapshot and map_msg, and a Security tab in examples/widgets.rs that shows the drawn path.
Icon buttons given a width or height are framed keys: preferred_size honours the explicit size instead of the icon size, and drawing paints the variant's background, border and focus ring, shared with text buttons through paint_frame, then centres the icon. An icon button without a size keeps the circular hover overlay it had. This is what lets the PIN keypad's backspace and OK keys sit in the same circles as the digits.
Keyboard routing now matches what App::on_key documents. handle_key_default, Backspace, Delete and Space checked focused_idx.is_some() and sent the key into text insertion whenever any widget had focus, which is a no-op on a button, so a focused button or pressable swallowed typed characters. focused_text_input replaces the five copies of that check; characters, Backspace and Delete stay in a focused text input and reach the app otherwise, Return and Space still go to the focused widget's submit or press message when it has one, and the arrow, Home and End handlers use the same helper. The lockscreen relies on this to take PIN digits with its dots row focused.
debian/changelog: 0.3.0-2.
2026-09-09 17:27:42 +02:00
admin a2752a5bd3 Handle SIGTERM/SIGINT with a sigaction self-pipe instead of a signalfd, and clear the signal mask of spawned children
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The signalfd-based source (calloop's `signals` feature) needs SIGTERM and SIGINT blocked in every thread of the process, since the kernel hands a process-directed signal to any thread that does not block it. calloop only blocks them in the thread that creates the source, so the runtime depended on `ltk::run` being entered before the app spawned any thread — a constraint apps do not honour: crustace-notifier builds its tokio runtime and its D-Bus thread first, and in that configuration a SIGTERM lands on a tokio worker and kills the process outright, skipping the clean exit (`save_state`, `mark_clean_exit`) entirely.
The blocked mask had a second consequence: it is inherited across fork and exec, and Rust's std deliberately does not reset it in the child. Every process an ltk app spawns from a thread created after `ltk::run` therefore started with SIGTERM blocked. For the `gsettings monitor` child of the text-scale watcher this meant it never died on systemd's stop, so the unit's cgroup stayed populated until `TimeoutStopSec` (90 s) expired and systemd resorted to SIGKILL — visible as a stop job holding up every session shutdown.
`install_signal_source` now installs a `sigaction` handler for SIGTERM and SIGINT that writes the signal number to a non-blocking `O_CLOEXEC` pipe (an atomic load and a `write`, errno preserved); the read end is a calloop `Generic` source that drains the pipe and sets `exit_requested`. A handler is process-wide, so the clean exit works no matter which thread receives the signal or when it was created, and no thread blocks anything, so children inherit a clean mask. `SA_RESTART` keeps the handler from injecting spurious EINTRs into the app's own threads; the pipe wakes the event loop regardless.
`text_scale` additionally clears the child's mask in `pre_exec` before running `gsettings`, as a guard against a mask already dirty when the process was launched. The `save_state` docs drop the caveat about threads started before `run`, the ordering comment at the call site goes with the constraint it described, and the `signals` feature is dropped from calloop. `libc` becomes a direct dependency for `sigaction`, `pipe2` and `sigprocmask`.
Tests: `child_starts_with_clear_signal_mask` blocks SIGTERM in the test thread, spawns a child through the helper and checks its `SigBlk` in `/proc`, with the inverse control asserting that std really does inherit the mask (the reason the helper exists); `handler_writes_signal_to_pipe` exercises the handler directly and through a real `sigaction` + `raise`, then restores `SIG_DFL`.
2026-08-25 09:47:08 +02:00
admin 902e23e7f2 Fallback fonts: probe glyph coverage with ttf-parser before building the fontdue face
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
The first string containing a glyph outside Sora stalled the caller for over a second, even on a desktop i7. `system_fonts::lookup_handle` walked the fallback chain building a `fontdue::Font` for every slot just to consult its `cmap`, and fontdue parses the whole face up front: reaching a dingbat such as U+2733 meant paying for `NotoSansCJK-Regular.ttc` (19 MB) although the glyph is not even there and only DejaVu, the last slot, carries it. In forge that showed as the app switcher taking more than a second to appear the first time a window title carried such a symbol; the top bar never triggered it because its text is all Latin.
Each slot now has two lazily resolved stages: the file bytes, read once, and the fontdue face. Coverage is answered from the bytes alone with `rustybuzz::ttf_parser::Face::parse(...).glyph_index(ch)`, which only touches the tables it is asked for, and the fontdue face is built solely for the slot that actually owns the glyph. A CJK codepoint still pays the CJK parse, since it has to be rasterised, but a symbol that lives in DejaVu no longer drags the collection into memory. The sticky negative results are kept at both stages, and the shaping-side `bytes` in `FontHandle` now share the `Arc` with the coverage stage instead of a second copy.
2026-08-18 11:36:28 +02:00
admin 3046d86337 Layer::Window overlays: an OverlaySpec can now be an ordinary xdg toplevel, named through App::overlay_title; showcase persists the last pressed button too
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
A shell built on ltk had no way to show something as a regular window: every overlay is a layer-shell surface, and layer-shell surfaces are either below all application windows or above all of them. crustace's "closing applications…" card ran into exactly that — on the overlay layer it covered the applications' own "save changes?" prompts, on the bottom layer it vanished behind their windows — and what that card wants is to be one window among the others: decorated by the compositor, stackable, listed in the switcher and the dock.
`Layer` gains a `Window` variant. An `OverlaySpec` carrying it is materialised by the reconciler as an `xdg_toplevel` (sctk `Window`, server-side decorations) instead of a layer surface: `size` is its fixed size, applied through min/max size (a zero component falls back to a default, since a toplevel cannot "fill"); anchor, exclusive zone and keyboard exclusivity are ignored. The window's app_id is `App::app_id()` and its title comes from the new, defaulted `App::overlay_title( id )`, so the compositor's window lists show something meaningful. `WindowHandler::configure` and `request_close` now resolve which surface a window belongs to: the main window keeps its behaviour, an overlay window is configured through its own `SurfaceState` (with the window geometry set to the configured size) and the compositor's close button delivers the spec's `on_dismiss` instead of exiting the app. Layer-shell applications now bind `xdg_wm_base` when the compositor offers it, so they can open such a window later. Adding a variant keeps every existing `OverlaySpec` literal compiling; the new trait method has a default.
The showcase example now also saves and restores `last_pressed`, bumping its private state format to version 2 with the note kept last so its line breaks survive.
2026-08-15 17:56:06 +02:00