First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

96
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,96 @@
# Gitea Actions CI for ltk.
#
# Runs on every push and pull request against `master`. Two independent
# jobs share the cache layer but otherwise execute in parallel:
#
# - `test` builds the workspace and runs the full test corpus.
# - `audit` runs `cargo audit` against the RustSec advisory database to
# catch dependency CVEs without waiting for a human to remember to run
# it locally.
#
# Note: ltk uses Modified Allman style (not rustfmt's default), so there
# is no `cargo fmt --check` step here on purpose. If rustfmt ever ships
# a stable Allman config, add a third `fmt` job.
#
# Compatible syntax with GitHub Actions, so swapping host providers does
# not require touching this file. Gitea also reads `.github/workflows/`
# by default, but `.gitea/workflows/` is preferred for clarity.
name: CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
# Surface the MSRV that `Cargo.toml` declares so the runner pins the
# toolchain to the same version the package is published against.
RUST_TOOLCHAIN: "1.85"
jobs:
test:
name: build + test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
# ltk links against Wayland / EGL / xkbcommon at compile time
# through smithay-client-toolkit, wayland-egl and khronos-egl.
# The runner image does not ship these headers by default.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwayland-dev libegl-dev libxkbcommon-dev pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --workspace --all-targets
- name: Test
run: cargo test --workspace --all-targets
# External-Markdown doctests. `cargo test --doc` only sees doctests
# inside `src/`; the cookbook and widget reference under `docs/`
# are stand-alone Markdown files that rustdoc has to be invoked
# against directly. The script discovers the freshly-built rlib
# from the `Build` step's `target/debug/` and runs `rustdoc --test`
# over each `.md`. Snippets are `no_run` so this only typechecks —
# the goal is to catch API drift, not integration-test the runtime.
- name: Markdown doctests
run: ./scripts/doctest-md.sh
audit:
name: cargo audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@v2
- name: Install cargo-audit
# `--locked` so the audit tool itself does not pick up an
# advisory-affected transitive dep at install time.
run: cargo install cargo-audit --locked
- name: Run cargo audit
# Default behaviour: fail on any active advisory. Add
# `--ignore RUSTSEC-...` here for advisories that have a
# documented mitigation upstream we cannot avoid yet.
run: cargo audit

136
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,136 @@
# Contributing to ltk
Thanks for considering a contribution. This document covers the practical
mechanics: how to set up your environment, how to run tests, what shape a
patch should take, and where to send it.
For background on the toolkit itself, read
[`docs/onboarding.md`](docs/onboarding.md) and
[`docs/architecture.md`](docs/architecture.md) first.
## Reporting bugs and proposing features
Open an issue on the project repository before sending a non-trivial
patch. We want to align on scope before you spend time on an
implementation. For security-relevant issues see
[`SECURITY.md`](SECURITY.md) — those should *not* go through the public
issue tracker.
When reporting a bug, include:
- the `ltk` version (commit hash if you built from source),
- the Rust toolchain (`rustc --version`),
- the compositor and OS,
- a minimal reproducer (Rust source preferred over screenshots),
- whether the issue happens on both the GLES and the software backend
(set `LTK_FORCE_SOFTWARE=1` to force software).
## Building and testing
The project requires the Rust toolchain shipped with Debian stable
(currently 1.85). On Debian / Ubuntu:
```bash
sudo apt-get install \
libwayland-dev libegl-dev libxkbcommon-dev pkg-config
git clone <repo>
cd ltk
cargo build
cargo test
```
The `Makefile` wraps the common targets:
```bash
make all # cargo build --release
make test # cargo test
make audit # cargo audit (installs cargo-audit on first run)
make doc # cargo doc --no-deps
make example # run every example under examples/ in turn
make clean
```
Running the examples requires a Wayland session and the default theme
on disk:
```bash
export LTK_THEMES_DIR="$PWD/themes"
cargo run --example showcase
```
## Code style
`ltk` uses a custom **Modified Allman** style. `rustfmt`'s default
settings do not match it; do not run `cargo fmt`. The full rules live
in [`code_style_guide.md`](code_style_guide.md), but the headline points
are:
- **tabs** for indentation (never spaces),
- opening `{` on its **own new line** for `fn`, `impl`, `struct`,
`enum`, `mod`, `if`, `for`, `while`, `match`, `loop`,
- `} else {` and `} else if … {` on the same line as the closing brace
(compact else),
- spaces inside non-empty parentheses: `fn foo( x: i32 )`,
`bar( arg )`, `Some( x )`, `Ok( v )`,
- spaces inside non-empty attribute brackets: `#[ derive( Clone, Debug ) ]`,
- no spaces inside `<>` generics: `Vec<String>`, `Option<i32>`,
- comments in **English** — never another language.
Match the surrounding code when in doubt.
## Patch shape
- Keep changes focused: one logical change per pull request. A bug fix
and a refactor go in separate PRs.
- Add tests. The repository has ~400 tests covering the existing
surface, and a contribution that adds behaviour without test
coverage will get review pushback. See [`tests/`](tests/) for
examples of integration tests via `UiSurface`, and the inline
`#[cfg(test)] mod tests` blocks under `src/` for unit tests.
- Document new public API. Every `pub` item exported from the crate
root is expected to have a `///` rustdoc comment with at minimum a
one-paragraph description and an example. Module-level `//!`
comments are required for new submodules.
- Don't break the public API surface without coordinating. Until the
crate hits `1.0`, breaking changes go in minor versions
(`0.x.0 → 0.(x+1).0`); patch versions (`0.x.y → 0.x.(y+1)`) keep
source compatibility.
- Run `cargo test` and `make audit` before sending. CI will run them
again, but it is faster for both of us if your local run is clean.
## Architectural decisions worth knowing
A few patterns recur across the codebase:
- **Builder methods consume `self`** (`pub fn padding( mut self, p: f32 ) -> Self`).
Chaining works because every builder returns `Self`. Don't introduce
setters that take `&mut self`.
- **Layouts and widgets share `Element<Msg>`.** Anything that converts
to `Element` can be pushed into any layout. The split between
[`crate::layout`] and [`crate::widget`] is documentation, not
architecture.
- **The runtime is single-threaded.** Use `RefCell` for caches inside
`App` state, never `Mutex`. Cross-thread communication goes through
[`ChannelSender`](src/app.rs).
- **`view()` must be pure.** No I/O, no allocation-heavy work, no
state mutation. Cache derived data on the app struct (behind
`RefCell` if needed) and look it up.
- **Theming is process-global.** There is no per-app theme; the active
document and mode live in a `RwLock<Arc<…>>`. `view()` reads the
state, never writes it. Mode flips and document swaps go through
[`crate::set_active_mode`] and [`crate::set_active_document`].
- **Per-frame allocations are fine.** Building the `Element` tree on
every render is the supported model. Don't try to retain widgets
across frames.
## License
By submitting a patch you agree it is licensed under
[`LGPL-2.1-only`](LICENSE), the same as the rest of the project. Do not
add `Co-Authored-By` lines or other AI attribution to commit messages.
## Code of conduct
Be respectful. Disagreements are welcome; personal attacks are not.
Maintainers will moderate threads that go off the rails.

46
Cargo.toml Normal file
View File

@@ -0,0 +1,46 @@
[package]
name = "ltk"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
license = "LGPL-2.1-only"
description = "Lightweight declarative Wayland UI toolkit. Elm-shaped App / view / update model with GLES and software rendering backends, layer-shell support, runtime theming and a runtime-free core surface for embedding."
repository = "https://github.com/liberux/ltk"
documentation = "https://docs.rs/ltk"
homepage = "https://liberux.net"
readme = "README.md"
keywords = [ "wayland", "ui", "gui", "toolkit", "layer-shell" ]
categories = [ "gui", "rendering", "os::linux-apis" ]
authors = [ "Liberux Labs, S. L. <info@liberux.net>" ]
exclude = [
"target/*",
"debian/*",
"themes/*",
"liberux.toml",
"TODO",
]
[dependencies]
chrono = { version = "0.4", features = ["clock"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-wayland-source", "xkbcommon"] }
calloop = "0.14"
calloop-wayland-source = "0.4"
tiny-skia = "0.12"
fontdue = "0.9"
image = { version = "=0.25.2", default-features = false, features = ["png", "jpeg", "webp"] }
resvg = "0.44"
rust-i18n = "3"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
wayland-egl = "0.32"
khronos-egl = { version = "6", features = ["dynamic"] }
glow = "0.17"
raw-window-handle = "0.6"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "lookup"
harness = false

673
LICENSE
View File

@@ -1,176 +1,501 @@
GNU LESSER GENERAL PUBLIC LICENSE
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Moe Ghoul>, 1 April 1990
Moe Ghoul, President of Vice
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.]
Preamble
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things.
To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others.
Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs.
When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library.
We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances.
For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system.
Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library.
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library.
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful.
(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.)
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library.
In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices.
Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange.
If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things:
a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place.
e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute.
7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above.
b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License.
11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License).
To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
one line to give the library's name and an idea of what it does.
Copyright (C) year name of author
This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in
the library `Frob' (a library for tweaking knobs) written
by James Random Hacker.
signature of Ty Coon, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!

53
Makefile Normal file
View File

@@ -0,0 +1,53 @@
REGISTRY ?= /usr/share/cargo/registry/ltk
DOCDIR ?= /usr/share/doc/libltk-doc/html
# Targets are recipes, not files. Listing them as PHONY tells make to
# ignore filesystem entries with the same name — without this `examples`
# silently no-ops because the `examples/` directory exists, and `doc`
# would do the same once `target/doc` is around.
.PHONY: all test doctest-md audit doc install examples clean distclean
all:
cargo build --release
test:
cargo test
# Typecheck the `no_run` snippets in docs/*.md by feeding each markdown
# file to `rustdoc --test`. Catches API drift in cookbook + widget
# reference: drops a renamed builder, removed function, etc. surface
# here just like `cargo test --doc` does for in-source doctests.
doctest-md:
./scripts/doctest-md.sh
audit:
@command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked
cargo audit
doc:
cargo doc --no-deps
install: doc
install -d $(REGISTRY)
cp -r src Cargo.toml liberux.toml $(REGISTRY)/
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
install -d $(DOCDIR)
cp -r target/doc/* $(DOCDIR)/
examples:
LTK_THEMES_DIR=themes cargo run --release --example showcase
LTK_THEMES_DIR=themes cargo run --release --example inputs
LTK_THEMES_DIR=themes cargo run --release --example scroll
LTK_THEMES_DIR=themes cargo run --release --example sliders
LTK_THEMES_DIR=themes cargo run --release --example combo
LTK_THEMES_DIR=themes cargo run --release --example mini_shell
LTK_THEMES_DIR=themes cargo run --release --example widgets
LTK_THEMES_DIR=themes cargo run --release --example pickers
clean:
dh_clean
cargo clean
rm -rf target
distclean: clean
rm -f Cargo.lock

375
README.md
View File

@@ -1,3 +1,376 @@
# ltk
Liberux ToolKit
`ltk` is a public Rust UI toolkit for Wayland applications.
It is developed by Liberux as part of the Eydos stack, where it powers
shell and application surfaces, but it is published as a reusable library for
third-party developers building their own Wayland software.
Being written in Rust is also part of the project's value proposition:
- memory safety without a garbage collector
- predictable resource lifetimes through ownership and borrowing
- good control over allocations and data movement in rendering-heavy code
- a strong fit for low-level UI, graphics, and system-integration work
For a Wayland toolkit, that combination is useful in practice: it reduces an
entire class of memory-management bugs common in lower-level UI stacks while
still allowing tight control over performance-sensitive paths.
## What It Is
`ltk` is a lightweight, declarative toolkit with an Elm-shaped model:
- implement `App`
- return an `Element<Msg>` tree from `view()`
- update your state in `update()`
- run the event loop with `ltk::run(app)`
The runtime handles layout, drawing, input dispatch, focus, overlays, and
backend selection between GLES and software rendering.
## What It Is Not
`ltk` is not:
- a browser UI toolkit
- a cross-platform desktop toolkit
- a general-purpose web-style framework
Today it is specifically a Wayland toolkit. If you are building native Wayland
applications, panels, launchers, lock screens, or other shell-adjacent
surfaces, it is in scope. If you need Windows, macOS, or browser targets, it
is not.
## Project Status
`ltk` is a public library intended for third-party use, but it is still shaped
by real production needs inside the Liberux / Eydos ecosystem.
That means:
- the API is usable for external applications today
- the project is optimized first for native Wayland workloads
- some advanced APIs are still more shell-oriented than app-oriented
- public documentation and examples are present, but the project is not trying
to present itself as a cross-platform beginner toolkit
If you are evaluating `ltk` for a third-party application, the right mental
model is "public Wayland toolkit with production consumers" rather than
"experimental demo crate".
## Why Third Parties Might Use It
`ltk` is designed around a few practical goals:
- low idle wakeups and event-driven redraws
- partial redraws and damage tracking
- a simple declarative tree instead of retained widgets
- direct support for normal windows and layer-shell surfaces
- a runtime-free core (`ltk::core::UiSurface`) for embedding layout and drawing
without `ltk::run()`
This makes it especially relevant for:
- Wayland applications
- mobile-first Linux shells
- launchers and dashboards
- greeters and lock screens
- compositor-side or embedded UI surfaces
## Quick Start
Add `ltk` to your `Cargo.toml`:
```toml
[dependencies]
ltk = { path = "../ltk" }
```
Minimal app:
```rust
use ltk::{ App, Element, button, column, spacer, text };
#[derive(Clone)]
enum Msg
{
Increment,
}
struct CounterApp
{
value: u32,
}
impl App for CounterApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column::<Msg>()
.padding( 32.0 )
.spacing( 16.0 )
.center_y( true )
.push( text( "Hello from ltk" ).size( 28.0 ) )
.push( text( format!( "Count: {}", self.value ) ).size( 18.0 ) )
.push( spacer() )
.push( button( "Increment" ).on_press( Msg::Increment ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Increment => self.value += 1,
}
}
}
fn main()
{
ltk::run( CounterApp { value: 0 } );
}
```
## Requirements
`ltk` currently assumes:
- **Rust 1.85** or newer (the toolchain shipped with Debian stable; declared
as `rust-version` in `Cargo.toml`).
- A running **Wayland** session — there is no X11 backend.
- System headers for `libwayland`, `libegl` and `libxkbcommon` at compile
time. On Debian / Ubuntu:
```bash
sudo apt-get install libwayland-dev libegl-dev libxkbcommon-dev pkg-config
```
- A usable system font (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`,
…). If none is installed `ltk` falls back to an embedded Sora Regular
build with a stderr warning.
- A theme named `default`, installed system-wide (the
`ltk-theme-default` Debian package drops it under
`/usr/share/ltk/themes/default/`) or exposed through
`LTK_THEMES_DIR` for development.
Rendering backend selection is automatic:
- **GLES** when EGL is available (every modern Wayland compositor).
- **Software** fallback otherwise.
- Set `LTK_FORCE_SOFTWARE=1` to force the software path even when EGL is
available — useful for headless test runs and for diagnosing
driver-specific bugs.
For development inside this repository:
```bash
export LTK_THEMES_DIR="$PWD/themes"
cargo run --example showcase
```
## Examples
Useful entry points in this repository:
- `cargo run --example showcase`
- `cargo run --example widgets`
- `cargo run --example inputs`
- `cargo run --example scroll`
- `cargo run --example mini_shell`
In general:
- start with `showcase` for a regular app window
- use `widgets` to see the core controls
- use `mini_shell` if you need overlays, theme switching, or shell-style
composition
## Public API Overview
Most applications should start with this subset:
- `App`
- `Element<Msg>`
- widgets such as `button`, `text`, `text_edit`, `image`
- layouts such as `column`, `row`, `stack`, `grid`, `spacer`
- `Color`
- `run`
More advanced APIs are available when needed:
- `overlays()`
- `shell_mode()` and layer-shell controls
- `set_channel_sender()` and `poll_external()`
- gesture hooks such as `on_swipe_*`
- `core::UiSurface`
- runtime theme APIs
## Windows and Shell Surfaces
By default, `ltk` creates a regular `xdg-shell` window.
That is the right starting point for:
- normal applications
- internal tools
- prototypes
Switch to layer-shell only when you are building shell surfaces such as:
- top bars
- docks
- homescreens
- notifications
- greeters
- lock screens
## Performance Notes
`ltk` is designed to sleep when idle and redraw only on real work.
The main rules for downstream applications are:
- keep `view()` pure and cheap
- do not perform I/O inside `view()`
- use `poll_interval()` sparingly
- return `true` from `is_animating()` only while something is actually moving
- cache decoded images and expensive derived state in your app
The library already provides:
- event-driven redraw scheduling
- per-surface invalidation
- partial redraws for interaction-only changes
- GPU and software backends behind the same widget API
## Backend Differences
The public API is the same across backends, but visual parity is not perfect
yet. The widget tree, layout, hit-testing, text, images, fills, strokes,
clipping and gradients all paint identically on both paths. The gap is in the
shadow / backdrop pipeline.
Effects that currently render only on the **GLES** backend, and are silent
no-ops on the **Software** backend:
- **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that
declare a `Shadow` slot show the soft halo on GLES and a flat fill on
software.
- **Inner / inset shadows** (`Canvas::fill_shadow_inset`) — `InsetShadow`
slots paint nothing on software.
- **Inset shadow blend modes** — `PlusLighter`, `Multiply`, `Screen` and
`Overlay` are GLES-only; the GLES `Overlay` path snapshots the framebuffer
and computes the CSS Overlay formula in-shader, which has no software
equivalent today.
Calls to these APIs are safe on both backends — they simply produce a flatter
appearance under software. No widget panics, returns an error, or skips
unrelated drawing.
If your application leans heavily on shadows or inset effects, validate both
rendering paths before shipping. Force the software path with:
```bash
LTK_FORCE_SOFTWARE=1 cargo run --example showcase
```
Closing this gap (porting the shadow / inset-shadow pipeline to tiny-skia) is
on the post-v0.1 roadmap.
## Documentation
| File | When to read it |
| --- | --- |
| [`docs/onboarding.md`](docs/onboarding.md) | First hour with the library — environment, first app, what to ignore at first. |
| [`docs/architecture.md`](docs/architecture.md) | Runtime model, overlays, animation, theming, performance and where the cost of a frame lives. |
| [`docs/widgets.md`](docs/widgets.md) | Per-widget catalogue: what each one is, when to use it, minimal example, see-also. |
| [`docs/theming.md`](docs/theming.md) | JSON theme schema, slot conventions, runtime APIs. |
| [`docs/cookbook.md`](docs/cookbook.md) | Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without `ltk::run`. |
| `cargo doc --open` | Per-item rustdoc for the public API. |
| [`SECURITY.md`](SECURITY.md) | How to report a vulnerability and what is in / out of scope. |
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Build, test, code style, patch shape. |
Recommended reading order for a new contributor:
1. run `examples/showcase.rs`
2. read `docs/onboarding.md`
3. browse `docs/widgets.md` for the catalogue
4. dip into `docs/cookbook.md` when you hit a specific shape
5. open `docs/architecture.md` once you need overlays, animations, or
runtime theming.
## Relationship to Liberux and Eydos
Liberux is the promoter and primary maintainer of `ltk`.
The project exists because Eydos needs a native Wayland toolkit for its
own shell and application stack, but `ltk` is intentionally published as a
public library rather than kept as a private internal component. Third-party
developers are part of the intended audience.
That origin matters because it explains the current priorities:
- strong Wayland focus
- support for layer-shell and shell-style overlays
- attention to mobile power usage
- theming and runtime surfaces that fit an operating system environment
## License
This project is licensed under `LGPL-2.1-only`.
That means third parties can use `ltk` in their own applications, including
proprietary ones, subject to the obligations of the GNU Lesser General Public
License v2.1. If you are planning a commercial or closed-source product, read
the license text carefully and make sure your distribution model complies with
it.
See [LICENSE](./LICENSE).
## Third-party assets
`ltk`'s default theme bundles two third-party asset sets that travel under
their own licences. Anyone redistributing the toolkit (or a binary that
embeds the default theme) must propagate the attributions below.
- **Symbolic icons** under `themes/default/icons/catalogue/` come from
Streamline's *Core Line Free* set, distributed under
[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/)
(CC BY 4.0). © Streamline. Some files have been modified for the
symbolic-tinting pipeline; details in
[`themes/default/icons/catalogue/LICENSE.md`](./themes/default/icons/catalogue/LICENSE.md).
Upstream: <https://www.streamlinehq.com/icons/core-line-free>.
- **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) is the
embedded font fallback, distributed under
[SIL Open Font Licence 1.1](https://scripts.sil.org/OFL).
© 2019-2020 The Sora Project Authors, Jonathan Barnbrook, Julián Moncada.
Upstream: <https://github.com/sora-xor/sora-font>.
The remaining artwork in the default theme — wallpapers, lockscreens,
launcher logo, brand-mark variants and per-application icons — is
original to Liberux Labs and travels under the toolkit's own
`LGPL-2.1-only` licence.
The full Debian-style declaration of every asset and its licence lives
in [`debian/copyright`](./debian/copyright); that is the file the `.deb`
ships under `/usr/share/doc/libltk*/copyright`.
## Contributing
Patches and bug reports are welcome. Read
[`CONTRIBUTING.md`](CONTRIBUTING.md) for the practical mechanics: build
prerequisites, how to run tests, the project's Modified Allman code
style, and what shape a pull request should take.
For security-sensitive issues see [`SECURITY.md`](SECURITY.md) — please
do not file those through the public issue tracker.
If you are evaluating `ltk` for a third-party product and are unsure
whether your use case is in scope, open a discussion before writing
code. That is especially useful when you are:
- missing an app-facing example,
- blocked by a shell-oriented assumption in the API,
- trying to understand whether a given platform target is realistic.

127
SECURITY.md Normal file
View File

@@ -0,0 +1,127 @@
# Security policy
`ltk` is a UI toolkit for Wayland clients. It runs inside the trust boundary
of the application that links it: the toolkit decodes images, parses theme
JSON, lays out widget trees and drives a GLES / software renderer. A bug in
any of those paths can crash the host application or, in worst-case, expose
information that a co-tenant Wayland client should not have.
This document describes how to report a vulnerability and what we consider in
or out of scope.
## Supported versions
`ltk` is in active development. Security fixes land on the latest released
minor version on crates.io and on the `master` branch in this repository.
Older minor versions are not patched.
## Reporting a vulnerability
Email **info@liberux.net** with:
- a description of the issue,
- the smallest reproducer you can share (Rust source preferred),
- the `ltk` version and Rust toolchain you used,
- whether the issue is exploitable today or requires a follow-up condition.
Please do **not** open a public GitHub issue, pull request, or discussion for
unpatched vulnerabilities. We aim to acknowledge reports within 5 working
days and ship a fix within 30 days for high-severity issues. If you do not
hear back, escalate by sending a follow-up email.
You may use PGP if you prefer — request the current public key in your first
message and we will reply with it before sharing any sensitive details.
## In scope
- memory-safety bugs reachable from any public API on the `ltk` crate root
or its `core::UiSurface` runtime-free surface,
- panics or undefined behaviour triggered by a theme JSON document, a font
file, or an image buffer that an application would reasonably accept from
user content (e.g. a wallpaper picker, a notification icon),
- denial-of-service through unbounded memory growth in caches or layout
recursion that the toolkit performs on behalf of the application,
- credential leaks in widgets that expose a `secure` mode (currently
`TextEdit`) — for example, plaintext password residue in heap dumps after
the widget is dropped.
## Out of scope
- bugs that require a malicious Wayland compositor: ltk treats the
compositor as part of the trusted computing base and does not defend
against compositor-level spoofing, layer-shell privilege misuse, or
protocol-level attacks,
- bugs that require an attacker to set environment variables on the user's
process (`LTK_THEMES_DIR`, `LTK_FORCE_SOFTWARE`, `XDG_DATA_HOME`):
ltk is not designed to run inside setuid binaries or anywhere the env is
not under the user's control,
- bugs in third-party dependencies (`tiny-skia`, `fontdue`, `image`,
`smithay-client-toolkit`, …): report those upstream. We will track the
fix and bump the dependency in a release note.
## Hardening features
- `TextEdit::secure( true )` zeroizes the underlying string buffer on drop
via `secure_mem::secure_zero` (volatile writes + a `compiler_fence` so
the optimiser cannot elide the wipe). The guarantee covers two copies
the runtime owns: the `TextEdit` itself and the per-frame
`WidgetHandlers::TextEdit` snapshot the layout pass produces for input
dispatch — both implement `Drop` and run the wipe before the
allocation returns to the allocator. The next `view()` rebuild
replaces the previous frame's snapshot, so the typical lifecycle is
"one frame on the heap, then overwritten on drop". Single-line and
multiline are mutually exclusive with secure (passwords have no line
breaks); the text-input-v3 IME path is also skipped in secure mode so
preedit / commit strings never reach the compositor's IME stack.
### What `TextEdit::secure` does **not** cover
The wipe only reaches buffers ltk allocated. Callers retain
responsibility for everything outside the widget tree:
- **Application-owned `String`s.** `text_edit( "Pwd", &self.password )`
only borrows `self.password`; the credential lives on the consumer's
struct. Wipe it explicitly (via `secure_mem::secure_zero` on
`password.as_bytes_mut()`) once the auth handshake completes, or put
the credential in a wrapper type with a `Drop` impl that does the
wipe.
- **`on_change` callback copies.** Each keystroke fires a closure with
a fresh `String` clone of the current value. If the closure stores
or forwards that value (e.g. to a worker thread for PAM), each
stored copy is the consumer's responsibility — ltk no longer owns
it past the closure call.
- **OS-level disclosure surfaces.** Swap, hibernation images, and
core dumps are outside any user-space wipe's reach. For threat
models that require resistance to these, link an `mlock`-aware
allocator, restrict the process's `PR_SET_DUMPABLE` to `0`, and
consider running on a no-swap mount. ltk itself does not call
`mlock` because the buffers it owns are short-lived (single frame)
and `mlock`-ing them per-frame would dominate the cost without
materially improving the threat model.
- **Compositor-side state.** The compositor sees pointer / keyboard
events for the surface but never the rendered glyphs (since
`secure` paints bullets). Custom IMEs that the user has installed
can still observe keystrokes — that is an OS-level concern outside
ltk's trust boundary.
Login / lock-screen consumers should pair `TextEdit::secure` with an
explicit wipe of their own state on the success path. The
[`docs/cookbook.md`](./docs/cookbook.md) "Password field with PAM
submit" recipe shows the canonical shape: clear `self.password` after
spawning the worker thread so the in-flight clone in the worker is
the only remaining copy, and let the worker's drop-on-completion run
its own wipe.
- `draw_image_data` (both backends) refuses to upload a buffer whose length
does not match `width × height × 4`. The mismatch path logs once and
draws nothing.
- Glyph caches and gradient stops have soft caps to bound memory growth
under unusual content. Exceeding the cap drains older entries (glyphs)
or truncates the input (gradient stops) with a stderr warning.
## Supply chain
ltk targets the Rust toolchain shipped with Debian stable (currently 1.85).
Direct dependencies are pinned at the minor-version level except `image`,
which is pinned to an exact version because the crate's MSRV and feature
surface change between patch releases. We monitor RustSec advisories
manually; running `cargo audit` in your own CI is recommended.

1
TODO Normal file
View File

@@ -0,0 +1 @@
- Backend winit + wgpu

150
benches/lookup.rs Normal file
View File

@@ -0,0 +1,150 @@
//! Per-frame dispatch hot path benches.
//!
//! The renderer rebuilds the widget tree on every frame, then hit-tests
//! pointer events against the resulting flat `Vec<LaidOutWidget>` and
//! looks up handlers by `flat_idx`. Both calls are O(N) in the slice
//! length. Run with `cargo bench --bench lookup`.
use criterion::{ black_box, criterion_group, criterion_main, BenchmarkId, Criterion };
use ltk::test_support::{ find_handlers, find_widget, find_widget_at, LaidOutWidget, WidgetHandlers };
use ltk::{ Point, Rect };
// ── Fixture builders ──────────────────────────────────────────────────────────
/// Build N widgets laid out in a vertical strip. Each widget is 100×30 with a
/// 1px gap, so `find_widget_at(x=50, y=k*31+15)` lands inside widget `k`. Half
/// of them carry a Button handler so `find_handlers` exercises the
/// match-and-clone path on roughly every other lookup.
fn build_widgets( n: usize ) -> Vec<LaidOutWidget<()>>
{
( 0 .. n ).map( |i|
{
let rect = Rect
{
x: 0.0,
y: ( i as f32 ) * 31.0,
width: 100.0,
height: 30.0,
};
let handlers = if i % 2 == 0
{
WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false }
} else {
WidgetHandlers::None
};
LaidOutWidget
{
rect,
flat_idx: i,
id: None,
paint_rect: rect,
handlers,
keyboard_focusable: true,
}
} ).collect()
}
// ── Benches ───────────────────────────────────────────────────────────────────
/// Pointer hit testing on a hot path. Three sub-benches:
/// - last: hit the last widget (worst case under reverse iteration is the
/// *first* widget; the helper iterates from the end to honour
/// "topmost wins", so the last widget in slice order is the cheap
/// one and the first is the expensive one).
/// - first: hit the first widget — full-slice walk.
/// - miss: point that lies outside every rect — also a full-slice walk
/// but never returns early.
fn bench_find_widget_at( c: &mut Criterion )
{
let mut group = c.benchmark_group( "find_widget_at" );
for &n in &[ 10usize, 100, 1000 ]
{
let widgets = build_widgets( n );
// Hit on the last widget in slice order — `find_widget_at` walks in
// reverse, so this returns on the first iteration.
let last_y = ( n as f32 - 1.0 ) * 31.0 + 15.0;
group.bench_with_input( BenchmarkId::new( "hit_last", n ), &n, |b, _|
{
b.iter( ||
{
let p = Point { x: 50.0, y: last_y };
black_box( find_widget_at( black_box( &widgets ), black_box( p ) ) )
} );
} );
// Hit on the first widget — full reverse walk.
group.bench_with_input( BenchmarkId::new( "hit_first", n ), &n, |b, _|
{
b.iter( ||
{
let p = Point { x: 50.0, y: 15.0 };
black_box( find_widget_at( black_box( &widgets ), black_box( p ) ) )
} );
} );
// Miss — point sits to the right of every rect.
group.bench_with_input( BenchmarkId::new( "miss", n ), &n, |b, _|
{
b.iter( ||
{
let p = Point { x: 9_999.0, y: 9_999.0 };
black_box( find_widget_at( black_box( &widgets ), black_box( p ) ) )
} );
} );
}
group.finish();
}
/// `flat_idx` lookup. Linear scan; we measure the worst case (last entry)
/// since it's what bounds latency on real frames.
fn bench_find_widget( c: &mut Criterion )
{
let mut group = c.benchmark_group( "find_widget" );
for &n in &[ 10usize, 100, 1000 ]
{
let widgets = build_widgets( n );
let target = n - 1;
group.bench_with_input( BenchmarkId::new( "last", n ), &n, |b, _|
{
b.iter( ||
{
black_box( find_widget( black_box( &widgets ), black_box( target ) ) )
} );
} );
}
group.finish();
}
/// Same shape as `find_widget` but exercises the handler-cloning path on hit.
/// Useful as a separate measurement because the clone cost grows with the
/// handler payload, even though the scan itself does not.
fn bench_find_handlers( c: &mut Criterion )
{
let mut group = c.benchmark_group( "find_handlers" );
for &n in &[ 10usize, 100, 1000 ]
{
let widgets = build_widgets( n );
let target = n - 1;
group.bench_with_input( BenchmarkId::new( "last", n ), &n, |b, _|
{
b.iter( ||
{
black_box( find_handlers( black_box( &widgets ), black_box( target ) ) )
} );
} );
}
group.finish();
}
criterion_group!( benches, bench_find_widget_at, bench_find_widget, bench_find_handlers );
criterion_main!( benches );

169
code_style_guide.md Executable file
View File

@@ -0,0 +1,169 @@
# Code Style Guide
This project follows a **Modified Allman** brace and formatting style.
It prioritizes **visual clarity, symmetry, and logical separation of blocks** over strict line compactness.
---
## Overview
### Key Principles
- Indentation with **tabs** (width = 4).
- **Opening braces `{`** always start on a new line.
- **`} else {`** and **`} catch {`** appear on the same line for compact flow.
- **Spaces inside parentheses only when non-empty:**
- `( arg1, arg2 )`
- `main()`
- **Classes and structs:** PascalCase
- **Variables, objects, and functions:** camelCase
- **Constants/macros:** UPPER_CASE_WITH_UNDERSCORES
---
## Examples
### `if / else` structure
```cpp
if ( condition )
{
doSomething();
} else {
doSomethingElse( arg1, arg2 );
}
```
### `try / catch` structure
```cpp
try
{
runOperation();
} catch ( const std::exception& ex ) {
handleError( ex );
}
```
### Class and method naming
```cpp
class DataAnalyzer
{
public:
DataAnalyzer( const std::string& filePath )
: filePath( filePath )
{
}
void processDataAndGenerateReport()
{
try
{
loadDataFromFile( filePath );
analyzeResultsAndPrintSummary();
} catch ( const std::runtime_error& error ) {
std::cerr << "Error: " << error.what() << std::endl;
}
}
private:
std::string filePath;
void loadDataFromFile( const std::string& path )
{
if ( path.empty() )
{
throw std::runtime_error( "File path cannot be empty." );
} else {
std::cout << "Loading data from: " << path << std::endl;
}
}
void analyzeResultsAndPrintSummary()
{
std::cout << "Analyzing data..." << std::endl;
std::cout << "Summary: OK" << std::endl;
}
};
```
### `main()` function
```cpp
int main()
{
DataAnalyzer analyzer( "results.csv" );
try
{
analyzer.processDataAndGenerateReport();
} catch ( const std::exception& ex ) {
std::cerr << "Unhandled exception: " << ex.what() << std::endl;
}
return 0;
}
```
---
## `.clang-format` configuration
Save this file as `.clang-format` in your project root:
```yaml
# === Style: Modified Allman (tabs, compact else/catch, and selective parentheses spacing) ===
# Naming conventions:
# · Classes and structs: PascalCase (e.g., MyClass, PlayerController)
# · Variables, objects, and functions: camelCase (e.g., myVariable, calculateScore)
# · Constants or macros: UPPER_CASE_WITH_UNDERSCORES
# · if/else and try/catch share the same brace layout:
# if ( condition )
# {
# ...
# } else {
# ...
# }
# try
# {
# ...
# } catch ( const std::exception& e ) {
# ...
# }
BasedOnStyle: LLVM
IndentWidth: 4
UseTab: Always
BraceWrapping:
AfterClass: true
AfterControlStatement: true
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterStruct: true
AfterUnion: true
BeforeCatch: false
BeforeElse: false
IndentBraces: false
SplitEmptyFunction: false
SplitEmptyRecord: false
SplitEmptyNamespace: false
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
SpaceBeforeParens: Always
SpaceInEmptyParentheses: false
SpaceInParens: true
SpacesInAngles: false
SpacesInCStyleCastParentheses: true
SpacesInSquareBrackets: false
KeepEmptyLinesAtTheStartOfBlocks: true
ColumnLimit: 0
AlignConsecutiveAssignments: true
AlignConsecutiveDeclarations: true
AlignEscapedNewlines: Left
AlignOperands: true
AlignTrailingComments: true
BreakBeforeBraces: Allman

1
debian/cargo-checksum.json vendored Normal file
View File

@@ -0,0 +1 @@
{"package":null,"files":{}}

5
debian/changelog vendored Normal file
View File

@@ -0,0 +1,5 @@
ltk (0.1.0-1) unstable; urgency=low
* Initial Debian packaging.
-- Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net> Tue, 10 Mar 2026 00:00:00 +0200

59
debian/control vendored Normal file
View File

@@ -0,0 +1,59 @@
Source: ltk
Section: libdevel
Priority: optional
Maintainer: Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net>
Build-Depends: debhelper-compat (= 13), libxkbcommon-dev
Rules-Requires-Root: no
Standards-Version: 4.7.0
Homepage: https://liberux.net
Package: libltk-dev
Architecture: all
Multi-Arch: foreign
Depends: ${misc:Depends}
Suggests: libltk-doc
Description: Liberux ToolKit — Rust UI toolkit (development files)
ltk is the UI toolkit for the Liberux desktop. It provides widgets
(buttons, text inputs, labels, images), layout containers (column, row,
stack) and a Wayland event loop built on smithay-client-toolkit,
tiny-skia and fontdue.
.
This package contains the Rust crate sources required to build projects
that depend on ltk.
Package: libltk-doc
Architecture: all
Multi-Arch: foreign
Section: doc
Depends: ${misc:Depends}
Description: Liberux ToolKit — API documentation
HTML reference documentation for the ltk Rust crate, generated by
cargo doc. Covers the App trait, all widgets, layout containers and
primitive types.
.
Browse the docs at /usr/share/doc/libltk-doc/html/ltk/index.html.
Package: ltk-theme-default
Architecture: all
Multi-Arch: foreign
Section: misc
Provides: ltk-theme
Depends: ${misc:Depends}, fonts-sora, fonts-noto-core, fonts-noto-cjk
Description: Liberux ToolKit — default Eydos theme assets
Wallpapers, lockscreen images and theme.json shipped as the Eydos
shell's default theme. Installed under /usr/share/ltk/themes/default/
where the Liberux shells (crustace, loginmanager, …) pick them up at
startup via ltk::ThemeDocument::find("default"). Override the search
root with the LTK_THEMES_DIR environment variable for in-tree
development.
.
Depends on fonts-sora rather than re-shipping the Sora typeface so we
stay aligned with Debian policy on duplicate file content. The
theme.json references the OTFs at their system-wide install path
(/usr/share/fonts/opentype/sora/Sora-{Light,Regular,SemiBold,Bold}.otf);
fontdue reads them directly so widgets get the four canonical weights
(300, 400, 600, 700) without going through fontconfig.
.
Provides the virtual package ltk-theme, so any Liberux app that
Depends on ltk-theme can be satisfied by installing this package (or
any other implementer, e.g. ltk-theme-<alternative>).

198
debian/copyright vendored Normal file
View File

@@ -0,0 +1,198 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: ltk
Upstream-Contact: Liberux Labs, S. L. <info@liberux.net>
Source: https://liberux.net
Files: *
Copyright: 2026 Liberux Labs, S. L. <info@liberux.net>
License: LGPL-2.1-only
Files: debian/*
Copyright: 2026 Liberux Labs, S. L. <info@liberux.net>
License: LGPL-2.1-only
Files: themes/default/branding/*
themes/default/icons/apps/*
themes/default/icons/app-default.svg
Copyright: 2026 Liberux Labs, S. L. <info@liberux.net>
License: LGPL-2.1-only
Comment:
Wallpapers, lockscreens, launcher logos (under
themes/default/branding/) and per-application icons (under
themes/default/icons/apps/, plus the `app-default.svg` fallback)
are original artwork by Liberux Labs, distributed under the same
LGPL-2.1-only terms as the rest of the toolkit. Raster variants
(`*.webp` under branding/{light,dark}/{wallpaper,lockscreen}/)
are pre-rendered from the canonical SVGs in the same directory
and inherit the same licence.
Files: themes/default/icons/catalogue/*
Copyright: 2024 Streamline (https://streamlinehq.com)
License: CC-BY-4.0
Comment:
Symbolic glyphs under themes/default/icons/catalogue/{filled,line}/
are sourced from Streamline's *Core Line Free* set, distributed
under Creative Commons Attribution 4.0 International. Some of the
SVG files have been modified from the upstream collection (typical
edits include viewBox normalisation, removal / normalisation of
inline fill / stroke so `theme::tint_symbolic` controls the final
colour at runtime, ID renames, and path simplification at small
chrome sizes); which files were touched and which were not is not
tracked per icon — treat any file in this directory as potentially
differing from the upstream release and fetch from upstream if a
pristine copy is required. This directory-wide declaration satisfies
CC BY 4.0 §3(a)(1)(B); the full text is in
themes/default/icons/catalogue/LICENSE.md. The runtime
`theme::tint_symbolic` pass is a separate transformation of the
rendered RGBA output and is not a modification of the on-disk SVGs.
Upstream collection: https://www.streamlinehq.com/icons/core-line-free
Attribution shorthand: "Icons by Streamline, CC BY 4.0".
Files: src/theme/fallback/Sora-Regular.otf
src/theme/fallback/Sora-LICENSE.txt
Copyright: 2019-2020 The Sora Project Authors
2020 Jonathan Barnbrook <jb@barnbrook.net>
2020 Julián Moncada <julian@moncada.work>
License: OFL-1.1
Comment:
Sora Regular is bundled as the last-resort font fallback used by
`Canvas::new` / `Canvas::new_gles` when no system font from the
candidate chain (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`,
…) is installed. The full OFL 1.1 text is reproduced in the
sibling `Sora-LICENSE.txt`. Upstream:
https://github.com/sora-xor/sora-font
License: LGPL-2.1-only
This library is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation; version 2.1 of the License.
.
This library is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
General Public License for more details.
.
On Debian systems, the full text of the GNU Lesser General Public
License version 2.1 can be found in the file
`/usr/share/common-licenses/LGPL-2.1'.
License: OFL-1.1
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
.
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
.
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
.
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
License: CC-BY-4.0
Creative Commons Attribution 4.0 International Public Licence (CC BY 4.0).
.
You are free to:
.
* Share — copy and redistribute the material in any medium or format.
* Adapt — remix, transform, and build upon the material for any
purpose, even commercially.
.
The licensor cannot revoke these freedoms as long as you follow the
licence terms.
.
Under the following terms:
.
* Attribution — You must give appropriate credit, provide a link to
the licence, and indicate if changes were made. You may do so in
any reasonable manner, but not in any way that suggests the
licensor endorses you or your use.
* No additional restrictions — You may not apply legal terms or
technological measures that legally restrict others from doing
anything the licence permits.
.
The full text of this licence is reproduced verbatim at:
https://creativecommons.org/licenses/by/4.0/legalcode
.
A human-readable summary (which is not a substitute for the licence) is
available at:
https://creativecommons.org/licenses/by/4.0/
.
The licence text above is a summary; the canonical legal text at the URL
is what governs use, distribution and modification of the Licensed
Material referenced under `Files: themes/default/icons/catalogue/*`.

1
debian/ltk-theme-default.install vendored Normal file
View File

@@ -0,0 +1 @@
themes/default usr/share/ltk/themes/

19
debian/rules vendored Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/make -f
include /usr/share/dpkg/pkg-info.mk
REGISTRY = debian/libltk-dev/usr/share/cargo/registry/ltk-$(DEB_VERSION_UPSTREAM)
DOCDIR = debian/libltk-doc/usr/share/doc/libltk-doc/html
%:
dh $@
override_dh_auto_configure:
override_dh_auto_build:
override_dh_auto_test:
override_dh_auto_install:
$(MAKE) install REGISTRY=$(REGISTRY) DOCDIR=$(DOCDIR)
override_dh_auto_clean:
$(MAKE) clean
dh_clean

1
debian/source/format vendored Normal file
View File

@@ -0,0 +1 @@
1.0

247
docs/architecture.md Normal file
View File

@@ -0,0 +1,247 @@
# ltk architecture
If you are new to the library, start with [`docs/onboarding.md`](./onboarding.md)
first. This document assumes you already know how to run an example and what
kind of application surface you are trying to build.
This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the [`App`] trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives.
For copy-pasteable patterns the canonical references are the two downstream consumers in the Eydos workspace:
- **crustace** (`crustace/src/`) — the Eydos shell. Layer-shell background surface + 8 overlays, system polling, MPRIS, notifications, animated OSD.
- **loginmanager** (`loginmanager/src/`) — greeter. `keyboard_exclusive`, single overlay, focus management, async PAM via `set_channel_sender`.
The rest of this document explains *why* those repos look the way they do.
If you are coming from `cargo doc`, keep the public API split in mind:
- `ltk::window` — normal application windows
- `ltk::shell` — layer-shell and overlays
- `ltk::runtime` — advanced runtime hooks and runtime-free embedding
This document mostly lives in the overlap between `ltk::shell` and
`ltk::runtime`. If you only want to build a plain app window, stay with
`docs/onboarding.md` and the `ltk::window` surface first.
## Mental model
ltk is Elm-shaped. The application is a value implementing [`App`]; ltk drives the loop and the application reacts.
Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element<Msg>` is rebuilt from scratch every frame from the application's own state.
This sounds expensive and is actually fine. The widget tree is plain enums, the layout pass is a single recursive walk that already has to happen anyway, and as of the `WidgetHandlers` snapshot work the input dispatch path no longer rebuilds the tree per event. The only thing the app must avoid in `view()` is *I/O* (reading files, scanning directories, walking icon caches) — keep those in `poll_external` or behind a `RefCell` cache.
In practice, that model is easiest to adopt in three steps:
1. Start with the `ltk::window` mental model: one app state, one `view()`, one `update()`, one normal window.
2. Add `ltk::shell` concepts only if you need layer-shell or overlays.
3. Reach for `ltk::runtime` hooks only when you need async wakeups, invalidation narrowing, or embedding outside `ltk::run()`.
## The trait surface, by purpose
`App` looks intimidating — most of it is opt-in. Group the methods by what you actually need:
**Always implement**
- `type Message` — your message enum.
- `view(&self) -> Element<Msg>` — main surface contents.
- `update(&mut self, msg: Msg)` — state transitions.
**Implement when your app is multi-surface**
- `overlays(&self) -> Vec<OverlaySpec<Msg>>` — see *Surface composition* below.
**Implement when your app is a shell component, not a window**
- `shell_mode()``ShellMode::Layer( Layer::Background | Bottom | Top | Overlay )`.
- `layer_anchor()`, `layer_size()`, `exclusive_zone()`, `keyboard_exclusive()` — the layer-shell knobs.
- `background_color()``Color::rgba( 0, 0, 0, 0 )` for transparent surfaces (panels, OSDs).
**Implement when external state matters**
- `set_channel_sender(sender)` — saved once at startup; clone into background threads to push messages into the loop without polling.
- `poll_external() -> Vec<Msg>` — called after every Wayland event *and* every `poll_interval()` tick. Drain receivers here.
- `poll_interval()``None` (event-driven only) or `Some( Duration )` (timer wakeups for clocks, expiry, etc.).
**Implement when input gestures matter**
- `on_swipe_up`, `on_swipe_down`, `on_swipe_progress`, `on_swipe_down_progress` (follow-the-finger).
- `on_tap` — taps that miss every widget.
- `on_key` / `on_key_with_modifiers` — global hotkeys.
- `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity.
**Implement for animations and focus**
- `is_animating()` — return `true` while a tween is running; the loop redraws at ~60 Hz.
- `take_focus_request()``Option<WidgetId>` — pull-once focus retargeting.
- `on_text_input_focused(active)` — surface IME state.
The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group.
Another way to read the trait is by API layer:
- `ltk::window`: `view`, `update`, plus the widgets/layouts you use to build the tree.
- `ltk::shell`: `shell_mode`, `layer_anchor`, `layer_size`, `exclusive_zone`, `keyboard_exclusive`, `overlays`.
- `ltk::runtime`: `set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`, and `core::UiSurface`.
That is the intended order of adoption for third-party users.
## Surface composition
The main surface is what `view()` paints. `overlays()` returns a `Vec<OverlaySpec<Msg>>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using [`OverlayId`]:
- Same id present last frame and this frame → keep the surface alive, only re-render its `view`.
- New id → create a new layer-shell surface.
- Id missing → destroy the surface.
This is why crustace declares stable `const OVERLAY_LAUNCHER: OverlayId = OverlayId(1)` etc. at the top of `app.rs`. Don't allocate ids dynamically — diffing relies on stability.
Each overlay carries its own `view`, `anchor`, `size`, `layer`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state.
`on_dismiss` is fired by three independent paths: a `popup_done` event from the compositor (xdg-popup mode); a pointer / touch press on the main surface that does not land on the trigger pointed at by `anchor_widget_id` while the overlay is mapped (covers compositors that route the button to the parent surface instead of breaking the popup grab); and Escape pressed while at least one xdg-popup overlay is open. The application only has to flip its `is_open` flag to `false` in `update()`; the runtime tolerates the message arriving more than once for the same open / close cycle.
Common patterns:
- *Modal panel*: `layer: Overlay`, `anchor: ALL`, `keyboard_exclusive: false`, `on_dismiss: Some( CloseMsg )`. Tap-outside dismisses; the panel itself centers via `column().push(spacer()).push(panel).push(spacer())`.
- *Pass-through OSD*: same as above but `input_region: Some(Vec::new())` so pointer events fall through to whatever is below.
- *Top bar / dock*: `layer: Top` or `Bottom`, `anchor: TOP`/`BOTTOM`, fixed `size`, non-zero `exclusive_zone` so app windows reflow around it. Usually returned from `view()` (single-purpose shell), not from `overlays()`.
- *Greeter / lock screen*: `shell_mode: Layer(Overlay)`, `keyboard_exclusive: true`. Loginmanager is the reference.
Overlays do not nest. A "submenu inside the quick settings panel" is just a second overlay with a different id whose `view()` builds the submenu. Crustace uses this for the WiFi and Bluetooth pickers.
If your application does not need overlays or layer-shell, you can ignore this
entire section and stay in the `ltk::window` subset.
## Theming
`ltk::theme` exposes a process-wide active theme. Three layers:
1. **Document** — a [`ThemeDocument`] loaded from disk (`/usr/share/ltk/themes/<id>/theme.json`). Each document carries a `light` and `dark` [`Mode`] with a typed [`SlotStore`] (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically.
2. **Mode** — [`ThemeMode::Light`] or `Dark`; flips which mode of the document is active.
3. **Active state**`ltk::active_document()` / `ltk::active_mode()` return the current pair. Per-slot shorthands (`ltk::theme_color`, `theme_paint`, `theme_shadows`, `theme_surface`, `theme_text_style`, `theme_palette`, `theme_window_controls`, `theme_wallpaper`, `theme_lockscreen`) cover the common patterns.
Inside a widget tree, read the palette through the per-slot helper:
```rust,no_run
# fn _ex() {
let _label = ltk::text( "Hello" )
.color( ltk::theme_palette().text_primary );
# }
```
To switch theme at runtime, dispatch a message that calls `ltk::set_active_mode( ThemeMode::Dark )` from `update()` and let the next frame re-resolve. There is no manual invalidation step.
Loading a different document:
```rust
let doc = ltk::ThemeDocument::find( "default" )
.expect( "default theme not installed (ltk-theme-default)" );
ltk::set_active_document( doc );
```
For dev iteration set `LTK_THEMES_DIR=/path/to/ltk/themes` so the lookup picks files in the working tree before the system path. The full search order is:
1. `LTK_THEMES_DIR/<id>/` when the env var is set
2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to `~/.local/share/ltk/themes/<id>/`)
3. `/usr/share/ltk/themes/<id>/`
Wallpapers ship as a single landscape PNG per variant. `ltk::WallpaperBundle::from_path_or_bytes( path, bundled_fallback )` handles the disk-or-builtin fallback, and `bundle.for_size( sw, sh )` returns the right crop for landscape *or* portrait surfaces — no need to ship two PNGs.
For many third-party apps, theming is optional at first. It is reasonable to
start with the default theme and come back to the runtime theme APIs later as
part of the `ltk::runtime` layer.
## Animations
The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`:
```rust,no_run
# struct App { toast: Option<()>, nav_progress: f32 }
# impl App {
fn is_animating( &self ) -> bool
{
self.toast.is_some() // an OSD is fading
|| self.nav_progress < 1.0 // a screen is sliding
}
# }
```
While `is_animating()` returns `true`, ltk redraws at ~60 Hz. Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value:
```rust,no_run
# use std::time::Instant;
# use ltk::Element;
# const TOAST_DURATION: f32 = 3.0;
# #[ derive( Clone ) ] enum Msg {}
# struct App { toast_started: Option<Instant> }
# impl App {
fn view( &self ) -> Element<Msg>
{
let progress = match self.toast_started
{
Some( t ) => ( t.elapsed().as_secs_f32() / TOAST_DURATION ).min( 1.0 ),
None => 0.0,
};
// … fade alpha = 1.0 - progress
# ltk::text( "" ).into()
}
# }
```
The end-of-animation cleanup belongs in `poll_external()`: when `progress >= 1.0` clear `self.toast_started` so `is_animating()` returns `false` and the loop sleeps again.
For follow-the-finger gestures use `on_swipe_progress(progress)` / `on_swipe_down_progress(progress)`. Those fire continuously during the drag with a `0.0..=1.0` value and don't require `is_animating` — the gesture itself drives the redraw.
For a basic application window, defer this whole area until the rest of the UI
is already working. Animation is part of the advanced runtime surface, not the
core onboarding path.
## Larger state patterns
A four-button demo can keep all state in one struct and one flat `Msg` enum. Anything bigger needs structure. Conventions used by crustace and loginmanager:
**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element<AppMsg>` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen.rs`, `launcher/`, `notifications.rs`, `powermenu.rs`.
**Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants.
**Ephemeral caches behind `RefCell` (single-threaded).** `view(&self)` is `&self`; if you need a mutable icon cache, scaled-image cache, layout cache, etc., wrap it in `RefCell<...>` on the app struct and `borrow_mut()` inside `view()`. Crustace's `IconCache` does exactly this. Don't reach for `Mutex` — the event loop is single-threaded.
**External state via channel + poll.** Anything that blocks (D-Bus, files, network, IPC) lives on a background thread. At startup save the `ChannelSender<Msg>` from `set_channel_sender`, hand a clone to the worker, and have the worker push messages back. `poll_external()` is the place for non-blocking `try_recv()` against in-process receivers (e.g. `mpsc`/`crossbeam` channels) or for expiry checks like "is this notification past its TTL".
**Stable widget ids only when you need to programmatically focus them.** `WidgetId` is an opt-in tag on a widget that pairs with `App::take_focus_request()`. Don't decorate every widget; tag the one input you want to autofocus on screen entry.
Again, the simplest progression is:
1. one flat app state in `ltk::window`
2. sub-state and overlays once the app becomes shell-like
3. caches, channels, focus retargeting, and cross-surface invalidation only when scale requires them
## Performance
The cheap things and the expensive things, in rough order:
- *Cheap*: building the `Element<Msg>` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes.
- *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks.
- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc<ThemeDocument>` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the eight canonical palette fields.
- *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up.
- *Avoid in `view()`*: cloning large `Vec<u8>` image buffers. `img_widget` takes an `Arc<Vec<u8>>`; build the `Arc` once at load time and clone *the Arc*, not the bytes.
- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target.
- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`.
- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper.
- *GPU vs software*: the GLES path is selected automatically when EGL is available; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application.
When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished.
## Where to look in the consumer repos
| Pattern | File |
| --- | --- |
| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, lines ~250380) |
| Background poller + channel sender | `crustace/src/app.rs` (`set_channel_sender`, `poll_external`) |
| Sub-module per screen | `crustace/src/{homescreen.rs, notifications.rs, powermenu.rs, launcher/}` |
| Cached icon loading via `RefCell` | `crustace/src/launcher/icon_cache.rs` and use sites in `app.rs` |
| OSD overlay with auto-expiry | `crustace/src/app.rs` (`show_osd`, `build_osd`, `OSD_TIMEOUT_SECS`) |
| `keyboard_exclusive` + `take_focus_request` | `loginmanager/src/main.rs` |
| Theme on disk (slot-typed JSON) | `ltk/themes/default/theme.json`, `ltk::ThemeDocument::find` |
For a self-contained example that exercises overlays, theme switching, and animation in one ~300-line file, see `examples/mini_shell.rs`.

825
docs/cookbook.md Normal file
View File

@@ -0,0 +1,825 @@
# ltk cookbook
Concrete recipes for patterns that come up often when building real
applications and shells with `ltk`. Each recipe pairs a sketch of the
problem with a copy-pasteable shape of the solution and pointers to the
relevant APIs.
If you are looking for the mental model, read
[`docs/onboarding.md`](./onboarding.md) and
[`docs/architecture.md`](./architecture.md) first. For per-widget
reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
[`docs/theming.md`](./theming.md).
## Table of contents
- [Slide-in panel](#slide-in-panel)
- [Password field with PAM submit](#password-field-with-pam-submit)
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
- [Runtime light / dark theme toggle](#runtime-light--dark-theme-toggle)
- [Icon launcher with WrapGrid](#icon-launcher-with-wrapgrid)
- [Channel-driven external state](#channel-driven-external-state)
- [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry)
- [Tab navigation between widgets](#tab-navigation-between-widgets)
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
---
## Slide-in panel
A quick-settings or notification panel that slides down from the top of
the screen and dissolves into transparency at its leading edge so the
edge does not knife-cut against the layer below.
```rust,no_run
# use std::time::Instant;
# use ltk::{ container, text, viewport, Anchor, Element, Layer, OverlayId, OverlaySpec };
# const OVERLAY_QS: OverlayId = OverlayId( 1 );
# const SLIDE_DURATION: f32 = 0.25;
# #[ derive( Clone ) ] enum Msg { CloseQs }
# struct App { qs_started: Option<Instant>, surface_width: u32, surface_height: u32 }
# impl App {
# fn quick_settings_view( &self ) -> Element<Msg> { text( "qs" ).into() }
fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
{
// Compute the slide progress based on a stored start instant. While
// animating, `is_animating()` returns `true` so the runtime redraws
// at ~60 Hz and reads the new progress every frame.
let progress = match self.qs_started
{
Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ),
None => 1.0,
};
let panel_height = self.surface_height as f32 * 0.85;
let visible_h = panel_height * progress;
// Feather the bottom edge during the slide; drop the fade once the
// panel is fully open so the bottom of a settled panel is hard.
let fade_px = if progress < 1.0 { 16.0 } else { 0.0 };
let panel: Element<Msg> = container( self.quick_settings_view() )
.surface( "surface-card" )
.padding( 24.0 )
.into();
OverlaySpec
{
id: OVERLAY_QS,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( self.surface_width, visible_h as u32 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: viewport( panel )
.height( panel_height )
.fade_bottom( fade_px )
.into(),
on_dismiss: Some( Msg::CloseQs ),
anchor_widget_id: None,
}
}
fn is_animating( &self ) -> bool
{
self.qs_started
.map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION )
.unwrap_or( false )
}
# }
```
The `fade_bottom( px )` builder is a GLES-only effect; the software
backend renders a hard edge. If your shell must look identical on both
backends, branch on [`ltk::is_software_render()`] and skip the fade
when the software path is active.
**See also**: [`Viewport`](./widgets.md#viewport),
[`OverlaySpec`](../src/app.rs).
---
## Password field with PAM submit
A login screen where the user types a password, presses Enter, and the
app forwards the submission to a background thread that runs PAM.
```rust,no_run
# use ltk::{ button, column, text, text_edit, App, ChannelSender, Element };
# #[ derive( Clone ) ] enum Msg {
# UsernameChanged( String ), PasswordChanged( String ),
# Submit, AuthResult( bool ),
# }
# fn pam_authenticate( _user: &str, _pass: &str ) -> bool { true }
struct LoginApp
{
username: String,
password: String,
sender: Option<ChannelSender<Msg>>,
}
impl App for LoginApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "Sign in" ).size( 28.0 ) )
.push(
text_edit( "Username", &self.username )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.secure( true ) // mask glyphs + zeroize on drop
.on_change( |s| Msg::PasswordChanged( s ) )
.on_submit( Msg::Submit ), // Enter fires this
)
.push( button( "Log in" ).on_press( Msg::Submit ) )
.into()
}
fn set_channel_sender( &mut self, s: ChannelSender<Msg> )
{
// Saved once at startup; cloned into worker threads so they can
// wake the loop without polling.
self.sender = Some( s );
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::UsernameChanged( s ) => self.username = s,
Msg::PasswordChanged( s ) => self.password = s,
Msg::Submit =>
{
let username = self.username.clone();
let password = self.password.clone();
let sender = self.sender.clone().unwrap();
std::thread::spawn( move ||
{
let result = pam_authenticate( &username, &password );
let _ = sender.send( Msg::AuthResult( result ) );
} );
// Clear the visible field so the user has feedback;
// `secure( true )` zeroizes the buffer when the next
// view() rebuild drops the old TextEdit.
self.password.clear();
}
Msg::AuthResult( true ) => std::process::exit( 0 ),
Msg::AuthResult( false ) => { /* show error */ }
}
}
}
```
`secure( true )` does two things:
1. Renders bullets instead of the actual characters.
2. Wipes the underlying byte buffer with zeroes when the
[`TextEdit`](./widgets.md#text_edit) (and the per-frame handler
snapshot) is dropped — the heap allocation that used to hold the
credential is overwritten before it returns to the allocator.
`text_edit::on_submit` fires when the user presses Enter inside the
field; it is wired to the same `Msg::Submit` the explicit button uses
so both keyboard and pointer paths converge on the same `update` arm.
The cleanup of `self.password` is the application's responsibility once
authentication completes. `ltk` cannot guarantee a clean memory image
on its own — for the strongest guarantees clear long-lived state in
your `Drop` impl too.
**See also**: [`SECURITY.md`](../SECURITY.md), the
[`text_edit`](./widgets.md#text_edit) widget, and
[`crate::ChannelSender`](../src/app.rs).
---
## Swipe-to-dismiss overlay
A modal panel that closes when the user swipes down past a threshold or
taps outside the panel.
```rust,no_run
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, OverlayId, OverlaySpec };
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
# #[ derive( Clone ) ] enum Msg { CloseModal }
# struct App { modal_open: bool, modal_drag_progress: f32 }
# impl App {
# fn modal_body( &self ) -> Element<Msg> { text( "modal" ).into() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
if !self.modal_open { return vec![]; }
// The modal body sits inside a column capped at 400 px so it stays
// legible on wide displays; the outer column with two spacers
// centres it vertically.
let modal: Element<Msg> = column()
.max_width( 400.0 )
.push(
container( self.modal_body() )
.surface( "surface-card" )
.padding( 24.0 ),
)
.into();
vec![
OverlaySpec
{
id: OVERLAY_MODAL,
layer: Layer::Overlay,
anchor: Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None, // accept input
view: column()
.center_y( true )
.push( spacer() )
.push( modal )
.push( spacer() )
.into(),
on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses
anchor_widget_id: None,
},
]
}
// Swipe-down gesture (only fires inside the overlay because the main
// surface does not declare a down-swipe target).
fn on_swipe_down( &mut self ) -> Option<Msg>
{
Some( Msg::CloseModal )
}
fn on_swipe_down_progress( &mut self, progress: f32 )
{
// Optional follow-the-finger feedback: store the in-progress value
// and use it in view() to translate or fade the modal contents.
self.modal_drag_progress = progress;
}
# }
```
`on_dismiss` is the runtime's "tap outside the panel" hook — it fires
for any release on the overlay surface that did not land on an
interactive widget. The runtime also fires it on a press on the main
surface that does not hit the trigger (relevant for xdg-popup overlays
under compositors that do not break the popup grab on parent-surface
clicks) and on Escape with an xdg-popup open. Pair with `on_swipe_down`
for a follow-the-finger gesture, and `on_swipe_down_progress` if you
want the modal to track the finger before commit.
**See also**: [`OverlaySpec`](../src/app.rs),
[`docs/architecture.md`](./architecture.md#surface-composition).
---
## Runtime light / dark theme toggle
A "switch theme" affordance that flips the active mode without a
restart.
```rust,no_run
# use ltk::{ button, Element, ThemeMode };
# #[ derive( Clone ) ] enum Msg { ToggleTheme }
# struct App;
# impl App {
fn view( &self ) -> Element<Msg>
{
let label = match ltk::active_mode()
{
ThemeMode::Light => "Switch to dark",
ThemeMode::Dark => "Switch to light",
};
button( label ).on_press( Msg::ToggleTheme ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleTheme =>
{
let new = match ltk::active_mode()
{
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::Light,
};
ltk::set_active_mode( new );
// No further action — the next render reads the new mode
// through the slot helpers and recomposes the surface.
}
}
}
# }
```
`set_active_mode` mutates a process-global cell; the next `view()`
rebuild reads the new mode through the per-slot helpers
([`theme_palette`], [`theme_surface`], [`theme_paint`], etc.) and
renders against the new colours. There is no manual invalidation step.
For a full theme swap, load a different `ThemeDocument` and apply it:
```rust,no_run
# fn _ex() {
let doc = ltk::ThemeDocument::find( "midnight" )
.expect( "midnight theme not installed" );
ltk::set_active_document( doc );
# }
```
**See also**: [`docs/theming.md`](./theming.md#using-the-theme-from-app-code).
---
## Icon launcher with WrapGrid
An app drawer that displays an N-column grid of icon buttons, scrolls
when content overflows, and caches decoded icons across frames.
```rust,no_run
# use std::cell::RefCell;
# use std::collections::HashMap;
# use std::sync::Arc;
# use ltk::{ grid, icon_button, scroll, App, Element };
# #[ derive( Clone ) ] enum Msg { Launch( String ) }
# struct DesktopEntry { id: String, icon_path: String }
# fn decode_icon( _path: &str ) -> ( Arc<Vec<u8>>, u32, u32 ) {
# ( Arc::new( vec![ 0; 4 ] ), 1, 1 )
# }
struct LauncherApp
{
apps: Vec<DesktopEntry>,
icon_cache: RefCell<HashMap<String, ( Arc<Vec<u8>>, u32, u32 )>>,
}
impl App for LauncherApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
let mut grid = grid::<Msg>( 4 )
.padding( 16.0 )
.spacing( 12.0 );
for app in &self.apps
{
// Decode icons once on first reference; reuse the Arc on
// every subsequent frame for a pointer copy.
let ( bytes, w, h ) = self
.icon_cache
.borrow_mut()
.entry( app.id.clone() )
.or_insert_with( || decode_icon( &app.icon_path ) )
.clone();
grid = grid.push(
icon_button( bytes, w, h )
.on_press( Msg::Launch( app.id.clone() ) ),
);
}
scroll( grid ).into()
}
fn update( &mut self, _msg: Msg ) { /* ... */ }
}
```
The `RefCell<HashMap<…>>` cache is single-threaded — the runtime is
single-threaded, so `Mutex` would only add lock overhead. Decoded
icons are kept as `Arc<Vec<u8>>` so building the widget is a pointer
clone instead of a full byte clone.
**See also**: [`grid`](./widgets.md#grid),
[`scroll`](./widgets.md#scroll),
[`docs/architecture.md`](./architecture.md#larger-state-patterns).
---
## Channel-driven external state
Surface external events (D-Bus signals, file watches, timers) into the
event loop without busy-polling.
```rust,no_run
# use std::time::{ Duration, Instant };
# use ltk::ChannelSender;
# #[ derive( Clone ) ] struct BatteryEvent;
# fn wait_for_battery_event() -> BatteryEvent { BatteryEvent }
# struct OsdToast { expires_at: Instant }
# #[ derive( Clone ) ] enum Msg { BatteryChanged( BatteryEvent ), HideToast }
# struct App { sender: Option<ChannelSender<Msg>>, toast: Option<OsdToast> }
# impl App {
fn set_channel_sender( &mut self, sender: ChannelSender<Msg> )
{
// Saved once and never changed.
self.sender = Some( sender.clone() );
// Spawn the worker that watches for external events and forwards
// them as messages. Substitute `wait_for_battery_event` with whatever
// blocks for your real source — a D-Bus signal, a file watch, a
// socket read, a timer, etc.
std::thread::spawn( move ||
{
loop
{
// Block until the external source produces an event. When it
// arrives, post a message into the loop.
let event = wait_for_battery_event();
let _ = sender.send( Msg::BatteryChanged( event ) );
}
} );
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// For state that doesn't need a dedicated thread (file mtime checks,
// expiry sweeps), drain it here. Called after every Wayland event
// and every poll_interval tick.
let mut msgs = vec![];
if let Some( osd ) = self.toast.as_ref()
{
if osd.expires_at <= Instant::now()
{
msgs.push( Msg::HideToast );
}
}
msgs
}
fn poll_interval( &self ) -> Option<Duration>
{
// Wake every minute to re-check the clock display. Keep this `None`
// unless you actually need a wall-clock tick — it costs battery
// life on mobile targets.
Some( Duration::from_secs( 60 ) )
}
# }
```
The `set_channel_sender` hook gives you a clone-able sender that wakes
the event loop from another thread without busy-waiting. `poll_external`
runs in the loop's own thread after each tick, so anything that costs
CPU but does not block (TTL checks, RefCell snapshot diffs) is fine
there.
**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns).
---
## Toast / OSD with auto-expiry
A short-lived overlay that fades out after a fixed duration without
blocking other UI.
```rust,no_run
# use std::time::Instant;
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, OverlayId, OverlaySpec };
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
# #[ derive( Clone ) ] enum Msg {}
struct AppState
{
toast: Option<Toast>,
// ...
}
struct Toast
{
text: String,
started: Instant,
}
const TOAST_DURATION: f32 = 2.0;
const TOAST_FADE: f32 = 0.25;
impl AppState
{
# fn main_view( &self ) -> Element<Msg> { text( "main" ).into() }
// ...
}
impl App for AppState
{
type Message = Msg;
fn view( &self ) -> Element<Msg> { self.main_view() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
let toast = match &self.toast
{
Some( t ) => t,
None => return vec![],
};
let elapsed = toast.started.elapsed().as_secs_f32();
let alpha = if elapsed >= TOAST_DURATION
{
// Fade-out window: 0.25 s after expiry the alpha hits 0.
( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 )
} else { 1.0 };
vec![
OverlaySpec
{
id: OVERLAY_TOAST,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: Some( vec![] ), // pass-through
view: container( text( &toast.text ).color( Color::WHITE ) )
.surface( "surface-panel" )
.padding( 12.0 )
.opacity( alpha )
.into(),
on_dismiss: None,
anchor_widget_id: None,
},
]
}
fn update( &mut self, _msg: Msg ) {}
fn is_animating( &self ) -> bool
{
// Redraw at 60 Hz while the toast is visible or fading.
self.toast.is_some()
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// Drop the toast once the fade window completes.
if let Some( t ) = &self.toast
{
if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE
{
self.toast = None;
}
}
vec![]
}
}
```
Two ideas worth noting:
- `input_region: Some( vec![] )` makes the overlay pass-through: pointer
events fall through to whatever surface is below. The toast does not
steal taps from the main UI.
- The fade-out cleanup belongs in `poll_external`, not `view()`. `view`
must stay pure — read state, build a tree, no mutation.
**See also**: [`docs/architecture.md`](./architecture.md#animations).
---
## Tab navigation between widgets
The runtime ships Tab / Shift+Tab traversal automatically; you only need
to set up programmatic focus when an external event should land focus on
a specific widget.
```rust,no_run
# use ltk::{ column, text_edit, App, Element, WidgetId };
# #[ derive( Clone ) ] enum Msg {
# UsernameChanged( String ), PasswordChanged( String ), AuthFailed,
# }
const FIELD_USERNAME: WidgetId = WidgetId( "username" );
const FIELD_PASSWORD: WidgetId = WidgetId( "password" );
struct LoginApp
{
username: String,
password: String,
pending_focus: Option<WidgetId>,
// ...
}
impl App for LoginApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column()
.push(
text_edit( "Username", &self.username )
.id( FIELD_USERNAME )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.id( FIELD_PASSWORD )
.secure( true )
.on_change( |s| Msg::PasswordChanged( s ) ),
)
.into()
}
fn take_focus_request( &mut self ) -> Option<WidgetId>
{
// Returned once; the runtime focuses that widget on the next
// frame. Subsequent calls return None.
self.pending_focus.take()
}
fn update( &mut self, msg: Msg )
{
if matches!( msg, Msg::AuthFailed )
{
// Clear the password and put focus back on the field so the
// user can retype without a click.
self.password.clear();
self.pending_focus = Some( FIELD_PASSWORD );
}
}
}
```
`take_focus_request` is consumed once: the runtime dispatches focus to
the returned id and the next call returns `None` because the slot was
drained. The application owns "when should focus move".
Tab and Shift+Tab traverse focusable widgets in declaration order — no
opt-in needed beyond the widget being interactive.
**See also**: [`WidgetId`](../src/types.rs),
[`tests/tab_navigation.rs`](../tests/tab_navigation.rs).
---
## Multi-screen app via sub-state pattern
When the app has more than ~30 message variants it is time to split by
screen. Each screen owns its sub-state and sub-message, and the
top-level enum wraps them.
```rust,no_run
# use ltk::{ column, text, App, Element };
# #[ derive( Clone, Copy ) ] enum Screen { Home, Settings, About }
# #[ derive( Clone ) ] enum HomeMsg {}
# #[ derive( Clone ) ] enum SettingsMsg {}
# struct HomeState;
# struct SettingsState;
# fn home_view( _: &HomeState ) -> Element<HomeMsg> { text( "home" ).into() }
# fn home_update( _: &mut HomeState, _: HomeMsg ) {}
# fn settings_view( _: &SettingsState ) -> Element<SettingsMsg> { text( "settings" ).into() }
# fn settings_update( _: &mut SettingsState, _: SettingsMsg ) {}
# fn about_view() -> Element<AppMsg> { text( "about" ).into() }
# fn nav_bar( _: Screen ) -> Element<AppMsg> { text( "nav" ).into() }
#[derive(Clone)]
enum AppMsg
{
Nav( Screen ),
Home( HomeMsg ),
Settings( SettingsMsg ),
}
struct AppState
{
current: Screen,
home: HomeState,
settings: SettingsState,
}
impl App for AppState
{
type Message = AppMsg;
fn view( &self ) -> Element<AppMsg>
{
let body = match self.current
{
Screen::Home => home_view( &self.home ).map( AppMsg::Home ),
Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ),
Screen::About => about_view(),
};
column()
.push( nav_bar( self.current ) )
.push( body )
.into()
}
fn update( &mut self, msg: AppMsg )
{
match msg
{
AppMsg::Nav( s ) => self.current = s,
AppMsg::Home( m ) => home_update( &mut self.home, m ),
AppMsg::Settings( m ) => settings_update( &mut self.settings, m ),
}
}
}
```
Each screen owns its `view( state ) -> Element<SubMsg>` and
`update( state, msg )` functions; the parent wraps the sub-message in
the outer enum on the way out and unwraps it on the way in. There is no
runtime-level "screen" abstraction in `ltk` because the widget tree is
already cheap to rebuild every frame — the dispatch above is just three
function calls.
In a real project each screen lives in its own file (`src/home.rs`,
`src/settings.rs`, `src/about.rs`) declared as `pub mod home;` etc. in
`lib.rs`, and the call sites become `home::view( &self.home )` and
`home::update( &mut self.home, m )` instead of the flat
`home_view` / `home_update` shape used above. The snippet is flat so
that `doctest-md` can typecheck the dispatch without needing one file
per screen.
**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns).
---
## Embedding ltk without `ltk::run`
A compositor or embedder that already owns the Wayland connection and
just wants ltk's layout, rendering and hit-testing.
```rust,no_run
# use ltk::{ button, Color, Element, Rect };
# use ltk::core::{ Canvas, RenderOptions, UiSurface };
# #[ derive( Clone ) ] enum Msg { Tick }
# struct App;
# impl App { fn view( &self ) -> Element<Msg> { button( "x" ).into() }
# fn update( &mut self, _: Msg ) {} }
# struct Event;
# impl Event { fn into_msg( self ) -> Msg { Msg::Tick } }
# struct Queue( Vec<Event> );
# impl Queue { fn drain( &mut self ) -> std::vec::Drain<'_, Event> { self.0.drain( .. ) } }
# fn present_argb8888( _: &[u8] ) {}
# fn wl_damage( _: &Rect ) {}
# fn _ex( width: u32, height: u32, mut app: App, mut input_queue: Queue, pos_x: f32, pos_y: f32 ) {
let mut surface = UiSurface::<Msg>::new( width, height );
loop
{
// 1. Drain pending app events from your own input source.
for ev in input_queue.drain() { app.update( ev.into_msg() ); }
// 2. Build the tree and render.
let view = app.view();
let out = surface.render(
&view,
RenderOptions::full_canvas( width, height )
.background( Color::TRANSPARENT ),
);
// 3. Pull pixels (software backend) or present the FBO (GLES).
match surface.canvas()
{
Canvas::Software( _ ) =>
{
let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ];
surface.canvas().write_to_wayland_buf( &mut buf, false );
present_argb8888( &buf );
}
Canvas::Gles( _ ) =>
{
// Already drawn into the FBO the embedder owns; commit
// through your own EGL context.
}
}
// 4. Use damage rects to feed wl_surface.damage_buffer if you are
// on the software path.
for rect in &out.damage_rects { wl_damage( rect ); }
// Pointer dispatch: turn a screen-space point into the widget under it.
let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } );
if let Some( idx ) = hit
{
if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() )
{
app.update( msg );
}
}
# break;
}
# }
```
`UiSurface` keeps the focus / hover / pressed state, the cursor map,
the scroll-offset table, and the per-frame widget rects. Set
`set_focused( Some( idx ) )` etc. from your own input handler and the
next render automatically uses the partial-damage path when only
interaction state changed.
**See also**: [`tests/core_surface.rs`](../tests/core_surface.rs) for
the full set of supported operations,
[`docs/onboarding.md`](./onboarding.md#when-to-use-coreuisurface).

385
docs/onboarding.md Normal file
View File

@@ -0,0 +1,385 @@
# ltk onboarding
This guide is for the first hour with `ltk`: what environment you need, how to
run the examples, how to build a minimal app, when to use layer-shell vs a
regular window, and what theme/font assumptions the toolkit currently makes.
If you already know the basics and want the deeper rationale, read
[`docs/architecture.md`](./architecture.md) next.
## What `ltk` is
`ltk` is a Rust UI toolkit for Wayland. It is aimed first at the Eydos shell
stack, but it can also be used to build normal client applications and
runtime-free UI surfaces.
At a high level:
- Implement the [`App`] trait.
- Return an [`Element<Msg>`] tree from `view()`.
- React to user input by handling messages in `update()`.
- Start the event loop with `ltk::run(app)`.
The model is declarative and Elm-shaped: the widget tree is rebuilt from your
state, then `ltk` handles layout, drawing and input dispatch.
If you are browsing the crate through `cargo doc`, the public API is also
grouped conceptually into three entry points:
- `ltk::window` — basic application windows
- `ltk::shell` — layer-shell and overlays
- `ltk::runtime` — advanced runtime hooks and runtime-free embedding
Most users should start with `ltk::window` and ignore the other two until they
have a normal app window running.
## Before you start
`ltk` is not a browser toolkit and not a cross-platform desktop toolkit. Today
it assumes:
- a running **Wayland** session
- Wayland client libraries available through Rust dependencies
- a usable system font such as `google-sora-fonts`, `liberation-fonts` or
`dejavu-fonts`
- an installed `default` theme, or a development theme directory exposed
through `LTK_THEMES_DIR`
The rendering backend is selected automatically:
- **GLES** when EGL/GLES is available
- **software** fallback otherwise, or when `LTK_FORCE_SOFTWARE=1`
## Fastest way to see it working
From the repo root:
```bash
cargo run --example showcase
```
Other useful examples:
- `cargo run --example widgets` — broad widget survey
- `cargo run --example inputs` — text entry
- `cargo run --example scroll` — scroll viewport patterns
- `cargo run --example mini_shell` — overlays, animation and theme switching
All examples require a running Wayland compositor.
## Theme and font setup
`ltk` currently expects a theme named `default`. Lookup order is:
1. `LTK_THEMES_DIR/<id>/`
2. `$XDG_DATA_HOME/ltk/themes/<id>/`
3. `/usr/share/ltk/themes/<id>/`
For development inside this repository, the simplest setup is:
```bash
export LTK_THEMES_DIR="$PWD/themes"
```
That makes `ThemeDocument::find("default")` resolve to
`$PWD/themes/default/theme.json`.
Font loading is separate from theme lookup. `Canvas` walks a chain of
common system font paths (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`,
`fonts-freefont`, …) and uses the first one it finds. If nothing matches,
it falls back to an embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped
inside the crate, so canvas construction never panics on a system without
the expected fonts. Installing one of the listed packages is still
recommended for richer glyph coverage.
## Your first app
The smallest useful `ltk` app implements `App`, returns a tree from `view()`,
updates its state in `update()`, and calls `ltk::run(...)`.
```rust,no_run
use ltk::{ App, Element, Keysym, button, column, spacer, text };
#[derive(Clone)]
enum Msg
{
Increment,
}
struct CounterApp
{
value: u32,
}
impl App for CounterApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column::<Msg>()
.padding( 32.0 )
.spacing( 16.0 )
.center_y( true )
.push( text( "Hello from ltk" ).size( 28.0 ) )
.push( text( format!( "Count: {}", self.value ) ).size( 18.0 ) )
.push( spacer() )
.push( button( "Increment" ).on_press( Msg::Increment ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Increment => self.value += 1,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
}
fn main()
{
ltk::run( CounterApp { value: 0 } );
}
```
### Minimal `Cargo.toml`
```toml
[package]
name = "my-ltk-app"
version = "0.1.0"
edition = "2021"
[dependencies]
ltk = { path = "../ltk" }
```
If you vend `ltk` from crates.io later, replace the `path` dependency with a
versioned one.
## Public API Layers
`ltk` exposes most items at the crate root, but for documentation and discovery
it is useful to think of the library in three layers.
### 1. `ltk::window`
This is the default entry point for third-party applications.
Use it for:
- normal application windows
- tools and prototypes
- most widget/layout work
The APIs you will usually touch first live here conceptually:
- `App`
- `Element<Msg>`
- `button`, `text`, `text_edit`, `image`
- `column`, `row`, `stack`, `grid`, `spacer`
- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio`
- `Color`
- `run`
### 2. `ltk::shell`
This layer groups the APIs that matter when your surface is part of the shell
rather than a normal app window.
Use it for:
- bars and docks
- homescreens
- notifications
- greeters and lock screens
- transient overlays
The most important APIs in this layer are:
- `ShellMode`
- `Layer`
- `Anchor`
- `OverlaySpec`
- `OverlayId`
- `overlays()`
### 3. `ltk::runtime`
This layer is for advanced integration points.
Use it when you need:
- external wakeups via `set_channel_sender()`
- timer-driven or async state via `poll_external()` / `poll_interval()`
- redraw narrowing via `invalidate_after()`
- runtime theme state access
- runtime-free embedding through `core::UiSurface`
Most applications do not need to start here.
## Regular app window vs shell surface
Most consumers should start with a **regular window**.
Default behaviour:
- `shell_mode()` defaults to `ShellMode::Window`
- `ltk::run(app)` creates an xdg-shell toplevel
Use this for:
- normal applications
- internal tools
- prototypes while learning the toolkit
Switch to **layer-shell** only when you are building a shell component:
- top bar
- dock
- homescreen
- notification surface
- lock screen / greeter
The knobs you will usually override are:
- `shell_mode()`
- `layer_anchor()`
- `layer_size()`
- `exclusive_zone()`
- `keyboard_exclusive()`
- `background_color()`
For a non-trivial layer-shell example, use `examples/mini_shell.rs` as the
reference entry point.
## The APIs you will touch first
In practice, most first apps only need a small subset of the surface area.
Start here:
- `App`
- `Element<Msg>`
- `button`, `text`, `text_edit`, `image`
- `column`, `row`, `stack`, `grid`, `spacer`
- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio`
- `Color`
- `run`
Do not start with these unless you need them:
- `ltk::shell`
- `ltk::runtime`
- `overlays()`
- gesture hooks such as `on_swipe_*`
- `set_channel_sender()` / `poll_external()`
- `core::UiSurface`
- custom theming APIs
## Message flow and state
The expected shape is:
1. user interaction emits a `Message`
2. `update()` mutates app state
3. `view()` rebuilds the UI from that state
Example:
```rust
#[derive(Clone)]
enum Msg
{
NameChanged( String ),
Submit,
}
```
For small apps, one top-level `enum Msg` is enough. Once the app grows, split
state by screen/panel and wrap sub-messages in the top-level enum:
```rust,no_run
# #[ derive( Clone ) ] pub enum HomeMsg {}
# #[ derive( Clone ) ] pub enum SettingsMsg {}
enum AppMsg
{
Home( HomeMsg ),
Settings( SettingsMsg ),
Quit,
}
```
This is the pattern used by `examples/mini_shell.rs`.
## Recommended learning order
If you are new to the library, this order minimizes confusion:
1. Run `examples/showcase.rs`.
2. Read the crate-level docs in `src/lib.rs`, especially `ltk::window`.
3. Build a plain xdg-shell window with `button`, `text`, `column`.
4. Add input handling with `text_edit` or `slider`.
5. Only then look at `ltk::shell` for overlays and layer-shell.
6. Move to `ltk::runtime` only when you need advanced hooks or embedding.
## Performance rules of thumb
`ltk` is designed to sleep when idle and redraw only on real changes, but the
application can still make bad choices. Keep these rules in mind:
- keep `view()` pure and cheap
- do not do filesystem I/O, parsing or image decoding inside `view()`
- cache expensive derived data on your app struct
- leave `poll_interval()` as `None` unless you genuinely need periodic wakeups
- only return `true` from `is_animating()` while something is actually moving
On mobile targets, the last two matter directly for battery life.
## When to use `core::UiSurface`
Most apps should ignore `core` at first.
Use `core::UiSurface` when you want `ltk`'s layout/drawing/hit-testing without
`ltk::run()`. Typical cases:
- compositor-side decorations
- embedding `ltk` widgets in another render loop
- offscreen rendering or previews
There is coverage for that path in `tests/core_surface.rs`.
## Current assumptions and rough edges
This repo is usable, but a few current behaviours are worth knowing up front:
- examples and docs assume Wayland, not X11
- theming is process-global
- theme discovery currently expects a `default` theme on disk (a B/W
fallback document kicks in when missing, with a red banner on every
frame so the gap is impossible to miss)
- the architecture docs mention downstream consumer repos that are not part of
this repository
None of that blocks learning the toolkit, but it matters when you evaluate
`ltk` as a third-party dependency.
## What to read next
- [`docs/architecture.md`](./architecture.md) — multi-surface patterns,
theming, animation and performance
- [`examples/showcase.rs`](../examples/showcase.rs) — smallest visual tour
- [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage
- [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell
patterns
- [`tests/core_surface.rs`](../tests/core_surface.rs) — runtime-free rendering

737
docs/theming.md Normal file
View File

@@ -0,0 +1,737 @@
# ltk theming
`ltk` reads a JSON theme document at startup and exposes a process-wide
active state for widgets and applications to query. This document
describes the on-disk format, the runtime APIs, and the slot conventions
that built-in widgets expect.
For background on *why* theming is process-global and how it interacts
with the runtime, see [`docs/architecture.md`](./architecture.md). This
file is the schema reference.
## File layout
A theme is a directory under one of:
1. `LTK_THEMES_DIR/<id>/` — only when the env var is set; intended
for development.
2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to
`~/.local/share/ltk/themes/<id>/`).
3. `/usr/share/ltk/themes/<id>/` — system-wide install path
(`ltk-theme-default` Debian package).
The directory tree:
```text
<id>/
├── theme.json required — the schema below
├── branding/ mode-aware branded assets
│ ├── light/
│ │ ├── launcher.svg launcher logo
│ │ ├── wallpaper.svg homescreen wallpaper
│ │ ├── lockscreen.svg greeter / lockscreen image
│ │ └── logo/ brand wordmark / icon variants
│ │ ├── logo.svg primary logo (about / splash)
│ │ ├── square.svg 1:1 variant (avatars, app icons)
│ │ └── horizontal.svg wordmark (header / sign-in bars)
│ └── dark/
│ └── (same set, dark variants)
└── icons/ symbolic + app icons
├── app-default.svg fallback icon for unknown app ids
├── apps/ per-application icons (firefox.svg, …)
└── catalogue/ symbolic glyph catalogue
├── filled/ solid silhouettes — preferred by default
│ └── <category>/ general, system, window, …
└── line/ outlined variants (same names)
```
Branded assets in `branding/` and icons in `icons/` are picked up by
convention — see [Branding assets](#branding-assets) and
[Icons](#icons) below. Asset paths declared inside `theme.json` (e.g. a
custom wallpaper override) resolve relative to the theme directory; a
bare `"path": "custom.png"` is portable across install prefixes,
absolute paths work for system fonts but break relocatable installs.
## Top-level structure
```json
{
"theme": { "id": "default", "name": "Default" },
"fonts": { ... },
"colors": { ... },
"gradients": { ... },
"inset_stacks": { ... },
"modes": {
"light": { ... },
"dark": { ... }
}
}
```
`theme.id` must match the directory name. `theme.name` is shown in any
theme-picker UI a shell builds on top of `ltk`.
The other six sections are described below in order. The parser is strict
(`deny_unknown_fields`): unknown keys at any level are an error so typos
surface immediately.
## `fonts`
```json
"fonts": {
"sora": {
"name": "Sora",
"fallbacks": ["system-ui", "sans-serif"],
"sources": [
{ "weight": 300, "path": "/usr/share/fonts/opentype/sora/Sora-Light.otf" },
{ "weight": 400, "path": "/usr/share/fonts/opentype/sora/Sora-Regular.otf" },
{ "weight": 600, "path": "/usr/share/fonts/opentype/sora/Sora-SemiBold.otf" },
{ "weight": 700, "path": "/usr/share/fonts/opentype/sora/Sora-Bold.otf" }
]
}
}
```
Each entry defines a font *family* with one source per OpenType / TrueType
weight. The map key (`"sora"`) is the family alias used inside the theme
(`"font_family": "sora"`); `name` is the human-readable label.
`fallbacks` is consulted when a glyph is missing from the primary font
files at render time. The chain is walked in order; the first family that
can rasterise the codepoint wins.
If none of the listed `sources` exist on disk, `ltk` falls back to its
embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every
frame pointing at the missing-theme problem.
## `colors`
A flat dictionary of named hex literals, used as building blocks
elsewhere in the document via the [`@name` reference syntax](#references):
```json
"colors": {
"navy": "#0A032E",
"white": "#FFFFFF",
"cyan": "#04D9FE",
"danger": "#E5484D",
"glass-hi": "#555555",
"ink": "#000000"
}
```
Values are 6- or 8-digit hex (`#RRGGBB` or `#RRGGBBAA`). Names use
kebab-case. There is no distinction between "palette" colours (used in
many places) and "raw" colours (single-use) — any hex can live here, and
single-use literals are equally valid inline.
## `gradients`
Named paints. Two variants: `linear` and `radial`. Used both as fills for
surfaces and as values referenced from a slot. Stops carry a `pos` (in
`[0, 1]`, but stop positions outside that range are accepted and used for
extrapolation) and a `color` (literal hex or `@reference`).
```json
"gradients": {
"fill-cyan": {
"type": "linear",
"angle_deg": 270,
"space": "linear-rgb",
"stops": [
{ "pos": 0, "color": "@cyan" },
{ "pos": 1, "color": "@cyan-soft" }
]
}
}
```
`space` is either `"srgb"` (perceptually quick, the default) or
`"linear-rgb"` (interpolate in linear-light space, more uniform mid-tones
for high-saturation gradients). `angle_deg` is the conventional CSS
gradient angle (`0` = up, `90` = right, `180` = down, `270` = left).
Radial gradients use `center: [x, y]` (relative to the painted rect, both
in `[0, 1]`) and `radius: r` (also relative).
Soft cap: a single gradient may carry **64 stops**. Beyond that the
parser truncates the tail with a stderr warning, so a hostile or
mistakenly-large gradient cannot blow up CPU + memory at parse time.
Realistic designs use 2 6 stops.
## `inset_stacks`
Named lists of inset shadows reused across surfaces. Convenient for the
"glass" stack used by every translucent slot in the default theme:
```json
"inset_stacks": {
"glass-insets": [
{ "offset": [0, 1], "blur": 4, "color": "@ink/0F", "blend": "normal" }
]
}
```
Each entry has `offset: [x, y]` (logical pixels), `blur` (Gaussian sigma
× 2, matching the CSS convention), optional `spread` (default `0`),
`color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`, or
`overlay`).
Reference an entire stack from a slot with `"inset_shadows":
"@glass-insets"`, or inline the array if you only use it once.
## `modes`
Two required entries: `light` and `dark`. Each carries the look the
theme applies in that lighting mode plus its own `slots` table.
### `wallpaper` / `lockscreen`
Both are optional. The runtime resolves them in two steps:
1. **Convention** — looks for `branding/{mode}/wallpaper.svg` and
`branding/{mode}/lockscreen.svg`, with the standard mode →
opposite-mode → no-mode fallback (see
[Branding assets](#branding-assets)).
2. **Override** — if `theme.json` declares an explicit block, that path
wins over the convention. Useful for raster wallpapers or
non-conventional locations:
```json
"wallpaper": { "path": "custom/path.png", "fit": "cover" }
```
`path` resolves against the theme directory. `fit` is one of `"cover"`,
`"contain"`, `"stretch"`, `"center"` (default `"cover"`). The wallpaper
bundle helper ([`ltk::WallpaperBundle::for_size`]) returns the right
crop for landscape or portrait surfaces, so a single landscape SVG / PNG
covers both.
### `launcher`
```json
"launcher": { "background": "@white/E6", "border_radius": 24.0 }
```
`background` is any color reference (`@name[/AA]`). `border_radius` is
the corner radius for the launcher panel, in logical pixels.
### `window_controls`
Per-mode tokens for the title-bar control buttons:
```json
"window_controls": {
"icon": "#5F5F68",
"hover_bg": "@navy/14",
"pressed_bg": "@navy/24",
"close_hover_bg": "@danger",
"close_icon": "@white",
"focus_ring": "@teal"
}
```
Consumed by the [`window_button`](../src/widget/window_button.rs) widget
through [`theme_window_controls()`].
The actual SVG glyphs (`close`, `maximize`, `minimize`, `restore`) live
in `icons/catalogue/filled/window/` and are tinted at runtime with the
`icon` colour from this block (and `close_icon` on close-hover). Themes
don't need to ship per-mode variants of these glyphs — the symbolic
tinting handles light vs dark colouring. They also don't need a
`line/window/` variant; chrome controls should look the same regardless
of the theme's overall icon style preference, and the existing `filled`
fallback in [`icon_path`](#icons) already covers the case.
### `slots`
The mode's slot table. Each entry is keyed by a stable id; widgets look
their slot up by id and the slot's `meta.semantic` field supplies a
human-readable hint that's useful in theme inspectors.
Three slot variants:
#### `color`
```json
"text-primary": {
"type": "color",
"value": "@navy",
"meta": { "semantic": "palette/text_primary" }
}
```
Plain colour values. `value` is a literal hex or `@reference`. `meta` is
optional but conventional: `palette/<role>` for the palette layer and
`effect/<group>/<name>` for everything else.
#### `shadows`
```json
"shadows-glass": {
"type": "shadows",
"shadows": [
{ "offset": [0, 0], "blur": 9, "color": "@glass-elev/1F" }
]
}
```
Outer drop shadows applied via [`theme_shadows(id)`]. Same field shape as
`inset_stacks` entries.
#### `surface`
```json
"surface-card": {
"type": "surface",
"fill": "@surface-glass-dark",
"shadows": "shadows-glass",
"inset_shadows": "@glass-insets",
"backdrop": { "blur_px": 22.5 },
"meta": { "semantic": "effect/glass/card" }
}
```
The most expressive slot kind. Composes:
- `fill` — a paint reference (`@gradient-name`) or inline gradient /
solid colour. Required.
- `shadows` — id of a `shadows` slot or an inline list. Optional.
- `inset_shadows` — id of an `inset_stacks` entry (`@glass-insets`) or
inline list. Optional.
- `backdrop``{ "blur_px": <σ × 2> }` for backdrop blur. Optional;
GLES backend renders it, software backend ignores it (documented
parity gap).
## References
The `@name` syntax substitutes a palette / gradient / inset-stack value
in place of an inline literal:
- `@cyan` — looks up `colors.cyan`, `gradients.cyan` or
`inset_stacks.cyan` (collisions across sections are an error). When the
resolved value is a colour, the alpha channel comes from the original.
- `@cyan/80` — the `/AA` suffix is a colour-only alpha override (two hex
digits). Lets a single base `@navy` serve `@navy/14`, `@navy/24`,
`@navy/99`, `@navy/D9` etc. without a separate entry per alpha.
References inside gradient stops or inset shadows are resolved at parse
time, so each downstream substitution at a slot call site is a flat
clone with no recursion at runtime.
Unknown references fail loud: parsing aborts with `ThemeError::
UnknownColorRef("foo")`.
## Canonical slot ids
The default widgets look up these slot ids; a custom theme that omits any
of them falls back to embedded defaults.
**Palette (every mode must define):**
| id | role | type |
| --- | --- | --- |
| `bg-page` | window background | color |
| `surface` | card / panel surface | color |
| `surface-alt` | text-input field background | color |
| `text-primary` | regular text colour | color |
| `text-secondary` | muted / placeholder text | color |
| `accent` | toggle on, slider fill, focus ring | color |
| `divider` | separator, toggle off, list item border | color |
| `icon` | icon-button glyph colour | color |
**Effects (optional but used by built-in widgets):**
| id | consumer | type |
| --- | --- | --- |
| `shadows-glass` | every surface that opts into elevation | shadows |
| `surface-card` | `Container::surface("surface-card")` | surface |
| `surface-card-flat` | flat variant for software backend | surface |
| `surface-panel` | overlay panels | surface |
| `surface-slider-track` | `Slider` track background | surface |
| `surface-slider-fill` | `Slider` filled portion | surface |
| `surface-slider-track-flat` | software-backend slider track | surface |
| `surface-slider-fill-flat` | software-backend slider fill | surface |
| `surface-toggle-active` | `Toggle` on-state surface | surface |
The `-flat` variants are used by the software backend, which lacks
backdrop blur; the GLES backend uses the non-flat ones.
## Using the theme from app code
The active theme is process-global mutable state: a `(ThemeDocument,
ThemeMode)` pair guarded by a `RwLock` behind the `ltk::theme` API.
Widgets and apps read it through cheap accessors that clone an `Arc`
or project a small struct out of the slot table — designed so it's
fine to call them dozens of times per frame from inside `view()`.
### The canonical pattern: read in `view()`, never cache
Read the theme at the top of every `view()` and let the next frame
re-resolve automatically. Storing a `Color` in your app state freezes
it at the moment you captured it: a later `set_active_mode( Dark )`
will repaint everyone who reads palette per-frame and skip the widgets
that read a stale field.
```rust,no_run
# use ltk::{ column, container, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# struct MyApp;
# impl MyApp {
fn view( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
column::<Msg>()
.push( text( "Hola" ).color( palette.text_primary ) )
.push( text( "subtítulo" ).color( palette.text_secondary ) )
.push(
container( text( "tarjeta" ).color( palette.text_primary ) )
.background( palette.surface )
.radius( 12.0 ),
)
.into()
}
# }
```
### Helpers reference
| Helper | Returns | When to use |
| --- | --- | --- |
| `theme_palette()` | `Palette` | Common case — named colour fields (`bg`, `surface`, `surface_alt`, `text_primary`, `text_secondary`, `accent`, `divider`, `icon`, `danger`, `danger_bg`). Cheap projection, ideal at the top of `view()`. |
| `theme_color( id )` | `Option<Color>` | Pull a single colour slot by id when it's not in the palette (`"surface-card-border"`, custom theme tokens). |
| `theme_color_or( id, fallback )` | `Color` | Same, with a baked-in default — ergonomic in widget defaults so missing slots don't return `None`. |
| `theme_paint( id )` | `Option<Paint>` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. |
| `theme_surface( id )` | `Option<Surface>` | Surface slot (fill + shadows + insets + backdrop). |
| `theme_resolve_surface( id )` | `Option<( Surface, Vec<Shadow> )>` | Same, but pre-resolves a `ShadowsRef::Named` reference to a flat `Vec`. Use this when you call `canvas.fill_surface` directly. |
| `theme_shadows( id )` | `Option<Vec<Shadow>>` | Outer shadow stack. |
| `theme_text_style( id )` | `Option<TextStyle>` | Typography slot (size, weight, line-height). |
| `theme_window_controls()` | `WindowControlsSpec` | Per-mode chrome tokens for the title-bar buttons. |
| `theme_wallpaper()` / `theme_lockscreen()` | `Option<WallpaperSpec>` | Full-screen branded images (SVG), with the convention fallback chain. |
| `theme_branding_image( name, sw, sh )` | `Option<PathBuf>` | Sized branded image: smallest covering raster (WebP / PNG / JPEG) under `branding/{mode}/{name}/`, or the largest available raster if none cover, falling back to the SVG only when no rasters exist. Pass `(0, 0)` for the smallest available (startup before surface-configure). |
| `theme_branding_raster( name, sw, sh )` | `Option<PathBuf>` | Raster-only variant of the above; returns `None` only when no rasters exist at all. |
| `theme_branding_asset( name, ext )` | `Option<PathBuf>` | Generic branded asset lookup (any extension). Powers `theme_launcher_icon`, `theme_wallpaper`, `theme_lockscreen`. |
| `theme_launcher_icon()` | `Option<PathBuf>` | Launcher logo SVG path, with the convention fallback. |
| `theme_logo()` | `Option<PathBuf>` | Primary brand logo SVG (`branding/{mode}/logo/logo.svg`) — about dialogs, splash screens. |
| `theme_logo_square()` | `Option<PathBuf>` | Square 1:1 logo variant (`logo/square.svg`) — app icons, login avatars, lockscreen badges. |
| `theme_logo_horizontal()` | `Option<PathBuf>` | Wordmark logo variant (`logo/horizontal.svg`) — header bars, sign-in screens. |
| `theme_app_icon( name )` / `theme_app_default_icon()` | `Option<PathBuf>` | Per-app icons under `icons/apps/`. |
| `theme_icon_path( "category/name" )` | `Option<PathBuf>` | Catalogue icon path (filled-then-line lookup). |
| `theme_icon_rgba( "category/name", size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. |
| `is_fallback_active()` | `bool` | `true` when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user. |
### Switching mode or document at runtime
Mutators live next to the readers. They take effect on the next render:
```rust,no_run
// Light → dark.
ltk::set_active_mode( ltk::ThemeMode::Dark );
// Replace the whole document (user picked a different theme id in a
// settings panel, etc.).
let doc = ltk::ThemeDocument::find( "midnight" )
.expect( "midnight theme not installed" );
ltk::set_active_document( doc );
```
The conventional wiring is to dispatch a message from the UI:
```rust,no_run
# struct MyApp;
enum Msg { ToggleTheme }
# impl MyApp {
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleTheme =>
{
let next = match ltk::active_mode()
{
ltk::ThemeMode::Light => ltk::ThemeMode::Dark,
ltk::ThemeMode::Dark => ltk::ThemeMode::Light,
};
ltk::set_active_mode( next );
}
}
}
# }
```
Every widget that read `theme_palette()` / `theme_surface(...)` /
... in `view()` automatically gets the new colours on the next
frame — there is no manual invalidation step, no observer
registration, no list of subscribed widgets. The entire reactivity
story is "ltk re-runs `view()` every frame and you read fresh values".
### Do / don't
- **Do** read palette / surfaces / icons inside `view()`, every frame.
- **Do** use `theme_color_or( id, fallback )` for non-palette slots so
a custom theme that omits a slot still paints something sane.
- **Do** use `theme_palette().<field>` for the eight common roles —
the palette is precomputed and cheap; named slot lookups only beat
it when you genuinely need a non-canonical token.
- **Don't** store `Color`, `Surface`, `Paint`, or icon `PathBuf`s in
your app state. They snapshot the moment of capture and stop
responding to mode changes.
- **Don't** hard-code `Color::hex( ... )` for chrome that should adapt
to mode — route it through a palette token or a custom slot.
- **Don't** call `set_active_mode` from `view()` (it's a side effect;
do it in `update()` from a `Msg`).
## Branding assets
The `branding/` directory holds the theme's identity assets — launcher
logo, wallpaper, lockscreen, brand wordmark — keyed by mode. The
runtime loader looks them up by convention so themes don't need to
declare them in `theme.json`.
### Layout
```text
branding/
├── light/
│ ├── launcher.svg
│ ├── wallpaper.svg
│ ├── wallpaper/ optional pre-rendered raster variants
│ │ ├── 1280x720.webp
│ │ ├── 1920x1080.webp
│ │ └── 3840x2160.webp
│ ├── lockscreen.svg
│ ├── lockscreen/
│ │ └── (same WIDTHxHEIGHT.webp set)
│ └── logo/ brand wordmark / icon variants
│ ├── logo.svg primary (about, splash)
│ ├── square.svg 1:1 (app icon, avatar)
│ └── horizontal.svg wordmark (header bar, sign-in)
└── dark/
└── (same structure)
```
The SVG is the canonical asset. Sized raster variants in the same-named
subdirectory are an optimisation: the loader prefers a pre-rendered
WebP / PNG / JPEG that already covers the surface (no upscale, no
runtime SVG rasterisation), falling back to the SVG when no raster
fits. Filenames must be `WIDTHxHEIGHT.<ext>` (literal numeric form,
lower-case `x`); the parser is strict so a bad name is silently
ignored rather than misclassified.
### Fallback chain — SVG
When a branded SVG asset is requested for the active mode, the runtime
tries three locations in order:
1. `branding/{active_mode}/{name}.svg` — preferred variant.
2. `branding/{opposite_mode}/{name}.svg` — graceful degradation when
the theme only ships one mode of the asset.
3. `branding/{name}.svg` — mode-agnostic asset, for themes that don't
bother with light/dark variants.
Returns `None` when none of the candidates exist; consumers fall back
to whatever default they prefer (solid `bg-page` for wallpapers, no
launcher decoration, etc.).
### Resolution — raster
When the surface size is known, [`theme_branding_raster`] looks for a
pre-rendered raster under `branding/{mode}/{name}/`. The same three-step
mode chain applies to the *directory*: tries the active mode first, then
the opposite mode, then the mode-less directory. Within the *first
existing* directory it parses every `WIDTHxHEIGHT.<ext>` filename
(`<ext>` ∈ {`webp`, `png`, `jpg`, `jpeg`}) and picks:
- the smallest entry whose two dimensions cover the surface, if any;
- otherwise the largest entry available — a fast-decoding upscaled
raster beats paying the SVG rasterisation cost.
The directory does *not* cross over to the opposite-mode tree once an
existing directory is found: a colour-wrong raster (light-mode asset
served in dark mode or vice versa) would be more jarring than an
upscaled same-mode raster. Cross-mode degradation only happens at the
SVG layer, where the vector form re-paints crisply at any size.
`theme_branding_image(name, sw, sh)` composes raster-then-SVG: tries
`theme_branding_raster` first, falls back to `theme_branding_asset(name,
"svg")` only when no raster files exist anywhere in the chain. Pass
`(0, 0)` to get the smallest available raster — every entry trivially
covers a zero-sized surface. Useful at startup before the
surface-configure event arrives, so the first frame paints from a
fast-decoded lightweight raster (typically a few ms) instead of the
SVG (typically 1-2 s for a gradient-heavy abstract wallpaper).
### Runtime API
```rust
// SVG resolution — mode-aware fallback chain on the file path.
let launcher = ltk::theme_launcher_icon(); // Option<PathBuf>
let wallpaper = ltk::theme_wallpaper(); // Option<WallpaperSpec>
let lockscreen = ltk::theme_lockscreen(); // Option<WallpaperSpec>
// Sized raster (WebP / PNG / JPEG) — pick the smallest covering variant.
let raster = ltk::theme_branding_raster( "wallpaper", 1920, 1080 );
// -> Option<PathBuf>
// Combined: raster first, SVG fallback. Recommended for wallpaper /
// lockscreen consumers that have the surface size at decode time.
let path = ltk::theme_branding_image( "wallpaper", 1920, 1080 );
// Brand wordmark / icon — three named variants under branding/{mode}/logo/.
let about_logo = ltk::theme_logo(); // Option<PathBuf> — primary
let app_icon = ltk::theme_logo_square(); // Option<PathBuf> — 1:1
let header_lg = ltk::theme_logo_horizontal(); // Option<PathBuf> — wordmark
// Generic helper for arbitrary branded SVG assets (splash, watermarks, …).
let splash = ltk::theme_branding_asset( "splash", "svg" );
```
`branding_asset(name, ext)` is the underlying SVG helper; `branding_raster`
and `branding_image` add the size-aware layer on top. The three `theme_logo*`
helpers are thin wrappers over `branding_asset( "logo/<variant>", "svg" )` —
the `name` argument freely accepts a `subdir/file` shape, so any other
sub-grouped asset family follows the same pattern.
## Icons
Two parallel trees under `icons/`:
- **`icons/apps/`** — per-application icons (`firefox.svg`,
`calculator.svg`, …). Each app's brand identity, mode-agnostic.
Looked up via [`theme::app_icon( "firefox" )`].
- **`icons/catalogue/`** — symbolic glyph catalogue intended for tinting
at runtime. Two style variants:
- `filled/` — solid silhouettes. Preferred by default.
- `line/` — outlined variants. Used as fallback when `filled` doesn't
ship a given icon. A theme that prefers a line aesthetic puts its
SVGs in `filled/` (where the lookup goes first); the directory
name reflects the *style of the asset*, not the lookup precedence.
Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`,
`archives`, `communication`, `controls`, `customisation`, `energy`,
`features`, `feedback`, `general`, `hardware`, `keyboard`, `multimedia`,
`navigation`, `safety`, `session`, `system`, `window`.
### Adding an icon
1. Drop a monochrome SVG into
`catalogue/filled/<category>/<name>.svg`. Convention: `<svg
fill="none">` at the root and an explicit fill on the path (e.g.
`<g fill="#000000">`). The rasteriser keeps only the alpha channel
for symbolic tinting, so the actual RGB of the source doesn't
matter — pick `#000000` for consistency with the rest of the
catalogue.
2. Optionally ship the `line/` variant in
`catalogue/line/<category>/<name>.svg`.
3. Reference it from widget code by its slash-separated stem, no
extension: `theme::icon_path( "general/down-simple" )`.
### Runtime API
```rust,no_run
# fn _ex() -> Option<()> {
// Path resolution. Tries filled/<name>.svg first, then line/<name>.svg.
let path = ltk::theme::icon_path( "window/close" );
// -> Option<PathBuf>
// Rasterise to RGBA8 (cached per (path, size) for the lifetime of the
// active document — set_active_document flushes the cache).
let ( rgba, w, h ) = ltk::theme::icon_rgba( "window/close", 16 )?;
// Tint a symbolic icon: keep the alpha, replace the RGB with `tint`.
// `palette.icon` is the catalogue tint; for window-chrome glyphs use
// `ltk::theme_window_controls().icon` instead.
let palette = ltk::theme_palette();
let tinted = ltk::theme::tint_symbolic( &rgba, palette.icon );
# Some( () )
# }
```
The `icon_rgba` + `tint_symbolic` pair is the standard pipeline for
catalogue and chrome icons: rasterise once, recolour per mode via
palette tokens. Themes ship a single SVG per glyph and the per-mode
look comes from code, not from duplicated assets.
## Localisation
ltk integrates `rust-i18n` for built-in widget strings (context-menu
labels, calendar month / day-of-week names, …). Locale files live in
`ltk/locales/<lang>.yaml`. English is the fallback. Currently shipped:
`en`, `es`, `fr`, `it`, `de`, `pt`, `pt_BR`.
### Existing keys
```yaml
context_menu:
copy: "Copy"
cut: "Cut"
paste: "Paste"
delete: "Delete"
date_picker:
month_1: "January"
...
month_12: "December"
dow_short_0: "S"
...
dow_short_6: "S"
```
`dow_short_<n>` is indexed from each locale's `first_dow` (Sunday-first
in `en`, Monday-first in `es` / `fr` / `it` / `de` / `pt` / `pt_BR`).
The `Locale` struct ships `first_dow: u8` and the date picker indexes
`dow_short_*` accordingly.
Built-in widgets read these via `rust_i18n::t!( "context_menu.copy" )`
at render time, so switching locale at runtime via
`rust_i18n::set_locale( "es" )` flips the UI on the next frame without
reconstructing widgets.
### Adding a string
1. Pick a key like `my_widget.label`.
2. Add it to **every** file under `ltk/locales/` so each language has a
translation (English at minimum is required — it's the fallback).
3. Read it from widget code with `rust_i18n::t!( "my_widget.label" )`.
### Adding a language
1. Create `ltk/locales/<code>.yaml` with all existing keys translated.
2. The `i18n!("locales", fallback = "en")` macro at the crate root picks
it up automatically — no registration step needed.
3. Apps select the locale at startup via
`rust_i18n::set_locale( "<code>" )`.
## Common errors
| Symptom | Cause | Fix |
| --- | --- | --- |
| Red banner on every frame | No theme found at any of the three search paths | Install `ltk-theme-default` Debian package or set `LTK_THEMES_DIR` |
| `unknown reference @foo` at startup | Typo in `@name` reference | Check the `colors` / `gradients` / `inset_stacks` section spelling |
| `unknown field 'foo'` | Stale schema after a `ltk` upgrade | Compare against this document and the default theme |
| Slot is rendering with the wrong colour after `set_active_mode` | App caches a `Color` from a previous frame | Read palette / surface inside `view()` each frame; the per-frame `Arc` clone is cheap |
## Custom themes
A custom theme directory can live anywhere under
`$XDG_DATA_HOME/ltk/themes/`. Three practical recipes:
- **Repaint for a brand**: copy `themes/default/`, then change:
- the `colors` palette in `theme.json` (everything else cascades),
- the up-to-six SVGs in `branding/{light,dark}/` (launcher,
wallpaper, lockscreen),
- leave the gradients, inset stacks, slot wiring and the entire
`icons/` tree untouched.
- **Override a single icon**: drop a replacement SVG at the same
catalogue path under your theme's
`icons/catalogue/filled/<category>/<name>.svg`. The `icon_path`
lookup resolves against whichever theme is active, so a partial
catalogue overlays the default cleanly without forking the rest.
- **Build a flat-only theme**: drop every `backdrop` block from the
slots and route every `surface-*-flat` slot to a solid `colors`
reference. Visual parity with the software backend is automatic.
All three recipes keep the slot ids and reference shapes intact, so the
built-in widgets continue to work without code changes.

851
docs/widgets.md Normal file
View File

@@ -0,0 +1,851 @@
# ltk widget catalogue
A flat reference of every widget and layout exported at the crate root.
Each entry has the same four sections: **what it is**, **when to use it**,
**minimal example**, **see also**. The full builder API and per-method
docs live on `cargo doc`; this file is the at-a-glance index.
For mental model and architecture, read [`docs/onboarding.md`](./onboarding.md)
and [`docs/architecture.md`](./architecture.md) first. For copy-pasteable
patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
## Table of contents
- [Buttons and activations](#buttons-and-activations)
- [`button`](#button) · [`icon_button`](#icon_button) · [`pressable`](#pressable) · [`window_button`](#window_button) · [`list_item`](#list_item)
- [Stateful binary controls](#stateful-binary-controls)
- [`toggle`](#toggle) · [`checkbox`](#checkbox) · [`radio`](#radio)
- [Continuous controls](#continuous-controls)
- [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar)
- [Text input and display](#text-input-and-display)
- [`text`](#text) · [`text_edit`](#text_edit)
- [Decoration and chrome](#decoration-and-chrome)
- [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget)
- [Clipping wrappers](#clipping-wrappers)
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex)
- [Overlays and feedback](#overlays-and-feedback)
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
- [Pickers](#pickers)
- [`date_picker`](#date_picker) · [`time_picker`](#time_picker) · [`color_picker`](#color_picker)
- [Layouts](#layouts)
- [`column`](#column) · [`row`](#row) · [`stack`](#stack) · [`grid`](#grid) · [`spacer`](#spacer)
---
## Buttons and activations
### `button`
A standard text button. Activates on tap, Enter, or Space when focused.
**When**: any place a normal app would have a "Save" / "Cancel" / "Send"
control.
```rust,no_run
# use ltk::{ button, Element };
# #[ derive( Clone ) ] enum Msg { Save }
# fn _ex() -> Element<Msg> {
button( "Save" ).on_press( Msg::Save )
.into()
# }
```
**See also**: [`pressable`](#pressable) for activation on a richer
custom-shaped surface, [`icon_button`](#icon_button) for image-only
buttons.
### `icon_button`
A button whose visual is an RGBA bitmap instead of text. Same dispatch
shape as [`button`](#button).
**When**: toolbar icons, system tray glyphs, anywhere the visual is
icon-first.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ icon_button, Element };
# #[ derive( Clone ) ] enum Msg { OpenSearch }
# fn _ex( rgba_bytes: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch )
.into()
# }
```
**See also**: [`button`](#button), [`window_button`](#window_button)
(specialised icon button for window decorations).
### `pressable`
Wraps any [`Element`](../src/widget/mod.rs) so it dispatches a press
message. Invisible to drawing — the wrapped child paints itself.
**When**: a card, a custom-styled list row, or any non-trivial visual
that should behave like a button. Inner widgets that are themselves
interactive (a button nested inside) keep priority.
```rust,no_run
# use ltk::{ column, container, pressable, row, Element, Pressable };
# #[ derive( Clone ) ] enum Msg { OpenWifiPicker }
# fn _ex(
# icon: Element<Msg>,
# title: Element<Msg>,
# subtitle: Element<Msg>,
# ) -> Pressable<Msg> {
pressable(
container( row()
.push( icon )
.push( column().push( title ).push( subtitle ) ) )
.surface( "surface-card" )
)
.on_press( Msg::OpenWifiPicker )
# }
```
**See also**: [`button`](#button) when a plain text button is enough,
[`list_item`](#list_item) for the standard "label + subtitle + trailing"
pattern.
### `window_button`
A title-bar control button (minimize / maximize / restore / close).
Comes with a special hover-tint for `Close`.
**When**: building custom server-side window decorations.
```rust,no_run
# use ltk::{ window_button, window_controls, Element, WindowButton, WindowButtonKind };
# #[ derive( Clone ) ] enum Msg { CloseWindow, Minimize, Maximize, Close }
# fn _ex() -> ( WindowButton<Msg>, Element<Msg> ) {
let close = window_button( WindowButtonKind::Close ).on_press( Msg::CloseWindow );
// Or the full standard set:
let bar = window_controls(
Some( Msg::Minimize ),
WindowButtonKind::Maximize,
Some( Msg::Maximize ),
Some( Msg::Close ),
);
# ( close, bar.into() )
# }
```
`focusable( true )` opts the button into the Tab cycle (off by default
to match desktop convention).
**See also**: [`crate::theme_window_controls`] for the colour tokens
each mode supplies.
### `list_item`
A row with a primary label, optional subtitle, optional right-aligned
trailing text, and a tappable surface.
**When**: settings menus, navigation lists, contact rows. Doubles its
height when a subtitle is set.
```rust,no_run
# use ltk::{ list_item, ListItem };
# #[ derive( Clone ) ] enum Msg { OpenWifi }
# fn _ex() -> ListItem<Msg> {
list_item( "Wi-Fi" )
.subtitle( "Eduroam" )
.trailing( "" )
.on_press( Msg::OpenWifi )
# }
```
**See also**: [`pressable`](#pressable) for free-form tappable rows,
[`scroll`](#scroll) to wrap a list of items in a scrollable container.
---
## Stateful binary controls
### `toggle`
A two-state on / off switch. Renders as a horizontal pill with a sliding
thumb.
**When**: prominent settings toggles ("Wi-Fi", "Do not disturb").
```rust,no_run
# use ltk::{ toggle, Toggle };
# #[ derive( Clone ) ] enum Msg { ToggleWifi }
# struct App { wifi_enabled: bool }
# impl App { fn _ex( &self ) -> Toggle<Msg> {
toggle( self.wifi_enabled )
.label( "Wi-Fi" )
.on_toggle( Msg::ToggleWifi )
# }}
```
**See also**: [`checkbox`](#checkbox) for less prominent opt-ins,
[`radio`](#radio) for mutually-exclusive groups.
### `checkbox`
A two-state opt-in with a square box and a check glyph.
**When**: form fields, terms acceptance, multi-select lists.
```rust,no_run
# use ltk::{ checkbox, Checkbox };
# #[ derive( Clone ) ] enum Msg { ToggleTerms }
# struct App { accept_terms: bool }
# impl App { fn _ex( &self ) -> Checkbox<Msg> {
checkbox( self.accept_terms )
.label( "I accept the terms" )
.on_toggle( Msg::ToggleTerms )
# }}
```
**See also**: [`toggle`](#toggle), [`radio`](#radio).
### `radio`
A single option inside a mutually-exclusive group. Build one per
variant; the application owns "which is selected".
**When**: priority pickers, layout choices, anywhere exactly one of N
is selected.
```rust,no_run
# use ltk::{ column, radio, Element };
# #[ derive( Clone, PartialEq ) ] enum Priority { Low, Medium, High }
# #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) }
# struct App { priority: Priority }
# impl App { fn _ex( &self ) -> Element<Msg> {
column()
.push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) )
.push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) )
.push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) )
.into()
# }}
```
**See also**: [`checkbox`](#checkbox), [`toggle`](#toggle).
---
## Continuous controls
### `slider`
A horizontal slider for selecting a value in `[0.0, 1.0]`.
**When**: brightness / volume / scrub bars. Drag the thumb or tap a
position; the change message fires continuously during drag.
```rust,no_run
# use ltk::{ slider, Slider };
# #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) }
# struct App { brightness: f32 }
# impl App { fn _ex( &self ) -> Slider<Msg> {
slider( self.brightness )
.on_change( |v| Msg::SetBrightness( v ) )
# }}
```
`accent_thumb( true )` swaps the default thumb for the two-circle
brand-coloured variant. `track_surface( id )` and `fill_surface( id )`
override the default theme slots.
**See also**: [`vslider`](#vslider) for the vertical axis,
[`progress_bar`](#progress_bar) for read-only progress display.
### `vslider`
A vertical slider. Same value model as [`slider`](#slider) — `0.0` at
the bottom, `1.0` at the top.
**When**: column-shaped equalisers, compact volume / brightness picks
inside narrow side panels.
```rust,no_run
# use ltk::{ vslider, VSlider };
# #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
# struct App { volume: f32 }
# impl App { fn _ex( &self ) -> VSlider<Msg> {
vslider( self.volume ).on_change( |v| Msg::SetVolume( v ) )
# }}
```
**See also**: [`slider`](#slider).
### `progress_bar`
A read-only linear progress indicator.
**When**: determinate operations with a known fraction
(downloads, file copies, install steps).
```rust,no_run
# use ltk::{ progress_bar, ProgressBar };
# struct App { download_fraction: f32 }
# impl App { fn _ex( &self ) -> ProgressBar {
progress_bar( self.download_fraction )
# }}
```
**See also**: [`spinner`](#spinner) for indeterminate operations
without a known fraction.
---
## Text input and display
### `text`
A single-line label. Truncates with an ellipsis when wider than its
allocated rect.
**When**: titles, captions, anything non-interactive.
```rust,no_run
# use ltk::{ text, Color, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> ( Element<Msg>, Element<Msg> ) {
let title = text( "Title" ).size( 24.0 ).color( Color::WHITE );
let centred = text( "Centred" ).align_center();
# ( title.into(), centred.into() )
# }
```
**See also**: [`text_edit`](#text_edit) for editable text.
### `text_edit`
A single-line text input with cursor, Backspace, Enter / Submit,
clipboard support, a `secure( true )` password mode that masks the
visible characters and zeroizes the buffer on drop, and a
`password_toggle( visible, on_toggle )` builder that pins a show /
hide-password eye icon to the right edge of the field.
**When**: login fields, chat inputs, search boxes.
```rust,no_run
# use ltk::{ text_edit, Element };
# #[ derive( Clone ) ] enum Msg {
# UsernameChanged( String ), PasswordChanged( String ),
# TogglePassword, Submit,
# }
# struct App { username: String, password: String, show_password: bool }
# impl App { fn _ex( &self ) -> ( Element<Msg>, Element<Msg> ) {
let user = text_edit( "Username", &self.username )
.on_change( |s| Msg::UsernameChanged( s ) )
.on_submit( Msg::Submit );
// Password field with the built-in show / hide eye. The widget
// owns the icon hit-testing — taps inside the eye zone fire
// `TogglePassword` instead of moving the caret. Flip the bool in
// your `update`; the widget reads it back on the next render.
let pw = text_edit( "Password", &self.password )
.on_change( |s| Msg::PasswordChanged( s ) )
.password_toggle( self.show_password, Msg::TogglePassword );
# ( user.into(), pw.into() )
# }}
```
`password_toggle` keeps the wipe-on-drop and IME-bypass guarantees
of `secure( true )` regardless of the current visibility, so
flipping the eye does not weaken the field's threat model at
runtime — only what the user sees on screen changes.
**See also**: the password recipe in
[`docs/cookbook.md`](./cookbook.md#password-field-with-pam-submit).
---
## Decoration and chrome
### `container`
A wrapper that adds padding, a background colour or themed surface, a
border radius, and (via theme slots) shadows / inset shadows / backdrop
blur.
**When**: cards, panels, callouts, anywhere a child needs a backdrop or
elevation.
```rust,no_run
# use ltk::{ column, container, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( title: Element<Msg>, subtitle: Element<Msg> ) -> Element<Msg> {
container( column().push( title ).push( subtitle ) )
.surface( "surface-card" )
.padding( 16.0 )
.radius( 24.0 )
.into()
# }
```
Per-edge padding is available with `padding_top` / `padding_right` /
`padding_bottom` / `padding_left`. Per-corner radius takes a [`Corners`]
struct or a tuple.
**See also**: [`pressable`](#pressable) wrapping a `container` makes a
card interactive.
### `separator`
A horizontal divider line with theme-default colour and 1 px thickness.
**When**: visual breaks between settings groups, list categories,
content blocks.
```rust,no_run
# use ltk::{ column, separator, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
column()
.push( text( "General" ) )
.push( separator() )
.push( text( "Network" ) )
.into()
# }
```
### `img_widget`
A static image rendered from RGBA pixel data shared via `Arc`.
**When**: wallpapers, icons, illustrations. The shared `Arc` makes
re-using the same buffer across frames a pointer copy.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ img_widget, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
img_widget( rgba_bytes, width, height )
.opacity( 0.8 )
.into()
# }
```
`cover()` scales to fill the rect preserving aspect; `size( w, h )` sets
explicit display dimensions.
**See also**: [`Image::from_path`](../src/widget/image.rs) helper for
disk-loaded files (PNG, JPEG via the `image` crate).
---
## Clipping wrappers
### `scroll`
A vertically-scrollable viewport. Drag the content to scroll; clipping
is automatic.
**When**: lists or grids that may overflow the available height.
```rust,no_run
# use ltk::{ column, list_item, scroll, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Element<Msg> {
scroll(
column()
.push( list_item::<Msg>( "Item 1" ) )
.push( list_item( "Item 2" ) )
.push( list_item( "Item 3" ) )
)
.into()
# }
```
The scroll widget owns its own gesture handling — drags inside it do
not trigger the app-level `on_swipe_*` callbacks.
**See also**: [`viewport`](#viewport) for passive clipping without
gestures, [`grid`](#grid) inside a `scroll` for app drawers.
### `viewport`
A passive clipping wrapper. Clips its child to the assigned rect; no
scrolling, no gesture handling.
**When**: panels that animate in via a parent translation, fade-in
content, anywhere you want a hard clip without scrolling. The
`fade_bottom( px )` builder feathers the bottom edge to transparent
during slide-in animations (GLES backend; software backend renders a
hard edge).
```rust,no_run
# use ltk::{ viewport, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( panel_view: Element<Msg>, panel_height: f32 ) -> Element<Msg> {
viewport( panel_view )
.height( panel_height )
.fade_bottom( 16.0 )
.into()
# }
```
**See also**: the slide-in panel recipe in
[`docs/cookbook.md`](./cookbook.md#slide-in-panel).
### `flex`
A row-only filler wrapper. Treats its non-spacer child like a
[`spacer`](#spacer) for leftover-width distribution but draws the child
inside the allocated rect.
**When**: a row where one non-trivial child should fill the remaining
width (a card next to a fixed-size icon).
```rust,no_run
# use ltk::{ column, flex, row, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex(
# icon: Element<Msg>,
# title: Element<Msg>,
# subtitle: Element<Msg>,
# ) -> Element<Msg> {
row()
.push( icon )
.push( flex( column().push( title ).push( subtitle ) ) )
.into()
# }
```
`weight( n )` sets the relative share when there are several flex /
spacer siblings.
**See also**: [`spacer`](#spacer) for invisible fillers,
[`column`](#column) and [`row`](#row).
---
## Overlays and feedback
### `spinner`
Indeterminate progress indicator. Animates while in the tree.
**When**: long-running operations with no known fraction
(network calls, indexing, "waiting for compositor").
```rust,no_run
# use ltk::{ spinner, Spinner };
# fn _ex() -> Spinner {
spinner().size( 24.0 )
# }
```
**See also**: [`progress_bar`](#progress_bar) for determinate progress.
### `toast`
Transient notification that floats over the surface and dismisses
itself after a timeout.
**When**: confirmation snackbars ("Saved"), non-blocking errors,
status flashes.
```rust,no_run
# use ltk::{ toast, Toast };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Toast<Msg> {
toast( "Saved" ).duration( 3.0 )
# }
```
**See also**: [`tooltip`](#tooltip) for hover-anchored hints.
### `tooltip`
Anchored hint that appears next to a target widget on hover or focus.
**When**: discoverable affordances on icon-only controls, keyboard
shortcut reminders, helper text that should not occupy permanent
layout space.
```rust,no_run
# use ltk::{ tooltip, Tooltip, WidgetId };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex() -> Tooltip<Msg> {
tooltip( "Ctrl+S", WidgetId( "btn/save" ) ).max_width( 240 )
# }
```
The widget paints next to the widget that registered the matching
`WidgetId`. See [`docs/cookbook.md`](./cookbook.md) for the full
overlay wiring.
**See also**: [`toast`](#toast) for transient self-dismissing
notifications.
### `combo`
Editable single-select with a popup list. Supports type-to-filter
and free-text entry.
**When**: pick-one fields with too many options for a row of
[`radio`](#radio) buttons but where a free typed answer is also
valid (autocomplete, country list, theme picker).
```rust,no_run
# use ltk::{ combo, Combo, ComboState };
# #[ derive( Clone ) ] enum Msg { Pick( usize ) }
# fn _ex( state: ComboState ) -> Combo<Msg> {
let items = vec![
"One".to_string(),
"Two".to_string(),
"Three".to_string(),
];
combo( state, items ).on_select_idx( Msg::Pick )
# }
```
**See also**: [`radio`](#radio) for small mutually-exclusive sets,
[`notebook`](#notebook) for tabbed mode switches.
### `tabs`
A bare tab bar — visual selection without owning the page content.
Use this when you want full control over what each tab shows.
**When**: paged settings screens where the panes are large and you
want to keep them as siblings in the tree, not as
[`notebook`](#notebook) children.
```rust,no_run
# use ltk::{ tabs, TabBar };
# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) }
# fn _ex( active: usize ) -> TabBar<Msg> {
tabs::<Msg, _, _>( [ "Profile", "Network", "Privacy" ] )
.selected( active )
.on_select( Msg::TabChanged )
# }
```
**See also**: [`notebook`](#notebook) for tabs that own their pages.
### `notebook`
Tabbed container. Owns the pages and shows only the active one.
**When**: settings dialogs, multi-step forms, anywhere a flat
[`tabs`](#tabs) bar over hand-managed pages would be more code than
benefit.
```rust,no_run
# use ltk::{ notebook, text, Notebook };
# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) }
# fn _ex( active: usize ) -> Notebook<Msg> {
notebook::<Msg>()
.page( "General", text( "general body" ) )
.page( "Advanced", text( "advanced body" ) )
.selected( active )
.on_select( Msg::TabChanged )
# }
```
**See also**: [`tabs`](#tabs) for a bare tab bar without page
ownership.
### `dialog`
Modal or non-modal centered confirmation card with a built-in scrim,
optional title, subtitle, custom body, and a right-aligned action
row. Pressing `Esc` fires the configured `cancel` message.
**When**: destructive confirmations ("Delete file?"), guided pickers
that block other interaction until resolved, "are you sure" gates
before a long-running operation.
```rust,no_run
# use ltk::{ button, dialog, ButtonVariant, Element };
# #[ derive( Clone ) ] enum Msg { Cancel, Confirm }
# fn _ex() -> Element<Msg> {
dialog()
.title( "Delete partition?" )
.subtitle( "This will erase every file on /dev/sda2." )
.cancel( Msg::Cancel )
.action( button::<Msg>( "Cancel" ).variant( ButtonVariant::Tertiary ).on_press( Msg::Cancel ) )
.action( button::<Msg>( "Delete" ).variant( ButtonVariant::Primary ).on_press( Msg::Confirm ) )
.into()
# }
```
`modal( false ) + dismiss_on_scrim( msg )` makes a tap on the dim
background fire `msg`; combining `dismiss_on_scrim` with `modal( true )`
panics at lower time (the contracts contradict). `body( elem )`
swaps a custom element in between the subtitle and the action row —
the example app at `examples/dialog.rs` uses this for an in-dialog
slider.
**See also**: [`toast`](#toast) for non-blocking transient
notifications, [`combo`](#combo) for a single-pick popup that does
not require modal blocking.
---
## Pickers
### `date_picker`
Calendar-grid date selector with month / year navigation.
**When**: birthdays, deadlines, any single-date input.
```rust,no_run
# use ltk::{ date_picker, Date, DatePicker };
# #[ derive( Clone ) ] enum Msg { Picked( Date ) }
# fn _ex() -> DatePicker<Msg> {
date_picker( Date::new( 2026, 5, 7 ) ).on_change( Msg::Picked )
# }
```
### `time_picker`
Hour / minute selector. Wheel-style scrubbing on touch.
**When**: alarms, scheduling, time-of-day input.
```rust,no_run
# use ltk::{ time_picker, Time, TimePicker };
# #[ derive( Clone ) ] enum Msg { Picked( Time ) }
# fn _ex( now: Time ) -> TimePicker<Msg> {
time_picker( now ).on_change( Msg::Picked )
# }
```
### `color_picker`
Hue + saturation/value picker with a hex/RGB readout.
**When**: theming UIs, paint tools, accent customisation.
```rust,no_run
# use ltk::{ color_picker, Color, ColorPicker };
# #[ derive( Clone ) ] enum Msg { Picked( Color ) }
# fn _ex( current: Color ) -> ColorPicker<Msg> {
color_picker( current ).on_change( Msg::Picked )
# }
```
---
## Layouts
### `column`
Vertical flow. Children stack top-to-bottom with optional padding,
spacing, alignment.
```rust,no_run
# use ltk::{ button, column, spacer, text, Element };
# #[ derive( Clone ) ] enum Msg { Ok }
# fn _ex() -> Element<Msg> {
column()
.padding( 24.0 )
.spacing( 12.0 )
.push( text( "Title" ) )
.push( spacer() )
.push( button( "OK" ).on_press( Msg::Ok ) )
.into()
# }
```
`max_width(px)` caps the inner content width; `fit_content()` reports
the natural content width to the parent (otherwise the column claims
the full available width). `center_y( true )` centres the content
block vertically when there are no spacers.
### `row`
Horizontal flow. Mirror of [`column`](#column) on the X axis.
```rust,no_run
# use ltk::{ flex, row, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex(
# label: Element<Msg>,
# field: Element<Msg>,
# submit_button: Element<Msg>,
# ) -> Element<Msg> {
row()
.spacing( 8.0 )
.push( label )
.push( flex( field ) )
.push( submit_button )
.into()
# }
```
### `stack`
Z-order overlay. Each child gets explicit horizontal and vertical
alignment (`HAlign` / `VAlign`) plus an optional margin and pixel
translation. Children are drawn in declaration order — the last child
sits on top.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ button, column, img_widget, stack, text, Element, HAlign, VAlign };
# #[ derive( Clone ) ] enum Msg { Add }
# fn _ex( background: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
stack()
.push( img_widget( background, w, h ) )
.push_aligned(
column().push( text( "Heading" ) ),
HAlign::Center, VAlign::Center,
)
.push_aligned_margin(
button( "+" ).on_press( Msg::Add ),
HAlign::End, VAlign::Bottom,
16.0,
)
.into()
# }
```
**See also**: [`column`](#column) and [`row`](#row) for flow layout.
### `grid`
Fixed-column-count grid that wraps its children into rows.
**When**: icon launchers, photo galleries, app drawers.
```rust,no_run
# use std::sync::Arc;
# use ltk::{ grid, icon_button, Element };
# #[ derive( Clone ) ] enum Msg { OpenApp1, OpenApp2 }
# fn _ex( app1_rgba: Arc<Vec<u8>>, app2_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
grid( 4 )
.padding( 16.0 )
.spacing( 12.0 )
.push( icon_button( app1_rgba, w, h ).on_press( Msg::OpenApp1 ) )
.push( icon_button( app2_rgba, w, h ).on_press( Msg::OpenApp2 ) )
// ...
.into()
# }
```
Wrap inside [`scroll`](#scroll) when the grid may overflow.
### `spacer`
An invisible flexible filler. Inside a column / row, absorbs leftover
space along the parent's main axis.
```rust,no_run
# use ltk::{ column, spacer, Element };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( header: Element<Msg>, footer: Element<Msg> ) -> Element<Msg> {
column()
.push( header )
.push( spacer() ) // pushes footer to the bottom
.push( footer )
.into()
# }
```
`weight( n )` sets the relative share; `height( px )` / `width( px )`
pin the spacer to a fixed size on its respective axis.
**See also**: [`flex`](#flex) for a non-empty filler.

227
examples/combo.rs Normal file
View File

@@ -0,0 +1,227 @@
//! `cargo run --example combo`
//!
//! Showcases the `combo` widget — a select / dropdown with editable
//! query, multi-select chips and a scrollable popup.
//!
//! The trigger lives in the main `view()` tree; the popup is returned
//! from [`App::overlays`] as a real Wayland **xdg-popup** child of the
//! main window, so it can extend outside the parent surface (the
//! canonical select / dropdown behaviour). The compositor positions the
//! popup adjacent to the trigger pill and dismisses it via
//! `popup_done` when the user clicks outside, which fires the spec's
//! `on_dismiss` message.
//!
//! Tap on an item to add it to the selection; tap on the `×` next to a
//! chip to remove it; tap outside the popup to dismiss. Esc exits.
use ltk::
{
combo, column, row, separator, spacer, text,
App, Color, ComboState, Element, Keysym, OverlaySpec, WidgetId,
};
// Distinct anchor ids so each combo's popup can find its own trigger
// pill in the previous-frame layout snapshot.
const FRUIT_ANCHOR: WidgetId = WidgetId( "demo-fruit-trigger" );
const REGION_ANCHOR: WidgetId = WidgetId( "demo-region-trigger" );
#[ derive( Clone, Debug ) ]
enum Msg
{
// Multi-select fruit picker.
FruitToggle,
FruitQuery( String ),
FruitSelect( usize ),
FruitUnselect( usize ),
FruitDismiss,
// Single-select region picker (non-searchable).
RegionToggle,
RegionSelect( usize ),
RegionDismiss,
}
struct Demo
{
fruits: ComboState,
fruit_items: Vec<String>,
region: ComboState,
region_items: Vec<String>,
}
impl Demo
{
fn new() -> Self
{
Self
{
fruits: ComboState::new(),
fruit_items: [
"Apple", "Apricot", "Avocado", "Banana", "Blackberry",
"Blueberry", "Cherry", "Date", "Fig", "Grape", "Kiwi",
"Lemon", "Lime", "Mango", "Orange", "Papaya", "Peach",
"Pear", "Pineapple", "Plum", "Raspberry", "Strawberry",
].iter().map( |s| s.to_string() ).collect(),
region: ComboState::new(),
region_items: [
"Galicia", "Asturias", "Cantabria", "País Vasco",
"Navarra", "La Rioja", "Aragón", "Cataluña",
].iter().map( |s| s.to_string() ).collect(),
}
}
fn fruit_combo( &self ) -> ltk::Combo<Msg>
{
combo( self.fruits.clone(), self.fruit_items.clone() )
.label( "Fruits" )
.description( "Pick the ones you want in the smoothie." )
.placeholder( "Type to filter…" )
.helper( "Selected fruits show up as chips above the field." )
.multi_select( true )
.searchable( true )
.anchor_id( FRUIT_ANCHOR )
.popup_max_height( 280.0 )
.on_query_change( Msg::FruitQuery )
.on_toggle_open( Msg::FruitToggle )
.on_select_idx( Msg::FruitSelect )
.on_unselect_idx( Msg::FruitUnselect )
.on_dismiss( Msg::FruitDismiss )
}
fn region_combo( &self ) -> ltk::Combo<Msg>
{
combo( self.region.clone(), self.region_items.clone() )
.label( "Region" )
.description( "Single-select, non-searchable." )
.placeholder( "Choose a region…" )
.anchor_id( REGION_ANCHOR )
.popup_max_height( 240.0 )
.on_toggle_open( Msg::RegionToggle )
.on_select_idx( Msg::RegionSelect )
.on_dismiss( Msg::RegionDismiss )
}
fn body( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
column::<Msg>()
.padding( 32.0 )
.spacing( 24.0 )
.center_y( false )
.push(
text( "ltk — combo / select demo" )
.size( 24.0 )
.color( palette.text_primary )
.align_center(),
)
.push( separator() )
.push( self.fruit_combo().trigger() )
.push( separator() )
.push( self.region_combo().trigger() )
.push( spacer() )
.push(
row::<Msg>()
.padding( 0.0 )
.spacing( 8.0 )
.push(
text( format!( "Fruits selected: {}", self.fruits.selected.len() ) )
.size( 14.0 )
.color( palette.text_secondary ),
)
.push( spacer() )
.push(
text(
self.region.selected.first()
.and_then( |&i| self.region_items.get( i ) )
.map( |s| format!( "Region: {s}" ) )
.unwrap_or_else( || "Region: —".to_string() ),
)
.size( 14.0 )
.color( palette.text_secondary ),
),
)
.push(
text( "Esc = quit" )
.size( 12.0 )
.color( palette.text_secondary )
.align_center(),
)
.into()
}
}
impl App for Demo
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
self.body()
}
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
// Each open combo contributes one xdg-popup overlay anchored
// to its trigger pill; closed combos contribute nothing.
let mut out = Vec::new();
if let Some( o ) = self.fruit_combo().overlay() { out.push( o ); }
if let Some( o ) = self.region_combo().overlay() { out.push( o ); }
out
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::FruitToggle => self.fruits.toggle_open(),
Msg::FruitQuery( q ) => self.fruits.query = q,
Msg::FruitSelect( i ) =>
{
if self.fruits.selected.contains( &i )
{
self.fruits.unselect( i );
}
else
{
self.fruits.select( i );
}
}
Msg::FruitUnselect( i ) => self.fruits.unselect( i ),
Msg::FruitDismiss =>
{
self.fruits.is_open = false;
self.fruits.query.clear();
}
Msg::RegionToggle => self.region.toggle_open(),
Msg::RegionSelect( i ) =>
{
// Single-select: replace the whole `selected` vector
// with just this index, then close.
self.region.selected = vec![ i ];
self.region.is_open = false;
}
Msg::RegionDismiss => self.region.is_open = false,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
fn background_color( &self ) -> Color
{
ltk::theme_palette().bg
}
}
fn main()
{
ltk::run( Demo::new() );
}

231
examples/dialog.rs Normal file
View File

@@ -0,0 +1,231 @@
//! Example for the `dialog` widget.
//!
//! Demonstrates the three shapes the widget covers:
//!
//! * **Modal confirm** — title + subtitle + "Cancel" / "Delete"
//! actions. Backed by `cancel( Msg::Cancel )` so pressing `Esc`
//! matches tapping the Cancel button. Pointer events outside the
//! card are silently absorbed.
//! * **Non-modal pick** — `modal( false ) + dismiss_on_scrim( … )`. A
//! tap on the scrim outside the card emits the dismiss message; ESC
//! still fires `cancel`.
//! * **Custom body** — a body element (here, a slider) injected
//! between the subtitle and the action row.
use ltk::{
App, ButtonVariant, Element,
button, column, dialog, row, slider, spacer, stack, text,
};
#[ derive( Clone, Copy, PartialEq, Eq ) ]
enum Open
{
None,
Modal,
NonModal,
CustomBody,
}
#[ derive( Clone ) ]
enum Msg
{
Open( Open ),
Close,
Confirm,
PickAlpha,
PickBeta,
PickGamma,
BrightnessChanged( f32 ),
}
struct DialogApp
{
open: Open,
last_event: String,
brightness: f32,
}
impl DialogApp
{
fn new() -> Self
{
Self
{
open: Open::None,
last_event: "(no dialog interactions yet)".to_string(),
brightness: 0.5,
}
}
}
impl App for DialogApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let header = column::<Msg>()
.spacing( 8.0 )
.push( text( "ltk · dialog widget" ).size( 24.0 ).color( primary ) )
.push( text( "Open one of the dialogs below. ESC always fires the dialog's `cancel` message." )
.size( 14.0 )
.color( secondary )
.wrap( true ) );
let openers = row::<Msg>()
.spacing( 12.0 )
.push(
button::<Msg>( "Modal confirm" )
.variant( ButtonVariant::Primary )
.on_press( Msg::Open( Open::Modal ) ),
)
.push(
button::<Msg>( "Non-modal pick" )
.variant( ButtonVariant::Secondary )
.on_press( Msg::Open( Open::NonModal ) ),
)
.push(
button::<Msg>( "Custom body" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::Open( Open::CustomBody ) ),
);
let event_label = text( format!( "Last event: {}", self.last_event ) )
.size( 13.0 )
.color( secondary )
.wrap( true );
let main = column::<Msg>()
.spacing( 24.0 )
.padding( 32.0 )
.push( header )
.push( openers )
.push( spacer() )
.push( event_label );
// Build the active dialog (if any) and stack it on top of the
// main column. Stack draws children in order, so the dialog
// always sits over the main UI.
let overlay: Option<Element<Msg>> = match self.open
{
Open::None => None,
Open::Modal => Some( modal_confirm() ),
Open::NonModal => Some( non_modal_pick() ),
Open::CustomBody => Some( custom_body_dialog( self.brightness ) ),
};
match overlay
{
Some( o ) =>
{
stack::<Msg>()
.push( main )
.push( o )
.into()
}
None => main.into(),
}
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Open( o ) => { self.open = o; self.last_event = "opened".into(); }
Msg::Close => { self.open = Open::None; self.last_event = "cancelled (button or ESC or scrim)".into(); }
Msg::Confirm => { self.open = Open::None; self.last_event = "confirmed".into(); }
Msg::PickAlpha => { self.open = Open::None; self.last_event = "picked Alpha".into(); }
Msg::PickBeta => { self.open = Open::None; self.last_event = "picked Beta".into(); }
Msg::PickGamma => { self.open = Open::None; self.last_event = "picked Gamma".into(); }
Msg::BrightnessChanged( v ) => { self.brightness = v; }
}
}
}
fn modal_confirm() -> Element<Msg>
{
dialog::<Msg>()
.title( "Delete partition?" )
.subtitle( "This will permanently erase every file in /dev/sda2 and cannot be undone. Make sure you have a backup before continuing." )
.cancel( Msg::Close )
.action(
button::<Msg>( "Cancel" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::Close ),
)
.action(
button::<Msg>( "Delete" )
.variant( ButtonVariant::Primary )
.on_press( Msg::Confirm ),
)
.into()
}
fn non_modal_pick() -> Element<Msg>
{
dialog::<Msg>()
.modal( false )
.title( "Pick a flavour" )
.subtitle( "Tap the dim background or press ESC to cancel." )
.cancel( Msg::Close )
.dismiss_on_scrim( Msg::Close )
.action(
button::<Msg>( "Alpha" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::PickAlpha ),
)
.action(
button::<Msg>( "Beta" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::PickBeta ),
)
.action(
button::<Msg>( "Gamma" )
.variant( ButtonVariant::Primary )
.on_press( Msg::PickGamma ),
)
.into()
}
fn custom_body_dialog( brightness: f32 ) -> Element<Msg>
{
let palette = ltk::theme_palette();
let secondary = palette.text_secondary;
let body = column::<Msg>()
.spacing( 8.0 )
.push( text( format!( "Brightness: {:>3.0} %", brightness * 100.0 ) )
.size( 14.0 )
.color( secondary ) )
.push(
slider::<Msg>( brightness )
.on_change( |v| Msg::BrightnessChanged( v ) )
.accent_thumb( true ),
);
dialog::<Msg>()
.title( "Display brightness" )
.subtitle( "Drag the slider, then accept or cancel." )
.cancel( Msg::Close )
.body( body )
.action(
button::<Msg>( "Cancel" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::Close ),
)
.action(
button::<Msg>( "Accept" )
.variant( ButtonVariant::Primary )
.on_press( Msg::Confirm ),
)
.into()
}
fn main()
{
ltk::run( DialogApp::new() );
}

128
examples/inputs.rs Normal file
View File

@@ -0,0 +1,128 @@
//! `cargo run --example inputs`
//!
//! Shows a plain username field and a secure password field with a
//! built-in show / hide-password eye toggle. Tap the eye to flip
//! the bullets ↔ plaintext rendering — the buffer keeps its
//! wipe-on-drop guarantee either way. Tab / Shift+Tab moves focus
//! between fields. Enter or the Login button submits. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::{ App, Element, Keysym, ButtonVariant, button, column, text, text_edit, spacer };
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
UsernameChanged( String ),
PasswordChanged( String ),
TogglePasswordVisibility,
Submit,
}
// ── App state ─────────────────────────────────────────────────────────────────
struct InputsApp
{
username: String,
password: String,
password_visible: bool,
submitted: bool,
}
impl InputsApp
{
fn new() -> Self
{
Self
{
username: String::new(),
password: String::new(),
password_visible: false,
submitted: false,
}
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for InputsApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let status = if self.submitted
{
format!( "Submitted as: {}", self.username )
} else {
String::from( "Fill in the fields and press Login" )
};
column::<Message>()
.padding( 32.0 )
.spacing( 16.0 )
.max_width( 380.0 )
.center_y( true )
.push( text( "ltk — text input showcase" ).size( 24.0 ).color( primary ).align_center() )
.push( text( status ).size( 14.0 ).color( secondary ).align_center() )
.push( spacer() )
.push(
text_edit::<Message>( "Username", &self.username )
.on_change( Message::UsernameChanged )
.on_submit( Message::Submit ),
)
.push(
text_edit::<Message>( "Password", &self.password )
.on_change( Message::PasswordChanged )
.on_submit( Message::Submit )
.password_toggle( self.password_visible, Message::TogglePasswordVisibility ),
)
.push(
button::<Message>( "Login" )
.variant( ButtonVariant::Primary )
.on_press( Message::Submit ),
)
.push( spacer() )
.push(
text( "Tab = next field · Enter = submit · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
)
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::UsernameChanged( v ) => self.username = v,
Message::PasswordChanged( v ) => self.password = v,
Message::TogglePasswordVisibility => self.password_visible = !self.password_visible,
Message::Submit => self.submitted = true,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
ltk::run( InputsApp::new() );
}

626
examples/mini_shell.rs Normal file
View File

@@ -0,0 +1,626 @@
//! `cargo run --example mini_shell`
//!
//! A self-contained mini-shell that exercises the patterns described in
//! `docs/architecture.md`:
//!
//! - Multi-screen routing on the main surface (Home / Settings)
//! - Two coordinated overlays (Quick Settings + Confirm dialog)
//! - A pass-through OSD overlay with a fade-in/fade-out animation driven by
//! `is_animating()`
//! - Live dark/light theme switching from a UI toggle
//! - Nested message enums (`AppMsg::Home(HomeMsg)`, `AppMsg::Settings(...)`)
//! - One module per screen / overlay so the file scales like a real app
//!
//! Controls:
//! Esc — quit
//! Swipe down — open Quick Settings
//! Tap outside — dismiss any overlay
//!
//! NOTE: ltk is a Wayland toolkit. Run inside a Wayland session
//! (sway, labwc, the Liberux desktop, …).
use std::time::{ Duration, Instant };
use ltk::
{
App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec,
ThemeMode,
button, column, container, progress_bar, row, slider, spacer, text, toggle,
};
// ── Stable overlay ids ────────────────────────────────────────────────────────
//
// Declare ids as constants so the runtime can diff the overlay list across
// frames and keep the underlying surfaces alive.
const OVERLAY_QUICK_SETTINGS: OverlayId = OverlayId( 1 );
const OVERLAY_CONFIRM: OverlayId = OverlayId( 2 );
const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
const TOAST_LIFETIME: Duration = Duration::from_millis( 1800 );
const TOAST_FADE_IN: Duration = Duration::from_millis( 200 );
const TOAST_FADE_OUT: Duration = Duration::from_millis( 600 );
// ── Routing ───────────────────────────────────────────────────────────────────
#[ derive( Clone, Copy, PartialEq, Eq ) ]
enum Route { Home, Settings }
// ── Confirm dialog payload ────────────────────────────────────────────────────
//
// Models a user-initiated action that needs confirmation before it takes
// effect. The dialog itself is just a view of `AppState::pending_action`.
#[ derive( Clone, Copy ) ]
enum Action
{
PowerOff,
Reset,
}
impl Action
{
fn label( self ) -> &'static str
{
match self
{
Action::PowerOff => "Power off this device?",
Action::Reset => "Reset all settings to defaults?",
}
}
}
// ── Message tree ──────────────────────────────────────────────────────────────
//
// The top-level enum stays small and forwards to per-screen sub-enums — the
// recommended pattern once a shell has more than a handful of messages.
#[ derive( Clone ) ]
enum AppMsg
{
Nav( Route ),
Home( HomeMsg ),
Settings( SettingsMsg ),
// Overlay coordination
OpenQuickSettings,
CloseQuickSettings,
SetBrightness( f32 ),
ShowConfirm( Action ),
DismissConfirm,
ConfirmYes,
// Cross-cutting
SetThemeMode( ThemeMode ),
ShowToast( String ),
Quit,
}
#[ derive( Clone ) ]
enum HomeMsg { LaunchApp( &'static str ) }
#[ derive( Clone ) ]
enum SettingsMsg { ToggleNotifications }
// ── App state ─────────────────────────────────────────────────────────────────
struct AppState
{
route: Route,
// Per-screen state (in a real app each lives in its own module struct).
home: home::Home,
settings: settings::Settings,
// Overlay state
quick_settings_visible: bool,
pending_action: Option<Action>,
brightness: f32,
// Animated OSD toast
toast_text: Option<String>,
toast_started: Option<Instant>,
}
impl AppState
{
fn new() -> Self
{
Self
{
route: Route::Home,
home: home::Home::new(),
settings: settings::Settings::new(),
quick_settings_visible: false,
pending_action: None,
brightness: 0.7,
toast_text: None,
toast_started: None,
}
}
fn show_toast( &mut self, msg: String )
{
self.toast_text = Some( msg );
self.toast_started = Some( Instant::now() );
}
/// 0.0 = invisible, 1.0 = fully opaque. Used by the OSD overlay's view.
fn toast_alpha( &self ) -> f32
{
let Some( start ) = self.toast_started else { return 0.0 };
let elapsed = start.elapsed();
if elapsed < TOAST_FADE_IN
{
elapsed.as_secs_f32() / TOAST_FADE_IN.as_secs_f32()
} else if elapsed < TOAST_LIFETIME - TOAST_FADE_OUT {
1.0
} else if elapsed < TOAST_LIFETIME {
let t = ( TOAST_LIFETIME - elapsed ).as_secs_f32() / TOAST_FADE_OUT.as_secs_f32();
t.max( 0.0 )
} else {
0.0
}
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for AppState
{
type Message = AppMsg;
fn view( &self ) -> Element<AppMsg>
{
match self.route
{
Route::Home => self.home.view(),
Route::Settings => self.settings.view(),
}
}
fn overlays( &self ) -> Vec<OverlaySpec<AppMsg>>
{
let mut out = Vec::new();
if self.quick_settings_visible
{
out.push( OverlaySpec
{
id: OVERLAY_QUICK_SETTINGS,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: overlays::quick_settings( self ),
on_dismiss: Some( AppMsg::CloseQuickSettings ),
anchor_widget_id: None,
} );
}
if let Some( action ) = self.pending_action
{
out.push( OverlaySpec
{
id: OVERLAY_CONFIRM,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: overlays::confirm( action ),
on_dismiss: Some( AppMsg::DismissConfirm ),
anchor_widget_id: None,
} );
}
if self.toast_text.is_some()
{
out.push( OverlaySpec
{
id: OVERLAY_TOAST,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
// Pass-through: the OSD must not steal taps from anything below.
input_region: Some( Vec::new() ),
view: overlays::toast( self ),
on_dismiss: None,
anchor_widget_id: None,
} );
}
out
}
fn update( &mut self, msg: AppMsg )
{
match msg
{
AppMsg::Nav( r ) => self.route = r,
AppMsg::Home( m ) =>
{
if let Some( follow ) = self.home.update( m )
{
self.update( follow );
}
}
AppMsg::Settings( m ) => self.settings.update( m ),
AppMsg::OpenQuickSettings => self.quick_settings_visible = true,
AppMsg::CloseQuickSettings => self.quick_settings_visible = false,
AppMsg::SetBrightness( v ) => self.brightness = v,
AppMsg::ShowConfirm( a ) => self.pending_action = Some( a ),
AppMsg::DismissConfirm => self.pending_action = None,
AppMsg::ConfirmYes =>
{
if let Some( action ) = self.pending_action.take()
{
match action
{
Action::PowerOff => self.show_toast( "Pretending to power off…".into() ),
Action::Reset =>
{
self.settings = settings::Settings::new();
self.brightness = 0.7;
self.show_toast( "Settings reset".into() );
}
}
}
}
AppMsg::SetThemeMode( m ) => ltk::set_active_mode( m ),
AppMsg::ShowToast( s ) => self.show_toast( s ),
AppMsg::Quit => std::process::exit( 0 ),
}
}
// Animation: keep redrawing while the toast is on screen so it can fade.
fn is_animating( &self ) -> bool
{
self.toast_started.is_some()
}
// Auto-expire the toast once its lifetime is up. Putting this in
// `poll_external` (rather than `update`) means the loop wakes itself when
// `is_animating()` is driving redraws and cleans up without user input.
fn poll_external( &mut self ) -> Vec<AppMsg>
{
if let Some( start ) = self.toast_started
{
if start.elapsed() >= TOAST_LIFETIME
{
self.toast_text = None;
self.toast_started = None;
}
}
Vec::new()
}
fn on_swipe_down( &mut self ) -> Option<AppMsg>
{
Some( AppMsg::OpenQuickSettings )
}
fn swipe_down_threshold( &self ) -> f32 { 0.10 }
fn on_key( &mut self, k: Keysym ) -> Option<AppMsg>
{
match k
{
Keysym::Escape => Some( AppMsg::Quit ),
_ => None,
}
}
fn background_color( &self ) -> Color
{
// Use the active theme's background so dark/light flips immediately.
ltk::theme_palette().bg
}
}
// ── Per-screen modules ────────────────────────────────────────────────────────
//
// In a real app each of these lives in its own file (`src/home.rs`,
// `src/settings.rs`, …). Inlined here so the example stays a single file.
mod home
{
use super::*;
pub struct Home
{
pub last_launched: Option<&'static str>,
}
impl Home
{
pub fn new() -> Self { Self { last_launched: None } }
/// Cmd-style: returns an optional follow-up message instead of mutating
/// shared state. The parent `update` runs the follow-up, which keeps
/// the borrow checker happy.
pub fn update( &mut self, msg: HomeMsg ) -> Option<AppMsg>
{
match msg
{
HomeMsg::LaunchApp( name ) =>
{
self.last_launched = Some( name );
Some( AppMsg::ShowToast( format!( "Launched {}", name ) ) )
}
}
}
pub fn view( &self ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let title = text( "Mini Shell" )
.size( 28.0 )
.color( palette.text_primary )
.align_center();
let status = text( match self.last_launched
{
Some( n ) => format!( "Last launched: {}", n ),
None => "No app launched yet".into(),
} )
.size( 13.0 )
.color( palette.text_secondary )
.align_center();
let mut grid = ltk::grid::<AppMsg>( 2 ).spacing( 12.0 ).padding( 0.0 );
for name in &["Browser", "Mail", "Camera", "Music"]
{
grid = grid.push(
button::<AppMsg>( *name )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::Home( HomeMsg::LaunchApp( name ) ) ),
);
}
column::<AppMsg>()
.padding( 32.0 )
.spacing( 16.0 )
.push( title )
.push( status )
.push( spacer() )
.push( grid )
.push( spacer() )
.push(
button::<AppMsg>( "Settings" )
.on_press( AppMsg::Nav( Route::Settings ) ),
)
.into()
}
}
}
mod settings
{
use super::*;
pub struct Settings
{
pub notifications_enabled: bool,
}
impl Settings
{
pub fn new() -> Self { Self { notifications_enabled: true } }
pub fn update( &mut self, msg: SettingsMsg )
{
match msg
{
SettingsMsg::ToggleNotifications => self.notifications_enabled = !self.notifications_enabled,
}
}
pub fn view( &self ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let is_dark = ltk::active_mode() == ThemeMode::Dark;
let next = if is_dark { ThemeMode::Light } else { ThemeMode::Dark };
let label = if is_dark { "Switch to Light" } else { "Switch to Dark" };
column::<AppMsg>()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "Settings" ).size( 24.0 ).color( palette.text_primary ) )
.push(
toggle::<AppMsg>( self.notifications_enabled )
.label( "Notifications" )
.on_toggle( AppMsg::Settings( SettingsMsg::ToggleNotifications ) ),
)
.push( spacer() )
.push( text( "Theme" ).size( 14.0 ).color( palette.text_secondary ) )
.push(
button::<AppMsg>( label )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::SetThemeMode( next ) ),
)
.push( spacer() )
.push(
row::<AppMsg>()
.spacing( 12.0 )
.push(
button::<AppMsg>( "Show toast" )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::ShowToast( "Hello from Settings".into() ) ),
)
.push(
button::<AppMsg>( "Reset settings" )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::ShowConfirm( Action::Reset ) ),
),
)
.push(
button::<AppMsg>( "Power off" )
.on_press( AppMsg::ShowConfirm( Action::PowerOff ) ),
)
.push( spacer() )
.push(
button::<AppMsg>( "Back" )
.variant( ButtonVariant::Tertiary )
.on_press( AppMsg::Nav( Route::Home ) ),
)
.into()
}
}
}
mod overlays
{
use super::*;
/// Quick Settings: brightness slider + theme toggle + close button.
pub fn quick_settings( app: &AppState ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let is_dark = ltk::active_mode() == ThemeMode::Dark;
let next = if is_dark { ThemeMode::Light } else { ThemeMode::Dark };
let panel = container::<AppMsg>(
column::<AppMsg>()
.padding( 0.0 )
.spacing( 12.0 )
.push(
row::<AppMsg>()
.spacing( 8.0 )
.push( text( "Quick Settings" ).size( 18.0 ).color( palette.text_primary ) )
.push( spacer() )
.push(
button::<AppMsg>( "Close" )
.variant( ButtonVariant::Tertiary )
.on_press( AppMsg::CloseQuickSettings ),
),
)
.push( text( format!( "Brightness {:.0}%", app.brightness * 100.0 ) )
.size( 13.0 )
.color( palette.text_primary ) )
.push( slider::<AppMsg>( app.brightness ).on_change( AppMsg::SetBrightness ) )
.push(
button::<AppMsg>( if is_dark { "Light theme" } else { "Dark theme" } )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::SetThemeMode( next ) ),
),
)
.background( palette.surface_alt )
.radius( 16.0 )
.padding( 20.0 );
// Center the panel on the overlay surface using spacers.
column::<AppMsg>()
.padding( 24.0 )
.spacing( 0.0 )
.max_width( 360.0 )
.push( panel )
.push( spacer() )
.into()
}
/// Confirm dialog. Same overlay pattern, different content.
pub fn confirm( action: Action ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let panel = container::<AppMsg>(
column::<AppMsg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( action.label() )
.size( 16.0 )
.color( palette.text_primary )
.align_center() )
.push(
row::<AppMsg>()
.spacing( 12.0 )
.push(
button::<AppMsg>( "Cancel" )
.variant( ButtonVariant::Tertiary )
.on_press( AppMsg::DismissConfirm ),
)
.push( spacer() )
.push(
button::<AppMsg>( "Confirm" )
.on_press( AppMsg::ConfirmYes ),
),
),
)
.background( palette.surface_alt )
.radius( 16.0 )
.padding( 24.0 );
column::<AppMsg>()
.padding( 0.0 )
.spacing( 0.0 )
.max_width( 320.0 )
.push( spacer() )
.push( panel )
.push( spacer() )
.into()
}
/// Pass-through OSD. The fade is computed from elapsed time, not stored.
pub fn toast( app: &AppState ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let alpha = app.toast_alpha();
let txt = app.toast_text.as_deref().unwrap_or( "" );
// Apply the fade by attenuating the surface alpha. Text/progress stay
// readable; the container background fades.
let mut bg = palette.surface_alt;
bg.a *= alpha;
let mut fg = palette.text_primary;
fg.a *= alpha;
let panel = container::<AppMsg>(
column::<AppMsg>()
.padding( 0.0 )
.spacing( 8.0 )
.push( text( txt ).size( 15.0 ).color( fg ).align_center() )
.push( progress_bar( alpha ) ),
)
.background( bg )
.radius( 12.0 )
.padding( 16.0 );
column::<AppMsg>()
.padding( 0.0 )
.spacing( 0.0 )
.max_width( 280.0 )
.push( spacer() )
.push( spacer() )
.push( spacer() )
.push( panel )
.into()
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
// Load the on-disk default theme. ltk has no in-code fallback: the
// `ltk-theme-default` Debian package (or a dev-time
// `LTK_THEMES_DIR=<repo>/themes`) must provide
// `default/theme.json`. `ltk::run` would otherwise panic on first
// theme access with the same message as the one below.
let doc = ltk::ThemeDocument::find( "default" )
.expect( "ltk: the `default` theme is required (install `ltk-theme-default` or set `LTK_THEMES_DIR`)" );
ltk::set_active_document( doc );
ltk::set_active_mode( ThemeMode::Dark );
ltk::run( AppState::new() );
}

166
examples/pickers.rs Normal file
View File

@@ -0,0 +1,166 @@
//! `cargo run --example pickers`
//!
//! Showcases the four composition widgets added in this pass:
//! `notebook` (paginated tabs with a content area), `date_picker`,
//! `time_picker`, and `color_picker`. Each picker lives on its own
//! notebook page so the example doubles as a notebook demo.
//!
//! Esc exits.
use ltk::
{
App, Element, Color, Keysym,
column, text, scroll,
notebook, date_picker, time_picker, color_picker,
Date, Time,
};
#[ derive( Clone, Debug ) ]
enum Msg
{
SelectTab( usize ),
DateChanged( Date ),
DateView( i32, u8 ),
TimeChanged( Time ),
ColorChanged( Color ),
}
struct PickerApp
{
tab: usize,
date: Date,
view_year: i32,
view_month: u8,
time: Time,
color: Color,
}
impl PickerApp
{
fn new() -> Self
{
let today = Date::new( 2026, 5, 2 );
Self
{
tab: 0,
date: today,
view_year: today.year,
view_month: today.month,
time: Time::new( 9, 30 ),
color: Color::hex( 0x14, 0xB8, 0xA6 ),
}
}
fn date_page( &self ) -> Element<Msg>
{
let primary = ltk::theme_palette().text_primary;
let summary = format!(
"Selected: {}-{:02}-{:02}",
self.date.year, self.date.month, self.date.day,
);
column::<Msg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( summary ).size( 14.0 ).color( primary ) )
.push(
date_picker( self.date )
.view( self.view_year, self.view_month )
.today( Date::new( 2026, 5, 2 ) )
.on_change( Msg::DateChanged )
.on_navigate( Msg::DateView )
)
.into()
}
fn time_page( &self ) -> Element<Msg>
{
let primary = ltk::theme_palette().text_primary;
let summary = format!(
"Selected: {:02}:{:02}",
self.time.hour, self.time.minute,
);
column::<Msg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( summary ).size( 14.0 ).color( primary ) )
.push(
time_picker( self.time )
.minute_step( 1 )
.twelve_hour( true )
.on_change( Msg::TimeChanged )
)
.into()
}
fn color_page( &self ) -> Element<Msg>
{
let primary = ltk::theme_palette().text_primary;
let summary = format!(
"R={:.2} G={:.2} B={:.2} A={:.2}",
self.color.r, self.color.g, self.color.b, self.color.a,
);
column::<Msg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( summary ).size( 14.0 ).color( primary ) )
.push(
color_picker( self.color )
.show_alpha( true )
.on_change( Msg::ColorChanged )
)
.into()
}
}
impl App for PickerApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
let header = text( "ltk pickers" )
.size( 24.0 )
.color( ltk::theme_palette().text_primary )
.align_center();
let body = column::<Msg>()
.padding( 24.0 )
.spacing( 16.0 )
.push( header )
.push(
notebook::<Msg>()
.page( "Date", self.date_page() )
.page( "Time", self.time_page() )
.page( "Color", self.color_page() )
.selected( self.tab )
.on_select( Msg::SelectTab )
);
// Wrap in a scroll so the calendar's 6×7 grid fits even on
// modest window heights.
scroll( body ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::SelectTab( i ) => self.tab = i,
Msg::DateChanged( d ) => { self.date = d; self.view_year = d.year; self.view_month = d.month; }
Msg::DateView( y, m ) => { self.view_year = y; self.view_month = m; }
Msg::TimeChanged( t ) => self.time = t,
Msg::ColorChanged( c ) => self.color = c,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
}
fn main()
{
ltk::run( PickerApp::new() );
}

154
examples/scroll.rs Normal file
View File

@@ -0,0 +1,154 @@
//! `cargo run --example scroll`
//!
//! Demonstrates the two main scroll use cases:
//!
//! - **List mode** — a long vertical list inside a `scroll(column(...))`.
//! - **Grid mode** — a grid of buttons inside a `scroll(grid(4))` (app-drawer style).
//!
//! Press the "List" / "Grid" toggle at the top to switch between modes.
//! Drag the content area to scroll. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::{
App, Element, Keysym, ButtonVariant,
button, column, row, text, scroll, grid, spacer,
};
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
ShowList,
ShowGrid,
ItemPressed( usize ),
}
// ── App state ─────────────────────────────────────────────────────────────────
#[ derive( PartialEq ) ]
enum Mode { List, Grid }
struct ScrollApp
{
mode: Mode,
last_pressed: Option<usize>,
}
impl ScrollApp
{
fn new() -> Self
{
Self { mode: Mode::List, last_pressed: None }
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
const ITEM_COUNT: usize = 64;
impl App for ScrollApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let status = match self.last_pressed
{
Some( i ) => format!( "Pressed item {}", i + 1 ),
None => String::from( "Drag to scroll · click an item" ),
};
// Top bar: toggle buttons + status
let toggle_list = button::<Message>( "List" )
.variant( if self.mode == Mode::List { ButtonVariant::Primary } else { ButtonVariant::Secondary } )
.on_press( Message::ShowList );
let toggle_grid = button::<Message>( "Grid" )
.variant( if self.mode == Mode::Grid { ButtonVariant::Primary } else { ButtonVariant::Secondary } )
.on_press( Message::ShowGrid );
let top_bar = row::<Message>()
.spacing( 8.0 )
.push( toggle_list )
.push( toggle_grid )
.push( spacer() )
.push( text( &status ).size( 14.0 ).color( secondary ) );
// Scrollable content area
let content: Element<Message> = match self.mode
{
Mode::List =>
{
// A long list of labeled buttons in a scrollable column
let mut col = column::<Message>().padding( 8.0 ).spacing( 6.0 );
for i in 0..ITEM_COUNT
{
col = col.push(
button::<Message>( format!( "List item {} — click me", i + 1 ) )
.variant( ButtonVariant::Secondary )
.on_press( Message::ItemPressed( i ) ),
);
}
scroll( col ).into()
}
Mode::Grid =>
{
// A grid of labeled buttons in a scrollable 4-column grid
let mut g = grid::<Message>( 4 ).padding( 8.0 ).spacing( 6.0 );
for i in 0..ITEM_COUNT
{
g = g.push(
button::<Message>( format!( "Item {}", i + 1 ) )
.variant( ButtonVariant::Secondary )
.on_press( Message::ItemPressed( i ) ),
);
}
scroll( g ).into()
}
};
column::<Message>()
.padding( 16.0 )
.spacing( 12.0 )
.push( text( "ltk — scroll showcase" ).size( 22.0 ).color( primary ).align_center() )
.push( top_bar )
.push( content )
.push(
text( "Drag = scroll · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
)
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::ShowList => self.mode = Mode::List,
Message::ShowGrid => self.mode = Mode::Grid,
Message::ItemPressed( i ) => self.last_pressed = Some( i ),
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
ltk::run( ScrollApp::new() );
}

252
examples/showcase.rs Normal file
View File

@@ -0,0 +1,252 @@
//! `cargo run --example showcase`
//!
//! Shows button variants, container with background, a slider, plus the
//! newer additions: a tab strip, a spinner driven by the wall clock, a
//! multiline text edit, and on-demand toast / tooltip overlays.
//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the
//! focused button. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use std::time::{ Duration, Instant };
use ltk::{
App, Element, Keysym, ButtonVariant, WidgetId,
OverlaySpec,
button, column, row, stack, text, text_edit, spacer, container, slider, scroll,
spinner, tabs, toast, tooltip,
};
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
Pressed( &'static str ),
SliderChanged( f32 ),
SelectTab( usize ),
NoteChanged( String ),
ShowToast,
ToggleTooltip,
HideToast,
}
// ── App state ─────────────────────────────────────────────────────────────────
struct ShowcaseApp
{
last_pressed: String,
slider_value: f32,
tab: usize,
note: String,
started_at: Instant,
toast_until: Option<Instant>,
tooltip_visible: bool,
}
impl ShowcaseApp
{
fn new() -> Self
{
Self
{
last_pressed: String::from( "" ),
slider_value: 0.5,
tab: 0,
note: String::new(),
started_at: Instant::now(),
toast_until: None,
tooltip_visible: false,
}
}
const SAVE_BUTTON_ID: WidgetId = WidgetId( "showcase/save" );
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for ShowcaseApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
// Pull text colours from the active theme — the runtime
// background defaults to `palette.bg`, so hard-coded white
// text would be unreadable in light mode.
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let status = format!( "Last pressed: {}", self.last_pressed );
// Tabs strip — the active tab just relabels a banner below.
let tabs_strip: Element<Message> = tabs( [ "Buttons", "Inputs", "Hints" ] )
.selected( self.tab )
.on_select( Message::SelectTab )
.into();
let tab_banner = format!(
"Active tab: {}",
[ "Buttons", "Inputs", "Hints" ][ self.tab.min( 2 ) ],
);
let buttons = row::<Message>()
.spacing( 16.0 )
.push(
button( "Primary" )
.variant( ButtonVariant::Primary )
.id( Self::SAVE_BUTTON_ID )
.on_press( Message::Pressed( "Primary" ) ),
)
.push(
button( "Secondary" )
.variant( ButtonVariant::Secondary )
.on_press( Message::Pressed( "Secondary" ) ),
)
.push(
button( "Tertiary" )
.variant( ButtonVariant::Tertiary )
.on_press( Message::Pressed( "Tertiary" ) ),
);
let card = container::<Message>(
column::<Message>()
.spacing( 8.0 )
.push( text( "container() demo" ).size( 14.0 ).color( primary ) )
.push(
text( "Background color + padding + corner radius" )
.size( 12.0 )
.color( secondary ),
),
)
.background( palette.surface_alt )
.radius( 12.0 )
.padding( 16.0 );
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );
// Spinner — phase comes straight from the wall clock so the arc
// completes one revolution per second.
let phase = self.started_at.elapsed().as_secs_f32();
let spin_row: Element<Message> = row::<Message>()
.spacing( 12.0 )
.push( spinner().phase( phase ) )
.push( text( "Working…" ).size( 14.0 ).color( primary ) )
.into();
// Multiline text area.
let textarea = text_edit( "Type a note (multi-line)", &self.note )
.multiline( true )
.rows( 3 )
.on_change( Message::NoteChanged );
// Buttons that exercise the overlay-returning widgets.
let overlay_row = row::<Message>()
.spacing( 12.0 )
.push( button( "Show toast" ).on_press( Message::ShowToast ) )
.push(
button( if self.tooltip_visible { "Hide tooltip" } else { "Show tooltip" } )
.on_press( Message::ToggleTooltip )
);
let body = column::<Message>()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "ltk showcase" ).size( 24.0 ).color( primary ).align_center() )
.push( text( status ).size( 14.0 ).color( secondary ).align_center() )
.push( tabs_strip )
.push( text( tab_banner ).size( 12.0 ).color( secondary ).align_center() )
.push( spacer() )
.push( buttons )
.push( card )
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.slider_value ).on_change( Message::SliderChanged ) )
.push( spin_row )
.push( textarea )
.push( overlay_row )
.push( spacer() )
.push(
text( "Tab = focus next · Enter / Space = press · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
);
// Toast lives inside the main view (works on every
// compositor — no `wlr-layer-shell` required). The Tooltip
// stays in `overlays()` because xdg-popup is universal.
let mut s = stack::<Message>().push( scroll( body ) );
if self.toast_until.is_some()
{
s = s.push( toast::<Message>( "Saved" ).view() );
}
s.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::Pressed( name ) => self.last_pressed = name.to_string(),
Message::SliderChanged( val ) => self.slider_value = val,
Message::SelectTab( i ) => self.tab = i,
Message::NoteChanged( s ) => self.note = s,
Message::ShowToast =>
{
self.toast_until = Some( Instant::now() + Duration::from_secs( 2 ) );
}
Message::HideToast => self.toast_until = None,
Message::ToggleTooltip => self.tooltip_visible = !self.tooltip_visible,
}
}
fn overlays( &self ) -> Vec<OverlaySpec<Message>>
{
// Tooltip is an xdg-popup → works on any xdg-shell compositor.
// Toast is rendered inline in `view()` (see the Stack at the
// bottom of the function) so it does not depend on
// `wlr-layer-shell` either.
if self.tooltip_visible
{
vec![ tooltip::<Message>( "Saves the document and closes the dialog", Self::SAVE_BUTTON_ID ).overlay() ]
} else {
vec![]
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
// Keep redrawing while the spinner is on screen and while a toast
// is pending so its expiry timestamp is checked every frame.
fn is_animating( &self ) -> bool { true }
// Auto-clear an expired toast.
fn poll_interval( &self ) -> Option<Duration>
{
Some( Duration::from_millis( 250 ) )
}
fn poll_external( &mut self ) -> Vec<Message>
{
match self.toast_until
{
Some( deadline ) if Instant::now() >= deadline => vec![ Message::HideToast ],
_ => vec![],
}
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
ltk::run( ShowcaseApp::new() );
}

155
examples/sliders.rs Normal file
View File

@@ -0,0 +1,155 @@
//! `cargo run --example sliders`
//!
//! Showcases the **Glass** effect on both axes of the slider widget. The
//! horizontal `slider` at the top and the two vertical `vslider` pills
//! below share the same composite surface — outer glow + four inset
//! shadows (two `PlusLighter` light-catchers, one `Overlay` rim, one
//! soft dark contact shadow) — over a translucent white-to-grey track
//! and a flat cyan accent fill.
//!
//! Drag or tap anywhere inside the controls to change their values; Esc
//! exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a
//! running Wayland compositor (e.g. sway, labwc, or a full desktop
//! session). The Glass effect is rendered by the GPU (GLES2) backend —
//! the software backend falls back to a flat fill.
use ltk::
{
App, Element, Color, Keysym,
column, row, text, spacer, slider, vslider,
};
#[ derive( Clone ) ]
enum Msg
{
SetHue( f32 ),
SetVolume( f32 ),
SetBrightness( f32 ),
}
struct SlidersApp
{
hue: f32,
volume: f32,
brightness: f32,
}
impl SlidersApp
{
fn new() -> Self
{
Self { hue: 0.30, volume: 0.48, brightness: 0.92 }
}
}
impl App for SlidersApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
// Pill dimensions chosen so the Glass insets read at their
// intended proportions. The insets (0.453.6 px offsets,
// 0.913.5 px blur radii) are absolute, so smaller pills
// produce relatively tighter highlights.
const PILL_W: f32 = 80.0;
const PILL_H: f32 = 176.0;
// Track text against the active theme so switching to dark mode
// (via `ltk::set_active_mode`) flips the labels automatically.
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
// Horizontal slider — claims its row's full width.
let hue_slider = slider( self.hue ).on_change( Msg::SetHue );
// Two vertical pills.
let brgt = vslider( self.brightness ).size( PILL_W, PILL_H ).on_change( Msg::SetBrightness );
let vol = vslider( self.volume ).size( PILL_W, PILL_H ).on_change( Msg::SetVolume );
let pills = row::<Msg>()
.spacing( 40.0 )
.padding( 0.0 )
.push( brgt )
.push( vol );
let pill_labels = row::<Msg>()
.spacing( 40.0 )
.padding( 0.0 )
.push(
text( format!( "Brightness {:>3.0}%", self.brightness * 100.0 ) )
.size( 14.0 )
.color( primary ),
)
.push(
text( format!( "Volume {:>3.0}%", self.volume * 100.0 ) )
.size( 14.0 )
.color( primary ),
);
column::<Msg>()
.padding( 32.0 )
.spacing( 16.0 )
.center_y( true )
.push(
text( "ltk — Glass sliders, both axes" )
.size( 24.0 )
.color( primary )
.align_center(),
)
.push( spacer() )
.push(
text( format!( "Hue {:>3.0}%", self.hue * 100.0 ) )
.size( 14.0 )
.color( primary ),
)
.push( hue_slider )
.push( spacer() )
.push( pills )
.push( pill_labels )
.push( spacer() )
.push(
text( "Drag or tap inside a control · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
)
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::SetHue( v ) => self.hue = v,
Msg::SetVolume( v ) => self.volume = v,
Msg::SetBrightness( v ) => self.brightness = v,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
// Track the active theme's background so the Glass highlights and
// the translucent track read correctly against it, and dark-mode
// runs (via `ltk::set_active_mode(ThemeMode::Dark)`) flip
// automatically.
fn background_color( &self ) -> Color
{
ltk::theme_palette().bg
}
}
fn main()
{
ltk::run( SlidersApp::new() );
}

217
examples/widgets.rs Normal file
View File

@@ -0,0 +1,217 @@
use std::time::Instant;
use ltk::{
App, Element, Keysym, WidgetId,
column, row, text, text_edit, spacer, separator, slider,
toggle, checkbox, radio, progress_bar, scroll,
spinner, tabs,
};
#[derive( Clone )]
enum Msg
{
ToggleWifi,
ToggleBluetooth,
ToggleDarkMode,
CheckNotifications,
CheckUpdates,
SelectThemeLight,
SelectThemeDark,
SelectThemeAuto,
SliderChanged( f32 ),
SelectTab( usize ),
NoteChanged( String ),
Tick,
}
struct WidgetsApp
{
wifi: bool,
bluetooth: bool,
dark_mode: bool,
notifications: bool,
updates: bool,
theme: usize,
progress: f32,
volume: f32,
tab: usize,
note: String,
started_at: Instant,
}
impl WidgetsApp
{
fn new() -> Self
{
Self
{
wifi: true,
bluetooth: false,
dark_mode: true,
notifications: true,
updates: false,
theme: 2,
progress: 0.65,
volume: 0.7,
tab: 0,
note: String::new(),
started_at: Instant::now(),
}
}
}
impl App for WidgetsApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
// Pull text colours from the active theme so the example
// reads correctly under both light and dark mode without
// touching the example code.
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center();
// Segmented tab strip — selection just rendered as a banner below.
let tabs_strip: Element<Msg> = tabs( [ "General", "Audio", "Network" ] )
.selected( self.tab )
.on_select( Msg::SelectTab )
.into();
let tab_label = format!(
"Active tab: {}",
[ "General", "Audio", "Network" ][ self.tab.min( 2 ) ],
);
let toggles = column::<Msg>()
.spacing( 0.0 )
.push( text( "Toggle" ).size( 13.0 ).color( secondary ) )
.push( toggle( self.wifi ).label( "Wi-Fi" ).on_toggle( Msg::ToggleWifi ) )
.push( toggle( self.bluetooth ).label( "Bluetooth" ).on_toggle( Msg::ToggleBluetooth ) )
.push( toggle( self.dark_mode ).label( "Dark mode" ).on_toggle( Msg::ToggleDarkMode ) );
let checkboxes = column::<Msg>()
.spacing( 0.0 )
.push( text( "Checkbox" ).size( 13.0 ).color( secondary ) )
.push( checkbox( self.notifications ).label( "Notifications" ).on_toggle( Msg::CheckNotifications ) )
.push( checkbox( self.updates ).label( "Auto updates" ).on_toggle( Msg::CheckUpdates ) );
let radios = column::<Msg>()
.spacing( 0.0 )
.push( text( "Radio" ).size( 13.0 ).color( secondary ) )
.push( radio( self.theme == 0 ).label( "Light" ).on_select( Msg::SelectThemeLight ) )
.push( radio( self.theme == 1 ).label( "Dark" ).on_select( Msg::SelectThemeDark ) )
.push( radio( self.theme == 2 ).label( "Auto" ).on_select( Msg::SelectThemeAuto ) );
let vol_label = format!( "Volume: {:.0}%", self.volume * 100.0 );
let prog_label = format!( "Progress: {:.0}%", self.progress * 100.0 );
// Spinner — animation phase derived from the wall clock so the
// arc rotates at one revolution per second.
let phase = self.started_at.elapsed().as_secs_f32();
let spin_row: Element<Msg> = row::<Msg>()
.spacing( 12.0 )
.push( spinner().phase( phase ) )
.push( text( "Loading…" ).size( 14.0 ).color( primary ) )
.into();
// Multiline text area — Enter inserts a newline, Tab leaves it.
let textarea = text_edit( "Type a note (multi-line)", &self.note )
.multiline( true )
.rows( 4 )
.id( WidgetId( "widgets/note" ) )
.on_change( Msg::NoteChanged );
let content = column::<Msg>()
.padding( 32.0 )
.spacing( 12.0 )
.push( title )
.push( tabs_strip )
.push( text( tab_label ).size( 13.0 ).color( secondary ) )
.push( separator() )
.push( toggles )
.push( separator() )
.push( checkboxes )
.push( separator() )
.push( radios )
.push( separator() )
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.volume ).on_change( Msg::SliderChanged ) )
.push( text( prog_label ).size( 13.0 ).color( secondary ) )
.push( progress_bar( self.progress ) )
.push( separator() )
.push( text( "Spinner" ).size( 13.0 ).color( secondary ) )
.push( spin_row )
.push( separator() )
.push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) )
.push( textarea )
.push( spacer() )
.push(
text( "Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
);
scroll( content ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleWifi => self.wifi = !self.wifi,
Msg::ToggleBluetooth => self.bluetooth = !self.bluetooth,
Msg::ToggleDarkMode => self.dark_mode = !self.dark_mode,
Msg::CheckNotifications => self.notifications = !self.notifications,
Msg::CheckUpdates => self.updates = !self.updates,
Msg::SelectThemeLight => self.theme = 0,
Msg::SelectThemeDark => self.theme = 1,
Msg::SelectThemeAuto => self.theme = 2,
Msg::SliderChanged( v ) => self.volume = v,
Msg::SelectTab( i ) => self.tab = i,
Msg::NoteChanged( s ) => self.note = s,
Msg::Tick => {}
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
// Drive the spinner — the runtime keeps redrawing while this returns
// `true`, and `view()` reads the wall clock to compute the phase, so
// no explicit Tick message is even needed for the visual to advance.
fn is_animating( &self ) -> bool { true }
fn poll_interval( &self ) -> Option<std::time::Duration>
{
// Suppress the dead-code warning on Msg::Tick; a real app would
// also use the timer to drive non-visual periodic work here.
Some( std::time::Duration::from_millis( 250 ) )
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// Consumed: the timer above keeps the runtime warm, but the
// spinner reads the wall clock directly so no Tick is required.
// We still emit one occasionally so the message variant is
// exercised — keeps the example honest about the pattern apps
// usually use for clock work.
if self.started_at.elapsed().as_millis() % 1_000 < 250
{
vec![ Msg::Tick ]
} else {
vec![]
}
}
}
fn main()
{
ltk::run( WidgetsApp::new() );
}

147
liberux.toml Normal file
View File

@@ -0,0 +1,147 @@
# ─────────────────────────────────────────────────────────────────────────────
# liberux.toml — Liberux ToolKit external theme file
# Edit freely; the application reloads this file at startup.
# All colors must be hex strings: "#RRGGBB" or "#RRGGBBAA".
# All sizes are in logical pixels (f32).
# ─────────────────────────────────────────────────────────────────────────────
# ── Global button geometry ────────────────────────────────────────────────────
[button]
# Fully rounded pill shape; set to e.g. 8.0 for rectangular corners.
border_radius = 100.0
[button.spacing]
# Gap between icon and label text (px)
icon_gap = 8.0
# ── Button heights per size variant ──────────────────────────────────────────
[button.sizes]
sm = 40.0
default = 48.0
lg = 56.0
# ── Horizontal padding [vertical, horizontal] per size variant ───────────────
[button.padding]
sm = [0.0, 20.0]
default = [0.0, 24.0]
lg = [0.0, 28.0]
# ── Primary button (cyan fill) ───────────────────────────────────────────────
[button.primary]
default_bg = "#00CFE8"
default_text = "#0B1B38"
hover_bg = "#0B1B38"
hover_text = "#FFFFFF"
pressed_bg = "#7EE8F2"
pressed_text = "#0B1B38"
# Focus ring drawn as a border on top of the default state
focus_outline = "#00CFE8"
focus_width = 3.0
disabled_bg = "#E5E7EB"
disabled_text = "#9CA3AF"
# ── Secondary button (outlined, white fill) ──────────────────────────────────
[button.secondary]
default_bg = "#FFFFFF"
default_text = "#0B1B38"
default_border = "#0B1B38"
border_width = 2.0
hover_bg = "#6B7280"
hover_text = "#FFFFFF"
pressed_bg = "#FFFFFF"
pressed_text = "#0B1B38"
pressed_border = "#00CFE8"
focus_bg = "#00CFE8"
focus_text = "#0B1B38"
disabled_bg = "#F3F4F6"
disabled_text = "#9CA3AF"
disabled_border = "#D1D5DB"
# ── Tertiary button (transparent, underlined text) ───────────────────────────
[button.tertiary]
default_text = "#0B1B38"
hover_bg = "#0B1B38"
hover_text = "#FFFFFF"
focus_text = "#0B1B38"
disabled_text = "#9CA3AF"
# ── Typography ────────────────────────────────────────────────────────────────
[typography]
font_size_sm = 14.0
font_size_default = 16.0
font_size_lg = 18.0
# ── Input text ────────────────────────────────────────────────────────────────
[input]
border_radius = 100.0
height_default = 48.0
height_lg = 56.0
label_size = 16.0
helper_size = 14.0
error_color = "#DC2626"
spacing = 4.0
[input.padding]
default = [13.0, 20.0]
lg = [17.0, 24.0]
# ── Default variant: fondo blanco con borde fino ──────────────────────────────
[input.default]
bg = "#FFFFFF"
border_color = "#D1D5DB"
border_width = 1.5
text_color = "#0B1B38"
placeholder_color = "#9CA3AF"
selection_color = "#00CFE840"
focus_border_color = "#0B1B38"
focus_border_width = 2.0
disabled_bg = "#F9FAFB"
disabled_border = "#E5E7EB"
disabled_text = "#9CA3AF"
error_bg = "#FFF0F0"
error_border = "#F87171"
error_border_width = 1.5
# ── Filled variant: fondo gris sin borde inicial ─────────────────────────────
[input.filled]
bg = "#F3F4F6"
border_color = "#F3F4F6"
border_width = 0.0
text_color = "#0B1B38"
placeholder_color = "#9CA3AF"
selection_color = "#00CFE840"
focus_border_color = "#0B1B38"
focus_border_width = 2.0
disabled_bg = "#E5E7EB"
disabled_border = "#E5E7EB"
disabled_text = "#9CA3AF"
error_bg = "#FFF0F0"
error_border = "#F87171"
error_border_width = 1.5

26
locales/de.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "Januar"
month_2: "Februar"
month_3: "März"
month_4: "April"
month_5: "Mai"
month_6: "Juni"
month_7: "Juli"
month_8: "August"
month_9: "September"
month_10: "Oktober"
month_11: "November"
month_12: "Dezember"
dow_short_0: "S"
dow_short_1: "M"
dow_short_2: "D"
dow_short_3: "M"
dow_short_4: "D"
dow_short_5: "F"
dow_short_6: "S"
context_menu:
copy: "Kopieren"
cut: "Ausschneiden"
paste: "Einfügen"
delete: "Löschen"

26
locales/en.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "January"
month_2: "February"
month_3: "March"
month_4: "April"
month_5: "May"
month_6: "June"
month_7: "July"
month_8: "August"
month_9: "September"
month_10: "October"
month_11: "November"
month_12: "December"
dow_short_0: "S"
dow_short_1: "M"
dow_short_2: "T"
dow_short_3: "W"
dow_short_4: "T"
dow_short_5: "F"
dow_short_6: "S"
context_menu:
copy: "Copy"
cut: "Cut"
paste: "Paste"
delete: "Delete"

26
locales/es.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "Enero"
month_2: "Febrero"
month_3: "Marzo"
month_4: "Abril"
month_5: "Mayo"
month_6: "Junio"
month_7: "Julio"
month_8: "Agosto"
month_9: "Septiembre"
month_10: "Octubre"
month_11: "Noviembre"
month_12: "Diciembre"
dow_short_0: "D"
dow_short_1: "L"
dow_short_2: "M"
dow_short_3: "X"
dow_short_4: "J"
dow_short_5: "V"
dow_short_6: "S"
context_menu:
copy: "Copiar"
cut: "Cortar"
paste: "Pegar"
delete: "Borrar"

26
locales/fr.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "Janvier"
month_2: "Février"
month_3: "Mars"
month_4: "Avril"
month_5: "Mai"
month_6: "Juin"
month_7: "Juillet"
month_8: "Août"
month_9: "Septembre"
month_10: "Octobre"
month_11: "Novembre"
month_12: "Décembre"
dow_short_0: "D"
dow_short_1: "L"
dow_short_2: "M"
dow_short_3: "M"
dow_short_4: "J"
dow_short_5: "V"
dow_short_6: "S"
context_menu:
copy: "Copier"
cut: "Couper"
paste: "Coller"
delete: "Supprimer"

26
locales/it.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "Gennaio"
month_2: "Febbraio"
month_3: "Marzo"
month_4: "Aprile"
month_5: "Maggio"
month_6: "Giugno"
month_7: "Luglio"
month_8: "Agosto"
month_9: "Settembre"
month_10: "Ottobre"
month_11: "Novembre"
month_12: "Dicembre"
dow_short_0: "D"
dow_short_1: "L"
dow_short_2: "M"
dow_short_3: "M"
dow_short_4: "G"
dow_short_5: "V"
dow_short_6: "S"
context_menu:
copy: "Copia"
cut: "Taglia"
paste: "Incolla"
delete: "Elimina"

26
locales/pt.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "Janeiro"
month_2: "Fevereiro"
month_3: "Março"
month_4: "Abril"
month_5: "Maio"
month_6: "Junho"
month_7: "Julho"
month_8: "Agosto"
month_9: "Setembro"
month_10: "Outubro"
month_11: "Novembro"
month_12: "Dezembro"
dow_short_0: "D"
dow_short_1: "S"
dow_short_2: "T"
dow_short_3: "Q"
dow_short_4: "Q"
dow_short_5: "S"
dow_short_6: "S"
context_menu:
copy: "Copiar"
cut: "Cortar"
paste: "Colar"
delete: "Eliminar"

26
locales/pt_BR.yaml Normal file
View File

@@ -0,0 +1,26 @@
date_picker:
month_1: "Janeiro"
month_2: "Fevereiro"
month_3: "Março"
month_4: "Abril"
month_5: "Maio"
month_6: "Junho"
month_7: "Julho"
month_8: "Agosto"
month_9: "Setembro"
month_10: "Outubro"
month_11: "Novembro"
month_12: "Dezembro"
dow_short_0: "D"
dow_short_1: "S"
dow_short_2: "T"
dow_short_3: "Q"
dow_short_4: "Q"
dow_short_5: "S"
dow_short_6: "S"
context_menu:
copy: "Copiar"
cut: "Recortar"
paste: "Colar"
delete: "Excluir"

68
scripts/doctest-md.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Run `rustdoc --test` over every Markdown file under `docs/` so the
# `no_run` snippets in the widget catalogue and cookbook get the same
# typecheck guard `cargo test --doc` provides for in-source doctests.
# Files with no Rust code blocks are a no-op for rustdoc.
#
# `no_run` blocks compile but do not execute — that is what we want here
# because none of the snippets has a meaningful side effect outside a
# real Wayland session and the goal is to catch API drift, not to
# integration-test the runtime.
#
# Output mirrors `cargo test`'s shape: one PASS / FAIL line per file
# with rustdoc's compile output streamed through. The script exits
# non-zero on the first file that fails so CI fails the job cleanly.
set -euo pipefail
REPO_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
cd "$REPO_ROOT"
# Build the library so an rlib lives under target/debug/ for --extern.
# `--lib` skips the examples / tests; the rlib is the only artefact we
# need.
cargo build --lib --quiet
# Cargo names the rlib `libltk-<hash>.rlib` under `target/debug/deps/`.
# The hash is unstable across rebuilds (it embeds the dependency graph),
# so we discover it instead of hard-coding it. `ls -t` orders by mtime
# so a stale rlib from a previous build cannot win.
RLIB="$( ls -t target/debug/deps/libltk-*.rlib 2>/dev/null | head -1 )"
if [ -z "$RLIB" ]; then
echo "doctest-md: no libltk-*.rlib found in target/debug/deps/" >&2
echo "doctest-md: did the cargo build above succeed?" >&2
exit 1
fi
# Pick the edition from Cargo.toml so we stay in lockstep with the
# package and a future bump from 2021 → 2024 just works.
EDITION="$( grep -E '^edition[[:space:]]*=' Cargo.toml | head -1 | awk -F\" '{ print $2 }' )"
if [ -z "$EDITION" ]; then
EDITION="2021"
fi
failures=0
for f in docs/*.md; do
# Each file gets its own synthetic crate name so rustdoc's diagnostics
# are easy to attribute. `tr -c '[:alnum:]_' '_'` rewrites any
# character a Rust crate name cannot contain (`-`, `.`, etc.).
crate_name="$( basename "$f" .md | tr -c '[:alnum:]_' '_' )_md"
echo "doctest-md: $f"
if ! rustdoc --test "$f" \
--edition "$EDITION" \
--extern "ltk=$RLIB" \
-L "target/debug" \
-L "target/debug/deps" \
--crate-name "$crate_name"
then
failures=$(( failures + 1 ))
fi
done
if [ "$failures" -gt 0 ]; then
echo "doctest-md: $failures markdown file(s) with failing snippets" >&2
exit 1
fi
echo "doctest-md: all docs/*.md snippets compile"

737
src/app.rs Normal file
View File

@@ -0,0 +1,737 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub use smithay_client_toolkit::seat::keyboard::Keysym;
pub use calloop::channel::Sender as ChannelSender;
use crate::widget::Element;
/// Wayland shell mode for the application surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellMode
{
/// Normal application window using xdg-shell protocol.
/// This is the default for regular applications.
Window,
/// Layer-shell surface at the specified layer.
/// Used for shell components like panels, backgrounds, overlays.
Layer( Layer ),
}
/// Layer-shell layer position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer
{
/// Below normal windows (wallpapers, desktop backgrounds).
Background,
/// Below normal windows but above background.
Bottom,
/// Above normal windows (panels, docks).
Top,
/// Above everything (notifications, on-screen displays).
Overlay,
}
impl Layer
{
/// Convert to smithay's wlr-layer Layer type.
pub( crate ) fn to_wlr_layer( self ) -> smithay_client_toolkit::shell::wlr_layer::Layer
{
use smithay_client_toolkit::shell::wlr_layer::Layer as WlrLayer;
match self
{
Layer::Background => WlrLayer::Background,
Layer::Bottom => WlrLayer::Bottom,
Layer::Top => WlrLayer::Top,
Layer::Overlay => WlrLayer::Overlay,
}
}
}
/// Layer-shell anchor edges.
/// Determines which screen edges the surface is attached to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Anchor
{
pub top: bool,
pub bottom: bool,
pub left: bool,
pub right: bool,
}
impl Anchor
{
/// Anchor to all edges (fill screen).
pub const ALL: Self = Self { top: true, bottom: true, left: true, right: true };
/// Anchor to top edge only (horizontal bar at top).
pub const TOP: Self = Self { top: true, bottom: false, left: true, right: true };
/// Anchor to bottom edge only (horizontal bar at bottom).
pub const BOTTOM: Self = Self { top: false, bottom: true, left: true, right: true };
/// Anchor to left edge only (vertical bar at left).
pub const LEFT: Self = Self { top: true, bottom: true, left: true, right: false };
/// Anchor to right edge only (vertical bar at right).
pub const RIGHT: Self = Self { top: true, bottom: true, left: false, right: true };
/// Convert to smithay's Anchor bitflags.
pub( crate ) fn to_wlr_anchor( self ) -> smithay_client_toolkit::shell::wlr_layer::Anchor
{
use smithay_client_toolkit::shell::wlr_layer::Anchor as WlrAnchor;
let mut anchor = WlrAnchor::empty();
if self.top { anchor |= WlrAnchor::TOP; }
if self.bottom { anchor |= WlrAnchor::BOTTOM; }
if self.left { anchor |= WlrAnchor::LEFT; }
if self.right { anchor |= WlrAnchor::RIGHT; }
anchor
}
}
/// Stable identifier for an overlay surface. The runtime uses it to diff the
/// overlay list between frames: if the same `OverlayId` is returned from
/// [`App::overlays`] on consecutive frames the underlying Wayland surface
/// and its internal state are kept; if it disappears the surface is destroyed.
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
pub struct OverlayId( pub u32 );
/// One of the surfaces an [`App`] can target with an invalidation. Used inside
/// [`InvalidationScope::Only`] to name the affected surfaces.
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
pub enum SurfaceTarget
{
/// The application's main surface (the one returned by [`App::view`]).
Main,
/// The overlay with the given stable [`OverlayId`].
Overlay( OverlayId ),
}
/// Which surfaces a given [`App::update`] mutation can affect.
///
/// Returned by [`App::invalidate_after`] (default [`InvalidationScope::All`])
/// to let the runtime skip redraws on surfaces whose contents could not
/// possibly have changed by the message in question. On a shell with many
/// overlays most messages only touch one of them, so the savings are large.
#[derive( Debug, Clone )]
pub enum InvalidationScope
{
/// Treat every surface as potentially affected (safe default).
All,
/// Only the listed surfaces may have changed; others can be skipped this
/// iteration. An empty list means "nothing visible to redraw" — useful
/// for messages that only touch internal bookkeeping.
Only( Vec<SurfaceTarget> ),
}
impl InvalidationScope
{
/// Combine two scopes. `All` absorbs any other scope; two `Only` scopes
/// merge into a deduped list of targets. Used by the run loop to fold the
/// per-message scopes from a batch of pending messages into a single
/// decision before applying it.
pub fn union( self, other: Self ) -> Self
{
match ( self, other )
{
( Self::All, _ ) | ( _, Self::All ) => Self::All,
( Self::Only( mut a ), Self::Only( b ) ) =>
{
for t in b
{
if !a.contains( &t ) { a.push( t ); }
}
Self::Only( a )
}
}
}
}
/// Description of an additional layer-shell surface rendered on top of the
/// main application surface.
///
/// Apps return a list of these from [`App::overlays`] each frame. The runtime
/// creates one Wayland layer-shell surface per active overlay, renders its
/// [`view`](Self::view) to its own canvas, and dispatches input to it
/// independently of the main surface. Overlays share the same message type as
/// the main app — messages emitted by widgets inside an overlay are delivered
/// to [`App::update`] exactly like messages from the main view.
///
/// Overlays are useful for building shells whose main surface sits behind
/// regular app windows (e.g. a homescreen on [`Layer::Background`]) while
/// still exposing on-demand panels above everything (launcher, quick
/// settings, power menu…).
pub struct OverlaySpec<Message: Clone>
{
/// Stable identifier, used to diff overlays between frames.
pub id: OverlayId,
/// Wayland layer for this overlay. Typically [`Layer::Overlay`] or
/// [`Layer::Top`].
pub layer: Layer,
/// Screen edges to anchor to.
pub anchor: Anchor,
/// Desired size `( width, height )` in logical pixels. `0` in either
/// component means "fill available space in that dimension".
pub size: ( u32, u32 ),
/// Exclusive zone in pixels reserved from the anchored edge.
/// `-1` requests focus without reserving space, `0` is the default for
/// transient overlays that should not push other surfaces around.
pub exclusive_zone: i32,
/// When `true`, the compositor sends keyboard events to this overlay
/// without requiring a click first.
pub keyboard_exclusive: bool,
/// Interactive input region as a list of rects (logical pixels). Only
/// these areas receive pointer/touch input; the rest passes through.
/// `None` means the full surface receives input.
pub input_region: Option<Vec<crate::types::Rect>>,
/// Widget tree for this overlay.
pub view: Element<Message>,
/// Message sent when the overlay should be dismissed. The runtime
/// fires it in three situations:
///
/// 1. The compositor sends `xdg_popup.popup_done` (xdg-popup mode)
/// — typically when the user clicks outside the popup with the
/// grab fully active.
/// 2. The user presses pointer / touch on the main surface while
/// the overlay is still mapped, and the press does not fall on
/// the trigger rect identified by [`Self::anchor_widget_id`].
/// This covers compositors (notably Mutter) that route the
/// button to the parent surface instead of breaking the popup
/// grab when the cursor was already over the parent.
/// 3. The user presses Escape while at least one xdg-popup overlay
/// is open.
///
/// The application is expected to flip its `is_open` flag (or
/// equivalent) to `false` in `update()` so the next frame stops
/// returning the spec from [`App::overlays`]. The runtime is
/// idempotent if the message arrives more than once for the same
/// open / close cycle.
pub on_dismiss: Option<Message>,
/// When set, the overlay is rendered as a Wayland **xdg-popup**
/// anchored to the rect of the widget tagged with this
/// [`crate::types::WidgetId`] in the previous frame's layout — the
/// standard mechanism for combo / context-menu / tooltip popups in
/// `xdg-shell` applications. The compositor positions the popup
/// adjacent to the anchor and is allowed to flip it (drop-up
/// instead of drop-down) when there is not enough room.
///
/// When this field is `None`, the overlay is rendered as a
/// [`wlr-layer-shell`](Layer) surface — the path used by shells,
/// panels, lock screens and overlays that need full-surface
/// coverage. In that mode `layer` / `anchor` / `exclusive_zone` /
/// `keyboard_exclusive` carry the placement; for the popup mode
/// they are ignored.
pub anchor_widget_id: Option<crate::types::WidgetId>,
}
/// Trait that application types must implement to integrate with ltk.
pub trait App: 'static
{
/// The message type produced by this application.
type Message: Clone + 'static;
/// Build the widget tree for the current frame.
fn view( &self ) -> Element<Self::Message>;
/// Apply a message to the application state.
fn update( &mut self, msg: Self::Message );
/// Tell the runtime which surfaces *could* have changed visibly as a
/// result of [`update`](Self::update) being called with this message.
///
/// Default is [`InvalidationScope::All`] — every surface is redrawn.
/// A shell that knows, for example, that a `SetVolume` message only
/// affects its quick-settings overlay can return
/// `InvalidationScope::Only( vec![ SurfaceTarget::Overlay( quick_id ) ] )`
/// to skip pointless work on other surfaces.
///
/// Called *before* [`update`](Self::update) (so it sees the message but
/// not the post-update state). Side-effect free: must not mutate `self`.
fn invalidate_after( &self, _msg: &Self::Message ) -> InvalidationScope
{
InvalidationScope::All
}
/// Describe the auxiliary overlay surfaces that should be active this
/// frame. Each entry becomes its own Wayland layer-shell surface
/// independent from the main surface returned by [`view`](Self::view).
///
/// Return an empty `Vec` (the default) to disable multi-surface support —
/// the app then renders only onto its main surface.
///
/// The runtime diffs the list across frames using [`OverlaySpec::id`]:
/// stable IDs are kept alive, new IDs cause a surface to be created, and
/// IDs that disappear cause the surface to be destroyed.
fn overlays( &self ) -> Vec<OverlaySpec<Self::Message>> { Vec::new() }
/// Return any pending messages from external sources (timers, async, etc.).
fn poll_external( &mut self ) -> Vec<Self::Message> { vec![] }
/// Called when the surface is asked to close (compositor request, titlebar button,
/// layer-shell closed event). Return `true` to allow the application to exit,
/// or `false` to cancel.
///
/// Default: `true` (always close).
fn on_close_requested( &mut self ) -> bool { true }
/// Called when tapping/clicking outside any widget.
fn on_tap( &mut self ) -> Option<Self::Message> { None }
/// Called on key press when no text input is focused.
fn on_key( &mut self, _keysym: Keysym ) -> Option<Self::Message> { None }
/// Called on key press with modifier state. Override this instead of
/// `on_key` when you need Ctrl/Shift/Alt awareness.
fn on_key_with_modifiers( &mut self, keysym: Keysym, _ctrl: bool, _shift: bool ) -> Option<Self::Message>
{
self.on_key( keysym )
}
/// Called when a sufficient upward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., opening an overview).
/// The gesture is recognized when the user drags upward by at least
/// `swipe_threshold()` × screen height and then releases.
fn on_swipe_up( &mut self ) -> Option<Self::Message> { None }
/// Called during an upward swipe gesture with progress 0.0..=1.0.
///
/// Use this to create follow-the-finger animations. `progress` starts at 0.0
/// when the drag begins and increases as the finger moves upward, reaching 1.0
/// when the drag distance equals `swipe_threshold()` × screen height.
fn on_swipe_progress( &mut self, _progress: f32 ) {}
/// Called when a sufficient downward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., toggling quick settings).
/// The gesture is recognized when the user drags downward by at least
/// `swipe_down_threshold()` × screen height and then releases.
fn on_swipe_down( &mut self ) -> Option<Self::Message> { None }
/// Called during a downward swipe gesture with progress ≥ 0.0.
///
/// Use this to create follow-the-finger animations. `progress` starts at 0.0
/// when the drag begins and increases as the finger moves downward, reaching
/// 1.0 when the drag distance equals `swipe_down_threshold()` × screen height.
/// It is **not** clamped at 1.0 — the value keeps growing if the finger drags
/// further so that follow-the-finger panels can continue tracking the finger
/// all the way down the screen. Clamp inside the handler if you need a
/// bounded `[0, 1]` range for a visual indicator.
///
/// When the user releases without completing the gesture, this method is called
/// once more with `progress = 0.0` to signal cancellation.
fn on_swipe_down_progress( &mut self, _progress: f32 ) {}
/// Fraction of screen height required to trigger [`on_swipe_up`](Self::on_swipe_up).
///
/// Default: `0.6` (60% of screen height). Lower values make the gesture easier
/// to trigger; higher values require more vertical travel.
fn swipe_threshold( &self ) -> f32 { 0.6 }
/// Fraction of screen height required to trigger [`on_swipe_down`](Self::on_swipe_down).
///
/// Default: `0.15` (15% of screen height). Typically lower than `swipe_threshold()`
/// because downward swipes are often initiated from a small UI element like a top bar.
fn swipe_down_threshold( &self ) -> f32 { 0.15 }
/// Top-edge band where a downward swipe may originate, as a fraction of
/// screen height. Presses below this band never fire
/// [`on_swipe_down`](Self::on_swipe_down) nor
/// [`on_swipe_down_progress`](Self::on_swipe_down_progress).
///
/// Default: `1.0` (entire screen — any starting point is accepted).
/// Set to a small value like `0.05` to restrict the gesture to the top
/// edge, matching the usual system-panel pull-down UX.
fn swipe_down_edge( &self ) -> f32 { 1.0 }
/// Called when a sufficient leftward swipe gesture is detected.
///
/// Return a message to handle the swipe (e.g., paging to the next
/// homescreen). Fires on release when the user dragged left by at least
/// `swipe_horizontal_threshold()` × screen width. Unlike vertical swipes,
/// horizontal swipes survive starting the press on an interactive widget:
/// once the drag distance crosses an 8 px threshold the press is promoted
/// to a horizontal drag and no tap will fire at release.
fn on_swipe_left( &mut self ) -> Option<Self::Message> { None }
/// Called when a sufficient rightward swipe gesture is detected.
/// Mirror of [`on_swipe_left`](Self::on_swipe_left) for the other direction.
fn on_swipe_right( &mut self ) -> Option<Self::Message> { None }
/// Called during a horizontal swipe with signed progress. Negative values
/// mean the finger has moved left, positive values mean right. The value
/// is `dx / (threshold * width)`, so ±1.0 marks the commit threshold;
/// it is **not** clamped so follow-the-finger panels can keep tracking
/// past the threshold. When the user releases without completing the
/// gesture this method is called once more with `0.0` to signal
/// cancellation.
fn on_swipe_horizontal_progress( &mut self, _progress: f32 ) {}
/// Fraction of screen width required to commit [`App::on_swipe_left`] /
/// [`App::on_swipe_right`]. Default: `0.5` (half the screen). A shell that
/// wants easier paging can lower this; one that wants to avoid
/// accidental pages can raise it.
fn swipe_horizontal_threshold( &self ) -> f32 { 0.5 }
/// Duration a press must remain stationary (within tolerance) before the
/// widget's long-press message fires and the gesture transitions into
/// drag mode. Default: `500 ms`.
fn long_press_duration( &self ) -> std::time::Duration
{
std::time::Duration::from_millis( 500 )
}
/// Called on every pointer motion once a long-press has fired and the
/// gesture has entered drag mode. `x`, `y` are in physical pixels of the
/// surface that owned the original press.
fn on_drag_move( &mut self, _x: f32, _y: f32 ) {}
/// Called on release once a long-press has fired. Return a message to
/// handle the drop (e.g., commit the drop target). `x`, `y` are in
/// physical pixels of the surface that owned the original press. The
/// regular tap / swipe handling is suppressed for this gesture.
fn on_drop( &mut self, _x: f32, _y: f32 ) -> Option<Self::Message> { None }
/// Called when a text-input widget gains or loses focus.
fn on_text_input_focused( &mut self, _active: bool ) {}
/// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId).
/// The app must return `None` after the first call.
fn take_focus_request( &mut self ) -> Option<crate::types::WidgetId> { None }
/// Called on every pointer motion (mouse move, button press, button
/// release). `x`, `y` are in physical pixels of the main surface,
/// the same coordinate space as widget rects. Default: no-op.
///
/// Useful for embedding components that need to know the live
/// pointer position without going through a full
/// [`crate::widget::external::ExternalSource`] / `WidgetHandlers`
/// dispatch path — for example forwarding clicks into an embedded
/// WPE WebView.
fn on_pointer_move( &mut self, _x: f32, _y: f32 ) {}
/// Called on a wheel / touchpad scroll event whose position does
/// not fall inside any LTK [`scroll`](crate::scroll) viewport. `x`,
/// `y` are in physical pixels (same coordinate space as
/// [`Self::on_pointer_move`]); `dx`, `dy` are scroll deltas in the
/// raw axis units the compositor delivered (`AxisSource::Wheel`
/// gives ~10 px ticks, touchpads give continuous values). Default:
/// no-op.
///
/// Useful for embeddings that take over scrolling for their own
/// content — for example forwarding the event to a WPE view that
/// owns a scrollable web page.
fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {}
/// Called on every left-button mouse press / release before the
/// regular gesture machine fires. `pressed = true` for press,
/// `false` for release. `(x, y)` are in physical pixels (same
/// coordinate space as [`Self::on_pointer_move`]). Default: no-op.
///
/// Lets embeddings see the raw button transitions without going
/// through LTK's tap/long-press/drag classification — needed for
/// forwarding clicks AND drags to an embedded view, since the tap
/// classifier collapses press+release into a single event.
fn on_pointer_button( &mut self, _x: f32, _y: f32, _pressed: bool ) {}
/// Called on every keyboard event (press *and* release) before the
/// regular focus-aware dispatch ([`Self::on_key`] /
/// [`Self::on_key_with_modifiers`]) runs. Default: no-op.
///
/// `keycode` is the raw hardware scancode straight from the
/// compositor (`wl_keyboard.key`'s `key` argument); `keysym` is
/// the xkb-translated keysym the user effectively pressed. Most
/// consumers only care about `keysym`, but embeddings that
/// forward into an inner window system (a WPE web view, an X11
/// emulator, …) typically need both — the inner system does its
/// own keycode → keysym translation for layout-aware shortcuts.
///
/// This bypass exists for those embeddings: it fires *in addition*
/// to the normal callbacks; consume or ignore at will.
fn on_raw_key( &mut self, _keysym: Keysym, _keycode: u32, _pressed: bool, _ctrl: bool, _shift: bool ) {}
/// Called when the surface is (re-)configured with a new size.
///
/// `width` and `height` are in **physical pixels** (the Wayland surface
/// size multiplied by the current buffer scale). They therefore match the
/// coordinate space used for layout and the pointer/touch callbacks
/// (`on_drag_move`, `on_drop`, widget hit-testing). Also fired when the
/// buffer scale changes, so apps can refresh any state keyed off the old
/// physical dimensions.
fn on_resize( &mut self, _width: u32, _height: u32 ) {}
/// Called once at startup with a channel sender that can be used from any
/// thread to deliver messages into the event loop. Sending a message
/// immediately wakes the event loop — no polling delay.
fn set_channel_sender( &mut self, _sender: ChannelSender<Self::Message> ) {}
/// How often the event loop should call [`poll_external`](Self::poll_external) for
/// periodic work such as clock ticks. Return `None` (the default) to disable the
/// timer — `poll_external` will still be called after every Wayland event.
fn poll_interval( &self ) -> Option<std::time::Duration> { None }
/// Delay before the runtime starts repeating a held-down key.
///
/// `Some(d)` overrides whatever the compositor advertises through
/// `wl_keyboard.repeat_info`; `None` (the default) means "use the
/// compositor's setting, or fall back to 500 ms when the compositor
/// did not provide one".
///
/// Returning `Some(Duration::ZERO)` effectively disables the wait
/// before the first repeat — useful only for diagnostic builds.
fn key_repeat_delay( &self ) -> Option<std::time::Duration> { None }
/// Interval between successive synthetic key events while a key is
/// held down past the initial [`Self::key_repeat_delay`].
///
/// `Some(d)` overrides the compositor's `wl_keyboard.repeat_info`
/// rate; `None` (the default) means "use the compositor's setting,
/// or fall back to 33 ms (~30 Hz) when the compositor did not
/// provide one". Pass `Some(Duration::ZERO)` to disable repeats
/// entirely (or override the per-key gate via
/// [`Self::key_repeats`]).
fn key_repeat_interval( &self ) -> Option<std::time::Duration> { None }
/// Override the pointer cursor shape globally. When `Some(shape)`,
/// the runtime sends that shape to the compositor regardless of
/// which widget the pointer is over — useful for "the app is busy"
/// states (return [`crate::CursorShape::Wait`]) or while the app
/// is doing a long synchronous operation. Returning `None` (the
/// default) lets per-widget defaults take effect.
///
/// The runtime calls this every frame, so flipping a `loading`
/// boolean in [`Self::update`] propagates to the cursor on the
/// next iteration without any extra wiring.
fn cursor_override( &self ) -> Option<crate::types::CursorShape> { None }
/// Decide whether a given key participates in held-key repeat.
///
/// Default: every non-modifier key repeats *except* `Escape`, `Tab`
/// and `ISO_Left_Tab` — those drive one-shot UI semantics
/// (dismiss / focus cycle) where a held key would multiply the
/// effect in surprising ways. Override to widen or narrow the gate
/// (a chess clock app, for example, might want even Tab to
/// repeat).
fn key_repeats( &self, keysym: Keysym ) -> bool
{
!matches!( keysym,
Keysym::Escape | Keysym::Tab | Keysym::ISO_Left_Tab,
)
}
/// Return `true` while a frame-by-frame animation is running.
/// The event loop will keep requesting redraws at ~60 fps until this returns `false`.
fn is_animating( &self ) -> bool { false }
/// Return `true` while the next frame should swap the expensive
/// Glass passes for cheap fallbacks — currently the
/// `backdrop-filter` blur drops from a 41-tap kernel to a 9-tap
/// kernel, with the snapshot region shrunk to match. The event
/// loop sets this on the renderer right before drawing through an
/// internal low-quality-paint flag.
///
/// Default: matches [`Self::is_animating`], so a settle animation
/// automatically downgrades. Override to include other "in motion"
/// states the runtime cannot observe — e.g. a finger-tracked
/// Background color for the canvas. Override to make the surface
/// transparent or to deviate from the theme. Default: the active
/// theme's `bg` token, so the window matches the rest of the
/// shell out of the box.
fn background_color( &self ) -> crate::types::Color
{
crate::theme::palette().bg
}
/// Return the interactive input region as a list of rects (logical pixels).
/// Only these areas receive pointer/touch input; the rest passes through.
/// Return `None` (default) to receive input everywhere.
fn input_region( &self ) -> Option<Vec<crate::types::Rect>> { None }
/// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of
/// layer-shell overlay. The compositor will display the title in the title bar
/// and use the app_id for taskbar/icon matching. Return `None` (default) to
/// use layer-shell when available.
///
/// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead.
fn window_config( &self ) -> Option<( &str, &str )> { None }
/// Specify the Wayland shell mode for this application.
///
/// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.**
/// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell).
///
/// For regular applications, use the default `Window` mode.
/// For shell components (panels, backgrounds, overlays), use `Layer`.
fn shell_mode( &self ) -> ShellMode { ShellMode::Window }
/// Suggest an initial size for an xdg-shell window in logical pixels.
///
/// Returning `Some(( w, h ))` makes ltk call both
/// `xdg_toplevel.set_min_size( w, h )` and
/// `xdg_toplevel.set_max_size( w, h )` before the first commit, which
/// most compositors honour as an exact size for the configure they
/// send back. The window is still resizable from the application's
/// point of view — if the compositor sends a different configure, the
/// runtime adopts that size on the next frame.
///
/// Only applies to [`ShellMode::Window`] surfaces and to the
/// xdg-shell fallback path used when `wlr-layer-shell` is missing.
/// Ignored for layer-shell surfaces, which use [`Self::layer_size`]
/// instead.
///
/// **Default**: `None` (let the compositor pick).
fn window_size_hint( &self ) -> Option<( u32, u32 )> { None }
/// Request fullscreen on the toplevel before its first commit.
///
/// When `true`, ltk calls `xdg_toplevel.set_fullscreen( None )` so
/// the compositor picks an output and maps the surface fullscreen
/// from the start (no flicker through a windowed configure first).
/// The compositor's own decorations are suppressed for fullscreen
/// surfaces; the built-in titlebar from [`Self::window_config`] is
/// still painted by ltk on top of the surface unless the app opts
/// out.
///
/// Only applies to [`ShellMode::Window`] surfaces and to the
/// xdg-shell fallback path used when `wlr-layer-shell` is missing.
///
/// **Default**: `false`.
fn start_fullscreen( &self ) -> bool { false }
/// Specify the exclusive zone for layer-shell surfaces.
///
/// The exclusive zone reserves screen space for this surface.
/// For example, a top panel with height 50 should return `50` to prevent
/// other windows from overlapping it.
///
/// - `> 0`: Reserve this many pixels from the anchored edge
/// - `0`: No exclusive zone (default for overlays)
/// - `-1`: Don't reserve space but request focus
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn exclusive_zone( &self ) -> i32 { -1 }
/// Specify which screen edges the layer-shell surface is anchored to.
///
/// Anchoring determines the position and size of the surface:
/// - [`Anchor::TOP`]: Top bar (anchored to top, left, right)
/// - [`Anchor::BOTTOM`]: Bottom bar (anchored to bottom, left, right)
/// - [`Anchor::LEFT`]: Left sidebar (anchored to left, top, bottom)
/// - [`Anchor::RIGHT`]: Right sidebar (anchored to right, top, bottom)
/// - [`Anchor::ALL`]: Full screen (anchored to all edges)
///
/// **Default**: `Anchor::ALL` (full screen)
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn layer_anchor( &self ) -> Anchor { Anchor::ALL }
/// Specify the desired size for layer-shell surfaces.
///
/// Returns `(width, height)` where:
/// - `0` means "fill available space in that dimension"
/// - `> 0` means "use this exact size in pixels"
///
/// **Examples:**
/// - Top bar: `(0, 50)` - full width, 50px height
/// - Bottom bar: `(0, 40)` - full width, 40px height
/// - Side panel: `(300, 0)` - 300px width, full height
/// - Full screen: `(0, 0)` - fill everything (default)
///
/// **Default**: `(0, 0)` (fill all available space)
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn layer_size( &self ) -> ( u32, u32 ) { ( 0, 0 ) }
/// Request exclusive keyboard focus for this layer-shell surface.
///
/// When `true`, the compositor sends keyboard events to this surface
/// without requiring a pointer click first. Useful for greeters,
/// lock screens, and other surfaces that must capture input immediately.
///
/// **Default**: `false` (on-demand keyboard interactivity)
///
/// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows.
fn keyboard_exclusive( &self ) -> bool { false }
}
/// Run the application. Blocks until the window is closed.
///
/// Panics on init failure (no Wayland compositor, missing protocol, etc.).
/// Embedders that need to recover gracefully — e.g. fall back to a
/// software TTY UI when no compositor is reachable — should call
/// [`try_run`] instead and match on the returned [`RunError`].
pub fn run<A: App>( app: A )
{
crate::event_loop::run( app );
}
/// Run the application, returning a typed error on init failure.
///
/// Same as [`run`] but recoverable: every fatal init step (Wayland
/// connection, registry, calloop event loop, `wl_compositor` / `wl_shm`
/// / `xdg_wm_base` bindings) is converted into a [`RunError`] variant
/// the caller can match on. Once init succeeds the function blocks
/// until the surface is closed and returns `Ok(())`. Errors from the
/// dispatch loop itself (already on screen) still panic — they are
/// non-recoverable since the surface state machine cannot be unwound
/// cleanly from this entry point.
///
/// Use this when:
///
/// * the application needs a graceful fallback path on systems without
/// a Wayland session (CI runners, minimal containers, X11-only
/// environments where ltk has no backend),
/// * an embedder wants to log the specific protocol that's missing
/// instead of panicking with a generic stack trace,
/// * the caller wants to retry / wait for a compositor to come up
/// instead of aborting.
///
/// The standard `run( app )` remains the simpler entry point for
/// applications that always run on a known-good Wayland session.
///
/// ```rust,no_run
/// # use ltk::{ button, App, Element, RunError };
/// # #[ derive( Clone ) ] enum Msg {}
/// # struct MyApp;
/// # impl App for MyApp {
/// # type Message = Msg;
/// # fn view( &self ) -> Element<Msg> { button( "x" ).into() }
/// # fn update( &mut self, _: Msg ) {}
/// # }
/// match ltk::try_run( MyApp )
/// {
/// Ok( () ) => {}
/// Err( RunError::NoWaylandConnection( _ ) ) =>
/// {
/// eprintln!( "no compositor — falling back to stdio" );
/// // run a CLI fallback…
/// }
/// Err( RunError::MissingProtocol { name, .. } ) =>
/// {
/// eprintln!( "compositor lacks `{name}` — refusing to start" );
/// std::process::exit( 1 );
/// }
/// Err( e ) =>
/// {
/// eprintln!( "ltk init failed: {e}" );
/// std::process::exit( 1 );
/// }
/// }
/// ```
pub fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
crate::event_loop::try_run( app )
}
pub use crate::event_loop::RunError;

485
src/core.rs Normal file
View File

@@ -0,0 +1,485 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime-free UI surface primitives.
//!
//! This module exposes the part of ltk that is useful outside `ltk::run`:
//! widget tree layout, drawing into a `Canvas`, hit-test rect snapshots, and
//! damage tracking. It deliberately contains no Wayland client event loop,
//! layer-shell, xdg-shell, SHM pool, or frame-callback logic.
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::Arc;
pub use crate::gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use crate::render::Canvas;
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
use crate::draw::{ self, DrawCtx };
use crate::egl_context::EglOffscreenContext;
use crate::types::{ Color, Point, Rect };
use crate::widget::Element;
/// Options for one runtime-free render pass.
#[ derive( Debug, Clone, Copy ) ]
pub struct RenderOptions
{
/// Physical-pixel bounds to lay out the tree into.
pub bounds: Rect,
/// Background fill. Use [`Color::TRANSPARENT`] for a transparent target.
pub background: Color,
/// Draw red debug rectangles around laid-out interactive widgets.
pub debug_layout: bool,
}
impl RenderOptions
{
/// Render into the full `width × height` canvas with a transparent
/// background and debug layout disabled.
pub fn full_canvas( width: u32, height: u32 ) -> Self
{
Self
{
bounds: Rect { x: 0.0, y: 0.0, width: width as f32, height: height as f32 },
background: Color::TRANSPARENT,
debug_layout: false,
}
}
/// Set the background fill for the render pass.
pub fn background( mut self, color: Color ) -> Self
{
self.background = color;
self
}
/// Enable or disable debug layout rectangles.
pub fn debug_layout( mut self, yes: bool ) -> Self
{
self.debug_layout = yes;
self
}
}
/// Result of rendering a tree into a [`UiSurface`].
#[ derive( Debug, Clone ) ]
pub struct RenderOutput
{
/// Damage rects in physical pixels. Empty means "damage the full bounds".
pub damage_rects: Vec<Rect>,
/// True when the caller should treat the whole render bounds as dirty.
pub full_redraw: bool,
}
/// A retained rendering target for code that wants ltk widgets without
/// `ltk::run`.
///
/// A compositor can keep one `UiSurface` per decoration or panel, mutate
/// focus/hover/pressed state from its own input routing, then call
/// [`Self::render`] whenever it decides a repaint is needed.
// Field declaration order is load-bearing: Rust drops fields top-to-bottom,
// so `canvas` is dropped before `egl_context`. That matters because
// `Canvas::Gles::Drop` releases textures / FBOs / shader programs through
// glow, which requires the matching GL context to be current — which is what
// `Drop for UiSurface` ensures by calling `make_owned_gles_current()` first.
// If `egl_context` were declared above `canvas`, the EGL context would be
// torn down first and `canvas`'s GL releases would silently leak / corrupt
// state. Do not reorder these two fields without preserving that property.
pub struct UiSurface<Msg: Clone>
{
canvas: Canvas,
egl_context: Option<EglOffscreenContext>,
focused_idx: Option<usize>,
hovered_idx: Option<usize>,
pressed_idx: Option<usize>,
prev_focused: Option<usize>,
prev_hovered: Option<usize>,
prev_pressed: Option<usize>,
widget_rects: Vec<LaidOutWidget<Msg>>,
cursor_state: HashMap<usize, usize>,
selection_anchor: HashMap<usize, usize>,
scroll_offsets: HashMap<usize, f32>,
scroll_canvases: HashMap<usize, Canvas>,
scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
content_dirty: bool,
}
impl<Msg: Clone> UiSurface<Msg>
{
/// Create a software-backed surface of the given physical size.
///
/// This is intentionally conservative for compositor integrations: an
/// embedder typically already owns an EGL/GLES context, so `new` must
/// not allocate a hidden offscreen context per decoration. Use
/// [`Self::from_gles_context`] when the caller can provide an
/// already-current GL context.
pub fn new( width: u32, height: u32 ) -> Self
{
Self::new_software( width, height )
}
/// Create a software-backed surface explicitly.
pub fn new_software( width: u32, height: u32 ) -> Self
{
Self::from_canvas( Canvas::new( width, height ) )
}
/// Create a GLES-backed surface with an ltk-owned offscreen EGL context.
///
/// The context is made current before render/resize operations. This is
/// useful for runtime-free rendering; compositors that already own a GL
/// context can instead build a `Canvas::new_gles(...)` and pass it to
/// [`Self::from_canvas`].
pub fn try_new_gles( width: u32, height: u32 ) -> Result<Self, String>
{
let egl_context = EglOffscreenContext::new()?;
egl_context.make_current()?;
let canvas = Canvas::new_gles(
Arc::clone( egl_context.gl() ),
egl_context.version(),
width,
height,
);
Ok( Self::from_canvas_with_egl_context( canvas, Some( egl_context ) ) )
}
/// Create a GLES-backed surface from a caller-owned, already-current GL
/// context.
///
/// This is the path intended for compositor embedders: no EGL display,
/// EGL context, or pbuffer is allocated by ltk. The caller must keep
/// the underlying GL context alive and current whenever this surface
/// is created, rendered, resized, or dropped.
pub fn from_gles_context(
gl: Arc<glow::Context>,
version: GlesVersion,
width: u32,
height: u32,
) -> Self
{
Self::from_canvas( Canvas::new_gles( gl, version, width, height ) )
}
/// Create a GLES-backed surface from the GL function loader of the current
/// context.
///
/// This is useful for renderers such as Smithay's `GlesRenderer`, which
/// keep their own EGL context and expose custom GL access through a
/// callback. This constructor does not allocate an EGL context; it only
/// builds ltk's `glow` dispatch table and GPU canvas resources in the
/// context that is current while this function runs.
///
/// # Safety
///
/// `loader` must resolve symbols for the GL context that is current on this
/// thread. That same context must remain alive, and must be made current
/// before any call that touches the returned surface — **including the
/// implicit destructor**: dropping the `UiSurface` releases GPU resources
/// (textures, FBOs, programs) through the caller-owned context and will
/// leak / corrupt state if that context is not current at drop time.
pub unsafe fn from_current_gles_loader<F>(
mut loader: F,
version: GlesVersion,
width: u32,
height: u32,
) -> Self
where
F: FnMut( &str ) -> *const c_void,
{
// SAFETY: forwarded from `from_current_gles_loader`'s own `# Safety`
// contract — `loader` resolves symbols for a context current on this
// thread, so `glGetString( GL_VERSION )` (called inside
// `from_loader_function`) is well-defined.
let gl = Arc::new( unsafe { glow::Context::from_loader_function( move |name| loader( name ) ) } );
Self::from_gles_context( gl, version, width, height )
}
/// Wrap an existing canvas. This is the hook for compositor-owned GPU
/// targets once the caller provides an already-current GL context. If
/// `canvas` is `Canvas::Gles`, the caller remains responsible for making the
/// matching GL context current before calling methods that touch the
/// canvas — **including the implicit destructor**: dropping the
/// `UiSurface` releases GPU resources (textures, FBOs, programs) through
/// the caller-owned context and will leak / corrupt state if that context
/// is not current at drop time.
pub fn from_canvas( canvas: Canvas ) -> Self
{
Self::from_canvas_with_egl_context( canvas, None )
}
fn from_canvas_with_egl_context(
canvas: Canvas,
egl_context: Option<EglOffscreenContext>,
) -> Self
{
Self
{
canvas,
egl_context,
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
prev_focused: None,
prev_hovered: None,
prev_pressed: None,
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
scroll_offsets: HashMap::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
content_dirty: true,
}
}
/// Access the backing canvas after a render pass.
pub fn canvas( &self ) -> &Canvas
{
&self.canvas
}
/// Mutable access to the backing canvas for compositor-specific upload or
/// presentation code. Mark content dirty afterwards if external drawing
/// changes what ltk should preserve across partial redraws.
pub fn canvas_mut( &mut self ) -> &mut Canvas
{
self.make_owned_gles_current();
&mut self.canvas
}
/// Current canvas size in physical pixels.
pub fn size( &self ) -> ( u32, u32 )
{
self.canvas.size()
}
/// Resize the backing canvas and force the next render to redraw fully.
pub fn resize( &mut self, width: u32, height: u32 )
{
self.make_owned_gles_current();
self.canvas.resize( width, height );
self.mark_content_dirty();
}
/// Set the DPI scale used for text and font metrics.
pub fn set_dpi_scale( &mut self, scale: f32 )
{
self.make_owned_gles_current();
self.canvas.set_dpi_scale( scale );
self.mark_content_dirty();
}
/// Mark the next render as content-changing, forcing a full repaint.
pub fn mark_content_dirty( &mut self )
{
self.content_dirty = true;
}
/// Current laid-out interactive widgets from the last render.
pub fn widget_rects( &self ) -> &[ LaidOutWidget<Msg> ]
{
&self.widget_rects
}
/// Hit-test a physical point against the last rendered widget rects.
pub fn hit_test( &self, pos: Point ) -> Option<usize>
{
crate::tree::find_widget_at( &self.widget_rects, pos )
}
/// Lookup a laid-out widget by flat index.
pub fn widget( &self, flat_idx: usize ) -> Option<&LaidOutWidget<Msg>>
{
crate::tree::find_widget( &self.widget_rects, flat_idx )
}
/// Lookup the handler snapshot for a laid-out widget.
pub fn handlers( &self, flat_idx: usize ) -> Option<&WidgetHandlers<Msg>>
{
crate::tree::find_handlers( &self.widget_rects, flat_idx )
}
/// Update keyboard focus state. This is interaction-only, so the next
/// render can use partial damage when layout/content did not change.
pub fn set_focused( &mut self, idx: Option<usize> )
{
self.focused_idx = idx;
}
/// Update hover state. This is interaction-only.
pub fn set_hovered( &mut self, idx: Option<usize> )
{
self.hovered_idx = idx;
}
/// Update pressed state. This is interaction-only.
pub fn set_pressed( &mut self, idx: Option<usize> )
{
self.pressed_idx = idx;
}
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
pub fn pressed( &self ) -> Option<usize> { self.pressed_idx }
/// Render `element` into the backing canvas.
///
/// This does not commit, swap buffers, request frame callbacks, or talk to
/// Wayland. The caller owns presentation and frame pacing.
pub fn render( &mut self, element: &Element<Msg>, options: RenderOptions ) -> RenderOutput
{
self.make_owned_gles_current();
let ( width, height ) = self.canvas.size();
let damage_rects = if self.content_dirty || self.widget_rects.is_empty()
{
Vec::new()
}
else
{
draw::compute_interaction_dirty_rects(
&self.widget_rects,
self.prev_focused, self.prev_hovered, self.prev_pressed,
self.focused_idx, self.hovered_idx, self.pressed_idx,
width,
height,
)
};
let use_partial = !self.content_dirty && !damage_rects.is_empty();
// Only `compute_damage` (called below in the !use_partial branch)
// needs the previous frame's rects, and it consumes them by reference.
// The clone is therefore deferred to that branch — the partial-damage
// path (typical for hover / focus / press transitions) skips it
// entirely, saving one Vec<LaidOutWidget> clone per frame.
let old_rects: Vec<LaidOutWidget<Msg>> = if use_partial
{
Vec::new()
}
else
{
self.widget_rects.clone()
};
if use_partial
{
self.canvas.set_clip_rects( &damage_rects );
if options.background.a > 0.0
{
self.canvas.fill( options.background );
}
else
{
self.canvas.clear_rects_transparent( &damage_rects );
}
}
else
{
self.canvas.clear_clip();
if options.background.a > 0.0
{
self.canvas.fill( options.background );
}
else
{
self.canvas.clear();
}
}
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: self.focused_idx,
hovered_idx: self.hovered_idx,
pressed_idx: self.pressed_idx,
cursor_state: std::mem::take( &mut self.cursor_state ),
selection_anchor: std::mem::take( &mut self.selection_anchor ),
widget_rects: Vec::new(),
debug_layout: options.debug_layout && !use_partial,
scroll_offsets: std::mem::take( &mut self.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut self.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ),
previous_widget_rects: self.widget_rects.clone(),
};
draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 );
if ctx.debug_layout && !use_partial
{
for w in &ctx.widget_rects
{
self.canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
self.canvas.clear_clip();
let output_damage = if use_partial
{
damage_rects
}
else
{
draw::compute_damage(
&old_rects,
&ctx.widget_rects,
self.prev_focused, self.prev_hovered, self.prev_pressed,
self.focused_idx, self.hovered_idx, self.pressed_idx,
width,
height,
)
};
self.prev_focused = self.focused_idx;
self.prev_hovered = self.hovered_idx;
self.prev_pressed = self.pressed_idx;
self.widget_rects = ctx.widget_rects;
self.cursor_state = ctx.cursor_state;
self.selection_anchor = ctx.selection_anchor;
self.scroll_offsets = ctx.scroll_offsets;
// `ctx.scroll_rects` is intentionally dropped here. The `Scroll` widget
// pushes its hit-test rects into the DrawCtx during the draw pass, but
// `UiSurface` exposes no wheel-event routing API, so persisting them
// across frames serves no consumer. If a future embedder needs to
// dispatch wheel events through `UiSurface`, add a `scroll_rects()`
// getter together with a `scroll_by( flat_idx, dy )` mutator and start
// keeping the field again.
self.scroll_canvases = ctx.scroll_canvases;
self.scroll_navigable_items = ctx.scroll_navigable_items;
self.content_dirty = false;
RenderOutput
{
full_redraw: output_damage.is_empty(),
damage_rects: output_damage,
}
}
fn make_owned_gles_current( &self )
{
if let Some( egl_context ) = &self.egl_context
{
if let Err( e ) = egl_context.make_current()
{
log_make_current_failure_once( &e );
}
}
}
}
fn log_make_current_failure_once( reason: &str )
{
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once( ||
{
eprintln!( "[ltk] core: eglMakeCurrent failed: {reason} (subsequent GL ops will silently no-op or render garbage)" );
} );
}
impl<Msg: Clone> Drop for UiSurface<Msg>
{
fn drop( &mut self )
{
self.make_owned_gles_current();
}
}

102
src/draw/chrome.rs Normal file
View File

@@ -0,0 +1,102 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Surface chrome: the client-side title bar and the Wayland input
//! region. Both are backend-agnostic — the software and GPU draw
//! paths call them with a `&mut Canvas` / a `&WlSurface` and get back
//! the geometry they need for hit testing + commit.
use smithay_client_toolkit::compositor::{ CompositorState, Region };
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::render::Canvas;
use crate::types::{ Color, Rect };
/// Paint the client-side title bar (close button, divider, title text).
///
/// Returns the close button rect in physical pixels so the caller can
/// store it for hit testing. Returns `Rect::default()` when `tb_h <=
/// 0` — overlays / layer-shell surfaces have no title bar.
pub( crate ) fn draw_titlebar( canvas: &mut Canvas, title: &str, pw: u32, tb_h: f32, sf: f32 ) -> Rect
{
if tb_h <= 0.0 { return Rect::default(); }
let tb_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: tb_h };
canvas.fill_rect( tb_rect, Color::rgb( 0.15, 0.15, 0.18 ), 0.0 );
let title_size = 15.0;
// `Canvas::draw_text` interprets `y` as the baseline. Match the
// vertical-centring formula used by `Button::draw_text_button` so
// chrome and content text lines line up at the same optical height.
let title_y = ( tb_h + title_size * sf ) / 2.0 - 2.0;
let title_w = canvas.measure_text( title, title_size );
let title_x = ( pw as f32 - title_w ) / 2.0;
canvas.draw_text( title, title_x, title_y, title_size, Color::WHITE );
let btn_size = tb_h - 8.0 * sf;
let btn_x = pw as f32 - btn_size - 8.0 * sf;
let btn_y = 4.0 * sf;
let close_rect_phys = Rect { x: btn_x, y: btn_y, width: btn_size, height: btn_size };
canvas.fill_rect( close_rect_phys, Color::rgba( 1.0, 1.0, 1.0, 0.1 ), 4.0 * sf );
let cx = btn_x + btn_size / 2.0;
let cy = btn_y + btn_size / 2.0;
let arm = btn_size * 0.25;
canvas.draw_line( cx - arm, cy - arm, cx + arm, cy + arm, Color::WHITE, 2.0 * sf );
canvas.draw_line( cx + arm, cy - arm, cx - arm, cy + arm, Color::WHITE, 2.0 * sf );
canvas.draw_line( 0.0, tb_h - 0.5, pw as f32, tb_h - 0.5, Color::rgba( 1.0, 1.0, 1.0, 0.15 ), 1.0 );
close_rect_phys
}
/// Stamp a red warning banner at the top of the surface when the
/// active theme came from the in-memory B/W fallback (i.e.
/// [`crate::theme::is_fallback_active`] returned `true`). Gives the
/// user a clear visual cue that `ltk-theme-default` is missing.
///
/// Paints a thin red strip across the top of the surface (24 px
/// logical) with the install-instruction text. When the fallback is
/// not active this is a no-op.
pub( crate ) fn draw_fallback_banner(
canvas: &mut Canvas,
pw: u32,
sf: f32,
)
{
if !crate::theme::is_fallback_active() { return; }
let banner_h = 24.0 * sf;
let rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: banner_h };
canvas.fill_rect( rect, Color::rgb( 0.75, 0.10, 0.10 ), 0.0 );
let msg = "ltk: fallback theme — install `ltk-theme-default`";
let size = 12.0;
let msg_w = canvas.measure_text( msg, size );
let x = ( ( pw as f32 - msg_w ) / 2.0 ).max( 6.0 * sf );
let y = ( banner_h - size * sf ) / 2.0;
canvas.draw_text( msg, x, y, size, Color::WHITE );
}
/// Apply or clear the surface's input region (set by
/// `App::input_region`). `None` restores the default of "receive
/// input everywhere"; `Some(&[])` makes the surface pointer-through.
pub( crate ) fn apply_input_region(
wl_surface: &WlSurface,
compositor: &CompositorState,
input_region: Option<&[Rect]>,
)
{
if let Some( regions ) = input_region
{
if let Ok( region ) = Region::new( compositor )
{
for r in regions
{
region.add( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
}
wl_surface.set_input_region( Some( region.wl_region() ) );
}
} else {
wl_surface.set_input_region( None );
}
}

511
src/draw/damage.rs Normal file
View File

@@ -0,0 +1,511 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Damage tracking: what rects need to be repainted this frame, and
//! what rects need to be declared to Wayland.
//!
//! Two entry points with different jobs:
//!
//! * [`compute_interaction_dirty_rects`] — called on the partial-redraw
//! path BEFORE painting. Takes the previous and current interaction
//! snapshots (focus / hover / pressed) and emits the union of the
//! `paint_rect`s of the widgets whose state transitioned. The caller
//! uses these to install a clip mask before the partial repaint and
//! to stamp `wl_surface.damage_buffer` afterwards.
//! * [`compute_damage`] — called on the full-redraw path AFTER painting.
//! Compares the previous-frame widget rects with the just-produced
//! ones; if layout moved or changed, returns an empty vec (meaning
//! "damage the whole surface"), otherwise returns a tight list of
//! rects for the widgets whose interaction state changed.
//!
//! The 50%-of-screen heuristic is shared by both: if the accumulated
//! damage covers more than half the surface, the single full-surface
//! damage rect is cheaper than the per-region list.
use crate::types::Rect;
use crate::widget::LaidOutWidget;
/// Intersect `r` with `bounds`, returning a rect that is entirely
/// inside `bounds`. Returns a zero-size rect if they do not overlap.
pub( super ) fn clamp_rect_to( r: Rect, bounds: Rect ) -> Rect
{
let x0 = r.x.max( bounds.x );
let y0 = r.y.max( bounds.y );
let x1 = ( r.x + r.width ).min( bounds.x + bounds.width );
let y1 = ( r.y + r.height ).min( bounds.y + bounds.height );
if x1 <= x0 || y1 <= y0
{
Rect { x: x0, y: y0, width: 0.0, height: 0.0 }
} else {
Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
}
}
/// Build the dirty-rect list for an interaction-only frame: union of
/// the `paint_rect`s of the widgets whose focus / hover / pressed
/// transitioned. Each widget's `paint_rect` already encloses its hover
/// halo, focus ring, and any other overdraw, so no extra padding is
/// needed here.
pub( crate ) fn compute_interaction_dirty_rects<Msg: Clone>(
widget_rects: &[ LaidOutWidget<Msg> ],
prev_focused: Option<usize>, prev_hovered: Option<usize>, prev_pressed: Option<usize>,
new_focused: Option<usize>, new_hovered: Option<usize>, new_pressed: Option<usize>,
pw: u32,
ph: u32,
) -> Vec<Rect>
{
let mut rects: Vec<Rect> = Vec::new();
let visit = |idx_opt: Option<usize>, sink: &mut Vec<Rect>|
{
if let Some( idx ) = idx_opt
{
if let Some( w ) = widget_rects.iter().find( |w| w.flat_idx == idx )
{
if !w.handlers.is_slider()
{
sink.push( w.paint_rect );
}
}
}
};
if prev_focused != new_focused
{
visit( prev_focused, &mut rects );
visit( new_focused, &mut rects );
}
if prev_hovered != new_hovered
{
visit( prev_hovered, &mut rects );
visit( new_hovered, &mut rects );
}
if prev_pressed != new_pressed
{
visit( prev_pressed, &mut rects );
visit( new_pressed, &mut rects );
}
// Snap to integer pixel boundaries before clamping. The clip mask is
// rasterized with anti_alias=false (binary, sampled at pixel centers), and
// `Canvas::clear_rects_transparent` uses `as i32` floor on the min and
// `.ceil() as i32` on the max. If `paint_rect` carries fractional coords
// (e.g. from `expand( 14.5 )` on icon-button hover halos), those two paths
// can disagree by 1 px at the edge — leaving a pixel cleared to transparent
// black but not repainted on the next frame. Floor min / ceil max here so
// both paths see the same whole-pixel rect.
let sw = pw as f32;
let sh = ph as f32;
for r in &mut rects
{
let x0 = r.x.floor().max( 0.0 );
let y0 = r.y.floor().max( 0.0 );
let x1 = ( r.x + r.width ).ceil().min( sw );
let y1 = ( r.y + r.height ).ceil().min( sh );
r.x = x0;
r.y = y0;
r.width = ( x1 - x0 ).max( 0.0 );
r.height = ( y1 - y0 ).max( 0.0 );
}
rects.retain( |r| r.width > 0.0 && r.height > 0.0 );
rects
}
/// Compare previous and current widget rects + interaction state to
/// find damaged regions. Returns a list of damage rects, or empty vec
/// if everything changed (full redraw).
pub( crate ) fn compute_damage<Msg: Clone>(
old_rects: &[ LaidOutWidget<Msg> ],
new_rects: &[ LaidOutWidget<Msg> ],
old_focused: Option<usize>,
old_hovered: Option<usize>,
old_pressed: Option<usize>,
new_focused: Option<usize>,
new_hovered: Option<usize>,
new_pressed: Option<usize>,
screen_w: u32,
screen_h: u32,
) -> Vec<Rect>
{
// Widget tree structure changed: full redraw.
if old_rects.len() != new_rects.len()
{
return Vec::new();
}
let mut damage = Vec::new();
let changed_indices: Vec<usize> = [
old_focused, new_focused,
old_hovered, new_hovered,
old_pressed, new_pressed,
].iter().filter_map( |&idx| idx ).collect();
for &idx in &changed_indices
{
// Each widget's declared `paint_rect` already encloses its overdraw.
if let Some( w ) = old_rects.iter().find( |w| w.flat_idx == idx )
{
damage.push( w.paint_rect );
}
if let Some( w ) = new_rects.iter().find( |w| w.flat_idx == idx )
{
damage.push( w.paint_rect );
}
}
// Layout shift: any rect moved or resized => full redraw.
for ( i, old_w ) in old_rects.iter().enumerate()
{
if let Some( new_w ) = new_rects.get( i )
{
let old_r = old_w.rect;
let new_r = new_w.rect;
if ( old_r.x - new_r.x ).abs() > 0.5
|| ( old_r.y - new_r.y ).abs() > 0.5
|| ( old_r.width - new_r.width ).abs() > 0.5
|| ( old_r.height - new_r.height ).abs() > 0.5
{
return Vec::new();
}
}
}
// No interaction changes but a redraw was requested => content changed
// (e.g. clock tick) and a full redraw is the right answer.
if damage.is_empty()
{
return Vec::new();
}
// Dilate every damage rect by 1 px on each side. The GPU rect/gradient
// shaders draw their quads expanded by 1 px so the outer half of the
// SDF antialiasing band has fragments to cover; under a partial
// redraw the scissor would otherwise clip that band at the widget's
// `paint_rect` boundary, re-introducing the aliased step on the
// straight edges of pills / rounded rects. The cost is negligible
// (one extra pixel of clear + repaint per damage rect).
for r in &mut damage
{
r.x -= 1.0;
r.y -= 1.0;
r.width += 2.0;
r.height += 2.0;
}
let sw = screen_w as f32;
let sh = screen_h as f32;
for r in &mut damage
{
r.x = r.x.max( 0.0 );
r.y = r.y.max( 0.0 );
r.width = r.width.min( sw - r.x );
r.height = r.height.min( sh - r.y );
}
// Total damage > 50% of screen: full redraw is no slower and emits a
// single damage rect instead of many.
let total_damage: f32 = damage.iter().map( |r| r.width * r.height ).sum();
if total_damage > sw * sh * 0.5
{
return Vec::new();
}
damage
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::widget::WidgetHandlers;
fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()>
{
LaidOutWidget
{
rect,
flat_idx: idx,
id: None,
paint_rect: paint,
handlers: WidgetHandlers::None,
keyboard_focusable: true,
cursor: crate::types::CursorShape::Default,
}
}
// ── clamp_rect_to ─────────────────────────────────────────────────────────
#[ test ]
fn clamp_rect_to_returns_intersection()
{
let bounds = r( 0.0, 0.0, 100.0, 100.0 );
let inside = r( 10.0, 10.0, 20.0, 20.0 );
assert_eq!( clamp_rect_to( inside, bounds ), inside );
}
#[ test ]
fn clamp_rect_to_clips_to_bounds()
{
let bounds = r( 0.0, 0.0, 100.0, 100.0 );
let bleed = r( 80.0, 80.0, 50.0, 50.0 );
assert_eq!( clamp_rect_to( bleed, bounds ), r( 80.0, 80.0, 20.0, 20.0 ) );
}
#[ test ]
fn clamp_rect_to_disjoint_returns_zero_size()
{
let bounds = r( 0.0, 0.0, 100.0, 100.0 );
let off = r( 200.0, 200.0, 50.0, 50.0 );
let out = clamp_rect_to( off, bounds );
assert_eq!( ( out.width, out.height ), ( 0.0, 0.0 ) );
}
// ── compute_interaction_dirty_rects ───────────────────────────────────────
#[ test ]
fn no_state_change_yields_empty_dirty_rects()
{
let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 1 ), None, None,
Some( 1 ), None, None,
800, 600,
);
assert!( rects.is_empty() );
}
#[ test ]
fn focus_change_emits_both_old_and_new_paint_rects()
{
let widgets = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 1 ), None, None,
Some( 2 ), None, None,
800, 600,
);
assert_eq!( rects.len(), 2 );
}
#[ test ]
fn hover_change_emits_paint_rects_independent_of_focus()
{
let widgets = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 1 ), Some( 1 ), None,
Some( 1 ), Some( 2 ), None,
800, 600,
);
assert_eq!( rects.len(), 2 );
}
#[ test ]
fn dirty_rects_are_snapped_and_clamped_to_screen()
{
let widgets = vec![
lw( 1, r( -10.0, -10.0, 30.5, 40.5 ), r( -10.0, -10.0, 30.5, 40.5 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert_eq!( rects.len(), 1 );
// Floor of -10 is -10 → max(0) = 0; ceil of -10+30.5=20.5 → 21.
assert_eq!( rects[ 0 ].x, 0.0 );
assert_eq!( rects[ 0 ].y, 0.0 );
assert_eq!( rects[ 0 ].width, 21.0 );
assert_eq!( rects[ 0 ].height, 31.0 );
}
#[ test ]
fn dirty_rects_outside_screen_are_dropped()
{
let widgets = vec![
lw( 1, r( 1000.0, 1000.0, 50.0, 50.0 ), r( 1000.0, 1000.0, 50.0, 50.0 ) ),
];
let rects = compute_interaction_dirty_rects(
&widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert!( rects.is_empty() );
}
#[ test ]
fn missing_widget_idx_silently_skipped()
{
let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
// Old focus references an idx that no longer exists in widget_rects —
// this happens during a layout where a widget disappeared between
// frames. The function must not panic; it just emits whatever new
// state's rect it can find.
let rects = compute_interaction_dirty_rects(
&widgets,
Some( 99 ), None, None,
Some( 1 ), None, None,
800, 600,
);
assert_eq!( rects.len(), 1 );
}
// ── compute_damage ────────────────────────────────────────────────────────
#[ test ]
fn tree_size_change_returns_full_redraw()
{
let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let new = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ),
];
let damage = compute_damage(
&old, &new,
None, None, None,
None, None, None,
800, 600,
);
assert!( damage.is_empty(), "tree size change must signal full redraw via empty vec" );
}
#[ test ]
fn layout_shift_returns_full_redraw()
{
let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let new = vec![ lw( 1, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ) ];
let damage = compute_damage(
&old, &new,
Some( 1 ), None, None,
Some( 1 ), None, None,
800, 600,
);
assert!( damage.is_empty() );
}
#[ test ]
fn focus_change_emits_partial_damage()
{
let widgets = vec![
lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
Some( 1 ), None, None,
Some( 2 ), None, None,
800, 600,
);
assert!( !damage.is_empty(), "focus change must produce non-empty damage list" );
assert!(
damage.len() >= 2,
"both old and new focused widgets contribute their paint_rects"
);
}
#[ test ]
fn no_change_with_redraw_request_returns_full_redraw()
{
// `damage.is_empty()` after the changed_indices loop means nothing
// interaction-level changed but the caller still asked to redraw —
// content tick (e.g. clock). The function returns the empty vec to
// signal "redraw everything" rather than nothing.
let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
None, None, None,
800, 600,
);
assert!( damage.is_empty() );
}
#[ test ]
fn damage_rects_are_dilated_by_one_pixel_each_side()
{
// Every damage rect grows by 1 px on each side to cover the SDF
// antialiasing band; the rect is then clamped to the surface.
let widgets = vec![ lw( 1, r( 100.0, 100.0, 50.0, 50.0 ), r( 100.0, 100.0, 50.0, 50.0 ) ) ];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
800, 600,
);
assert_eq!( damage.len(), 2 );
for d in &damage
{
assert_eq!( d.x, 99.0 );
assert_eq!( d.y, 99.0 );
assert_eq!( d.width, 52.0 );
assert_eq!( d.height, 52.0 );
}
}
#[ test ]
fn damage_above_fifty_percent_collapses_to_full_redraw()
{
// Widget covers 60 % of a 100×100 surface. The dilated rect easily
// exceeds the 50 % threshold and the function falls back to full
// redraw.
let widgets = vec![
lw( 1, r( 0.0, 0.0, 80.0, 80.0 ), r( 0.0, 0.0, 80.0, 80.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert!( damage.is_empty(), "damage > 50 % of surface must signal full redraw" );
}
#[ test ]
fn damage_below_fifty_percent_returns_partial()
{
// 10×10 widget on a 100×100 surface → ~1 % per rect, well under 50 %.
let widgets = vec![
lw( 1, r( 5.0, 5.0, 10.0, 10.0 ), r( 5.0, 5.0, 10.0, 10.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
assert!( !damage.is_empty() );
}
#[ test ]
fn damage_clamped_to_surface_extents()
{
// Widget paint_rect at the right edge — dilation can push it past the
// surface; the clamp must keep width/height within the surface.
let widgets = vec![
lw( 1, r( 90.0, 90.0, 5.0, 5.0 ), r( 90.0, 90.0, 5.0, 5.0 ) ),
];
let damage = compute_damage(
&widgets, &widgets,
None, None, None,
Some( 1 ), None, None,
100, 100,
);
for d in &damage
{
assert!( d.x + d.width <= 100.0 + f32::EPSILON );
assert!( d.y + d.height <= 100.0 + f32::EPSILON );
}
}
}

253
src/draw/gles.rs Normal file
View File

@@ -0,0 +1,253 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GLES (EGL + FBO) draw paths.
//!
//! The GPU counterparts to [`super::software::draw_surface_full`] and
//! [`super::software::draw_surface_partial`]. Same DrawCtx flow; the
//! differences are:
//!
//! * Canvas is `Canvas::new_gles` backed by a persistent FBO rather
//! than a CPU pixmap → FBO pixels survive across frames naturally,
//! so the partial path only needs a scissor.
//! * Presentation goes through `egl_ctx.swap_buffers_with_damage`
//! which attaches + damages + commits atomically. The partial path
//! translates the top-left dirty rects into EGL's bottom-left
//! convention before passing them in.
//! * No `wl_shm` pool; no `pick_shm_format`; no byte-order swap.
//!
//! The Y-flip for the swap-with-damage call lives here (only the GPU
//! path needs it — SHM buffers are already top-down).
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::egl_context::EglContext;
use crate::event_loop::SurfaceState;
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::Element;
use super::{ DrawCtx, layout_and_draw };
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
/// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`]
/// but writes into the EGL surface's persistent FBO instead of an SHM
/// buffer:
///
/// 1. `eglMakeCurrent` so subsequent GL calls hit this surface.
/// 2. (Lazily) build the [`Canvas::Gles`], or resize its FBO if the
/// surface dimensions changed.
/// 3. Clear, run layout+draw, blit the FBO to the default framebuffer.
/// 4. Set buffer_scale + input_region (state attached to the implicit
/// commit done by `eglSwapBuffers`).
/// 5. `eglSwapBuffers` — performs the wl attach/damage/commit atomically.
pub( crate ) fn draw_surface_full_gpu<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: &Arc<EglContext>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( es ) = ss.egl_surface.as_ref() else { return };
if egl_ctx.make_current( es ).is_err() { return; }
let canvas = ss.canvas.get_or_insert_with( ||
{
let mut c = Canvas::new_gles(
Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph,
);
c.set_dpi_scale( scale as f32 );
if let Some( reg ) = crate::theme::build_font_registry()
{
c.set_font_registry( Arc::new( reg ) );
}
c
} );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.clear_clip();
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); }
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: ss.focused_idx,
hovered_idx: ss.hovered_idx,
pressed_idx: ss.gesture.pressed_idx,
cursor_state: std::mem::take( &mut ss.cursor_state ),
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
widget_rects: Vec::new(),
debug_layout,
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout
{
for w in &ctx.widget_rects
{
canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded.
draw_fallback_banner( canvas, pw, sf );
canvas.present();
ss.prev_focused = ss.focused_idx;
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
let wl_surface = ss.surface.wl_surface();
apply_input_region( wl_surface, compositor, input_region );
// Frame callback request must precede the implicit commit done by
// `swap_buffers_with_damage`, so the compositor can attach it to this
// frame and not the previous one.
request_frame( wl_surface );
// Full-surface damage. (0,0,w,h) is identical in EGL bottom-left and
// top-left coords, so no Y-flip needed.
let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] );
ss.frame_pending = true;
}
/// GPU partial-redraw path. Same flow as
/// [`super::software::draw_surface_partial`] (clip mask → repaint
/// background + widgets → present), but the clip is implemented with
/// `glScissor` (bbox union) inside the canvas, and presentation goes
/// through `eglSwapBuffers`. The FBO is preserved between frames so
/// the unclipped pixels naturally carry over.
pub( crate ) fn draw_surface_partial_gpu<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: &Arc<EglContext>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
dirty_rects: Vec<Rect>,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( es ) = ss.egl_surface.as_ref() else { return };
if egl_ctx.make_current( es ).is_err() { return; }
let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.set_clip_rects( &dirty_rects );
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); }
else
{
canvas.clear_rects_transparent( &dirty_rects );
}
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: ss.focused_idx,
hovered_idx: ss.hovered_idx,
pressed_idx: ss.gesture.pressed_idx,
cursor_state: std::mem::take( &mut ss.cursor_state ),
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded.
draw_fallback_banner( canvas, pw, sf );
canvas.clear_clip();
canvas.present();
ss.prev_focused = ss.focused_idx;
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
let wl_surface = ss.surface.wl_surface();
apply_input_region( wl_surface, compositor, input_region );
request_frame( wl_surface );
// Convert top-left dirty rects to EGL bottom-left coords for the
// swap-with-damage call.
let damage: Vec<( i32, i32, i32, i32 )> = dirty_rects.iter().map( |r|
{
let x = r.x.floor() as i32;
let w = r.width.ceil() as i32;
let h = r.height.ceil() as i32;
let y_top = r.y.floor() as i32;
let y_bottom = ph as i32 - y_top - h;
( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) )
} ).collect();
let _ = egl_ctx.swap_buffers_with_damage( es, &damage );
ss.frame_pending = true;
}

379
src/draw/layout.rs Normal file
View File

@@ -0,0 +1,379 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Element-tree recursive walker.
//!
//! [`layout_and_draw`] is the single bottom of the draw pipeline:
//! given an [`Element`] + its allocated rect, it lays out children,
//! paints leaves, threads scroll sub-canvases, and records the
//! [`LaidOutWidget`] entries the input layer will hit-test against.
//! Both software and GPU paths funnel every widget through this one
//! function.
use crate::render::Canvas;
use crate::types::Rect;
use crate::widget::{ Element, LaidOutWidget, WidgetHandlers };
use super::DrawCtx;
use super::damage::clamp_rect_to;
pub( crate ) fn layout_and_draw<Msg: Clone>(
element: &Element<Msg>,
canvas: &mut Canvas,
rect: Rect,
ctx: &mut DrawCtx<Msg>,
flat_idx: usize,
) -> usize
{
match element
{
Element::Column( col ) =>
{
let child_rects = col.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &col.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Row( r ) =>
{
let child_rects = r.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &r.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Stack( s ) =>
{
let child_rects = s.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &s.children[ child_i ].0, canvas, child_rect, ctx, idx );
}
idx
}
Element::WrapGrid( g ) =>
{
let child_rects = g.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &g.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Flex( f ) =>
{
// `Flex` is invisible chrome: it claimed leftover width up at
// `Row::layout`, here we just unwrap it and draw the child
// inside the allocated rect. No flat-index of its own.
layout_and_draw::<Msg>( f.child.as_ref(), canvas, rect, ctx, flat_idx )
}
Element::AnchoredOverlay( a ) =>
{
// Look up the anchor's rect from the previous frame's
// `widget_rects` snapshot. If found, place the child flush
// below the anchor at the child's intrinsic size; if not,
// fall back to the parent-supplied rect so the child still
// renders (modal-style, typically a one-frame artefact on
// the first frame after open).
let anchor_rect = ctx.previous_widget_rects.iter()
.find( |w| w.id == Some( a.anchor_id ) )
.map( |w| w.rect );
let target = match anchor_rect
{
Some( anchor ) =>
{
// Use the child's intrinsic preferred size so the
// popup keeps its design width / height regardless
// of how wide the trigger pill happens to be.
let ( w, h ) = a.child.preferred_size( rect.width, canvas );
Rect
{
x: anchor.x,
y: anchor.y + anchor.height + a.gap,
width: w,
height: h,
}
}
None => rect,
};
layout_and_draw::<Msg>( a.child.as_ref(), canvas, target, ctx, flat_idx )
}
Element::Pressable( p ) =>
{
// Push the wrapper's hit rect *before* recursing so that any
// interactive child pushed during recursion sits later in
// `widget_rects` and wins under `iter().rev()` hit testing.
let my_idx = flat_idx;
if p.has_handler()
{
ctx.widget_rects.push( LaidOutWidget
{
rect,
flat_idx: my_idx,
id: p.id,
paint_rect: rect,
handlers: WidgetHandlers::Button
{
on_press: p.on_press.clone(),
on_long_press: p.on_long_press.clone(),
on_drag_start: p.on_drag_start.clone(),
on_escape: p.on_escape.clone(),
repeating: false,
},
keyboard_focusable: false,
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
} );
}
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
}
Element::Container( c ) =>
{
let saved_alpha = canvas.global_alpha();
canvas.set_global_alpha( saved_alpha * c.opacity );
// Surface slot takes precedence over flat background; falls
// through to `c.background` when the slot is absent (third-
// party theme without the named surface — content still
// renders, just without the themed chrome).
let painted = match c.surface.as_deref()
{
Some( slot ) => match crate::theme::resolve_surface( slot )
{
Some( ( surf, outer ) ) =>
{
canvas.fill_surface
(
rect,
&surf.fill,
&outer,
&surf.inset_shadows,
c.corners,
);
true
}
None => false,
},
None => false,
};
if !painted
{
if let Some( bg ) = c.background
{
canvas.fill_rect( rect, bg, c.corners );
}
}
if let Some( ( color, width ) ) = c.border
{
canvas.stroke_rect( rect, color, width, c.corners );
}
let inner = crate::types::Rect
{
x: rect.x + c.pad_left,
y: rect.y + c.pad_top,
width: ( rect.width - c.pad_left - c.pad_right ).max( 0.0 ),
height: ( rect.height - c.pad_top - c.pad_bottom ).max( 0.0 ),
};
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );
canvas.set_global_alpha( saved_alpha );
result
}
Element::Scroll( s ) =>
{
let my_idx = flat_idx;
let offset_raw = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( 0.0 );
let child_h = s.child.preferred_size( rect.width, canvas ).1;
let offset = crate::widget::scroll::clamp_offset( offset_raw, child_h, rect.height );
// Write the clamped offset back so input handlers (wheel /
// drag) cannot accumulate past the content extents. Without
// this, repeated wheel ticks past the bottom keep growing
// `offset_raw` and the user has to "undo" that excess before
// the content scrolls back up. The clamp is idempotent for
// in-range values, so the only effect is collapsing
// out-of-range entries to the legitimate maximum.
if offset != offset_raw
{
ctx.scroll_offsets.insert( my_idx, offset );
}
// Reuse the sub-canvas from the previous frame if its size matches;
// only reallocate when the viewport is resized.
let sw = (rect.width.ceil() as u32).max( 1 );
let sh = (rect.height.ceil() as u32).max( 1 );
let mut sub = ctx.scroll_canvases.remove( &my_idx )
.filter( |c| c.size() == ( sw, sh ) )
.unwrap_or_else( || canvas.sub_canvas( sw, sh ) );
sub.clear();
// Child content fills at least the full viewport height so that
// children with VAlign::Bottom are positioned correctly when the
// content is shorter than the viewport.
let effective_h = child_h.max( rect.height );
// Shift child up by offset so scrolled content appears at y=0 in
// the sub-canvas.
let child_rect = Rect { x: 0.0, y: -offset, width: rect.width, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let next_idx = layout_and_draw::<Msg>( s.child.as_ref(), &mut sub, child_rect, ctx, my_idx + 1 );
// Translate widget_rects from sub-canvas space to global space; drop
// clipped ones. Also clamp `paint_rect` to the scroll viewport so a
// widget painting outside the sub-canvas does not invalidate pixels
// it cannot actually reach (the sub-canvas is blitted as a whole,
// so nothing outside the viewport appears on screen anyway).
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
// Capture every interactive item the child laid out — including
// items that the visibility filter below will discard — so the
// keyboard handler can step `hovered_idx` item-by-item without
// caring about which items are currently scrolled into view.
// The recorded Y is in pre-translation, pre-offset coordinates
// (i.e. `child_y` relative to the start of the scroll content),
// recovered by undoing the `-offset` shift the child layout pass
// applied: `content_y = w.rect.y - child_rect.y = w.rect.y + offset`.
let navigable: Vec<( usize, f32, f32 )> = new_rects.iter()
.filter( |w| w.handlers.is_navigable_list_item() )
.map( |w| ( w.flat_idx, w.rect.y + offset, w.rect.height ) )
.collect();
if !navigable.is_empty()
{
ctx.scroll_navigable_items.insert( my_idx, navigable );
}
for mut w in new_rects
{
// Sub-canvas origin (0,0) maps to global (rect.x, rect.y).
w.rect.x += rect.x;
w.rect.y += rect.y;
w.paint_rect.x += rect.x;
w.paint_rect.y += rect.y;
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
// Only keep widgets at least partially visible inside the viewport.
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
{
ctx.widget_rects.push( w );
}
}
// Same translation for scroll_rects pushed by any nested
// scroll widgets — without this, the wheel handler hit-tests
// in surface coords against rects in sub-canvas coords and
// only matches when the sub-canvas happens to start at
// (0, 0) of the surface. Clamp to the outer scroll rect so
// the inner scroll only captures wheel events inside its
// visible region.
let new_scroll_rects: Vec<( Rect, usize )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx ) in new_scroll_rects
{
r.x += rect.x;
r.y += rect.y;
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx ) );
}
}
ctx.scroll_rects.push( ( rect, my_idx ) );
canvas.blit( &sub, rect.x as i32, rect.y as i32 );
ctx.scroll_canvases.insert( my_idx, sub );
next_idx
}
Element::Viewport( v ) =>
{
let child_h = v.child.preferred_size( rect.width, canvas ).1;
let effective_h = child_h.max( rect.height );
let vw = ( rect.width.ceil() as u32 ).max( 1 );
let vh = ( rect.height.ceil() as u32 ).max( 1 );
let mut sub = canvas.sub_canvas( vw, vh );
sub.clear();
let child_rect = Rect { x: 0.0, y: 0.0, width: rect.width, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let next_idx = layout_and_draw::<Msg>( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx );
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
for mut w in new_rects
{
w.rect.x += rect.x;
w.rect.y += rect.y;
w.paint_rect.x += rect.x;
w.paint_rect.y += rect.y;
w.paint_rect = clamp_rect_to( w.paint_rect, rect );
if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height
{
ctx.widget_rects.push( w );
}
}
// Translate scroll_rects pushed by nested scroll widgets
// from sub-canvas to surface space. Same reasoning as the
// scroll-inside-scroll case above.
let new_scroll_rects: Vec<( Rect, usize )> =
ctx.scroll_rects.drain( scroll_rects_before.. ).collect();
for ( mut r, idx ) in new_scroll_rects
{
r.x += rect.x;
r.y += rect.y;
let clamped = clamp_rect_to( r, rect );
if clamped.width > 0.0 && clamped.height > 0.0
{
ctx.scroll_rects.push( ( clamped, idx ) );
}
}
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
next_idx
}
other =>
{
// Gate the focus ring by `is_focusable`: a widget that opts out
// of keyboard focus (e.g. `Button::focusable(false)`) should not
// paint a ring even if it ends up in `focused_idx` after a tap.
let is_focused = ctx.focused_idx == Some( flat_idx ) && other.is_focusable();
let is_hovered = ctx.hovered_idx == Some( flat_idx );
let is_pressed = ctx.pressed_idx == Some( flat_idx );
let cursor_pos = ctx.cursor_state.get( &flat_idx ).copied().unwrap_or( 0 );
let sel_anchor = ctx.selection_anchor.get( &flat_idx ).copied().unwrap_or( cursor_pos );
let widget_id = match other
{
Element::Button( b ) => b.id,
Element::TextEdit( t ) => t.id,
Element::Toggle( t ) => t.id,
Element::Checkbox( c ) => c.id,
Element::Radio( r ) => r.id,
Element::ListItem( l ) => l.id,
Element::WindowButton( b ) => b.id,
_ => None,
};
other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor );
if other.is_interactive()
{
ctx.widget_rects.push( LaidOutWidget
{
rect,
flat_idx,
id: widget_id,
paint_rect: other.paint_bounds( rect ),
handlers: other.handlers(),
keyboard_focusable: other.is_focusable(),
cursor: other.cursor_shape(),
} );
}
flat_idx + 1
}
}
}

327
src/draw/mod.rs Normal file
View File

@@ -0,0 +1,327 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Per-frame drawing pipeline.
//!
//! The run loop hands each configured surface to [`draw_frame`] once per
//! vblank; this module walks it through a decision tree:
//!
//! * **Skip** — no content changed and no interaction state moved, so the
//! previously committed buffer is still correct.
//! * **Partial** — only focus / hover / pressed changed. Install a clip
//! mask covering the paint rects of the affected widgets, repaint only
//! under the clip, damage Wayland with exactly those rects.
//! * **Full** — something substantive changed (app message, animation
//! tick, configure, text edit, scroll, slider drag). Clear + redraw
//! the entire view, let damage tracking tighten the commit.
//!
//! Each of those paths has a software variant (CPU + SHM pool) and a
//! GLES variant (FBO + EGL swap). The four resulting functions live in
//! [`software`] and [`gles`]; this file is just the router plus the
//! small shared setup ([`DrawCtx`], [`pick_shm_format`]).
//!
//! # Submodule layout
//!
//! * [`software`] — `draw_surface_full` / `draw_surface_partial`
//! * [`gles`] — `draw_surface_full_gpu` / `draw_surface_partial_gpu`
//! * [`damage`] — `compute_interaction_dirty_rects`, `compute_damage`,
//! `clamp_rect_to`
//! * [`chrome`] — `draw_titlebar`, `apply_input_region`
//! * [`layout`] — `layout_and_draw` (the recursive element walker)
use std::collections::HashMap;
use std::sync::Arc;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use smithay_client_toolkit::shm::Shm;
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::{ Element, LaidOutWidget };
pub( crate ) mod software;
pub( crate ) mod gles;
pub( crate ) mod damage;
pub( crate ) mod chrome;
pub( crate ) mod layout;
pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects };
pub( crate ) use layout::layout_and_draw;
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
///
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems, so we can
/// copy with a plain memcpy. If the compositor doesn't advertise it, fall back
/// to Argb8888 (mandatory per wl_shm) which requires a per-channel swap.
///
/// Returns `(format, swap_rb)`.
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
{
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
{
( wl_shm::Format::Abgr8888, false )
} else {
( wl_shm::Format::Argb8888, true )
}
}
/// Per-frame draw state threaded through [`layout_and_draw`]. Captures
/// the interaction snapshot (focus / hover / pressed), scratch space
/// for the widget-rect list the frame will produce, and the scroll
/// offsets / sub-canvases carried across frames.
///
/// `scroll_canvases` arrives populated (the previous frame's
/// sub-canvases) so `layout_and_draw` can re-use them for Scroll
/// viewports whose size did not change. `scroll_rects` and
/// `widget_rects` start empty and get filled as the element tree is
/// walked.
pub( crate ) struct DrawCtx<Msg: Clone>
{
pub focused_idx: Option<usize>,
pub hovered_idx: Option<usize>,
pub pressed_idx: Option<usize>,
pub cursor_state: HashMap<usize, usize>,
pub selection_anchor: HashMap<usize, usize>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub debug_layout: bool,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<(Rect, usize)>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll navigation map: list of `(flat_idx, content_y, height)`
/// for every interactive item the scroll's child laid out, in
/// document order, **including items currently scrolled off-screen**.
/// Keyboard arrow handlers read this to step the runtime's
/// `hovered_idx` item-by-item without needing to know how the popup
/// content was composed. The Y is in pre-translation, pre-offset
/// coordinates (i.e. relative to the start of the scroll's child
/// content) so the keyboard auto-scroll can compute the offset
/// needed to bring an item into view without depending on the
/// current scroll position.
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
/// Snapshot of the previous frame's `widget_rects`. Read by
/// [`crate::widget::anchored_overlay::AnchoredOverlay`] at draw time
/// to look up the rect of an anchor widget by [`crate::WidgetId`] and
/// re-position itself relative to that rect. Drivers populate this
/// before invoking the recursive layout / draw walk.
pub previous_widget_rects: Vec<LaidOutWidget<Msg>>,
}
/// Paint the built-in Copy / Cut / Paste context menu on top of the
/// finished surface content. Called from the software and GLES draw
/// paths right before `present()` so the menu sits above everything
/// the widget tree painted, matching the convention every other
/// toolkit follows for runtime-internal popups.
pub( crate ) fn draw_context_menu(
canvas: &mut crate::render::Canvas,
menu: &crate::event_loop::app_data::ContextMenu,
)
{
let palette = crate::theme::palette();
let bg = palette.surface;
let border = palette.divider;
let text = palette.text_primary;
let muted = palette.text_secondary;
let hi = palette.surface_alt;
let r = menu.rect;
canvas.fill_rect( r, bg, 8.0 );
canvas.stroke_rect( r, border, 1.0, 8.0 );
let ( ys, row_h ) = menu.row_ys();
// Row order: Copy / Cut / Paste / Delete. Labels go through
// `rust_i18n::t!()` so the menu picks up the active locale; the
// `enabled` flag mirrors the gating in `handle_context_menu_press`.
let labels: [ ( String, bool ); 4 ] =
[
( rust_i18n::t!( "context_menu.copy" ).to_string(), menu.has_selection ),
( rust_i18n::t!( "context_menu.cut" ).to_string(), menu.has_selection ),
( rust_i18n::t!( "context_menu.paste" ).to_string(), menu.can_paste ),
( rust_i18n::t!( "context_menu.delete" ).to_string(), menu.has_selection ),
];
// Subtle accent band on the row matching the *primary* action so
// the menu reads as "Paste is the default" when there is no
// selection (the common case for a paste-into-empty-field click)
// and "Copy is the default" when a selection is active. Just a
// hint, not a binding — every row still works on its own click.
let primary_idx = if menu.has_selection { 0 } else { 2 };
let primary_band = crate::types::Rect
{
x: r.x + 4.0, y: ys[ primary_idx ] + 2.0,
width: r.width - 8.0, height: row_h - 4.0,
};
canvas.fill_rect( primary_band, hi, 6.0 );
for ( i, ( label, enabled ) ) in labels.iter().enumerate()
{
let color = if *enabled { text } else { muted };
canvas.draw_text(
label,
r.x + 16.0,
ys[ i ] + row_h * 0.5 + 5.0,
14.0,
color,
);
}
// Thin separator between every pair of rows.
let sep_color = palette.divider;
for y in ys.iter().skip( 1 )
{
canvas.draw_line( r.x + 8.0, *y, r.x + r.width - 8.0, *y, sep_color, 1.0 );
}
}
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
{
// Caches were refreshed by the run loop just before calling us; pull them
// by reference instead of re-invoking `App::view` / `App::overlays` each
// frame. The two `expect`s document the run loop's contract.
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
let main_bg = data.app.background_color();
let main_region = data.app.input_region();
let debug_layout = data.debug_layout;
let ( format, swap_rb ) = pick_shm_format( &data.shm );
let egl_ctx = data.egl_context.as_ref();
let qh = &data.qh;
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
{
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
draw_surface::<A::Message>(
&mut data.main,
&data.compositor_state,
egl_ctx,
main_view,
main_bg,
main_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
}
for spec in overlays
{
if let Some( ss ) = data.overlays.get_mut( &spec.id )
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let focus = SurfaceFocus::Overlay( spec.id );
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
draw_surface::<A::Message>(
ss,
&data.compositor_state,
egl_ctx,
&spec.view,
bg,
spec.input_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
ss.needs_redraw = false;
ss.last_draw = std::time::Instant::now();
}
}
}
/// Render one surface's current view. Picks between software and GLES,
/// and within each picks between full and partial redraw based on what
/// changed since the last committed frame.
///
/// The decision:
/// * **Skip** — `content_dirty` is false and no interaction state
/// changed. Previously committed buffer stays on screen.
/// * **Partial** — `content_dirty` is false but focus / hover / pressed
/// transitioned. Canvas is preserved across frames, so clip to the
/// dirty widgets and repaint only under the clip.
/// * **Full** — `content_dirty` is true. Clear + redraw + damage.
///
/// Partial eligibility also bails out when the total dirty area >50%
/// of the surface: at that ratio per-region clipping is no faster than
/// a plain full redraw, so the code prefers one big damage rect over
/// several small ones.
fn draw_surface<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &smithay_client_toolkit::compositor::CompositorState,
egl_ctx: Option<&Arc<crate::egl_context::EglContext>>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
request_frame: &dyn Fn( &WlSurface ),
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
let w = ss.width;
let h = ss.height;
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
// Decide partial-redraw eligibility BEFORE allocating a buffer. If we end
// up skipping the frame, we want to avoid touching the SHM pool at all.
let canvas_ready = ss.canvas.as_ref()
.map( |c| c.size() == ( pw, ph ) )
.unwrap_or( false );
let partial_eligible = !ss.content_dirty
&& canvas_ready
&& !ss.widget_rects.is_empty();
if partial_eligible
{
let dirty_rects = compute_interaction_dirty_rects(
&ss.widget_rects,
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
pw, ph,
);
if dirty_rects.is_empty()
{
// Nothing visible changed — keep the previously committed buffer.
return;
}
// Total dirty area > 50% of screen: a full redraw is no slower than
// per-region clipping but emits a single damage rect.
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
if total < pw as f32 * ph as f32 * 0.5
{
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
gles::draw_surface_partial_gpu(
ss, compositor, ctx, view, bg, input_region,
dirty_rects, pw, ph, scale, request_frame,
);
} else {
software::draw_surface_partial(
ss, compositor, view, bg, input_region,
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
);
}
return;
}
}
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
gles::draw_surface_full_gpu(
ss, compositor, ctx, view, bg, input_region, debug_layout,
pw, ph, scale, request_frame,
);
} else {
software::draw_surface_full(
ss, compositor, view, bg, input_region, debug_layout,
shm_format, swap_rb, pw, ph, scale, request_frame,
);
}
}

283
src/draw/software.rs Normal file
View File

@@ -0,0 +1,283 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Software (CPU + `wl_shm` pool) draw paths.
//!
//! Two functions, both symmetric with their GPU counterparts in
//! [`super::gles`]:
//!
//! * [`draw_surface_full`] — full redraw. Allocate a buffer, clear the
//! canvas, run layout+draw, write pixels to the SHM buffer, damage
//! the whole surface (or fine-grained rects from [`compute_damage`]),
//! commit.
//! * [`draw_surface_partial`] — clip-masked repaint. The canvas
//! pixmap from the previous frame is still valid; install a clip
//! covering only the dirty rects, repaint the background + title bar
//! + widget tree under the clip, and damage Wayland with exactly
//! those rects.
//!
//! Both paths share the DrawCtx / `layout_and_draw` / `draw_titlebar`
//! / `apply_input_region` helpers with the GPU path.
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use crate::event_loop::SurfaceState;
use crate::render::Canvas;
use crate::types::{ Color, Rect };
use crate::widget::Element;
use super::{ DrawCtx, compute_damage, layout_and_draw };
use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar };
/// Full redraw path: clear the canvas, run layout+draw for every widget, then
/// emit either tight per-widget damage or a single full-surface damage rect
/// depending on what [`compute_damage`] derives.
pub( crate ) fn draw_surface_full<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( pool ) = ss.pool.as_mut() else { return };
let stride = pw * 4;
let ( buffer, canvas_buf ) = match pool.create_buffer(
pw as i32, ph as i32, stride as i32, shm_format,
)
{
Ok( r ) => r,
Err( _ ) => return,
};
let canvas = ss.canvas.get_or_insert_with( || {
let mut c = Canvas::new( pw, ph );
c.set_dpi_scale( scale as f32 );
if let Some( reg ) = crate::theme::build_font_registry()
{
c.set_font_registry( Arc::new( reg ) );
}
c
} );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.clear_clip();
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) };
if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); }
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: ss.focused_idx,
hovered_idx: ss.hovered_idx,
pressed_idx: ss.gesture.pressed_idx,
cursor_state: std::mem::take( &mut ss.cursor_state ),
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
widget_rects: Vec::new(),
debug_layout,
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if ctx.debug_layout
{
for w in &ctx.widget_rects
{
canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded.
draw_fallback_banner( canvas, pw, sf );
let damage_rects = if ss.content_dirty
{
Vec::new()
} else {
compute_damage(
&ss.widget_rects,
&ctx.widget_rects,
ss.prev_focused,
ss.prev_hovered,
ss.prev_pressed,
ss.focused_idx,
ss.hovered_idx,
ss.gesture.pressed_idx,
pw, ph,
)
};
ss.prev_focused = ss.focused_idx;
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
let wl_surface = ss.surface.wl_surface();
buffer.attach_to( wl_surface ).expect( "attach" );
if damage_rects.is_empty()
{
wl_surface.damage_buffer( 0, 0, pw as i32, ph as i32 );
} else {
for r in &damage_rects
{
wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
}
}
apply_input_region( wl_surface, compositor, input_region );
// Request a frame callback BEFORE commit so the compositor schedules it
// against this exact frame; sets `frame_pending` so the run loop won't
// try to draw this surface again until the callback fires.
request_frame( wl_surface );
wl_surface.commit();
ss.frame_pending = true;
}
/// Partial redraw: install a clip mask covering only `dirty_rects`, repaint
/// the background + title bar + widget tree under that mask (so unchanged
/// pixels from the previous frame stay), and damage Wayland with exactly
/// those rects.
pub( crate ) fn draw_surface_partial<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
shm_format: wl_shm::Format,
swap_rb: bool,
dirty_rects: Vec<Rect>,
pw: u32,
ph: u32,
scale: u32,
request_frame: &dyn Fn( &WlSurface ),
)
{
let Some( pool ) = ss.pool.as_mut() else { return };
let stride = pw * 4;
let ( buffer, canvas_buf ) = match pool.create_buffer(
pw as i32, ph as i32, stride as i32, shm_format,
)
{
Ok( r ) => r,
Err( _ ) => return,
};
// Canvas pixels from the previous frame are still valid for the unclipped
// region and serve as our background — do not call clear()/fill() here.
let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" );
canvas.set_clip_rects( &dirty_rects );
let sf = scale as f32;
let tb_h = ss.titlebar_height * sf;
let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) };
// Repaint surface background under the clip so the dirty rects start from
// a known state instead of leaking the previous widget's pixels.
if bg.a > 0.0 { canvas.fill( bg ); }
else
{
canvas.clear_rects_transparent( &dirty_rects );
}
ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf );
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: ss.focused_idx,
hovered_idx: ss.hovered_idx,
pressed_idx: ss.gesture.pressed_idx,
cursor_state: std::mem::take( &mut ss.cursor_state ),
selection_anchor: std::mem::take( &mut ss.selection_anchor ),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: std::mem::take( &mut ss.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut ss.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ),
previous_widget_rects: ss.widget_rects.clone(),
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
if let Some( ref menu ) = ss.context_menu
{
super::draw_context_menu( canvas, menu );
}
// Fallback-theme warning. Painted last so it sits on top of every
// widget; no-op when the real theme is loaded. If the banner's row
// happens to fall outside the partial-redraw clip, the previous
// frame's banner is still on the canvas (pixmap preserved), which
// is the correct behaviour.
draw_fallback_banner( canvas, pw, sf );
canvas.clear_clip();
ss.prev_focused = ss.focused_idx;
ss.prev_hovered = ss.hovered_idx;
ss.prev_pressed = ss.gesture.pressed_idx;
ss.widget_rects = ctx.widget_rects;
ss.scroll_rects = ctx.scroll_rects;
ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases );
ss.cursor_state = ctx.cursor_state;
ss.selection_anchor = ctx.selection_anchor;
ss.scroll_offsets = ctx.scroll_offsets;
ss.scroll_navigable_items = ctx.scroll_navigable_items;
ss.content_dirty = false;
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
let wl_surface = ss.surface.wl_surface();
buffer.attach_to( wl_surface ).expect( "attach" );
for r in &dirty_rects
{
wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
}
apply_input_region( wl_surface, compositor, input_region );
request_frame( wl_surface );
wl_surface.commit();
ss.frame_pending = true;
}

515
src/egl_context.rs Normal file
View File

@@ -0,0 +1,515 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! EGL bootstrap for the GPU rendering path.
//!
//! Initialises an `EGLDisplay` from the Wayland connection, picks an
//! `EGLConfig`, and creates an `EGLContext`. Tries GLES 3 first and falls
//! back to GLES 2 if the driver does not advertise it. On success returns an
//! [`EglContext`] holding a `glow::Context` already pointed at the resolved
//! GL functions; on failure returns `Err( reason )` so the caller can fall
//! back to the software `wl_shm` path.
//!
//! Per-surface, [`EglSurface`] wraps a `wl_egl_window` plus an `EGLSurface`
//! pinned to the wayland surface. Resizing the wayland surface must call
//! [`EglSurface::resize`] so the underlying buffer follows.
//!
//! The bootstrap honours `LTK_FORCE_SOFTWARE=1` by failing fast with a
//! descriptive error. Backend selection is announced exactly once per
//! process via `eprintln!( "[ltk] render backend: {GLES3|GLES2|SOFTWARE (...)}" )`
//! — the GPU branch logs from [`EglContext::new`], the SOFTWARE branch from
//! [`log_software_fallback`] (called by the integration site in `draw.rs`).
use khronos_egl as egl;
use std::ffi::c_void;
use std::sync::{ Arc, Once, OnceLock };
use crate::gles_render::GlesVersion;
use smithay_client_toolkit::reexports::client::
{
Connection,
Proxy,
protocol::wl_surface::WlSurface,
};
type EglInstance = egl::DynamicInstance<egl::EGL1_4>;
/// `eglSwapBuffersWithDamageKHR` / `EXT` signature. Loaded at runtime via
/// `eglGetProcAddress` when the corresponding extension is advertised.
///
/// Why we need this: Mesa's plain `eglSwapBuffers` emits
/// `wl_surface.damage(0, 0, INT32_MAX, INT32_MAX)` as a "damage everything"
/// sentinel. Some compositor renderers (sway/wlroots GLES backend at the
/// time of writing) consume that value literally, calling
/// `glTexSubImage2D(0, 0, INT32_MAX, INT32_MAX)` to upload the wl_buffer to
/// their internal cache texture. That fails with `GL_INVALID_VALUE` and the
/// compositor keeps showing the previously-cached frame (the first frame
/// goes through `glTexImage2D` which doesn't have this bug, so the *initial*
/// render is fine — but every subsequent eglSwapBuffers is silently dropped).
///
/// Using `eglSwapBuffersWithDamage` makes Mesa emit
/// `wl_surface.damage_buffer(x, y, w, h)` with the actual rect we pass,
/// which the compositor then uploads correctly.
type SwapBuffersWithDamageFn = unsafe extern "system" fn(
display: egl::EGLDisplay,
surface: egl::EGLSurface,
rects: *const egl::Int,
n_rects: egl::Int,
) -> egl::Boolean;
/// Process-wide EGL display + GLES context. Cheap to clone because the heavy
/// state (`Arc<EglInstance>`, `Arc<glow::Context>`) is reference-counted; the
/// raw EGL handles are POD.
pub struct EglContext
{
pub egl: Arc<EglInstance>,
pub display: egl::Display,
pub config: egl::Config,
pub context: egl::Context,
pub version: GlesVersion,
// Initialised lazily on the first `make_current`. Glow's constructor
// eagerly calls `glGetString( GL_VERSION )`, which requires a current
// context — and at `EglContext::new` time there is no surface yet.
gl: OnceLock<Arc<glow::Context>>,
/// Resolved `eglSwapBuffersWithDamageKHR` / `EXT` pointer when either
/// extension is advertised, `None` otherwise. The draw path uses it in
/// place of `eglSwapBuffers` to avoid Mesa's `INT32_MAX` damage sentinel
/// (see [`SwapBuffersWithDamageFn`] docs).
swap_with_damage: Option<SwapBuffersWithDamageFn>,
}
/// Per-Wayland-surface EGL window. `egl_window` owns the `wl_egl_window`; it
/// must outlive `surface` because EGL keeps a raw pointer into it. `egl` and
/// `display` are kept so that `Drop` can call `eglDestroySurface` without
/// requiring a `&EglContext` at the call site.
pub struct EglSurface
{
pub egl_window: wayland_egl::WlEglSurface,
pub surface: egl::Surface,
egl: Arc<EglInstance>,
display: egl::Display,
}
/// Runtime-free EGL target for code that wants a GPU [`crate::render::Canvas`]
/// without going through `ltk::run`.
///
/// This owns an EGL display, GLES context, and tiny pbuffer surface. The ltk
/// GLES canvas still renders into its own FBO; the pbuffer exists only to make
/// a valid EGL context current for GL calls. Compositors that already own a GL
/// context should normally create `Canvas::new_gles(...)` themselves and pass it
/// to `UiSurface::from_canvas` instead.
pub struct EglOffscreenContext
{
egl: Arc<EglInstance>,
display: egl::Display,
config: egl::Config,
context: egl::Context,
surface: egl::Surface,
version: GlesVersion,
gl: Arc<glow::Context>,
}
impl Drop for EglSurface
{
fn drop( &mut self )
{
// Best-effort: errors here can't be acted on, the surface is going away.
let _ = self.egl.destroy_surface( self.display, self.surface );
}
}
impl Drop for EglOffscreenContext
{
fn drop( &mut self )
{
let _ = self.egl.make_current( self.display, None, None, None );
let _ = self.egl.destroy_surface( self.display, self.surface );
let _ = self.egl.destroy_context( self.display, self.context );
let _ = self.egl.terminate( self.display );
}
}
impl EglContext
{
/// Initialise EGL on `conn`. Returns `Err( reason )` if EGL cannot be
/// used (forced software, library missing, no compatible config, ES2/3
/// context creation failed). The caller falls back to SHM and is
/// expected to log via [`log_software_fallback`].
pub fn new( conn: &Connection ) -> Result<Self, String>
{
if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false )
{
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
}
// SAFETY: `EglInstance::load_required` performs a `dlopen` on the
// system `libEGL.so` and is unsafe because the loaded library has
// arbitrary side effects on global process state. We tolerate that:
// libEGL is an established system component and ltk has no
// alternative path to GPU rendering.
let egl: Arc<EglInstance> = Arc::new(
unsafe { EglInstance::load_required() }
.map_err( |e| format!( "load libEGL: {e:?}" ) )?,
);
let wl_display_ptr = conn.backend().display_ptr() as *mut c_void;
// SAFETY: `wl_display_ptr` comes from `Connection::backend().display_ptr()`,
// which guarantees a valid `wl_display *` for as long as `conn` lives.
// `conn` is a `&Connection` argument so the pointer is valid for the
// duration of this call. EGL retains the display reference internally
// and we keep `egl` alive for the process lifetime.
let display = unsafe { egl.get_display( wl_display_ptr ) }
.ok_or_else( || "eglGetDisplay returned NULL".to_string() )?;
egl.initialize( display )
.map_err( |e| format!( "eglInitialize: {e:?}" ) )?;
// Multiple client APIs can coexist on EGL 1.4; we want OpenGL ES.
egl.bind_api( egl::OPENGL_ES_API )
.map_err( |e| format!( "eglBindAPI: {e:?}" ) )?;
let config_attribs = [
egl::SURFACE_TYPE, egl::WINDOW_BIT,
egl::RED_SIZE, 8,
egl::GREEN_SIZE, 8,
egl::BLUE_SIZE, 8,
egl::ALPHA_SIZE, 8,
egl::RENDERABLE_TYPE, egl::OPENGL_ES2_BIT,
egl::NONE,
];
let config = egl.choose_first_config( display, &config_attribs )
.map_err( |e| format!( "eglChooseConfig: {e:?}" ) )?
.ok_or_else( || "no compatible EGL config".to_string() )?;
// Try ES3 first, fall back to ES2. CONTEXT_CLIENT_VERSION applies to
// both ES2 and ES3 contexts when the value is the major version.
let ( context, version ) = match try_create_context( &egl, display, config, 3 )
{
Ok( ctx ) => ( ctx, GlesVersion::V3 ),
Err( _ ) => match try_create_context( &egl, display, config, 2 )
{
Ok( ctx ) => ( ctx, GlesVersion::V2 ),
Err( e ) => return Err( format!( "eglCreateContext (ES2/ES3): {e:?}" ) ),
},
};
log_backend_once( match version
{
GlesVersion::V3 => "GLES3",
GlesVersion::V2 => "GLES2",
} );
// Try to resolve eglSwapBuffersWithDamage{KHR,EXT}. The KHR variant is
// preferred (newer, identical signature). Falling back to plain
// eglSwapBuffers when neither is available means we keep the latent
// INT32_MAX-damage bug, but at least we don't silently fail to start.
let extensions = egl.query_string( Some( display ), egl::EXTENSIONS )
.ok()
.and_then( |s| s.to_str().ok() )
.unwrap_or( "" );
let proc_name = if extensions.contains( "EGL_KHR_swap_buffers_with_damage" )
{
Some( "eglSwapBuffersWithDamageKHR" )
} else if extensions.contains( "EGL_EXT_swap_buffers_with_damage" ) {
Some( "eglSwapBuffersWithDamageEXT" )
} else {
None
};
// SAFETY: `proc_name` is one of the literals
// `"eglSwapBuffersWithDamageKHR"` / `"eglSwapBuffersWithDamageEXT"`,
// gated on the matching extension being advertised by `eglQueryString`.
// When EGL returns a non-null pointer for that name, the symbol is
// guaranteed by the EGL extension spec to have the
// `SwapBuffersWithDamageFn` signature. The pointer is stored as
// `Option<fn>` and only invoked through that typed slot.
let swap_with_damage: Option<SwapBuffersWithDamageFn> = proc_name
.and_then( |n| egl.get_proc_address( n ) )
.map( |p| unsafe { std::mem::transmute::<_, SwapBuffersWithDamageFn>( p ) } );
Ok( Self { egl, display, config, context, version, gl: OnceLock::new(), swap_with_damage } )
}
/// Access the lazily-constructed `glow::Context`. Must be called only after
/// the first successful `make_current`; panics otherwise.
pub fn gl( &self ) -> &Arc<glow::Context>
{
self.gl.get().expect( "EglContext::gl() called before make_current" )
}
/// Create an `EGLSurface` pinned to `wl_surface` at the given pixel size.
pub fn create_surface(
&self, wl_surface: &WlSurface, width: i32, height: i32,
) -> Result<EglSurface, String>
{
let id = wl_surface.id();
let egl_window = wayland_egl::WlEglSurface::new( id, width.max( 1 ), height.max( 1 ) )
.map_err( |e| format!( "wl_egl_window::new: {e:?}" ) )?;
// SAFETY: `egl_window` is a freshly-built `WlEglSurface` whose `ptr()`
// returns the live `wl_egl_window *`. The returned `EglSurface`
// embeds `egl_window` so the pointer outlives the EGL surface (EGL
// retains its own internal reference). `display` and `config` are
// the values we used at context creation, valid for the lifetime
// of `self`.
let surface = unsafe
{
self.egl.create_window_surface(
self.display,
self.config,
egl_window.ptr() as egl::NativeWindowType,
None,
)
}.map_err( |e| format!( "eglCreateWindowSurface: {e:?}" ) )?;
Ok( EglSurface
{
egl_window,
surface,
egl: Arc::clone( &self.egl ),
display: self.display,
} )
}
/// Make `surface` current for subsequent GL calls. The first successful
/// call lazily constructs the shared `glow::Context` (glow needs a live
/// current context to read `GL_VERSION` during initialisation).
pub fn make_current( &self, surface: &EglSurface ) -> Result<(), String>
{
self.egl.make_current(
self.display,
Some( surface.surface ),
Some( surface.surface ),
Some( self.context ),
).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )?;
if self.gl.get().is_none()
{
let egl_for_loader = Arc::clone( &self.egl );
// SAFETY: `from_loader_function` is unsafe because it calls
// `glGetString( GL_VERSION )` during construction, which requires
// a current context — established by the `make_current` call
// immediately above. The closure resolves symbols through the
// retained `egl_for_loader` (refcounted clone) so the loader
// stays valid for the lifetime of the returned `glow::Context`.
let gl = Arc::new( unsafe
{
glow::Context::from_loader_function( move |name|
{
egl_for_loader.get_proc_address( name )
.map( |p| p as *const _ )
.unwrap_or( std::ptr::null() )
} )
} );
let _ = self.gl.set( gl );
}
Ok( () )
}
pub fn swap_buffers( &self, surface: &EglSurface ) -> Result<(), String>
{
self.egl.swap_buffers( self.display, surface.surface )
.map_err( |e| format!( "eglSwapBuffers: {e:?}" ) )
}
/// Like [`Self::swap_buffers`] but submits explicit damage rects so Mesa
/// emits proper `wl_surface.damage_buffer` requests instead of its
/// `INT32_MAX`-everywhere sentinel. Falls back to plain `swap_buffers`
/// when the extension is unavailable — that path still works on
/// cooperative compositors but trips over the sentinel on others (see the
/// crate-private `SwapBuffersWithDamageFn` type alias above for the
/// underlying rationale).
///
/// Each rect is `(x, y, width, height)` in **EGL window coordinates**
/// (origin at the bottom-left, in physical pixels). Callers that work in
/// top-left screen coords must flip Y before passing them in. For
/// full-surface damage, `(0, 0, w, h)` is correct in either convention.
pub fn swap_buffers_with_damage(
&self, surface: &EglSurface, rects: &[ ( i32, i32, i32, i32 ) ],
) -> Result<(), String>
{
let Some( func ) = self.swap_with_damage else
{
return self.swap_buffers( surface );
};
// Pack rects into a contiguous i32 array as required by the extension.
let mut packed: Vec<egl::Int> = Vec::with_capacity( rects.len() * 4 );
for &( x, y, w, h ) in rects
{
packed.extend_from_slice( &[ x, y, w, h ] );
}
// SAFETY: `func` was resolved in `EglContext::new` via the typed
// `Option<SwapBuffersWithDamageFn>` slot, so its signature is
// known. `display` / `surface.surface` belong to the live `&self`
// / `&surface` borrows. `packed.as_ptr()` is valid for `rects.len()
// * 4` `egl::Int` reads — we just built it with that exact length.
let ok = unsafe
{
func(
self.display.as_ptr(),
surface.surface.as_ptr(),
packed.as_ptr(),
rects.len() as egl::Int,
)
};
if ok == egl::TRUE
{
Ok( () )
} else {
Err( "eglSwapBuffersWithDamage failed".to_string() )
}
}
}
impl EglSurface
{
pub fn resize( &self, width: i32, height: i32 )
{
self.egl_window.resize( width.max( 1 ), height.max( 1 ), 0, 0 );
}
}
impl EglOffscreenContext
{
/// Create a runtime-free EGL context suitable for `Canvas::new_gles`.
pub fn new() -> Result<Self, String>
{
if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false )
{
return Err( "LTK_FORCE_SOFTWARE=1".to_string() );
}
// SAFETY: same as `EglContext::new` — `dlopen` of the system
// `libEGL.so`. Same tradeoff and rationale apply.
let egl: Arc<EglInstance> = Arc::new(
unsafe { EglInstance::load_required() }
.map_err( |e| format!( "load libEGL: {e:?}" ) )?,
);
let display = offscreen_display( &egl )?;
egl.initialize( display )
.map_err( |e| format!( "eglInitialize: {e:?}" ) )?;
egl.bind_api( egl::OPENGL_ES_API )
.map_err( |e| format!( "eglBindAPI: {e:?}" ) )?;
let config_attribs = [
egl::SURFACE_TYPE, egl::PBUFFER_BIT,
egl::RED_SIZE, 8,
egl::GREEN_SIZE, 8,
egl::BLUE_SIZE, 8,
egl::ALPHA_SIZE, 8,
egl::RENDERABLE_TYPE, egl::OPENGL_ES2_BIT,
egl::NONE,
];
let config = egl.choose_first_config( display, &config_attribs )
.map_err( |e| format!( "eglChooseConfig: {e:?}" ) )?
.ok_or_else( || "no compatible offscreen EGL config".to_string() )?;
let ( context, version ) = match try_create_context( &egl, display, config, 3 )
{
Ok( ctx ) => ( ctx, GlesVersion::V3 ),
Err( _ ) => match try_create_context( &egl, display, config, 2 )
{
Ok( ctx ) => ( ctx, GlesVersion::V2 ),
Err( e ) => return Err( format!( "eglCreateContext (ES2/ES3): {e:?}" ) ),
},
};
let pbuffer_attribs = [
egl::WIDTH, 1,
egl::HEIGHT, 1,
egl::NONE,
];
let surface = match egl.create_pbuffer_surface( display, config, &pbuffer_attribs )
{
Ok( surface ) => surface,
Err( e ) =>
{
let _ = egl.destroy_context( display, context );
let _ = egl.terminate( display );
return Err( format!( "eglCreatePbufferSurface: {e:?}" ) );
},
};
if let Err( e ) = egl.make_current( display, Some( surface ), Some( surface ), Some( context ) )
{
let _ = egl.destroy_surface( display, surface );
let _ = egl.destroy_context( display, context );
let _ = egl.terminate( display );
return Err( format!( "eglMakeCurrent: {e:?}" ) );
}
let egl_for_loader = Arc::clone( &egl );
// SAFETY: as in `EglContext::make_current`. The `make_current`
// call above has already established the EGL context as current
// on this thread, so `glGetString( GL_VERSION )` (called inside
// `from_loader_function`) is well-defined.
let gl = Arc::new( unsafe
{
glow::Context::from_loader_function( move |name|
{
egl_for_loader.get_proc_address( name )
.map( |p| p as *const _ )
.unwrap_or( std::ptr::null() )
} )
} );
log_backend_once( match version
{
GlesVersion::V3 => "GLES3",
GlesVersion::V2 => "GLES2",
} );
Ok( Self { egl, display, config, context, surface, version, gl } )
}
/// Make this context current on the calling thread.
pub fn make_current( &self ) -> Result<(), String>
{
self.egl.make_current(
self.display,
Some( self.surface ),
Some( self.surface ),
Some( self.context ),
).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )
}
pub fn gl( &self ) -> &Arc<glow::Context> { &self.gl }
pub fn version( &self ) -> GlesVersion { self.version }
pub fn config( &self ) -> egl::Config { self.config }
}
fn try_create_context(
egl: &EglInstance, display: egl::Display, config: egl::Config, major: i32,
) -> Result<egl::Context, egl::Error>
{
let attribs = [
egl::CONTEXT_CLIENT_VERSION, major,
egl::NONE,
];
egl.create_context( display, config, None, &attribs )
}
fn offscreen_display( egl: &EglInstance ) -> Result<egl::Display, String>
{
// SAFETY: `DEFAULT_DISPLAY` is the EGL sentinel for "the default display
// for the current platform" and is always a valid argument to
// `eglGetDisplay` — the spec guarantees it returns either a valid
// display handle or `EGL_NO_DISPLAY`. No raw pointer is dereferenced
// in this crate.
unsafe { egl.get_display( egl::DEFAULT_DISPLAY ) }
.ok_or_else( || "eglGetDisplay(DEFAULT_DISPLAY) returned NULL".to_string() )
}
/// Log the SOFTWARE fallback once, with the reason. The GPU branch logs
/// from [`EglContext::new`].
pub fn log_software_fallback( reason: &str )
{
log_backend_once( &format!( "SOFTWARE ({reason})" ) );
}
fn log_backend_once( label: &str )
{
static ONCE: Once = Once::new();
ONCE.call_once( ||
{
eprintln!( "[ltk] render backend: {label}" );
} );
}

2037
src/event_loop/app_data.rs Normal file

File diff suppressed because it is too large Load Diff

495
src/event_loop/handlers.rs Normal file
View File

@@ -0,0 +1,495 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorHandler,
delegate_compositor, delegate_layer, delegate_output, delegate_registry,
delegate_seat, delegate_keyboard, delegate_pointer, delegate_touch, delegate_shm,
delegate_xdg_popup, delegate_xdg_shell, delegate_xdg_window,
output::{ OutputHandler, OutputState },
registry::{ ProvidesRegistryState, RegistryState },
registry_handlers,
seat::{ Capability, SeatHandler, SeatState },
shell::
{
WaylandSurface,
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
xdg::window::{ Window, WindowConfigure, WindowHandler },
},
shm::{ Shm, ShmHandler },
};
use smithay_client_toolkit::reexports::client::
{
protocol::
{
wl_callback::{ self, WlCallback },
wl_output::{ self, WlOutput },
wl_surface::WlSurface,
wl_seat::WlSeat,
},
Connection, Dispatch, QueueHandle,
};
use wayland_protocols::wp::text_input::zv3::client::
{
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
zwp_text_input_v3::{ self, ZwpTextInputV3 },
};
use crate::app::App;
use super::app_data::AppData;
impl<A: App> CompositorHandler for AppData<A>
{
fn scale_factor_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, surface: &WlSurface, new_factor: i32 )
{
if new_factor <= 0 { return; }
let Some( focus ) = self.focus_for_surface( surface ) else { return };
let ( pw, ph ) = {
let shm = &self.shm;
let ss = match focus
{
super::SurfaceFocus::Main => &mut self.main,
super::SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id )
{
Some( s ) => s,
None => return,
},
};
if new_factor == ss.scale_factor { return; }
ss.scale_factor = new_factor;
surface.set_buffer_scale( new_factor );
if let Some( ref mut canvas ) = ss.canvas
{
canvas.set_dpi_scale( new_factor as f32 );
}
let pw = ss.width * new_factor as u32;
let ph = ss.height * new_factor as u32;
// Resize whichever rendering target is active. The two are mutually
// exclusive so only one branch runs.
if let Some( ref es ) = ss.egl_surface
{
es.resize( pw as i32, ph as i32 );
// Canvas FBO reallocation is deferred to the draw path: it needs
// `eglMakeCurrent` first.
} else {
ss.pool = Some(
smithay_client_toolkit::shm::slot::SlotPool::new(
( pw * ph * 4 ) as usize, shm,
).expect( "pool" ),
);
if let Some( ref mut canvas ) = ss.canvas
{
canvas.resize( pw, ph );
}
}
ss.request_redraw();
( pw, ph )
};
// Notify the app of the new physical dimensions. The previous
// `on_resize` it received was scaled with the OLD factor, so any
// app-side state keyed off those pixels is now stale. Only fire
// for the main surface — overlay resizes don't go through
// `App::on_resize`.
if matches!( focus, super::SurfaceFocus::Main )
{
self.app.on_resize( pw, ph );
self.dirty_caches();
}
}
fn transform_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: wl_output::Transform ) {}
fn frame(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &WlSurface,
_time: u32,
)
{
// Do NOT set needs_redraw here — that would create an infinite 60 fps loop
// (commit → frame callback → redraw → commit → ...). Redraws are driven
// exclusively by input events, poll_external messages, and configure events.
}
fn surface_enter( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: &WlOutput ) {}
fn surface_leave( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: &WlOutput ) {}
}
impl<A: App> LayerShellHandler for AppData<A>
{
fn closed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
layer: &LayerSurface,
)
{
match self.focus_for_surface( layer.wl_surface() )
{
Some( super::SurfaceFocus::Main ) | None =>
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
// Compositor asked us to destroy this overlay. Remove it;
// the next reconcile will not recreate it unless the app
// still returns its id from `App::overlays()`.
self.overlays.remove( &id );
}
}
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
layer: &LayerSurface,
configure: LayerSurfaceConfigure,
_serial: u32,
)
{
let ( w, h ) = configure.new_size;
let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
match self.focus_for_surface( layer.wl_surface() )
{
Some( super::SurfaceFocus::Main ) | None =>
{
self.on_configure( w, h );
self.app.on_resize( w, h );
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
if let Some( ss ) = self.overlays.get_mut( &id )
{
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
}
}
}
}
}
impl<A: App> WindowHandler for AppData<A>
{
fn request_close(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_window: &Window,
)
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
window: &Window,
configure: WindowConfigure,
_serial: u32,
)
{
// Mutter ignores set_fullscreen sent before the surface is
// mapped, so reapply on the first configure.
if self.pending_fullscreen
{
window.set_fullscreen( None );
self.pending_fullscreen = false;
}
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( 800 );
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( 600 );
self.on_configure( w, h );
self.app.on_resize( w, h );
}
}
impl<A: App> ShmHandler for AppData<A>
{
fn shm_state( &mut self ) -> &mut Shm
{
&mut self.shm
}
}
impl<A: App> OutputHandler for AppData<A>
{
fn output_state( &mut self ) -> &mut OutputState
{
&mut self.output_state
}
fn new_output( &mut self, _: &Connection, qh: &QueueHandle<Self>, output: WlOutput )
{
// If we were waiting for an output to assign the layer surface to, create it now.
// Same for any overlays that were created before an output existed.
if let Some( ref layer_shell ) = self.layer_shell
{
self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
for ss in self.overlays.values_mut()
{
ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
}
}
}
fn update_output( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
}
impl<A: App> SeatHandler for AppData<A>
{
fn seat_state( &mut self ) -> &mut SeatState
{
&mut self.seat_state
}
fn new_seat(
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
) {}
fn new_capability(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
seat: WlSeat,
capability: Capability,
)
{
match capability
{
Capability::Keyboard if self.keyboard.is_none() =>
{
self.keyboard = Some(
self.seat_state
.get_keyboard( qh, &seat, None )
.expect( "keyboard" ),
);
}
Capability::Pointer if self.pointer.is_none() =>
{
let pointer = self.seat_state
.get_pointer( qh, &seat )
.expect( "pointer" );
// Create a per-pointer cursor-shape device when the
// compositor advertises wp_cursor_shape_v1. The device
// outlives the WlPointer we just created and is what
// `set_shape(serial, shape)` is called on.
if let Some( ref mgr ) = self.cursor_shape_manager
{
self.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) );
}
self.pointer = Some( pointer );
}
Capability::Touch if self.touch.is_none() =>
{
self.touch = Some(
self.seat_state
.get_touch( qh, &seat )
.expect( "touch" ),
);
}
_ => {}
}
}
fn remove_capability(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_seat: WlSeat,
capability: Capability,
)
{
match capability
{
Capability::Keyboard => { self.keyboard = None; }
Capability::Pointer => { self.pointer = None; }
Capability::Touch => { self.touch = None; }
_ => {}
}
}
fn remove_seat(
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
) {}
}
impl<A: App> PopupHandler for AppData<A>
{
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
popup: &Popup,
config: PopupConfigure,
)
{
let ( w, h ) = ( config.width.max( 1 ) as u32, config.height.max( 1 ) as u32 );
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
{
if let Some( ss ) = self.overlays.get_mut( &id )
{
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
}
}
}
fn done(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
popup: &Popup,
)
{
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
{
if let Some( msg ) = self.overlay_dismiss_msg( id )
{
self.pending_msgs.push( msg );
}
self.overlays.remove( &id );
}
}
}
// --- Delegate macros ---
delegate_compositor!( @<A: App> AppData<A> );
delegate_output!( @<A: App> AppData<A> );
delegate_shm!( @<A: App> AppData<A> );
delegate_seat!( @<A: App> AppData<A> );
delegate_keyboard!( @<A: App> AppData<A> );
delegate_pointer!( @<A: App> AppData<A> );
delegate_touch!( @<A: App> AppData<A> );
delegate_layer!( @<A: App> AppData<A> );
delegate_xdg_shell!( @<A: App> AppData<A> );
delegate_xdg_window!( @<A: App> AppData<A> );
delegate_xdg_popup!( @<A: App> AppData<A> );
delegate_registry!( @<A: App> AppData<A> );
impl<A: App> ProvidesRegistryState for AppData<A>
{
fn registry( &mut self ) -> &mut RegistryState
{
&mut self.registry_state
}
registry_handlers![ OutputState, SeatState ];
}
// --- Dispatch impls for zwp_text_input_v3 ---
impl<A: App> Dispatch<ZwpTextInputManagerV3, ()> for AppData<A>
{
fn event(
_state: &mut Self,
_proxy: &ZwpTextInputManagerV3,
_event: <ZwpTextInputManagerV3 as smithay_client_toolkit::reexports::client::Proxy>::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
// no events from manager
}
}
// Frame-callback routing.
//
// Each `draw_*` path requests a `wl_surface.frame` with `SurfaceFocus` user-
// data identifying which `SurfaceState` it belongs to. The compositor fires
// the callback when the surface is ready for its next commit (display refresh,
// VRR cadence, "screen is on", …). Clearing `frame_pending` unblocks the run
// loop so the next iteration is allowed to draw that surface again.
//
// While the app is animating we re-arm the surface for redraw inline so the
// loop keeps ticking at the compositor's pace without needing a fixed-period
// timer.
impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
{
fn event(
state: &mut Self,
_proxy: &WlCallback,
_event: wl_callback::Event,
focus: &super::SurfaceFocus,
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
let is_animating = state.app.is_animating();
match *focus
{
super::SurfaceFocus::Main =>
{
state.main.frame_pending = false;
if is_animating
{
state.view_dirty = true;
state.main.request_redraw();
}
}
super::SurfaceFocus::Overlay( id ) =>
{
if let Some( ss ) = state.overlays.get_mut( &id )
{
ss.frame_pending = false;
if is_animating
{
state.overlays_dirty = true;
ss.request_redraw();
}
}
}
}
}
}
impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
{
fn event(
state: &mut Self,
_proxy: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
)
{
let focus = state.keyboard_focus;
match event
{
zwp_text_input_v3::Event::CommitString { text } =>
{
if let Some( text ) = text
{
if !text.is_empty()
{
state.handle_text_insert( focus, &text );
}
}
}
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, .. } =>
{
for _ in 0..before_length
{
state.handle_backspace( focus );
}
}
zwp_text_input_v3::Event::Done { .. } =>
{
state.surface_mut( focus ).request_redraw();
}
_ => {}
}
}
}

814
src/event_loop/mod.rs Normal file
View File

@@ -0,0 +1,814 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod app_data;
mod handlers;
pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use smithay_client_toolkit::
{
compositor::{ CompositorState, Surface },
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::
{
WaylandSurface,
wlr_layer::LayerShell,
xdg::
{
XdgPositioner, XdgShell, XdgSurface,
popup::Popup,
window::WindowDecorations,
},
},
shm::Shm,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use wayland_protocols::xdg::shell::client::xdg_positioner::
{
Anchor as PositionerAnchor,
ConstraintAdjustment,
Gravity,
};
use crate::app::{ App, InvalidationScope, SurfaceTarget };
use crate::draw::draw_frame;
use crate::types::{ Point, Rect };
use std::collections::HashSet;
/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail
/// to bring up the event loop.
///
/// Every variant maps to a fatal failure during init: the Wayland
/// connection, the calloop event loop, or one of the protocol bindings
/// the runtime cannot operate without (`wl_compositor`, `wl_shm`,
/// `xdg_wm_base`). Once init succeeds, runtime errors during the dispatch
/// loop still panic — they are non-recoverable from the caller's point
/// of view since the surface is already on screen.
///
/// Embedders that want a software-rendered fallback or that need to
/// degrade gracefully should call [`crate::try_run`] and match on the
/// variants instead of letting [`crate::run`] panic.
#[ derive( Debug ) ]
pub enum RunError
{
/// `WAYLAND_DISPLAY` is unset, the socket is missing, or the
/// compositor refused the handshake. Includes the detailed reason
/// from the underlying `wayland-client` error.
NoWaylandConnection( String ),
/// The Wayland registry could not be enumerated. Almost always a
/// compositor / driver bug — the registry is the first thing every
/// Wayland client touches.
RegistryInit( String ),
/// `calloop`'s `EventLoop::try_new` or its Wayland source insertion
/// failed (typically an `io` error talking to the kernel).
EventLoop( String ),
/// A required Wayland protocol is missing from the compositor.
/// `name` is the wire-format protocol name; `detail` is the
/// underlying bind error.
MissingProtocol
{
name: &'static str,
detail: String,
},
}
impl std::fmt::Display for RunError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
Self::NoWaylandConnection( d ) =>
write!( f, "Wayland connection failed: {d}" ),
Self::RegistryInit( d ) =>
write!( f, "Wayland registry init failed: {d}" ),
Self::EventLoop( d ) =>
write!( f, "event-loop setup failed: {d}" ),
Self::MissingProtocol { name, detail } =>
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
}
}
}
impl std::error::Error for RunError {}
/// Run the application, panicking on init failure. Thin wrapper over
/// [`try_run`] kept for backwards-compatibility — embedders that need
/// to recover from a missing compositor or a stripped-down driver
/// should call [`try_run`] instead.
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
/// Run the application, returning a typed error on init failure.
/// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
let conn = Connection::connect_to_env()
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
let ( globals, queue ) =
smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
let qh = queue.handle();
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
WaylandSource::new( conn.clone(), queue )
.insert( event_loop.handle() )
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
let compositor = CompositorState::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
let shm = Shm::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
// Try to bring EGL up. On failure (no libEGL, no compatible config,
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
// reason and every surface falls back to the SHM path.
let egl_context = match crate::egl_context::EglContext::new( &conn )
{
Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ),
Err( reason ) =>
{
crate::egl_context::log_software_fallback( &reason );
None
}
};
crate::render::set_software_render( egl_context.is_none() );
// Bind layer-shell up front. Both the main surface (when requested via
// ShellMode::Layer) and every overlay returned by App::overlays() share
// this single binding.
let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();
// Backwards compatibility: window_config() overrides shell_mode()
let force_window = app.window_config()
.map( |( t, id )| ( t.to_string(), id.to_string() ) );
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
-> Result<XdgShell, RunError>
{
XdgShell::bind( globals, qh )
.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
};
// Pinning min == max is the standard idiom on xdg-shell for "I
// want this exact size". Skipped when going fullscreen — Mutter
// rejects the fullscreen request if a min_size constrains it.
let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen() { return; }
match app.window_size_hint()
{
Some( ( w, h ) ) =>
{
// `set_min_size` doubles as the suggested initial size:
// most compositors send the first configure with this
// value, after which the surface remains user-resizable
// (the runtime adopts whatever size the compositor
// supplies on subsequent configures). `set_max_size` is
// deliberately *not* called — pinning min == max would
// lock the toplevel and refuse user-initiated resize.
window.set_min_size( Some( ( w, h ) ) );
}
None =>
{
window.set_min_size( Some( ( 360, 480 ) ) );
}
}
};
let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen()
{
window.set_fullscreen( None );
}
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title.as_str() );
window.set_app_id( app_id.as_str() );
apply_size_hint( &window );
apply_fullscreen( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
} else {
// Use shell_mode() to determine surface type
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
ShellMode::Layer( layer ) => {
if layer_shell_opt.is_some()
{
// Defer surface creation until new_output fires: if we create the layer
// surface before the compositor has any output ready (e.g. sway startup),
// the compositor cannot assign it and logs an error.
let cfg = LayerConfig {
layer: layer.to_wlr_layer(),
exclusive_zone: app.exclusive_zone(),
anchor: app.layer_anchor(),
size: app.layer_size(),
keyboard_exclusive: app.keyboard_exclusive(),
namespace: "ltk-sctk",
};
( SurfaceKind::Pending( cfg ), None )
} else {
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
}
}
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default();
let pending_fullscreen = app.start_fullscreen();
let mut data = AppData
{
app,
registry_state: RegistryState::new( &globals ),
seat_state: SeatState::new( &globals, &qh ),
output_state: OutputState::new( &globals, &qh ),
compositor_state: compositor,
shm,
egl_context,
xdg_shell,
layer_shell: layer_shell_opt,
keyboard: None,
pointer: None,
touch: None,
pointer_pos: Point::default(),
cursor_shape_manager:
smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
cursor_shape_device: None,
last_pointer_enter_serial: 0,
current_cursor_shape: None,
text_input_manager,
text_input: None,
shift_pressed: false,
ctrl_pressed: false,
loop_handle: event_loop.handle(),
compositor_repeat_rate: 0,
compositor_repeat_delay: 0,
key_repeat: None,
button_repeat: None,
clipboard: String::new(),
last_press_time: None,
last_press_pos: None,
debug_layout,
pending_msgs: Vec::new(),
pending_drag_inits: Vec::new(),
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(),
cached_view: None,
cached_overlays: None,
view_dirty: true,
overlays_dirty: true,
};
// Register a calloop channel so the app can send messages from any thread.
// Messages sent through the Sender wake the event loop immediately.
{
let ( sender, channel ) = calloop::channel::channel::<A::Message>();
event_loop.handle()
.insert_source(
channel,
|event, _, data: &mut AppData<A>|
{
if let calloop::channel::Event::Msg( msg ) = event
{
// Just queue the message — the run loop will run
// `App::invalidate_after` and apply the resulting
// scope, which is what decides which surfaces (if
// any) actually need to redraw.
data.pending_msgs.push( msg );
}
},
)
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
data.app.set_channel_sender( sender );
}
// Register a periodic timer if the app wants one (e.g. clock tick every second).
// The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval()
{
event_loop.handle()
.insert_source(
Timer::from_duration( dur ),
|_, _, data: &mut AppData<A>|
{
let msgs = data.app.poll_external();
data.pending_msgs.extend( msgs );
let next = data.app.poll_interval()
.unwrap_or( std::time::Duration::from_secs( 1 ) );
TimeoutAction::ToDuration( next )
},
)
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
}
while !data.exit_requested
{
// Sleep until something interesting fires:
// * Wayland event (input, configure, frame callback, …)
// * calloop timer (poll_external)
// * calloop channel (App::set_channel_sender)
// Pacing is driven by `wl_surface.frame` callbacks, so an idle /
// off-screen / VRR display blocks indefinitely instead of polling
// at a fixed rate. The one exception is a pending long-press:
// cap the wait at its deadline so a perfectly still press still
// fires on time.
let timeout = data.next_long_press_wakeup();
event_loop.dispatch( timeout, &mut data ).expect( "dispatch" );
// Any surface whose press has now crossed `long_press_duration`
// emits its stored message and flips into drag mode for the rest
// of the gesture.
data.check_long_press_deadlines();
// Poll external messages (immediate async results, e.g. PAM auth channel)
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Process pending messages, folding their per-message invalidation
// scopes into a single decision before applying it.
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
let had_msgs = !msgs.is_empty();
if had_msgs
{
let mut scope = InvalidationScope::Only( Vec::new() );
for msg in msgs
{
scope = scope.union( data.app.invalidate_after( &msg ) );
data.app.update( msg );
}
apply_invalidation( &mut data, scope );
// `update()` may have flipped the busy / loading flag
// the app reads from inside `cursor_override`. Re-sync
// the pointer cursor so the change propagates without
// waiting for the next motion event.
let pf = data.pointer_focus;
data.dispatch_cursor_shape( pf );
}
// Seed `on_drag_move` with the long-press origin for any drag that
// just started. Must run *after* `update()` so the app's drag state
// has already been set up by the paired long-press message — the
// coords would otherwise hit a dragging_app=None shell and be lost.
let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect();
if !drag_inits.is_empty()
{
for origin in drag_inits
{
data.app.on_drag_move( origin.x, origin.y );
}
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
}
// After update() the app state is the source of truth — discard any
// pending text values so that the next keystroke reads the fresh state
// instead of a stale pre-update buffer (e.g. password cleared on auth failure).
if !data.main.pending_text_values.is_empty()
{
data.main.pending_text_values.clear();
}
for ss in data.overlays.values_mut()
{
ss.pending_text_values.clear();
}
// Reconcile the overlay set against the app's current specs: drop any
// overlays whose id disappeared, create new ones for ids that just
// appeared. Specs are re-queried next frame for drawing.
reconcile_overlays( &mut data );
// Draw any surface that's configured, dirty, and not already waiting
// on a frame callback. The compositor decides our cadence — when no
// surface qualifies we just loop back to `dispatch(None)` and sleep.
let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable
{
// Rebuild while motion is in progress and on the first frame
// after it ends, so the settle frame paints at full quality
// instead of freezing one step short of the target.
// `wants_low_quality_paint` is the source of truth so
// finger-tracked drags get the same treatment. Slider /
// scroll drags are owned by the runtime gesture machine,
// not by `App::update`, so OR with the gesture state to
// pick up that motion signal too.
let runtime_slider_motion =
data.main.gesture.dragging_slider.is_some()
|| data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
if runtime_slider_motion
{
data.view_dirty = true;
data.overlays_dirty = true;
}
if data.view_dirty
{
data.cached_view = Some( data.app.view() );
data.view_dirty = false;
}
if data.overlays_dirty
{
data.cached_overlays = Some( data.app.overlays() );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it
if let Some( id ) = data.app.take_focus_request()
{
let found = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| w.flat_idx );
if let Some( flat_idx ) = found
{
let qh = data.qh.clone();
data.set_focus( SurfaceFocus::Main, Some( flat_idx ), &qh );
data.main.request_redraw();
}
}
}
Ok( () )
}
/// Apply a folded [`InvalidationScope`] from one message-batch iteration:
/// dirty the relevant cache(s) so the next draw rebuilds the view via
/// [`App::view`] / [`App::overlays`], and call `request_redraw` on each
/// affected surface so the run loop's "anything to draw" check picks it up.
///
/// `InvalidationScope::All` (the default returned by `App::invalidate_after`)
/// matches the pre-hook behaviour of broadcasting to every surface.
fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: InvalidationScope )
{
match scope
{
InvalidationScope::All =>
{
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
}
InvalidationScope::Only( targets ) =>
{
for t in targets
{
match t
{
SurfaceTarget::Main =>
{
data.view_dirty = true;
data.main.request_redraw();
}
SurfaceTarget::Overlay( id ) =>
{
// `overlays()` returns a single Vec, so any
// per-overlay change forces the whole list to be
// re-queried. Coarser than per-overlay caching but
// matches the existing API shape.
data.overlays_dirty = true;
if let Some( ss ) = data.overlays.get_mut( &id )
{
ss.request_redraw();
}
}
}
}
}
}
}
/// Pure overlay-id diff. Given the current set of live overlay ids and the
/// list the app just returned from [`crate::app::App::overlays`], compute
/// `( added, removed )`.
///
/// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller).
pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ],
) -> ( Vec<crate::app::OverlayId>, Vec<crate::app::OverlayId> )
{
let current_set: HashSet<crate::app::OverlayId> = current.into_iter().collect();
let next_set: HashSet<crate::app::OverlayId> = next.iter().copied().collect();
let added: Vec<_> = next.iter().copied().filter( |id| !current_set.contains( id ) ).collect();
let removed: Vec<_> = current_set.into_iter().filter( |id| !next_set.contains( id ) ).collect();
( added, removed )
}
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
/// destroy any overlay whose id disappeared and create a fresh `SurfaceState`
/// for every id that just appeared. Created surfaces are materialized
/// immediately if an output is already available; otherwise they stay
/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up.
fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
let specs = data.app.overlays();
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
// Drop overlays that disappeared from the spec list. If the overlay had
// an in-flight drag (long-press fired), migrate that state to `main` so
// the motion / release that follows still routes through the app's drag
// handlers — apps typically hide the overlay *because* of the long-press,
// and we must not lose the drag state with the surface.
let removed: Vec<_> = data.overlays.keys()
.filter( |id| !wanted.contains( id ) )
.copied()
.collect();
for id in removed
{
if let Some( ss ) = data.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
data.main.gesture.long_press_fired = true;
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
}
// Clear any stale per-device focus pointing at a destroyed overlay.
if let SurfaceFocus::Overlay( id ) = data.pointer_focus
{
if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( id ) = data.keyboard_focus
{
if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; }
}
// Rewrite touch_focus entries pointing at a destroyed overlay to Main
// rather than dropping them. Dropping would make subsequent motion/up
// events for the same touch id default to Main *without* the long-press
// drag state, turning a release into a stray tap.
for f in data.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( id ) = *f
{
if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; }
}
}
// Create overlays that just appeared. Uses field-level borrow splitting so
// the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state`
// / `output_state` / `qh` can coexist with the mutable borrow of
// `overlays` inside the loop.
let layer_shell_opt = data.layer_shell.as_ref();
let xdg_shell_opt = data.xdg_shell.as_ref();
let output_opt = data.output_state.outputs().next();
let cs = &data.compositor_state;
let qh = &data.qh;
let grab_seat = data.seat_state.seats().next();
let grab_serial = data.last_input_serial;
// Snapshot the parent xdg_surface (only an xdg toplevel can host
// xdg-popups; layer-shell parents would need a different code path
// via `LayerSurface::get_popup`, which we do not currently support).
let parent_xdg = match data.main.surface
{
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
_ => None,
};
let parent_scale = data.main.scale_factor.max( 1 ) as f32;
// Snapshot the previous-frame anchor lookup table so we can resolve
// `anchor_widget_id` → `Rect` without holding a borrow on `data.main`
// across the overlay-mut loop below.
let main_widget_rects = &data.main.widget_rects;
let overlays_m = &mut data.overlays;
for spec in &specs
{
if let Some( ss ) = overlays_m.get_mut( &spec.id )
{
// Already-live overlay: propagate size changes to the layer
// surface so apps can animate an overlay's dimensions (e.g. a
// slide-down panel whose height grows each frame). The very
// first size was applied at `materialize` time and recorded in
// `last_requested_size`, so we only commit when it actually
// differs. Commit is needed after `set_size` so the compositor
// sends a configure; the usual `on_configure` path picks up the
// new dimensions and drives the redraw. Popups don't grow /
// shrink mid-life — close and reopen instead.
if spec.size != ss.last_requested_size
{
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{
layer_surface.set_size( spec.size.0, spec.size.1 );
layer_surface.commit();
ss.last_requested_size = spec.size;
}
}
// Compare the anchor at integer logical-pixel resolution:
// floating-point jitter would otherwise call `reposition`
// every frame, which several compositors react to by
// dropping the popup grab.
if let SurfaceKind::Popup( ref popup ) = ss.surface
{
if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt )
{
if let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
{
let to_logical = | r: Rect |
{
(
( r.x / parent_scale ).round() as i32,
( r.y / parent_scale ).round() as i32,
( r.width / parent_scale ).round().max( 1.0 ) as i32,
( r.height / parent_scale ).round().max( 1.0 ) as i32,
)
};
let new_q = to_logical( anchor_rect );
let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true );
if moved
{
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
{
let ( ax, ay, aw, ah ) = new_q;
let ( spec_w, spec_h ) = spec.size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 );
popup.reposition( &positioner, ss.popup_reposition_token );
ss.last_popup_anchor = Some( anchor_rect );
}
}
}
}
}
continue;
}
// `anchor_widget_id = Some(_)` → xdg-popup path; `None` →
// wlr-layer-shell path.
if let Some( anchor_id ) = spec.anchor_widget_id
{
let Some( xdg_shell ) = xdg_shell_opt else
{
eprintln!( "ltk: ignoring popup overlay {:?} — main surface is not an xdg-shell window", spec.id );
continue;
};
let Some( ref parent ) = parent_xdg else
{
eprintln!( "ltk: ignoring popup overlay {:?} — no xdg parent surface", spec.id );
continue;
};
let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
else
{
eprintln!( "ltk: popup overlay {:?} could not find anchor widget id {:?}", spec.id, anchor_id );
continue;
};
let positioner = match XdgPositioner::new( xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: XdgPositioner::new failed for popup {:?}: {e}", spec.id );
continue;
}
};
// Convert the requested popup size and the anchor rect from
// physical pixels (the layout coordinate space) to logical
// pixels — the positioner expresses everything in window
// geometry, which is logical. `size.0 == 0` is the
// "match anchor width" convention (paralleling the
// layer-shell `0 = fill` semantic): the popup is sized to
// the trigger pill so combos / selects render flush with
// the field they belong to.
let ax = ( anchor_rect.x / parent_scale ).round() as i32;
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
let ( spec_w, spec_h ) = spec.size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
// xdg_popup.grab must be issued before the first commit
// (error 0 `invalid_grab` otherwise). `Popup::new` commits
// internally, so the lower-level `from_surface` path is
// the only one that lets the grab land in time.
let surface = match Surface::new( cs, qh )
{
Ok( s ) => s,
Err( e ) =>
{
eprintln!( "ltk: Surface::new failed for popup overlay {:?}: {e}", spec.id );
continue;
}
};
let popup = match Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: Popup::from_surface failed for overlay {:?}: {e}", spec.id );
continue;
}
};
if let Some( ref seat ) = grab_seat
{
popup.xdg_popup().grab( seat, grab_serial );
}
popup.wl_surface().commit();
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
ss.last_requested_size = spec.size;
ss.last_popup_anchor = Some( anchor_rect );
overlays_m.insert( spec.id, ss );
continue;
}
// wlr-layer-shell path.
let Some( layer_shell ) = layer_shell_opt else
{
eprintln!( "ltk: ignoring layer-shell overlay {:?} — wlr-layer-shell not available", spec.id );
continue;
};
let cfg = LayerConfig
{
layer: spec.layer.to_wlr_layer(),
exclusive_zone: spec.exclusive_zone,
anchor: spec.anchor,
size: spec.size,
keyboard_exclusive: spec.keyboard_exclusive,
namespace: "ltk-overlay",
};
let mut surface = SurfaceKind::Pending( cfg );
if let Some( ref output ) = output_opt
{
surface.materialize( cs, layer_shell, qh, output );
}
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
ss.last_requested_size = spec.size;
overlays_m.insert( spec.id, ss );
}
}

158
src/gles_render/clip.rs Normal file
View File

@@ -0,0 +1,158 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `glScissor`-based clipping + whole-canvas fill / clear for
//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives
//! multiple rects the bounding-box union is used as the scissor —
//! coarse, but the partial-redraw path normally clusters 13 rects
//! so the union is barely larger than the sum. Disjoint regions
//! would want a stencil-buffer path; not implemented today.
use glow::HasContext;
use crate::types::{ Color, Rect };
use super::GlesCanvas;
impl GlesCanvas
{
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
// Scissor is global GL state; rebind our FBO first so the scissor
// applies to this canvas and not whatever target was active before.
self.activate_target();
if rects.is_empty()
{
self.clear_clip();
return;
}
let mut x0 = f32::INFINITY;
let mut y0 = f32::INFINITY;
let mut x1 = -f32::INFINITY;
let mut y1 = -f32::INFINITY;
for r in rects
{
x0 = x0.min( r.x );
y0 = y0.min( r.y );
x1 = x1.max( r.x + r.width );
y1 = y1.max( r.y + r.height );
}
x0 = x0.max( 0.0 );
y0 = y0.max( 0.0 );
x1 = x1.min( self.width as f32 );
y1 = y1.min( self.height as f32 );
if x1 <= x0 || y1 <= y0
{
// Empty union — install a zero-area scissor so subsequent draws
// are no-ops without disabling the test.
self.set_scissor( Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } );
return;
}
self.set_scissor( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } );
}
pub fn clear_clip( &mut self )
{
// SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is
// pure global-state mutation; the cached `clip_scissor` is updated
// to match below.
unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
self.clip_scissor = None;
}
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no
/// scissor is set).
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
{
self.clip_scissor.map_or_else( Vec::new, |r| vec![ r ] )
}
/// Apply `rect` as the current scissor (top-left coords, GL bottom-left).
fn set_scissor( &mut self, rect: Rect )
{
let ( x, y, w, h ) = self.scissor_pixels( rect );
// SAFETY: `scissor_pixels` clamps to non-negative integers; GL accepts
// arbitrary scissor rects (regions outside the framebuffer simply
// cull all fragments). State change is mirrored in `clip_scissor`.
unsafe
{
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
self.clip_scissor = Some( rect );
}
/// Convert a top-left rect to the bottom-left integer pixel scissor that
/// GL expects.
pub( super ) fn scissor_pixels( &self, rect: Rect ) -> ( i32, i32, i32, i32 )
{
let x = rect.x.floor() as i32;
let w = rect.width.ceil() as i32;
let h = rect.height.ceil() as i32;
// GL origin is bottom-left, our coords are top-left.
let y_top = rect.y.floor() as i32;
let y_bottom = self.height as i32 - y_top - h;
( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) )
}
/// Clear to a solid color. Honours the active scissor — if a clip is set,
/// only the clipped region is filled.
pub fn fill( &mut self, color: Color )
{
self.activate_target();
// SAFETY: `clear` writes the configured `clear_color` into every
// fragment that survives the scissor test (which `activate_target`
// has already configured to match `clip_scissor`).
unsafe
{
self.gl.clear_color( color.r, color.g, color.b, color.a );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Clear to fully transparent. Honours the active scissor.
pub fn clear( &mut self )
{
self.activate_target();
// SAFETY: same as `fill`, with a transparent clear colour.
unsafe
{
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Zero the pixels inside each rect (alpha+RGB → 0).
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
self.activate_target();
let saved = self.clip_scissor;
// SAFETY: enable scissor + set transparent clear colour once for
// the whole loop; per-rect we rewrite `scissor` and clear. After
// the loop we restore `saved` via `set_scissor` / `clear_clip`
// so the cached `clip_scissor` matches the actual GL state again.
unsafe
{
self.gl.enable( glow::SCISSOR_TEST );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
}
for r in rects
{
let ( x, y, w, h ) = self.scissor_pixels( *r );
if w <= 0 || h <= 0 { continue; }
// SAFETY: per-rect scissor + clear; same invariants as above.
unsafe
{
self.gl.scissor( x, y, w, h );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
// Restore the previous scissor (or disable if none was active).
match saved
{
Some( r ) => self.set_scissor( r ),
None => self.clear_clip(),
}
}
}

View File

@@ -0,0 +1,342 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! FBO / framebuffer management for [`GlesCanvas`]: sub-canvas blit,
//! main-FBO ⇄ default-framebuffer present, lazy auxiliary FBO for
//! snapshot-based effects (Overlay blend inset shadow), and the
//! externally-exposed borrowed-texture view.
//!
//! See `primitives.rs` module doc for the canvas-wide `unsafe` contract
//! shared by every block in this file. Per-block notes below only call
//! out what is specific to the operation.
use glow::HasContext;
use crate::types::Rect;
use super::helpers::{ alloc_fbo_tex, native_framebuffer_id, native_texture_id, ortho_rect };
use super::raii::{ FboBinding, ProgramBinding };
use super::{ BorrowedGlesTexture, GlesCanvas };
impl GlesCanvas
{
pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 )
{
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 );
}
/// Blit `src` into this canvas at `( dest_x, dest_y )`, optionally feathering
/// the last `fade_bottom_px` source rows so the bottom edge dissolves into
/// transparency instead of cutting off cleanly. Used by viewports whose
/// bottom edge is the leading edge of a slide-down animation, where a hard
/// cut against the underlying layer reads as a knife. With `fade_bottom_px
/// == 0.0` this matches [`Self::blit`] exactly.
pub fn blit_fade_bottom( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 )
{
self.activate_target();
let dest = Rect
{
x: dest_x as f32,
y: dest_y as f32,
width: src.width as f32,
height: src.height as f32,
};
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = self.global_alpha;
let height_px = src.height as f32;
let fade_clamp = fade_bottom_px.max( 0.0 ).min( height_px );
// SAFETY: `src.fbo_tex` is owned by `src` (a `&GlesCanvas` argument)
// and outlives the call. `src` and `self` share the same `Arc<glow::Context>`
// — verified by construction (sub-canvases are built via `sub_canvas`,
// which clones `Arc::clone(&self.gl)`) — so sampling `src`'s texture
// from `self`'s FBO is well-defined.
unsafe
{
// Both the main canvas and the sub-canvas FBO hold premultiplied
// colour, and the global blend is `(ONE, ONE_MINUS_SRC_ALPHA)` —
// the premul over-composite formula this blit needs. No temporary
// blend switch necessary.
self.gl.use_program( Some( self.sub_blit_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_subblit_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_subblit_opacity ), alpha );
self.gl.uniform_1_f32( Some( &self.u_subblit_fade_bottom ), fade_clamp );
self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), height_px );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( src.fbo_tex ) );
self.gl.uniform_1_i32( Some( &self.u_subblit_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
/// Re-bind our FBO + viewport + scissor as the active GL state. Cheap and
/// idempotent, called at the top of every draw / clear / clip method so
/// that switching between canvases (e.g. main → sub-canvas → main) leaves
/// each one's state correct without explicit "make active" calls.
///
/// Why this exists: GL state (FBO binding, scissor box, viewport) is
/// global — there is no implicit per-canvas state. When rendering
/// switches between targets, every method on the active canvas must
/// reassert its own FBO + viewport, plus re-enable its own scissor (or
/// disable scissor when the canvas has no clip).
pub( super ) fn activate_target( &self )
{
// SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`)
// live as long as `&self`, and the bind is idempotent.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor
{
Some( r ) =>
{
let ( x, y, w, h ) = self.scissor_pixels( r );
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
None =>
{
self.gl.disable( glow::SCISSOR_TEST );
}
}
}
}
/// Return a borrowed descriptor for the FBO color texture
/// containing the latest rendered pixels.
///
/// `y_inverted` is `true`: the FBO uses GL's native lower-left
/// origin, so row 0 in texture memory is the bottom of the
/// rendered image. Consumers that follow the same convention flip
/// during sampling when this flag is set, producing a correctly-
/// oriented result. The CPU-side counterpart
/// [`Self::read_rgba_pixels`] does the same flip inline so the
/// byte buffer is top-down.
pub fn borrowed_texture( &self ) -> BorrowedGlesTexture
{
BorrowedGlesTexture
{
texture_id: native_texture_id( self.fbo_tex ),
framebuffer_id: native_framebuffer_id( self.fbo ),
texture: self.fbo_tex,
framebuffer: self.fbo,
width: self.width,
height: self.height,
premultiplied: true,
y_inverted: true,
}
}
/// Read the FBO color attachment into `out` as tightly packed RGBA8,
/// top-left row first.
///
/// This is a compatibility escape hatch. It forces a GPU→CPU sync and
/// should not be used in steady-state hot paths.
pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
{
let needed = self.width as usize * self.height as usize * 4;
if out.len() < needed
{
return Err( format!(
"read_rgba_pixels needs {needed} bytes, got {}",
out.len(),
) );
}
let mut raw = vec![ 0_u8; needed ];
// SAFETY: `raw.len() == needed == width * height * 4` and PACK_ALIGNMENT
// is set to 1, so `read_pixels` writes exactly `needed` bytes into a
// buffer of exactly that size. `RGBA + UNSIGNED_BYTE` is the only
// guaranteed-readable format on every GLES2/3 driver.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.pixel_store_i32( glow::PACK_ALIGNMENT, 1 );
self.gl.read_pixels(
0,
0,
self.width as i32,
self.height as i32,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelPackData::Slice( Some( &mut raw ) ),
);
}
let stride = self.width as usize * 4;
for y in 0..self.height as usize
{
let src = ( self.height as usize - 1 - y ) * stride;
let dst = y * stride;
out[ dst..dst + stride ].copy_from_slice( &raw[ src..src + stride ] );
}
Ok( () )
}
/// Lazily allocate the auxiliary FBO+texture pair used as a snapshot of
/// `fbo` for framebuffer-fetch-style effects (Overlay blend,
/// backdrop-blur source). The pair is sized to match the canvas so
/// `gl_FragCoord.xy / canvas_size` samples the right texel.
///
/// Returns the texture handle of `aux_a`. The FBO is only needed for
/// the backdrop blur passes that write into `aux_b`; Overlay only
/// reads from `aux_a`, so this helper keeps the blur-only `aux_b`
/// allocation deferred until it is actually needed.
fn ensure_aux_a( &mut self ) -> glow::Texture
{
if self.aux_a.is_none()
{
// SAFETY: `alloc_fbo_tex` is `unsafe fn`; its requirement (current
// GL context) holds. Same FBO build / completeness assertion as
// `setup.rs::new`. We deliberately leave `aux_a`'s FBO as the live
// binding — the next draw goes through `activate_target` which
// rebinds `self.fbo`.
unsafe
{
let fbo = self.gl.create_framebuffer().expect( "aux_a FBO" );
let tex = alloc_fbo_tex( &self.gl, self.version, self.width, self.height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
self.gl.framebuffer_texture_2d
(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( tex ), 0,
);
let status = self.gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "aux_a FBO incomplete: 0x{status:x}" );
self.aux_a = Some( ( fbo, tex ) );
}
}
self.aux_a.expect( "just allocated" ).1
}
/// Snapshot variant that additionally clamps `region` to the active
/// scissor. Safe only for shaders that sample the snapshot at
/// exactly one point per fragment.
pub( super ) fn snapshot_fbo_region_tight( &mut self, region: Rect )
{
self.snapshot_fbo_region_impl( region, true )
}
fn snapshot_fbo_region_impl( &mut self, region: Rect, intersect_scissor: bool )
{
let aux_tex = self.ensure_aux_a();
// Clamp `region` to canvas bounds. `copy_tex_sub_image_2d` would
// generate `INVALID_VALUE` (or undefined behaviour on some
// drivers) if the source rect extends outside the framebuffer.
let cw = self.width as f32;
let ch = self.height as f32;
let mut x0 = region.x.max( 0.0 );
let mut y0_top = region.y.max( 0.0 );
let mut x1 = ( region.x + region.width ).min( cw );
let mut y1_top = ( region.y + region.height ).min( ch );
if intersect_scissor
{
if let Some( clip ) = self.clip_scissor
{
x0 = x0.max( clip.x );
y0_top = y0_top.max( clip.y );
x1 = x1.min( clip.x + clip.width );
y1_top = y1_top.min( clip.y + clip.height );
}
}
let w = ( x1 - x0 ).floor() as i32;
let h = ( y1_top - y0_top ).floor() as i32;
if w <= 0 || h <= 0 { return; }
// GL framebuffer origin is bottom-left; our rect is top-left.
let src_x = x0.floor() as i32;
let src_y = self.height as i32 - y0_top.floor() as i32 - h;
// SAFETY: the four bounds checks above guarantee `(src_x, src_y, w, h)`
// lies fully inside `self.fbo`'s colour attachment, so
// `copy_tex_sub_image_2d` will not raise INVALID_VALUE. `aux_tex` was
// allocated through `ensure_aux_a` to canvas dimensions, so the
// destination region is also in-bounds.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
self.gl.copy_tex_sub_image_2d
(
glow::TEXTURE_2D, 0,
src_x, src_y,
src_x, src_y,
w, h,
);
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
/// Drop both auxiliary FBO+texture pairs if allocated. Called from
/// [`Self::resize`] so the next effect re-allocates at the new size.
pub( super ) fn invalidate_aux( &mut self )
{
// SAFETY: each (fbo, tex) pair was created through `self.gl` in
// `ensure_aux_a` / `ensure_aux_b`, so deleting through the same
// context is well-defined. `take()` ensures we never double-delete.
unsafe
{
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_b.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
}
}
/// Blit the FBO color attachment onto the default framebuffer (the EGL
/// window). Caller is responsible for the `eglSwapBuffers` that
/// publishes the result. After present, the FBO is rebound so the next
/// frame's draws keep accumulating into the shadow canvas.
///
/// The blit always covers the full surface — partial-redraw still saves
/// work upstream (only changed widget pixels are repainted into the
/// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen
/// op.
pub fn present( &mut self )
{
// Scoped guards: bind the default framebuffer (id 0) and the
// blit program for the duration of this fn. On Drop they restore
// `self.fbo` and "no program", so any future early-return / panic
// in the blit body cannot leave the canvas pointing at the wrong
// FBO or program. The viewport, blend and scissor are restored
// inline below — they are non-resource state that does not need
// the guard treatment because `activate_target` rewrites them on
// every subsequent draw.
//
// SAFETY: GL context is current (canvas invariant). `self.fbo` is
// the canvas-owned FBO from `setup.rs::new`. `self.blit_program`
// was linked in `setup.rs::new`. Restoring "no program active"
// (`None`) is always sound.
let _fbo = unsafe { FboBinding::scoped( &self.gl, None, Some( self.fbo ) ) };
let _prog = unsafe { ProgramBinding::scoped( &self.gl, Some( self.blit_program ), None ) };
// SAFETY: see above. The block sets up the blit pipeline state,
// draws a fullscreen quad sampling `fbo_tex`, then restores blend
// and viewport so the next frame's draws inherit the canvas-wide
// defaults. FBO + program are restored by the guards on scope exit.
unsafe
{
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
self.gl.disable( glow::SCISSOR_TEST );
self.gl.disable( glow::BLEND );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.fbo_tex ) );
self.gl.uniform_1_i32( Some( &self.u_blit_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.enable( glow::BLEND );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
}
// Scissor was disabled above; reflect that in our cached state.
self.clip_scissor = None;
// Guards drop here: FBO → self.fbo, program → None.
}
}

293
src/gles_render/helpers.rs Normal file
View File

@@ -0,0 +1,293 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Backend-neutral free helpers for the GLES renderer: MVP matrix
//! construction, shader compilation, FBO / texture allocation,
//! system-font lookup, small typed-handle extractors. Visible only
//! within `crate::gles_render` — callers always go through
//! `GlesCanvas`'s public methods.
use glow::HasContext;
use crate::types::Rect;
use super::GlesVersion;
pub( super ) fn ortho_rect( vp_w: u32, vp_h: u32, rect: Rect ) -> [f32; 16]
{
let w = vp_w as f32;
let h = vp_h as f32;
let sx = rect.width * 2.0 / w;
let sy = rect.height * 2.0 / h;
let tx = rect.x * 2.0 / w - 1.0;
let ty = 1.0 - rect.y * 2.0 / h - sy;
[
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
tx, ty, 0.0, 1.0,
]
}
/// Upload `data` as a 2D RGBA texture, premultiplying alpha into the
/// upload buffer.
///
/// `data` is straight-alpha (the format CPU PNG / JPG decoders
/// produce). GL_LINEAR sampling interpolates RGB and A independently
/// across texels: at the antialiased edge of an icon — say a fully
/// opaque black texel next to a fully transparent white texel —
/// straight-alpha interpolation midway gives `( 0.5, 0.5, 0.5, 0.5 )`
/// which composes onto the destination as a 50 % gray halo, while
/// premultiplied interpolation gives `( 0, 0, 0, 0.5 )` and composes
/// as transparent black. Premultiplying once at upload eliminates
/// the halo on every later draw.
///
/// Defensive: when the declared `w × h × 4` size does not match
/// `data.len()`, the function refuses the upload, logs once via
/// stderr, and substitutes a 1×1 transparent placeholder. The GL
/// driver would otherwise read past the slice end inside
/// `tex_image_2d`, since it trusts the dimensions over the slice
/// length.
pub( super ) fn upload_rgba_texture( gl: &glow::Context, version: GlesVersion, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
let expected = ( w as i64 ).saturating_mul( h as i64 ).saturating_mul( 4 );
let valid = w > 0 && h > 0 && expected >= 0 && expected as usize == data.len();
let placeholder: [u8; 4] = [ 0, 0, 0, 0 ];
let ( safe_w, safe_h, safe_data ): ( i32, i32, &[u8] ) = if valid
{
( w, h, data )
}
else
{
eprintln!(
"[ltk] upload_rgba_texture: refusing malformed upload — \
{w}×{h} declared, {} bytes provided, expected {}",
data.len(),
expected.max( 0 ),
);
( 1, 1, &placeholder[..] )
};
let mut premul = Vec::with_capacity( safe_data.len() );
for px in safe_data.chunks_exact( 4 )
{
let a = px[3] as u32;
// `(c * a + 127) / 255` — round-to-nearest integer scale.
// Plain `c * a / 255` truncates, leaving fully-opaque pixels
// with rgb < their straight value (visibly darker icons).
premul.push( ( ( px[0] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[1] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[2] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( px[3] );
}
// `RGBA8` (sized) on GLES 3 forces 8-bit-per-channel storage; the
// unsized `RGBA` token leaves the format up to the driver and some
// mobile GPUs pick a 4-bits-per-channel or 565+A4 layout for it,
// which shows up as banded / colour-quantised icons. ES 2 has no
// `RGBA8` constant, so we fall back to the unsized form there
// (matching `alloc_fbo_tex`).
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
// SAFETY: caller's GL context is current. The most important
// invariant is on the upload size: the validity check above
// guarantees `safe_w * safe_h * 4 == safe_data.len() == premul.len()`,
// or replaces the upload with a 1×1 transparent placeholder when the
// caller provided malformed dimensions. Without this guard the GLES
// driver trusts the dimensions and reads past the slice end inside
// `tex_image_2d` (the original UB this defensive code prevents).
// `RGBA + UNSIGNED_BYTE` is universally supported. We unbind on
// exit to avoid stranding TEXTURE_2D bound to the new texture.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
safe_w, safe_h, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( &premul ) ),
);
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
/// Allocate a fresh color texture sized for the FBO. Internal format is sized
/// (`GL_RGBA8`) on ES3 and unsized (`GL_RGBA`) on ES2 — the unsized form is
/// required for color-renderable textures on ES2 drivers.
pub( super ) unsafe fn alloc_fbo_tex( gl: &glow::Context, version: GlesVersion, w: u32, h: u32 ) -> glow::Texture
{
// SAFETY: caller guarantees the GL context bound to `gl` is current
// on this thread. `tex_image_2d` with a `None` data slice allocates
// uninitialised storage of size `w*h*4` bytes (RGBA8 / unsized RGBA);
// the size and format combination is valid on every GLES2 / GLES3
// driver. The bind / unbind pair leaves TEXTURE_2D unbound on exit
// so we don't strand a binding the caller might rely on.
unsafe
{
let tex = gl.create_texture().expect( "create_texture" );
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( None ),
);
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn upload_alpha_texture( gl: &glow::Context, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
// SAFETY: caller's GL context is current. Caller is responsible for the
// `data.len() == w * h` invariant — `upload_alpha_texture` is only called
// from the glyph atlas path on bitmaps fontdue produced at the same
// dimensions. UNPACK_ALIGNMENT is set to 1 for the upload (1 byte/pixel)
// and restored to the GL default of 4 immediately after, so subsequent
// uploads are not affected.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
// NEAREST, not LINEAR. Glyph atlases are drawn 1:1 with their bitmap
// (dest size = texture size, integer-aligned position). Mathematically
// LINEAR at an exact texel center collapses to the texel value, but
// mediump precision in the fragment shader (and the `1 - v_uv.y` flip)
// can drift the sample point a fraction of a texel off-center, and the
// LINEAR filter then blends with neighbours — visible as soft, washed
// stems, especially at small sizes. NEAREST snaps to the correct texel
// every time, matching the software path's pixel-perfect bitmap copy.
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
// GL_LUMINANCE is 1 byte/pixel; default UNPACK_ALIGNMENT (4) misreads
// any row whose width is not a multiple of 4, scrambling those glyphs.
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, glow::LUMINANCE as i32,
w, h, 0, glow::LUMINANCE, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( data ) ),
);
gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &str ) -> glow::Program
{
// SAFETY: caller's GL context is current. The shaders are compiled and
// linked inside this block; on success `program` is a fresh, fully linked
// GL program object. Vertex / fragment shaders are released with
// `delete_shader` after attach + link — they are flagged for deletion and
// freed when the program is deleted, which is the canonical pattern.
// Compile / link asserts panic with the driver's info log instead of
// returning a half-broken program (callers cannot recover anyway).
unsafe
{
let program = gl.create_program().unwrap();
let vs = gl.create_shader( glow::VERTEX_SHADER ).unwrap();
gl.shader_source( vs, vert_src );
gl.compile_shader( vs );
assert!( gl.get_shader_compile_status( vs ), "VS: {}", gl.get_shader_info_log( vs ) );
let fs = gl.create_shader( glow::FRAGMENT_SHADER ).unwrap();
gl.shader_source( fs, frag_src );
gl.compile_shader( fs );
assert!( gl.get_shader_compile_status( fs ), "FS: {}", gl.get_shader_info_log( fs ) );
gl.attach_shader( program, vs );
gl.attach_shader( program, fs );
gl.bind_attrib_location( program, 0, "a_pos" );
gl.link_program( program );
assert!( gl.get_program_link_status( program ), "Link: {}", gl.get_program_info_log( program ) );
gl.delete_shader( vs );
gl.delete_shader( fs );
program
}
}
pub( super ) fn bytemuck_cast_slice( floats: &[f32] ) -> &[u8]
{
// SAFETY: `f32` has the same allocation provenance / validity as
// `[u8; 4]` for any bit pattern (every bit pattern is a valid byte;
// `f32` admits NaN payloads but those are valid `u8` reads). The
// returned slice's lifetime is tied to the input slice's lifetime
// through the function signature so the read window cannot outlive
// the underlying storage. `len * 4` cannot overflow `usize` because
// it would require an `f32` slice exceeding `usize::MAX / 4` bytes
// — physically impossible on any addressable target.
unsafe
{
std::slice::from_raw_parts(
floats.as_ptr() as *const u8,
floats.len() * 4,
)
}
}
pub( super ) fn native_texture_id( texture: glow::Texture ) -> u32
{
texture.0.get()
}
pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32
{
framebuffer.0.get()
}
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path the `ltk-theme-default`
// package depends on. Listed first so Sora wins as the default
// font whenever that package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Load the bytes of a default system font. Tries
/// [`SYSTEM_FONT_CANDIDATES`] in order; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
/// OFL 1.1) when nothing matches or the file cannot be read. Always
/// returns usable bytes so canvas construction never panics on a
/// system without the expected fonts.
pub( super ) fn load_default_font_bytes() -> Vec<u8>
{
for path in SYSTEM_FONT_CANDIDATES.iter()
{
if std::path::Path::new( path ).exists()
{
if let Ok( bytes ) = std::fs::read( path )
{
return bytes;
}
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}

168
src/gles_render/image.rs Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Raster-image draw path for [`GlesCanvas`]. Uploads the RGBA
//! bytes as a premultiplied-alpha texture (cached by content
//! fingerprint so repeated draws of the same buffer do not
//! re-upload) and composites it through the texture shader,
//! honouring the canvas' `global_alpha` via the opacity uniform.
//!
//! The cache is keyed by `(size, fingerprint)` where the fingerprint
//! is a 64-bit hash sampled from the RGBA bytes. This avoids the
//! address-reuse trap of a pointer-based key — when an `Arc<Vec<u8>>`
//! gets dropped and the allocator hands the same heap address to a
//! *different* buffer on the next frame, a pointer-keyed cache would
//! serve the stale texture for the new content. Content-keying makes
//! that impossible: identical bytes → identical key, regardless of
//! where they live in memory.
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use glow::HasContext;
use crate::types::Rect;
use super::helpers::{ ortho_rect, upload_rgba_texture };
use super::GlesCanvas;
/// Compute a 64-bit fingerprint of an RGBA buffer for the texture
/// cache. Hashes the full byte slice for small buffers (icons,
/// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a
/// strided 8 × 512-byte sample for anything larger so a wallpaper
/// blit does not pay an 8 MB hash on every frame. Both modes
/// distinguish the chevron-icon-style cases that motivated the move
/// to content-keying — the SVG-rasterised buffers differ across
/// most of their interior bytes, not just at the corners.
fn fingerprint_rgba( bytes: &[u8] ) -> u64
{
const FULL_HASH_THRESHOLD: usize = 16 * 1024;
const SAMPLE_CHUNKS: usize = 8;
const SAMPLE_CHUNK_BYTES: usize = 512;
let mut h = DefaultHasher::new();
let n = bytes.len();
n.hash( &mut h );
if n <= FULL_HASH_THRESHOLD
{
bytes.hash( &mut h );
} else {
let stride = n / SAMPLE_CHUNKS;
for i in 0..SAMPLE_CHUNKS
{
let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES );
bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h );
}
}
h.finish()
}
impl GlesCanvas
{
/// Blit RGBA image data scaled to dest rect with opacity.
///
/// Defensive: rejects buffers whose declared `img_w × img_h × 4` does not
/// match `rgba_data.len()`. The mismatch path logs a one-line warning
/// and returns without uploading or drawing — the same boundary that
/// the internal `upload_rgba_texture` helper enforces, raised one
/// level so the cache key is never seeded with a bogus mapping.
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
if img_w == 0 || img_h == 0 || rgba_data.len() != expected
{
eprintln!(
"[ltk] GlesCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
img_w, img_h, rgba_data.len(), expected,
);
return;
}
self.activate_target();
// Content-fingerprint key — see the module doc for the
// rationale. The (w, h) prefix means a pathological pair of
// buffers with identical bytes but different declared sizes
// stays distinct (cannot happen for valid input, defence in
// depth).
let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) );
if !self.image_cache.contains_key( &cache_key )
{
let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 );
self.image_cache.insert( cache_key, ( tex, img_w, img_h ) );
}
// Snap to integer pixels. With GL_LINEAR sampling, a
// fractional `dest.x` / `dest.y` makes every fragment sample
// at sub-texel offset — bilinear blends adjacent texels and
// the result reads as ~1 px softer than the source. At
// integer offset every fragment center maps to a texel
// centre and the bilinear collapses to identity, so a 1:1
// sampled icon renders crisp.
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
if let Some( ( tex, _, _ ) ) = self.image_cache.get( &cache_key )
{
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = opacity * self.global_alpha;
// SAFETY: see `primitives.rs` module doc. `*tex` is owned by
// `self.image_cache` so it outlives the call. The image cache
// stays valid as long as `&mut self` is held — no eviction
// path runs concurrently with the draw.
unsafe
{
self.gl.use_program( Some( self.tex_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( *tex ) );
self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
}
/// Draw an externally-owned GL texture into `dest`.
///
/// The caller owns the texture and is responsible for keeping it valid
/// for the duration of this call. No upload, no caching — used to
/// composite content rendered by another GL producer (web engine,
/// video decoder, …) into the LTK widget tree.
pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 )
{
self.activate_target();
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = opacity * self.global_alpha;
// SAFETY: caller-owned texture must outlive this call. We only
// sample it; we never delete or reassign the GL name.
unsafe
{
self.gl.use_program( Some( self.tex_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
}

387
src/gles_render/mod.rs Normal file
View File

@@ -0,0 +1,387 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GPU-accelerated rendering backend using EGL + GLES2 / GLES3.
//!
//! Mirrors the public surface of the software backend so that
//! [`crate::core::Canvas`] can route widget draw calls to either backend
//! by `match self`. The EGL context bootstrap lives in
//! [`crate::egl_context`] — this module is just the renderer that runs
//! once a context is current.
//!
//! Clipping is implemented with `glScissor`. When
//! [`GlesCanvas::set_clip_rects`] receives multiple rects the
//! bounding-box union is used as the scissor — coarse, but the
//! partial-redraw path normally clusters the dirty rects of 13
//! widgets so the union is barely larger than the sum. Disjoint
//! regions would want a stencil-buffer path; not implemented today.
//!
//! # Submodule layout
//!
//! * `setup` — `GlesCanvas::{new, sub_canvas, resize, set_font_registry,
//! font_for, dpi/alpha accessors}`.
//! * `framebuffer` — `GlesCanvas::{blit, present, activate_target,
//! borrowed_texture, read_rgba_pixels, ensure_aux_*, snapshot_fbo_region,
//! fill_backdrop, invalidate_aux}` — everything that manipulates the
//! FBO or auxiliary snapshot textures.
//! * `clip` — `GlesCanvas::{set_clip_rects, clear_clip, set_scissor,
//! scissor_pixels, fill, clear, clear_rects_transparent}`.
//! * `primitives` — `GlesCanvas::{fill_rect, fill_linear_gradient_rect,
//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset,
//! stroke_rect, draw_line}`.
//! * `text` — `GlesCanvas::{draw_text, measure_text, draw_glyph_texture}`.
//! * `image` — `GlesCanvas::draw_image_data`.
//! * `shaders` — GLSL ES 1.00 shader sources (const strings).
//! * `helpers` — free functions: `ortho_rect`, `compile_program`,
//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors,
//! `find_font` + `SYSTEM_FONT_CANDIDATES`.
//! * `raii` — `FboBinding` / `ProgramBinding` scoped guards for the
//! handful of operations that change global GL state for a scope
//! and must guarantee restoration even on early return / panic
//! (presently only `present`). The renderer otherwise relies on
//! `activate_target`'s lazy re-bind at the entry of each draw
//! method — see the module's own doc for when to use the guards
//! and when not to.
use std::collections::HashMap;
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::theme::FontRegistry;
use crate::types::Rect;
pub( crate ) mod shaders;
pub( crate ) mod helpers;
pub( crate ) mod setup;
pub( crate ) mod framebuffer;
pub( crate ) mod clip;
pub( crate ) mod primitives;
pub( crate ) mod text;
pub( crate ) mod image;
mod raii;
// ─── Public types ────────────────────────────────────────────────────────────
/// Which GLES profile the active context is. Stored so per-frame
/// fast-paths can be selected without re-querying GL.
#[ derive( Clone, Copy, PartialEq, Eq, Debug ) ]
pub enum GlesVersion
{
V2,
V3,
}
/// Borrowed view of the texture backing a [`GlesCanvas`].
///
/// The texture and framebuffer remain owned by ltk. Consumers may
/// sample the texture while the canvas is alive, but must not delete
/// or take ownership of the GL names. A resize can replace both
/// names, so callers should query this after rendering/resizing, not
/// cache it indefinitely.
#[ derive( Clone, Copy, Debug ) ]
pub struct BorrowedGlesTexture
{
/// Native GL texture name for the canvas color attachment.
pub texture_id: u32,
/// Native GL framebuffer name that owns `texture_id` as color attachment 0.
pub framebuffer_id: u32,
/// Glow texture handle for callers already using glow.
pub texture: glow::Texture,
/// Glow framebuffer handle for callers already using glow.
pub framebuffer: glow::Framebuffer,
/// Texture width in physical pixels.
pub width: u32,
/// Texture height in physical pixels.
pub height: u32,
/// The texture contains RGBA pixels with premultiplied alpha.
pub premultiplied: bool,
/// Whether consumers should treat the texture as vertically inverted.
pub y_inverted: bool,
}
/// Cached glyph: pre-rasterized bitmap uploaded as a GL texture.
pub ( super ) struct GlyphEntry
{
pub ( super ) texture: glow::Texture,
pub ( super ) metrics: fontdue::Metrics,
pub ( super ) tex_w: i32,
pub ( super ) tex_h: i32,
}
// ─── GlesCanvas ──────────────────────────────────────────────────────────────
/// GPU-accelerated canvas using EGL + GLES2/3.
///
/// Renders into a persistent FBO (the "shadow canvas") so widget
/// pixels survive across frames — this mirrors the software pixmap
/// model and is what enables the partial-redraw path on the GPU side.
/// `Self::present` blits the FBO onto the default framebuffer; the
/// caller is responsible for the `eglSwapBuffers` that follows.
pub struct GlesCanvas
{
pub gl: Arc<glow::Context>,
pub version: GlesVersion,
/// Default font loaded from the system via `helpers::find_font`.
/// Kept as a fallback for callers that do not route through the
/// theme registry.
pub font: Arc<Font>,
/// Optional theme font registry. Populated by the runtime after
/// theme load; until then it is `None` and [`Self::font_for`]
/// falls back to [`Self::font`].
pub font_registry: Option<Arc<FontRegistry>>,
pub dpi_scale: f32,
pub global_alpha: f32,
pub width: u32,
pub height: u32,
// Shader programs. `Program` is a Copy handle; sub-canvases share
// these with their parent (no reference counting needed since they
// outlive the process).
rect_program: glow::Program,
tex_program: glow::Program,
glyph_program: glow::Program,
blit_program: glow::Program,
sub_blit_program: glow::Program,
linear_gradient_program: glow::Program,
radial_gradient_program: glow::Program,
shadow_outer_program: glow::Program,
shadow_inset_program: glow::Program,
// Shared geometry (a unit quad as two triangles)
quad_vao: glow::VertexArray,
_quad_vbo: glow::Buffer,
// Uniform locations for rect shader
u_rect_mvp: glow::UniformLocation,
u_rect_color: glow::UniformLocation,
u_rect_size: glow::UniformLocation,
u_rect_radii: glow::UniformLocation,
u_rect_stroke: glow::UniformLocation,
u_rect_pad: glow::UniformLocation,
// Uniform locations for texture shader
u_tex_mvp: glow::UniformLocation,
u_tex_opacity: glow::UniformLocation,
u_tex_sampler: glow::UniformLocation,
// Uniform locations for glyph shader
u_glyph_mvp: glow::UniformLocation,
u_glyph_color: glow::UniformLocation,
u_glyph_opacity: glow::UniformLocation,
u_glyph_sampler: glow::UniformLocation,
// Uniform location for blit shader
u_blit_sampler: glow::UniformLocation,
// Uniform locations for sub-canvas blit shader
u_subblit_mvp: glow::UniformLocation,
u_subblit_sampler: glow::UniformLocation,
u_subblit_opacity: glow::UniformLocation,
u_subblit_fade_bottom: glow::UniformLocation,
u_subblit_height_px: glow::UniformLocation,
// Uniform locations for the linear gradient shader
u_lingrad_mvp: glow::UniformLocation,
u_lingrad_lut: glow::UniformLocation,
u_lingrad_dir: glow::UniformLocation,
u_lingrad_size: glow::UniformLocation,
u_lingrad_line_length: glow::UniformLocation,
u_lingrad_radii: glow::UniformLocation,
u_lingrad_pad: glow::UniformLocation,
u_lingrad_lut_domain_min: glow::UniformLocation,
u_lingrad_lut_domain_span: glow::UniformLocation,
// Uniform locations for the radial gradient shader
u_radgrad_mvp: glow::UniformLocation,
u_radgrad_lut: glow::UniformLocation,
u_radgrad_center: glow::UniformLocation,
u_radgrad_radius_frac: glow::UniformLocation,
u_radgrad_size: glow::UniformLocation,
u_radgrad_radii: glow::UniformLocation,
u_radgrad_pad: glow::UniformLocation,
u_radgrad_lut_domain_min: glow::UniformLocation,
u_radgrad_lut_domain_span: glow::UniformLocation,
// Uniform locations for the outer shadow shader
u_shadow_mvp: glow::UniformLocation,
u_shadow_size: glow::UniformLocation,
u_shadow_padding: glow::UniformLocation,
u_shadow_radii: glow::UniformLocation,
u_shadow_spread: glow::UniformLocation,
u_shadow_sigma: glow::UniformLocation,
u_shadow_color: glow::UniformLocation,
// Uniform locations for the inner (inset) shadow shader
u_inset_mvp: glow::UniformLocation,
u_inset_size: glow::UniformLocation,
u_inset_padding: glow::UniformLocation,
u_inset_radii: glow::UniformLocation,
u_inset_spread: glow::UniformLocation,
u_inset_sigma: glow::UniformLocation,
u_inset_offset: glow::UniformLocation,
u_inset_color: glow::UniformLocation,
/// Inset-shadow shader variant for `BlendMode::Overlay`. Distinct
/// from [`Self::shadow_inset_program`] because CSS Overlay cannot
/// be expressed with fixed-function blend — this shader samples
/// the just-snapshotted FBO content (via `aux_a`) and
/// computes the per-channel Overlay formula in-shader, then
/// outputs premultiplied.
shadow_inset_overlay_program: glow::Program,
u_inset_ov_mvp: glow::UniformLocation,
u_inset_ov_size: glow::UniformLocation,
u_inset_ov_padding: glow::UniformLocation,
u_inset_ov_radii: glow::UniformLocation,
u_inset_ov_spread: glow::UniformLocation,
u_inset_ov_sigma: glow::UniformLocation,
u_inset_ov_offset: glow::UniformLocation,
u_inset_ov_color: glow::UniformLocation,
u_inset_ov_snapshot: glow::UniformLocation,
u_inset_ov_canvas_size: glow::UniformLocation,
/// Horizontal pass of the separable Gaussian used by
/// [`Self::fill_backdrop`](framebuffer). Samples
/// `aux_a` (snapshot of the main FBO) and writes the
/// horizontally-blurred result into `aux_b`.
backdrop_blur_h_program: glow::Program,
u_bd_h_source: glow::UniformLocation,
u_bd_h_texel: glow::UniformLocation,
u_bd_h_canvas_size: glow::UniformLocation,
u_bd_h_sigma: glow::UniformLocation,
/// Vertical pass of the separable Gaussian combined with the SDF
/// clip to the surface shape and optional tint. Reads
/// `aux_b` (H-blurred) and writes to the main FBO at the
/// surface rect.
backdrop_composite_program: glow::Program,
u_bd_c_mvp: glow::UniformLocation,
u_bd_c_source: glow::UniformLocation,
u_bd_c_canvas_size: glow::UniformLocation,
u_bd_c_texel: glow::UniformLocation,
u_bd_c_sigma: glow::UniformLocation,
u_bd_c_size: glow::UniformLocation,
u_bd_c_padding: glow::UniformLocation,
u_bd_c_radii: glow::UniformLocation,
u_bd_c_tint: glow::UniformLocation,
/// Fast (low-quality) horizontal Gaussian. Same role as
/// `backdrop_blur_h_program` but with a 9-tap kernel
/// (`RADIUS = 4`) instead of 41 taps. Used during animations /
/// drags via [`crate::render::low_quality_paint`].
backdrop_fast_blur_h_program: glow::Program,
u_bd_fh_source: glow::UniformLocation,
u_bd_fh_texel: glow::UniformLocation,
u_bd_fh_canvas_size: glow::UniformLocation,
u_bd_fh_sigma: glow::UniformLocation,
/// Fast (low-quality) vertical + SDF + tint composite. 9-tap
/// kernel counterpart to `backdrop_composite_program`.
backdrop_fast_composite_program: glow::Program,
u_bd_fc_mvp: glow::UniformLocation,
u_bd_fc_source: glow::UniformLocation,
u_bd_fc_canvas_size: glow::UniformLocation,
u_bd_fc_texel: glow::UniformLocation,
u_bd_fc_sigma: glow::UniformLocation,
u_bd_fc_size: glow::UniformLocation,
u_bd_fc_padding: glow::UniformLocation,
u_bd_fc_radii: glow::UniformLocation,
u_bd_fc_tint: glow::UniformLocation,
// Glyph cache: (char, size_key, font_id) → GlyphEntry. The
// `font_id` is the address of the `Arc<Font>` used for the
// rasterisation, so distinct weights / families of the same
// (char, size) keep separate atlas entries.
glyph_cache: HashMap<(char, u32, usize), GlyphEntry>,
// Reusable texture cache for images. Keyed by
// `(width, height, content fingerprint)` rather than the source
// buffer's heap address — pointer-keying produced ghosting when
// short-lived `Arc<Vec<u8>>` buffers got dropped and the
// allocator handed the same address to a different buffer next
// frame (the cache would happily serve the stale texture).
// Content-keying tolerates that case at the cost of one
// `DefaultHasher` pass over the bytes per draw call — fast for
// any reasonable icon size.
image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>,
// Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture.
// Gradients are theme-derived and constant across frames; caching avoids
// a glTexImage2D round-trip (create + upload + delete) on every draw call.
// Cleared via `clear_gradient_cache()` on theme changes.
gradient_lut_cache: HashMap<u64, glow::Texture>,
/// Current scissor: `Some(rect)` when a clip is installed
/// (GL_SCISSOR_TEST is enabled), `None` when cleared.
clip_scissor: Option<Rect>,
/// Persistent shadow framebuffer. All draw methods bind this;
/// [`Self::present`](framebuffer) is the only call that switches
/// to the default framebuffer.
fbo: glow::Framebuffer,
/// Color attachment for `fbo`. Reallocated on resize.
fbo_tex: glow::Texture,
/// Auxiliary FBO + texture used as a snapshot of `fbo`
/// for framebuffer-fetch-style effects (CSS `Overlay` blend,
/// backdrop blur). Lazily allocated on first use; dropped on
/// resize so the next user re-allocates at the new size.
aux_a: Option<( glow::Framebuffer, glow::Texture )>,
/// Second auxiliary FBO, used as ping-pong target for the
/// separable Gaussian blur in backdrop compositing. Uses LINEAR
/// filtering for the V-pass bilinear sampling (vs `aux_a`'s
/// NEAREST).
aux_b: Option<( glow::Framebuffer, glow::Texture )>,
}
// ─── Drop ────────────────────────────────────────────────────────────────────
/// Free this canvas's owned GL resources: FBO, color attachment, aux
/// FBOs if allocated, and any cached glyph / image textures. Shader
/// programs and the quad VAO/VBO are shared with sub-canvases and
/// intentionally NOT deleted here — they leak at process exit, which
/// is fine for a process-wide GL context.
impl Drop for GlesCanvas
{
fn drop( &mut self )
{
// SAFETY: every handle freed below was created through `self.gl` —
// either in `setup.rs::new` / `sub_canvas` (`fbo`, `fbo_tex`),
// `framebuffer.rs::ensure_aux_a` / `ensure_aux_b` (`aux_a`, `aux_b`),
// `text.rs::draw_text` (`glyph_cache`), `image.rs::draw_image_data`
// (`image_cache`), or `primitives.rs::ensure_lut_texture`
// (`gradient_lut_cache`). Each container `drain` / `take` is
// consumed once so no double-free is possible. Caller must keep
// the GL context current at drop time — this is documented on
// `core::UiSurface::from_current_gles_loader` and
// `from_canvas_with_egl_context`.
unsafe
{
self.gl.delete_framebuffer( self.fbo );
self.gl.delete_texture( self.fbo_tex );
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_b.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
for ( _, entry ) in self.glyph_cache.drain()
{
self.gl.delete_texture( entry.texture );
}
for ( _, ( tex, _, _ ) ) in self.image_cache.drain()
{
self.gl.delete_texture( tex );
}
for ( _, tex ) in self.gradient_lut_cache.drain()
{
self.gl.delete_texture( tex );
}
}
}
}

View File

@@ -0,0 +1,545 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Primitive draw ops for [`GlesCanvas`]: solid and gradient rect
//! fills, inner and outer shadows, stroke, line. All go through the
//! shared quad VAO + one of the pre-compiled shader programs from
//! [`super::shaders`], with uniforms set per-call.
//!
//! # Shared `unsafe` invariants
//!
//! Every `unsafe` block below relies on the same canvas-wide contract
//! and only adds one note per block when something specific applies:
//!
//! * The GL context behind `self.gl` is current on this thread — the
//! `GlesCanvas` constructors only return a value when this is true,
//! and every `&mut self` method runs on the construction thread.
//! * Every `program` / `uniform_*` / `vertex_array` / `texture` handle
//! stored on `self` was produced by the same context in `setup.rs`
//! and outlives the draw call.
//! * Each draw method calls `activate_target` first, which re-binds the
//! canvas FBO and re-applies viewport / scissor — so the unsafe block
//! never inherits a stranded binding from a sibling canvas.
//! * `bind_vertex_array(None)` and `bind_texture(_, None)` at the end
//! of the unsafe block leaves the global GL state in the same shape
//! the next draw method assumes (no stranded VAO / texture binding).
use glow::HasContext;
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
use crate::types::{ Color, Corners, Rect };
use super::helpers::ortho_rect;
use super::GlesCanvas;
impl GlesCanvas
{
/// Returns `true` when `rect` (expanded by `margin` on every side)
/// is entirely outside the active scissor — the GPU would cull
/// every fragment pre-shader anyway, so skipping the draw saves
/// the `activate_target` / `use_program` / uniform / VAO / draw
/// sequence. No scissor = no cull (the whole canvas is fair game).
fn rect_culled( &self, rect: Rect, margin: f32 ) -> bool
{
let Some( clip ) = self.clip_scissor else { return false };
let r_x0 = rect.x - margin;
let r_y0 = rect.y - margin;
let r_x1 = rect.x + rect.width + margin;
let r_y1 = rect.y + rect.height + margin;
let c_x1 = clip.x + clip.width;
let c_y1 = clip.y + clip.height;
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
self.activate_target();
// Expand the quad 1 px on each side so the outer half of the SDF
// antialiasing band (d ∈ [0, 0.5]) has fragments to cover along the
// straight edges of pills / rounded rects. `u_size` and `u_radii`
// stay anchored to the original rect — `u_pad` lets the shader
// remap `v_uv` from the larger quad back into rect-local space.
let pad = 1.0_f32;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
let alpha = color.a * self.global_alpha;
// SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill
// branch of `RECT_FRAG_SRC`; the SDF reads `u_size` / `u_radii` of
// the original rect while `u_pad` remaps `v_uv` from the expanded
// quad — so the rasteriser sees the padded geometry but the shader
// computes coverage in original-rect coordinates.
unsafe
{
self.gl.use_program( Some( self.rect_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), 0.0 );
self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Return the cached gradient LUT texture for `lut_bytes`, uploading
/// it on the first call for each unique byte sequence. Subsequent calls
/// with the same bytes skip `glTexImage2D` entirely. The texture lives
/// until `clear_gradient_cache` is called (e.g. on theme change) or
/// the canvas is dropped.
fn ensure_lut_texture( &mut self, lut_bytes: &[u8] ) -> glow::Texture
{
use std::hash::{ Hash, Hasher };
let mut h = std::collections::hash_map::DefaultHasher::new();
lut_bytes.hash( &mut h );
let key = h.finish();
if let Some( &tex ) = self.gradient_lut_cache.get( &key )
{
return tex;
}
// SAFETY: see module doc. `lut_bytes` is the contiguous
// `LUT_SAMPLES * 4` byte LUT produced by `gradient_lut::build_lut_bytes`
// (RGBA8, one row); the texture allocation matches that exact shape.
// We unbind TEXTURE_2D on exit to keep the unit-0 binding shape the
// rest of the canvas assumes.
unsafe
{
let tex = self.gl.create_texture().expect( "gradient LUT texture" );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
gradient_lut::LUT_SAMPLES as i32,
1,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( lut_bytes ) ),
);
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gradient_lut_cache.insert( key, tex );
tex
}
}
/// Fill a rectangle with a linear gradient.
///
/// Bakes a CPU-side LUT from `g.stops` and fetches (or creates) the
/// corresponding cached GPU texture via `ensure_lut_texture`,
/// then draws the quad with the gradient shader.
pub fn fill_linear_gradient_rect( &mut self, rect: Rect, g: &LinearGradient, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space );
let tex = self.ensure_lut_texture( &lut_bytes );
let theta = g.angle_deg.to_radians();
// CSS convention: 0° points up. dir.y is negative-up in screen space.
let dir_x = theta.sin();
let dir_y = -theta.cos();
let line_length = ( rect.width * dir_x ).abs() + ( rect.height * dir_y ).abs();
let line_length = if line_length.abs() < 1e-3 { 1e-3 } else { line_length };
self.activate_target();
// See fill_rect for the rationale on the 1 px quad pad.
let pad = 1.0_f32;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
// SAFETY: see module doc. `tex` is the cached LUT for `g.stops`
// produced by `ensure_lut_texture` above (RGBA8, sampler unit 0);
// `dir_x`, `dir_y`, `line_length` are derived from finite inputs
// (`line_length` is clamped above 1e-3 so the shader's divide is
// well-defined).
unsafe
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.use_program( Some( self.linear_gradient_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_lingrad_mvp ), false, &mvp );
self.gl.uniform_1_i32( Some( &self.u_lingrad_lut ), 0 );
self.gl.uniform_2_f32( Some( &self.u_lingrad_dir ), dir_x, dir_y );
self.gl.uniform_2_f32( Some( &self.u_lingrad_size ), rect.width, rect.height );
self.gl.uniform_1_f32( Some( &self.u_lingrad_line_length ), line_length );
self.gl.uniform_4_f32_slice( Some( &self.u_lingrad_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_lingrad_pad ), pad );
self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 );
self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
/// Fill a rectangle with a radial gradient.
///
/// `g.center` is interpreted in box-relative fractions (as declared by
/// the theme), `g.radius` is the fractional radial extent. Same cached
/// LUT strategy as [`Self::fill_linear_gradient_rect`].
pub fn fill_radial_gradient_rect( &mut self, rect: Rect, g: &RadialGradient, corners: Corners )
{
if self.rect_culled( rect, 1.0 ) { return; }
let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space );
let tex = self.ensure_lut_texture( &lut_bytes );
self.activate_target();
// See fill_rect for the rationale on the 1 px quad pad.
let pad = 1.0_f32;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
// SAFETY: see module doc. Same LUT contract as the linear path
// above. `g.center` and `g.radius` are finite fractional values
// from the theme parser (validated at load time).
unsafe
{
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
self.gl.use_program( Some( self.radial_gradient_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_radgrad_mvp ), false, &mvp );
self.gl.uniform_1_i32( Some( &self.u_radgrad_lut ), 0 );
self.gl.uniform_2_f32( Some( &self.u_radgrad_center ), g.center[0], g.center[1] );
self.gl.uniform_1_f32( Some( &self.u_radgrad_radius_frac ), g.radius );
self.gl.uniform_2_f32( Some( &self.u_radgrad_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_radgrad_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_radgrad_pad ), pad );
self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 );
self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
/// Paint an outer drop shadow behind a rounded rect.
///
/// Analytic Gaussian approximation over the shape SDF — see the note
/// above `SHADOW_OUTER_FRAG_SRC`. The drawing quad is expanded on each
/// side by `max(blur, 0) + max(spread, 0) + 1` (the `+ 1` leaves a
/// single antialias pixel of slack) and offset by `shadow.offset` so
/// the fragment shader sees the full falloff region.
///
/// Only `BlendMode::Normal` is honoured today; other modes silently
/// fall through to `Normal` because the analytic shader only outputs
/// `over`.
pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: Corners )
{
let blur_margin = shadow.blur.max( 0.0 );
let spread_margin = shadow.spread.max( 0.0 );
let margin = blur_margin + spread_margin + 1.0;
// Outer shadows draw a quad expanded by `margin` on each side
// (to capture the Gaussian falloff outside the shape); offset
// the target by `shadow.offset` for the cull test so a shadow
// that sits off-centre is not skipped prematurely.
let culled_rect = Rect
{
x: target.x + shadow.offset[0],
y: target.y + shadow.offset[1],
width: target.width,
height: target.height,
};
if self.rect_culled( culled_rect, margin ) { return; }
let quad = Rect
{
x: target.x + shadow.offset[0] - margin,
y: target.y + shadow.offset[1] - margin,
width: target.width + 2.0 * margin,
height: target.height + 2.0 * margin,
};
let sigma = shadow.sigma().max( 0.5 );
let alpha = shadow.color.a * self.global_alpha;
self.activate_target();
let mvp = ortho_rect( self.width, self.height, quad );
// SAFETY: see module doc. `sigma` is clamped above 0.5 so the
// shader's divide is well-defined; `margin` covers the full
// Gaussian falloff so the rasteriser sees every fragment the
// SDF wants to shade.
unsafe
{
self.gl.use_program( Some( self.shadow_outer_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_shadow_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_shadow_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_shadow_padding ), margin, margin );
self.gl.uniform_4_f32_slice( Some( &self.u_shadow_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_shadow_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_shadow_sigma ), sigma );
self.gl.uniform_4_f32( Some( &self.u_shadow_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Paint an inner (inset) shadow inside a rounded rect.
///
/// Differences versus [`Self::fill_shadow_outer`]:
///
/// * The drawing quad matches the target rect exactly — the inset is
/// clipped to the outer SDF by the shader, so no external padding
/// is needed and there is no spatial offset of the geometry.
/// * The shader carries the per-shadow `offset` as a uniform rather
/// than translating the quad, because the inset is biased *inside*
/// the shape rather than cast outside it.
/// * The pipeline blend state is switched for the duration of the
/// draw to honour `InsetShadow::blend` and restored afterwards.
///
/// Blend modes: `Normal` stays on the pipeline default
/// `(ONE, ONE_MINUS_SRC_ALPHA)`. `PlusLighter` uses `(ONE, ONE)` —
/// pure additive on premultiplied inputs, naturally clamped by the
/// framebuffer to `[0, 1]`, which is exactly the CSS definition.
/// `Multiply` uses `(DST_COLOR, ZERO)` on RGB and `(DST_ALPHA, ZERO)`
/// on alpha — a straight multiplicative blend. `Screen` uses
/// `(ONE_MINUS_DST_COLOR, ONE)`, the canonical `a + b a·b` form.
/// `Overlay` cannot be expressed with GL's fixed-function blend
/// state alone — it needs to read the destination pixel. This
/// branch snapshots the current FBO into `aux_a` via
/// `glCopyTexSubImage2D`, then draws through
/// `shadow_inset_overlay_program` which samples that snapshot at
/// `gl_FragCoord.xy / canvas_size`, computes the per-channel CSS
/// Overlay formula in-shader, and emits premultiplied
/// `(overlay * mask, mask)` — so the usual premul over blend
/// composes the blended colour on top of the base. One FBO
/// snapshot per Overlay shadow.
pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: Corners )
{
// Inset shadows draw a quad at `target` ± 1 px AA pad; the
// shape lives entirely inside. If that quad is outside the
// scissor, every fragment is culled — skip the whole path
// (including the Overlay snapshot, which is the expensive
// bit).
if self.rect_culled( target, 1.0 ) { return; }
let sigma = shadow.sigma().max( 0.5 );
let alpha = shadow.color.a * self.global_alpha;
// Overlay goes through the framebuffer-fetch path. Everything
// else uses the original SDF inset shader with a blend-state
// swap.
if matches!( shadow.blend, BlendMode::Overlay )
{
// Snapshot the inset's draw rect plus the 1 px AA pad so the
// quad's expanded edge still samples valid snapshot data.
// The shader samples `aux_a` at `gl_FragCoord.xy /
// canvas_size`, so reads outside the snapshotted region
// would pull stale content from a previous frame.
//
// Use the scissor-tight variant: Overlay samples at exactly
// one point per fragment, and any fragment outside the
// active scissor is culled before the shader runs, so the
// snapshot only needs to cover the intersection.
let pad = 1.0_f32;
let snap_rect = Rect
{
x: target.x - pad,
y: target.y - pad,
width: target.width + 2.0 * pad,
height: target.height + 2.0 * pad,
};
self.snapshot_fbo_region_tight( snap_rect );
self.activate_target();
let expanded = Rect
{
x: target.x - pad,
y: target.y - pad,
width: target.width + 2.0 * pad,
height: target.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
let aux_tex = self.aux_a.expect( "snapshotted" ).1;
// SAFETY: see module doc. `aux_tex` was just populated by
// `snapshot_fbo_region_tight` so it carries a valid copy of
// the live FBO at full canvas resolution; the shader samples
// it through `gl_FragCoord.xy / canvas_size`. We unbind unit-0
// after the draw to avoid stranding the snapshot binding.
unsafe
{
self.gl.use_program( Some( self.shadow_inset_overlay_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_ov_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_padding ), pad, pad );
self.gl.uniform_4_f32_slice( Some( &self.u_inset_ov_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_inset_ov_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_inset_ov_sigma ), sigma );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_offset ), shadow.offset[0], shadow.offset[1] );
self.gl.uniform_4_f32( Some( &self.u_inset_ov_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_inset_ov_canvas_size ), self.width as f32, self.height as f32 );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
self.gl.uniform_1_i32( Some( &self.u_inset_ov_snapshot ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
return;
}
self.activate_target();
// 1 px AA pad on the quad so the outer-silhouette clip
// (`outer_coverage`) renders its full smoothstep band instead
// of terminating at the surface rect. Same rationale as
// fill_rect. `u_size` / `u_radii` stay anchored to `target`.
let pad = 1.0_f32;
let expanded = Rect
{
x: target.x - pad,
y: target.y - pad,
width: target.width + 2.0 * pad,
height: target.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
// SAFETY: see module doc. We swap the global blend state for the
// duration of one draw and restore the canvas-wide default
// `(ONE, ONE_MINUS_SRC_ALPHA)` at the end of the block so the
// next draw inherits the expected pipeline blend.
unsafe
{
// Switch the blend state for this one draw.
match shadow.blend
{
BlendMode::Normal => { /* already the pipeline default */ }
BlendMode::PlusLighter => self.gl.blend_func( glow::ONE, glow::ONE ),
BlendMode::Multiply => self.gl.blend_func_separate
(
glow::DST_COLOR, glow::ZERO,
glow::DST_ALPHA, glow::ZERO,
),
BlendMode::Screen => self.gl.blend_func( glow::ONE_MINUS_DST_COLOR, glow::ONE ),
BlendMode::Overlay => unreachable!( "Overlay handled above via snapshot" ),
}
self.gl.use_program( Some( self.shadow_inset_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_inset_size ), target.width, target.height );
self.gl.uniform_2_f32( Some( &self.u_inset_padding ), pad, pad );
self.gl.uniform_4_f32_slice( Some( &self.u_inset_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_inset_spread ), shadow.spread );
self.gl.uniform_1_f32( Some( &self.u_inset_sigma ), sigma );
self.gl.uniform_2_f32( Some( &self.u_inset_offset ), shadow.offset[0], shadow.offset[1] );
self.gl.uniform_4_f32( Some( &self.u_inset_color ),
shadow.color.r, shadow.color.g, shadow.color.b, alpha );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
// Restore the pipeline default.
if !matches!( shadow.blend, BlendMode::Normal )
{
self.gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA );
}
}
}
/// Stroke a rectangle outline. The stroke is centered on the (rounded)
/// boundary, matching tiny-skia's stroke_path so software and GPU paths
/// produce the same shape (e.g. a circular focus ring around an icon
/// button stays circular).
///
/// The drawing quad is expanded by `width / 2` so the outer half of the
/// stroke — which lies *outside* the original rect — has fragments to
/// cover; the SDF in the rect shader then clamps to the ring.
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners )
{
let half = width * 0.5;
if self.rect_culled( rect, half + 1.0 ) { return; }
self.activate_target();
// Expand the *quad* outward so the outer half of the stroke has
// fragments to cover, plus 1 px extra so the 2 px AA band on the
// outer side of the stroke (half_w + 1 in the shader) has
// fragments too. `u_size` and `u_radii` keep their ORIGINAL
// values — they define the SDF, and the stroke's centerline must
// sit on the SDF zero-line (the original rect boundary). `u_pad`
// tells the fragment shader to remap `v_uv` from the larger quad
// back into original-rect space, so the SDF stays anchored to
// the original geometry. Growing `u_size`/`u_radii` instead
// would shift the zero-line outward and, in the circle case
// (radius = size/2), turn the result into a rounded square.
let pad = half + 1.0;
let expanded = Rect
{
x: rect.x - pad,
y: rect.y - pad,
width: rect.width + 2.0 * pad,
height: rect.height + 2.0 * pad,
};
let mvp = ortho_rect( self.width, self.height, expanded );
let alpha = color.a * self.global_alpha;
// SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers
// the stroke branch of `RECT_FRAG_SRC`. Same SDF-anchored-to-original
// remap as `fill_rect`; here the quad is padded by `half + 1.0` so
// the outer half of the stroke plus its 1 px AA band have fragments.
unsafe
{
self.gl.use_program( Some( self.rect_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha );
self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height );
self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() );
self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), width );
self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
}
}
/// Draw a line as a thin axis-aligned rect (diagonal lines fall back to
/// stamping small squares).
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
let dx = x1 - x0;
let dy = y1 - y0;
let len = ( dx * dx + dy * dy ).sqrt();
if len < 0.1 { return; }
let min_x = x0.min( x1 );
let min_y = y0.min( y1 );
if dy.abs() < 0.1
{
self.fill_rect( Rect { x: min_x, y: min_y - width / 2.0, width: dx.abs(), height: width }, color, Corners::ZERO );
} else if dx.abs() < 0.1 {
self.fill_rect( Rect { x: min_x - width / 2.0, y: min_y, width, height: dy.abs() }, color, Corners::ZERO );
} else {
let steps = len.ceil() as usize;
for i in 0..steps
{
let t = i as f32 / len;
let px = x0 + dx * t;
let py = y0 + dy * t;
self.fill_rect( Rect { x: px - width / 2.0, y: py - width / 2.0, width, height: width }, color, Corners::ZERO );
}
}
}
}

107
src/gles_render/raii.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! RAII guards for scoped GL bindings.
//!
//! The renderer normally restores GL state lazily: every draw method
//! calls [`super::GlesCanvas::activate_target`] at entry, which
//! re-binds the canvas FBO and reasserts viewport / scissor. So a
//! method that temporarily binds a different FBO (e.g. `aux_a` for a
//! snapshot) does not need to restore the previous binding — the next
//! draw will. That is a deliberate optimisation; introducing a RAII
//! restore at every site would double-bind the canvas FBO on every
//! frame.
//!
//! These guards exist for the *opposite* shape — operations that
//! genuinely change global state for the duration of a scope and
//! must guarantee restoration **even on early return / panic**.
//! Today that fits exactly one site: [`super::GlesCanvas::present`],
//! which binds the default framebuffer (id 0) and the blit program,
//! draws a fullscreen quad, and rebinds the canvas FBO + previous
//! program. Without RAII, an early return between the two binds
//! would leave the GL context with the default FBO and the wrong
//! program active — the next draw on this canvas would render to
//! the wrong target until `activate_target` ran (FBO is corrected
//! by `activate_target`; `use_program` is **not**).
//!
//! When in doubt: do not wrap `bind_framebuffer` / `use_program` in
//! a guard "for safety". The lazy-restore convention is part of the
//! renderer's contract and the guard pays for itself only when the
//! scope can exit through a path that bypasses `activate_target`.
use glow::HasContext;
/// Scoped framebuffer binding. Binds `target` on construction and
/// restores `previous` on Drop. Caller passes `previous` explicitly
/// because every site that needs this guard already knows what it
/// wants to restore (typically `self.fbo`) — querying
/// `GL_FRAMEBUFFER_BINDING` would force a sync round-trip we do not
/// need.
pub( super ) struct FboBinding<'a>
{
gl: &'a glow::Context,
previous: Option<glow::Framebuffer>,
}
impl<'a> FboBinding<'a>
{
/// Bind `target` immediately; remember `previous` to rebind on Drop.
///
/// # Safety
///
/// The GL context behind `gl` must be current on the calling thread
/// for the entire lifetime of the returned guard. Both `target` and
/// `previous` must be names produced by this same context (or
/// `None` for the default framebuffer).
pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option<glow::Framebuffer>, previous: Option<glow::Framebuffer> ) -> Self
{
// SAFETY: forwarded from the fn's own `# Safety` contract.
unsafe { gl.bind_framebuffer( glow::FRAMEBUFFER, target ); }
Self { gl, previous }
}
}
impl<'a> Drop for FboBinding<'a>
{
fn drop( &mut self )
{
// SAFETY: the construction-time invariant (current context) is the
// guard's lifetime invariant. Restoring `previous` is a pure
// state-machine mutation.
unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, self.previous ); }
}
}
/// Scoped program binding. Same shape as [`FboBinding`] but for
/// `glUseProgram`. Caller passes the program to restore explicitly
/// (typically the program the next pipeline stage will need, or
/// `None` to leave nothing bound).
pub( super ) struct ProgramBinding<'a>
{
gl: &'a glow::Context,
previous: Option<glow::Program>,
}
impl<'a> ProgramBinding<'a>
{
/// Activate `target` immediately; remember `previous` to restore on Drop.
///
/// # Safety
///
/// Same as [`FboBinding::scoped`].
pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option<glow::Program>, previous: Option<glow::Program> ) -> Self
{
// SAFETY: forwarded from the fn's own `# Safety` contract.
unsafe { gl.use_program( target ); }
Self { gl, previous }
}
}
impl<'a> Drop for ProgramBinding<'a>
{
fn drop( &mut self )
{
// SAFETY: see `FboBinding::drop`.
unsafe { self.gl.use_program( self.previous ); }
}
}

657
src/gles_render/setup.rs Normal file
View File

@@ -0,0 +1,657 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Construction + accessors for [`GlesCanvas`].
//!
//! The bulk of `GlesCanvas::new` is one big shader-compile +
//! uniform-lookup block: each of the shader programs gets compiled
//! from [`super::shaders`], its uniform locations are pulled out via
//! `get_uniform_location`, and both end up as `Copy` handles on the
//! struct so sub-canvases can share them for free. `sub_canvas` is
//! the same struct-literal again with the `new`-only bootstrap
//! elided (programs, VAO, default font all come from the parent).
use std::collections::HashMap;
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings, LineMetrics, Metrics };
use glow::HasContext;
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, load_default_font_bytes };
use super::shaders::
{
BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC,
BACKDROP_FAST_BLUR_H_FRAG_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC,
BLIT_FRAG_SRC, BLIT_VERT_SRC,
GLYPH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_FRAG_SRC,
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
SUB_BLIT_FRAG_SRC,
TEX_FRAG_SRC, VERT_SRC,
};
use super::{ GlesCanvas, GlesVersion };
/// Process-wide cache of the GLES path's default font. Avoids
/// re-reading + re-parsing the small Sora face on every surface
/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …)
/// is owned by the crate-private system-fonts module and loaded
/// lazily per codepoint, not per canvas.
static DEFAULT_FONT_GLES: OnceLock<Arc<Font>> = OnceLock::new();
fn default_font_gles() -> Arc<Font>
{
Arc::clone( DEFAULT_FONT_GLES.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
Arc::new( font )
} ) )
}
impl GlesCanvas
{
pub fn new( gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32 ) -> Self
{
let font = default_font_gles();
let rect_program = compile_program( &gl, VERT_SRC, RECT_FRAG_SRC );
let tex_program = compile_program( &gl, VERT_SRC, TEX_FRAG_SRC );
let glyph_program = compile_program( &gl, VERT_SRC, GLYPH_FRAG_SRC );
let blit_program = compile_program( &gl, BLIT_VERT_SRC, BLIT_FRAG_SRC );
let sub_blit_program = compile_program( &gl, VERT_SRC, SUB_BLIT_FRAG_SRC );
let linear_gradient_program = compile_program( &gl, VERT_SRC, LINEAR_GRADIENT_FRAG_SRC );
let radial_gradient_program = compile_program( &gl, VERT_SRC, RADIAL_GRADIENT_FRAG_SRC );
let shadow_outer_program = compile_program( &gl, VERT_SRC, SHADOW_OUTER_FRAG_SRC );
let shadow_inset_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_FRAG_SRC );
let shadow_inset_overlay_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC );
let backdrop_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_BLUR_H_FRAG_SRC );
let backdrop_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_COMPOSITE_FRAG_SRC );
let backdrop_fast_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_FAST_BLUR_H_FRAG_SRC );
let backdrop_fast_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC );
// SAFETY: every program in scope has just been linked successfully by
// `compile_program` (which panics on link failure), so `get_uniform_location`
// on these programs is well-defined. Each name argument is a `'static`
// ASCII literal — `glow` will not invoke UB on a malformed C string.
// `get_uniform_location` does not mutate the GL state machine, so this
// block has no interaction with whatever bindings precede it.
let (
u_rect_mvp, u_rect_color, u_rect_size, u_rect_radii, u_rect_stroke, u_rect_pad,
u_tex_mvp, u_tex_opacity, u_tex_sampler,
u_glyph_mvp, u_glyph_color, u_glyph_opacity, u_glyph_sampler,
u_blit_sampler,
u_subblit_mvp, u_subblit_sampler, u_subblit_opacity, u_subblit_fade_bottom, u_subblit_height_px,
u_lingrad_mvp, u_lingrad_lut, u_lingrad_dir, u_lingrad_size, u_lingrad_line_length,
u_lingrad_radii, u_lingrad_pad, u_lingrad_lut_domain_min, u_lingrad_lut_domain_span,
u_radgrad_mvp, u_radgrad_lut, u_radgrad_center, u_radgrad_radius_frac, u_radgrad_size,
u_radgrad_radii, u_radgrad_pad, u_radgrad_lut_domain_min, u_radgrad_lut_domain_span,
u_shadow_mvp, u_shadow_size, u_shadow_padding, u_shadow_radii, u_shadow_spread, u_shadow_sigma, u_shadow_color,
u_inset_mvp, u_inset_size, u_inset_padding, u_inset_radii, u_inset_spread, u_inset_sigma, u_inset_offset, u_inset_color,
u_inset_ov_mvp, u_inset_ov_size, u_inset_ov_padding, u_inset_ov_radii,
u_inset_ov_spread, u_inset_ov_sigma, u_inset_ov_offset, u_inset_ov_color,
u_inset_ov_snapshot, u_inset_ov_canvas_size,
u_bd_h_source, u_bd_h_texel, u_bd_h_canvas_size, u_bd_h_sigma,
u_bd_c_mvp, u_bd_c_source, u_bd_c_canvas_size, u_bd_c_texel, u_bd_c_sigma,
u_bd_c_size, u_bd_c_padding, u_bd_c_radii, u_bd_c_tint,
u_bd_fh_source, u_bd_fh_texel, u_bd_fh_canvas_size, u_bd_fh_sigma,
u_bd_fc_mvp, u_bd_fc_source, u_bd_fc_canvas_size, u_bd_fc_texel, u_bd_fc_sigma,
u_bd_fc_size, u_bd_fc_padding, u_bd_fc_radii, u_bd_fc_tint,
) = unsafe
{(
gl.get_uniform_location( rect_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( rect_program, "u_color" ).unwrap(),
gl.get_uniform_location( rect_program, "u_size" ).unwrap(),
gl.get_uniform_location( rect_program, "u_radii" ).unwrap(),
gl.get_uniform_location( rect_program, "u_stroke" ).unwrap(),
gl.get_uniform_location( rect_program, "u_pad" ).unwrap(),
gl.get_uniform_location( tex_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( tex_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( tex_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_color" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_sampler" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_opacity" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_fade_bottom_px" ).unwrap(),
gl.get_uniform_location( sub_blit_program, "u_height_px" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_dir" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_size" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_line_length" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_radii" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_pad" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut_domain_min" ).unwrap(),
gl.get_uniform_location( linear_gradient_program, "u_lut_domain_span" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_center" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_radius_frac" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_size" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_radii" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_pad" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut_domain_min" ).unwrap(),
gl.get_uniform_location( radial_gradient_program, "u_lut_domain_span" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_outer_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_offset" ).unwrap(),
gl.get_uniform_location( shadow_inset_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_size" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_padding" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_radii" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_spread" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_offset" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_color" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_snapshot" ).unwrap(),
gl.get_uniform_location( shadow_inset_overlay_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_blur_h_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_size" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_padding" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_radii" ).unwrap(),
gl.get_uniform_location( backdrop_composite_program, "u_tint" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_blur_h_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_source" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_canvas_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_texel" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_sigma" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_size" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_padding" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_radii" ).unwrap(),
gl.get_uniform_location( backdrop_fast_composite_program, "u_tint" ).unwrap(),
)};
let quad_vertices: [f32; 12] = [
0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
];
// SAFETY: the GL context is current (caller contract). We allocate a
// VAO + VBO, upload `quad_vertices` (24 bytes, statically known size,
// matches `STATIC_DRAW` semantics), and configure attribute 0 to read
// 2-float vertices from the VBO at offset 0 with stride 8. The vertex
// attrib pointer is bound to `vbo` because `vbo` is the current
// `ARRAY_BUFFER` binding when `vertex_attrib_pointer_f32` runs. We
// unbind the VAO at the end so we don't strand a binding the rest of
// `new` might inherit; the VBO remains attached to the VAO and is not
// touched again until `Drop`.
let ( quad_vao, quad_vbo ) = unsafe
{
let vao = gl.create_vertex_array().unwrap();
let vbo = gl.create_buffer().unwrap();
gl.bind_vertex_array( Some( vao ) );
gl.bind_buffer( glow::ARRAY_BUFFER, Some( vbo ) );
gl.buffer_data_u8_slice(
glow::ARRAY_BUFFER,
bytemuck_cast_slice( &quad_vertices ),
glow::STATIC_DRAW,
);
gl.enable_vertex_attrib_array( 0 );
gl.vertex_attrib_pointer_f32( 0, 2, glow::FLOAT, false, 8, 0 );
gl.bind_vertex_array( None );
( vao, vbo )
};
// Build the persistent FBO (shadow canvas) and bind it as the active
// draw target. From here on, every draw method writes into the FBO;
// `present` is the only call that switches to the default framebuffer.
//
// SAFETY: the GL context is current. `create_framebuffer` allocates a
// fresh FBO name. `alloc_fbo_tex` (an `unsafe fn`) requires the same
// invariant and returns a colour-renderable RGBA texture sized to the
// caller's width × height — the call is sound because both invariants
// are established. The bind + framebuffer_texture_2d pair attach the
// texture to COLOR_ATTACHMENT0; `check_framebuffer_status` is the
// canonical post-condition check and will assert before we return a
// handle to a broken FBO. The trailing GL state (`BLEND`, `blend_func`,
// `viewport`, `clear`) is the canvas-wide default state every draw
// method assumes — see the comment block beside `blend_func` for why
// `(ONE, ONE_MINUS_SRC_ALPHA)` is the correct pair for premultiplied
// shaders.
let ( fbo, fbo_tex ) = unsafe
{
let fbo = gl.create_framebuffer().expect( "create_framebuffer" );
let fbo_tex = alloc_fbo_tex( &gl, version, width, height );
gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( fbo_tex ), 0,
);
let status = gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "FBO incomplete: 0x{status:x}" );
gl.enable( glow::BLEND );
// Premultiplied-alpha "over" composite: `result = src + dst * (1 - src.a)`
// applied uniformly to both colour and alpha. All shaders in this
// pipeline emit premul colour (see the note above `RECT_FRAG_SRC`),
// so `(ONE, ONE_MINUS_SRC_ALPHA)` is correct for both channels.
// Premultiplied inputs are also a requirement for the plus-lighter
// and overlay blend modes.
gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA );
gl.viewport( 0, 0, width as i32, height as i32 );
// Clear the FBO once at startup so the first frame is not garbage.
gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
gl.clear( glow::COLOR_BUFFER_BIT );
( fbo, fbo_tex )
};
Self
{
gl,
version,
font,
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
width,
height,
rect_program,
tex_program,
glyph_program,
blit_program,
sub_blit_program,
linear_gradient_program,
radial_gradient_program,
shadow_outer_program,
shadow_inset_program,
shadow_inset_overlay_program,
quad_vao,
_quad_vbo: quad_vbo,
u_rect_mvp,
u_rect_color,
u_rect_size,
u_rect_radii,
u_rect_stroke,
u_rect_pad,
u_tex_mvp,
u_tex_opacity,
u_tex_sampler,
u_glyph_mvp,
u_glyph_color,
u_glyph_opacity,
u_glyph_sampler,
u_blit_sampler,
u_subblit_mvp,
u_subblit_sampler,
u_subblit_opacity,
u_subblit_fade_bottom,
u_subblit_height_px,
u_lingrad_mvp,
u_lingrad_lut,
u_lingrad_dir,
u_lingrad_size,
u_lingrad_line_length,
u_lingrad_radii,
u_lingrad_pad,
u_lingrad_lut_domain_min,
u_lingrad_lut_domain_span,
u_radgrad_mvp,
u_radgrad_lut,
u_radgrad_center,
u_radgrad_radius_frac,
u_radgrad_size,
u_radgrad_radii,
u_radgrad_pad,
u_radgrad_lut_domain_min,
u_radgrad_lut_domain_span,
u_shadow_mvp,
u_shadow_size,
u_shadow_padding,
u_shadow_radii,
u_shadow_spread,
u_shadow_sigma,
u_shadow_color,
u_inset_mvp,
u_inset_size,
u_inset_padding,
u_inset_radii,
u_inset_spread,
u_inset_sigma,
u_inset_offset,
u_inset_color,
u_inset_ov_mvp,
u_inset_ov_size,
u_inset_ov_padding,
u_inset_ov_radii,
u_inset_ov_spread,
u_inset_ov_sigma,
u_inset_ov_offset,
u_inset_ov_color,
u_inset_ov_snapshot,
u_inset_ov_canvas_size,
backdrop_blur_h_program,
u_bd_h_source,
u_bd_h_texel,
u_bd_h_canvas_size,
u_bd_h_sigma,
backdrop_composite_program,
u_bd_c_mvp,
u_bd_c_source,
u_bd_c_canvas_size,
u_bd_c_texel,
u_bd_c_sigma,
u_bd_c_size,
u_bd_c_padding,
u_bd_c_radii,
u_bd_c_tint,
backdrop_fast_blur_h_program,
u_bd_fh_source,
u_bd_fh_texel,
u_bd_fh_canvas_size,
u_bd_fh_sigma,
backdrop_fast_composite_program,
u_bd_fc_mvp,
u_bd_fc_source,
u_bd_fc_canvas_size,
u_bd_fc_texel,
u_bd_fc_sigma,
u_bd_fc_size,
u_bd_fc_padding,
u_bd_fc_radii,
u_bd_fc_tint,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
fbo,
fbo_tex,
aux_a: None,
aux_b: None,
}
}
/// Build a sub-canvas: a separate render target sharing this canvas's GL
/// context, font, shader programs, geometry, and uniform locations, but
/// with its own FBO + color texture sized to `width × height`. Used to
/// render content into an off-screen target that can then be composited
/// back via [`Self::blit`].
///
/// The returned canvas inherits the parent's `dpi_scale` and `global_alpha`
/// (so glyphs render at the same pixel size). Its glyph cache starts empty
/// — re-rasterising on first use is the cost of not sharing GL textures
/// across canvases.
pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas
{
let gl = Arc::clone( &self.gl );
// SAFETY: `self.gl` is the same context held by `self`; if `self` was
// constructed soundly its context is current on this thread. We
// allocate a new FBO + colour texture and attach them, then assert
// completeness. We deliberately leave `gl` with the new FBO bound
// instead of restoring the parent's binding — every draw method goes
// through `activate_target`, which re-binds the canvas's own FBO
// before issuing any draw call, so the transient binding cannot be
// observed by other code on this thread.
let ( fbo, fbo_tex ) = unsafe
{
let fbo = gl.create_framebuffer().expect( "create_framebuffer" );
let fbo_tex = alloc_fbo_tex( &gl, self.version, width, height );
gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( fbo_tex ), 0,
);
let status = gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "sub-FBO incomplete: 0x{status:x}" );
( fbo, fbo_tex )
};
GlesCanvas
{
gl,
version: self.version,
font: Arc::clone( &self.font ),
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
width,
height,
rect_program: self.rect_program,
tex_program: self.tex_program,
glyph_program: self.glyph_program,
blit_program: self.blit_program,
sub_blit_program: self.sub_blit_program,
linear_gradient_program: self.linear_gradient_program,
radial_gradient_program: self.radial_gradient_program,
shadow_outer_program: self.shadow_outer_program,
shadow_inset_program: self.shadow_inset_program,
shadow_inset_overlay_program: self.shadow_inset_overlay_program,
backdrop_blur_h_program: self.backdrop_blur_h_program,
backdrop_composite_program: self.backdrop_composite_program,
backdrop_fast_blur_h_program: self.backdrop_fast_blur_h_program,
backdrop_fast_composite_program: self.backdrop_fast_composite_program,
quad_vao: self.quad_vao,
_quad_vbo: self._quad_vbo,
u_rect_mvp: self.u_rect_mvp,
u_rect_color: self.u_rect_color,
u_rect_size: self.u_rect_size,
u_rect_radii: self.u_rect_radii,
u_rect_stroke: self.u_rect_stroke,
u_rect_pad: self.u_rect_pad,
u_tex_mvp: self.u_tex_mvp,
u_tex_opacity: self.u_tex_opacity,
u_tex_sampler: self.u_tex_sampler,
u_glyph_mvp: self.u_glyph_mvp,
u_glyph_color: self.u_glyph_color,
u_glyph_opacity: self.u_glyph_opacity,
u_glyph_sampler: self.u_glyph_sampler,
u_blit_sampler: self.u_blit_sampler,
u_subblit_mvp: self.u_subblit_mvp,
u_subblit_sampler: self.u_subblit_sampler,
u_subblit_opacity: self.u_subblit_opacity,
u_subblit_fade_bottom: self.u_subblit_fade_bottom,
u_subblit_height_px: self.u_subblit_height_px,
u_lingrad_mvp: self.u_lingrad_mvp,
u_lingrad_lut: self.u_lingrad_lut,
u_lingrad_dir: self.u_lingrad_dir,
u_lingrad_size: self.u_lingrad_size,
u_lingrad_line_length: self.u_lingrad_line_length,
u_lingrad_radii: self.u_lingrad_radii,
u_lingrad_pad: self.u_lingrad_pad,
u_lingrad_lut_domain_min: self.u_lingrad_lut_domain_min,
u_lingrad_lut_domain_span: self.u_lingrad_lut_domain_span,
u_radgrad_mvp: self.u_radgrad_mvp,
u_radgrad_lut: self.u_radgrad_lut,
u_radgrad_center: self.u_radgrad_center,
u_radgrad_radius_frac: self.u_radgrad_radius_frac,
u_radgrad_size: self.u_radgrad_size,
u_radgrad_radii: self.u_radgrad_radii,
u_radgrad_pad: self.u_radgrad_pad,
u_radgrad_lut_domain_min: self.u_radgrad_lut_domain_min,
u_radgrad_lut_domain_span: self.u_radgrad_lut_domain_span,
u_shadow_mvp: self.u_shadow_mvp,
u_shadow_size: self.u_shadow_size,
u_shadow_padding: self.u_shadow_padding,
u_shadow_radii: self.u_shadow_radii,
u_shadow_spread: self.u_shadow_spread,
u_shadow_sigma: self.u_shadow_sigma,
u_shadow_color: self.u_shadow_color,
u_inset_mvp: self.u_inset_mvp,
u_inset_size: self.u_inset_size,
u_inset_padding: self.u_inset_padding,
u_inset_radii: self.u_inset_radii,
u_inset_spread: self.u_inset_spread,
u_inset_sigma: self.u_inset_sigma,
u_inset_offset: self.u_inset_offset,
u_inset_color: self.u_inset_color,
u_inset_ov_mvp: self.u_inset_ov_mvp,
u_inset_ov_size: self.u_inset_ov_size,
u_inset_ov_padding: self.u_inset_ov_padding,
u_inset_ov_radii: self.u_inset_ov_radii,
u_inset_ov_spread: self.u_inset_ov_spread,
u_inset_ov_sigma: self.u_inset_ov_sigma,
u_inset_ov_offset: self.u_inset_ov_offset,
u_inset_ov_color: self.u_inset_ov_color,
u_inset_ov_snapshot: self.u_inset_ov_snapshot,
u_inset_ov_canvas_size: self.u_inset_ov_canvas_size,
u_bd_h_source: self.u_bd_h_source,
u_bd_h_texel: self.u_bd_h_texel,
u_bd_h_canvas_size: self.u_bd_h_canvas_size,
u_bd_h_sigma: self.u_bd_h_sigma,
u_bd_c_mvp: self.u_bd_c_mvp,
u_bd_c_source: self.u_bd_c_source,
u_bd_c_canvas_size: self.u_bd_c_canvas_size,
u_bd_c_texel: self.u_bd_c_texel,
u_bd_c_sigma: self.u_bd_c_sigma,
u_bd_c_size: self.u_bd_c_size,
u_bd_c_padding: self.u_bd_c_padding,
u_bd_c_radii: self.u_bd_c_radii,
u_bd_c_tint: self.u_bd_c_tint,
u_bd_fh_source: self.u_bd_fh_source,
u_bd_fh_texel: self.u_bd_fh_texel,
u_bd_fh_canvas_size: self.u_bd_fh_canvas_size,
u_bd_fh_sigma: self.u_bd_fh_sigma,
u_bd_fc_mvp: self.u_bd_fc_mvp,
u_bd_fc_source: self.u_bd_fc_source,
u_bd_fc_canvas_size: self.u_bd_fc_canvas_size,
u_bd_fc_texel: self.u_bd_fc_texel,
u_bd_fc_sigma: self.u_bd_fc_sigma,
u_bd_fc_size: self.u_bd_fc_size,
u_bd_fc_padding: self.u_bd_fc_padding,
u_bd_fc_radii: self.u_bd_fc_radii,
u_bd_fc_tint: self.u_bd_fc_tint,
glyph_cache: HashMap::new(),
image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(),
clip_scissor: None,
fbo,
fbo_tex,
aux_a: None,
aux_b: None,
}
}
pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) }
/// Discard all cached gradient LUT textures. Call after a theme change
/// so stale LUTs for old palette colours are freed and rebuilt fresh.
pub fn clear_gradient_cache( &mut self )
{
// SAFETY: GL context is current (canvas invariant). Each texture in
// `gradient_lut_cache` was created through this same context in
// `gradient.rs::ensure_lut`, so deleting them through the same context
// is well-defined. `drain` consumes the entries so the same name is
// never deleted twice.
unsafe
{
for ( _, tex ) in self.gradient_lut_cache.drain()
{
self.gl.delete_texture( tex );
}
}
}
pub fn dpi_scale( &self ) -> f32 { self.dpi_scale }
pub fn set_dpi_scale( &mut self, s: f32 ) { self.dpi_scale = s; }
pub fn global_alpha( &self ) -> f32 { self.global_alpha }
pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; }
pub fn font( &self ) -> &Font { &self.font }
/// Install a theme font registry so [`Self::font_for`] can resolve
/// family+weight+style triples declared by the theme's `fonts` block.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
self.font_registry = Some( registry );
}
/// Resolve a specific font from the theme registry, falling back to the
/// canvas' default [`Self::font`] when no registry is installed or the
/// triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
self.font_registry
.as_ref()
.and_then( |r| r.resolve( family, weight, style ) )
.unwrap_or_else( || Arc::clone( &self.font ) )
}
/// Pick the right font for `ch`. Tries the primary [`Self::font`]
/// first; on a miss, delegates to the crate-private system-fonts
/// fallback chain (lazy load of the relevant Noto pack). Falls
/// back to the primary (which paints a `.notdef` box) when no
/// installed fallback covers the codepoint.
pub fn font_for_char( &self, ch: char ) -> Arc<Font>
{
if self.font.lookup_glyph_index( ch ) != 0
{
return Arc::clone( &self.font );
}
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
}
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale )
}
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
{
self.font.horizontal_line_metrics( size )
}
/// Resize the FBO and viewport. The previous color attachment is freed and
/// a fresh one of the new size is attached — frame-N pixels are dropped, so
/// the caller should expect to do a full redraw immediately after a resize.
pub fn resize( &mut self, width: u32, height: u32 )
{
if width == self.width && height == self.height { return; }
self.width = width;
self.height = height;
// SAFETY: GL context is current (canvas invariant). Sequence:
// release the old colour attachment (created via this context in
// `new`/`sub_canvas`/last `resize`), allocate a fresh one of the
// new size, swap it in for `COLOR_ATTACHMENT0` of the existing FBO,
// resize the viewport to match, and clear so the first frame after
// resize is well-defined RGBA. `self.fbo` is unchanged and remains
// valid; only its colour attachment is replaced.
unsafe
{
self.gl.delete_texture( self.fbo_tex );
self.fbo_tex = alloc_fbo_tex( &self.gl, self.version, width, height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.framebuffer_texture_2d(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
);
self.gl.viewport( 0, 0, width as i32, height as i32 );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
// Auxiliary textures were sized for the old dimensions — drop them so
// the next effect that needs them re-allocates at the new size.
self.invalidate_aux();
}
}

812
src/gles_render/shaders.rs Normal file
View File

@@ -0,0 +1,812 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GLES 2/3 shader sources used by `GlesCanvas`. All fragment shaders
//! emit premultiplied-alpha colour so they compose correctly under
//! the pipeline's default `glBlendFunc(ONE, ONE_MINUS_SRC_ALPHA)`.
//!
//! Every constant is `pub(super)` so only files inside
//! `crate::gles_render` can reach them — callers always go through
//! `GlesCanvas`'s public methods, not the raw shader source.
// Vertex shader shared by both programs (just transforms a unit quad).
// GLSL ES 1.00 — works on both GLES2 and GLES3 contexts (forward compatible
// when no `#version` directive is present).
pub( super ) const VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
uniform mat4 u_mvp;
void main()
{
v_uv = a_pos;
gl_Position = u_mvp * vec4(a_pos, 0.0, 1.0);
}
"#;
// Fragment shader for solid/rounded rects (signed-distance smoothstep at edge).
//
// `u_stroke == 0` ⇒ filled rect. The shader fills the rounded shape with
// `u_color`, antialiased over a 1-pixel band at the edge.
// `u_stroke > 0` ⇒ stroked outline of width `u_stroke`, centered on the
// rounded boundary, so outlines keep their corner radius.
//
// Distance formula is Iñigo Quílez's exact SDF for a rounded box, extended
// to per-corner radii by picking `r` per-quadrant:
// r = u_radii[ corner_index_from_sign( p - center ) ]
// q = abs(p - center) - (size/2 - r)
// d = min(max(q.x, q.y), 0) + length(max(q, 0)) - r
// `u_radii` is ordered `(tl, tr, br, bl)` clockwise from top-left and is
// uploaded as `Corners::to_uniform()`. A uniform-radii fill is the
// degenerate case where every component is equal — the per-quadrant
// branch collapses to the single-`r` formula.
//
// `u_pad` lets the caller draw the quad larger than `u_size` (used by
// `stroke_rect`, which expands the quad by `stroke/2` on each side so the
// outer half of the stroke has fragments to cover). The shader maps `v_uv`
// from the larger quad back into the original-rect space:
// p = v_uv * (size + 2*pad) - pad
// so `p` ranges `[-pad, size+pad]`. Keeping `u_size` and `u_radii` at the
// *original* values is essential — expanding them shifts the SDF zero-line
// outward and breaks the circle case (radius = size/2).
//
// Each component of `u_radii` is clamped to `min(size.x, size.y) * 0.5`
// before use. Callers frequently pass very large values (e.g.
// `theme::RADIUS = 100`) as a "please make this a pill / capsule"
// sentinel. Without the clamp, `size/2 - r` goes negative in the shorter
// dimension and the rounded-box formula degenerates — rendering an
// ellipse in the middle of the rect instead of a pill.
//
// Emits premultiplied-alpha colour. The pipeline blend is
// `(ONE, ONE_MINUS_SRC_ALPHA)`, so every shader that writes colour must
// premultiply its RGB by the final alpha. This matters for non-opaque
// fills (coverage from the SDF, translucent `u_color.a`) where straight-
// alpha output would under-saturate antialiased edges.
pub( super ) const RECT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec4 u_color;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_stroke;
uniform float u_pad;
// Per-fragment corner radius lookup. `c` is the fragment position
// relative to the rect's centre. Inside the shader, p.y grows UPWARD:
// `ortho_rect` flips the v_uv axis so v_uv.y=0 maps to the bottom edge
// of the rect in screen space and v_uv.y=1 to the top. So with `c =
// p - size/2`:
// c.x < 0, c.y > 0 → top-left → r.x
// c.x > 0, c.y > 0 → top-right → r.y
// c.x > 0, c.y < 0 → bottom-right → r.z
// c.x < 0, c.y < 0 → bottom-left → r.w
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
float r_max = max(max(u_radii.x, u_radii.y), max(u_radii.z, u_radii.w));
if (r_max <= 0.0 && u_stroke <= 0.0 && u_pad <= 0.0)
{
float a = u_color.a;
gl_FragColor = vec4(u_color.rgb * a, a);
return;
}
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px-wide AA band (half-width 1.0). A narrower (±0.5) band is
// only sampled when pixel centres happen to fall inside it — when
// the rasterizer grid aligns with the edge at subpixel ±0.5 the
// band is jumped over and the transition becomes a binary step
// (visible stair-stepping on curved edges). Doubling the band
// guarantees at least one partial-coverage sample per scanline
// regardless of alignment. The quad pad set by the caller is 1 px
// so this still fits inside the geometry at d ≤ 1.
float coverage;
if (u_stroke > 0.0)
{
float half_w = u_stroke * 0.5;
coverage = 1.0 - smoothstep(half_w - 1.0, half_w + 1.0, abs(d));
} else {
coverage = 1.0 - smoothstep(-1.0, 1.0, d);
}
float a = u_color.a * coverage;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Fragment shader for RGBA textured quads (images / icons).
//
// Flips uv.y because images are uploaded top-down (CPU convention: row 0 =
// top of source) but `glTexImage2D` puts the first row at the texture's
// lower-left. With the quad orientation produced by `ortho_rect`, sampling
// `v_uv` directly would draw the source upside-down on screen.
//
// Texture data arrives PREMULTIPLIED — `upload_rgba_texture` premuls the
// straight-alpha CPU buffer once at upload. GL_LINEAR sampling can then
// blend across texel boundaries without halo artefacts (a fully opaque
// black texel next to a fully transparent white texel interpolates to
// `( 0, 0, 0, 0.5 )` instead of the halo-producing `( 0.5, 0.5, 0.5, 0.5 )`
// of straight-alpha interpolation). Multiplying premul by uniform opacity
// preserves the invariant `rgb == rgb_straight * a`.
pub( super ) const TEX_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
void main()
{
gl_FragColor = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)) * u_opacity;
}
"##;
// Fragment shader for single-channel glyph textures with color tint. Same
// Y-flip rationale as `TEX_FRAG_SRC`. The texture is `GL_LUMINANCE`, which
// replicates the uploaded byte into `.r`, `.g`, `.b` (with `.a = 1`), so the
// coverage value is read from `.r`. We deliberately avoid `GL_ALPHA` here:
// some Mesa GLES3 paths handle the legacy alpha-only format inconsistently
// (sampled `.a` returns near-zero in glyph interiors, leaving only the
// antialias edges visible — text appears as thin faded outlines instead of
// solid strokes). `LUMINANCE` is also a legacy format but its mapping to
// `.r=.g=.b=data` is well-supported across ES2/ES3 drivers.
pub( super ) const GLYPH_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform vec4 u_color;
uniform float u_opacity;
void main()
{
float coverage = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)).r;
float a = u_color.a * coverage * u_opacity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Vertex shader for the present-blit: maps the unit quad to fullscreen NDC
// without going through an MVP. UVs are equal to a_pos so the FBO appears
// the same way up on the default framebuffer as it was rendered into the FBO
// (both use GL pixel coords with origin at bottom-left).
pub( super ) const BLIT_VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
void main()
{
v_uv = a_pos;
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
}
"#;
// Fragment shader for the present-blit: straight texture sample, no opacity.
pub( super ) const BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
void main()
{
gl_FragColor = texture2D(u_sampler, v_uv);
}
"#;
// Fragment shader for inter-FBO blits (used by `blit` to composite a sub-canvas
// back onto its parent). Reuses `VERT_SRC` so the quad is positioned via
// `u_mvp` like any other textured draw. No Y-flip: source and destination are
// both FBOs storing content in GL's native bottom-up convention, so sampling
// at `v_uv` puts the visually-top row of the source on the top of the dest.
//
// `u_fade_bottom_px` feathers the bottom edge of the blit: the last
// `u_fade_bottom_px` visible rows ramp the source alpha linearly from 1.0
// (at the inner edge of the band) to 0.0 (at the very bottom row), so a
// growing viewport does not look like a knife cut against whatever is
// behind it. `v_uv.y == 1.0` is the visually-top row of the source (FBO
// origin is bottom-left), so `v_uv.y * u_height_px` is the distance in
// source pixels from the bottom; the linear ramp falls out of dividing
// that by `u_fade_bottom_px` and clamping. With `u_fade_bottom_px == 0`
// the divide is skipped and the blit stays hard-edged. Linear (not
// smoothstep) because premultiplied output multiplied by a linear alpha
// already reads as a soft fade — smoothstep would compress the ramp into
// fewer effective rows and reintroduce a faint shoulder.
pub( super ) const SUB_BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
uniform float u_fade_bottom_px;
uniform float u_height_px;
void main()
{
vec4 c = texture2D(u_sampler, v_uv);
float fade = 1.0;
if (u_fade_bottom_px > 0.0)
{
float y_from_bottom = v_uv.y * u_height_px;
fade = clamp(y_from_bottom / u_fade_bottom_px, 0.0, 1.0);
}
gl_FragColor = c * (u_opacity * fade);
}
"#;
// Linear gradient shader. Samples a CPU-baked 1D LUT (`u_lut`, size
// `gradient_lut::LUT_SAMPLES × 1`, RGBA8, straight-alpha) along the
// CSS linear-gradient convention: the gradient line passes through the
// centre of the rect, `0°` points up, positive angles rotate clockwise.
// Stop positions outside `[0, 1]` are already baked into the LUT via
// linear extrapolation, so the shader only remaps `t` from the extended
// domain `[u_lut_domain_min, u_lut_domain_min + u_lut_domain_span]`
// into the texture's `[0, 1]` sampling range.
//
// The same SDF as `RECT_FRAG_SRC` is reused for the rounded-rect / pill
// silhouette; the coverage multiplies the fragment's alpha before
// premultiplying.
pub( super ) const LINEAR_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_dir;
uniform vec2 u_size;
uniform float u_line_length;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Gradient evaluated in rect-local pixel space. `p` already accounts
// for `u_pad` (set by the caller to expand the quad for AA), so the
// gradient direction stays anchored to the original rect even when
// the quad spills outside.
float dist = dot(c, u_dir);
float t = 0.5 + dist / u_line_length;
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Radial gradient shader. `u_center` is in box-relative fractions
// (`[0, 1]` on each axis), `u_radius_frac` is the radial extent in the
// same fractional space. `t = distance(v_uv, u_center) / u_radius_frac`
// so stops at `position == 1.0` fall exactly at the chosen radius.
pub( super ) const RADIAL_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_center;
uniform float u_radius_frac;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Centre/extent in box-relative fractions evaluated against `p`
// (which already accounts for `u_pad`), so the radial centre stays
// anchored to the original rect when the quad spills outside.
vec2 t_uv = p / u_size;
float t = distance(t_uv, u_center) / max(u_radius_frac, 1e-6);
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Outer drop shadow shader.
//
// Rather than baking a blurred shape into an intermediate texture via a
// separable Gaussian pass, this uses the analytical soft-shadow
// approximation `exp(-d² / (2σ²))` over the rounded-rect SDF. The maths:
//
// • `d` is the signed distance from the fragment to the (possibly
// spread-expanded) shape. `d < 0` is inside, `d > 0` is outside.
// • `intensity = 1.0` inside the shape, `exp(-d²/2σ²)` outside.
// • `σ = shadow.blur / 2` (CSS blur radius → Gaussian sigma).
//
// The result is visually indistinguishable from a true Gaussian blur for
// small to moderate σ. For very large σ the tail of `exp()` departs from
// a real Gaussian, at which point a real two-pass blur or a correction
// factor would be needed.
//
// Running the shadow as a single analytic pass means there's no need for
// a per-shadow FBO, no ping-pong, and no framebuffer readback: it is all
// one cheap draw call.
// Inner (inset) shadow shader.
//
// Two SDFs cooperate here:
//
// • `d_outer` — the signed distance to the surface itself. Positive
// outside, negative inside. We multiply the final intensity by the
// smooth-step coverage of this SDF so the inset never leaks past the
// shape's own silhouette.
//
// • `d_inner` — the signed distance to the shape shifted by
// `shadow.offset` and eroded by `shadow.spread`. This is the "hole"
// the inset falls into: `d_inner ≥ 0` means the pixel is on the
// shadow side of the offset edge (full intensity); `d_inner < 0`
// means the pixel is deeper into the unshadowed interior, where the
// intensity decays as `exp(-d_inner² / (2σ²))`.
//
// Together they reproduce the CSS `inset` semantics: the shadow sits
// inside the rounded rect, biased toward the side opposite `offset`,
// and fades toward the middle with a Gaussian falloff. Premul output
// matches the rest of the pipeline.
pub( super ) const SHADOW_INSET_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer SDF: the shape itself, untransformed. Used to clip the inset
// to the silhouette. 2-px AA band — see RECT_FRAG_SRC.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Inner SDF: the shape shifted by offset and eroded by spread. Its
// distance drives the Gaussian falloff. The inner radii match the
// outer per-corner shape, eroded by `u_spread` (clamped at zero).
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
// Degenerate case: spread larger than half the shape erodes it to a
// point. Guard so `half_inner` never goes negative.
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
pub( super ) const SHADOW_OUTER_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5 + vec2(u_spread);
// Per-corner shadow radius — outer shape grown uniformly by spread.
vec4 r_base = max(u_radii + vec4(u_spread), vec4(0.0));
float r = corner_radius(c, r_base);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float intensity;
if (d <= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d * d) / (2.0 * s * s));
}
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Horizontal Gaussian blur for the backdrop pipeline. Part one of the
// separable-Gaussian pair: samples the aux_a snapshot horizontally and
// writes to aux_b at the fragment's pixel position. Drawn over a
// vertically-extended surface rect so the vertical pass — which reads
// aux_b up to ±3σ away from each target pixel — has valid data wherever
// it samples. `u_canvas_size` maps `gl_FragCoord` into the aux texture,
// and the aux pair is sized to the main FBO so that mapping is trivial.
//
// Kernel: 41 taps (`RADIUS = 20`), weights computed inline as
// `exp(-i²/(2σ²))` and normalized by the sum. At σ ≈ 11 this captures
// ~93 % of the Gaussian mass — the remainder is a faint tail that is
// visually imperceptible. For σ past ~15 the kernel radius would need
// to grow or a downsample pass would be required; everything else in
// the pipeline already deals in σ and would not change.
pub( super ) const BACKDROP_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 20;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
// Vertical Gaussian blur + SDF clip + tint, the last pass of the
// backdrop pipeline. Runs on a quad matching the surface rect and
// writes to the main FBO.
//
// Per fragment: (1) computes the rounded-rect SDF coverage just like
// the rect shader so the backdrop is clipped to the surface shape with
// a 1-pixel anti-aliased edge; (2) samples `u_source` (the H-blurred
// aux_b) vertically with the same 41-tap Gaussian as the H pass; (3)
// applies the optional `u_tint` over the blurred sample using standard
// premul-over math; (4) outputs premultiplied with `alpha = coverage`.
//
// Alpha handling. The output is `(result_rgb * coverage, coverage)` —
// not `(result_rgb * coverage * result_a, coverage * result_a)`. The
// snapshot holds premultiplied content that is effectively opaque
// inside the canvas (every pixel has been written by some draw, even
// if that draw was `clear(0,0,0,0)`; the FBO is never sampled outside
// the canvas bounds). Treating the blurred sample's alpha as 1.0 at
// output time means the composite pass REPLACES the base pixel inside
// the surface shape with the tinted blurred content, rather than
// alpha-blending on top of it. That is the correct semantics for
// `backdrop-filter`: you want the original content to disappear
// entirely where the surface covers it, replaced by the blurred
// version.
//
// `fill_backdrop` runs this before outer shadows / fill / insets so
// later passes composite on top of the blurred backdrop.
pub( super ) const BACKDROP_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 20;
// Rounded-rect SDF clip. 2-px AA band — see RECT_FRAG_SRC.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
// Vertical Gaussian on the H-blurred source.
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
// Optional tint, "tint over blurred" in premul space.
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// ── Fast (low-quality) variants of the backdrop shaders ────────────────
//
// Same separable-Gaussian pipeline as the full-quality pair above, but
// with `RADIUS = 4` instead of `RADIUS = 20`. That cuts each pass from
// 41 taps to 9 — a ~4.5× reduction in fragment-shader work — and
// shrinks the snapshot region the renderer has to copy from the main
// FBO from `target ± 21` to `target ± 5` pixels. Used during motion
// via [`super::low_quality_paint`]; the static frame is painted with
// the full-quality pair.
//
// `u_sigma` is still a uniform so the CPU side can clamp it to a
// value compatible with the smaller kernel (typically ≤ 2.0), keeping
// the kernel a sensible Gaussian rather than a sharply truncated one.
// Visually this means a thinner blur band during motion, which fades
// back to the full blur on the static frame.
pub( super ) const BACKDROP_FAST_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 4;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
pub( super ) const BACKDROP_FAST_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 4;
// Rounded-rect SDF clip — identical to the full-quality variant.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// Inset shadow with CSS `Overlay` blend. Uses the same SDF dance as
// `SHADOW_INSET_FRAG_SRC` to compute `intensity` (outer-silhouette clip
// + Gaussian falloff from the offset-shifted inner SDF), then samples
// the snapshotted FBO content under the fragment and applies the
// per-channel Overlay formula:
//
// overlay(base, src) = base < 0.5 ? 2 * base * src
// : 1 - 2 * (1 - base) * (1 - src)
//
// Output is premultiplied with `mask = u_color.a * intensity` — so the
// standard `(ONE, ONE_MINUS_SRC_ALPHA)` blend on top of the main FBO
// yields `result = overlay_rgb * mask + base * (1 - mask)`, i.e. the
// Overlay effect modulated by the shadow mask, composed onto the
// original backdrop. The snapshot texture holds premultiplied content
// matching the main FBO, so we unpremultiply before applying Overlay.
//
// `u_canvas_size` is the main FBO's pixel size. `gl_FragCoord` has its
// origin at bottom-left in GLES, which matches the FBO's native
// orientation, so `gl_FragCoord.xy / u_canvas_size` gives the texture
// coordinates of the pixel we're about to write to. No Y-flip needed.
pub( super ) const SHADOW_INSET_OVERLAY_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
uniform sampler2D u_snapshot;
uniform vec2 u_canvas_size;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer silhouette clip — same as SHADOW_INSET_FRAG_SRC, 2-px AA band.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Offset-shifted inner SDF → Gaussian intensity. Per-corner inner
// radii match the outer shape eroded by `u_spread`.
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float mask = u_color.a * intensity;
// Sample the snapshot at the fragment's FBO position. The snapshot
// holds premultiplied content; divide by alpha before applying the
// straight-alpha Overlay formula. Guard alpha=0 to avoid NaNs.
vec2 snap_uv = gl_FragCoord.xy / u_canvas_size;
vec4 snap = texture2D(u_snapshot, snap_uv);
vec3 base = snap.a > 0.0 ? snap.rgb / snap.a : vec3(0.0);
vec3 src = u_color.rgb;
// Per-channel Overlay. `step(0.5, base)` yields 0 where base < 0.5
// and 1 otherwise; `mix` picks multiply vs screen accordingly.
vec3 multiply = 2.0 * base * src;
vec3 screen = 1.0 - 2.0 * (1.0 - base) * (1.0 - src);
vec3 overlay = mix(multiply, screen, step(0.5, base));
// Output premul: `(overlay * mask, mask)`. Combined with the premul
// over blend this replaces the base by `overlay` wherever mask == 1
// and leaves it untouched where mask == 0.
gl_FragColor = vec4(overlay * mask, mask);
}
"##;

176
src/gles_render/text.rs Normal file
View File

@@ -0,0 +1,176 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`GlesCanvas`]: glyph-atlas cache + per-glyph
//! draw call. Each glyph is rasterised once via fontdue, uploaded as
//! a one-off `GL_LUMINANCE` texture, and stored on the canvas; the
//! per-frame hot path picks positions and issues one draw call per
//! glyph.
//!
//! `GL_LUMINANCE` is deliberate — `GL_ALPHA` has inconsistent
//! handling across Mesa GLES3 drivers (sampled `.a` returns near-zero
//! in glyph interiors, leaving only antialias edges visible), and
//! luminance's `.r = .g = .b = data` mapping is well-supported
//! across ES2/ES3.
use std::sync::Arc;
use fontdue::Font;
use glow::HasContext;
use crate::types::{ Color, Rect };
use super::helpers::{ ortho_rect, upload_alpha_texture };
use super::{ GlesCanvas, GlyphEntry };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
fn font_id( font: &Arc<Font> ) -> usize
{
Arc::as_ptr( font ) as usize
}
impl GlesCanvas
{
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
{
self.draw_text_inner( text, x, y, size, color, None );
}
/// Draw `text` using `font` instead of the canvas default + lazy
/// system-font fallback chain. Glyphs from this font live under
/// their own atlas keys.
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
{
self.draw_text_inner( text, x, y, size, color, Some( font ) );
}
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
{
self.activate_target();
let scaled = size * self.dpi_scale;
let mut cursor_x = x;
for ch in text.chars()
{
let size_key = (scaled * 10.0) as u32;
let ( id, font_arc ): ( usize, Arc<Font> ) = match font
{
Some( f ) =>
{
if f.lookup_glyph_index( ch ) != 0
{
( font_id( f ), Arc::clone( f ) )
}
else
{
self.font_id_for_char( ch )
}
}
None => self.font_id_for_char( ch ),
};
let key = ( ch, size_key, id );
if !self.glyph_cache.contains_key( &key )
{
if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
{
self.evict_glyph_cache_half();
}
let ( metrics, bitmap ) = font_arc.rasterize( ch, scaled );
if metrics.width > 0 && metrics.height > 0
{
let tex = upload_alpha_texture( &self.gl, &bitmap, metrics.width as i32, metrics.height as i32 );
self.glyph_cache.insert( key, GlyphEntry
{
texture: tex,
metrics,
tex_w: metrics.width as i32,
tex_h: metrics.height as i32,
} );
} else {
cursor_x += metrics.advance_width;
continue;
}
}
if let Some( entry ) = self.glyph_cache.get( &key )
{
let gx = ( cursor_x + entry.metrics.xmin as f32 ).round();
let gy = ( y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round();
let dest = Rect
{
x: gx,
y: gy,
width: entry.tex_w as f32,
height: entry.tex_h as f32,
};
self.draw_glyph_texture( entry.texture, dest, color, self.global_alpha );
cursor_x += entry.metrics.advance_width;
}
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
{
let f = if font.lookup_glyph_index( ch ) != 0
{
Arc::clone( font )
}
else
{
self.font_for_char( ch )
};
f.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
fn font_id_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
fn evict_glyph_cache_half( &mut self )
{
let drop_n = self.glyph_cache.len() / 2;
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
for key in victims
{
if let Some( entry ) = self.glyph_cache.remove( &key )
{
unsafe { self.gl.delete_texture( entry.texture ); }
}
}
}
fn draw_glyph_texture( &self, texture: glow::Texture, dest: Rect, color: Color, opacity: f32 )
{
let mvp = ortho_rect( self.width, self.height, dest );
unsafe
{
self.gl.use_program( Some( self.glyph_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_glyph_mvp ), false, &mvp );
self.gl.uniform_4_f32( Some( &self.u_glyph_color ), color.r, color.g, color.b, color.a );
self.gl.uniform_1_f32( Some( &self.u_glyph_opacity ), opacity );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
self.gl.uniform_1_i32( Some( &self.u_glyph_sampler ), 0 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
}

251
src/input/dispatch.rs Normal file
View File

@@ -0,0 +1,251 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Dispatch helpers shared by the pointer and touch handlers.
//!
//! The gesture state machine returns abstract outcomes; this file is
//! where those outcomes turn into concrete side-effects on `AppData`:
//! pending-message pushes, app-level callbacks (`on_swipe_*`,
//! `on_drag_move`, `on_drop`, `on_tap`, `overlay_dismiss_msg`), and
//! the redraw / cache invalidation flags that keep the loop moving
//! after a non-Message-producing gesture.
//!
//! Splitting these into a dedicated file means pointer.rs and touch.rs
//! only carry the Wayland-event translation. A future input source
//! (stylus, gamepad) can reuse the same dispatcher by feeding the
//! state machine the same way.
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::types::Point;
use crate::widget::WidgetHandlers;
use super::gesture::{ MoveOutcome, ReleaseEvent };
impl<A: App> AppData<A>
{
/// If the press at `pos` lands on a password-toggle icon zone of
/// the widget at `idx`, push the toggle message and return
/// `true` so the caller can skip the rest of the cursor /
/// selection placement that would otherwise consume the press.
/// Returns `false` when there is no toggle on that widget or
/// the press fell outside the icon's hit area, leaving the
/// caller to dispatch the press normally.
pub( super ) fn handle_password_toggle_press
(
&mut self,
focus: SurfaceFocus,
idx: usize,
pos: Point,
) -> bool
{
let toggle_msg = self.surface( focus ).widget_rects.iter()
.find( |w| w.flat_idx == idx )
.and_then( |w| match &w.handlers
{
WidgetHandlers::TextEdit { password_toggle_msg: Some( msg ), .. } =>
{
let zone = crate::widget::text_edit::password_toggle_hit_zone( w.rect );
if zone.contains( pos ) { Some( msg.clone() ) } else { None }
}
_ => None,
} );
if let Some( msg ) = toggle_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
true
}
else
{
false
}
}
}
impl<A: App> AppData<A>
{
/// Apply the outcome of a motion event. Non-blocking side-effects
/// only: push messages, call app callbacks, set redraw flags.
pub( super ) fn apply_move_outcome
(
&mut self,
focus: SurfaceFocus,
outcome: MoveOutcome<A::Message>,
)
{
match outcome
{
MoveOutcome::Idle => {}
MoveOutcome::Drag { pos } =>
{
self.app.on_drag_move( pos.x, pos.y );
self.dirty_caches();
self.surface_mut( focus ).request_redraw();
self.main.request_redraw();
}
MoveOutcome::Slider { msg } =>
{
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
self.surface_mut( focus ).request_redraw();
}
MoveOutcome::Scroll =>
{
self.surface_mut( focus ).request_redraw();
}
MoveOutcome::Swipe { up, down, horizontal } =>
{
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
if let Some( v ) = down { self.app.on_swipe_down_progress( v ); }
if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); }
// Swipe-progress callbacks mutate app state outside
// `update`, so the cached view tree is stale.
self.dirty_caches();
self.surface_mut( focus ).request_redraw();
for ss in self.overlays.values_mut()
{
ss.request_redraw();
}
}
}
}
/// Apply the ordered list of events from a release. A single
/// release can emit more than one event (e.g. horizontal
/// fall-through followed by a vertical commit or fall-through), so
/// we walk the list and run each event's side-effects in order.
pub( super ) fn apply_release_events
(
&mut self,
focus: SurfaceFocus,
events: Vec<ReleaseEvent<A::Message>>,
)
{
for event in events
{
self.apply_release_event( focus, event );
}
}
fn apply_release_event
(
&mut self,
focus: SurfaceFocus,
event: ReleaseEvent<A::Message>,
)
{
match event
{
ReleaseEvent::Drop { pos } =>
{
if let Some( msg ) = self.app.on_drop( pos.x, pos.y )
{
self.pending_msgs.push( msg );
}
self.clear_long_press_drag();
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::SwipeLeft =>
{
if let Some( msg ) = self.app.on_swipe_left()
{
self.pending_msgs.push( msg );
}
// When the app handles the release internally (settle
// animation, state-only mutation) it returns no
// Message, so the update-driven redraw never fires.
// Kick the main surface here so `is_animating()` has a
// frame to latch onto.
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::SwipeRight =>
{
if let Some( msg ) = self.app.on_swipe_right()
{
self.pending_msgs.push( msg );
}
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::SwipeUp =>
{
if let Some( msg ) = self.app.on_swipe_up()
{
self.pending_msgs.push( msg );
}
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
for ss in self.overlays.values_mut()
{
ss.request_redraw();
ss.frame_pending = false;
}
}
ReleaseEvent::SwipeDown =>
{
if let Some( msg ) = self.app.on_swipe_down()
{
self.pending_msgs.push( msg );
}
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
for ss in self.overlays.values_mut()
{
ss.request_redraw();
ss.frame_pending = false;
}
}
ReleaseEvent::HorizontalFellThrough =>
{
// Below threshold: pulse horizontal 0 so a
// vertical-dominant gesture that drifted laterally
// does not leave the app stuck on a stale horizontal
// progress.
self.app.on_swipe_horizontal_progress( 0.0 );
self.dirty_caches();
self.main.request_redraw();
self.main.frame_pending = false;
}
ReleaseEvent::VerticalFellThrough =>
{
self.app.on_swipe_progress( 0.0 );
self.app.on_swipe_down_progress( 0.0 );
self.dirty_caches();
}
ReleaseEvent::PushMsg( msg ) =>
{
self.pending_msgs.push( msg );
}
ReleaseEvent::EmptyRelease =>
{
match focus
{
SurfaceFocus::Main =>
{
if let Some( msg ) = self.app.on_tap()
{
self.pending_msgs.push( msg );
}
}
SurfaceFocus::Overlay( id ) =>
{
if let Some( msg ) = self.overlay_dismiss_msg( id )
{
self.pending_msgs.push( msg );
}
}
}
}
}
}
}

1070
src/input/gesture.rs Normal file

File diff suppressed because it is too large Load Diff

637
src/input/keyboard.rs Normal file
View File

@@ -0,0 +1,637 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland keyboard → ltk dispatch.
//!
//! Translates `wl_keyboard` events into focus / text-insertion /
//! widget-submit actions. Does not share state with the gesture state
//! machine — keyboard tracks its own focus (`AppData::keyboard_focus`)
//! and modifier flags (`shift_pressed`, `ctrl_pressed`).
//!
//! The only cross-cutting state it reads is `SurfaceState::focused_idx`
//! (set by pointer / touch focus updates) so keyboard navigation can
//! branch on "is a widget focused?" — Return submits / Space presses /
//! typing inserts all funnel through that check.
use std::time::Duration;
use smithay_client_toolkit::seat::keyboard::
{
KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo,
};
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_keyboard::WlKeyboard, wl_surface::WlSurface },
Connection, QueueHandle,
};
use calloop::timer::{ Timer, TimeoutAction };
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::event_loop::app_data::KeyRepeatState;
use crate::tree::{ find_handlers, next_focusable_index };
/// Built-in fallback for the initial key-repeat delay when the
/// compositor does not advertise one and the app does not override
/// [`crate::App::key_repeat_delay`].
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis( 500 );
/// Built-in fallback for the inter-repeat interval when the compositor
/// does not advertise a rate. ~30 Hz, matching the GNOME / KDE default
/// for "fast" without straying into uncomfortably-twitchy territory.
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis( 33 );
impl<A: App> KeyboardHandler for AppData<A>
{
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
surface: &WlSurface,
_serial: u32,
_raw: &[u32],
_keysyms: &[Keysym],
)
{
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
self.keyboard_focus = focus;
self.surface_mut( focus ).request_redraw();
}
fn leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_surface: &WlSurface,
_serial: u32,
)
{
self.stop_key_repeat();
self.keyboard_focus = SurfaceFocus::Main;
}
fn press_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
let focus = self.keyboard_focus;
// Raw observer hook (e.g. forwarding to an embedded WPE view).
// Fires before the focus-aware dispatch.
self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed );
// A new press always cancels any in-flight repeat — the user
// has either released the previous key or is pressing a
// different one. Either way, the prior timer's keysym should
// not keep firing.
self.stop_key_repeat();
self.dispatch_key( focus, event.clone() );
self.start_key_repeat( focus, event );
}
fn release_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed );
// Only stop if the released key matches the one currently
// repeating; releasing a non-repeating key (e.g. a shift that
// snuck through, or any key we never armed) leaves the timer
// untouched.
if let Some( ref state ) = self.key_repeat
{
if state.event.keysym == event.keysym
{
self.stop_key_repeat();
}
}
}
fn update_modifiers(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
modifiers: Modifiers,
_raw_modifiers: RawModifiers,
_layout: u32,
)
{
self.shift_pressed = modifiers.shift;
self.ctrl_pressed = modifiers.ctrl;
}
fn update_repeat_info(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
info: RepeatInfo,
)
{
match info
{
RepeatInfo::Repeat { rate, delay } =>
{
self.compositor_repeat_rate = rate.get();
self.compositor_repeat_delay = delay;
}
RepeatInfo::Disable =>
{
self.compositor_repeat_rate = 0;
self.compositor_repeat_delay = 0;
self.stop_key_repeat();
}
}
}
fn repeat_key(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_keyboard: &WlKeyboard,
_serial: u32,
event: KeyEvent,
)
{
// Compositor-driven repeat (wl_keyboard v10). When the
// compositor takes the repeat job we just re-dispatch — and
// suppress our internal timer to avoid double-firing.
self.stop_key_repeat();
let focus = self.keyboard_focus;
self.dispatch_key( focus, event );
}
}
impl<A: App> AppData<A>
{
/// Run the same dispatch logic that the Wayland press-key handler
/// runs, but without the trait-callback signature — used both by
/// the trait method and by the key-repeat timer.
fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
// `set_focus` (Tab handling) needs a `QueueHandle`. Cloning is
// cheap (an `Arc`-equivalent under the hood) and avoids
// threading the qh through every helper.
let qh = self.qh.clone();
let qh = &qh;
let focused = self.surface( focus ).focused_idx;
match event.keysym
{
Keysym::BackSpace =>
{
if focused.is_some() { self.handle_backspace( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Delete =>
{
// Supr / Forward-Delete: same shape as Backspace but
// removes the char *after* the cursor. When no widget
// is focused, the keysym bubbles to the app.
if focused.is_some() { self.handle_delete_forward( focus ); }
else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Return | Keysym::KP_Enter =>
{
// Enter targets, in order: (a) the focused widget's submit
// message (TextEdit), (b) its press message (Button etc),
// (c) the press message of the keyboard-hovered list item
// in the topmost scroll. The hovered fallback lets users
// confirm a combo / list choice with the keyboard after
// arrow-navigating to it without ever taking widget focus.
//
// Multiline text-input override: when the focused widget
// is a `text_edit().multiline( true )`, Enter inserts a
// literal `\n` into the buffer instead of submitting, so
// the user can compose paragraphs.
let is_multi = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_multiline_text_input() ) ).unwrap_or( false );
if is_multi
{
self.handle_text_insert( focus, "\n" );
} else {
let target = focused.or( self.surface( focus ).hovered_idx );
if let Some( idx ) = target
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::Down | Keysym::Up =>
{
// When a text input is focused, Up/Down move the cursor
// between lines first. They only fall through to list
// hover-navigation (combo / scrollable list) when the
// cursor was already on the topmost / bottommost line —
// that lets a user keyboard-step out of a multiline
// `text_edit` into surrounding navigable items without
// changing focus first.
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let reverse = event.keysym == Keysym::Up;
let extend = self.shift_pressed;
let consumed = if is_text
{
if reverse { self.handle_cursor_up( focus, extend ) }
else { self.handle_cursor_down( focus, extend ) }
} else { false };
if !consumed && !self.move_keyboard_hover( focus, reverse )
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::Left | Keysym::Right =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let extend = self.shift_pressed;
if is_text
{
if event.keysym == Keysym::Left
{
self.handle_cursor_left( focus, extend );
} else {
self.handle_cursor_right( focus, extend );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
Keysym::Home =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
}
Keysym::End =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
}
Keysym::a | Keysym::A if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_select_all( focus ); }
}
Keysym::c | Keysym::C if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_copy( focus ); }
}
Keysym::x | Keysym::X if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cut( focus ); }
}
Keysym::v | Keysym::V if self.ctrl_pressed =>
{
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_paste( focus ); }
}
Keysym::Tab | Keysym::ISO_Left_Tab =>
{
let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed;
let ss = self.surface( focus );
let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse );
if let Some( next_idx ) = next_idx
{
self.set_focus( focus, Some( next_idx ), qh );
}
}
Keysym::Escape =>
{
// Esc peels off transient UI one layer at a time.
// Order: (1) open xdg-popup overlays → dismiss them;
// (2) context menu → close it; (3) active selection in
// a focused text input → collapse to cursor; (4) any
// laid-out pressable carrying an `on_escape` message
// (typically the topmost `dialog`'s cancel) → fire
// that; (5) otherwise → drop focus and let the app see
// Esc.
if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() )
{
self.dismiss_all_popups();
} else if self.surface( focus ).context_menu.is_some()
{
self.hide_context_menu( focus );
} else if self.collapse_selection_if_any( focus )
{
// Selection collapsed — keep focus, no app msg.
} else if let Some( msg ) = self.surface( focus ).widget_rects.iter()
.rev()
.find_map( |w| w.handlers.escape_msg() )
{
self.pending_msgs.push( msg );
} else {
self.set_focus( focus, None, qh );
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
Keysym::space =>
{
if let Some( idx ) = focused
{
let press = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.press_msg() );
if let Some( msg ) = press
{
self.pending_msgs.push( msg );
} else {
// Focused widget is a TextEdit (or has no on_press) — insert space as text
self.handle_text_insert( focus, " " );
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
_ =>
{
if self.ctrl_pressed
{
if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
} else if focused.is_some()
{
if let Some( txt ) = event.utf8
{
if !txt.is_empty() && txt.chars().all( |c| !c.is_control() )
{
self.handle_text_insert( focus, &txt );
}
}
} else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed )
{
self.pending_msgs.push( msg );
}
}
}
self.surface_mut( focus ).request_redraw();
}
/// Compute the effective initial repeat delay — app override wins,
/// then compositor info, then a built-in default.
pub( crate ) fn effective_repeat_delay( &self ) -> Duration
{
if let Some( d ) = self.app.key_repeat_delay() { return d; }
if self.compositor_repeat_delay > 0
{
Duration::from_millis( self.compositor_repeat_delay as u64 )
} else {
DEFAULT_REPEAT_DELAY
}
}
/// Compute the effective inter-repeat interval. Same precedence as
/// [`Self::effective_repeat_delay`]: app override → compositor →
/// built-in default. Returns `None` when repeat is disabled by the
/// active source (compositor `RepeatInfo::Disable` with no app
/// override, or an app override of `Some(Duration::ZERO)`).
pub( crate ) fn effective_repeat_interval( &self ) -> Option<Duration>
{
if let Some( d ) = self.app.key_repeat_interval()
{
if d.is_zero() { return None; }
return Some( d );
}
if self.compositor_repeat_rate == 0
{
// Compositor explicitly disabled repeat, app did not
// override — fall back to a built-in default rather than
// disabling, because most environments where the
// compositor reports 0 also fail to send the event in
// the first place. The default keeps the feel close to
// what people expect from a desktop toolkit.
return Some( DEFAULT_REPEAT_INTERVAL );
}
let ms = ( 1000 / self.compositor_repeat_rate ).max( 1 );
Some( Duration::from_millis( ms as u64 ) )
}
/// Schedule a key-repeat timer for the given event. No-op when the
/// app's [`crate::App::key_repeats`] gate returns `false` for this
/// keysym, when repeat is disabled, or when the calloop timer
/// insertion fails (in which case held keys simply do not repeat).
pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyEvent )
{
if !self.app.key_repeats( event.keysym ) { return; }
let interval = match self.effective_repeat_interval()
{
Some( i ) => i,
None => return,
};
let delay = self.effective_repeat_delay();
let event_for_timer = event.clone();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
data.dispatch_key( focus, event_for_timer.clone() );
TimeoutAction::ToDuration( interval )
} );
match token
{
Ok( token ) => { self.key_repeat = Some( KeyRepeatState { event, token } ); }
Err( _ ) => {}
}
}
/// Cancel any active key-repeat timer.
pub( crate ) fn stop_key_repeat( &mut self )
{
if let Some( state ) = self.key_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
/// Schedule a button-press repeat timer. The runtime fires the
/// `on_press` message *immediately* on press too — this fn only
/// arms the held-down repeat path; the first fire happens at
/// the call site so a quick tap still registers as a single
/// press.
///
/// Each timer tick re-reads the live `on_press` from the
/// current widget tree (via the snapshotted `flat_idx`) rather
/// than replaying a captured message. This is what makes
/// stepper-style buttons work: a stepper builds an `on_press`
/// like `"go to value + 5"` at view-build time, so replaying
/// the press-time snapshot after the first fire would re-issue
/// the same target and the value would freeze. By reading
/// `on_press` afresh each tick we pick up the new target the
/// view rebuilt with the updated value.
///
/// No-op when [`Self::effective_repeat_interval`] reports
/// repeat disabled (zero rate, app override of
/// `Duration::ZERO`). Self-cancels on the first tick where the
/// widget at `idx` no longer exists or no longer carries a
/// press message.
pub( crate ) fn start_button_repeat( &mut self, focus: SurfaceFocus, idx: usize )
{
// Cancel any pre-existing repeat first — only one button can
// be in repeat mode at a time, and a fresh press should
// supersede a previous one.
self.stop_button_repeat();
// Button repeat ticks at a fixed 120 ms (~8 Hz). The keyboard
// repeat interval is ~33 ms (30 Hz), which suits cursor /
// character entry but is too aggressive for pointer steppers
// — a date / time picker would walk a full minute in under
// two seconds. 120 ms is fast enough to ramp through values,
// slow enough that the user can still release on the value
// they want.
if matches!( self.effective_repeat_interval(), None ) { return; }
let interval = std::time::Duration::from_millis( 120 );
let delay = self.effective_repeat_delay();
let timer = Timer::from_duration( delay );
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
let live_msg = crate::tree::find_handlers(
&data.surface( focus ).widget_rects,
idx,
)
.and_then( |h| h.press_msg() );
match live_msg
{
Some( m ) =>
{
data.pending_msgs.push( m );
TimeoutAction::ToDuration( interval )
}
None =>
{
// Widget gone (view restructured) or no longer
// has an on_press → drop ourselves so the timer
// does not fire forever against a stale slot.
data.button_repeat = None;
TimeoutAction::Drop
}
}
} );
if let Ok( token ) = token
{
self.button_repeat = Some( crate::event_loop::app_data::ButtonRepeatState { token } );
}
}
/// Cancel any active button-press repeat timer.
pub( crate ) fn stop_button_repeat( &mut self )
{
if let Some( state ) = self.button_repeat.take()
{
self.loop_handle.remove( state.token );
}
}
/// Step the topmost scroll's `hovered_idx` one item up or down with
/// the keyboard. Returns `true` when the move was applied (a scroll
/// with at least one navigable item was on screen and the new item
/// is different from the current hover) so the caller can fall
/// through to the application's own `on_key` only on a no-op.
///
/// The "topmost scroll" is the last entry in `scroll_rects`, which
/// matches Stack-overlay layout order: a popup pushed after the
/// main content sits above it. Auto-scrolls the new item into view
/// by adjusting `scroll_offsets[scroll_idx]`.
pub( crate ) fn move_keyboard_hover( &mut self, focus: SurfaceFocus, reverse: bool ) -> bool
{
// Find the topmost scroll that has a navigable item list.
let scroll_meta = {
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find_map( |( rect, idx )|
{
ss.scroll_navigable_items.get( idx )
.filter( |list| !list.is_empty() )
.map( |list| ( *rect, *idx, list.clone() ) )
} )
};
let Some( ( scroll_rect, scroll_idx, items ) ) = scroll_meta else { return false; };
let current = self.surface( focus ).hovered_idx;
let pos = current.and_then( |h| items.iter().position( |( i, _, _ )| *i == h ) );
let next_pos = match ( reverse, pos )
{
( false, None ) => 0,
( false, Some( p ) ) => ( p + 1 ).min( items.len() - 1 ),
( true, None ) => items.len() - 1,
( true, Some( p ) ) => p.saturating_sub( 1 ),
};
let ( new_idx, content_y, content_h ) = items[ next_pos ];
// Auto-scroll to bring the new item fully into view. Item Y is
// in pre-offset content coordinates, so the offset that places
// the item flush with the top of the viewport is `content_y`,
// and flush with the bottom is `content_y + content_h - viewport_h`.
let viewport_h = scroll_rect.height;
let current_offset = self.surface( focus )
.scroll_offsets.get( &scroll_idx )
.copied().unwrap_or( 0.0 );
let new_offset = if content_y < current_offset
{
content_y
}
else if content_y + content_h > current_offset + viewport_h
{
( content_y + content_h - viewport_h ).max( 0.0 )
}
else
{
current_offset
};
let ss = self.surface_mut( focus );
ss.hovered_idx = Some( new_idx );
ss.scroll_offsets.insert( scroll_idx, new_offset );
ss.request_redraw();
true
}
}

36
src/input/mod.rs Normal file
View File

@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Input-event layer.
//!
//! Three Wayland input sources — `wl_keyboard`, `wl_pointer`,
//! `wl_touch` — land in their respective submodules and translate
//! into ltk-level actions. Keyboard is standalone (it drives focus
//! and text insertion, no gesture lifecycle); pointer and touch share
//! the [`gesture::GestureState`] state machine so press → move →
//! release logic (long-press, slider drag, scroll, swipe commit, tap,
//! drop) is written once and fed by both sources.
//!
//! The [`dispatch`] submodule turns gesture outcomes into concrete
//! side-effects on [`AppData`](crate::event_loop::AppData): pending
//! messages, app callbacks, surface redraws, cache invalidation. It
//! is `pub( super )`-scoped so pointer.rs and touch.rs can call it
//! without exposing the helpers at the crate root.
//!
//! With [`GestureState`] owning the press / move / release lifecycle
//! and [`dispatch`] owning the side-effects, the Wayland-facing
//! handlers stay small and a hypothetical new input source (stylus,
//! gamepad) can plug in by feeding the state machine the same way.
pub( crate ) mod gesture;
pub( crate ) mod keyboard;
pub( crate ) mod pointer;
pub( crate ) mod touch;
pub( crate ) mod dispatch;
// `GestureState` is the only type consumers outside `input/` need —
// `AppData` stores one per `SurfaceState` and the long-press deadline
// poller reaches through it. The rest (`MoveOutcome`, `PressOutcome`,
// `ReleaseEvent`, `SwipeConfig`) stay at `super::gesture::*` because
// only the sibling modules in `input/` touch them.
pub use gesture::GestureState;

495
src/input/pointer.rs Normal file
View File

@@ -0,0 +1,495 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland pointer → ltk dispatch.
//!
//! Each `wl_pointer` event (motion / press / release / axis) translates
//! into a call on the surface's [`GestureState`](super::gesture::GestureState)
//! plus the follow-up side-effects that can only live at the
//! [`AppData`] level (pending-message push, surface redraw, dirty-cache
//! invalidation, window move). The pointer handler adds three things
//! touch does not have:
//!
//! * **Hover tracking** — motion updates `SurfaceState::hovered_idx`
//! before delegating the motion to the gesture machine, since the
//! hovered widget drives visual state independent of whether a press
//! is active.
//! * **Title-bar interaction** — a press inside the client-side title
//! bar either closes the window (close button hit) or initiates a
//! compositor-driven window move. Neither path goes through the
//! gesture machine.
//! * **Wheel / axis scroll** — routed directly into the per-viewport
//! `scroll_offsets` map; axis events do not have a press/release
//! lifecycle so they bypass the state machine entirely.
use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind, PointerHandler };
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_pointer, wl_pointer::WlPointer },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::tree::{ find_handlers, find_widget_at };
use crate::widget::WidgetHandlers;
use super::gesture::SwipeConfig;
impl<A: App> PointerHandler for AppData<A>
{
fn pointer_frame(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_pointer: &WlPointer,
events: &[PointerEvent],
)
{
for event in events
{
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
self.pointer_focus = focus;
match event.kind
{
PointerEventKind::Enter { serial } =>
{
// Pointer just entered our surface — capture the
// serial that wp_cursor_shape_device_v1::set_shape
// requires, then push the initial cursor shape
// *unconditionally*. Resetting `current_cursor_shape`
// to `None` is what forces the dispatch: the
// compositor was showing whatever the previous
// client asked for (e.g. a text I-beam from a
// terminal under our window), and we must claim
// the cursor for our surface even when the target
// equals what we last pushed.
self.last_pointer_enter_serial = serial;
self.current_cursor_shape = None;
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Motion { .. } =>
{
let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pp;
self.app.on_pointer_move( pp.x, pp.y );
// Hover tracking — pointer-only (touch has no hover).
// Runs before the gesture motion so the cache-dirty
// below picks up any hover-dependent redraw request.
let new_hover = find_widget_at( &self.surface( focus ).widget_rects, pp );
let old_hover = self.surface( focus ).hovered_idx;
if new_hover != old_hover
{
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
let ss = self.surface_mut( focus );
ss.hovered_idx = new_hover;
if redraw { ss.needs_redraw = true; }
}
// Mouse drag-promotion: a left-button press whose hit
// widget carries `on_drag_start` should arm a drag as
// soon as the cursor moves past the threshold, without
// waiting for the touch hold timer. Touch keeps its
// hold-then-drag path because scroll / swipe gestures
// need the in-between motion budget; mouse has a
// dedicated right-click for the menu so left-button
// can be drag-only.
//
// Threshold of 24 px (logical) sits comfortably above
// the gesture machine's 6 px long-press cancel
// tolerance and above crustace's 16 px drag-commit
// threshold, so by the time we promote the next
// motion sample is already past the app's commit
// distance and drag mode latches without flashing
// any half-state.
//
// Promotion is synchronous (`self.app.update(...)`
// directly) so the app's drag state is armed BEFORE
// the `on_drag_move` call below runs — otherwise the
// seed coords land on a `dragging_item = None`
// shell and get lost. We pay the cost of bypassing
// `invalidate_after` for this one msg, but the next
// frame will repaint everything anyway because the
// drag is in flight.
let promote = {
let ss = self.surface( focus );
match ( ss.gesture.long_press_origin, ss.gesture.drag_start_msg.is_some() )
{
( Some( o ), true ) => ( pp.x - o.x ).hypot( pp.y - o.y ) > 24.0,
_ => false,
}
};
if promote
{
let ( ds_msg, origin ) = {
let ss = self.surface_mut( focus );
let m = ss.gesture.drag_start_msg.take().expect( "promote checked is_some" );
let o = ss.gesture.long_press_origin.expect( "promote checked Some(origin)" );
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
ss.gesture.long_press_msg = None;
ss.gesture.long_press_text_idx = None;
ss.gesture.long_press_fired = true;
ss.request_redraw();
( m, o )
};
self.app.update( ds_msg );
self.app.on_drag_move( origin.x, origin.y );
self.dirty_caches();
self.stop_button_repeat();
}
// `global_drag` must be sampled AFTER the promotion
// above — promotion flips `long_press_fired` and we
// want the gesture machine to take the drag branch
// for the same motion event that triggered the
// promotion.
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
// Drag-to-select inside a TextEdit. Runs after the
// gesture machine so the gesture's "did we leave
// the press's hit rect?" reasoning still applies
// to the press itself; for text fields the answer
// is "fine, keep selecting" because we only widen
// the selection while the pointer is still inside
// the same widget rect.
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
if let Some( idx ) = pressed_text
{
self.handle_text_pointer_drag( focus, idx, pp );
}
// Cursor shape: hover may have changed → push the
// new shape to the compositor (no-op when
// unchanged).
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Press { button: 0x111, .. } =>
{
// Right-click: the desktop equivalent of a touch
// long-press. Three cases on the press target:
//
// * Widget with `on_long_press` (Button or
// Pressable that opted in) — fire the message.
// The drag-arm slot (`on_drag_start`) is NOT
// consumed: right-click never enters drag mode,
// so an icon's context menu opens but no drag
// is armed.
// * TextEdit with no user `on_long_press` — open
// the built-in Copy / Cut / Paste menu near the
// click. Selection is preserved (we deliberately
// skip `handle_text_pointer_down`) so the common
// "select text → right-click → Copy" flow works.
// * Anywhere else — dismiss any already-open menu.
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
let hit_idx = find_widget_at( &self.surface( focus ).widget_rects, pos );
let lp_msg = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.long_press_msg() ) );
if let Some( msg ) = lp_msg
{
self.pending_msgs.push( msg );
self.surface_mut( focus ).request_redraw();
} else {
let is_text = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text
{
let idx = hit_idx.unwrap();
if self.surface( focus ).focused_idx != Some( idx )
{
self.set_focus( focus, hit_idx, qh );
}
self.show_context_menu( focus, idx, pos );
} else {
self.hide_context_menu( focus );
}
}
}
PointerEventKind::Press { button: 0x110, serial, .. } =>
{
self.last_pointer_serial = serial;
self.last_input_serial = serial;
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
}
// Built-in context menu intercepts the press
// before the regular gesture machine. Either an
// item activates (Copy / Cut / Paste) or the
// click is outside the menu and dismisses it —
// in both cases we consume the event.
if self.surface( focus ).context_menu.is_some()
{
if self.handle_context_menu_press( focus, pos )
{
continue;
}
}
// Client-side title bar interaction — pointer-only
// (touch never hits a titlebar; layer-shell surfaces
// have titlebar_height == 0).
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let tb_h = self.surface( focus ).titlebar_height * sf;
if tb_h > 0.0 && pos.y < tb_h
{
let close_rect = self.surface( focus ).titlebar_close_rect;
if close_rect.contains( pos )
{
if self.app.on_close_requested()
{
self.exit_requested = true;
}
return;
}
}
// Resize-edge interception. Wins over the titlebar
// drag-move (top-left / top-right corners overlap
// the titlebar) and over the gesture machine.
if let Some( edge ) = self.resize_edge_under_pointer( focus )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.resize( &seat, serial, edge );
}
}
continue;
}
if tb_h > 0.0 && pos.y < tb_h
{
// Drag to move the window.
if matches!( focus, SurfaceFocus::Main )
{
if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
window.move_( &seat, serial );
}
}
}
continue;
}
// Past every chrome interception — surface the
// press to the app so embeddings (e.g. an
// embedded WPE WebView) see real button-down
// events that were not consumed by the window
// frame.
self.app.on_pointer_button( pos.x, pos.y, true );
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
// Mark this gesture as mouse-driven so the
// gesture machine's 6 px stray-cancel skips
// the drag-start / long-press slots — mouse
// motion is intentional and should never
// drop the candidate before the pointer-side
// promotion at 24 px gets to fire.
ss.gesture.mouse_press = true;
ss.needs_redraw = true;
result
};
self.set_focus( focus, outcome.hit_idx, qh );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
}
// Press-and-hold repeat: when the hit is a button
// that opted into `.repeating( true )`, fire the
// `on_press` message immediately and arm the
// repeat timer. The release handler in
// `gesture.rs` knows to suppress the regular tap-
// on-release fire for repeating buttons so a
// quick click still counts as exactly one press.
// The timer re-reads `on_press` from the live
// widget tree on every tick — see
// `start_button_repeat` for why.
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
} else { None }
};
if let Some( msg ) = immediate
{
self.pending_msgs.push( msg );
self.start_button_repeat( focus, idx );
}
}
// Click-to-position the text cursor when the press
// landed on a TextEdit. `set_focus` above moves the
// cursor to the end of the value; this overrides
// it with the byte offset under the pointer and
// collapses the selection there so a subsequent
// drag widens the selection from the click point.
//
// Double-click on a TextEdit selects the word
// under the cursor instead of just positioning.
if let Some( idx ) = outcome.hit_idx
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
// Eye icon hit on a password field
// short-circuits the text-edit
// dispatch — fire the toggle msg and
// skip cursor placement.
if self.handle_password_toggle_press( focus, idx, pos )
{
let _ = self.note_press_for_double_click( pos );
}
else
{
let is_double = self.note_press_for_double_click( pos );
if is_double
{
self.handle_text_select_word( focus, idx, pos );
} else {
self.handle_text_pointer_down( focus, idx, pos );
}
}
} else {
let _ = self.note_press_for_double_click( pos );
}
} else {
let _ = self.note_press_for_double_click( pos );
}
// Slider press → drag may have started; refresh
// the cursor shape so it switches to `Grabbing`.
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Release { button: 0x110, .. } =>
{
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
self.app.on_pointer_button( pos.x, pos.y, false );
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
// Cancel any held-button repeat — the press is
// over, so the timer no longer has anything to
// fire against.
self.stop_button_repeat();
// Slider drag (if any) just ended — cursor reverts
// from `Grabbing` to whatever the hovered widget
// asks for.
self.dispatch_cursor_shape( focus );
}
PointerEventKind::Axis { horizontal, vertical, source, .. } =>
{
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
let scroll_idx_opt =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
.find( |( r, _ )| r.contains( pos ) )
.map( |( _, idx )| *idx )
};
if let Some( scroll_idx ) = scroll_idx_opt
{
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let step = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
*entry = ( *entry + step ).max( 0.0 );
ss.request_redraw();
} else {
// No LTK scroll viewport under the cursor —
// surface the raw axis to the app so embedded
// content (e.g. a WPE view) can handle scrolling
// itself.
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
_ => 1.0,
};
let dx = horizontal.absolute as f32 * multiplier;
let dy = vertical.absolute as f32 * multiplier;
self.app.on_pointer_axis( pos.x, pos.y, dx, dy );
}
}
_ => {}
}
}
}
}
fn hover_affects_paint<Msg: Clone>(
widget_rects: &[crate::widget::LaidOutWidget<Msg>],
idx: Option<usize>,
) -> bool
{
idx.and_then( |i| find_handlers( widget_rects, i ) )
.map( |h| !h.is_slider() )
.unwrap_or( false )
}
/// Pointer-side helpers on `AppData`. Split out so touch can call the
/// same swipe-config factory; `apply_*` helpers live in `dispatch.rs`
/// because they are shared.
impl<A: App> AppData<A>
{
/// Snapshot the swipe thresholds + surface dimensions into a
/// [`SwipeConfig`] for the gesture machine. Called once per
/// motion / release event.
pub( super ) fn swipe_config( &self, focus: SurfaceFocus ) -> SwipeConfig
{
let ss = self.surface( focus );
SwipeConfig
{
up_thresh: self.app.swipe_threshold(),
down_thresh: self.app.swipe_down_threshold(),
down_edge: self.app.swipe_down_edge(),
horizontal_thresh: self.app.swipe_horizontal_threshold(),
surface_width: ss.physical_width(),
surface_height: ss.physical_height(),
}
}
}

223
src/input/touch.rs Normal file
View File

@@ -0,0 +1,223 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Wayland touch → ltk dispatch.
//!
//! `wl_touch` down / up / motion map directly onto the three
//! lifecycle methods of [`GestureState`](super::gesture::GestureState),
//! same as pointer press / release / motion. Touch has no hover and
//! no scroll-axis event, so this file is shorter than `pointer.rs`;
//! the two handlers share `apply_move_outcome` /
//! `apply_release_events` in `dispatch.rs`.
//!
//! The only touch-specific state is `AppData::touch_focus`, a
//! `HashMap<touch_id, SurfaceFocus>` so a finger that landed on an
//! overlay continues to route to that overlay even if it drifts over
//! the main surface. Multi-finger tracking is not yet modelled — the
//! gesture machine is single-gesture; a second finger arriving while
//! the first is pressed overwrites the same slot. Good enough for
//! sliders, swipes and taps; a proper multi-touch rewrite is a
//! separate refactor.
use smithay_client_toolkit::seat::touch::TouchHandler;
use smithay_client_toolkit::reexports::client::
{
protocol::{ wl_surface::WlSurface, wl_touch::WlTouch },
Connection, QueueHandle,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
use crate::tree::find_handlers;
impl<A: App> TouchHandler for AppData<A>
{
fn down(
&mut self,
_conn: &Connection,
qh: &QueueHandle<Self>,
_touch: &WlTouch,
serial: u32,
_time: u32,
surface: WlSurface,
id: i32,
position: ( f64, f64 ),
)
{
self.last_input_serial = serial;
let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main );
self.touch_focus.insert( id, focus );
let pos = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pos;
if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty()
{
self.dismiss_main_outside_popups( pos );
}
// Built-in context menu intercepts the touch before the
// regular gesture machine — same logic as the pointer path.
if self.surface( focus ).context_menu.is_some()
{
if self.handle_context_menu_press( focus, pos )
{
return;
}
}
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
ss.needs_redraw = true;
result
};
self.set_focus( focus, outcome.hit_idx, qh );
if let Some( msg ) = outcome.initial_slider_msg
{
self.pending_msgs.push( msg );
}
// Press-and-hold repeat — same wiring as the pointer path.
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
if matches!( handlers, Some( crate::widget::WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
} else { None }
};
if let Some( msg ) = immediate
{
self.pending_msgs.push( msg );
self.start_button_repeat( focus, idx );
}
}
// Click-to-position the text cursor for touch presses too,
// and double-tap selects the word under the press.
if let Some( idx ) = outcome.hit_idx
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
// Eye icon hit on a password field short-circuits
// the text-edit dispatch — fire the toggle msg and
// skip cursor placement.
if self.handle_password_toggle_press( focus, idx, pos )
{
let _ = self.note_press_for_double_click( pos );
}
else
{
let is_double = self.note_press_for_double_click( pos );
if is_double
{
self.handle_text_select_word( focus, idx, pos );
} else {
self.handle_text_pointer_down( focus, idx, pos );
}
}
} else {
let _ = self.note_press_for_double_click( pos );
}
} else {
let _ = self.note_press_for_double_click( pos );
}
}
fn up(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_serial: u32,
_time: u32,
id: i32,
)
{
let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main );
// Touch-up does not carry a position in wl_touch — the last
// motion's position is the release point.
let pos = self.pointer_pos;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
self.stop_button_repeat();
}
fn motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_time: u32,
id: i32,
position: ( f64, f64 ),
)
{
let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main );
let pp = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pp;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
// Drag-to-select inside a TextEdit (touch path).
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
if let Some( idx ) = pressed_text
{
self.handle_text_pointer_drag( focus, idx, pp );
}
}
fn shape(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_major: f64,
_minor: f64,
) {}
fn orientation(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_touch: &WlTouch,
_id: i32,
_orientation: f64,
) {}
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
{
self.touch_focus.clear();
// The compositor is stealing every active touch — drop all
// in-flight gesture state across every surface.
let clear = |ss: &mut SurfaceState<A::Message>| { ss.gesture.on_cancel(); };
clear( &mut self.main );
for ss in self.overlays.values_mut()
{
clear( ss );
}
self.stop_button_repeat();
}
}

359
src/layout/column.rs Normal file
View File

@@ -0,0 +1,359 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// A vertical layout container.
///
/// Children are arranged top-to-bottom with optional spacing and padding.
/// Spacers absorb remaining vertical space, enabling push-to-bottom layouts.
///
/// ```rust,no_run
/// # use ltk::{ button, column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg { Ok }
/// # fn _ex() -> Element<Msg> {
/// column()
/// .padding( 24.0 )
/// .spacing( 12.0 )
/// .push( text( "Title" ) )
/// .push( spacer() )
/// .push( button( "OK" ).on_press( Msg::Ok ) )
/// .into()
/// # }
/// ```
pub struct Column<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
pub align_center_x: bool,
pub center_y: bool,
pub max_width: Option<f32>,
pub fit_content: bool,
}
impl<Msg: Clone> Column<Msg>
{
pub fn new() -> Self
{
Self
{
children: Vec::new(),
spacing: 8.0,
padding: 16.0,
align_center_x: true,
center_y: false,
max_width: None,
fit_content: false,
}
}
/// Append a child widget or layout.
pub fn push( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.children.push( e.into() );
self
}
/// Set the vertical gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing = s;
self
}
/// Set the padding (all sides) in pixels. Default: `16.0`.
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// When `true` (default), children are centered horizontally.
pub fn align_center_x( mut self, c: bool ) -> Self
{
self.align_center_x = c;
self
}
/// When `true`, center the content block vertically (only when no spacers are present).
pub fn center_y( mut self, c: bool ) -> Self
{
self.center_y = c;
self
}
/// Limit the content width in pixels. The column still reports `max_width` as
/// its preferred width so the parent allocates the full available rect.
pub fn max_width( mut self, w: f32 ) -> Self
{
self.max_width = Some( w );
self
}
/// Report the intrinsic content width as preferred width instead of filling
/// the available `max_width`. Use this when the column represents a card
/// or widget meant to sit side-by-side with other children inside a
/// [`Row`](crate::layout::row::Row) — without this flag, two columns in a
/// row each claim the full row width and overflow their siblings.
///
/// The preferred width is computed as the max of children's preferred
/// widths plus padding, capped by the external `max_width` the parent
/// offers and by any `max_width` setting on the column itself.
pub fn fit_content( mut self ) -> Self
{
self.fit_content = true;
self
}
fn inner_w( &self, available: f32 ) -> f32
{
let w = available - self.padding * 2.0;
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
}
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
{
// Spacers contribute 0 to natural height; spacing still applies between all children.
self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ),
other => other.preferred_size( inner_w, canvas ).1,
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let inner_w = self.inner_w( max_width );
let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0;
let w = if self.fit_content
{
// "Filler" widgets (Spacer, Separator, Scroll, ProgressBar, Slider,
// Toggle, TextEdit) all report `max_width` as their preferred width:
// they stretch across whatever rect the parent allocates. Including
// them when picking the intrinsic content width would claim
// `max_width` and defeat the flag, so skip them — only content-sized
// children (Text, Button, Image, nested fit-content Columns/Rows)
// drive the natural width.
let content_w = self.children.iter()
.map( |c| match c
{
Element::Spacer( _ ) => 0.0,
Element::Separator( _ ) => 0.0,
Element::Scroll( _ ) => 0.0,
Element::ProgressBar( _ ) => 0.0,
Element::Slider( _ ) => 0.0,
// TextEdit defaults to claiming `max_width`, but
// a field built with `.fixed_width( w )` reports
// a pinned natural size — let those through so a
// numeric digit field inside a `fit_content`
// stepper column can drive the column's width.
Element::TextEdit( t ) => if t.fixed_width.is_some()
{
t.preferred_size( inner_w, canvas ).0
} else { 0.0 },
other => other.preferred_size( inner_w, canvas ).0,
} )
.fold( 0.0_f32, f32::max );
( content_w + self.padding * 2.0 ).min( max_width )
} else {
max_width
};
( w, total_h )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
/// Layout children within rect and return (rect, child_index) pairs.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_w = self.inner_w( rect.width );
// Flexible spacers and Scroll widgets claim remaining vertical space.
// Fixed-height spacers behave like normal fixed-size children.
let total_weight: u32 = self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) if s.fixed_height.is_none() => s.weight,
Element::Scroll( _ ) => 1,
_ => 0,
} )
.sum();
let fixed_h: f32 = self.children.iter()
.map( |c|
{
if matches!( c, Element::Scroll( _ ) )
{
0.0
} else if let Element::Spacer( s ) = c {
s.fixed_height.unwrap_or( 0.0 )
} else {
c.preferred_size( inner_w, canvas ).1
}
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32;
let avail_h = rect.height - self.padding * 2.0;
let avail_spare = (avail_h - fixed_h).max( 0.0 );
// `center_y` only applies when there are no spacers.
let start_y = if total_weight == 0 && self.center_y
{
rect.y + self.padding + avail_spare / 2.0
} else {
rect.y + self.padding
};
let start_x = rect.x + (rect.width - inner_w) / 2.0;
let mut y = start_y;
let mut result = Vec::new();
for ( i, child ) in self.children.iter().enumerate()
{
let ( w, h ) = match child
{
Element::Spacer( s ) =>
{
let h = if let Some( fixed ) = s.fixed_height
{
fixed
} else if total_weight > 0
{
avail_spare * s.weight as f32 / total_weight as f32
} else {
0.0
};
( inner_w, h )
},
Element::Scroll( _ ) =>
{
let h = if total_weight > 0
{
avail_spare / total_weight as f32
} else {
0.0
};
( inner_w, h )
},
other => other.preferred_size( inner_w, canvas ),
};
let x = if self.align_center_x && !matches!( child, Element::Spacer( _ ) )
{
start_x + (inner_w - w) / 2.0
} else {
start_x
};
result.push( ( Rect { x, y, width: w, height: h }, i ) );
y += h + self.spacing;
}
result
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Column<U>
where
U: Clone + 'static,
Msg: 'static,
{
Column
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
spacing: self.spacing,
padding: self.padding,
align_center_x: self.align_center_x,
center_y: self.center_y,
max_width: self.max_width,
fit_content: self.fit_content,
}
}
}
/// Create an empty column layout.
pub fn column<Msg: Clone>() -> Column<Msg>
{
Column::new()
}
impl<Msg: Clone> Default for Column<Msg>
{
fn default() -> Self
{
Self::new()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn preferred_size_width_equals_max_width()
{
let canvas = make_canvas();
let col = column::<()>().padding( 10.0 );
let ( w, _ ) = col.preferred_size( 200.0, &canvas );
assert_eq!( w, 200.0 );
}
#[ test ]
fn empty_column_height_is_two_paddings()
{
let canvas = make_canvas();
let col = column::<()>().padding( 10.0 );
let ( _, h ) = col.preferred_size( 200.0, &canvas );
assert_eq!( h, 20.0 );
}
#[ test ]
fn max_width_caps_inner_w_not_preferred_w()
{
let canvas = make_canvas();
// preferred_size always returns available max_width; max_width caps inner layout only
let col = column::<()>().padding( 0.0 ).max_width( 100.0 );
let ( w, _ ) = col.preferred_size( 200.0, &canvas );
assert_eq!( w, 200.0 );
}
#[ test ]
fn inner_w_respects_padding_and_max_width()
{
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
// available = 200, minus padding*2 = 160, capped at max_width = 100
assert_eq!( col.inner_w( 200.0 ), 100.0 );
}
#[ test ]
fn inner_w_without_max_width_subtracts_padding()
{
let col = column::<()>().padding( 10.0 );
assert_eq!( col.inner_w( 200.0 ), 180.0 );
}
#[ test ]
fn spacing_between_children_accumulates()
{
let canvas = make_canvas();
// Three zero-height spacers, two 8 px gaps between them = 16.
let col = column::<()>()
.padding( 0.0 )
.spacing( 8.0 )
.push( crate::spacer() )
.push( crate::spacer() )
.push( crate::spacer() );
let ( _, h ) = col.preferred_size( 100.0, &canvas );
assert_eq!( h, 16.0 );
}
}

47
src/layout/mod.rs Normal file
View File

@@ -0,0 +1,47 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Layouts — composable arrangers for [`Element`](crate::Element) trees.
//!
//! Layouts decide *where* their children sit; they don't paint anything of
//! their own. Each layout exposes a free constructor (`column()`, `row()`,
//! `stack()`, `grid(N)`, `spacer()`) and a builder-style API for spacing,
//! padding, alignment and sizing. Layouts and [widgets](crate::widget)
//! share the same [`Element<Msg>`](crate::Element) tree — anything that
//! converts into an `Element` can be pushed into any layout.
//!
//! ## What's available
//!
//! * **Flow** — [`column::Column`] (top-to-bottom),
//! [`row::Row`] (left-to-right). Both honour `padding`, `spacing`,
//! `max_width`, `align_center_*` and inline `Spacer` distribution.
//! * **Overlay** — [`stack::Stack`] for FrameLayout-style layering with
//! per-child [`HAlign`](stack::HAlign) / [`VAlign`](stack::VAlign) and
//! margin / pixel-translation overrides. Useful for foreground HUD on
//! top of a background image, or a floating action button anchored to
//! the bottom-right.
//! * **Grid** — [`wrap_grid::WrapGrid`] for fixed-column-count grids that
//! wrap their children into rows (icon launchers, photo galleries).
//! * **Filler** — [`spacer::Spacer`], an invisible child that absorbs
//! leftover space along the parent's main axis. Pair with
//! [`flex::Flex`](crate::Flex) when the filler is non-trivial (a card
//! that should stretch a row).
//!
//! Most layouts default to "fill the parent's available rect" and only
//! shrink to their content with `fit_content()`.
//!
//! ## Sizing model
//!
//! Layouts implement `preferred_size( max_width, &Canvas ) -> ( w, h )`
//! the same way widgets do. The convention is: the layout claims the
//! parent-supplied `max_width` (or the explicit `max_width(...)` setting)
//! and reports the height needed for its children. Spacers and
//! `Scroll`/`Flex` declare zero intrinsic main-axis size and absorb the
//! leftover space the parent has after fixed-size siblings have been laid
//! out.
pub mod column;
pub mod row;
pub mod spacer;
pub mod stack;
pub mod wrap_grid;

299
src/layout/row.rs Normal file
View File

@@ -0,0 +1,299 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// A horizontal layout container.
///
/// Children are arranged left-to-right. Use [`Row::align_right`] to
/// push the content block to the right edge of the available width.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ icon_button, row, Element };
/// # #[ derive( Clone ) ] enum Msg { A, B }
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// row()
/// .spacing( 16.0 )
/// .align_right()
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
/// .into()
/// # }
/// ```
pub struct Row<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
pub align_right: bool,
}
impl<Msg: Clone> Row<Msg>
{
pub fn new() -> Self
{
Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false }
}
/// Append a child widget or layout.
pub fn push( mut self, e: impl Into<Element<Msg>> ) -> Self
{
self.children.push( e.into() );
self
}
/// Set the horizontal gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing = s;
self
}
/// Set the padding (all sides) in pixels. Default: `0.0`.
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// Push the content block to the right edge of the available width.
pub fn align_right( mut self ) -> Self
{
self.align_right = true;
self
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
// Used to compute the residual width that wrap-style children will
// actually render in, so their reported height matches the layout.
let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 );
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter()
.filter( |c| match c
{
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
_ => true,
} )
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum();
let residual = ( inner_w - fixed_w - gaps ).max( 0.0 );
let max_h: f32 = self.children.iter()
.map( |c| match c
{
Element::Flex( _ ) => c.preferred_size( residual, canvas ).1,
Element::Spacer( _ ) => c.preferred_size( max_width, canvas ).1,
_ => c.preferred_size( max_width, canvas ).1,
} )
.fold( 0.0_f32, f32::max );
// `align_right` and any flex / weight-only spacer child make the row
// claim the full `max_width`: in those cases the row's rendered width
// comes from leftover distribution (or the right-edge anchor), not
// from the sum of children's preferred widths. Reporting only the
// natural sum would leave the parent allocating a too-narrow rect and
// the flex children would collapse to 0.
let has_flex = self.children.iter().any( |c| match c
{
Element::Flex( _ ) => true,
Element::Spacer( s ) => s.fixed_width.is_none(),
_ => false,
} );
let w = if self.align_right || has_flex
{
max_width
} else {
let total_w: f32 = self.children.iter()
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum::<f32>()
+ gaps
+ self.padding * 2.0;
total_w.min( max_width )
};
( w, max_h + self.padding * 2.0 )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
/// Layout children within rect and return `(rect, child_index)` pairs.
///
/// Flexible [`Spacer`](crate::layout::spacer::Spacer) children claim the
/// leftover horizontal space: non-spacer widgets keep their preferred
/// width, the remaining width (after subtracting spacing + padding) is
/// distributed between spacers in proportion to their `weight`. When no
/// spacers are present the cluster is centered (or right-aligned via
/// [`Row::align_right`]).
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_h = rect.height - self.padding * 2.0;
// Spacers and `Flex` wrappers report 0 width here; their real width
// comes from the flex distribution below.
let sizes: Vec<(f32, f32)> = self.children.iter()
.map( |c| c.preferred_size( rect.width, canvas ) )
.collect();
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
.filter( |( c, _ )| match c
{
// Pure flex children (`Flex` and weight-only `Spacer`) take
// width from the leftover pool; everything else, including
// `Spacer::width(...)`-pinned spacers, contributes to the
// fixed-width tally.
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
_ => true,
} )
.map( |( _, ( w, _ ) )| *w )
.sum();
let total_weight: u32 = self.children.iter()
.filter_map( |c| match c {
Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ),
Element::Flex( f ) => Some( f.weight ),
_ => None,
} )
.sum();
let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 );
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
let has_spacers = total_weight > 0;
let ( start_x, flex_unit ) = if has_spacers
{
// Spacers and `Flex` wrappers claim the leftover; the cluster
// sits flush to the left edge of the inner rect.
( rect.x + self.padding, leftover / total_weight as f32 )
}
else if self.align_right
{
( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 )
}
else
{
( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 )
};
let mut x = start_x;
let mut result = Vec::with_capacity( self.children.len() );
for ( i, ( (w, h), child) ) in sizes.into_iter().zip( self.children.iter() ).enumerate()
{
let width = match child
{
Element::Spacer( s ) => match s.fixed_width
{
Some( fw ) => fw,
None => flex_unit * s.weight as f32,
},
Element::Flex( f ) => flex_unit * f.weight as f32,
_ => w,
};
let y = rect.y + self.padding + (inner_h - h) / 2.0;
result.push( ( Rect { x, y, width, height: h }, i ) );
x += width + self.spacing;
}
result
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Row<U>
where
U: Clone + 'static,
Msg: 'static,
{
Row
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
spacing: self.spacing,
padding: self.padding,
align_right: self.align_right,
}
}
}
/// Create an empty row layout.
pub fn row<Msg: Clone>() -> Row<Msg>
{
Row::new()
}
impl<Msg: Clone> Default for Row<Msg>
{
fn default() -> Self
{
Self::new()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn align_right_returns_full_max_width()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 500.0 );
}
#[ test ]
fn align_right_true_regardless_of_children()
{
let canvas = make_canvas();
let r = row::<()>().align_right().spacing( 999.0 );
let ( w, _ ) = r.preferred_size( 300.0, &canvas );
assert_eq!( w, 300.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_width()
{
let canvas = make_canvas();
let r = row::<()>();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 0.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 0.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 0.0 );
}
#[ test ]
fn padding_adds_to_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 8.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 16.0 );
}
#[ test ]
fn layout_of_empty_row_is_empty()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
assert!( r.layout( rect, &canvas ).is_empty() );
}
}

143
src/layout/spacer.rs Normal file
View File

@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::widget::Element;
/// A flexible, invisible spacer that expands to fill available space.
///
/// The optional `weight` controls how much of the remaining space this spacer
/// claims relative to other spacers in the same layout. A spacer with `weight = 2`
/// takes twice as much space as one with `weight = 1`.
///
/// Place a `Spacer` between two widgets inside a [`Column`](crate::layout::column::Column)
/// or [`Row`](crate::layout::row::Row) to push them apart:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Top" ) )
/// .push( spacer() ) // pushes "Bottom" to the bottom
/// .push( text( "Bottom" ) )
/// .into()
/// # }
/// ```
///
/// Use [`.weight(n)`](Spacer::weight) to replace several consecutive spacers:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, Column };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() {
/// // These two are equivalent:
/// let _: Column<Msg> = column().push( spacer().weight( 3 ) );
/// let _: Column<Msg> = column().push( spacer() ).push( spacer() ).push( spacer() );
/// # }
/// ```
///
/// Use [`.height(px)`](Spacer::height) to create a fixed-size vertical spacer:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Header" ) )
/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap
/// .push( text( "Content" ) )
/// .into()
/// # }
/// ```
pub struct Spacer
{
/// Relative weight of this spacer (default 1).
pub weight: u32,
/// Fixed height in pixels (overrides flexible behavior in a column).
pub fixed_height: Option<f32>,
/// Fixed width in pixels (overrides flexible behavior in a row).
pub fixed_width: Option<f32>,
}
impl Spacer
{
/// Set the relative weight of this spacer (default 1).
pub fn weight( mut self, w: u32 ) -> Self
{
self.weight = w;
self
}
/// Set a fixed height for this spacer in pixels.
/// When set, the spacer will occupy exactly this much vertical space
/// instead of expanding flexibly.
pub fn height( mut self, h: f32 ) -> Self
{
self.fixed_height = Some( h );
self
}
/// Set a fixed width for this spacer in pixels.
/// When set, the spacer occupies exactly this much horizontal space
/// inside a [`Row`](crate::layout::row::Row) instead of expanding
/// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful
/// to reserve a precise visual margin while a sibling
/// [`Flex`](crate::Flex) claims the remaining width.
pub fn width( mut self, w: f32 ) -> Self
{
self.fixed_width = Some( w );
self
}
/// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the
/// axes that were not pinned. The parent layout distributes leftover
/// along its main axis among the still-flexible spacers and `Flex`
/// wrappers, weighted by `weight`.
pub fn preferred_size( &self ) -> (f32, f32)
{
( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) )
}
/// No-op — spacers are invisible.
pub fn draw( &self ) {}
}
impl<Msg: Clone + 'static> From<Spacer> for Element<Msg>
{
fn from( s: Spacer ) -> Self
{
Element::Spacer( s )
}
}
/// Create a flexible spacer with weight 1.
///
/// Call [`.weight(n)`](Spacer::weight) to set a relative weight greater than 1:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, Column };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() {
/// // These two are equivalent:
/// let _: Column<Msg> = column().push( spacer().weight( 3 ) );
/// let _: Column<Msg> = column().push( spacer() ).push( spacer() ).push( spacer() );
/// # }
/// ```
///
/// Call [`.height(px)`](Spacer::height) to create a fixed-size vertical gap:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "First" ) )
/// .push( spacer().height( 24.0 ) ) // Fixed 24px gap
/// .push( text( "Second" ) )
/// .into()
/// # }
/// ```
pub fn spacer() -> Spacer
{
Spacer { weight: 1, fixed_height: None, fixed_width: None }
}

182
src/layout/stack.rs Normal file
View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// Horizontal alignment of a child within a [`Stack`] rect.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum HAlign
{
/// Align to the left edge.
Start,
/// Center horizontally.
Center,
/// Align to the right edge.
End,
/// Stretch to fill the full width.
Fill,
}
/// Vertical alignment of a child within a [`Stack`] rect.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum VAlign
{
/// Align to the top edge.
Top,
/// Center vertically.
Center,
/// Align to the bottom edge.
Bottom,
/// Stretch to fill the full height.
Fill,
}
/// A layout that draws all its children stacked on top of each other.
/// Each child can be positioned within the Stack rect via [`HAlign`]/[`VAlign`].
///
/// Useful for overlaying a foreground widget on top of a background image:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ column, img_widget, stack, text, Element, HAlign, VAlign };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( bg_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// stack()
/// .push( img_widget( bg_rgba, w, h ) )
/// .push_aligned( column().push( text( "Bottom right" ) ), HAlign::End, VAlign::Bottom )
/// .into()
/// # }
/// ```
pub struct Stack<Msg: Clone>
{
/// Children with their alignment, margin, and extra `(x, y)` translation
/// applied after alignment. Drawn in order — last child is on top.
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32 )>,
}
impl<Msg: Clone> Stack<Msg>
{
/// Create an empty stack.
pub fn new() -> Self
{
Self { children: Vec::new() }
}
/// Append a child that fills the entire Stack rect (Android FrameLayout default).
pub fn push( self, e: impl Into<Element<Msg>> ) -> Self
{
self.push_aligned_margin( e, HAlign::Fill, VAlign::Fill, 0.0 )
}
/// Append a child with explicit horizontal and vertical alignment.
pub fn push_aligned(
self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
) -> Self
{
self.push_aligned_margin( e, h_align, v_align, 0.0 )
}
/// Append a child with alignment and a uniform margin (inset from the Stack edges).
pub fn push_aligned_margin(
mut self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
margin: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) );
self
}
/// Append a child with alignment plus an extra `(x, y)` translation in
/// logical pixels. Useful when a child needs to shift outside the normal
/// alignment grid without giving up the margin or alignment shorthand.
/// Positive `x` / `y` move the child right / down.
pub fn push_translated(
mut self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
offset_x: f32,
offset_y: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) );
self
}
/// Return the preferred `(width, height)` — the maximum height among children.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let max_h = self.children.iter()
.map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
.fold( 0.0_f32, f32::max );
( max_width, max_h )
}
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy ) )|
{
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
let ( x, width ) = match h_align
{
HAlign::Start => ( rect.x + margin, pref_w ),
HAlign::Center => ( rect.x + ( rect.width - pref_w ) / 2.0, pref_w ),
HAlign::End => ( rect.x + rect.width - pref_w - margin, pref_w ),
HAlign::Fill => ( rect.x + margin, inner_w ),
};
let ( y, height ) = match v_align
{
VAlign::Top => ( rect.y + margin, pref_h ),
VAlign::Center => ( rect.y + ( rect.height - pref_h ) / 2.0, pref_h ),
VAlign::Bottom => ( rect.y + rect.height - pref_h - margin, pref_h ),
VAlign::Fill => ( rect.y + margin, inner_h ),
};
( Rect { x: x + ox, y: y + oy, width, height }, i )
} ).collect()
}
/// No-op — children are drawn directly by the event loop during layout.
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Stack<U>
where
U: Clone + 'static,
Msg: 'static,
{
Stack
{
children: self.children.into_iter()
.map( |( child, ha, va, margin, ox, oy )|
( child.map_arc( f ), ha, va, margin, ox, oy ) )
.collect(),
}
}
}
impl<Msg: Clone> Default for Stack<Msg>
{
fn default() -> Self
{
Self::new()
}
}
/// Create an empty [`Stack`].
pub fn stack<Msg: Clone>() -> Stack<Msg>
{
Stack::new()
}

347
src/layout/wrap_grid.rs Normal file
View File

@@ -0,0 +1,347 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use crate::widget::Element;
/// A grid layout that wraps children into rows of a fixed column count.
///
/// All cells in a row share the same height (the tallest item in that row).
/// Column widths are equal, dividing the available width minus padding and spacing.
///
/// Designed for app-drawer style layouts — combine with [`scroll()`](crate::widget::scroll::scroll)
/// for vertically scrollable grids:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ grid, icon_button, scroll, Element };
/// # #[ derive( Clone ) ] enum Msg { Open( usize ) }
/// # fn _ex( data: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// scroll(
/// grid( 4 )
/// .padding( 16.0 )
/// .spacing( 12.0 )
/// .push( icon_button( data.clone(), w, h ).on_press( Msg::Open( 0 ) ) )
/// .push( icon_button( data, w, h ).on_press( Msg::Open( 1 ) ) )
/// // ...
/// )
/// .into()
/// # }
/// ```
pub struct WrapGrid<Msg: Clone>
{
/// Child widgets laid out in row-major order.
pub children: Vec<Element<Msg>>,
/// Number of columns per row.
pub columns: usize,
/// Horizontal gap between cells (pixels).
pub spacing_x: f32,
/// Vertical gap between rows (pixels).
pub spacing_y: f32,
/// Padding on all sides (pixels).
pub padding: f32,
}
impl<Msg: Clone> WrapGrid<Msg>
{
/// Append a child widget to the grid.
pub fn push( mut self, child: impl Into<Element<Msg>> ) -> Self
{
self.children.push( child.into() );
self
}
/// Set both horizontal and vertical gap between cells (default 8.0).
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing_x = s;
self.spacing_y = s;
self
}
/// Set only the horizontal gap between cells; leaves vertical spacing untouched.
pub fn spacing_x( mut self, s: f32 ) -> Self
{
self.spacing_x = s;
self
}
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
pub fn spacing_y( mut self, s: f32 ) -> Self
{
self.spacing_y = s;
self
}
/// Set the padding on all sides (default 0.0).
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// Compute the preferred size given an available width.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
if self.children.is_empty() || self.columns == 0
{
return ( max_width, 0.0 );
}
let cols = self.columns;
let inner_w = (max_width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let row_count = (self.children.len() + cols - 1) / cols;
let mut total_h = self.padding * 2.0;
for row in 0..row_count
{
let start = row * cols;
let end = (start + cols).min( self.children.len() );
let row_h = self.children[start..end]
.iter()
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
total_h += row_h;
if row + 1 < row_count { total_h += self.spacing_y; }
}
( max_width, total_h )
}
/// Compute child rects. Returns `(child_rect, index_in_children)` pairs.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
if self.children.is_empty() || self.columns == 0
{
return Vec::new();
}
let cols = self.columns;
let inner_w = (rect.width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + self.padding;
let mut y = rect.y + self.padding;
let row_count = (self.children.len() + cols - 1) / cols;
let mut out = Vec::with_capacity( self.children.len() );
for row in 0..row_count
{
let start = row * cols;
let end = (start + cols).min( self.children.len() );
let row_h = self.children[start..end]
.iter()
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
for col in 0..(end - start)
{
let x = x0 + col as f32 * (cell_w + self.spacing_x);
let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) );
}
y += row_h + self.spacing_y;
}
out
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> WrapGrid<U>
where
U: Clone + 'static,
Msg: 'static,
{
WrapGrid
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
columns: self.columns,
spacing_x: self.spacing_x,
spacing_y: self.spacing_y,
padding: self.padding,
}
}
}
impl<Msg: Clone + 'static> From<WrapGrid<Msg>> for Element<Msg>
{
fn from( g: WrapGrid<Msg> ) -> Self
{
Element::WrapGrid( g )
}
}
#[cfg(test)]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::layout::spacer::spacer;
fn canvas() -> Canvas { Canvas::new( 1, 1 ) }
// Helper: build a grid of N spacer children with the given settings.
fn spacer_grid( cols: usize, n: usize, spacing: f32, padding: f32 ) -> WrapGrid<()>
{
let mut g = grid( cols ).spacing( spacing ).padding( padding );
for _ in 0..n { g = g.push( spacer() ); }
g
}
// --- preferred_size ---
#[test]
fn empty_grid_height_is_zero()
{
let g: WrapGrid<()> = grid( 4 );
let ( _, h ) = g.preferred_size( 400.0, &canvas() );
assert_eq!( h, 0.0 );
}
#[test]
fn preferred_width_equals_max_width()
{
let g = spacer_grid( 4, 8, 0.0, 0.0 );
let ( w, _ ) = g.preferred_size( 320.0, &canvas() );
assert_eq!( w, 320.0 );
}
// --- layout: cell widths ---
#[test]
fn cell_width_no_spacing_no_padding()
{
// 400px / 4 cols = 100px each
let g = spacer_grid( 4, 4, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 4 );
for ( r, _ ) in &rects { assert!( (r.width - 100.0).abs() < 0.01 ); }
}
#[test]
fn cell_width_with_spacing()
{
// (400 - 3 * 10) / 4 = 370 / 4 = 92.5
let g = spacer_grid( 4, 4, 10.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let rects = g.layout( rect, &c );
for ( r, _ ) in &rects { assert!( (r.width - 92.5).abs() < 0.01 ); }
}
#[test]
fn cell_width_with_padding()
{
// inner = 400 - 2*20 = 360; 360 / 4 = 90
let g = spacer_grid( 4, 4, 0.0, 20.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
let rects = g.layout( rect, &c );
for ( r, _ ) in &rects { assert!( (r.width - 90.0).abs() < 0.01 ); }
}
// --- layout: child count and indices ---
#[test]
fn layout_yields_one_rect_per_child()
{
let g = spacer_grid( 4, 7, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 7 );
}
#[test]
fn layout_indices_are_sequential()
{
let g = spacer_grid( 3, 5, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 300.0 };
let rects = g.layout( rect, &c );
let indices: Vec<usize> = rects.iter().map( |( _, i )| *i ).collect();
assert_eq!( indices, vec![ 0, 1, 2, 3, 4 ] );
}
// --- layout: column x-positions ---
#[test]
fn column_x_positions_no_spacing()
{
// 300px / 3 cols = 100px each, starting at x=0
let g = spacer_grid( 3, 3, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 };
let rects = g.layout( rect, &c );
let xs: Vec<f32> = rects.iter().map( |( r, _ )| r.x ).collect();
assert!( (xs[0] - 0.0).abs() < 0.01 );
assert!( (xs[1] - 100.0).abs() < 0.01 );
assert!( (xs[2] - 200.0).abs() < 0.01 );
}
#[test]
fn column_x_positions_with_spacing()
{
// (300 - 2*10) / 3 = 280/3 ≈ 93.33; x[0]=0, x[1]=103.33, x[2]=206.67
let g = spacer_grid( 3, 3, 10.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 };
let rects = g.layout( rect, &c );
let cell_w = 280.0_f32 / 3.0;
let xs: Vec<f32> = rects.iter().map( |( r, _ )| r.x ).collect();
assert!( (xs[0] - 0.0).abs() < 0.01 );
assert!( (xs[1] - (cell_w + 10.0)).abs() < 0.01 );
assert!( (xs[2] - (2.0 * (cell_w + 10.0))).abs() < 0.01 );
}
// --- layout: partial last row ---
#[test]
fn partial_last_row_has_correct_count()
{
// 7 children, 4 cols => row 0: 4, row 1: 3.
let g = spacer_grid( 4, 7, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 7 );
for ( r, _ ) in &rects[..4] { assert!( r.y.abs() < 0.01 ); }
}
// --- layout: rect origin offset ---
#[test]
fn layout_respects_rect_origin()
{
let g = spacer_grid( 2, 2, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 50.0, y: 30.0, width: 200.0, height: 100.0 };
let rects = g.layout( rect, &c );
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
}
}
/// Create a grid layout with the given number of columns.
///
/// Use [`.push()`](WrapGrid::push), [`.spacing()`](WrapGrid::spacing), and
/// [`.padding()`](WrapGrid::padding) to populate and style the grid.
///
/// ```rust,no_run
/// # use ltk::{ button, grid, WrapGrid };
/// # #[ derive( Clone ) ] enum Msg { A }
/// # fn _ex() -> WrapGrid<Msg> {
/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) )
/// # }
/// ```
pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
WrapGrid
{
children: Vec::new(),
columns,
spacing_x: 8.0,
spacing_y: 8.0,
padding: 0.0,
}
}

429
src/lib.rs Executable file
View File

@@ -0,0 +1,429 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
// Inside `unsafe fn` bodies, every unsafe op must still be wrapped in
// its own `unsafe { ... }` block. Without this lint the compiler treats
// the whole body as one implicit `unsafe` scope and a SAFETY: comment
// per call site becomes impossible to enforce — the kind of drift that
// hides UB in renderer code where every glow::* call is `unsafe fn`.
#![ deny( unsafe_op_in_unsafe_fn ) ]
//! # ltk — Liberux ToolKit
//!
//! A lightweight Wayland UI toolkit built on top of
//! [smithay-client-toolkit](https://crates.io/crates/smithay-client-toolkit),
//! [tiny-skia](https://crates.io/crates/tiny-skia) and
//! [fontdue](https://crates.io/crates/fontdue).
//!
//! ltk is the UI toolkit for the Liberux desktop. The Liberux compositor (Forge)
//! handles window management, decorations, and positioning — ltk focuses on
//! rendering the content of each Wayland surface.
//!
//! `ltk` is also a public library for third-party developers building native
//! Wayland applications. If you are approaching the crate through `cargo doc`,
//! the API is grouped conceptually into three navigation modules:
//!
//! - [`window`] — the basic application window path most apps should start with
//! - [`shell`] — layer-shell and overlay APIs for shell-like surfaces
//! - [`runtime`] — advanced runtime hooks, invalidation, channels, and
//! runtime-free embedding via [`core::UiSurface`]
//!
//! ## Quick start
//!
//! ```rust,no_run
//! use ltk::{App, Element, column, text, button, spacer, Color, ButtonVariant};
//!
//! #[derive(Clone)]
//! enum Msg { Quit }
//!
//! struct MyApp;
//!
//! impl App for MyApp
//! {
//! type Message = Msg;
//!
//! fn view( &self ) -> Element<Msg>
//! {
//! column()
//! .push( text( "Hello, ltk!" ).size( 32.0 ).color( Color::WHITE ) )
//! .push( spacer() )
//! .push( button( "Quit" ).on_press( Msg::Quit ) )
//! .into()
//! }
//!
//! fn update( &mut self, msg: Msg )
//! {
//! match msg { Msg::Quit => std::process::exit( 0 ) }
//! }
//! }
//!
//! fn main() { ltk::run( MyApp ); }
//! ```
//!
//! ## Architecture
//!
//! - **[`App`]** trait — implement this to define your application.
//! - **[`Element`]** enum — the widget tree returned by [`App::view`].
//! - Widgets, layouts and primitive types are listed below in their own
//! sidebar sections; the [`widgets`], [`layouts`] and [`types`] modules
//! are concept-oriented landing pages that `cargo doc` exposes for the
//! same set, grouped by category.
//!
//! ## Widgets
//!
//! The interactive and decorative leaves of the [`Element`] tree:
//!
//! - **Buttons / activations** — [`button()`], [`icon_button`],
//! [`pressable`], [`window_button`], [`list_item()`].
//! - **Stateful binary controls** — [`toggle()`], [`checkbox()`],
//! [`radio()`].
//! - **Continuous controls** — [`slider()`], [`vslider()`],
//! [`progress_bar()`].
//! - **Text** — [`text()`], [`text_edit()`].
//! - **Decoration** — [`container()`], [`separator()`], [`img_widget()`].
//! - **Clipping wrappers** — [`scroll()`], [`viewport()`], [`flex()`].
//! - **Modal overlays** — [`dialog()`] (centered confirmation card with
//! optional title, subtitle, custom body and action row; built-in
//! scrim, ESC-to-cancel, and tap-outside-to-dismiss for the
//! non-modal variant).
//!
//! See [`widgets`] for the grouped landing page and
//! `docs/widgets.md` for the per-widget catalogue.
//!
//! ## Layouts
//!
//! Composable arrangers for [`Element`] trees:
//!
//! - [`column()`] — vertical flow.
//! - [`row()`] — horizontal flow.
//! - [`stack()`] — z-order overlay with per-child alignment.
//! - [`grid()`] — fixed-column-count wrapping grid.
//! - [`spacer()`] — invisible flexible filler.
//!
//! See [`layouts`] for the grouped landing page.
//!
//! ## Types
//!
//! Geometry and primitive values that flow through every builder:
//!
//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`].
//!
//! See [`types`] for the full module with `//!` description.
//!
//! ## Runtime-free embedding
//!
//! Use [`core::UiSurface`] when you need ltk's layout, drawing and
//! hit-testing without [`run()`] — typically for compositor-side
//! decorations, embedding ltk widgets in another render loop, or
//! offscreen rendering / previews.
//!
//! ## Licence and third-party assets
//!
//! `ltk` itself is distributed under `LGPL-2.1-only`. The default
//! theme bundles two third-party asset sets that travel under their
//! own licences and must be credited when the toolkit (or a binary
//! that embeds the default theme) is redistributed:
//!
//! - **Symbolic icons** under `themes/default/icons/catalogue/` —
//! Streamline's *Core Line Free* set, [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/),
//! © Streamline. Some files modified for the symbolic-tinting
//! pipeline; details in `themes/default/icons/catalogue/LICENSE.md`.
//! Upstream: <https://www.streamlinehq.com/icons/core-line-free>.
//! - **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) — the
//! embedded font fallback, [SIL OFL 1.1](https://scripts.sil.org/OFL),
//! © The Sora Project Authors, Jonathan Barnbrook, Julián Moncada.
//!
//! The remaining artwork in the default theme (wallpapers, lockscreens,
//! launcher logo, brand-mark variants, per-application icons) is
//! original to Liberux Labs and travels under the toolkit's own
//! `LGPL-2.1-only` licence. The full Debian-style declaration lives in
//! `debian/copyright` of the source tree.
// Load YAML locale files from `ltk/locales/*.yaml`. The `t!()` macro is
// re-exported (see below) so consuming applications can also use it; their
// own `i18n!()` invocations merge into the same runtime registry. The
// fallback locale is English — built-in widget strings always have an
// English entry.
rust_i18n::i18n!( "locales", fallback = "en" );
pub mod types;
pub( crate ) mod render;
pub( crate ) mod system_fonts;
pub( crate ) mod widget;
pub( crate ) mod layout;
pub( crate ) mod app;
pub mod theme;
pub mod wallpaper;
pub( crate ) mod tree;
pub( crate ) mod draw;
pub( crate ) mod input;
pub( crate ) mod event_loop;
pub( crate ) mod secure_mem;
pub mod gles_render;
pub mod egl_context;
pub mod core;
pub use app::
{
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
InvalidationScope, SurfaceTarget,
};
pub use theme::
{
Palette, ThemeMode, ThemePreference, ThemeError,
ThemeDocument, Mode, SlotStore,
WallpaperSpec, WallpaperFit, LauncherSpec, WindowControlsSpec,
active_document, active_mode, active_theme_id,
is_fallback_active,
set_active_document, set_active_mode,
tint_symbolic,
};
pub use theme::{ color as theme_color, color_or as theme_color_or };
pub use theme::{ paint as theme_paint, shadows as theme_shadows };
pub use theme::{ surface as theme_surface, text_style as theme_text_style };
pub use theme::resolve_surface as theme_resolve_surface;
pub use theme::palette as theme_palette;
pub use theme::window_controls as theme_window_controls;
pub use theme::wallpaper as theme_wallpaper;
pub use theme::lockscreen as theme_lockscreen;
pub use theme::app_icon as theme_app_icon;
pub use theme::app_default_icon as theme_app_default_icon;
pub use theme::launcher_icon as theme_launcher_icon;
pub use theme::logo as theme_logo;
pub use theme::logo_square as theme_logo_square;
pub use theme::logo_horizontal as theme_logo_horizontal;
pub use theme::branding_asset as theme_branding_asset;
pub use theme::branding_raster as theme_branding_raster;
pub use theme::branding_image as theme_branding_image;
pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba };
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use render::is_software_render;
pub use wallpaper::{ WallpaperBundle, ImageData };
pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId };
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
pub use widget::button::ButtonVariant;
pub use widget::slider::{ Slider, slider, SliderAxis };
pub use widget::vslider::{ VSlider, vslider };
pub use widget::text::TextAlign;
pub use widget::toggle::{ Toggle, toggle };
pub use widget::separator::{ Separator, separator };
pub use widget::progress_bar::{ ProgressBar, progress_bar };
pub use widget::checkbox::{ Checkbox, checkbox };
pub use widget::radio::{ Radio, radio };
pub use widget::list_item::{ ListItem, list_item };
pub use widget::window_button::{ WindowButton, WindowButtonKind, window_button, window_controls };
pub use widget::pressable::{ Pressable, pressable };
pub use widget::flex::{ Flex, flex };
pub use widget::combo::{ Combo, ComboState, combo };
pub use widget::spinner::{ Spinner, spinner };
pub use widget::tab_bar::{ TabBar, tabs };
pub use widget::toast::{ Toast, toast };
pub use widget::tooltip::{ Tooltip, tooltip };
pub use widget::notebook::{ Notebook, NotebookPage, notebook };
pub use widget::external::{ External, ExternalSource };
pub use widget::external as widget_external;
pub use widget::date_picker::
{
Date, DatePicker, Locale as DateLocale, date_picker,
is_leap_year, days_in_month, day_of_week, add_months,
};
pub use widget::time_picker::{ Time, TimePicker, time_picker };
pub use widget::color_picker::
{
ColorPicker, color_picker, color_to_hex, parse_hex,
};
pub use widget::dialog::{ Dialog, dialog };
pub use layout::spacer::{ Spacer, spacer };
pub use layout::column::{ Column, column };
pub use layout::row::{ Row, row };
pub use layout::stack::{ Stack, stack, HAlign, VAlign };
// push_aligned_margin is available as a method on Stack — no separate re-export needed.
pub use layout::wrap_grid::{ WrapGrid, grid };
pub use widget::scroll::scroll;
pub use widget::viewport::{ Viewport, viewport };
pub use app::run;
pub use app::{ try_run, RunError };
pub use smithay_client_toolkit::seat::keyboard::Keysym;
/// Widgets — the interactive and decorative leaves of the [`Element`]
/// tree.
///
/// Concept-oriented sidebar entry: every widget the toolkit ships is also
/// available at the crate root (`ltk::button`, `ltk::toggle`, …); this
/// module groups them so `cargo doc` shows a single landing page when you
/// are looking for "what controls can I draw".
///
/// See [`docs/widgets.md`](https://github.com/liberux/ltk/blob/master/docs/widgets.md)
/// for the per-widget catalogue with usage notes and minimal examples.
pub mod widgets
{
pub use crate::
{
// Buttons / activations
button, icon_button,
Pressable, pressable,
WindowButton, WindowButtonKind, window_button, window_controls,
ListItem, list_item,
// Stateful binary controls
Toggle, toggle,
Checkbox, checkbox,
Radio, radio,
// Continuous controls
Slider, slider, SliderAxis,
VSlider, vslider,
ProgressBar, progress_bar,
// Composite picker
Combo, ComboState, combo,
// Activity / hint indicators
Spinner, spinner,
// Segmented selector + paginated tabs
TabBar, tabs,
Notebook, NotebookPage, notebook,
// Date / time / color pickers
Date, DatePicker, DateLocale, date_picker,
Time, TimePicker, time_picker,
ColorPicker, color_picker, color_to_hex, parse_hex,
// Transient overlays (return OverlaySpec)
Toast, toast,
Tooltip, tooltip,
// Modal / non-modal centered overlays
Dialog, dialog,
// Text input and display
text, text_edit, TextAlign,
// Decoration and chrome
container, Separator, separator,
img_widget,
// Clipping wrappers
scroll,
Viewport, viewport,
Flex, flex,
// Button styling token
ButtonVariant,
};
}
/// Layouts — composable arrangers for [`Element`] trees.
///
/// Concept-oriented sidebar entry. Each layout has a free constructor
/// (`column()`, `row()`, …) and a builder-style API for spacing, padding
/// and alignment. Layouts and [widgets] share the same `Element<Msg>`
/// tree.
pub mod layouts
{
pub use crate::
{
Column, column,
Row, row,
Stack, stack, HAlign, VAlign,
WrapGrid, grid,
Spacer, spacer,
};
}
/// Basic application-window API.
///
/// Start here if you are building a normal Wayland client window.
///
/// This module is documentation-first: it re-exports the common entry points
/// that most applications need so `cargo doc` presents a smaller and more
/// approachable surface before the user gets into overlays, gestures, and
/// shell-specific features.
///
/// The default path is:
///
/// 1. implement [`App`]
/// 2. return an [`Element`] tree from [`App::view`]
/// 3. mutate state in [`App::update`]
/// 4. start the event loop with [`run`]
///
/// If you are looking for layer-shell, overlays, or advanced runtime hooks,
/// move on to [`crate::shell`] or [`crate::runtime`].
pub mod window
{
pub use crate::
{
App, ButtonVariant, Color, Element, Keysym, Point, Rect, Size,
Column, Row, Stack, WrapGrid, Spacer,
button, icon_button, text, text_edit, img_widget,
container, checkbox, radio, toggle, separator,
progress_bar, list_item, slider, vslider, scroll, viewport,
column, row, stack, grid, spacer,
TextAlign, SliderAxis,
run,
};
}
/// Shell and layer-surface API.
///
/// Use this module when you are building shell-like surfaces rather than a
/// plain application window:
///
/// - panels
/// - docks
/// - homescreens
/// - greeters
/// - lock screens
/// - transient overlays
///
/// These items are also available at the crate root; this module exists so
/// `cargo doc` exposes a concept-oriented entry point for layer-shell users.
pub mod shell
{
pub use crate::
{
Anchor, App, Layer, OverlayId, OverlaySpec, ShellMode,
SurfaceTarget, InvalidationScope,
WindowButton, WindowButtonKind, window_button, window_controls,
};
}
/// Advanced runtime and embedding API.
///
/// This module groups the hooks that are useful once the basic app-window flow
/// is no longer enough:
///
/// - external wakeups via [`ChannelSender`]
/// - redraw narrowing via [`InvalidationScope`]
/// - surface-level invalidation targets via [`SurfaceTarget`]
/// - runtime-free embedding with [`crate::core::UiSurface`]
/// - direct theme/runtime state access
///
/// Most applications do not need to start here.
pub mod runtime
{
pub use crate::
{
ChannelSender, InvalidationScope, SurfaceTarget,
Palette, ThemeDocument, ThemeError, ThemeMode, ThemePreference,
WallpaperBundle, ImageData,
active_document, active_mode, active_theme_id,
is_fallback_active,
set_active_document, set_active_mode,
theme_color, theme_color_or, theme_paint, theme_palette,
theme_resolve_surface, theme_shadows, theme_surface, theme_text_style,
theme_wallpaper, theme_lockscreen, theme_window_controls,
theme_branding_asset, theme_branding_raster, theme_branding_image,
tint_symbolic,
};
pub use crate::core::{ RenderOptions, RenderOutput, UiSurface };
}
/// Internal helpers re-exported for the integration tests under `tests/` and
/// the criterion benches under `benches/`. The items themselves are `pub` but
/// live inside `pub(crate)` modules, so the only path that reaches them from
/// outside the crate is `ltk::test_support::*` — which is exactly what test
/// crates (and benches, which are also external) need.
///
/// **Not part of the stable public API.** Anything in here may change between
/// patch releases without notice. Hidden from generated docs via
/// `#[doc(hidden)]` for the same reason.
#[ doc( hidden ) ]
pub mod test_support
{
pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index };
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, SliderAxis };
pub use crate::widget::vslider::value_from_y_in_rect;
pub use crate::event_loop::diff_overlay_ids;
}

100
src/render/clip.rs Normal file
View File

@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
//! path calls `set_clip_rects` before every repaint so only pixels
//! inside the dirty rects are touched.
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
use crate::types::Rect;
use super::SoftwareCanvas;
impl SoftwareCanvas
{
/// Set the active clip region to the union of `rects` (physical pixels).
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
let w = self.pixmap.width();
let h = self.pixmap.height();
let Some( mut mask ) = Mask::new( w, h ) else
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
return;
};
let mut pb = PathBuilder::new();
for r in rects
{
let x0 = r.x.max( 0.0 ).min( w as f32 );
let y0 = r.y.max( 0.0 ).min( h as f32 );
let x1 = ( r.x + r.width ).max( 0.0 ).min( w as f32 );
let y1 = ( r.y + r.height ).max( 0.0 ).min( h as f32 );
if x1 <= x0 || y1 <= y0 { continue; }
pb.push_rect( tiny_skia::Rect::from_ltrb( x0, y0, x1, y1 )
.expect( "valid rect" ) );
}
if let Some( path ) = pb.finish()
{
mask.fill_path( &path, FillRule::Winding, false, Transform::identity() );
self.clip_mask = Some( mask );
self.clip_bounds = rects.to_vec();
} else {
self.clip_mask = None;
self.clip_bounds = Vec::new();
}
}
/// Remove the active clip so subsequent paints cover the full canvas.
pub fn clear_clip( &mut self )
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
}
pub ( super ) fn has_clip( &self ) -> bool
{
self.clip_mask.is_some()
}
/// Snapshot of the active clip bounds (empty when no clip is set).
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
{
if self.has_clip() { self.clip_bounds.clone() } else { Vec::new() }
}
/// True when a horizontal strip `y` in `[y0, y1]` touches any clip bound.
pub ( super ) fn strip_intersects_clip( &self, y0: f32, y1: f32 ) -> bool
{
if self.clip_bounds.is_empty() { return !self.has_clip(); }
self.clip_bounds.iter().any( |r|
{
y1 > r.y && y0 < r.y + r.height
} )
}
/// Zero the alpha+RGB bytes inside each rect, used by the
/// partial-redraw path when the surface background is fully
/// transparent.
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
let pw = self.pixmap.width() as i32;
let ph = self.pixmap.height() as i32;
let bytes = self.pixmap.data_mut();
for r in rects
{
let x0 = ( r.x as i32 ).max( 0 );
let y0 = ( r.y as i32 ).max( 0 );
let x1 = ( ( r.x + r.width ).ceil() as i32 ).min( pw );
let y1 = ( ( r.y + r.height ).ceil() as i32 ).min( ph );
if x1 <= x0 || y1 <= y0 { continue; }
for py in y0..y1
{
let row_start = ( py * pw + x0 ) as usize * 4;
let row_end = ( py * pw + x1 ) as usize * 4;
bytes[ row_start..row_end ].fill( 0 );
}
}
}
}

112
src/render/helpers.rs Normal file
View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Backend-neutral helpers for the software renderer: rounded-rect
//! path construction + system-font lookup.
use tiny_skia::{ Path, PathBuilder };
use crate::types::Corners;
/// Cubic bezier control-point factor for a quarter-circle approximation
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
const KAPPA: f32 = 0.5523_f32;
/// Build a rounded rectangle path with independent per-corner radii
/// using cubic bezier curves. Each corner is clamped against the
/// inscribed-circle limit `min(width, height) / 2` before drawing,
/// so callers can pass theme pill sentinels (e.g. `RADIUS = 100`) and
/// still get a well-formed pill on a small rect.
pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -> Option<Path>
{
let c = corners.clamp_to_size( rect.width(), rect.height() );
let tl = c.tl;
let tr = c.tr;
let br = c.br;
let bl = c.bl;
let x0 = rect.left();
let y0 = rect.top();
let x1 = rect.right();
let y1 = rect.bottom();
let mut pb = PathBuilder::new();
pb.move_to( x0 + tl, y0 );
pb.line_to( x1 - tr, y0 );
if tr > 0.0
{
let kk = tr * KAPPA;
pb.cubic_to( x1 - tr + kk, y0, x1, y0 + tr - kk, x1, y0 + tr );
}
pb.line_to( x1, y1 - br );
if br > 0.0
{
let kk = br * KAPPA;
pb.cubic_to( x1, y1 - br + kk, x1 - br + kk, y1, x1 - br, y1 );
}
pb.line_to( x0 + bl, y1 );
if bl > 0.0
{
let kk = bl * KAPPA;
pb.cubic_to( x0 + bl - kk, y1, x0, y1 - bl + kk, x0, y1 - bl );
}
pb.line_to( x0, y0 + tl );
if tl > 0.0
{
let kk = tl * KAPPA;
pb.cubic_to( x0, y0 + tl - kk, x0 + tl - kk, y0, x0 + tl, y0 );
}
pb.close();
pb.finish()
}
/// System-font search chain, ordered by preference. Shared by
/// [`find_font`] (which panics when none match) and
/// [`find_font_opt`] (which returns `None` — used by tests that
/// want to skip gracefully on images without the usual fonts
/// installed).
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
// depends on. Listed first so Sora wins as the default font
// whenever the package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Resolve the first system font available from
/// [`SYSTEM_FONT_CANDIDATES`], or `None` if none exist. Used by
/// tests; runtime code uses [`find_font`].
pub ( crate ) fn find_font_opt() -> Option<String>
{
SYSTEM_FONT_CANDIDATES.iter()
.find( |p| std::path::Path::new( p ).exists() )
.copied()
.map( str::to_string )
}
/// Load the bytes of a default system font. Tries the candidate chain
/// via [`find_font_opt`]; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
/// OFL 1.1) when nothing matches or the file cannot be read. Always
/// returns usable bytes so canvas construction never panics on a
/// system without the expected fonts.
pub ( super ) fn load_default_font_bytes() -> Vec<u8>
{
if let Some( path ) = find_font_opt()
{
if let Ok( bytes ) = std::fs::read( &path )
{
return bytes;
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}

115
src/render/image.rs Normal file
View File

@@ -0,0 +1,115 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Image draw + SHM serialisation for [`SoftwareCanvas`].
//!
//! `draw_image_data` premultiplies the incoming straight-alpha RGBA
//! into a thread-local scratch buffer, wraps the result in a
//! short-lived tiny-skia pixmap, and composites it into `self.pixmap`
//! honouring the active clip mask + global alpha + `opacity`.
//!
//! `write_to_wayland_buf` is the serialisation path to the `wl_shm`
//! pool used by the software draw path. Either memcpys straight
//! (Abgr8888 matches tiny-skia's byte order) or swaps R/B in blocks
//! of four pixels (Argb8888 fallback).
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use crate::types::Rect;
use super::SoftwareCanvas;
impl SoftwareCanvas
{
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
if img_w == 0 || img_h == 0 || rgba_data.len() != expected
{
eprintln!(
"[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
img_w, img_h, rgba_data.len(), expected,
);
return;
}
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
thread_local! {
static PREMUL_BUF: std::cell::RefCell<Vec<u8>> = std::cell::RefCell::new( Vec::new() );
}
PREMUL_BUF.with( |cell|
{
let mut premul = cell.borrow_mut();
let needed = rgba_data.len();
premul.resize( needed, 0 );
for ( dst, src ) in premul.chunks_exact_mut( 4 ).zip( rgba_data.chunks_exact( 4 ) )
{
let a = (src[3] as f32 / 255.0) * opacity * self.global_alpha;
dst[0] = (src[0] as f32 * a) as u8;
dst[1] = (src[1] as f32 * a) as u8;
dst[2] = (src[2] as f32 * a) as u8;
dst[3] = (a * 255.0) as u8;
}
if let Some( src_pixmap ) = Pixmap::from_vec( std::mem::take( &mut *premul ), int_size )
{
let sx = dest.width / img_w as f32;
let sy = dest.height / img_h as f32;
let t = Transform::from_scale( sx, sy ).post_translate( dest.x, dest.y );
let paint = PixmapPaint
{
quality: tiny_skia::FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap.draw_pixmap( 0, 0, src_pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
*premul = src_pixmap.take();
}
} );
}
pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool )
{
let src = self.pixmap.data();
let len = src.len().min( buf.len() );
if !swap_rb
{
buf[..len].copy_from_slice( &src[..len] );
return;
}
let chunks = len / 16;
let remainder = len % 16;
let mut i = 0;
for _ in 0..chunks
{
buf[i] = src[i + 2];
buf[i + 1] = src[i + 1];
buf[i + 2] = src[i];
buf[i + 3] = src[i + 3];
buf[i + 4] = src[i + 6];
buf[i + 5] = src[i + 5];
buf[i + 6] = src[i + 4];
buf[i + 7] = src[i + 7];
buf[i + 8] = src[i + 10];
buf[i + 9] = src[i + 9];
buf[i + 10] = src[i + 8];
buf[i + 11] = src[i + 11];
buf[i + 12] = src[i + 14];
buf[i + 13] = src[i + 13];
buf[i + 14] = src[i + 12];
buf[i + 15] = src[i + 15];
i += 16;
}
for _ in 0..(remainder / 4)
{
buf[i] = src[i + 2];
buf[i + 1] = src[i + 1];
buf[i + 2] = src[i];
buf[i + 3] = src[i + 3];
i += 4;
}
}
}

634
src/render/mod.rs Normal file
View File

@@ -0,0 +1,634 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Rendering surface used by every widget.
//!
//! [`Canvas`] is a thin enum wrapper over the per-frame rendering
//! backend. The CPU backend is [`SoftwareCanvas`] (tiny-skia + fontdue
//! rasterised into a `Pixmap`). The GPU backend is
//! [`crate::gles_render::GlesCanvas`] (EGL + GLES2/3).
//!
//! Widgets only ever see `&mut Canvas` — they call `fill_rect`,
//! `draw_text`, etc. The enum dispatches by `match self` (no `dyn`,
//! so the call sites stay monomorphic and inlinable). Field-style
//! access to backend internals (`pixmap`, `font`, `dpi_scale`…) is
//! replaced by accessor methods that the GPU variant can also
//! implement.
//!
//! # Submodule layout
//!
//! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit,
//! set_font_registry, font_for}` (construction + accessors).
//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, clear_clip,
//! has_clip, strip_intersects_clip, clear_rects_transparent}`.
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
//! stroke_rect, draw_line}`.
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text,
//! rasterize_cached}`.
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
//! write_to_wayland_buf}`.
//! * [`helpers`] — free functions: `build_rounded_rect`,
//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`.
use std::cell::Cell;
use std::sync::Arc;
use fontdue::{ Font, LineMetrics, Metrics };
use tiny_skia::{ Mask, Pixmap };
use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion };
use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow };
use crate::types::{ Color, Corners, Rect };
pub( crate ) mod setup;
pub( crate ) mod clip;
pub( crate ) mod primitives;
pub( crate ) mod text;
pub( crate ) mod image;
pub( crate ) mod helpers;
// ─── Backend flag ────────────────────────────────────────────────────────────
thread_local!
{
/// `true` when this thread's surfaces are rendered through the
/// software (tiny-skia / SHM) path, `false` when they go through
/// the GLES path. Set once at startup based on EGL availability
/// and read by view code that needs to branch on backend (e.g. a
/// layout that costs something specific to one path and isn't
/// worth replicating on the other). Stays a thread-local so view
/// code does not need to plumb a flag through every layout call.
static SOFTWARE_RENDER: Cell<bool> = const { Cell::new( false ) };
}
/// Toggle the software-render flag for this thread. Consumers read
/// with [`is_software_render`].
pub fn set_software_render( on: bool )
{
SOFTWARE_RENDER.with( | c | c.set( on ) );
}
/// `true` when the active surfaces on this thread render through the
/// software path. Used by view code that wants to avoid pipeline
/// effects the software backend doesn't implement.
pub fn is_software_render() -> bool
{
SOFTWARE_RENDER.with( | c | c.get() )
}
// ─── Glyph cache ─────────────────────────────────────────────────────────────
/// Cache key for a rasterized glyph. `size_bits` is the f32 bit
/// pattern of `size * dpi_scale`; `font_id` is the address of the
/// `Arc<Font>` used for the rasterisation, so distinct weights /
/// families of the same `(char, size)` do not collide on the cache.
#[ derive( Hash, PartialEq, Eq, Clone, Copy ) ]
pub ( super ) struct GlyphKey
{
pub ( super ) ch: char,
pub ( super ) size_bits: u32,
pub ( super ) font_id: usize,
}
/// Cached glyph bitmap and metrics. Fontdue's rasterize call is the
/// dominant per-frame CPU cost for text-heavy UIs; reusing across
/// frames avoids that work.
pub ( super ) struct GlyphEntry
{
pub ( super ) metrics: Metrics,
pub ( super ) bitmap: Vec<u8>,
}
// ─── SoftwareCanvas ──────────────────────────────────────────────────────────
/// Software rendering backend backed by a tiny-skia [`Pixmap`] and a
/// fontdue [`Font`].
///
/// Wrapped by [`Canvas`] so the GPU backend can be slotted in by the
/// runtime without changing widget code. Widgets themselves never see
/// `SoftwareCanvas` directly.
pub struct SoftwareCanvas
{
/// The pixel buffer drawn into each frame.
pub pixmap: Pixmap,
/// The loaded system font used for all text rendering.
///
/// Kept as the default fallback so widgets that do not yet ask for a
/// specific family through [`SoftwareCanvas::font_for`] keep
/// working. Populated from
/// [`crate::render::helpers::find_font`] at construction time.
pub font: Arc<Font>,
/// Optional theme font registry. When present,
/// [`SoftwareCanvas::font_for`] consults it before falling back
/// to `font`. Populated by the caller once the theme's `fonts`
/// block has been loaded.
pub font_registry: Option<Arc<FontRegistry>>,
/// DPI scale factor applied to font sizes.
pub dpi_scale: f32,
/// Global alpha multiplier for all drawing operations (0.0 =
/// transparent, 1.0 = opaque).
pub global_alpha: f32,
/// Persistent cache of rasterized glyphs, indexed by (char, scaled size).
/// Grows on demand; not LRU-bounded since typical UIs use few sizes.
glyph_cache: std::collections::HashMap<GlyphKey, GlyphEntry>,
/// Optional clip mask applied to all paint operations. Set via
/// [`Canvas::set_clip_rects`] during a partial redraw so only
/// pixels inside the dirty rects are touched. `None` means "draw
/// everywhere".
clip_mask: Option<Mask>,
/// Bounding boxes of the clip rects in physical pixels. Used by
/// [`SoftwareCanvas::draw_text`] to do an early reject without
/// poking the mask byte by byte (the Mask buffer is still
/// authoritative inside the pixel loop).
clip_bounds: Vec<Rect>,
}
// ─── Canvas enum + dispatch ─────────────────────────────────────────────────
/// Per-frame rendering surface. Wraps a backend (software or GPU)
/// behind an enum so widgets can stay backend-agnostic.
///
/// All drawing methods are dispatched by `match self` — no `dyn`
/// indirection, so the backend branch stays predictable and
/// inlinable in the hot path.
pub enum Canvas
{
/// CPU rasterisation via tiny-skia + fontdue, written to a
/// `wl_shm` buffer.
Software( SoftwareCanvas ),
/// GPU rasterisation via EGL + GLES 2/3. Presents via
/// `eglSwapBuffers`; [`Canvas::write_to_wayland_buf`] is a no-op
/// for this variant.
Gles( GlesCanvas ),
}
impl Canvas
{
/// Build a software canvas. The GPU backend requires an EGL
/// context — see [`Canvas::new_gles`].
pub fn new( width: u32, height: u32 ) -> Self
{
Canvas::Software( SoftwareCanvas::new( width, height ) )
}
/// Build a GPU canvas on an already-current EGL context.
pub fn new_gles(
gl: Arc<glow::Context>, version: GlesVersion, width: u32, height: u32,
) -> Self
{
Canvas::Gles( GlesCanvas::new( gl, version, width, height ) )
}
/// `(width, height)` of the underlying surface in physical pixels.
pub fn size( &self ) -> ( u32, u32 )
{
match self
{
Canvas::Software( c ) => ( c.pixmap.width(), c.pixmap.height() ),
Canvas::Gles( c ) => c.size(),
}
}
/// Borrow the GLES texture backing this canvas, when the canvas
/// is GPU-backed.
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
{
match self
{
Canvas::Software( _ ) => None,
Canvas::Gles( c ) => Some( c.borrowed_texture() ),
}
}
/// Read a GLES canvas into tightly packed RGBA8, top-left row
/// first. Intentionally unavailable for software canvases because
/// the software backend's canonical export path is
/// [`Self::write_to_wayland_buf`].
pub fn read_gles_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
{
match self
{
Canvas::Software( _ ) => Err( "read_gles_rgba_pixels requires Canvas::Gles".to_string() ),
Canvas::Gles( c ) => c.read_rgba_pixels( out ),
}
}
/// Composite an externally-owned GL texture into `dest`. No-op on
/// the software backend (no GL state to sample from). Used by
/// widgets that host content rendered by an external producer —
/// the producer keeps ownership of the texture name; this call
/// only samples it through the standard texture program.
pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 )
{
match self
{
Canvas::Software( _ ) => {}
Canvas::Gles( c ) => c.draw_external_texture( texture, dest, opacity ),
}
}
pub fn dpi_scale( &self ) -> f32
{
match self
{
Canvas::Software( c ) => c.dpi_scale,
Canvas::Gles( c ) => c.dpi_scale(),
}
}
pub fn set_dpi_scale( &mut self, s: f32 )
{
match self
{
Canvas::Software( c ) => c.dpi_scale = s,
Canvas::Gles( c ) => c.set_dpi_scale( s ),
}
}
pub fn global_alpha( &self ) -> f32
{
match self
{
Canvas::Software( c ) => c.global_alpha,
Canvas::Gles( c ) => c.global_alpha(),
}
}
pub fn set_global_alpha( &mut self, a: f32 )
{
match self
{
Canvas::Software( c ) => c.global_alpha = a,
Canvas::Gles( c ) => c.set_global_alpha( a ),
}
}
/// Shared font handle. Exposed so widgets that need raw `fontdue`
/// access (e.g. `Text` for ascent/descent) do not have to go
/// through wrappers for every metric they read.
pub fn font( &self ) -> &Font
{
match self
{
Canvas::Software( c ) => &c.font,
Canvas::Gles( c ) => c.font(),
}
}
/// Install a theme font registry on the active backend.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
match self
{
Canvas::Software( c ) => c.set_font_registry( registry ),
Canvas::Gles( c ) => c.set_font_registry( registry ),
}
}
/// Resolve a specific font via the theme registry, falling back
/// to the system-default [`Self::font`] when no registry is
/// installed or the triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
match self
{
Canvas::Software( c ) => c.font_for( family, weight, style ),
Canvas::Gles( c ) => c.font_for( family, weight, style ),
}
}
/// Convenience wrapper around `font().metrics(...)` already
/// pre-scaled by `dpi_scale`. Most callers want this rather than
/// the raw font handle.
pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics
{
self.font().metrics( ch, size * self.dpi_scale() )
}
/// Convenience wrapper around `font().horizontal_line_metrics(...)`.
pub fn font_line_metrics( &self, size: f32 ) -> Option<LineMetrics>
{
self.font().horizontal_line_metrics( size )
}
pub fn resize( &mut self, width: u32, height: u32 )
{
match self
{
Canvas::Software( c ) => c.resize( width, height ),
Canvas::Gles( c ) => c.resize( width, height ),
}
}
pub fn sub_canvas( &self, width: u32, height: u32 ) -> Canvas
{
match self
{
Canvas::Software( c ) => Canvas::Software( c.sub_canvas( width, height ) ),
Canvas::Gles( c ) => Canvas::Gles( c.sub_canvas( width, height ) ),
}
}
pub fn blit( &mut self, src: &Canvas, dest_x: i32, dest_y: i32 )
{
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 )
}
/// Like [`Self::blit`] but feathers the last `fade_bottom_px` source
/// rows so the bottom edge fades to transparent. The software backend
/// currently ignores `fade_bottom_px`, so the dissolve is GLES-only.
pub fn blit_fade_bottom( &mut self, src: &Canvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 )
{
match ( self, src )
{
( Canvas::Software( dst ), Canvas::Software( s ) ) =>
{
let _ = fade_bottom_px;
dst.blit( s, dest_x, dest_y );
}
( Canvas::Gles( dst ), Canvas::Gles( s ) ) =>
{
dst.blit_fade_bottom( s, dest_x, dest_y, fade_bottom_px );
}
// Cross-backend blits would need an SHM↔texture upload.
// The toolkit only ever creates sub-canvases of the same
// kind as their parent, so this is unreachable in practice.
_ => unimplemented!( "cross-backend blit not supported" ),
}
}
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
match self
{
Canvas::Software( c ) => c.set_clip_rects( rects ),
Canvas::Gles( c ) => c.set_clip_rects( rects ),
}
}
/// Snapshot the currently installed clip bounds (empty when no clip
/// is active). Used by widgets that need to install a tighter clip
/// for a single primitive and then restore whatever the outer
/// partial-redraw or sub-canvas clip was — there is no stack
/// internally, so round-tripping through
/// [`Self::set_clip_rects`] with the snapshot is how to compose.
pub fn clip_bounds( &self ) -> Vec<Rect>
{
match self
{
Canvas::Software( c ) => c.clip_bounds_snapshot(),
Canvas::Gles( c ) => c.clip_bounds_snapshot(),
}
}
pub fn clear_clip( &mut self )
{
match self
{
Canvas::Software( c ) => c.clear_clip(),
Canvas::Gles( c ) => c.clear_clip(),
}
}
pub fn clear( &mut self )
{
match self
{
Canvas::Software( c ) => c.clear(),
Canvas::Gles( c ) => c.clear(),
}
}
pub fn fill( &mut self, color: Color )
{
match self
{
Canvas::Software( c ) => c.fill( color ),
Canvas::Gles( c ) => c.fill( color ),
}
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( c ) => c.fill_rect( rect, color, corners ),
Canvas::Gles( c ) => c.fill_rect( rect, color, corners ),
}
}
/// Paint-driven rectangle fill.
///
/// Dispatches on the [`crate::theme::Paint`] variant. Solid
/// fills go straight through [`Self::fill_rect`]. Gradients
/// (linear and radial) are routed to dedicated shaders on the
/// GPU backend; on the Software backend they still collapse to a
/// flat fill from the first stop — tiny-skia can render
/// gradients natively, but wiring that up is left for a
/// follow-up.
pub fn fill_paint_rect( &mut self, rect: Rect, paint: &ThemePaint, corners: impl Into<Corners> )
{
let corners = corners.into();
match paint
{
ThemePaint::Solid( c ) => self.fill_rect( rect, *c, corners ),
ThemePaint::Linear( g ) =>
{
match self
{
Canvas::Software( sc ) =>
{
let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT );
sc.fill_rect( rect, c, corners );
}
Canvas::Gles( gc ) => gc.fill_linear_gradient_rect( rect, g, corners ),
}
}
ThemePaint::Radial( g ) =>
{
match self
{
Canvas::Software( sc ) =>
{
let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT );
sc.fill_rect( rect, c, corners );
}
Canvas::Gles( gc ) => gc.fill_radial_gradient_rect( rect, g, corners ),
}
}
}
}
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( c ) => c.stroke_rect( rect, color, width, corners ),
Canvas::Gles( c ) => c.stroke_rect( rect, color, width, corners ),
}
}
/// Paint an outer drop shadow behind the rounded rect `target`.
///
/// On the GPU backend this runs an analytic soft-shadow shader
/// in one draw call — no FBO, no cache, no readback. On the
/// Software backend it is a no-op today.
pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( _ ) => { /* TODO: tiny-skia BlurDropShadow */ }
Canvas::Gles( c ) => c.fill_shadow_outer( target, shadow, corners ),
}
}
/// Paint an inner (inset) shadow inside the rounded rect
/// `target`.
///
/// On the GPU backend, uses a dedicated shader whose inner SDF
/// encodes `shadow.offset` and `shadow.spread`. The blend state
/// is switched per-call to honour `shadow.blend`: `Normal`,
/// `PlusLighter`, `Multiply` and `Screen` map to fixed-function
/// blend modes; `Overlay` routes through a dedicated shader that
/// snapshots the FBO and computes the CSS Overlay formula
/// in-shader.
///
/// On the Software backend this is a no-op today.
pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: impl Into<Corners> )
{
let corners = corners.into();
match self
{
Canvas::Software( _ ) => { /* TODO: tiny-skia inner shadow */ }
Canvas::Gles( c ) => c.fill_shadow_inset( target, shadow, corners ),
}
}
/// Unified surface painter. Composes a themed surface in the canonical
/// paint order: outer shadows → fill → insets.
pub fn fill_surface
(
&mut self,
rect: Rect,
fill: &ThemePaint,
outer_shadows: &[Shadow],
inset_shadows: &[InsetShadow],
corners: impl Into<Corners>,
)
{
let corners = corners.into();
for shadow in outer_shadows
{
self.fill_shadow_outer( rect, shadow, corners );
}
self.fill_paint_rect( rect, fill, corners );
for inset in inset_shadows
{
self.fill_shadow_inset( rect, inset, corners );
}
}
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
match self
{
Canvas::Software( c ) => c.draw_line( x0, y0, x1, y1, color, width ),
Canvas::Gles( c ) => c.draw_line( x0, y0, x1, y1, color, width ),
}
}
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
{
match self
{
Canvas::Software( c ) => c.draw_text( text, x, y, size, color ),
Canvas::Gles( c ) => c.draw_text( text, x, y, size, color ),
}
}
/// Draw `text` with an explicitly supplied font instead of the
/// canvas default. Use [`Self::font_for`] to resolve a `(family,
/// weight, style)` triple from the active theme registry first.
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
{
match self
{
Canvas::Software( c ) => c.draw_text_with_font( text, x, y, size, color, font ),
Canvas::Gles( c ) => c.draw_text_with_font( text, x, y, size, color, font ),
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
match self
{
Canvas::Software( c ) => c.measure_text( text, size ),
Canvas::Gles( c ) => c.measure_text( text, size ),
}
}
/// Width of `text` rendered with `font`. Mirrors
/// [`Self::measure_text`] but bypasses the canvas default font so
/// text laid out at one weight and drawn at another stays aligned.
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
match self
{
Canvas::Software( c ) => c.measure_text_with_font( text, size, font ),
Canvas::Gles( c ) => c.measure_text_with_font( text, size, font ),
}
}
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
{
match self
{
Canvas::Software( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ),
Canvas::Gles( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ),
}
}
/// Zero pixels inside each rect — used by the partial-redraw
/// path when the surface background is fully transparent.
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
match self
{
Canvas::Software( c ) => c.clear_rects_transparent( rects ),
Canvas::Gles( c ) => c.clear_rects_transparent( rects ),
}
}
/// Copy / present the rendered frame. For software this fills a
/// `wl_shm` buffer (with optional R/B swap for Argb8888). For
/// GPU the commit happens via `eglSwapBuffers` elsewhere — this
/// call is a no-op.
pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool )
{
match self
{
Canvas::Software( c ) => c.write_to_wayland_buf( buf, swap_rb ),
Canvas::Gles( _ ) => {}
}
}
/// Publish the in-progress GPU frame: blit the FBO onto the EGL
/// window's default framebuffer. The follow-up `eglSwapBuffers`
/// (done outside the canvas) is what actually commits to the
/// compositor. No-op on software, where presentation is the SHM
/// `attach_to`/`commit` pair.
pub fn present( &mut self )
{
match self
{
Canvas::Software( _ ) => {}
Canvas::Gles( c ) => c.present(),
}
}
}

103
src/render/primitives.rs Normal file
View File

@@ -0,0 +1,103 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Primitive draw ops for [`SoftwareCanvas`]: clear, solid fill,
//! rounded-rect fill, stroke, line. tiny-skia does the heavy
//! lifting; this file just converts from ltk's `Rect` / `Color` to
//! tiny-skia's and threads `global_alpha` + `clip_mask` through
//! every call.
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
use crate::types::{ Color, Corners, Rect };
use super::helpers::build_rounded_rect;
use super::SoftwareCanvas;
impl SoftwareCanvas
{
pub fn clear( &mut self )
{
self.pixmap.fill( tiny_skia::Color::TRANSPARENT );
}
pub fn fill( &mut self, color: Color )
{
if self.clip_mask.is_none()
{
self.pixmap.fill( color.to_tiny_skia() );
return;
}
let w = self.pixmap.width() as f32;
let h = self.pixmap.height() as f32;
let Some( ts_rect ) = tiny_skia::Rect::from_ltrb( 0.0, 0.0, w, h ) else { return };
let mut paint = Paint::default();
paint.set_color( color.to_tiny_skia() );
self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() );
}
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
{
let pw = self.pixmap.width() as f32;
let ph = self.pixmap.height() as f32;
if rect.x + rect.width < 0.0 || rect.x > pw
|| rect.y + rect.height < 0.0 || rect.y > ph { return; }
let Some( ts_rect ) = rect.to_tiny_skia() else { return };
let mut paint = Paint::default();
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
paint.set_color( adjusted_color.to_tiny_skia() );
paint.anti_alias = true;
if !corners.is_zero()
{
if let Some( path ) = build_rounded_rect( ts_rect, corners )
{
self.pixmap.fill_path(
&path,
&paint,
tiny_skia::FillRule::Winding,
Transform::identity(),
self.clip_mask.as_ref(),
);
}
} else {
self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() );
}
}
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners )
{
let Some( ts_rect ) = rect.to_tiny_skia() else { return };
let mut paint = Paint::default();
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
paint.set_color( adjusted_color.to_tiny_skia() );
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = width;
if !corners.is_zero()
{
if let Some( path ) = build_rounded_rect( ts_rect, corners )
{
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
} else {
let path = PathBuilder::from_rect( ts_rect );
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
}
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
{
let mut pb = PathBuilder::new();
pb.move_to( x0, y0 );
pb.line_to( x1, y1 );
let Some( path ) = pb.finish() else { return };
let mut paint = Paint::default();
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
paint.set_color( adjusted_color.to_tiny_skia() );
paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = width;
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
}
}

124
src/render/setup.rs Normal file
View File

@@ -0,0 +1,124 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Construction + accessors for [`SoftwareCanvas`]: new / sub_canvas
//! / resize plus the font-registry installer and `blit`.
use std::collections::HashMap;
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings };
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::load_default_font_bytes;
use super::SoftwareCanvas;
/// Process-wide cache of the default font face. Avoids re-reading +
/// re-parsing the file on every surface bring-up. Sora is small
/// (~50 KB) so the cost was minor in absolute terms — but a layer
/// shell that brings up a launcher overlay, a QS panel, a calendar
/// popup and a handful of toast surfaces would still pay the parse
/// cost a dozen times in a single session, all of which is wasted
/// work.
static DEFAULT_FONT: OnceLock<Arc<Font>> = OnceLock::new();
fn default_font() -> Arc<Font>
{
Arc::clone( DEFAULT_FONT.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
Arc::new( font )
} ) )
}
impl SoftwareCanvas
{
/// Create a canvas of the given pixel dimensions, loading a system font.
///
/// Fallback fonts (Noto Sans / CJK / Devanagari / …) are NOT loaded
/// here — they are owned by the crate-private system-fonts chain
/// and loaded lazily per codepoint. A canvas that only ever paints
/// Latin text will never touch those files; the first non-Latin
/// glyph triggers a single targeted load and the rest of the
/// process reuses the cached `Arc<Font>`.
pub fn new( width: u32, height: u32 ) -> Self
{
Self
{
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
font: default_font(),
font_registry: None,
dpi_scale: 1.0,
global_alpha: 1.0,
glyph_cache: HashMap::new(),
clip_mask: None,
clip_bounds: Vec::new(),
}
}
/// Create a blank sub-canvas sharing the same font and DPI scale.
pub fn sub_canvas( &self, width: u32, height: u32 ) -> SoftwareCanvas
{
SoftwareCanvas
{
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
font: Arc::clone( &self.font ),
font_registry: self.font_registry.as_ref().map( Arc::clone ),
dpi_scale: self.dpi_scale,
global_alpha: self.global_alpha,
glyph_cache: HashMap::new(),
clip_mask: None,
clip_bounds: Vec::new(),
}
}
/// Install a theme font registry so [`Self::font_for`] can
/// resolve family+weight+style triples declared by the theme's
/// `fonts` block. The default [`Self::font`] stays in place as a
/// fallback.
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
{
self.font_registry = Some( registry );
}
/// Resolve a specific font from the theme registry, falling back
/// to the canvas' default [`Self::font`] when no registry is
/// installed or the triple cannot be satisfied.
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<Font>
{
self.font_registry
.as_ref()
.and_then( |r| r.resolve( family, weight, style ) )
.unwrap_or_else( || Arc::clone( &self.font ) )
}
/// Pick the right font for `ch`. Tries the primary [`Self::font`]
/// first; on a miss, delegates to the crate-private system-fonts
/// fallback chain (lazy load of the relevant Noto pack). Falls
/// back to the primary (which paints a `.notdef` box) when no
/// installed fallback covers the codepoint.
pub fn font_for_char( &self, ch: char ) -> Arc<Font>
{
if self.font.lookup_glyph_index( ch ) != 0
{
return Arc::clone( &self.font );
}
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
}
pub fn blit( &mut self, src: &SoftwareCanvas, dest_x: i32, dest_y: i32 )
{
let paint = PixmapPaint::default();
let t = Transform::from_translate( dest_x as f32, dest_y as f32 );
self.pixmap.draw_pixmap( 0, 0, src.pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
}
pub fn resize( &mut self, width: u32, height: u32 )
{
self.pixmap = Pixmap::new( width, height ).expect( "pixmap" );
}
}

203
src/render/text.rs Normal file
View File

@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text rendering for [`SoftwareCanvas`]. fontdue rasterises each
//! glyph once into the persistent [`super::GlyphEntry`] cache; the
//! per-frame hot path just lays out positions and blends cached
//! bitmaps into the pixmap with the active clip mask + global alpha.
use std::sync::Arc;
use fontdue::Font;
use crate::types::Color;
use super::{ GlyphEntry, GlyphKey, SoftwareCanvas };
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
/// Stable identifier for an `Arc<Font>`: the address of the font's
/// allocation. Different reweights / families always live in
/// distinct allocations, so the address is enough to disambiguate
/// glyph cache entries.
fn font_id( font: &Arc<Font> ) -> usize
{
Arc::as_ptr( font ) as usize
}
impl SoftwareCanvas
{
/// Per-glyph default font lookup. Routes through
/// [`Self::font_for_char`], which already consults the lazy
/// system-font fallback chain. Returns an owned [`Arc<Font>`] so
/// callers can hold the handle across `&mut self` borrows of the
/// glyph cache.
fn default_font_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
{
let font = self.font_for_char( ch );
let id = Arc::as_ptr( &font ) as usize;
( id, font )
}
pub ( super ) fn rasterize_cached( &mut self, ch: char, scaled: f32 ) -> &GlyphEntry
{
let ( id, font ) = self.default_font_for_char( ch );
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
/// Pick the font to use for `ch` given a "preferred" override.
/// Falls through to the canvas default + Noto chain when the
/// preferred font does not own the glyph — so Sora Bold rendering
/// of CJK / Devanagari / etc. still works.
fn font_for_char_with_pref( &self, ch: char, pref: &Arc<Font> ) -> ( usize, Arc<Font> )
{
if pref.lookup_glyph_index( ch ) != 0
{
return ( font_id( pref ), Arc::clone( pref ) );
}
self.default_font_for_char( ch )
}
fn rasterize_cached_with( &mut self, ch: char, scaled: f32, pref: &Arc<Font> ) -> &GlyphEntry
{
let ( id, font ) = self.font_for_char_with_pref( ch, pref );
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
self.evict_if_full( &key );
if !self.glyph_cache.contains_key( &key )
{
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
}
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
}
fn evict_if_full( &mut self, key: &GlyphKey )
{
if !self.glyph_cache.contains_key( key )
&& self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
{
let drop_n = self.glyph_cache.len() / 2;
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
for k in victims
{
self.glyph_cache.remove( &k );
}
}
}
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
{
self.draw_text_inner( text, x, y, size, color, None );
}
/// Draw `text` using the explicitly supplied font instead of the
/// canvas default + fallback chain.
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
{
self.draw_text_inner( text, x, y, size, color, Some( font ) );
}
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
{
let scaled = size * self.dpi_scale;
let line_h = scaled * 1.5;
let ph = self.pixmap.height() as f32;
let pw = self.pixmap.width() as f32;
if y + line_h < 0.0 || y - line_h > ph { return; }
if x > pw { return; }
if self.has_clip() && !self.strip_intersects_clip( y - line_h, y + 0.5 * line_h )
{
return;
}
let mut layout: Vec<( GlyphKey, f32 )> = Vec::with_capacity( text.chars().count() );
{
let mut cursor_x = x;
for ch in text.chars()
{
let ( id, advance ) = match font
{
Some( f ) =>
{
let id = self.font_for_char_with_pref( ch, f ).0;
let advance = self.rasterize_cached_with( ch, scaled, f ).metrics.advance_width;
( id, advance )
}
None =>
{
let id = self.default_font_for_char( ch ).0;
let advance = self.rasterize_cached( ch, scaled ).metrics.advance_width;
( id, advance )
}
};
layout.push( ( GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }, cursor_x ) );
cursor_x += advance;
}
}
let w = self.pixmap.width() as i32;
let h = self.pixmap.height() as i32;
let cr = (color.r * 255.0) as u8;
let cg = (color.g * 255.0) as u8;
let cb = (color.b * 255.0) as u8;
let color_a = color.a * self.global_alpha;
let pixels = self.pixmap.data_mut();
let cache = &self.glyph_cache;
let mask_data = self.clip_mask.as_ref().map( |m| ( m.data(), m.width() as i32 ) );
for ( key, cursor_x ) in layout
{
let entry = cache.get( &key ).expect( "warmed above" );
let metrics = &entry.metrics;
let bitmap = &entry.bitmap;
for ( i, &alpha ) in bitmap.iter().enumerate()
{
if alpha == 0 { continue; }
let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32;
let py = y as i32
- metrics.ymin as i32
- metrics.height as i32
+ 1
+ (i / metrics.width) as i32;
if px < 0 || py < 0 || px >= w || py >= h { continue; }
if let Some( ( md, mw ) ) = mask_data
{
if md[ ( py * mw + px ) as usize ] == 0 { continue; }
}
let idx = (py as usize * w as usize + px as usize) * 4;
let a = (alpha as f32 / 255.0) * color_a;
let inv = 1.0 - a;
pixels[idx] = (cr as f32 * a + pixels[idx] as f32 * inv) as u8;
pixels[idx + 1] = (cg as f32 * a + pixels[idx + 1] as f32 * inv) as u8;
pixels[idx + 2] = (cb as f32 * a + pixels[idx + 2] as f32 * inv) as u8;
let a_dst = pixels[idx + 3] as f32 / 255.0;
pixels[idx + 3] = ( ( a + a_dst * inv ) * 255.0 ) as u8;
}
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
{
text.chars().map( |ch|
{
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
{
text.chars().map( |ch|
{
let ( _, picked ) = self.font_for_char_with_pref( ch, font );
picked.metrics( ch, size * self.dpi_scale ).advance_width
} ).sum()
}
}

78
src/secure_mem.rs Normal file
View File

@@ -0,0 +1,78 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Defensive primitives for credential handling.
//!
//! [`secure_zero`] overwrites a byte slice with zeros using volatile stores
//! so the optimiser cannot elide the wipe even when it can prove the buffer
//! is no longer read. It is the building block the `Drop` impls of secure
//! widgets (currently [`crate::widget::text_edit::TextEdit`] when
//! `secure( true )` is set) use to scrub credential text before the heap is
//! returned to the allocator.
//!
//! This is the minimal stand-in for the well-known `zeroize` crate: ltk is
//! a UI toolkit, the only call site is text-input wiping, and the cost of
//! pulling another dependency is not justified.
use core::ptr;
use core::sync::atomic::{ compiler_fence, Ordering };
/// Overwrite `buf` with zeros. The writes go through `write_volatile` so
/// the compiler treats them as observable side effects — the elision pass
/// cannot drop them even when the buffer is about to be freed.
///
/// A `compiler_fence(SeqCst)` after the loop pins the wipe to "before any
/// later memory operation", so a subsequent `Drop` that hands the
/// underlying allocation back to the allocator cannot be reordered above
/// the zero stores.
pub( crate ) fn secure_zero( buf: &mut [u8] )
{
for b in buf.iter_mut()
{
// SAFETY: writing a primitive byte through a unique mutable
// reference; volatile reflects the intent that the store has an
// observer beyond ordinary Rust semantics (the ex-credential).
unsafe { ptr::write_volatile( b, 0u8 ); }
}
compiler_fence( Ordering::SeqCst );
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn empty_slice_is_a_noop()
{
let mut buf: [u8; 0] = [];
secure_zero( &mut buf );
// Nothing to assert beyond "did not panic" — exercised so the
// fence + zero-iter loop compiles for the empty case.
}
#[ test ]
fn fills_every_byte_with_zero()
{
let mut buf = [ 0xAAu8; 64 ];
secure_zero( &mut buf );
assert!( buf.iter().all( |&b| b == 0 ) );
}
#[ test ]
fn wipes_a_credential_string_in_place()
{
let mut password = String::from( "hunter2" );
// SAFETY: as_mut_vec lets us reach the underlying byte buffer.
// We only write zeros, leaving an empty / NUL-filled UTF-8 byte
// sequence which is still valid UTF-8 (NUL is U+0000).
let bytes = unsafe { password.as_mut_vec() };
secure_zero( bytes );
assert!( bytes.iter().all( |&b| b == 0 ) );
// After the wipe the String is technically all NULs, not empty;
// the consumer drops it immediately so the heap allocation is
// returned to the allocator already overwritten.
assert_eq!( password.len(), 7 );
assert!( password.bytes().all( |b| b == 0 ) );
}
}

133
src/system_fonts.rs Normal file
View File

@@ -0,0 +1,133 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Lazy per-glyph fallback font resolution.
//!
//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora)
//! covers Latin and a portion of extended Latin; everything outside
//! that band — Cyrillic, Devanagari, Arabic, Hebrew, Thai, the CJK
//! ideographic block — relies on a chain of system Noto fonts loaded
//! on demand: a fallback file is read off disk the first time some
//! glyph asks for it and the resulting `Arc<Font>` is cached
//! process-wide.
//!
//! Each `(path, face)` entry in [`FALLBACK_FONT_CANDIDATES`] gets its
//! own `OnceLock<Option<Arc<Font>>>` slot. `Some(font)` means the
//! file was read and parsed successfully; `None` means it's missing
//! or unparseable, locked in for the rest of the process so we don't
//! re-`stat` the same path on every glyph miss. The `OnceLock`
//! handles concurrent first-loads correctly: at most one thread runs
//! the closure for a given slot, the rest wait for the result.
//!
//! Renderers (`SoftwareCanvas` and `GlesCanvas`) consult this module
//! through [`lookup`] in their `font_for_char` paths.
use std::sync::{ Arc, OnceLock };
use fontdue::{ Font, FontSettings };
/// Per-script fallback font path with the TrueType-collection face
/// index fontdue should load (most files are single-face → 0; CJK
/// `.ttc` archives carry many faces, see the SC face on the canonical
/// Adobe-built `NotoSansCJK-Regular.ttc`).
struct FallbackFontSpec
{
path: &'static str,
face: u32,
}
/// Ordered fallback chain consulted on a glyph miss. Order matters:
/// the first slot whose font owns a non-zero glyph index for the
/// codepoint wins, so the broadest families come first (Noto Sans
/// covers Cyrillic, Greek, extended Latin) and the script-specific
/// + CJK packs trail. DejaVu is the last resort — most distros carry
/// it under one path or another.
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
&[
// Noto Sans — Cyrillic, Greek, extended Latin.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSans-Regular.ttf", face: 0 },
// Devanagari (Hindi).
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSansDevanagari-Regular.ttf", face: 0 },
// Arabic, Hebrew, Thai — common scripts cheap to keep.
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", face: 0 },
// CJK packs ship as `.ttc`. The collection layout on the canonical
// Adobe-built `NotoSansCJK-Regular.ttc` is 0=JP, 1=KR, 2=SC, 3=TC,
// 4=HK; we want CJK shared ideographs available in any locale, so
// any of the faces is fine — pick SC (face 2) as the broadest
// baseline. ~30 MB on disk; under the lazy loader this only fires
// when a user-visible string actually contains a CJK codepoint.
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", face: 2 },
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
FallbackFontSpec { path: "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
// Last resort — DejaVu has broad-but-shallow coverage of most
// scripts and ships almost everywhere.
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
];
/// Per-slot lazy state. `Vec` length matches
/// [`FALLBACK_FONT_CANDIDATES`]; each `OnceLock` resolves
/// independently so the act of looking up a Devanagari codepoint
/// doesn't drag the CJK pack into memory. `None` inside a resolved
/// slot means the file was missing or fontdue rejected it — a sticky
/// negative result so subsequent misses skip the slot in O(1).
fn slots() -> &'static [ OnceLock<Option<Arc<Font>>> ]
{
static SLOTS: OnceLock<Vec<OnceLock<Option<Arc<Font>>>>> = OnceLock::new();
SLOTS.get_or_init( ||
{
( 0..FALLBACK_FONT_CANDIDATES.len() )
.map( |_| OnceLock::new() )
.collect()
} )
}
/// Try to load and parse the fallback font at slot `idx`. Each
/// `OnceLock` wraps `Option<Arc<Font>>` so a missing or malformed
/// file is recorded as `None` and never re-attempted.
fn slot_font( idx: usize ) -> Option<Arc<Font>>
{
let slot = &slots()[ idx ];
slot.get_or_init( ||
{
let spec = &FALLBACK_FONT_CANDIDATES[ idx ];
let bytes = std::fs::read( spec.path ).ok()?;
let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() };
Font::from_bytes( bytes.as_slice(), opts ).ok().map( Arc::new )
} )
.as_ref()
.map( Arc::clone )
}
/// Find the first fallback font that has a non-zero glyph index for
/// `ch`, loading it from disk on the first hit and caching the
/// `Arc<Font>` for the rest of the process. Returns `None` if no
/// installed fallback covers the codepoint — the caller then paints
/// the primary font's `.notdef` rather than dropping the glyph.
///
/// Side effect: walking the chain may load and cache a slot even if
/// it doesn't end up covering `ch` (`lookup_glyph_index` reads the
/// `cmap` table, which requires the font to be parsed). That's
/// acceptable — the slot is cached on the first encounter regardless,
/// and most coverage gaps in early slots are the small Noto Sans
/// scripts (Devanagari, Arabic, …) whose total weight is a fraction
/// of the CJK pack everyone was paying for unconditionally.
pub fn lookup( ch: char ) -> Option<Arc<Font>>
{
for idx in 0..FALLBACK_FONT_CANDIDATES.len()
{
let Some( font ) = slot_font( idx ) else { continue };
if font.lookup_glyph_index( ch ) != 0
{
return Some( font );
}
}
None
}

111
src/theme/document.rs Normal file
View File

@@ -0,0 +1,111 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The top-level theme document: metadata, fonts block and per-mode slots.
//!
//! This is the runtime representation of a fully parsed `theme.json`,
//! and the single source of truth for the active theme — installed via
//! [`super::set_active_document`] and read back via
//! [`super::active_document`].
//!
//! # Modes
//!
//! `fonts` is shared between light and dark and only what actually differs
//! — slot tables, wallpaper paths, optional window-controls overrides — is
//! nested under `modes.light` / `modes.dark`.
use std::collections::HashMap;
use std::path::{ Path, PathBuf };
use super::fonts::FontFamilyDef;
use super::slots::SlotStore;
use super::{ search_paths, LauncherSpec, ThemeError, WallpaperSpec, WindowControlsSpec };
// ─── Mode ────────────────────────────────────────────────────────────────────
/// Per-variant content: the slot table plus the surfaces that don't fit
/// cleanly in slots (wallpaper image, window-controls payload).
#[ derive( Debug, Clone ) ]
pub struct Mode
{
/// Homescreen / shell wallpaper. Absent means "solid background, no
/// image" (the renderer falls back to whatever the theme's surface slot
/// resolves to for the viewport).
pub wallpaper: Option<WallpaperSpec>,
/// Lockscreen / greeter wallpaper. Distinct from `wallpaper` because the
/// lockscreen is typically quieter (often a darker crop).
pub lockscreen: Option<WallpaperSpec>,
/// Launcher surface styling. Aggregate (background + border_radius)
/// rather than a slot because the radius is a scalar that widgets treat
/// as theme-imposed geometry, not a design-system colour token.
pub launcher: Option<LauncherSpec>,
/// Window-decoration controls payload. Kept out of the slot table
/// because it is a contract with an external app, not a
/// design-system token.
pub window_controls: Option<WindowControlsSpec>,
/// The typed slot table for this mode.
pub slots: SlotStore,
}
// ─── ThemeDocument ───────────────────────────────────────────────────────────
/// A fully parsed theme, as loaded from a `theme.json` on disk.
#[ derive( Debug, Clone ) ]
pub struct ThemeDocument
{
/// Stable identifier used to look up the theme across search paths.
pub id: String,
/// Human-readable display name.
pub name: String,
/// Directory the document was loaded from. `None` for documents built
/// in-memory (e.g. test fixtures).
pub root: Option<PathBuf>,
/// Font families declared in the document. Indexed by the id used by
/// [`super::FontRef::Named`]. The same registry is shared between modes.
pub fonts: HashMap<String, FontFamilyDef>,
/// Light-mode content.
pub light: Mode,
/// Dark-mode content.
pub dark: Mode,
}
impl ThemeDocument
{
/// Return the mode matching `mode`.
pub fn mode( &self, mode: super::ThemeMode ) -> &Mode
{
match mode
{
super::ThemeMode::Light => &self.light,
super::ThemeMode::Dark => &self.dark,
}
}
/// Load a document from a directory containing a `theme.json`.
///
/// Paths inside the document (wallpaper, lockscreen, font sources) are
/// resolved against `dir`.
pub fn load_from_dir( dir: &Path ) -> Result<Self, ThemeError>
{
super::schema::load_document_from_dir( dir )
}
/// Look up a document by id across the standard search paths.
///
/// Order, highest priority first:
/// 1. `$LTK_THEMES_DIR/<id>/` (when the env var is set)
/// 2. `$XDG_DATA_HOME/ltk/themes/<id>/` (defaults to `~/.local/share/...`)
/// 3. `/usr/share/ltk/themes/<id>/`
pub fn find( id: &str ) -> Result<Self, ThemeError>
{
for base in search_paths()
{
let dir = base.join( id );
if dir.join( "theme.json" ).is_file()
{
return Self::load_from_dir( &dir );
}
}
Err( ThemeError::NotFound( id.to_string() ) )
}
}

View File

@@ -0,0 +1,134 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: sora
Upstream-Contact: The Sora Project Authors
Files-Excluded: docs fonts/*
Source: https://github.com/sora-xor/sora-font
Files: *
Copyright: 2019-2020 The Sora Project Authors
2020 Jonathan Barnbrook <jb@barnbrook.net>
2020 Julián Moncada <julian@moncada.work>
License: OFL-1.1
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
.
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
.
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
.
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Files: sources/sora-stat-table.py
Copyright: 2020 Google Sans Authors
License: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you
may not use this file except in compliance with the License. You may
obtain a copy of the License at
.
http://www.apache.org/licenses/LICENSE-2.0
.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing
permissions and limitations under the License.
.
On Debian systems, the complete text of the Apache version 2.0
license can be found in "/usr/share/common-licenses/Apache-2.0".
Files: debian/*
Copyright: 2020-2024 Alex Myczko <tar@debian.org>
License: GPL-2+
This package is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
.
This package is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>
.
On Debian systems, the complete text of the GNU General
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".

Binary file not shown.

112
src/theme/fallback/mod.rs Normal file
View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! "Last resort" assets used when the canonical `default` theme or the
//! system font search chain come up empty. Two pieces:
//!
//! * [`document`] builds a black/white [`ThemeDocument`] with the
//! eight canonical palette slots populated — no wallpaper, no
//! launcher spec, no decorated surface slots. Widgets that look up
//! surface or shadow slots fall through to their flat-colour branch;
//! the UI is usable but not decorated.
//! * [`FALLBACK_FONT`] bundles Sora Regular (~50 KB, SIL OFL 1.1) so
//! `Canvas::new` / `Canvas::new_gles` can always load a font even
//! on systems without `fonts-sora`, `fonts-liberation` or
//! `fonts-dejavu`.
//!
//! Both are activated by [`super::ensure_active`] when
//! `ThemeDocument::find("default")` fails. The crate stamps every
//! frame with a red banner so the user can't miss the
//! "install `ltk-theme-default`" signal.
//!
//! # Font licence
//!
//! `Sora-Regular.otf` is distributed under the SIL Open Font Licence
//! 1.1. The full licence text is in
//! [`Sora-LICENSE.txt`](./Sora-LICENSE.txt) next to this source
//! file. A binary-only redistribution must carry the OFL text
//! alongside — see the crate's `debian/copyright`.
use std::collections::HashMap;
use crate::types::Color;
use super::slots::{ Metadata, Slot, SlotStore };
use super::{ Mode, ThemeDocument };
/// Sora Regular, OFL 1.1. Loaded by `Canvas::new` / `Canvas::new_gles`
/// when no system font is found through the candidate chain. ~50 KB,
/// one weight, one style — enough to render the fallback banner and
/// any app that only asks for default typography.
pub( crate ) const FALLBACK_FONT: &[u8] = include_bytes!( "Sora-Regular.otf" );
/// Build the B/W fallback document. Eight canonical palette slots per
/// mode (`bg-page`, `surface`, `surface-alt`, `text-primary`,
/// `text-secondary`, `accent`, `divider`, `icon`), no wallpaper, no
/// launcher, no window_controls, no fonts registered (the default
/// canvas font is used for every widget).
///
/// Light mode: pure white backgrounds, pure black text + accent.
/// Dark mode: pure black backgrounds, pure white text + accent.
/// `accent` doubles as brand colour and focus-ring colour; setting
/// it to black/white keeps the fallback visually consistent with the
/// rest of the palette and gives the focus ring maximum contrast.
pub( super ) fn document() -> ThemeDocument
{
ThemeDocument
{
id: "fallback".to_string(),
name: "Fallback (B/W)".to_string(),
root: None,
fonts: HashMap::new(),
light: light_mode(),
dark: dark_mode(),
}
}
fn mk_color_slot( c: Color ) -> Slot
{
Slot::Color { value: c, meta: Metadata::default() }
}
fn light_mode() -> Mode
{
let mut slots = SlotStore::new();
slots.insert( "bg-page", mk_color_slot( Color::WHITE ) );
slots.insert( "surface", mk_color_slot( Color::WHITE ) );
slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.95, 0.95, 0.95 ) ) );
slots.insert( "text-primary", mk_color_slot( Color::BLACK ) );
slots.insert( "text-secondary", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ) );
slots.insert( "accent", mk_color_slot( Color::BLACK ) );
slots.insert( "divider", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.1 ) ) );
slots.insert( "icon", mk_color_slot( Color::BLACK ) );
Mode
{
wallpaper: None,
lockscreen: None,
launcher: None,
window_controls: None,
slots,
}
}
fn dark_mode() -> Mode
{
let mut slots = SlotStore::new();
slots.insert( "bg-page", mk_color_slot( Color::BLACK ) );
slots.insert( "surface", mk_color_slot( Color::BLACK ) );
slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.1, 0.1, 0.1 ) ) );
slots.insert( "text-primary", mk_color_slot( Color::WHITE ) );
slots.insert( "text-secondary", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.6 ) ) );
slots.insert( "accent", mk_color_slot( Color::WHITE ) );
slots.insert( "divider", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.1 ) ) );
slots.insert( "icon", mk_color_slot( Color::WHITE ) );
Mode
{
wallpaper: None,
lockscreen: None,
launcher: None,
window_controls: None,
slots,
}
}

357
src/theme/font_registry.rs Normal file
View File

@@ -0,0 +1,357 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime font registry: families → (weight, style) → `fontdue::Font`.
//!
//! The registry is the live, loaded-in-memory counterpart of the theme's
//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load
//! time by calling [`FontRegistry::from_families`], then handed to the
//! render backends as an `Arc<FontRegistry>` so they can resolve specific
//! sources on demand without re-reading TTFs from disk.
//!
//! # Resolution order
//!
//! [`FontRegistry::resolve`] looks up a family / weight / style triple with
//! the following precedence:
//!
//! 1. Exact `(family, weight, style)` match.
//! 2. Same family and style, weight closest to the request (absolute diff).
//! 3. Same family, any style, weight closest to the request.
//! 4. Walk the family's `fallbacks` chain, recursing into each fallback
//! family with the original weight/style.
//!
//! When nothing matches, [`FontRegistry::resolve`] returns `None`; callers
//! fall back to the canvas' system font.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use fontdue::{ Font, FontSettings };
use super::fonts::FontFamilyDef;
use super::text_style::FontStyle;
// ─── Key ─────────────────────────────────────────────────────────────────────
/// Composite key identifying a single font source within a family.
///
/// `family` is the id under which the family was declared in the theme's
/// `fonts` block (e.g. `"sora"`), not its human-readable name.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub struct FontKey
{
pub family: String,
pub weight: u16,
pub style: FontStyle,
}
// ─── Errors ──────────────────────────────────────────────────────────────────
/// Error raised when loading a font source fails.
#[ derive( Debug ) ]
pub enum FontLoadError
{
/// The source file could not be read.
Io( PathBuf, std::io::Error ),
/// The source file was read but `fontdue` rejected its contents.
Parse( PathBuf, String ),
}
impl std::fmt::Display for FontLoadError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
FontLoadError::Io( p, e ) => write!( f, "reading font {}: {}", p.display(), e ),
FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ),
}
}
}
impl std::error::Error for FontLoadError {}
// ─── Registry ────────────────────────────────────────────────────────────────
/// A loaded font registry: the theme's declared families materialised into
/// live `fontdue::Font` handles, indexed by family id / weight / style.
#[ derive( Debug, Default ) ]
pub struct FontRegistry
{
by_key: HashMap<FontKey, Arc<Font>>,
fallbacks: HashMap<String, Vec<String>>,
}
impl FontRegistry
{
/// Create an empty registry. Use [`Self::insert`] / [`Self::set_fallbacks`]
/// to populate it, or [`Self::from_families`] to load a whole theme in
/// one go.
pub fn new() -> Self
{
Self { by_key: HashMap::new(), fallbacks: HashMap::new() }
}
/// Register a single font source.
pub fn insert
(
&mut self,
family: impl Into<String>,
weight: u16,
style: FontStyle,
font: Arc<Font>,
)
{
self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
}
/// Set the fallback chain for a family.
pub fn set_fallbacks( &mut self, family: impl Into<String>, chain: Vec<String> )
{
self.fallbacks.insert( family.into(), chain );
}
/// Number of loaded sources across all families. Useful in tests.
pub fn len( &self ) -> usize { self.by_key.len() }
/// Whether the registry has no sources loaded.
pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }
/// Resolve a triple to a loaded [`Font`]. See the module docs for the
/// precedence order.
pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
{
// 1. Exact match.
let exact = FontKey { family: family.to_string(), weight, style };
if let Some( f ) = self.by_key.get( &exact )
{
return Some( Arc::clone( f ) );
}
// 2. Same family + style, closest weight.
let best_same_style = self.by_key.iter()
.filter( |( k, _ )| k.family == family && k.style == style )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_style
{
return Some( Arc::clone( f ) );
}
// 3. Same family, any style, closest weight.
let best_same_family = self.by_key.iter()
.filter( |( k, _ )| k.family == family )
.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
if let Some( ( _, f ) ) = best_same_family
{
return Some( Arc::clone( f ) );
}
// 4. Walk fallback chain.
if let Some( chain ) = self.fallbacks.get( family )
{
for fb in chain
{
if let Some( f ) = self.resolve( fb, weight, style )
{
return Some( f );
}
}
}
None
}
/// Load every source declared in `families` into the registry.
///
/// `families` is keyed by family id (the string the theme JSON uses in
/// `fonts.<id>` and that [`super::FontRef::Named`] references). The
/// family's `name` field is carried for human display only; lookups go
/// through the id.
pub fn from_families
(
families: &HashMap<String, FontFamilyDef>,
) -> Result<Self, FontLoadError>
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = std::fs::read( &src.path )
.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
Ok( reg )
}
/// Like [`Self::from_families`] but tolerant: sources that fail to load
/// are logged via `eprintln!` and skipped instead of aborting the whole
/// registry build. Intended for the runtime path where a missing TTF
/// on a user's machine should degrade to "fall back to system font for
/// that weight" rather than crash the shell.
///
/// Returns a registry that may be partial — callers cannot assume any
/// specific weight is loaded, only that what COULD be loaded is
/// loaded. Font resolution's fallback ladder (family → closest weight
/// → fallback chain → `Canvas::font`) handles the gaps gracefully.
pub fn from_families_lenient
(
families: &HashMap<String, FontFamilyDef>,
) -> Self
{
let mut reg = Self::new();
for ( id, family ) in families
{
if !family.fallbacks.is_empty()
{
reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
}
for src in &family.sources
{
let bytes = match std::fs::read( &src.path )
{
Ok( b ) => b,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
{
Ok( f ) => f,
Err( e ) =>
{
eprintln!
(
"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
src.path.display(), src.weight, src.style, e
);
continue;
}
};
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
}
}
reg
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
/// Load a real TTF from the system via the same search chain the
/// software canvas uses. Only used by tests that need an actual
/// `Font` — skipped when no font is found (keeps CI green on images
/// without the usual system fonts).
fn system_font() -> Option<Arc<Font>>
{
let path = crate::render::helpers::find_font_opt()?;
let bytes = std::fs::read( path ).ok()?;
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
Some( Arc::new( font ) )
}
#[ test ]
fn empty_registry_resolves_to_none()
{
let reg = FontRegistry::new();
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() );
assert!( reg.is_empty() );
}
#[ test ]
fn exact_match_wins()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() );
assert_eq!( reg.len(), 2 );
}
#[ test ]
fn closest_weight_is_picked_when_exact_missing()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) );
reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
// Ask for 400: closer to 300 than to 700. Both rounds 100 away or
// 300 away respectively; 300 wins.
assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
// Ask for 800: closer to 700.
assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() );
}
#[ test ]
fn fallback_chain_is_walked_when_family_unknown()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
reg.set_fallbacks( "display", vec![ "sora".to_string() ] );
// `display` has no direct entries, but its fallback chain leads to
// `sora` which is loaded.
assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() );
}
#[ test ]
fn unreachable_family_returns_none()
{
let Some( font ) = system_font() else { return; };
let mut reg = FontRegistry::new();
reg.insert( "sora", 400, FontStyle::Normal, font );
assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() );
}
#[ test ]
fn from_families_reports_io_error_for_missing_path()
{
use crate::theme::fonts::FontSource;
let mut fams = HashMap::new();
fams.insert( "sora".to_string(), FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: Vec::new(),
sources: vec!
[
FontSource
{
weight: 400,
style: FontStyle::Normal,
path: "/this/path/does/not/exist.ttf".into(),
},
],
});
match FontRegistry::from_families( &fams )
{
Err( FontLoadError::Io( p, _ ) ) =>
{
assert!( p.to_string_lossy().contains( "does/not/exist" ) );
}
other => panic!( "expected Io error, got {:?}", other ),
}
}
}

77
src/theme/fonts.rs Normal file
View File

@@ -0,0 +1,77 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Font family definitions as declared by the theme document.
//!
//! This module only models the **declaration**: a family name, a set of
//! source files indexed by weight/style, and a fallback chain. Loading the
//! `.ttf` bytes into `fontdue` and resolving text styles against a live
//! registry is the runtime registry's job (see [`super::FontRegistry`]).
use std::path::PathBuf;
use super::text_style::FontStyle;
// ─── FontFamilyDef ───────────────────────────────────────────────────────────
/// A font family as declared by the theme document.
///
/// `sources` is a flat list indexed by weight + style, not a nested map, so
/// the JSON round-trips without ambiguity on ordering.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct FontFamilyDef
{
/// Human-readable family name (e.g. `"Sora"`). Shown in telemetry and
/// in any eventual font-picker UI.
pub name: String,
/// Fallback chain if the family or a given weight/style is missing.
/// Resolved in order: the first entry that the font registry can
/// satisfy is used.
pub fallbacks: Vec<String>,
/// One source per weight/style the family ships. The runtime font
/// registry registers each source in `fontdue` at load time.
pub sources: Vec<FontSource>,
}
// ─── FontSource ──────────────────────────────────────────────────────────────
/// A single font file, specified by its numeric weight and style.
#[ derive( Debug, Clone, PartialEq, Eq ) ]
pub struct FontSource
{
/// CSS numeric weight (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Path to the `.ttf` / `.otf` file. Resolved to absolute at load time
/// (relative paths are taken to be relative to the theme directory root).
pub path: PathBuf,
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn family_def_holds_multiple_weights_of_same_family()
{
let sora = FontFamilyDef
{
name: "Sora".to_string(),
fallbacks: vec![ "system-ui".to_string(), "sans-serif".to_string() ],
sources: vec!
[
FontSource { weight: 300, style: FontStyle::Normal, path: "fonts/Sora-Light.ttf".into() },
FontSource { weight: 400, style: FontStyle::Normal, path: "fonts/Sora-Regular.ttf".into() },
FontSource { weight: 600, style: FontStyle::Normal, path: "fonts/Sora-SemiBold.ttf".into() },
FontSource { weight: 700, style: FontStyle::Normal, path: "fonts/Sora-Bold.ttf".into() },
],
};
assert_eq!( sora.sources.len(), 4 );
assert_eq!( sora.sources[2].weight, 600 );
assert_eq!( sora.fallbacks[0], "system-ui" );
}
}

304
src/theme/gradient_lut.rs Normal file
View File

@@ -0,0 +1,304 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! CPU-side gradient sampling and LUT baking.
//!
//! The GPU gradient path in `gles_render` shaders samples a 1D lookup
//! texture baked on the CPU: for each gradient we precompute N equally
//! spaced samples across an extended `t` domain (so stops outside
//! `[0, 1]` are covered without extra shader logic), already colour-space
//! converted, and upload those N × 4 bytes as an RGBA8 texture.
//!
//! # Extrapolation
//!
//! Stops whose `position` falls outside `[0, 1]` are supported by
//! **linear extrapolation**: below the first stop we prolong the
//! `(first, second)` slope, above the last stop we prolong the
//! `(last-1, last)` slope. Values are not clamped. This is the
//! physically correct behaviour for CSS `linear-gradient` stops defined
//! with positions outside the visible range.
//!
//! # Colour spaces
//!
//! [`GradientSpace::Srgb`] interpolates raw sRGB channels (cheap, looks
//! muddy on saturated gradients). [`GradientSpace::LinearRgb`] — the
//! default — converts each stop to linear light, interpolates, and
//! converts the result back to sRGB. [`GradientSpace::Oklab`] is not yet
//! implemented and silently falls back to `LinearRgb`.
use crate::types::Color;
use super::paint::{ ColorStop, GradientSpace };
/// How many samples the LUT stores along the `t` axis.
pub const LUT_SAMPLES: usize = 512;
/// Extended `t` domain the LUT covers. Wide enough to comfortably contain
/// the typical out-of-`[0, 1]` stops produced by design-tool exports.
pub const LUT_DOMAIN: ( f32, f32 ) = ( -2.0, 3.0 );
// ─── sRGB ↔ linear ───────────────────────────────────────────────────────────
/// Convert one sRGB gamma-encoded channel to linear light.
#[ inline ]
pub fn srgb_to_linear( x: f32 ) -> f32
{
if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf( 2.4 ) }
}
/// Convert one linear-light channel to sRGB gamma-encoded.
#[ inline ]
pub fn linear_to_srgb( x: f32 ) -> f32
{
if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf( 1.0 / 2.4 ) - 0.055 }
}
// ─── Sampling ────────────────────────────────────────────────────────────────
/// Sample the stops at position `t` using the requested interpolation
/// space. Stops need not be sorted. `t` may fall outside `[0, 1]`.
pub fn sample_stops( stops: &[ColorStop], t: f32, space: GradientSpace ) -> Color
{
if stops.is_empty() { return Color::TRANSPARENT; }
if stops.len() == 1 { return stops[0].color; }
// Sort by position. We do this each call because gradients hold at
// most a handful of stops and the LUT builder only calls us N times
// per gradient build (not per pixel).
let mut sorted: Vec<&ColorStop> = stops.iter().collect();
sorted.sort_by( |a, b|
a.position.partial_cmp( &b.position ).unwrap_or( std::cmp::Ordering::Equal )
);
// Pick the bracketing pair. Below the first stop we extrapolate from
// `(first, second)`; above the last, from `(last-1, last)`.
let n = sorted.len();
let ( a, b ) = if t <= sorted[0].position
{
( sorted[0], sorted[1] )
}
else if t >= sorted[n - 1].position
{
( sorted[n - 2], sorted[n - 1] )
}
else
{
let mut pair = ( sorted[0], sorted[1] );
for win in sorted.windows( 2 )
{
if t >= win[0].position && t <= win[1].position
{
pair = ( win[0], win[1] );
break;
}
}
pair
};
let dt = b.position - a.position;
let u = if dt.abs() < 1e-6 { 0.0 } else { ( t - a.position ) / dt };
mix_colors( a.color, b.color, u, space )
}
/// Linear mix of two colours at parameter `u` (not clamped — the caller
/// has already chosen the right bracketing pair).
fn mix_colors( a: Color, b: Color, u: f32, space: GradientSpace ) -> Color
{
let alpha = a.a + ( b.a - a.a ) * u;
match space
{
GradientSpace::Srgb => Color
{
r: a.r + ( b.r - a.r ) * u,
g: a.g + ( b.g - a.g ) * u,
b: a.b + ( b.b - a.b ) * u,
a: alpha,
},
GradientSpace::LinearRgb => mix_linear( a, b, u, alpha ),
// TODO: proper Oklab mix. For now fall back to linear-light.
GradientSpace::Oklab => mix_linear( a, b, u, alpha ),
}
}
fn mix_linear( a: Color, b: Color, u: f32, alpha: f32 ) -> Color
{
let ar = srgb_to_linear( a.r );
let ag = srgb_to_linear( a.g );
let ab = srgb_to_linear( a.b );
let br = srgb_to_linear( b.r );
let bg = srgb_to_linear( b.g );
let bb = srgb_to_linear( b.b );
let r = linear_to_srgb( ar + ( br - ar ) * u );
let g = linear_to_srgb( ag + ( bg - ag ) * u );
let b = linear_to_srgb( ab + ( bb - ab ) * u );
Color { r, g, b, a: alpha }
}
// ─── LUT baking ──────────────────────────────────────────────────────────────
/// Build an RGBA8 LUT of `LUT_SAMPLES` equally spaced samples spanning
/// [`LUT_DOMAIN`]. The returned vector has `LUT_SAMPLES * 4` bytes in
/// straight-alpha, row-major. The GPU shader expects this layout and
/// premultiplies at sample time.
pub fn build_lut_bytes( stops: &[ColorStop], space: GradientSpace ) -> Vec<u8>
{
let ( t0, t1 ) = LUT_DOMAIN;
let n = LUT_SAMPLES;
let mut out = Vec::with_capacity( n * 4 );
for i in 0..n
{
let t = t0 + ( t1 - t0 ) * ( i as f32 / ( n - 1 ) as f32 );
let c = sample_stops( stops, t, space );
out.push( ( c.r.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.g.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.b.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
out.push( ( c.a.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 );
}
out
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
fn approx( a: f32, b: f32 ) -> bool { ( a - b ).abs() < 2e-2 }
#[ test ]
fn srgb_linear_roundtrip_is_stable()
{
for &v in &[ 0.0, 0.04, 0.1, 0.25, 0.5, 0.75, 1.0 ]
{
let back = linear_to_srgb( srgb_to_linear( v ) );
assert!( ( v - back ).abs() < 1e-5, "roundtrip {} -> {}", v, back );
}
}
#[ test ]
fn sample_at_exact_stop_returns_that_stop()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
assert_eq!( a, Color::WHITE );
assert_eq!( b, Color::BLACK );
}
#[ test ]
fn sample_midpoint_srgb_is_halfway()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
];
let m = sample_stops( &stops, 0.5, GradientSpace::Srgb );
assert!( approx( m.r, 0.5 ) && approx( m.g, 0.5 ) && approx( m.b, 0.5 ) );
}
#[ test ]
fn sample_midpoint_linear_rgb_is_brighter_than_srgb()
{
// Classic demonstration that linear-light midpoint is brighter
// than the naive sRGB midpoint when interpolating 0 → 1.
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) },
ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) },
];
let m_srgb = sample_stops( &stops, 0.5, GradientSpace::Srgb );
let m_linear = sample_stops( &stops, 0.5, GradientSpace::LinearRgb );
assert!( m_linear.r > m_srgb.r, "linear {:?} should be brighter than srgb {:?}", m_linear, m_srgb );
}
#[ test ]
fn extrapolation_below_first_stop_continues_slope()
{
// stops at 0.0 (white) and 1.0 (black). Extrapolating to -1.0
// along the same slope lands above 1.0 — we do NOT clamp; the
// LUT baker will clip when converting to u8.
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let c = sample_stops( &stops, -1.0, GradientSpace::Srgb );
// Slope is (-1, -1, -1) per unit of t; at t=-1 we get rgb=(2,2,2).
assert!( c.r > 1.0, "extrapolation should exceed 1.0 before clamp: {}", c.r );
}
#[ test ]
fn extrapolation_above_last_stop_continues_slope()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgb( 0.2, 0.2, 0.2 ) },
ColorStop { position: 1.0, color: Color::rgb( 0.8, 0.8, 0.8 ) },
];
let c = sample_stops( &stops, 2.0, GradientSpace::Srgb );
// Slope (0.6, 0.6, 0.6) per unit. At t=2.0, rgb=(1.4, …) — exceeds 1.0.
assert!( c.r > 1.0, "extrapolation should exceed 1.0: {}", c.r );
}
#[ test ]
fn alpha_mixes_linearly_across_spaces()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) },
ColorStop { position: 1.0, color: Color::rgba( 1.0, 0.0, 0.0, 0.0 ) },
];
for space in [ GradientSpace::Srgb, GradientSpace::LinearRgb, GradientSpace::Oklab ]
{
let m = sample_stops( &stops, 0.5, space );
assert!( approx( m.a, 0.5 ), "alpha should mix linearly in {:?}: got {}", space, m.a );
}
}
#[ test ]
fn unsorted_stops_are_handled()
{
let stops = vec!
[
ColorStop { position: 1.0, color: Color::BLACK },
ColorStop { position: 0.0, color: Color::WHITE },
];
let a = sample_stops( &stops, 0.0, GradientSpace::Srgb );
let b = sample_stops( &stops, 1.0, GradientSpace::Srgb );
assert_eq!( a, Color::WHITE );
assert_eq!( b, Color::BLACK );
}
#[ test ]
fn build_lut_has_expected_length()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let bytes = build_lut_bytes( &stops, GradientSpace::LinearRgb );
assert_eq!( bytes.len(), LUT_SAMPLES * 4 );
}
#[ test ]
fn build_lut_clips_extrapolation_to_u8_range()
{
let stops = vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::BLACK },
];
let bytes = build_lut_bytes( &stops, GradientSpace::Srgb );
// All bytes must be in [0, 255] — no panics from out-of-range casts.
for b in &bytes { let _ = *b; }
}
}

1103
src/theme/mod.rs Normal file

File diff suppressed because it is too large Load Diff

207
src/theme/paint.rs Normal file
View File

@@ -0,0 +1,207 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Paint primitives: the "what do we fill a shape with" side of theming.
//!
//! A [`Paint`] is either a flat [`Color`], a [`LinearGradient`] or a
//! [`RadialGradient`]. Gradients carry their colour stops, a direction (angle
//! for linear, center+radius for radial) and the [`GradientSpace`] in which
//! stops are interpolated. Sampling happens downstream in the renderer; these
//! types are pure data and have no rendering logic of their own.
//!
//! # Stop positions
//!
//! Stops are represented as fractions (`0.0..=1.0` for the caller's mental
//! model) but the [`ColorStop::position`] field accepts **values outside
//! that range**. This is intentional: design-tool exports often emit
//! gradients whose stops fall outside `[0, 1]`, meaning the visible region
//! of the shape only covers a middle slice of the interpolation. The
//! renderer is expected to extrapolate linearly, not clamp.
//!
//! # Colour space
//!
//! Picking the right interpolation space matters for saturated gradients:
//! interpolating `#04D9FE → #8A38F5` in sRGB produces a muddy grey in the
//! middle, while Oklab keeps the chroma. The space is resolved at theme-load
//! time (stops converted once), not per pixel.
use crate::types::Color;
// ─── Gradient space ──────────────────────────────────────────────────────────
/// Colour space in which a gradient's stops are interpolated.
///
/// The default is [`GradientSpace::LinearRgb`]: cheap, physically correct, and
/// a clear win over naive sRGB interpolation. [`GradientSpace::Oklab`] is kept
/// as an opt-in for brand gradients with high-chroma endpoints where even
/// linear-light shows an undesirable darkening in the mid-point. [`GradientSpace::Srgb`]
/// exists primarily to reproduce designs that were authored against it
/// byte-for-byte.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum GradientSpace
{
/// Interpolate directly in sRGB gamma-encoded space. Cheap, but midpoints
/// of saturated gradients look muddy.
Srgb,
/// Interpolate in linear-light RGB. Default. Physically correct and fast.
LinearRgb,
/// Interpolate in the Oklab perceptual colour space. Best for saturated
/// brand gradients; slightly more expensive.
Oklab,
}
impl Default for GradientSpace
{
fn default() -> Self { GradientSpace::LinearRgb }
}
// ─── Colour stops ────────────────────────────────────────────────────────────
/// One stop of a gradient: a `position` along the gradient axis (for linear)
/// or along the radius (for radial), plus the [`Color`] at that point.
///
/// `position` is a fraction but **may fall outside `[0.0, 1.0]`**. See the
/// module-level note on extrapolation.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct ColorStop
{
/// Position along the gradient. `0.0` is the start, `1.0` is the end;
/// values outside this range are allowed and will be extrapolated.
pub position: f32,
/// Colour at this position.
pub color: Color,
}
// ─── Linear gradient ─────────────────────────────────────────────────────────
/// A straight gradient swept along a vector set by an angle.
///
/// The angle convention follows CSS `linear-gradient`: `0deg` points from the
/// bottom edge towards the top, `90deg` from left to right, `180deg` from top
/// to bottom, and so on.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct LinearGradient
{
/// Direction of the gradient, in degrees (CSS convention).
pub angle_deg: f32,
/// Stops along the axis, in source order. The renderer does not require
/// them to be sorted by position — it sorts internally — but keeping them
/// in increasing order is conventional and makes diffs readable.
pub stops: Vec<ColorStop>,
/// Space in which stops are interpolated. See [`GradientSpace`].
pub space: GradientSpace,
}
// ─── Radial gradient ─────────────────────────────────────────────────────────
/// A gradient radiating from a centre point out to a radius.
///
/// `center` and `radius` are expressed as **fractions of the bounding box**
/// (not pixels) so the gradient scales with the widget. `center: [0.5, 0.5]`
/// and `radius: 0.5` describes a circle inscribed in a square widget.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct RadialGradient
{
/// Centre of the gradient in box-relative coordinates, `[0.0, 1.0]`.
pub center: [f32; 2],
/// Radius of the gradient in box-relative units. `0.5` reaches the edge
/// of a square box, `1.0` reaches the corner diagonally.
pub radius: f32,
/// Stops along the radius, in source order.
pub stops: Vec<ColorStop>,
/// Space in which stops are interpolated. See [`GradientSpace`].
pub space: GradientSpace,
}
// ─── Paint ───────────────────────────────────────────────────────────────────
/// How a shape is filled: a flat colour or one of the gradient variants.
///
/// Theming consumers rarely construct [`Paint`] directly: a slot of kind
/// `color` is promoted to [`Paint::Solid`] automatically when the widget asks
/// for a paint. Only slots that actually declare a gradient in the theme JSON
/// round-trip through [`Paint::Linear`] / [`Paint::Radial`].
#[ derive( Debug, Clone, PartialEq ) ]
pub enum Paint
{
/// A uniform fill with a single [`Color`].
Solid( Color ),
/// A linear gradient sweep.
Linear( LinearGradient ),
/// A radial gradient sweep.
Radial( RadialGradient ),
}
impl Paint
{
/// Convenience constructor for the common solid case.
pub fn solid( color: Color ) -> Self
{
Paint::Solid( color )
}
}
impl From<Color> for Paint
{
fn from( c: Color ) -> Self { Paint::Solid( c ) }
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn stops_positions_outside_unit_range_are_accepted()
{
// Construction must succeed; the renderer will extrapolate.
let grad = LinearGradient
{
angle_deg: 152.77,
stops: vec!
[
ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) },
ColorStop { position: 1.2332, color: Color::WHITE },
],
space: GradientSpace::LinearRgb,
};
assert_eq!( grad.stops.len(), 2 );
assert!( grad.stops[0].position < 0.0 );
assert!( grad.stops[1].position > 1.0 );
}
#[ test ]
fn default_gradient_space_is_linear_rgb()
{
assert_eq!( GradientSpace::default(), GradientSpace::LinearRgb );
}
#[ test ]
fn paint_promotes_from_color()
{
let c = Color::hex( 0x04, 0xD9, 0xFE );
let p: Paint = c.into();
assert_eq!( p, Paint::Solid( c ) );
}
#[ test ]
fn radial_gradient_uses_box_relative_coordinates()
{
// Documenting the contract: center + radius are fractions, not pixels.
let g = RadialGradient
{
center: [ 0.5, 0.5 ],
radius: 0.5,
stops: vec!
[
ColorStop { position: 0.0, color: Color::WHITE },
ColorStop { position: 1.0, color: Color::TRANSPARENT },
],
space: GradientSpace::LinearRgb,
};
assert_eq!( g.center, [ 0.5, 0.5 ] );
assert_eq!( g.radius, 0.5 );
}
}

1449
src/theme/schema.rs Normal file

File diff suppressed because it is too large Load Diff

193
src/theme/shadow.rs Normal file
View File

@@ -0,0 +1,193 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Shadow primitives: outer drop shadows, inner inset shadows, and the
//! blend modes used to composite them.
//!
//! # Units
//!
//! [`Shadow::blur`] and [`InsetShadow::blur`] store the **CSS blur radius**,
//! not the SVG `stdDeviation`. The relationship is `blur = 2 × stdDeviation`,
//! which is what browsers compute for `box-shadow: … blur …`. The shader
//! integrates against `sigma`, so it applies `sigma = blur / 2` internally
//! (see [`Shadow::sigma`]).
//!
//! # Order
//!
//! A theme's `shadows` list is stored **back-to-front**, mirroring SVG's
//! `feBlend` stacking order. The first entry is painted first (lowest layer),
//! the last entry is painted last (topmost). This is the inverse of CSS
//! `box-shadow` string order. Documented here so the renderer loop (`for
//! shadow in shadows { ... }`) produces the visually correct result without
//! reversing.
use crate::types::Color;
// ─── Blend modes ─────────────────────────────────────────────────────────────
/// How a shadow composites against the layers below it.
///
/// All modes assume **premultiplied** colour and alpha. The GPU pipeline is
/// expected to be premul-correct; the software pipeline must premultiply
/// before applying these formulas.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum BlendMode
{
/// Standard `src-over`: `result = src + dst × (1 src.a)`.
Normal,
/// CSS `plus-lighter`: `result = min(1, src + dst)`, channel-wise on
/// premultiplied values. Adds light; never darkens.
PlusLighter,
/// Overlay (multiply on dark base, screen on light base). Preserves the
/// base's luminance while pushing local contrast.
Overlay,
/// Multiplicative blend: `result = src × dst`. Only darkens.
Multiply,
/// Screen blend: `result = 1 (1 src) × (1 dst)`. Only lightens.
Screen,
}
impl Default for BlendMode
{
fn default() -> Self { BlendMode::Normal }
}
// ─── Outer shadow ────────────────────────────────────────────────────────────
/// An outer drop shadow cast by a shape.
///
/// Modelled after CSS `box-shadow`: an offset, a blur radius, an optional
/// spread that dilates (positive) or erodes (negative) the silhouette before
/// blurring, a colour, and a blend mode.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Shadow
{
/// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward.
pub offset: [f32; 2],
/// CSS blur radius in pixels (2 × SVG `stdDeviation`).
pub blur: f32,
/// Spread in CSS pixels. Positive values dilate the silhouette before
/// blurring; negative values erode it. Usually `0.0`.
pub spread: f32,
/// Shadow colour, including alpha.
pub color: Color,
/// Compositing mode. Most drop shadows use [`BlendMode::Normal`].
pub blend: BlendMode,
}
impl Shadow
{
/// Gaussian sigma derived from the CSS blur radius.
///
/// The shader integrates Gaussian kernels against `sigma`, but the public
/// field stores the CSS blur radius for parity with CSS `box-shadow`.
/// The relationship is `sigma = blur / 2`.
pub fn sigma( &self ) -> f32 { self.blur * 0.5 }
}
// ─── Inner shadow ────────────────────────────────────────────────────────────
/// An inset shadow: a shadow painted **inside** the shape's silhouette, as
/// opposed to the outer drop shadow cast behind it.
///
/// Structurally identical to [`Shadow`]; kept as a separate type so the
/// renderer and theme JSON can't accidentally treat an inset as an outer
/// (and vice versa) — the dispatch is at the type level.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct InsetShadow
{
/// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward.
pub offset: [f32; 2],
/// CSS blur radius in pixels (2 × SVG `stdDeviation`).
pub blur: f32,
/// Spread in CSS pixels.
pub spread: f32,
/// Shadow colour, including alpha.
pub color: Color,
/// Compositing mode against the layers below. Insets routinely use
/// non-`Normal` modes (`PlusLighter` for highlights, `Overlay` for rim).
pub blend: BlendMode,
}
impl InsetShadow
{
/// Gaussian sigma derived from the CSS blur radius. See [`Shadow::sigma`].
pub fn sigma( &self ) -> f32 { self.blur * 0.5 }
}
// ─── Shadow reference ────────────────────────────────────────────────────────
/// How a [`crate::theme::Surface`] refers to its outer shadow stack: either
/// by name (reused across several surfaces — the common case for elevation
/// tokens) or inline (one-off, uncommon).
#[ derive( Debug, Clone, PartialEq ) ]
pub enum ShadowsRef
{
/// Reference to another slot in the theme, by id.
Named( String ),
/// The shadow list, carried inline. Used when the surface is exotic
/// enough that the stack isn't worth a dedicated slot.
Inline( Vec<Shadow> ),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn sigma_is_half_of_css_blur()
{
// CSS blur 4 → SVG stdDev 2. The shader needs sigma == stdDev == 2.
let s = Shadow
{
offset: [ 0.0, 2.0 ],
blur: 4.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ),
blend: BlendMode::Normal,
};
assert_eq!( s.sigma(), 2.0 );
}
#[ test ]
fn inset_sigma_follows_same_convention()
{
let i = InsetShadow
{
offset: [ -3.6, -3.6 ],
blur: 13.5,
spread: 0.0,
color: Color::hex( 0x55, 0x55, 0x55 ),
blend: BlendMode::PlusLighter,
};
assert!( ( i.sigma() - 6.75 ).abs() < 1e-6 );
}
#[ test ]
fn default_blend_mode_is_normal()
{
assert_eq!( BlendMode::default(), BlendMode::Normal );
}
#[ test ]
fn shadows_ref_distinguishes_named_and_inline()
{
let n = ShadowsRef::Named( "shadows-2".to_string() );
let i = ShadowsRef::Inline( vec!
[
Shadow
{
offset: [ 0.0, 4.0 ],
blur: 10.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.08 ),
blend: BlendMode::Normal,
},
]);
match n { ShadowsRef::Named( ref s ) => assert_eq!( s, "shadows-2" ), _ => panic!() }
match i { ShadowsRef::Inline( v ) => assert_eq!( v.len(), 1 ), _ => panic!() }
}
}

345
src/theme/slots.rs Normal file
View File

@@ -0,0 +1,345 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The slot-typed lookup table that backs the new theme API.
//!
//! A [`Slot`] is a typed entry in the theme document: it is always either a
//! [`Color`], a [`Paint`] (that is a gradient — solid colours live in the
//! `Color` variant), an outer [`Shadow`] stack, a composite [`Surface`] or
//! a [`TextStyle`]. Widgets resolve them through [`SlotStore`].
//!
//! # Promotion
//!
//! The store offers five accessors (`color`, `paint`, `shadows`, `surface`,
//! `text_style`). They promote automatically wherever it makes sense: asking
//! for a paint against a colour slot returns `Paint::Solid(c)`; asking for a
//! surface against a colour returns a `Surface` with `fill: Solid(c)` and
//! empty decorations; asking for a surface against a paint returns a
//! `Surface` wrapping that paint. Stricter requests that cannot be satisfied
//! (asking for a `TextStyle` against a colour, for example) return `None`.
use std::collections::HashMap;
use crate::types::Color;
use super::paint::Paint;
use super::shadow::Shadow;
use super::surface::Surface;
use super::text_style::TextStyle;
// ─── Metadata ────────────────────────────────────────────────────────────────
/// Optional free-form annotations a theme author can attach to any slot. The
/// runtime ignores these — inspection tools (and human readers of the JSON)
/// use them.
///
/// All fields are optional and default to `None`. Missing fields in the JSON
/// do not raise an error.
#[ derive( Debug, Clone, Default, PartialEq, Eq ) ]
pub struct Metadata
{
/// Canonical name of the token in the design system
/// (e.g. `"primary/500"`).
pub semantic: Option<String>,
/// Equivalent name in another system (e.g. `"NeutralColors.white"` for
/// Fluent). Useful when migrating from or cross-referencing other kits.
pub fluent: Option<String>,
/// Human-readable guidance on where to use this slot.
pub usage: Option<String>,
/// Free-form note, typically for quirks worth flagging in the JSON.
pub note: Option<String>,
}
// ─── Slot ────────────────────────────────────────────────────────────────────
/// One typed entry of the theme.
#[ derive( Debug, Clone, PartialEq ) ]
pub enum Slot
{
/// A single opaque or translucent colour.
Color { value: Color, meta: Metadata },
/// A gradient (linear or radial). Solid colours are kept in
/// [`Slot::Color`] — they do not round-trip through this variant.
Paint { value: Paint, meta: Metadata },
/// An ordered stack of outer shadows, typically an elevation level.
Shadows { value: Vec<Shadow>, meta: Metadata },
/// A composite surface: fill, outer shadows (ref or inline), inset
/// shadows and an optional backdrop.
Surface { value: Surface, meta: Metadata },
/// A resolved text style (family, weight, size, line-height, …).
TextStyle { value: TextStyle, meta: Metadata },
}
impl Slot
{
/// Annotations attached to the slot, if any.
pub fn metadata( &self ) -> &Metadata
{
match self
{
Slot::Color { meta, .. } => meta,
Slot::Paint { meta, .. } => meta,
Slot::Shadows { meta, .. } => meta,
Slot::Surface { meta, .. } => meta,
Slot::TextStyle { meta, .. } => meta,
}
}
/// Short tag identifying the slot's kind. Useful in error messages.
pub fn kind_tag( &self ) -> &'static str
{
match self
{
Slot::Color { .. } => "color",
Slot::Paint { .. } => "paint",
Slot::Shadows { .. } => "shadows",
Slot::Surface { .. } => "surface",
Slot::TextStyle { .. } => "typography",
}
}
}
// ─── Store ───────────────────────────────────────────────────────────────────
/// Lookup table indexed by slot id.
///
/// The store is immutable from a consumer's standpoint: themes are built
/// once at load time and handed to the renderer. All accessors return
/// references bound to the store's lifetime.
#[ derive( Debug, Clone, Default ) ]
pub struct SlotStore
{
entries: HashMap<String, Slot>,
}
impl SlotStore
{
/// Build an empty store. Used by tests and by the JSON loader.
pub fn new() -> Self
{
Self { entries: HashMap::new() }
}
/// Insert a slot under `id`. Returns the previous slot under that id if
/// there was one. Used by the JSON loader; not typically called from
/// widget code.
pub fn insert( &mut self, id: impl Into<String>, slot: Slot ) -> Option<Slot>
{
self.entries.insert( id.into(), slot )
}
/// The raw entry, if present.
pub fn get( &self, id: &str ) -> Option<&Slot>
{
self.entries.get( id )
}
/// Number of slots in the store.
pub fn len( &self ) -> usize { self.entries.len() }
/// Whether the store is empty.
pub fn is_empty( &self ) -> bool { self.entries.is_empty() }
// ─── Typed accessors ─────────────────────────────────────────────────
/// Resolve `id` to a [`Color`]. Only `Slot::Color` matches — gradients
/// do not promote down to a single colour.
pub fn color( &self, id: &str ) -> Option<Color>
{
match self.entries.get( id )?
{
Slot::Color { value, .. } => Some( *value ),
_ => None,
}
}
/// Resolve `id` to a [`Paint`]. A colour slot promotes to
/// [`Paint::Solid`]; a paint slot returns directly.
pub fn paint( &self, id: &str ) -> Option<Paint>
{
match self.entries.get( id )?
{
Slot::Color { value, .. } => Some( Paint::Solid( *value ) ),
Slot::Paint { value, .. } => Some( value.clone() ),
_ => None,
}
}
/// Resolve `id` to an outer-shadow stack. Only `Slot::Shadows` matches.
pub fn shadows( &self, id: &str ) -> Option<&[Shadow]>
{
match self.entries.get( id )?
{
Slot::Shadows { value, .. } => Some( value.as_slice() ),
_ => None,
}
}
/// Resolve `id` to a [`Surface`]. A colour slot promotes to a surface
/// with a solid fill and no decorations; a paint slot promotes to a
/// surface with that paint as fill and no decorations; a surface slot
/// returns directly.
pub fn surface( &self, id: &str ) -> Option<Surface>
{
match self.entries.get( id )?
{
Slot::Color { value, .. } => Some( Surface::from_paint( Paint::Solid( *value ) ) ),
Slot::Paint { value, .. } => Some( Surface::from_paint( value.clone() ) ),
Slot::Surface { value, .. } => Some( value.clone() ),
_ => None,
}
}
/// Resolve `id` to a [`TextStyle`]. Only `Slot::TextStyle` matches —
/// typography does not promote from anything else.
pub fn text_style( &self, id: &str ) -> Option<&TextStyle>
{
match self.entries.get( id )?
{
Slot::TextStyle { value, .. } => Some( value ),
_ => None,
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::theme::paint::{ ColorStop, GradientSpace, LinearGradient };
use crate::theme::shadow::{ BlendMode };
use crate::theme::text_style::{ FontRef, LineHeight };
fn sample_color_slot() -> ( &'static str, Slot )
{
(
"primary-500",
Slot::Color
{
value: Color::hex( 0x04, 0xD9, 0xFE ),
meta: Metadata { semantic: Some( "primary/500".to_string() ), ..Metadata::default() },
},
)
}
fn sample_gradient_slot() -> ( &'static str, Slot )
{
(
"gradient-error-light",
Slot::Paint
{
value: Paint::Linear( LinearGradient
{
angle_deg: 152.77,
stops: vec!
[
ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) },
ColorStop { position: 1.2332, color: Color::WHITE },
],
space: GradientSpace::LinearRgb,
}),
meta: Metadata::default(),
},
)
}
#[ test ]
fn color_slot_promotes_to_paint_and_surface()
{
let mut store = SlotStore::new();
let ( id, slot ) = sample_color_slot();
store.insert( id, slot );
assert!( store.color( id ).is_some() );
assert_eq!( store.color( id ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) );
assert!( matches!( store.paint( id ).unwrap(), Paint::Solid( _ ) ) );
let surface = store.surface( id ).unwrap();
assert!( matches!( surface.fill, Paint::Solid( _ ) ) );
assert!( surface.inset_shadows.is_empty() );
}
#[ test ]
fn paint_slot_does_not_downgrade_to_color()
{
let mut store = SlotStore::new();
let ( id, slot ) = sample_gradient_slot();
store.insert( id, slot );
assert!( store.color( id ).is_none(), "gradient must not answer as color" );
assert!( matches!( store.paint( id ).unwrap(), Paint::Linear( _ ) ) );
assert!( matches!( store.surface( id ).unwrap().fill, Paint::Linear( _ ) ) );
}
#[ test ]
fn shadows_and_text_style_are_strict()
{
let mut store = SlotStore::new();
let ( c_id, c_slot ) = sample_color_slot();
store.insert( c_id, c_slot );
// Color does not promote into shadows or text_style.
assert!( store.shadows( c_id ).is_none() );
assert!( store.text_style( c_id ).is_none() );
// A text-style slot answers as such.
store.insert
(
"body-m",
Slot::TextStyle
{
value: TextStyle::new
(
FontRef::Named( "sora".to_string() ),
400,
16.0,
LineHeight::Px( 24.0 ),
),
meta: Metadata::default(),
},
);
let ts = store.text_style( "body-m" ).unwrap();
assert_eq!( ts.size, 16.0 );
}
#[ test ]
fn missing_id_returns_none_everywhere()
{
let store = SlotStore::new();
assert!( store.color ( "nope" ).is_none() );
assert!( store.paint ( "nope" ).is_none() );
assert!( store.shadows ( "nope" ).is_none() );
assert!( store.surface ( "nope" ).is_none() );
assert!( store.text_style( "nope" ).is_none() );
}
#[ test ]
fn metadata_accessor_is_uniform_across_variants()
{
let ( id, slot ) = sample_color_slot();
assert_eq!( slot.metadata().semantic.as_deref(), Some( "primary/500" ) );
assert_eq!( slot.kind_tag(), "color" );
let shadow_slot = Slot::Shadows
{
value: vec!
[
Shadow
{
offset: [ 0.0, 2.0 ],
blur: 4.0,
spread: 0.0,
color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ),
blend: BlendMode::Normal,
},
],
meta: Metadata { note: Some( "elevation 1 - topmost layer".to_string() ), ..Default::default() },
};
assert_eq!( shadow_slot.kind_tag(), "shadows" );
assert!( shadow_slot.metadata().note.is_some() );
let _ = id;
}
}

107
src/theme/surface.rs Normal file
View File

@@ -0,0 +1,107 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Composite surfaces: fill + outer shadows + inset shadows.
//!
//! A [`Surface`] is the richest kind of theme slot. It packages together a
//! fill (possibly a gradient), an elevation stack, and inset shadows with
//! their own blend modes. Widgets that only need a flat colour or a plain
//! gradient do not construct [`Surface`] values directly; slot promotion
//! (in `theme::slots`) hands them a [`Surface`] with empty decorations when
//! they ask for one against a simpler slot.
use super::paint::Paint;
use super::shadow::{ InsetShadow, ShadowsRef };
use crate::types::Color;
// ─── Surface ─────────────────────────────────────────────────────────────────
/// A composite theme surface: fill, elevation, insets.
///
/// All decorations are optional. A surface with `fill: Paint::Solid(...)`,
/// no shadows and no insets behaves exactly like a flat-colour fill, which
/// is why the promotion path from a `color` slot to a [`Surface`] is trivial.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct Surface
{
/// What the surface is filled with.
pub fill: Paint,
/// Outer shadow stack. When `Some`, can reference another slot by name
/// or inline the full list.
pub shadows: Option<ShadowsRef>,
/// Inset shadows, in back-to-front order. Each entry carries its own
/// [`crate::theme::BlendMode`].
pub inset_shadows: Vec<InsetShadow>,
}
impl Surface
{
/// Build a surface from just a [`Paint`], with no shadows or insets.
pub fn from_paint( paint: Paint ) -> Self
{
Self
{
fill: paint,
shadows: None,
inset_shadows: Vec::new(),
}
}
}
impl From<Paint> for Surface
{
fn from( p: Paint ) -> Self { Surface::from_paint( p ) }
}
impl From<Color> for Surface
{
fn from( c: Color ) -> Self { Surface::from_paint( Paint::Solid( c ) ) }
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::types::Color;
use crate::theme::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef };
#[ test ]
fn surface_promotes_from_color()
{
let s: Surface = Color::hex( 0x04, 0xD9, 0xFE ).into();
assert_eq!( s.fill, Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ) );
assert!( s.shadows.is_none() );
assert!( s.inset_shadows.is_empty() );
}
#[ test ]
fn glass_accent_shape_composes()
{
let s = Surface
{
fill: Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ),
shadows: Some( ShadowsRef::Named( "shadows-glass".to_string() ) ),
inset_shadows: vec!
[
InsetShadow { offset: [ -3.6, -3.6 ], blur: 13.5, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter },
InsetShadow { offset: [ 1.8, 1.8 ], blur: 1.8, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter },
InsetShadow { offset: [ 0.45, 0.45 ], blur: 0.9, spread: 0.0, color: Color::BLACK, blend: BlendMode::Overlay },
InsetShadow { offset: [ 1.8, 1.8 ], blur: 7.2, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.15 ), blend: BlendMode::Normal },
],
};
assert_eq!( s.inset_shadows.len(), 4 );
assert_eq!( s.inset_shadows[0].blend, BlendMode::PlusLighter );
assert_eq!( s.inset_shadows[2].blend, BlendMode::Overlay );
let _ = Shadow
{
offset: [ 0.0, 0.0 ],
blur: 9.0,
spread: 0.0,
color: Color::rgba( 33.0 / 255.0, 33.0 / 255.0, 33.0 / 255.0, 0.25 ),
blend: BlendMode::Normal,
};
}
}

225
src/theme/text_style.rs Normal file
View File

@@ -0,0 +1,225 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Typography tokens: a resolved text style (family + weight + size + …) as
//! the theme knows it.
//!
//! The struct is deliberately named [`TextStyle`] rather than `Typography`
//! to avoid colliding with the [`super::typography`] constants module that
//! ships alongside it.
//!
//! # Resolution
//!
//! A [`TextStyle`] carries a [`FontRef`] (by name) and optionally a slot
//! reference for its default colour. Both are resolved lazily by the theme's
//! slot table: the string IDs live here, the actual font bytes and
//! [`crate::types::Color`] value come from the font registry and the slot
//! store respectively. This keeps the struct copy-cheap and JSON-friendly.
// ─── Font reference ──────────────────────────────────────────────────────────
/// Reference to a font family registered in the theme's `fonts` block.
///
/// A family name like `"sora"` maps to a [`super::FontFamilyDef`] that
/// lists the actual `.ttf` paths for each weight/style. Keeping the
/// reference by name lets multiple text styles share a family without
/// duplicating the source list.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub enum FontRef
{
/// Reference by family id, looked up in the theme's `fonts` block.
Named( String ),
}
// ─── Style axes ──────────────────────────────────────────────────────────────
/// Italic vs upright.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub enum FontStyle
{
Normal,
Italic,
}
impl Default for FontStyle
{
fn default() -> Self { FontStyle::Normal }
}
/// Case transform applied at render time.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextTransform
{
/// Render the string unchanged.
None,
/// Uppercase every character.
Uppercase,
/// Lowercase every character.
Lowercase,
/// Uppercase the first letter of each word, leave the rest untouched.
Capitalize,
}
impl Default for TextTransform
{
fn default() -> Self { TextTransform::None }
}
/// Underline / strikethrough decoration.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextDecoration
{
None,
Underline,
Strikethrough,
}
impl Default for TextDecoration
{
fn default() -> Self { TextDecoration::None }
}
// ─── Line height ─────────────────────────────────────────────────────────────
/// How the vertical advance between lines is specified.
///
/// Both forms are supported because design-tool exports usually emit absolute
/// pixel heights, while hand-authored themes more often prefer relative
/// line-heights that scale with the font size.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum LineHeight
{
/// Absolute pixel box height for the line.
Px( f32 ),
/// Multiplier of the font size. `1.5` means line-height is 1.5× the size.
Multiplier( f32 ),
}
impl LineHeight
{
/// Resolve to an absolute pixel height given the font size.
pub fn resolve( self, font_size: f32 ) -> f32
{
match self
{
LineHeight::Px( h ) => h,
LineHeight::Multiplier( m ) => font_size * m,
}
}
}
// ─── TextStyle ───────────────────────────────────────────────────────────────
/// A resolved text style: family, weight, size, line-height and the visual
/// modifiers that go with them.
///
/// `color` is a slot id (string) rather than a resolved [`crate::types::Color`]
/// because the slot store is what knows how to resolve names to values — and
/// widgets can override the colour with [`.color()`] at the call-site, which
/// always wins over the style's default.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct TextStyle
{
/// Font family, looked up in the theme's `fonts` block.
pub family: FontRef,
/// Weight as a CSS numeric value (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Font size in CSS pixels.
pub size: f32,
/// Line height.
pub line_height: LineHeight,
/// Letter spacing in `em` (fraction of the font size). `0.0` by default.
pub letter_spacing: f32,
/// Case transform applied before shaping.
pub transform: TextTransform,
/// Underline / strikethrough.
pub decoration: TextDecoration,
/// Default colour slot id. `None` means "inherit from the widget's own
/// colour setting". When `Some`, widgets that don't override with
/// [`.color()`] get this.
pub color: Option<String>,
}
impl TextStyle
{
/// Convenience constructor that fills the non-essential fields with
/// sensible defaults (upright, no transform, no decoration, no default
/// colour, no letter spacing).
pub fn new( family: FontRef, weight: u16, size: f32, line_height: LineHeight ) -> Self
{
Self
{
family,
weight,
style: FontStyle::Normal,
size,
line_height,
letter_spacing: 0.0,
transform: TextTransform::None,
decoration: TextDecoration::None,
color: None,
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn new_constructor_sets_sensible_defaults()
{
let s = TextStyle::new
(
FontRef::Named( "sora".to_string() ),
400,
16.0,
LineHeight::Px( 24.0 ),
);
assert_eq!( s.weight, 400 );
assert_eq!( s.size, 16.0 );
assert_eq!( s.line_height.resolve( 16.0 ), 24.0 );
assert_eq!( s.transform, TextTransform::None );
assert_eq!( s.letter_spacing, 0.0 );
assert!( s.color.is_none() );
}
#[ test ]
fn caption_l_upcases_at_render_time()
{
let s = TextStyle
{
family: FontRef::Named( "sora".to_string() ),
weight: 400,
style: FontStyle::Normal,
size: 16.0,
line_height: LineHeight::Px( 20.0 ),
letter_spacing: 0.0,
transform: TextTransform::Uppercase,
decoration: TextDecoration::None,
color: None,
};
assert_eq!( s.transform, TextTransform::Uppercase );
}
#[ test ]
fn line_height_multiplier_scales_with_size()
{
let lh = LineHeight::Multiplier( 1.5 );
assert_eq!( lh.resolve( 16.0 ), 24.0 );
assert_eq!( lh.resolve( 12.0 ), 18.0 );
}
#[ test ]
fn defaults_are_normal_and_none()
{
assert_eq!( FontStyle::default(), FontStyle::Normal );
assert_eq!( TextTransform::default(), TextTransform::None );
assert_eq!( TextDecoration::default(), TextDecoration::None );
}
}

90
src/tree.rs Executable file
View File

@@ -0,0 +1,90 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Point;
use crate::widget::{ LaidOutWidget, WidgetHandlers };
/// Hit test `pos` against the laid-out focusable widgets. Returns the
/// `flat_idx` of the topmost widget under the point, or `None` if the point
/// hits nothing focusable. Topmost-wins because layout pushes parents before
/// children — the reverse iteration order makes the visually-on-top widget
/// (drawn last) hit-tested first.
pub fn find_widget_at<Msg: Clone>(
widget_rects: &[ LaidOutWidget<Msg> ],
pos: Point,
) -> Option<usize>
{
for w in widget_rects.iter().rev()
{
if w.rect.contains( pos ) { return Some( w.flat_idx ); }
}
None
}
/// O(N) lookup of a single widget by `flat_idx`. N is the number of
/// *focusable leaves*, not the size of the [`crate::widget::Element`] tree.
/// If the hot path ever needs O(1), swap the per-surface `widget_rects`
/// slice for a `Vec` + a `HashMap<usize, usize>` index.
pub fn find_widget<'a, Msg: Clone>(
widget_rects: &'a [ LaidOutWidget<Msg> ],
flat_idx: usize,
) -> Option<&'a LaidOutWidget<Msg>>
{
widget_rects.iter().find( |w| w.flat_idx == flat_idx )
}
/// Convenience wrapper around [`find_widget`] that returns just the handler
/// snapshot — what most input dispatch sites actually want.
pub fn find_handlers<'a, Msg: Clone>(
widget_rects: &'a [ LaidOutWidget<Msg> ],
flat_idx: usize,
) -> Option<&'a WidgetHandlers<Msg>>
{
find_widget( widget_rects, flat_idx ).map( |w| &w.handlers )
}
/// Compute the next focusable widget for Tab / Shift+Tab navigation.
/// Returns the `flat_idx` of the next keyboard-focusable entry after the one
/// matching `current` (or wrapping around). `reverse` flips the direction
/// (Shift+Tab). Returns `None` when no entry has
/// [`LaidOutWidget::keyboard_focusable`] set — typical for a surface that only
/// hosts hit-testable chrome (e.g. a row of `WindowButton`s).
///
/// When `current` is `None` (no widget currently focused) the result is the
/// first or last keyboard-focusable widget depending on direction — matching
/// how desktop toolkits behave when Tab is pressed in an unfocused window.
///
/// Non-focusable interactive entries (chrome such as `WindowButton`) live in
/// the same `widget_rects` slice so pointer/touch hit testing finds them, but
/// are skipped here so they don't steal Tab focus from window content.
pub fn next_focusable_index<Msg: Clone>(
widget_rects: &[ LaidOutWidget<Msg> ],
current: Option<usize>,
reverse: bool,
) -> Option<usize>
{
// Project the slice down to just the keyboard-focusable entries. Tab
// cycles over this projection only — pointer-only chrome sits in
// `widget_rects` for hit testing but never receives keyboard focus.
let focusables: Vec<usize> = widget_rects
.iter()
.filter( |w| w.keyboard_focusable )
.map( |w| w.flat_idx )
.collect();
let n = focusables.len();
if n == 0 { return None; }
let current_pos = current.and_then( |fi| focusables.iter().position( |&i| i == fi ) );
let next = if reverse
{
current_pos
.map( |pos| focusables[ ( pos + n - 1 ) % n ] )
.unwrap_or( focusables[ n - 1 ] )
} else {
current_pos
.map( |pos| focusables[ ( pos + 1 ) % n ] )
.unwrap_or( focusables[ 0 ] )
};
Some( next )
}

432
src/types.rs Normal file
View File

@@ -0,0 +1,432 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Geometry and primitive value types used across the public API.
//!
//! These are the cheap, copy-friendly types that flow through every
//! widget builder, layout method and runtime hook:
//!
//! - [`Color`] — RGBA in `[0.0, 1.0]` floats; `Color::WHITE`,
//! `Color::BLACK`, `Color::TRANSPARENT` constants and a `Color::hex(r, g, b)`
//! constructor for byte literals.
//! - [`Rect`] — axis-aligned `(x, y, width, height)`; the universal
//! layout / hit-test currency.
//! - [`Point`] — a 2D point used by hit testing and gesture progress.
//! - [`Size`] — a `(width, height)` pair without an origin.
//! - [`Corners`] — per-corner radius for the
//! [`Container`](crate::container()) widget and any other rounded
//! surface; coerces from `f32` for the uniform case.
//! - [`WidgetId`] — a stable `&'static str` identifier for focus
//! management, paired with [`crate::App::take_focus_request`].
//!
//! Every type is `Copy` (or `Clone`) so passing them by value is the
//! default. The crate root re-exports them all (`ltk::Color`,
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
//! prefix.
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Color
{
/// Red channel `[0.0, 1.0]`.
pub r: f32,
/// Green channel `[0.0, 1.0]`.
pub g: f32,
/// Blue channel `[0.0, 1.0]`.
pub b: f32,
/// Alpha channel — `0.0` is fully transparent, `1.0` is fully opaque.
pub a: f32,
}
impl Color
{
/// Fully opaque white.
pub const WHITE: Self = Self { r: 1., g: 1., b: 1., a: 1. };
/// Fully opaque black.
pub const BLACK: Self = Self { r: 0., g: 0., b: 0., a: 1. };
/// Fully transparent black.
pub const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. };
/// Create an opaque color from 8-bit `r`, `g`, `b` components.
pub const fn hex( r: u8, g: u8, b: u8 ) -> Self
{
Self { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 }
}
/// Create an opaque color from float `r`, `g`, `b` components in `[0.0, 1.0]`.
pub fn rgb( r: f32, g: f32, b: f32 ) -> Self
{
Self { r, g, b, a: 1. }
}
/// Create a color from float `r`, `g`, `b`, `a` components in `[0.0, 1.0]`.
pub fn rgba( r: f32, g: f32, b: f32, a: f32 ) -> Self
{
Self { r, g, b, a }
}
/// Convert to a [`tiny_skia::Color`] for rendering.
pub fn to_tiny_skia( self ) -> tiny_skia::Color
{
tiny_skia::Color::from_rgba( self.r, self.g, self.b, self.a )
.unwrap_or( tiny_skia::Color::BLACK )
}
}
/// A 2-D point in screen coordinates (pixels, top-left origin).
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Point
{
/// Horizontal position in pixels.
pub x: f32,
/// Vertical position in pixels.
pub y: f32,
}
/// A width/height pair in pixels.
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Size
{
/// Width in pixels.
pub width: f32,
/// Height in pixels.
pub height: f32,
}
/// An axis-aligned rectangle in screen coordinates.
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Rect
{
/// Left edge in pixels.
pub x: f32,
/// Top edge in pixels.
pub y: f32,
/// Width in pixels.
pub width: f32,
/// Height in pixels.
pub height: f32,
}
impl Rect
{
/// Returns `true` if `p` lies inside or on the boundary of this rect.
pub fn contains( &self, p: Point ) -> bool
{
p.x >= self.x
&& p.x <= self.x + self.width
&& p.y >= self.y
&& p.y <= self.y + self.height
}
/// Returns a new rect grown by `amount` pixels on every side.
pub fn expand( &self, amount: f32 ) -> Self
{
Self
{
x: self.x - amount,
y: self.y - amount,
width: self.width + amount * 2.0,
height: self.height + amount * 2.0,
}
}
/// Convert to [`tiny_skia::Rect`], returning `None` if dimensions are non-positive.
pub fn to_tiny_skia( &self ) -> Option<tiny_skia::Rect>
{
tiny_skia::Rect::from_xywh( self.x, self.y, self.width, self.height )
}
}
/// Per-corner radii for a rounded rect, ordered top-left → top-right →
/// bottom-right → bottom-left (clockwise from top-left, matching CSS
/// `border-radius`'s long form). All four values are independent
/// pixel radii — set any subset to `0.0` for a square corner, or use
/// the [`top`](Self::top), [`bottom`](Self::bottom),
/// [`left`](Self::left), [`right`](Self::right) shortcuts for the
/// common asymmetric cases.
///
/// The renderer caps each corner against the inscribed-circle limit
/// `min(width, height) / 2`, mirroring tiny-skia / browser behaviour:
/// passing absurdly large values is a "make this side a pill" idiom
/// rather than an error.
///
/// `f32` and `(f32, f32, f32, f32)` both convert via [`From`] so any
/// API taking `impl Into<Corners>` accepts a uniform radius literal
/// (`.radius( 16.0 )`), an explicit set (`.radius( ( 16.0, 16.0,
/// 0.0, 0.0 ) )`), or a constructed value (`.radius( Corners::top(
/// 16.0 ) )`) interchangeably.
#[ derive( Debug, Clone, Copy, PartialEq, Default ) ]
pub struct Corners
{
/// Top-left corner radius in pixels.
pub tl: f32,
/// Top-right corner radius in pixels.
pub tr: f32,
/// Bottom-right corner radius in pixels.
pub br: f32,
/// Bottom-left corner radius in pixels.
pub bl: f32,
}
impl Corners
{
/// All four corners square (radius `0`).
pub const ZERO: Self = Self { tl: 0.0, tr: 0.0, br: 0.0, bl: 0.0 };
/// Uniform radius on every corner — equivalent to `r.into()` and
/// the most common construction.
pub const fn all( r: f32 ) -> Self
{
Self { tl: r, tr: r, br: r, bl: r }
}
/// Rounded top corners, square bottom corners. Matches the CSS
/// shorthand `border-radius: r r 0 0` and the typical "card sits
/// flush against the bottom of the screen" pattern (docks,
/// bottom-anchored modals).
pub const fn top( r: f32 ) -> Self
{
Self { tl: r, tr: r, br: 0.0, bl: 0.0 }
}
/// Rounded bottom corners, square top corners. Mirror of
/// [`top`](Self::top) for top-anchored chrome.
pub const fn bottom( r: f32 ) -> Self
{
Self { tl: 0.0, tr: 0.0, br: r, bl: r }
}
/// Rounded left corners, square right corners.
pub const fn left( r: f32 ) -> Self
{
Self { tl: r, tr: 0.0, br: 0.0, bl: r }
}
/// Rounded right corners, square left corners.
pub const fn right( r: f32 ) -> Self
{
Self { tl: 0.0, tr: r, br: r, bl: 0.0 }
}
/// `true` when every corner is `<= 0` — the renderer can take
/// the fast straight-rect path.
pub fn is_zero( &self ) -> bool
{
self.tl <= 0.0 && self.tr <= 0.0 && self.br <= 0.0 && self.bl <= 0.0
}
/// `true` when every corner has the same radius. Used by the
/// software path to fall back to the single-radius cubic builder
/// when the asymmetric path would produce an identical curve.
pub fn is_uniform( &self ) -> bool
{
self.tl == self.tr && self.tr == self.br && self.br == self.bl
}
/// The largest of the four radii. Useful for sizing the shader
/// quad's anti-alias pad — the worst-case AA band has to cover
/// the steepest curve.
pub fn max( &self ) -> f32
{
self.tl.max( self.tr ).max( self.br ).max( self.bl )
}
/// Cap every corner to `min(width, height) / 2`, the inscribed-
/// circle limit a rounded box can't exceed without degenerating.
/// Mirrors the clamp the GLES shader applies internally; software
/// path callers use it before building the path so the cubic
/// control points stay inside the rect.
pub fn clamp_to_size( &self, width: f32, height: f32 ) -> Self
{
let cap = ( width.min( height ) * 0.5 ).max( 0.0 );
Self
{
tl: self.tl.min( cap ).max( 0.0 ),
tr: self.tr.min( cap ).max( 0.0 ),
br: self.br.min( cap ).max( 0.0 ),
bl: self.bl.min( cap ).max( 0.0 ),
}
}
/// Pack as `[ tl, tr, br, bl ]` for `glUniform4fv`. Order
/// matches the `vec4 u_radii` convention every fragment shader
/// in `gles_render::shaders` reads.
pub fn to_uniform( &self ) -> [ f32; 4 ]
{
[ self.tl, self.tr, self.br, self.bl ]
}
}
impl From<f32> for Corners
{
fn from( r: f32 ) -> Self { Self::all( r ) }
}
impl From<( f32, f32, f32, f32 )> for Corners
{
/// Tuple form, ordered `( tl, tr, br, bl )` — matches CSS shorthand.
fn from( t: ( f32, f32, f32, f32 ) ) -> Self
{
Self { tl: t.0, tr: t.1, br: t.2, bl: t.3 }
}
}
/// A stable widget identifier used for focus management.
///
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
/// focus via [`App::take_focus_request`](crate::app::App::take_focus_request).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub struct WidgetId( pub &'static str );
/// Pointer cursor shape, sent to the compositor via
/// `wp_cursor_shape_v1` when the pointer enters a widget that
/// declares one. Mirrors `cursor_icon::CursorIcon` 1:1 so the
/// runtime can convert losslessly. Compositors that do not advertise
/// `wp_cursor_shape_v1` ignore these — the user sees their default
/// system cursor.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub enum CursorShape
{
Default,
ContextMenu,
Help,
/// "Hand" — clickable buttons, links.
Pointer,
/// "Spinning wheel" — work in progress, you can still interact.
Progress,
/// "Hourglass" — UI is busy and unresponsive.
Wait,
Cell,
Crosshair,
/// I-beam — text input fields.
Text,
VerticalText,
Alias,
Copy,
Move,
NoDrop,
NotAllowed,
/// Open hand — draggable but not yet dragging.
Grab,
/// Closed hand — currently dragging.
Grabbing,
EResize,
NResize,
NeResize,
NwResize,
SResize,
SeResize,
SwResize,
WResize,
EwResize,
NsResize,
NeswResize,
NwseResize,
ColResize,
RowResize,
AllScroll,
ZoomIn,
ZoomOut,
}
impl Default for CursorShape
{
fn default() -> Self { CursorShape::Default }
}
#[ cfg( test ) ]
mod tests
{
use super::*;
// ── Color ─────────────────────────────────────────────────────────────────
#[ test ]
fn color_hex_sets_rgb_and_full_alpha()
{
let c = Color::hex( 0xFF, 0x00, 0x80 );
assert!( ( c.r - 1.0 ).abs() < 1e-3 );
assert!( ( c.g - 0.0 ).abs() < 1e-6 );
assert!( ( c.b - 0x80 as f32 / 255.0 ).abs() < 1e-3 );
assert_eq!( c.a, 1.0 );
}
#[ test ]
fn color_rgba_stores_all_channels()
{
let c = Color::rgba( 0.1, 0.2, 0.3, 0.4 );
assert!( ( c.r - 0.1 ).abs() < 1e-6 );
assert!( ( c.g - 0.2 ).abs() < 1e-6 );
assert!( ( c.b - 0.3 ).abs() < 1e-6 );
assert!( ( c.a - 0.4 ).abs() < 1e-6 );
}
#[ test ]
fn color_white_constant_is_all_ones()
{
let c = Color::WHITE;
assert_eq!( c.r, 1. );
assert_eq!( c.g, 1. );
assert_eq!( c.b, 1. );
assert_eq!( c.a, 1. );
}
#[ test ]
fn color_transparent_has_zero_alpha()
{
assert_eq!( Color::TRANSPARENT.a, 0. );
}
#[ test ]
fn color_rgb_sets_full_alpha()
{
let c = Color::rgb( 0.5, 0.5, 0.5 );
assert_eq!( c.a, 1.0 );
}
// ── Rect ──────────────────────────────────────────────────────────────────
#[ test ]
fn rect_contains_interior_point()
{
let r = Rect { x: 10., y: 20., width: 100., height: 50. };
assert!( r.contains( Point { x: 60., y: 45. } ) );
}
#[ test ]
fn rect_contains_boundary_points()
{
let r = Rect { x: 0., y: 0., width: 100., height: 100. };
assert!( r.contains( Point { x: 0., y: 0. } ) );
assert!( r.contains( Point { x: 100., y: 100. } ) );
}
#[ test ]
fn rect_does_not_contain_exterior_points()
{
let r = Rect { x: 10., y: 20., width: 100., height: 50. };
assert!( !r.contains( Point { x: 5., y: 45. } ) );
assert!( !r.contains( Point { x: 60., y: 5. } ) );
assert!( !r.contains( Point { x: 200., y: 45. } ) );
assert!( !r.contains( Point { x: 60., y: 80. } ) );
}
#[ test ]
fn rect_expand_grows_in_all_directions()
{
let r = Rect { x: 10., y: 10., width: 80., height: 40. };
let e = r.expand( 5. );
assert_eq!( e.x, 5. );
assert_eq!( e.y, 5. );
assert_eq!( e.width, 90. );
assert_eq!( e.height, 50. );
}
#[ test ]
fn rect_expand_zero_is_identity()
{
let r = Rect { x: 1., y: 2., width: 3., height: 4. };
let e = r.expand( 0. );
assert_eq!( r, e );
}
}

Some files were not shown because too many files have changed in this diff Show More