1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Merge branch 'master' into cache_galley_lines

This commit is contained in:
Hubert Głuchowski
2025-03-26 18:52:43 +01:00
319 changed files with 10259 additions and 5112 deletions

1
.gitattributes vendored
View File

@@ -1,6 +1,7 @@
* text=auto eol=lf
Cargo.lock linguist-generated=false
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
# Exclude some small files from LFS:
crates/eframe/data/* !filter !diff !merge text=auto eol=lf

View File

@@ -6,7 +6,13 @@ jobs:
cargo-machete:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.85
- name: Machete install
run: cargo install cargo-machete --locked
- name: Checkout
uses: actions/checkout@v3
- name: Machete
run: cargo install cargo-machete --locked && cargo machete
uses: actions/checkout@v4
- name: Machete Check
run: cargo machete

View File

@@ -39,7 +39,7 @@ jobs:
with:
profile: minimal
target: wasm32-unknown-unknown
toolchain: 1.80.0
toolchain: 1.81.0
override: true
- uses: Swatinem/rust-cache@v2

View File

@@ -25,7 +25,7 @@ jobs:
exclude_pattern=$(printf "|^%s" "${exclude_paths[@]}" | sed 's/^|//')
if comm -23 <(git ls-files | grep -Ev "$exclude_pattern" | sort) <(git lfs ls-files -n | sort) | grep "\.${ext}$"; then
echo "Error: Found binary file with extension .$ext not tracked by git LFS. See CONTRIBUTING.md"
echo "Error: Found binary file with extension .$ext not tracked by git LFS. See https://github.com/emilk/egui/blob/master/CONTRIBUTING.md#working-with-git-lfs"
exit 1
fi
done

View File

@@ -16,8 +16,11 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.81.0
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
prefix-key: "pr-preview-"

View File

@@ -9,7 +9,7 @@ env:
jobs:
fmt-crank-check-test:
name: Format + check + test
name: Format + check
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -18,11 +18,11 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.80.0
toolchain: 1.81.0
- name: Install packages (Linux)
if: runner.os == 'Linux'
uses: awalsh128/cache-apt-pkgs-action@v1.4.2
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgtk-3-dev # libgtk-3-dev is used by rfd
version: 1.0
@@ -83,7 +83,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.80.0
toolchain: 1.81.0
targets: wasm32-unknown-unknown
- run: sudo apt-get update && sudo apt-get install libgtk-3-dev libatk1.0-dev
@@ -103,7 +103,7 @@ jobs:
- name: wasm-bindgen
uses: jetli/wasm-bindgen-action@v0.1.0
with:
version: "0.2.95"
version: "0.2.97"
- run: ./scripts/wasm_bindgen_check.sh --skip-setup
@@ -153,9 +153,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v1
- uses: EmbarkStudios/cargo-deny-action@v2
with:
rust-version: "1.80.0"
rust-version: "1.81.0"
log-level: error
command: check
arguments: --target ${{ matrix.target }}
@@ -170,7 +170,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.80.0
toolchain: 1.81.0
targets: aarch64-linux-android
- name: Set up cargo cache
@@ -189,7 +189,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.80.0
toolchain: 1.81.0
targets: aarch64-apple-ios
- name: Set up cargo cache
@@ -208,7 +208,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.80.0
toolchain: 1.81.0
- name: Set up cargo cache
uses: Swatinem/rust-cache@v2
@@ -223,7 +223,7 @@ jobs:
tests:
name: Run tests
# We run the tests on macOS because it will run with a actual GPU
# We run the tests on macOS because it will run with an actual GPU
runs-on: macos-latest
steps:
@@ -232,7 +232,7 @@ jobs:
lfs: true
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.80.0
toolchain: 1.81.0
- name: Set up cargo cache
uses: Swatinem/rust-cache@v2

View File

@@ -4,7 +4,7 @@ on: [pull_request]
jobs:
typos:
# https://github.com/crate-ci/typos
# Add exceptions to _typos.toml
# Add exceptions to .typos.toml
# install and run locally: cargo install typos-cli && typos
name: typos
runs-on: ubuntu-latest
@@ -14,15 +14,7 @@ jobs:
- name: Check spelling of entire workspace
uses: crate-ci/typos@master
# Disabled: too many names of crates and user-names etc
# spellcheck:
# name: Spellcheck
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: streetsidesoftware/cspell-action@v2
# with:
# files: "**/*.md"
linkinator:
name: linkinator
runs-on: ubuntu-latest

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
**/target_wasm
**/tests/snapshots/**/*.diff.png
**/tests/snapshots/**/*.new.png
**/tests/snapshots/**/*.old.png
/.*.json
/.vscode
/media/*

View File

@@ -6,6 +6,17 @@
ime = "ime" # Input Method Editor
nknown = "nknown" # part of @55nknown username
ro = "ro" # read-only, also part of the username @Phen-Ro
typ = "typ" # Often used because `type` is a keyword in Rust
# I mistype these so often
tesalator = "tessellator"
teselator = "tessellator"
tessalator = "tessellator"
tesselator = "tessellator"
tesalation = "tessellation"
teselation = "tessellation"
tessalation = "tessellation"
tesselation = "tessellation"
[files]
extend-exclude = ["web_demo/egui_demo_app.js"] # auto-generated

View File

@@ -33,7 +33,11 @@
"--all-features",
],
"rust-analyzer.showUnlinkedFileNotification": false,
"rust-analyzer.cargo.extraEnv": {
// rust-analyzer is only guaranteed to support the latest stable version of Rust. Use it instead of whatever is
// specified in rust-toolchain.
"RUSTUP_TOOLCHAIN": "stable"
},
// Uncomment the following options and restart rust-analyzer to get it to check code behind `cfg(target_arch=wasm32)`.
// Don't forget to put it in a comment again before committing.
// "rust-analyzer.cargo.target": "wasm32-unknown-unknown",

View File

@@ -14,6 +14,93 @@ This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.31.1 - 2025-03-05
* Fix sizing bug in `TextEdit::singleline` [#5640](https://github.com/emilk/egui/pull/5640) by [@IaVashik](https://github.com/IaVashik)
* Fix panic when rendering thin textured rectangles [#5692](https://github.com/emilk/egui/pull/5692) by [@PPakalns](https://github.com/PPakalns)
## 0.31.0 - 2025-02-04 - Scene container, improved rendering quality
### Highlights ✨
#### Scene container
This release adds the `Scene` container to egui. It is a pannable, zoomable canvas that can contain `Widget`s and child `Ui`s.
This will make it easier to e.g. implement a graph editor.
![scene](https://github.com/user-attachments/assets/7dc5e395-a3cb-4bf3-83a3-51a76a48c409)
#### Clearer, pixel perfect rendering
The tessellator has been updated for improved rendering quality and better performance. It will produce fewer vertices
and shapes will have less overdraw. We've also defined what `CornerRadius` (previously `Rounding`) means.
We've also added a tessellator test to the [demo app](https://www.egui.rs/), where you can play around with different
values to see what's produced:
https://github.com/user-attachments/assets/adf55e3b-fb48-4df0-aaa2-150ee3163684
Check the [PR](https://github.com/emilk/egui/pull/5669) for more details.
#### `CornerRadius`, `Margin`, `Shadow` size reduction
In order to pave the path for more complex and customizable styling solutions, we've reduced the size of
`CornerRadius`, `Margin` and `Shadow` values to `i8` and `u8`.
### Migration guide
- Add a `StrokeKind` to all your `Painter::rect` calls [#5648](https://github.com/emilk/egui/pull/5648)
- `StrokeKind::default` was removed, since the 'normal' value depends on the context [#5658](https://github.com/emilk/egui/pull/5658)
- You probably want to use `StrokeKind::Inside` when drawing rectangles
- You probably want to use `StrokeKind::Middle` when drawing open paths
- Rename `Rounding` to `CornerRadius` [#5673](https://github.com/emilk/egui/pull/5673)
- `CornerRadius`, `Margin` and `Shadow` have been updated to use `i8` and `u8` [#5563](https://github.com/emilk/egui/pull/5563), [#5567](https://github.com/emilk/egui/pull/5567), [#5568](https://github.com/emilk/egui/pull/5568)
- Remove the .0 from your values
- Cast dynamic values with `as i8` / `as u8` or `as _` if you want Rust to infer the type
- Rust will do a 'saturating' cast, so if your `f32` value is bigger than `127` it will be clamped to `127`
- `RectShape` parameters changed [#5565](https://github.com/emilk/egui/pull/5565)
- Prefer to use the builder methods to create it instead of initializing it directly
- `Frame` now takes the `Stroke` width into account for its sizing, so check all views of your app to make sure they still look right.
Read the [PR](https://github.com/emilk/egui/pull/5575) for more info.
### ⭐ Added
* Add `egui::Scene` for panning/zooming a `Ui` [#5505](https://github.com/emilk/egui/pull/5505) by [@grtlr](https://github.com/grtlr)
* Animated WebP support [#5470](https://github.com/emilk/egui/pull/5470) by [@Aely0](https://github.com/Aely0)
* Improve tessellation quality [#5669](https://github.com/emilk/egui/pull/5669) by [@emilk](https://github.com/emilk)
* Add `OutputCommand` for copying text and opening URL:s [#5532](https://github.com/emilk/egui/pull/5532) by [@emilk](https://github.com/emilk)
* Add `Context::copy_image` [#5533](https://github.com/emilk/egui/pull/5533) by [@emilk](https://github.com/emilk)
* Add `WidgetType::Image` and `Image::alt_text` [#5534](https://github.com/emilk/egui/pull/5534) by [@lucasmerlin](https://github.com/lucasmerlin)
* Add `epaint::Brush` for controlling `RectShape` texturing [#5565](https://github.com/emilk/egui/pull/5565) by [@emilk](https://github.com/emilk)
* Implement `nohash_hasher::IsEnabled` for `Id` [#5628](https://github.com/emilk/egui/pull/5628) by [@emilk](https://github.com/emilk)
* Add keys for `!`, `{`, `}` [#5548](https://github.com/emilk/egui/pull/5548) by [@Its-Just-Nans](https://github.com/Its-Just-Nans)
* Add `RectShape::stroke_kind ` to control if stroke is inside/outside/centered [#5647](https://github.com/emilk/egui/pull/5647) by [@emilk](https://github.com/emilk)
### 🔧 Changed
* ⚠️ `Frame` now includes stroke width as part of padding [#5575](https://github.com/emilk/egui/pull/5575) by [@emilk](https://github.com/emilk)
* Rename `Rounding` to `CornerRadius` [#5673](https://github.com/emilk/egui/pull/5673) by [@emilk](https://github.com/emilk)
* Require a `StrokeKind` when painting rectangles with strokes [#5648](https://github.com/emilk/egui/pull/5648) by [@emilk](https://github.com/emilk)
* Round widget coordinates to even multiple of 1/32 [#5517](https://github.com/emilk/egui/pull/5517) by [@emilk](https://github.com/emilk)
* Make all lines and rectangles crisp [#5518](https://github.com/emilk/egui/pull/5518) by [@emilk](https://github.com/emilk)
* Tweak window resize handles [#5524](https://github.com/emilk/egui/pull/5524) by [@emilk](https://github.com/emilk)
### 🔥 Removed
* Remove `egui::special_emojis::TWITTER` [#5622](https://github.com/emilk/egui/pull/5622) by [@emilk](https://github.com/emilk)
* Remove `StrokeKind::default` [#5658](https://github.com/emilk/egui/pull/5658) by [@emilk](https://github.com/emilk)
### 🐛 Fixed
* Use correct minimum version of `profiling` crate [#5494](https://github.com/emilk/egui/pull/5494) by [@lucasmerlin](https://github.com/lucasmerlin)
* Fix interactive widgets sometimes being incorrectly marked as hovered [#5523](https://github.com/emilk/egui/pull/5523) by [@emilk](https://github.com/emilk)
* Fix panic due to non-total ordering in `Area::compare_order()` [#5569](https://github.com/emilk/egui/pull/5569) by [@HactarCE](https://github.com/HactarCE)
* Fix hovering through custom menu button [#5555](https://github.com/emilk/egui/pull/5555) by [@M4tthewDE](https://github.com/M4tthewDE)
### 🚀 Performance
* Use `u8` in `CornerRadius`, and introduce `CornerRadiusF32` [#5563](https://github.com/emilk/egui/pull/5563) by [@emilk](https://github.com/emilk)
* Store `Margin` using `i8` to reduce its size [#5567](https://github.com/emilk/egui/pull/5567) by [@emilk](https://github.com/emilk)
* Shrink size of `Shadow` by using `i8/u8` instead of `f32` [#5568](https://github.com/emilk/egui/pull/5568) by [@emilk](https://github.com/emilk)
* Avoid allocations for loader cache lookup [#5584](https://github.com/emilk/egui/pull/5584) by [@mineichen](https://github.com/mineichen)
* Use bitfield instead of bools in `Response` and `Sense` [#5556](https://github.com/emilk/egui/pull/5556) by [@polwel](https://github.com/polwel)
## 0.30.0 - 2024-12-16 - Modals and better layer support
### ✨ Highlights

View File

@@ -34,14 +34,11 @@ Browse through [`ARCHITECTURE.md`](ARCHITECTURE.md) to get a sense of how all pi
You can test your code locally by running `./scripts/check.sh`.
There are snapshots test that might need to be updated.
Run the tests with `UPDATE_SNAPSHOTS=true cargo test --workspace --all-features` to update all of them.
If CI keeps complaining about snapshots (which could happen if you don't use macOS, snapshots in CI are currently
rendered with macOS), you can instead run `./scripts/update_snapshots_from_ci.sh` to update your local snapshots from
the last CI run of your PR (which will download the `test_results` artefact).
For more info about the tests see [egui_kittest](./crates/egui_kittest/README.md).
We use [git-lfs](https://git-lfs.com/) to store big files in the repository.
Make sure you have it installed (running `git lfs ls-files` from the repository root should list some files).
Don't forget to run `git lfs install` after installing the git-lfs binary.
You need to add any .png images to `git lfs`.
If the CI complains about this, make sure you run `git add --renormalize .`.
Snapshots and other big files are stored with git lfs. See [Working with git lfs](#working-with-git-lfs) for more info.
If you see an `InvalidSignature` error when running snapshot tests, it's probably a problem related to git-lfs.
When you have something that works, open a draft PR. You may get some helpful feedback early!
@@ -51,6 +48,31 @@ Don't worry about having many small commits in the PR - they will be squashed to
Please keep pull requests small and focused. The smaller it is, the more likely it is to get merged.
## Working with git lfs
We use [git-lfs](https://git-lfs.com/) to store big files in the repository.
Make sure you have it installed (running `git lfs ls-files` from the repository root should list some files).
Don't forget to run `git lfs install` in this repo after installing the git-lfs binary.
You need to add any .png images to `git lfs` (see the .gitattributes file for rules and exclusions).
If the CI complains about lfs, try running `git add --renormalize .`.
Common git-lfs commands:
```bash
# Install git-lfs in the repo (installs git hooks)
git lfs install
# Move a file to git lfs
git lfs track "path/to/file/or/pattern" # OR manually edit .gitattributes
git add --renormalize . # Moves already added files to lfs (according to .gitattributes)
# Move a file from lfs to regular git
git lfs untrack "path/to/file/or/pattern" # OR manually edit .gitattributes
git add --renormalize . # Moves already added files to regular git (according to .gitattributes)
# Push to a contributor remote (see https://github.com/cli/cli/discussions/8794#discussioncomment-8695076)
git push --no-verify
```
## PR review
Most PR reviews are done by me, Emil, but I very much appreciate any help I can get reviewing PRs!

1034
Cargo.lock

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,8 @@ members = [
[workspace.package]
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.80"
version = "0.30.0"
rust-version = "1.81"
version = "0.31.1"
[profile.release]
@@ -55,24 +55,27 @@ opt-level = 2
[workspace.dependencies]
emath = { version = "0.30.0", path = "crates/emath", default-features = false }
ecolor = { version = "0.30.0", path = "crates/ecolor", default-features = false }
epaint = { version = "0.30.0", path = "crates/epaint", default-features = false }
epaint_default_fonts = { version = "0.30.0", path = "crates/epaint_default_fonts" }
egui = { version = "0.30.0", path = "crates/egui", default-features = false }
egui-winit = { version = "0.30.0", path = "crates/egui-winit", default-features = false }
egui_extras = { version = "0.30.0", path = "crates/egui_extras", default-features = false }
egui-wgpu = { version = "0.30.0", path = "crates/egui-wgpu", default-features = false }
egui_demo_lib = { version = "0.30.0", path = "crates/egui_demo_lib", default-features = false }
egui_glow = { version = "0.30.0", path = "crates/egui_glow", default-features = false }
egui_kittest = { version = "0.30.0", path = "crates/egui_kittest", default-features = false }
eframe = { version = "0.30.0", path = "crates/eframe", default-features = false }
emath = { version = "0.31.1", path = "crates/emath", default-features = false }
ecolor = { version = "0.31.1", path = "crates/ecolor", default-features = false }
epaint = { version = "0.31.1", path = "crates/epaint", default-features = false }
epaint_default_fonts = { version = "0.31.1", path = "crates/epaint_default_fonts" }
egui = { version = "0.31.1", path = "crates/egui", default-features = false }
egui-winit = { version = "0.31.1", path = "crates/egui-winit", default-features = false }
egui_extras = { version = "0.31.1", path = "crates/egui_extras", default-features = false }
egui-wgpu = { version = "0.31.1", path = "crates/egui-wgpu", default-features = false }
egui_demo_lib = { version = "0.31.1", path = "crates/egui_demo_lib", default-features = false }
egui_glow = { version = "0.31.1", path = "crates/egui_glow", default-features = false }
egui_kittest = { version = "0.31.1", path = "crates/egui_kittest", default-features = false }
eframe = { version = "0.31.1", path = "crates/eframe", default-features = false }
accesskit = "0.18.0"
accesskit_winit = "0.24"
ahash = { version = "0.8.11", default-features = false, features = [
"no-rng", # we don't need DOS-protection, so we let users opt-in to it instead
"std",
] }
backtrace = "0.3"
bitflags = "2.6"
bytemuck = "1.7.2"
criterion = { version = "0.5.1", default-features = false }
dify = { version = "0.7", default-features = false }
@@ -82,7 +85,7 @@ glutin = { version = "0.32.0", default-features = false }
glutin-winit = { version = "0.5.0", default-features = false }
home = "0.5.9"
image = { version = "0.25", default-features = false }
kittest = { version = "0.1" }
kittest = { version = "0.1.0", git = "https://github.com/rerun-io/kittest", branch = "main" }
log = { version = "0.4", features = ["std"] }
nohash-hasher = "0.2"
parking_lot = "0.12"
@@ -99,7 +102,7 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3.70"
web-time = "1.1.0" # Timekeeping for native and web
wgpu = { version = "23.0.0", default-features = false }
wgpu = { version = "24.0.0", default-features = false }
windows-sys = "0.59"
winit = { version = "0.30.7", default-features = false }
@@ -113,6 +116,7 @@ rust_2018_idioms = { level = "warn", priority = -1 }
rust_2021_prelude_collisions = "warn"
semicolon_in_expressions_from_macros = "warn"
trivial_numeric_casts = "warn"
unexpected_cfgs = "warn"
unsafe_op_in_unsafe_fn = "warn" # `unsafe_op_in_unsafe_fn` may become the default in future Rust versions: https://github.com/rust-lang/rust/issues/71668
unused_extern_crates = "warn"
unused_import_braces = "warn"
@@ -202,8 +206,8 @@ match_same_arms = "warn"
match_wild_err_arm = "warn"
match_wildcard_for_single_variants = "warn"
mem_forget = "warn"
mismatched_target_os = "warn"
mismatching_type_param_order = "warn"
missing_assert_message = "warn"
missing_enforced_import_renames = "warn"
missing_errors_doc = "warn"
missing_safety_doc = "warn"
@@ -271,7 +275,6 @@ zero_sized_map_values = "warn"
# TODO(emilk): maybe enable more of these lints?
iter_over_hash_type = "allow"
missing_assert_message = "allow"
should_panic_without_expect = "allow"
too_many_lines = "allow"
unwrap_used = "allow" # TODO(emilk): We really wanna warn on this one

View File

@@ -11,7 +11,7 @@
<div align="center">
<a href="https://www.rerun.io/"><img src="media/rerun_io_logo.png" width="250"></a>
<a href="https://www.rerun.io/"><img src="https://github.com/user-attachments/assets/78e79463-4357-461b-bbd1-31aa5ef5e1a2" width="250"></a>
egui development is sponsored by [Rerun](https://www.rerun.io/), a startup building<br>
an SDK for visualizing streams of multimodal data.
@@ -46,7 +46,7 @@ ui.label(format!("Hello '{name}', age {age}"));
ui.image(egui::include_image!("ferris.png"));
```
<img alt="Dark mode" src="media/demo.gif"> &nbsp; &nbsp; <img alt="Light mode" src="media/demo_light_mode.png" height="278">
<img alt="Dark mode" src="https://github.com/user-attachments/assets/3b446d29-99d8-4c82-86bb-4d8ef0516017"> &nbsp; &nbsp; <img alt="Light mode" src="https://github.com/user-attachments/assets/a5e7da93-89a8-4ba0-86b8-0fa2228a4f62" height="278">
## Sections:
@@ -133,26 +133,26 @@ Still, egui can be used to create professional looking applications, like [the R
* Label text selection
* And more!
Check out the [3rd party egui crates wiki](https://github.com/emilk/egui/wiki/3rd-party-egui-crates) for even more
Check out the [3rd party egui crates wiki](https://github.com/emilk/egui/wiki/3rd-party-egui-crates) for even more
widgets and features, maintained by the community.
<img src="media/widget_gallery_0.23.gif" width="50%">
<img src="https://github.com/user-attachments/assets/13e73b76-e456-42bd-8ec9-220802834268" width="50%">
Light Theme:
<img src="media/widget_gallery_0.23_light.png" width="50%">
<img src="https://github.com/user-attachments/assets/2e38972c-a444-4894-b32f-47a2719cf369" width="50%">
## Dependencies
`egui` has a minimal set of default dependencies:
* [`ab_glyph`](https://crates.io/crates/ab_glyph)
* [`ahash`](https://crates.io/crates/ahash)
* [`bitflags`](https://crates.io/crates/bitflags)
* [`nohash-hasher`](https://crates.io/crates/nohash-hasher)
* [`parking_lot`](https://crates.io/crates/parking_lot)
Heavier dependencies are kept out of `egui`, even as opt-in.
No code that isn't fully Wasm-friendly is part of `egui`.
All code in `egui` is Wasm-friendly (even outside a browser).
To load images into `egui` you can use the official [`egui_extras`](https://github.com/emilk/egui/tree/master/crates/egui_extras) crate.
@@ -190,7 +190,7 @@ These are the official egui integrations:
### 3rd party integrations
Check the wiki to find [3rd party integrations](https://github.com/emilk/egui/wiki/3rd-party-integrations)
Check the wiki to find [3rd party integrations](https://github.com/emilk/egui/wiki/3rd-party-integrations)
and [egui crates](https://github.com/emilk/egui/wiki/3rd-party-egui-crates).
### Writing your own egui integration
@@ -267,7 +267,7 @@ This is not yet as powerful as say CSS, [but this is going to improve](https://g
Here is an example (from https://github.com/a-liashenko/TinyPomodoro):
<img src="media/pompodoro-skin.png" width="50%">
<img src="https://github.com/user-attachments/assets/e6107237-2547-41d6-996b-9a20ae0345ab" width="50%">
### How do I use egui with `async`?
If you call `.await` in your GUI code, the UI will freeze, which is very bad UX. Instead, keep the GUI thread non-blocking and communicate with any concurrent tasks (`async` tasks or other threads) with something like:
@@ -375,7 +375,7 @@ Default fonts:
---
<div align="center">
<a href="https://www.rerun.io/"><img src="media/rerun_io_logo.png" width="440"></a>
<a href="https://www.rerun.io/"><img src="https://github.com/user-attachments/assets/78e79463-4357-461b-bbd1-31aa5ef5e1a2" width="440"></a>
egui development is sponsored by [Rerun](https://www.rerun.io/), a startup building<br>
an SDK for visualizing streams of multimodal data.

View File

@@ -36,23 +36,19 @@ We don't update the MSRV in a patch release, unless we really, really need to.
## Release testing
* [ ] `cargo r -p egui_demo_app` and click around for while
* [ ] `./scripts/build_demo_web.sh --release -g`
- check frame-rate and wasm size
- test on mobile
- test on chromium
- check the in-browser profiler
* [ ] check the color test
* [ ] update `eframe_template` and test
* [ ] update `egui_plot` and test
* [ ] update `egui_table` and test
* [ ] update `egui_tiles` and test
* [ ] test with Rerun
* [ ] `./scripts/check.sh`
* [ ] check that CI is green
## Preparation
* [ ] make sure there are no important unmerged PRs
* [ ] run `scripts/generate_example_screenshots.sh` if needed
* [ ] write a short release note that fits in a tweet
* [ ] record gif for `CHANGELOG.md` release note (and later twitter post)
* [ ] write a short release note that fits in a bluesky post
* [ ] record gif for `CHANGELOG.md` release note (and later bluesky post)
* [ ] update changelogs using `scripts/generate_changelog.py --version 0.x.0 --write`
* [ ] bump version numbers in workspace `Cargo.toml`
@@ -60,9 +56,9 @@ We don't update the MSRV in a patch release, unless we really, really need to.
I usually do this all on the `master` branch, but doing it in a release branch is also fine, as long as you remember to merge it into `master` later.
* [ ] Run `typos`
* [ ] `git commit -m 'Release 0.x.0 - summary'`
* [ ] `git commit -m 'Release 0.x.0 - <release title>'`
* [ ] `cargo publish` (see below)
* [ ] `git tag -a 0.x.0 -m 'Release 0.x.0 - summary'`
* [ ] `git tag -a 0.x.0 -m 'Release 0.x.0 - <release title>'`
* [ ] `git pull --tags ; git tag -d latest && git tag -a latest -m 'Latest release' && git push --tags origin latest --force ; git push --tags`
* [ ] merge release PR or push to `master`
* [ ] check that CI is green
@@ -79,15 +75,17 @@ I usually do this all on the `master` branch, but doing it in a release branch i
(cd crates/egui && cargo publish --quiet) && echo "✅ egui"
(cd crates/egui-winit && cargo publish --quiet) && echo "✅ egui-winit"
(cd crates/egui-wgpu && cargo publish --quiet) && echo "✅ egui-wgpu"
(cd crates/eframe && cargo publish --quiet) && echo "✅ eframe"
(cd crates/egui_kittest && cargo publish --quiet) && echo "✅ egui_kittest"
(cd crates/egui_extras && cargo publish --quiet) && echo "✅ egui_extras"
(cd crates/egui_demo_lib && cargo publish --quiet) && echo "✅ egui_demo_lib"
(cd crates/egui_glow && cargo publish --quiet) && echo "✅ egui_glow"
(cd crates/eframe && cargo publish --quiet) && echo "✅ eframe"
```
\<continue with the checklist above\>
## Announcements
* [ ] [twitter](https://x.com/ernerfeldt/status/1772665412225823105)
* [ ] [Bluesky](https://bsky.app/profile/ernerfeldt.bsky.social)
* [ ] egui discord
* [ ] [r/rust](https://www.reddit.com/r/rust/comments/1bocr5s/announcing_egui_027_with_improved_menus_and/)
* [ ] [r/programming](https://www.reddit.com/r/programming/comments/1bocsf6/announcing_egui_027_an_easytouse_crossplatform/)
@@ -98,3 +96,5 @@ I usually do this all on the `master` branch, but doing it in a release branch i
* [ ] publish new `egui_plot`
* [ ] publish new `egui_table`
* [ ] publish new `egui_tiles`
* [ ] make a PR to `egui_commonmark`
* [ ] make a PR to `rerun`

View File

@@ -3,7 +3,7 @@
# -----------------------------------------------------------------------------
# Section identical to scripts/clippy_wasm/clippy.toml:
msrv = "1.80"
msrv = "1.81"
allow-unwrap-in-tests = true

View File

@@ -6,6 +6,14 @@ This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.31.1 - 2025-03-05
Nothing new
## 0.31.0 - 2025-02-04
* Add `Color32::CYAN` and `Color32::MAGENTA` [#5663](https://github.com/emilk/egui/pull/5663) by [@juancampa](https://github.com/juancampa)
## 0.30.0 - 2024-12-16
* Use boxed slice for lookup table to avoid stack overflow [#5212](https://github.com/emilk/egui/pull/5212) by [@YgorSouza](https://github.com/YgorSouza)
* Add `Color32::mul` [#5437](https://github.com/emilk/egui/pull/5437) by [@emilk](https://github.com/emilk)

View File

@@ -8,4 +8,6 @@
A simple color storage and conversion library.
Made for [`egui`](https://github.com/emilk/egui/).
This crate is built for the wants and needs of [`egui`](https://github.com/emilk/egui/).
If you want an actual _good_ color crate, use [`color`](https://crates.io/crates/color) instead.

View File

@@ -5,10 +5,24 @@ use crate::{fast_round, linear_f32_from_linear_u8, Rgba};
/// Instead of manipulating this directly it is often better
/// to first convert it to either [`Rgba`] or [`crate::Hsva`].
///
/// Internally this uses 0-255 gamma space `sRGBA` color with premultiplied alpha.
/// Alpha channel is in linear space.
/// Internally this uses 0-255 gamma space `sRGBA` color with _premultiplied alpha_.
///
/// The special value of alpha=0 means the color is to be treated as an additive color.
/// It's the non-linear ("gamma") values that are multiplied with the alpha.
///
/// Premultiplied alpha means that the color values have been pre-multiplied with the alpha (opacity).
/// This is in contrast with "normal" RGBA, where the alpha is _separate_ (or "unmultiplied").
/// Using premultiplied alpha has some advantages:
/// * It allows encoding additive colors
/// * It is the better way to blend colors, e.g. when filtering texture colors
/// * Because the above, it is the better way to encode colors in a GPU texture
///
/// The color space is assumed to be [sRGB](https://en.wikipedia.org/wiki/SRGB).
///
/// All operations on `Color32` are done in "gamma space" (see <https://en.wikipedia.org/wiki/SRGB>).
/// This is not physically correct, but it is fast and sometimes more perceptually even than linear space.
/// If you instead want to perform these operations in linear-space color, use [`Rgba`].
///
/// An `alpha=0` means the color is to be treated as an additive color.
#[repr(C)]
#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -16,6 +30,7 @@ use crate::{fast_round, linear_f32_from_linear_u8, Rgba};
pub struct Color32(pub(crate) [u8; 4]);
impl std::fmt::Debug for Color32 {
/// Prints the contents with premultiplied alpha!
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [r, g, b, a] = self.0;
write!(f, "#{r:02X}_{g:02X}_{b:02X}_{a:02X}")
@@ -56,7 +71,10 @@ impl Color32 {
pub const RED: Self = Self::from_rgb(255, 0, 0);
pub const LIGHT_RED: Self = Self::from_rgb(255, 128, 128);
pub const CYAN: Self = Self::from_rgb(0, 255, 255);
pub const MAGENTA: Self = Self::from_rgb(255, 0, 255);
pub const YELLOW: Self = Self::from_rgb(255, 255, 0);
pub const ORANGE: Self = Self::from_rgb(255, 165, 0);
pub const LIGHT_YELLOW: Self = Self::from_rgb(255, 255, 0xE0);
pub const KHAKI: Self = Self::from_rgb(240, 230, 140);
@@ -69,6 +87,8 @@ impl Color32 {
pub const BLUE: Self = Self::from_rgb(0, 0, 255);
pub const LIGHT_BLUE: Self = Self::from_rgb(0xAD, 0xD8, 0xE6);
pub const PURPLE: Self = Self::from_rgb(0x80, 0, 0x80);
pub const GOLD: Self = Self::from_rgb(255, 215, 0);
pub const DEBUG_COLOR: Self = Self::from_rgba_premultiplied(0, 200, 0, 128);
@@ -85,41 +105,49 @@ impl Color32 {
#[deprecated = "Renamed to PLACEHOLDER"]
pub const TEMPORARY_COLOR: Self = Self::PLACEHOLDER;
/// From RGB with alpha of 255 (opaque).
#[inline]
pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 255])
}
/// From RGB into an additive color (will make everything it blend with brighter).
#[inline]
pub const fn from_rgb_additive(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 0])
}
/// From `sRGBA` with premultiplied alpha.
///
/// You likely want to use [`Self::from_rgba_unmultiplied`] instead.
#[inline]
pub const fn from_rgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
Self([r, g, b, a])
}
/// From `sRGBA` WITHOUT premultiplied alpha.
/// From `sRGBA` with separate alpha.
///
/// This is a "normal" RGBA value that you would find in a color picker or a table somewhere.
///
/// You can use [`Self::to_srgba_unmultiplied`] to get back these values,
/// but for transparent colors what you get back might be slightly different (rounding errors).
#[inline]
pub fn from_rgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
use std::sync::OnceLock;
match a {
// common-case optimization
// common-case optimization:
0 => Self::TRANSPARENT,
// common-case optimization
// common-case optimization:
255 => Self::from_rgb(r, g, b),
a => {
static LOOKUP_TABLE: OnceLock<Box<[u8]>> = OnceLock::new();
let lut = LOOKUP_TABLE.get_or_init(|| {
use crate::{gamma_u8_from_linear_f32, linear_f32_from_gamma_u8};
(0..=u16::MAX)
.map(|i| {
let [value, alpha] = i.to_ne_bytes();
let value_lin = linear_f32_from_gamma_u8(value);
let alpha_lin = linear_f32_from_linear_u8(alpha);
gamma_u8_from_linear_f32(value_lin * alpha_lin)
fast_round(value as f32 * linear_f32_from_linear_u8(alpha))
})
.collect()
});
@@ -131,22 +159,26 @@ impl Color32 {
}
}
/// Opaque gray.
#[doc(alias = "from_grey")]
#[inline]
pub const fn from_gray(l: u8) -> Self {
Self([l, l, l, 255])
}
/// Black with the given opacity.
#[inline]
pub const fn from_black_alpha(a: u8) -> Self {
Self([0, 0, 0, a])
}
/// White with the given opacity.
#[inline]
pub fn from_white_alpha(a: u8) -> Self {
Rgba::from_white_alpha(linear_f32_from_linear_u8(a)).into()
Self([a, a, a, a])
}
/// Additive white.
#[inline]
pub const fn from_additive_luminance(l: u8) -> Self {
Self([l, l, l, 0])
@@ -157,21 +189,25 @@ impl Color32 {
self.a() == 255
}
/// Red component multiplied by alpha.
#[inline]
pub const fn r(&self) -> u8 {
self.0[0]
}
/// Green component multiplied by alpha.
#[inline]
pub const fn g(&self) -> u8 {
self.0[1]
}
/// Blue component multiplied by alpha.
#[inline]
pub const fn b(&self) -> u8 {
self.0[2]
}
/// Alpha (opacity).
#[inline]
pub const fn a(&self) -> u8 {
self.0[3]
@@ -208,9 +244,26 @@ impl Color32 {
(self.r(), self.g(), self.b(), self.a())
}
/// Convert to a normal "unmultiplied" RGBA color (i.e. with separate alpha).
///
/// This will unmultiply the alpha.
///
/// This is the inverse of [`Self::from_rgba_unmultiplied`],
/// but due to precision problems it may return slightly different values for transparent colors.
#[inline]
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
Rgba::from(*self).to_srgba_unmultiplied()
let [r, g, b, a] = self.to_array();
match a {
// Common-case optimization.
0 | 255 => self.to_array(),
a => {
let factor = 255.0 / a as f32;
let r = fast_round(factor * r as f32);
let g = fast_round(factor * g as f32);
let b = fast_round(factor * b as f32);
[r, g, b, a]
}
}
}
/// Multiply with 0.5 to make color half as opaque, perceptually.
@@ -220,7 +273,10 @@ impl Color32 {
/// This is perceptually even, and faster that [`Self::linear_multiply`].
#[inline]
pub fn gamma_multiply(self, factor: f32) -> Self {
debug_assert!(0.0 <= factor && factor.is_finite());
debug_assert!(
0.0 <= factor && factor.is_finite(),
"factor should be finite, but was {factor}"
);
let Self([r, g, b, a]) = self;
Self([
(r as f32 * factor + 0.5) as u8,
@@ -230,13 +286,33 @@ impl Color32 {
])
}
/// Multiply with 127 to make color half as opaque, perceptually.
///
/// Fast multiplication in gamma-space.
///
/// This is perceptually even, and faster that [`Self::linear_multiply`].
#[inline]
pub fn gamma_multiply_u8(self, factor: u8) -> Self {
let Self([r, g, b, a]) = self;
let factor = factor as u32;
Self([
((r as u32 * factor + 127) / 255) as u8,
((g as u32 * factor + 127) / 255) as u8,
((b as u32 * factor + 127) / 255) as u8,
((a as u32 * factor + 127) / 255) as u8,
])
}
/// Multiply with 0.5 to make color half as opaque in linear space.
///
/// This is using linear space, which is not perceptually even.
/// You likely want to use [`Self::gamma_multiply`] instead.
#[inline]
pub fn linear_multiply(self, factor: f32) -> Self {
debug_assert!(0.0 <= factor && factor.is_finite());
debug_assert!(
0.0 <= factor && factor.is_finite(),
"factor should be finite, but was {factor}"
);
// As an unfortunate side-effect of using premultiplied alpha
// we need a somewhat expensive conversion to linear space and back.
Rgba::from(self).multiply(factor).into()
@@ -268,6 +344,19 @@ impl Color32 {
fast_round(lerp((self[3] as f32)..=(other[3] as f32), t)),
)
}
/// Blend two colors in gamma space, so that `self` is behind the argument.
pub fn blend(self, on_top: Self) -> Self {
self.gamma_multiply_u8(255 - on_top.a()) + on_top
}
/// Intensity of the color.
///
/// Returns a value in the range 0-1.
/// The brighter the color, the closer to 1.
pub fn intensity(&self) -> f32 {
(self.r() as f32 * 0.299 + self.g() as f32 * 0.587 + self.b() as f32 * 0.114) / 255.0
}
}
impl std::ops::Mul for Color32 {
@@ -284,3 +373,145 @@ impl std::ops::Mul for Color32 {
])
}
}
impl std::ops::Add for Color32 {
type Output = Self;
#[inline]
fn add(self, other: Self) -> Self {
Self([
self[0].saturating_add(other[0]),
self[1].saturating_add(other[1]),
self[2].saturating_add(other[2]),
self[3].saturating_add(other[3]),
])
}
}
#[cfg(test)]
mod test {
use super::*;
fn test_rgba() -> impl Iterator<Item = [u8; 4]> {
[
[0, 0, 0, 0],
[0, 0, 0, 255],
[10, 0, 30, 0],
[10, 0, 30, 40],
[10, 100, 200, 0],
[10, 100, 200, 100],
[10, 100, 200, 200],
[10, 100, 200, 255],
[10, 100, 200, 40],
[10, 20, 0, 0],
[10, 20, 0, 255],
[10, 20, 30, 255],
[10, 20, 30, 40],
[255, 255, 255, 0],
[255, 255, 255, 255],
]
.into_iter()
}
#[test]
fn test_color32_additive() {
let opaque = Color32::from_rgb(40, 50, 60);
let additive = Color32::from_rgb(255, 127, 10).additive();
assert_eq!(additive.blend(opaque), opaque, "opaque on top of additive");
assert_eq!(
opaque.blend(additive),
Color32::from_rgb(255, 177, 70),
"additive on top of opaque"
);
}
#[test]
fn test_color32_blend_vs_gamma_blend() {
let opaque = Color32::from_rgb(0x60, 0x60, 0x60);
let transparent = Color32::from_rgba_unmultiplied(168, 65, 65, 79);
assert_eq!(
transparent.blend(opaque),
opaque,
"Opaque on top of transparent"
);
// Blending in gamma-space is the de-facto standard almost everywhere.
// Browsers and most image editors do it, and so it is what users expect.
assert_eq!(
opaque.blend(transparent),
Color32::from_rgb(
blend(0x60, 168, 79),
blend(0x60, 65, 79),
blend(0x60, 65, 79)
),
"Transparent on top of opaque"
);
fn blend(dest: u8, src: u8, alpha: u8) -> u8 {
let src = src as f32 / 255.0;
let dest = dest as f32 / 255.0;
let alpha = alpha as f32 / 255.0;
fast_round((src * alpha + dest * (1.0 - alpha)) * 255.0)
}
}
#[test]
fn color32_unmultiplied_round_trip() {
for in_rgba in test_rgba() {
let [r, g, b, a] = in_rgba;
if a == 0 {
continue;
}
let c = Color32::from_rgba_unmultiplied(r, g, b, a);
let out_rgba = c.to_srgba_unmultiplied();
if a == 255 {
assert_eq!(in_rgba, out_rgba);
} else {
// There will be small rounding errors whenever the alpha is not 0 or 255,
// because we multiply and then unmultiply the alpha.
for (&a, &b) in in_rgba.iter().zip(out_rgba.iter()) {
assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}");
}
}
}
}
#[test]
fn from_black_white_alpha() {
for a in 0..=255 {
assert_eq!(
Color32::from_white_alpha(a),
Color32::from_rgba_unmultiplied(255, 255, 255, a)
);
assert_eq!(
Color32::from_white_alpha(a),
Color32::WHITE.gamma_multiply_u8(a)
);
assert_eq!(
Color32::from_black_alpha(a),
Color32::from_rgba_unmultiplied(0, 0, 0, a)
);
assert_eq!(
Color32::from_black_alpha(a),
Color32::BLACK.gamma_multiply_u8(a)
);
}
}
#[test]
fn to_from_rgba() {
for [r, g, b, a] in test_rgba() {
let original = Color32::from_rgba_unmultiplied(r, g, b, a);
let rgba = Rgba::from(original);
let back = Color32::from(rgba);
assert_eq!(back, original);
}
assert_eq!(
Color32::from(Rgba::from_rgba_unmultiplied(1.0, 0.0, 0.0, 0.5)),
Color32::from_rgba_unmultiplied(255, 0, 0, 128)
);
}
}

View File

@@ -90,14 +90,15 @@ impl HexColor {
let [r, gb] = u16::from_str_radix(s, 16)
.map_err(ParseHexColorError::InvalidInt)?
.to_be_bytes();
let [r, g, b] = [r, gb >> 4, gb & 0x0f].map(|u| u << 4 | u);
let [r, g, b] = [r, gb >> 4, gb & 0x0f].map(|u| (u << 4) | u);
Ok(Self::Hex3(Color32::from_rgb(r, g, b)))
}
4 => {
let [r_g, b_a] = u16::from_str_radix(s, 16)
.map_err(ParseHexColorError::InvalidInt)?
.to_be_bytes();
let [r, g, b, a] = [r_g >> 4, r_g & 0x0f, b_a >> 4, b_a & 0x0f].map(|u| u << 4 | u);
let [r, g, b, a] =
[r_g >> 4, r_g & 0x0f, b_a >> 4, b_a & 0x0f].map(|u| (u << 4) | u);
Ok(Self::Hex4(Color32::from_rgba_unmultiplied(r, g, b, a)))
}
6 => {
@@ -207,17 +208,22 @@ mod tests {
#[test]
fn hex_string_round_trip() {
use Color32 as C;
let cases = [
C::from_rgba_unmultiplied(10, 20, 30, 0),
C::from_rgba_unmultiplied(10, 20, 30, 40),
C::from_rgba_unmultiplied(10, 20, 30, 255),
C::from_rgba_unmultiplied(0, 20, 30, 0),
C::from_rgba_unmultiplied(10, 0, 30, 40),
C::from_rgba_unmultiplied(10, 20, 0, 255),
[0, 20, 30, 0],
[10, 0, 30, 40],
[10, 100, 200, 0],
[10, 100, 200, 100],
[10, 100, 200, 200],
[10, 100, 200, 255],
[10, 100, 200, 40],
[10, 20, 0, 255],
[10, 20, 30, 0],
[10, 20, 30, 255],
[10, 20, 30, 40],
];
for color in cases {
assert_eq!(C::from_hex(color.to_hex().as_str()), Ok(color));
for [r, g, b, a] in cases {
let color = Color32::from_rgba_unmultiplied(r, g, b, a);
assert_eq!(Color32::from_hex(color.to_hex().as_str()), Ok(color));
}
}
}

View File

@@ -1,6 +1,5 @@
use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32, Color32, Rgba,
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_u8_from_linear_f32, Color32, Rgba,
};
/// Hue, saturation, value, alpha. All in the range [0, 1].
@@ -29,30 +28,20 @@ impl Hsva {
/// From `sRGBA` with premultiplied alpha
#[inline]
pub fn from_srgba_premultiplied([r, g, b, a]: [u8; 4]) -> Self {
Self::from_rgba_premultiplied(
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
linear_f32_from_linear_u8(a),
)
Self::from(Color32::from_rgba_premultiplied(r, g, b, a))
}
/// From `sRGBA` without premultiplied alpha
#[inline]
pub fn from_srgba_unmultiplied([r, g, b, a]: [u8; 4]) -> Self {
Self::from_rgba_unmultiplied(
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
linear_f32_from_linear_u8(a),
)
Self::from(Color32::from_rgba_unmultiplied(r, g, b, a))
}
/// From linear RGBA with premultiplied alpha
#[inline]
pub fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
#![allow(clippy::many_single_char_names)]
if a == 0.0 {
if a <= 0.0 {
if r == 0.0 && b == 0.0 && a == 0.0 {
Self::default()
} else {
@@ -152,13 +141,7 @@ impl Hsva {
#[inline]
pub fn to_srgba_premultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_premultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a),
]
Color32::from(*self).to_array()
}
/// To gamma-space 0-255.

View File

@@ -1,9 +1,20 @@
//! Color conversions and types.
//!
//! This crate is built for the wants and needs of [`egui`](https://github.com/emilk/egui/).
//!
//! If you want an actual _good_ color crate, use [`color`](https://crates.io/crates/color) instead.
//!
//! If you want a compact color representation, use [`Color32`].
//! If you want to manipulate RGBA colors use [`Rgba`].
//! If you want to manipulate RGBA colors in linear space use [`Rgba`].
//! If you want to manipulate colors in a way closer to how humans think about colors, use [`HsvaGamma`].
//!
//! ## Conventions
//! The word "gamma" or "srgb" is used to refer to values in the non-linear space defined by
//! [the sRGB transfer function](https://en.wikipedia.org/wiki/SRGB).
//! We use `u8` for anything in the "gamma" space.
//!
//! We use `f32` in 0-1 range for anything in the linear space.
//!
//! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//!
@@ -39,23 +50,46 @@ pub use hex_color_runtime::*;
impl From<Color32> for Rgba {
fn from(srgba: Color32) -> Self {
Self([
linear_f32_from_gamma_u8(srgba.0[0]),
linear_f32_from_gamma_u8(srgba.0[1]),
linear_f32_from_gamma_u8(srgba.0[2]),
linear_f32_from_linear_u8(srgba.0[3]),
])
let [r, g, b, a] = srgba.to_array();
if a == 0 {
// Additive, or completely transparent
Self([
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
0.0,
])
} else {
let a = linear_f32_from_linear_u8(a);
Self([
linear_from_gamma(r as f32 / (255.0 * a)) * a,
linear_from_gamma(g as f32 / (255.0 * a)) * a,
linear_from_gamma(b as f32 / (255.0 * a)) * a,
a,
])
}
}
}
impl From<Rgba> for Color32 {
fn from(rgba: Rgba) -> Self {
Self([
gamma_u8_from_linear_f32(rgba.0[0]),
gamma_u8_from_linear_f32(rgba.0[1]),
gamma_u8_from_linear_f32(rgba.0[2]),
linear_u8_from_linear_f32(rgba.0[3]),
])
let [r, g, b, a] = rgba.to_array();
if a == 0.0 {
// Additive, or completely transparent
Self([
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
0,
])
} else {
Self([
fast_round(gamma_u8_from_linear_f32(r / a) as f32 * a),
fast_round(gamma_u8_from_linear_f32(g / a) as f32 * a),
fast_round(gamma_u8_from_linear_f32(b / a) as f32 * a),
linear_u8_from_linear_f32(a),
])
}
}
}

View File

@@ -1,9 +1,8 @@
use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32,
};
use crate::Color32;
/// 0-1 linear space `RGBA` color with premultiplied alpha.
///
/// See [`crate::Color32`] for explanation of what "premultiplied alpha" means.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -70,20 +69,12 @@ impl Rgba {
#[inline]
pub fn from_srgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r);
let g = linear_f32_from_gamma_u8(g);
let b = linear_f32_from_gamma_u8(b);
let a = linear_f32_from_linear_u8(a);
Self::from_rgba_premultiplied(r, g, b, a)
Self::from(Color32::from_rgba_premultiplied(r, g, b, a))
}
#[inline]
pub fn from_srgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r);
let g = linear_f32_from_gamma_u8(g);
let b = linear_f32_from_gamma_u8(b);
let a = linear_f32_from_linear_u8(a);
Self::from_rgba_premultiplied(r * a, g * a, b * a, a)
Self::from(Color32::from_rgba_unmultiplied(r, g, b, a))
}
#[inline]
@@ -99,15 +90,24 @@ impl Rgba {
#[inline]
pub fn from_luminance_alpha(l: f32, a: f32) -> Self {
debug_assert!(0.0 <= l && l <= 1.0);
debug_assert!(0.0 <= a && a <= 1.0);
debug_assert!(
0.0 <= l && l <= 1.0,
"l should be in the range [0, 1], but was {l}"
);
debug_assert!(
0.0 <= a && a <= 1.0,
"a should be in the range [0, 1], but was {a}"
);
Self([l * a, l * a, l * a, a])
}
/// Transparent black
#[inline]
pub fn from_black_alpha(a: f32) -> Self {
debug_assert!(0.0 <= a && a <= 1.0);
debug_assert!(
0.0 <= a && a <= 1.0,
"a should be in the range [0, 1], but was {a}"
);
Self([0.0, 0.0, 0.0, a])
}
@@ -211,13 +211,12 @@ impl Rgba {
/// unmultiply the alpha
#[inline]
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_unmultiplied();
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a.abs()),
]
crate::Color32::from(*self).to_srgba_unmultiplied()
}
/// Blend two colors in linear space, so that `self` is behind the argument.
pub fn blend(self, on_top: Self) -> Self {
self.multiply(1.0 - on_top.a()) + on_top
}
}
@@ -276,3 +275,72 @@ impl std::ops::Mul<Rgba> for f32 {
])
}
}
#[cfg(test)]
mod test {
use super::*;
fn test_rgba() -> impl Iterator<Item = [u8; 4]> {
[
[0, 0, 0, 0],
[0, 0, 0, 255],
[10, 0, 30, 0],
[10, 0, 30, 40],
[10, 100, 200, 0],
[10, 100, 200, 100],
[10, 100, 200, 200],
[10, 100, 200, 255],
[10, 100, 200, 40],
[10, 20, 0, 0],
[10, 20, 0, 255],
[10, 20, 30, 255],
[10, 20, 30, 40],
[255, 255, 255, 0],
[255, 255, 255, 255],
]
.into_iter()
}
#[test]
fn test_rgba_blend() {
let opaque = Rgba::from_rgb(0.4, 0.5, 0.6);
let transparent = Rgba::from_rgb(1.0, 0.5, 0.0).multiply(0.3);
assert_eq!(
transparent.blend(opaque),
opaque,
"Opaque on top of transparent"
);
assert_eq!(
opaque.blend(transparent),
Rgba::from_rgb(
0.7 * 0.4 + 0.3 * 1.0,
0.7 * 0.5 + 0.3 * 0.5,
0.7 * 0.6 + 0.3 * 0.0
),
"Transparent on top of opaque"
);
}
#[test]
fn test_rgba_roundtrip() {
for in_rgba in test_rgba() {
let [r, g, b, a] = in_rgba;
if a == 0 {
continue;
}
let rgba = Rgba::from_srgba_unmultiplied(r, g, b, a);
let out_rgba = rgba.to_srgba_unmultiplied();
if a == 255 {
assert_eq!(in_rgba, out_rgba);
} else {
// There will be small rounding errors whenever the alpha is not 0 or 255,
// because we multiply and then unmultiply the alpha.
for (&a, &b) in in_rgba.iter().zip(out_rgba.iter()) {
assert!(a.abs_diff(b) <= 3, "{in_rgba:?} != {out_rgba:?}");
}
}
}
}
}

View File

@@ -1,12 +1,24 @@
# Changelog for eframe
All notable changes to the `eframe` crate.
NOTE: [`egui-winit`](../egui-winit/CHANGELOG.md), [`egui_glium`](../egui_glium/CHANGELOG.md), [`egui_glow`](../egui_glow/CHANGELOG.md),and [`egui-wgpu`](../egui-wgpu/CHANGELOG.md) have their own changelogs!
NOTE: [`egui-winit`](../egui-winit/CHANGELOG.md), [`egui_glow`](../egui_glow/CHANGELOG.md),and [`egui-wgpu`](../egui-wgpu/CHANGELOG.md) have their own changelogs!
This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.31.1 - 2025-03-05
Nothing new
## 0.31.0 - 2025-02-04
* Web: Fix incorrect scale when moving to screen with new DPI [#5631](https://github.com/emilk/egui/pull/5631) by [@emilk](https://github.com/emilk)
* Re-enable IME support on Linux [#5198](https://github.com/emilk/egui/pull/5198) by [@YgorSouza](https://github.com/YgorSouza)
* Serialize window maximized state in `WindowSettings` [#5554](https://github.com/emilk/egui/pull/5554) by [@landaire](https://github.com/landaire)
* Save state on suspend on Android and iOS [#5601](https://github.com/emilk/egui/pull/5601) by [@Pandicon](https://github.com/Pandicon)
* Eframe web: forward cmd-S/O to egui app (stop default browser action) [#5655](https://github.com/emilk/egui/pull/5655) by [@emilk](https://github.com/emilk)
## 0.30.0 - 2024-12-16 - Android support
NOTE: you now need to enable the `wayland` or `x11` features to get Linux support, including getting it to work on most CI systems.

View File

@@ -177,12 +177,14 @@ wgpu = { workspace = true, optional = true, features = [
# mac:
[target.'cfg(any(target_os = "macos"))'.dependencies]
objc2 = "0.5.1"
objc2-foundation = { version = "0.2.0", features = [
objc2-foundation = { version = "0.2.0", default-features = false, features = [
"std",
"block2",
"NSData",
"NSString",
] }
objc2-app-kit = { version = "0.2.0", features = [
objc2-app-kit = { version = "0.2.0", default-features = false, features = [
"std",
"NSApplication",
"NSImage",
"NSMenu",
@@ -209,6 +211,7 @@ percent-encoding = "2.1"
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [
"AddEventListenerOptions",
"BinaryType",
"Blob",
"BlobPropertyBag",
@@ -250,6 +253,7 @@ web-sys = { workspace = true, features = [
"ResizeObserverEntry",
"ResizeObserverOptions",
"ResizeObserverSize",
"ShadowRoot",
"Storage",
"Touch",
"TouchEvent",

View File

@@ -878,20 +878,6 @@ pub trait Storage {
fn flush(&mut self);
}
/// Stores nothing.
#[derive(Clone, Default)]
pub(crate) struct DummyStorage {}
impl Storage for DummyStorage {
fn get_string(&self, _key: &str) -> Option<String> {
None
}
fn set_string(&mut self, _key: &str, _value: String) {}
fn flush(&mut self) {}
}
/// Get and deserialize the [RON](https://github.com/ron-rs/ron) stored at the given key.
#[cfg(feature = "ron")]
pub fn get_value<T: serde::de::DeserializeOwned>(storage: &dyn Storage, key: &str) -> Option<T> {

View File

@@ -58,27 +58,34 @@ fn roaming_appdata() -> Option<PathBuf> {
extern "C" {
fn wcslen(buf: *const u16) -> usize;
}
unsafe {
let mut path = ptr::null_mut();
match SHGetKnownFolderPath(
let mut path_raw = ptr::null_mut();
// SAFETY: SHGetKnownFolderPath allocates for us, we don't pass any pointers to it.
// See https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shgetknownfolderpath
let result = unsafe {
SHGetKnownFolderPath(
&FOLDERID_RoamingAppData,
KF_FLAG_DONT_VERIFY as u32,
std::ptr::null_mut(),
&mut path,
) {
S_OK => {
let path_slice = slice::from_raw_parts(path, wcslen(path));
let s = OsString::from_wide(&path_slice);
CoTaskMemFree(path.cast());
Some(PathBuf::from(s))
}
_ => {
// Free any allocated memory even on failure. A null ptr is a no-op for `CoTaskMemFree`.
CoTaskMemFree(path.cast());
None
}
}
}
&mut path_raw,
)
};
let path = if result == S_OK {
// SAFETY: SHGetKnownFolderPath indicated success and is supposed to allocate a nullterminated string for us.
let path_slice = unsafe { slice::from_raw_parts(path_raw, wcslen(path_raw)) };
Some(PathBuf::from(OsString::from_wide(path_slice)))
} else {
None
};
// SAFETY:
// This memory got allocated by SHGetKnownFolderPath, we didn't touch anything in the process.
// A null ptr is a no-op for `CoTaskMemFree`, so in case this failed we're still good.
// https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cotaskmemfree
unsafe { CoTaskMemFree(path_raw.cast()) };
path
}
#[cfg(any(not(windows), target_vendor = "uwp"))]
@@ -89,7 +96,7 @@ fn roaming_appdata() -> Option<PathBuf> {
// ----------------------------------------------------------------------------
/// A key-value store backed by a [RON](https://github.com/ron-rs/ron) file on disk.
/// Used to restore egui state, glium window position/size and app state.
/// Used to restore egui state, glow window position/size and app state.
pub struct FileStorage {
ron_filepath: PathBuf,
kv: HashMap<String, String>,

View File

@@ -272,7 +272,7 @@ impl<'app> GlowWinitApp<'app> {
..
} = viewport
{
egui_winit.init_accesskit(window, event_loop_proxy);
egui_winit.init_accesskit(event_loop, window, event_loop_proxy);
}
}
@@ -344,7 +344,7 @@ impl<'app> GlowWinitApp<'app> {
}
}
impl<'app> WinitApp for GlowWinitApp<'app> {
impl WinitApp for GlowWinitApp<'_> {
fn egui_ctx(&self) -> Option<&egui::Context> {
self.running.as_ref().map(|r| &r.integration.egui_ctx)
}
@@ -366,6 +366,20 @@ impl<'app> WinitApp for GlowWinitApp<'app> {
.and_then(|r| r.glutin.borrow().window_from_viewport.get(&id).copied())
}
fn save(&mut self) {
log::debug!("WinitApp::save called");
if let Some(running) = self.running.as_mut() {
profiling::function_scope!();
// This is used because of the "save on suspend" logic on Android. Once the application is suspended, there is no window associated to it, which was causing panics when `.window().expect()` was used.
let window_opt = running.glutin.borrow().window_opt(ViewportId::ROOT);
running
.integration
.save(running.app.as_mut(), window_opt.as_deref());
}
}
fn save_and_destroy(&mut self) {
if let Some(mut running) = self.running.take() {
profiling::function_scope!();
@@ -413,7 +427,7 @@ impl<'app> WinitApp for GlowWinitApp<'app> {
if let Some(running) = &mut self.running {
running.glutin.borrow_mut().on_suspend()?;
}
Ok(EventResult::Wait)
Ok(EventResult::Save)
}
fn device_event(
@@ -479,7 +493,7 @@ impl<'app> WinitApp for GlowWinitApp<'app> {
}
}
impl<'app> GlowWinitRunning<'app> {
impl GlowWinitRunning<'_> {
fn run_ui_and_paint(
&mut self,
event_loop: &ActiveEventLoop,
@@ -1214,10 +1228,12 @@ impl GlutinWindowContext {
.expect("viewport doesn't exist")
}
fn window_opt(&self, viewport_id: ViewportId) -> Option<Arc<Window>> {
self.viewport(viewport_id).window.clone()
}
fn window(&self, viewport_id: ViewportId) -> Arc<Window> {
self.viewport(viewport_id)
.window
.clone()
self.window_opt(viewport_id)
.expect("winit window doesn't exist")
}

View File

@@ -89,47 +89,56 @@ impl<T: WinitApp> WinitAppWrapper<T> {
event_result: Result<EventResult>,
) {
let mut exit = false;
let mut save = false;
log::trace!("event_result: {event_result:?}");
let combined_result = event_result.and_then(|event_result| {
match event_result {
EventResult::Wait => {
event_loop.set_control_flow(ControlFlow::Wait);
Ok(event_result)
}
EventResult::RepaintNow(window_id) => {
log::trace!("RepaintNow of {window_id:?}",);
let mut event_result = event_result;
if cfg!(target_os = "windows") {
// Fix flickering on Windows, see https://github.com/emilk/egui/pull/2280
self.winit_app.run_ui_and_paint(event_loop, window_id)
} else {
// Fix for https://github.com/emilk/egui/issues/2425
self.windows_next_repaint_times
.insert(window_id, Instant::now());
Ok(event_result)
}
}
EventResult::RepaintNext(window_id) => {
log::trace!("RepaintNext of {window_id:?}",);
if cfg!(target_os = "windows") {
if let Ok(EventResult::RepaintNow(window_id)) = event_result {
log::trace!("RepaintNow of {window_id:?}");
self.windows_next_repaint_times
.insert(window_id, Instant::now());
// Fix flickering on Windows, see https://github.com/emilk/egui/pull/2280
event_result = self.winit_app.run_ui_and_paint(event_loop, window_id);
}
}
let combined_result = event_result.map(|event_result| match event_result {
EventResult::Wait => {
event_loop.set_control_flow(ControlFlow::Wait);
event_result
}
EventResult::RepaintNow(window_id) => {
log::trace!("RepaintNow of {window_id:?}",);
self.windows_next_repaint_times
.insert(window_id, Instant::now());
event_result
}
EventResult::RepaintNext(window_id) => {
log::trace!("RepaintNext of {window_id:?}",);
self.windows_next_repaint_times
.insert(window_id, Instant::now());
event_result
}
EventResult::RepaintAt(window_id, repaint_time) => {
self.windows_next_repaint_times.insert(
window_id,
self.windows_next_repaint_times
.insert(window_id, Instant::now());
Ok(event_result)
}
EventResult::RepaintAt(window_id, repaint_time) => {
self.windows_next_repaint_times.insert(
window_id,
self.windows_next_repaint_times
.get(&window_id)
.map_or(repaint_time, |last| (*last).min(repaint_time)),
);
Ok(event_result)
}
EventResult::Exit => {
exit = true;
Ok(event_result)
}
.get(&window_id)
.map_or(repaint_time, |last| (*last).min(repaint_time)),
);
event_result
}
EventResult::Save => {
save = true;
event_result
}
EventResult::Exit => {
exit = true;
event_result
}
});
@@ -139,6 +148,11 @@ impl<T: WinitApp> WinitAppWrapper<T> {
self.return_result = Err(err);
};
if save {
log::debug!("Received an EventResult::Save - saving app state");
self.winit_app.save();
}
if exit {
if self.run_and_return {
log::debug!("Asking to exit event loop…");

View File

@@ -183,7 +183,7 @@ impl<'app> WgpuWinitApp<'app> {
) -> crate::Result<&mut WgpuWinitRunning<'app>> {
profiling::function_scope!();
#[allow(unsafe_code, unused_mut, unused_unsafe)]
let mut painter = egui_wgpu::winit::Painter::new(
let mut painter = pollster::block_on(egui_wgpu::winit::Painter::new(
egui_ctx.clone(),
self.native_options.wgpu_options.clone(),
self.native_options.multisampling.max(1) as _,
@@ -193,7 +193,7 @@ impl<'app> WgpuWinitApp<'app> {
),
self.native_options.viewport.transparent.unwrap_or(false),
self.native_options.dithering,
);
));
let window = Arc::new(window);
@@ -249,7 +249,7 @@ impl<'app> WgpuWinitApp<'app> {
#[cfg(feature = "accesskit")]
{
let event_loop_proxy = self.repaint_proxy.lock().clone();
egui_winit.init_accesskit(&window, event_loop_proxy);
egui_winit.init_accesskit(event_loop, &window, event_loop_proxy);
}
let app_creator = std::mem::take(&mut self.app_creator)
@@ -323,7 +323,7 @@ impl<'app> WgpuWinitApp<'app> {
}
}
impl<'app> WinitApp for WgpuWinitApp<'app> {
impl WinitApp for WgpuWinitApp<'_> {
fn egui_ctx(&self) -> Option<&egui::Context> {
self.running.as_ref().map(|r| &r.integration.egui_ctx)
}
@@ -355,6 +355,13 @@ impl<'app> WinitApp for WgpuWinitApp<'app> {
)
}
fn save(&mut self) {
log::debug!("WinitApp::save called");
if let Some(running) = self.running.as_mut() {
running.save();
}
}
fn save_and_destroy(&mut self) {
if let Some(mut running) = self.running.take() {
running.save_and_destroy();
@@ -415,7 +422,7 @@ impl<'app> WinitApp for WgpuWinitApp<'app> {
fn suspended(&mut self, _: &ActiveEventLoop) -> crate::Result<EventResult> {
#[cfg(target_os = "android")]
self.drop_window()?;
Ok(EventResult::Wait)
Ok(EventResult::Save)
}
fn device_event(
@@ -487,14 +494,24 @@ impl<'app> WinitApp for WgpuWinitApp<'app> {
}
}
impl<'app> WgpuWinitRunning<'app> {
impl WgpuWinitRunning<'_> {
/// Saves the application state
fn save(&mut self) {
let shared = self.shared.borrow();
// This is done because of the "save on suspend" logic on Android. Once the application is suspended, there is no window associated to it.
let window = if let Some(Viewport { window, .. }) = shared.viewports.get(&ViewportId::ROOT)
{
window.as_deref()
} else {
None
};
self.integration.save(self.app.as_mut(), window);
}
fn save_and_destroy(&mut self) {
profiling::function_scope!();
let mut shared = self.shared.borrow_mut();
if let Some(Viewport { window, .. }) = shared.viewports.get(&ViewportId::ROOT) {
self.integration.save(self.app.as_mut(), window.as_deref());
}
self.save();
#[cfg(feature = "glow")]
self.app.on_exit(None);
@@ -502,6 +519,7 @@ impl<'app> WgpuWinitRunning<'app> {
#[cfg(not(feature = "glow"))]
self.app.on_exit();
let mut shared = self.shared.borrow_mut();
shared.painter.destroy();
}

View File

@@ -70,6 +70,8 @@ pub trait WinitApp {
fn window_id_from_viewport_id(&self, id: ViewportId) -> Option<WindowId>;
fn save(&mut self);
fn save_and_destroy(&mut self);
fn run_ui_and_paint(
@@ -119,6 +121,9 @@ pub enum EventResult {
RepaintAt(WindowId, Instant),
/// Causes a save of the client state when the persistence feature is enabled.
Save,
Exit,
}

View File

@@ -18,7 +18,7 @@ impl Stopwatch {
}
pub fn start(&mut self) {
assert!(self.start.is_none());
assert!(self.start.is_none(), "Stopwatch already running");
self.start = Some(Instant::now());
}
@@ -29,7 +29,7 @@ impl Stopwatch {
}
pub fn resume(&mut self) {
assert!(self.start.is_none());
assert!(self.start.is_none(), "Stopwatch still running");
self.start = Some(Instant::now());
}

View File

@@ -59,7 +59,7 @@ impl AppRunner {
egui_ctx.options_mut(|o| {
// On web by default egui follows the zoom factor of the browser,
// and lets the browser handle the zoom shortscuts.
// and lets the browser handle the zoom shortcuts.
// A user can still zoom egui separately by calling [`egui::Context::set_zoom_factor`].
o.zoom_with_keyboard = false;
o.zoom_factor = 1.0;
@@ -216,6 +216,18 @@ impl AppRunner {
let canvas_size = super::canvas_size_in_points(self.canvas(), self.egui_ctx());
let mut raw_input = self.input.new_frame(canvas_size);
if super::DEBUG_RESIZE {
log::info!(
"egui running at canvas size: {}x{}, DPR: {}, zoom_factor: {}. egui size: {}x{} points",
self.canvas().width(),
self.canvas().height(),
super::native_pixels_per_point(),
self.egui_ctx.zoom_factor(),
canvas_size.x,
canvas_size.y,
);
}
self.app.raw_input_hook(&self.egui_ctx, &mut raw_input);
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {
@@ -324,6 +336,9 @@ impl AppRunner {
egui::OutputCommand::OpenUrl(open_url) => {
super::open_url(&open_url.url, open_url.new_tab);
}
egui::OutputCommand::SetPointerPosition(_) => {
// Not supported on the web.
}
}
}

View File

@@ -1,10 +1,14 @@
use crate::web::string_from_js_value;
use super::{
button_from_mouse_event, location_hash, modifiers_from_kb_event, modifiers_from_mouse_event,
modifiers_from_wheel_event, pos_from_mouse_event, prefers_color_scheme_dark, primary_touch_pos,
push_touches, text_from_keyboard_event, theme_from_dark_mode, translate_key, AppRunner,
Closure, JsCast, JsValue, WebRunner,
modifiers_from_wheel_event, native_pixels_per_point, pos_from_mouse_event,
prefers_color_scheme_dark, primary_touch_pos, push_touches, text_from_keyboard_event,
theme_from_dark_mode, translate_key, AppRunner, Closure, JsCast, JsValue, WebRunner,
DEBUG_RESIZE,
};
use web_sys::EventTarget;
use web_sys::{Document, EventTarget, ShadowRoot};
// TODO(emilk): there are more calls to `prevent_default` and `stop_propagation`
// than what is probably needed.
@@ -215,9 +219,16 @@ fn should_prevent_default_for_key(
// * cmd-shift-C (debug tools)
// * cmd/ctrl-c/v/x (lest we prevent copy/paste/cut events)
// Prevent ctrl-P from opening the print dialog. Users may want to use it for a command palette.
if egui_key == egui::Key::P && (modifiers.ctrl || modifiers.command || modifiers.mac_cmd) {
return true;
// Prevent cmd/ctrl plus these keys from triggering the default browser action:
let keys = [
egui::Key::O, // open
egui::Key::P, // print (cmd-P is common for command palette)
egui::Key::S, // save
];
for key in keys {
if egui_key == key && (modifiers.ctrl || modifiers.command || modifiers.mac_cmd) {
return true;
}
}
if egui_key == egui::Key::Space && !runner.text_agent.has_focus() {
@@ -363,10 +374,17 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
runner.save();
})?;
// NOTE: resize is handled by `ResizeObserver` below
// We want to handle the case of dragging the browser from one monitor to another,
// which can cause the DPR to change without any resize event (e.g. Safari).
install_dpr_change_event(runner_ref)?;
// No need to subscribe to "resize": we already subscribe to the canvas
// size using a ResizeObserver, and we also subscribe to DPR changes of the monitor.
for event_name in &["load", "pagehide", "pageshow"] {
runner_ref.add_event_listener(window, event_name, move |_: web_sys::Event, runner| {
// log::debug!("{event_name:?}");
if DEBUG_RESIZE {
log::debug!("{event_name:?}");
}
runner.needs_repaint.repaint_asap();
})?;
}
@@ -380,6 +398,48 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
Ok(())
}
fn install_dpr_change_event(web_runner: &WebRunner) -> Result<(), JsValue> {
let original_dpr = native_pixels_per_point();
let window = web_sys::window().unwrap();
let Some(media_query_list) =
window.match_media(&format!("(resolution: {original_dpr}dppx)"))?
else {
log::error!(
"Failed to create MediaQueryList: eframe won't be able to detect changes in DPR"
);
return Ok(());
};
let closure = move |_: web_sys::Event, app_runner: &mut AppRunner, web_runner: &WebRunner| {
let new_dpr = native_pixels_per_point();
log::debug!("Device Pixel Ratio changed from {original_dpr} to {new_dpr}");
if true {
// Explicitly resize canvas to match the new DPR.
// This is a bit ugly, but I haven't found a better way to do it.
let canvas = app_runner.canvas();
canvas.set_width((canvas.width() as f32 * new_dpr / original_dpr).round() as _);
canvas.set_height((canvas.height() as f32 * new_dpr / original_dpr).round() as _);
log::debug!("Resized canvas to {}x{}", canvas.width(), canvas.height());
}
// It may be tempting to call `resize_observer.observe(&canvas)` here,
// but unfortunately this has no effect.
if let Err(err) = install_dpr_change_event(web_runner) {
log::error!(
"Failed to install DPR change event: {}",
string_from_js_value(&err)
);
}
};
let options = web_sys::AddEventListenerOptions::default();
options.set_once(true);
web_runner.add_event_listener_ex(&media_query_list, "change", &options, closure)
}
fn install_color_scheme_change_event(
runner_ref: &WebRunner,
window: &web_sys::Window,
@@ -510,10 +570,17 @@ fn install_pointerup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(),
/// Returns true if the cursor is above the canvas, or if we're dragging something.
/// Pass in the position in browser viewport coordinates (usually event.clientX/Y).
fn is_interested_in_pointer_event(runner: &AppRunner, pos: egui::Pos2) -> bool {
let document = web_sys::window().unwrap().document().unwrap();
let is_hovering_canvas = document
.element_from_point(pos.x, pos.y)
.is_some_and(|element| element.eq(runner.canvas()));
let root_node = runner.canvas().get_root_node();
let element_at_point = if let Some(document) = root_node.dyn_ref::<Document>() {
document.element_from_point(pos.x, pos.y)
} else if let Some(shadow) = root_node.dyn_ref::<ShadowRoot>() {
shadow.element_from_point(pos.x, pos.y)
} else {
None
};
let is_hovering_canvas = element_at_point.is_some_and(|element| element.eq(runner.canvas()));
let is_pointer_down = runner
.egui_ctx()
.input(|i| i.pointer.any_down() || i.any_touches());
@@ -813,53 +880,81 @@ fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result
Ok(())
}
/// Install a `ResizeObserver` to observe changes to the size of the canvas.
///
/// This is the only way to ensure a canvas size change without an associated window `resize` event
/// actually results in a resize of the canvas.
/// A `ResizeObserver` is used to observe changes to the size of the canvas.
///
/// The resize observer is called the by the browser at `observe` time, instead of just on the first actual resize.
/// We use that to trigger the first `request_animation_frame` _after_ updating the size of the canvas to the correct dimensions,
/// to avoid [#4622](https://github.com/emilk/egui/issues/4622).
pub(crate) fn install_resize_observer(runner_ref: &WebRunner) -> Result<(), JsValue> {
let closure = Closure::wrap(Box::new({
let runner_ref = runner_ref.clone();
move |entries: js_sys::Array| {
// Only call the wrapped closure if the egui code has not panicked
if let Some(mut runner_lock) = runner_ref.try_lock() {
let canvas = runner_lock.canvas();
let (width, height) = match get_display_size(&entries) {
Ok(v) => v,
Err(err) => {
log::error!("{}", super::string_from_js_value(&err));
return;
pub struct ResizeObserverContext {
observer: web_sys::ResizeObserver,
// Kept so it is not dropped until we are done with it.
_closure: Closure<dyn FnMut(js_sys::Array)>,
}
impl Drop for ResizeObserverContext {
fn drop(&mut self) {
self.observer.disconnect();
}
}
impl ResizeObserverContext {
pub fn new(runner_ref: &WebRunner) -> Result<Self, JsValue> {
let closure = Closure::wrap(Box::new({
let runner_ref = runner_ref.clone();
move |entries: js_sys::Array| {
if DEBUG_RESIZE {
log::info!("ResizeObserverContext callback");
}
// Only call the wrapped closure if the egui code has not panicked
if let Some(mut runner_lock) = runner_ref.try_lock() {
let canvas = runner_lock.canvas();
let (width, height) = match get_display_size(&entries) {
Ok(v) => v,
Err(err) => {
log::error!("{}", super::string_from_js_value(&err));
return;
}
};
if DEBUG_RESIZE {
log::info!(
"ResizeObserver: new canvas size: {width}x{height}, DPR: {}",
web_sys::window().unwrap().device_pixel_ratio()
);
}
};
canvas.set_width(width);
canvas.set_height(height);
canvas.set_width(width);
canvas.set_height(height);
// force an immediate repaint
runner_lock.needs_repaint.repaint_asap();
paint_if_needed(&mut runner_lock);
drop(runner_lock);
// we rely on the resize observer to trigger the first `request_animation_frame`:
if let Err(err) = runner_ref.request_animation_frame() {
log::error!("{}", super::string_from_js_value(&err));
};
// force an immediate repaint
runner_lock.needs_repaint.repaint_asap();
paint_if_needed(&mut runner_lock);
drop(runner_lock);
// we rely on the resize observer to trigger the first `request_animation_frame`:
if let Err(err) = runner_ref.request_animation_frame() {
log::error!("{}", super::string_from_js_value(&err));
};
} else {
log::warn!("ResizeObserverContext callback: failed to lock runner");
}
}
}
}) as Box<dyn FnMut(js_sys::Array)>);
}) as Box<dyn FnMut(js_sys::Array)>);
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
let options = web_sys::ResizeObserverOptions::new();
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox);
if let Some(runner_lock) = runner_ref.try_lock() {
observer.observe_with_options(runner_lock.canvas(), &options);
drop(runner_lock);
runner_ref.set_resize_observer(observer, closure);
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
Ok(Self {
observer,
_closure: closure,
})
}
Ok(())
pub fn observe(&self, canvas: &web_sys::HtmlCanvasElement) {
if DEBUG_RESIZE {
log::info!("Calling observe on canvas…");
}
let options = web_sys::ResizeObserverOptions::new();
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox);
self.observer.observe_with_options(canvas, &options);
}
}
// Code ported to Rust from:
@@ -878,6 +973,10 @@ fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32
width = size.inline_size();
height = size.block_size();
dpr = 1.0; // no need to apply
if DEBUG_RESIZE {
// log::info!("devicePixelContentBoxSize {width}x{height}");
}
} else if JsValue::from_str("contentBoxSize").js_in(entry.as_ref()) {
let content_box_size = entry.content_box_size();
let idx0 = content_box_size.at(0);
@@ -892,6 +991,9 @@ fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32
width = size.inline_size();
height = size.block_size();
}
if DEBUG_RESIZE {
log::info!("contentBoxSize {width}x{height}");
}
} else {
// legacy
let content_rect = entry.content_rect();

View File

@@ -41,7 +41,7 @@ pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu;
pub use backend::*;
use wasm_bindgen::prelude::*;
use web_sys::MediaQueryList;
use web_sys::{Document, MediaQueryList, Node};
use input::{
button_from_mouse_event, modifiers_from_kb_event, modifiers_from_mouse_event,
@@ -51,6 +51,9 @@ use input::{
// ----------------------------------------------------------------------------
/// Debug browser resizing?
const DEBUG_RESIZE: bool = false;
pub(crate) fn string_from_js_value(value: &JsValue) -> String {
value.as_string().unwrap_or_else(|| format!("{value:#?}"))
}
@@ -61,18 +64,22 @@ pub(crate) fn string_from_js_value(value: &JsValue) -> String {
/// - `<a>`/`<area>` with an `href` attribute
/// - `<input>`/`<select>`/`<textarea>`/`<button>` which aren't `disabled`
/// - any other element with a `tabindex` attribute
pub(crate) fn focused_element() -> Option<web_sys::Element> {
web_sys::window()?
.document()?
.active_element()?
.dyn_into()
.ok()
pub(crate) fn focused_element(root: &Node) -> Option<web_sys::Element> {
if let Some(document) = root.dyn_ref::<Document>() {
document.active_element()
} else if let Some(shadow) = root.dyn_ref::<web_sys::ShadowRoot>() {
shadow.active_element()
} else {
None
}
}
pub(crate) fn has_focus<T: JsCast>(element: &T) -> bool {
fn try_has_focus<T: JsCast>(element: &T) -> Option<bool> {
let element = element.dyn_ref::<web_sys::Element>()?;
let focused_element = focused_element()?;
let root = element.get_root_node();
let focused_element = focused_element(&root)?;
Some(element == &focused_element)
}
try_has_focus(element).unwrap_or(false)
@@ -152,7 +159,10 @@ fn canvas_content_rect(canvas: &web_sys::HtmlCanvasElement) -> egui::Rect {
}
fn canvas_size_in_points(canvas: &web_sys::HtmlCanvasElement, ctx: &egui::Context) -> egui::Vec2 {
let pixels_per_point = ctx.pixels_per_point();
// ctx.pixels_per_point can be outdated
let pixels_per_point = ctx.zoom_factor() * native_pixels_per_point();
egui::vec2(
canvas.width() as f32 / pixels_per_point,
canvas.height() as f32 / pixels_per_point,
@@ -352,3 +362,8 @@ pub fn percent_decode(s: &str) -> String {
.decode_utf8_lossy()
.to_string()
}
/// Are we running inside the Safari browser?
pub fn is_safari_browser() -> bool {
web_sys::window().is_some_and(|window| window.has_own_property(&JsValue::from("safari")))
}

View File

@@ -56,7 +56,7 @@ struct PanicHandlerInner {
/// Contains a summary about a panics.
///
/// This is basically a human-readable version of [`std::panic::PanicInfo`]
/// This is basically a human-readable version of [`std::panic::PanicHookInfo`]
/// with an added callstack.
#[derive(Clone, Debug)]
pub struct PanicSummary {
@@ -66,7 +66,7 @@ pub struct PanicSummary {
impl PanicSummary {
/// Construct a summary from a panic.
pub fn new(info: &std::panic::PanicInfo<'_>) -> Self {
pub fn new(info: &std::panic::PanicHookInfo<'_>) -> Self {
let message = info.to_string();
let callstack = Error::new().stack();
Self { message, callstack }

View File

@@ -4,6 +4,7 @@
use std::cell::Cell;
use wasm_bindgen::prelude::*;
use web_sys::{Document, Node};
use super::{AppRunner, WebRunner};
@@ -14,15 +15,16 @@ pub struct TextAgent {
impl TextAgent {
/// Attach the agent to the document.
pub fn attach(runner_ref: &WebRunner) -> Result<Self, JsValue> {
pub fn attach(runner_ref: &WebRunner, root: Node) -> Result<Self, JsValue> {
let document = web_sys::window().unwrap().document().unwrap();
// create an `<input>` element
let input = document
.create_element("input")?
.dyn_into::<web_sys::HtmlInputElement>()?;
.dyn_into::<web_sys::HtmlElement>()?;
input.set_autofocus(true)?;
let input = input.dyn_into::<web_sys::HtmlInputElement>()?;
input.set_type("text");
input.set_autofocus(true);
input.set_attribute("autocapitalize", "off")?;
// append it to `<body>` and hide it outside of the viewport
@@ -36,7 +38,17 @@ impl TextAgent {
style.set_property("position", "absolute")?;
style.set_property("top", "0")?;
style.set_property("left", "0")?;
document.body().unwrap().append_child(&input)?;
if root.has_type::<Document>() {
// root object is a document, append to its body
root.dyn_into::<Document>()?
.body()
.unwrap()
.append_child(&input)?;
} else {
// append input into root directly
root.append_child(&input)?;
}
// attach event listeners

View File

@@ -4,7 +4,7 @@ use super::web_painter::WebPainter;
use crate::WebOptions;
use egui::{Event, UserData, ViewportId};
use egui_wgpu::capture::{capture_channel, CaptureReceiver, CaptureSender, CaptureState};
use egui_wgpu::{RenderState, SurfaceErrorAction, WgpuSetup};
use egui_wgpu::{RenderState, SurfaceErrorAction};
use wasm_bindgen::JsValue;
use web_sys::HtmlCanvasElement;
@@ -63,49 +63,7 @@ impl WebPainterWgpu {
) -> Result<Self, String> {
log::debug!("Creating wgpu painter");
let instance = match &options.wgpu_options.wgpu_setup {
WgpuSetup::CreateNew {
supported_backends: backends,
power_preference,
..
} => {
let mut backends = *backends;
// Don't try WebGPU if we're not in a secure context.
if backends.contains(wgpu::Backends::BROWSER_WEBGPU) {
let is_secure_context =
web_sys::window().map_or(false, |w| w.is_secure_context());
if !is_secure_context {
log::info!(
"WebGPU is only available in secure contexts, i.e. on HTTPS and on localhost."
);
// Don't try WebGPU since we established now that it will fail.
backends.remove(wgpu::Backends::BROWSER_WEBGPU);
if backends.is_empty() {
return Err("No available supported graphics backends.".to_owned());
}
}
}
log::debug!("Creating wgpu instance with backends {:?}", backends);
let instance =
wgpu::util::new_instance_with_webgpu_detection(wgpu::InstanceDescriptor {
backends,
..Default::default()
})
.await;
// On wasm, depending on feature flags, wgpu objects may or may not implement sync.
// It doesn't make sense to switch to Rc for that special usecase, so simply disable the lint.
#[allow(clippy::arc_with_non_send_sync)]
Arc::new(instance)
}
WgpuSetup::Existing { instance, .. } => instance.clone(),
};
let instance = options.wgpu_options.wgpu_setup.new_instance().await;
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
.map_err(|err| format!("failed to create wgpu surface: {err}"))?;

View File

@@ -4,7 +4,11 @@ use wasm_bindgen::prelude::*;
use crate::{epi, App};
use super::{events, text_agent::TextAgent, AppRunner, PanicHandler};
use super::{
events::{self, ResizeObserverContext},
text_agent::TextAgent,
AppRunner, PanicHandler,
};
/// This is how `eframe` runs your web application
///
@@ -18,7 +22,7 @@ pub struct WebRunner {
/// If we ever panic during running, this `RefCell` is poisoned.
/// So before we use it, we need to check [`Self::panic_handler`].
runner: Rc<RefCell<Option<AppRunner>>>,
app_runner: Rc<RefCell<Option<AppRunner>>>,
/// In case of a panic, unsubscribe these.
/// They have to be in a separate `Rc` so that we don't need to pass them to
@@ -39,7 +43,7 @@ impl WebRunner {
Self {
panic_handler,
runner: Rc::new(RefCell::new(None)),
app_runner: Rc::new(RefCell::new(None)),
events_to_unsubscribe: Rc::new(RefCell::new(Default::default())),
frame: Default::default(),
resize_observer: Default::default(),
@@ -58,28 +62,36 @@ impl WebRunner {
) -> Result<(), JsValue> {
self.destroy();
let text_agent = TextAgent::attach(self)?;
let runner = AppRunner::new(canvas, web_options, app_creator, text_agent).await?;
{
// Make sure the canvas can be given focus.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
runner.canvas().set_tab_index(0);
canvas.set_tab_index(0);
// Don't outline the canvas when it has focus:
runner.canvas().style().set_property("outline", "none")?;
canvas.style().set_property("outline", "none")?;
}
self.runner.replace(Some(runner));
{
events::install_event_handlers(self)?;
// The resize observer handles calling `request_animation_frame` to start the render loop.
events::install_resize_observer(self)?;
// First set up the app runner:
let text_agent = TextAgent::attach(self, canvas.get_root_node())?;
let app_runner =
AppRunner::new(canvas.clone(), web_options, app_creator, text_agent).await?;
self.app_runner.replace(Some(app_runner));
}
{
let resize_observer = events::ResizeObserverContext::new(self)?;
// Properly size the canvas. Will also call `self.request_animation_frame()` (eventually)
resize_observer.observe(&canvas);
self.resize_observer.replace(Some(resize_observer));
}
events::install_event_handlers(self)?;
log::info!("event handlers installed.");
Ok(())
}
@@ -109,10 +121,7 @@ impl WebRunner {
}
}
if let Some(context) = self.resize_observer.take() {
context.resize_observer.disconnect();
drop(context.closure);
}
self.resize_observer.replace(None);
}
/// Shut down eframe and clean up resources.
@@ -124,7 +133,7 @@ impl WebRunner {
window.cancel_animation_frame(frame.id).ok();
}
if let Some(runner) = self.runner.replace(None) {
if let Some(runner) = self.app_runner.replace(None) {
runner.destroy();
}
}
@@ -138,7 +147,7 @@ impl WebRunner {
self.unsubscribe_from_all_events();
None
} else {
let lock = self.runner.try_borrow_mut().ok()?;
let lock = self.app_runner.try_borrow_mut().ok()?;
std::cell::RefMut::filter_map(lock, |lock| -> Option<&mut AppRunner> { lock.as_mut() })
.ok()
}
@@ -166,20 +175,45 @@ impl WebRunner {
event_name: &'static str,
mut closure: impl FnMut(E, &mut AppRunner) + 'static,
) -> Result<(), wasm_bindgen::JsValue> {
let runner_ref = self.clone();
let options = web_sys::AddEventListenerOptions::default();
self.add_event_listener_ex(
target,
event_name,
&options,
move |event, app_runner, _web_runner| closure(event, app_runner),
)
}
/// Convenience function to reduce boilerplate and ensure that all event handlers
/// are dealt with in the same way.
///
/// All events added with this method will automatically be unsubscribed on panic,
/// or when [`Self::destroy`] is called.
pub fn add_event_listener_ex<E: wasm_bindgen::JsCast>(
&self,
target: &web_sys::EventTarget,
event_name: &'static str,
options: &web_sys::AddEventListenerOptions,
mut closure: impl FnMut(E, &mut AppRunner, &Self) + 'static,
) -> Result<(), wasm_bindgen::JsValue> {
let web_runner = self.clone();
// Create a JS closure based on the FnMut provided
let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
// Only call the wrapped closure if the egui code has not panicked
if let Some(mut runner_lock) = runner_ref.try_lock() {
if let Some(mut runner_lock) = web_runner.try_lock() {
// Cast the event to the expected event type
let event = event.unchecked_into::<E>();
closure(event, &mut runner_lock);
closure(event, &mut runner_lock, &web_runner);
}
}) as Box<dyn FnMut(web_sys::Event)>);
// Add the event listener to the target
target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?;
target.add_event_listener_with_callback_and_add_event_listener_options(
event_name,
closure.as_ref().unchecked_ref(),
options,
)?;
let handle = TargetEvent {
target: target.clone(),
@@ -208,13 +242,13 @@ impl WebRunner {
let window = web_sys::window().unwrap();
let closure = Closure::once({
let runner_ref = self.clone();
let web_runner = self.clone();
move || {
// We can paint now, so clear the animation frame.
// This drops the `closure` and allows another
// animation frame to be scheduled
let _ = runner_ref.frame.take();
events::paint_and_schedule(&runner_ref)
let _ = web_runner.frame.take();
events::paint_and_schedule(&web_runner)
}
});
@@ -226,19 +260,6 @@ impl WebRunner {
Ok(())
}
pub(crate) fn set_resize_observer(
&self,
resize_observer: web_sys::ResizeObserver,
closure: Closure<dyn FnMut(js_sys::Array)>,
) {
self.resize_observer
.borrow_mut()
.replace(ResizeObserverContext {
resize_observer,
closure,
});
}
}
// ----------------------------------------------------------------------------
@@ -253,11 +274,6 @@ struct AnimationFrameRequest {
_closure: Closure<dyn FnMut() -> Result<(), JsValue>>,
}
struct ResizeObserverContext {
resize_observer: web_sys::ResizeObserver,
closure: Closure<dyn FnMut(js_sys::Array)>,
}
struct TargetEvent {
target: web_sys::EventTarget,
event_name: String,

View File

@@ -6,6 +6,16 @@ This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.31.1 - 2025-03-05
Nothing new
## 0.31.0 - 2025-02-04
* Upgrade to wgpu 24 [#5610](https://github.com/emilk/egui/pull/5610) by [@torokati44](https://github.com/torokati44)
* Extend `WgpuSetup`, `egui_kittest` now prefers software rasterizers for testing [#5506](https://github.com/emilk/egui/pull/5506) by [@Wumpf](https://github.com/Wumpf)
* Wgpu resources are no longer wrapped in `Arc` (since they are now `Clone`) [#5612](https://github.com/emilk/egui/pull/5612) by [@Wumpf](https://github.com/Wumpf)
## 0.30.0 - 2024-12-16
* Fix docs.rs build [#5204](https://github.com/emilk/egui/pull/5204) by [@lucasmerlin](https://github.com/lucasmerlin)
* Free textures after submitting queue instead of before with wgpu renderer [#5226](https://github.com/emilk/egui/pull/5226) by [@Rusty-Cube](https://github.com/Rusty-Cube)

View File

@@ -139,9 +139,9 @@ impl CaptureState {
encoder.copy_texture_to_buffer(
tex.as_image_copy(),
wgpu::ImageCopyBuffer {
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::ImageDataLayout {
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padding.padded_bytes_per_row),
rows_per_image: None,

View File

@@ -23,8 +23,10 @@ pub use wgpu;
/// Low-level painting of [`egui`](https://github.com/emilk/egui) on [`wgpu`].
mod renderer;
mod setup;
pub use renderer::*;
use wgpu::{Adapter, Device, Instance, Queue, TextureFormat};
pub use setup::{NativeAdapterSelectorMethod, WgpuSetup, WgpuSetupCreateNew, WgpuSetupExisting};
/// Helpers for capturing screenshots of the UI.
pub mod capture;
@@ -40,8 +42,8 @@ use epaint::mutex::RwLock;
/// An error produced by egui-wgpu.
#[derive(thiserror::Error, Debug)]
pub enum WgpuError {
#[error("Failed to create wgpu adapter, no suitable adapter found.")]
NoSuitableAdapterFound,
#[error("Failed to create wgpu adapter, no suitable adapter found: {0}")]
NoSuitableAdapterFound(String),
#[error("There was no valid format for the surface at all.")]
NoSurfaceFormatsAvailable,
@@ -61,20 +63,20 @@ pub enum WgpuError {
#[derive(Clone)]
pub struct RenderState {
/// Wgpu adapter used for rendering.
pub adapter: Arc<wgpu::Adapter>,
pub adapter: wgpu::Adapter,
/// All the available adapters.
///
/// This is not available on web.
/// On web, we always select WebGPU is available, then fall back to WebGL if not.
#[cfg(not(target_arch = "wasm32"))]
pub available_adapters: Arc<[wgpu::Adapter]>,
pub available_adapters: Vec<wgpu::Adapter>,
/// Wgpu device used for rendering, created from the adapter.
pub device: Arc<wgpu::Device>,
pub device: wgpu::Device,
/// Wgpu queue used for rendering, created from the adapter.
pub queue: Arc<wgpu::Queue>,
pub queue: wgpu::Queue,
/// The target texture format used for presenting to the window.
pub target_format: wgpu::TextureFormat,
@@ -83,6 +85,72 @@ pub struct RenderState {
pub renderer: Arc<RwLock<Renderer>>,
}
async fn request_adapter(
instance: &wgpu::Instance,
power_preference: wgpu::PowerPreference,
compatible_surface: Option<&wgpu::Surface<'_>>,
_available_adapters: &[wgpu::Adapter],
) -> Result<wgpu::Adapter, WgpuError> {
profiling::function_scope!();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference,
compatible_surface,
// We don't expose this as an option right now since it's fairly rarely useful:
// * only has an effect on native
// * fails if there's no software rasterizer available
// * can achieve the same with `native_adapter_selector`
force_fallback_adapter: false,
})
.await
.ok_or_else(|| {
#[cfg(not(target_arch = "wasm32"))]
if _available_adapters.is_empty() {
log::info!("No wgpu adapters found");
} else if _available_adapters.len() == 1 {
log::info!(
"The only available wgpu adapter was not suitable: {}",
adapter_info_summary(&_available_adapters[0].get_info())
);
} else {
log::info!(
"No suitable wgpu adapter found out of the {} available ones: {}",
_available_adapters.len(),
describe_adapters(_available_adapters)
);
}
WgpuError::NoSuitableAdapterFound("`request_adapters` returned `None`".to_owned())
})?;
#[cfg(target_arch = "wasm32")]
log::debug!(
"Picked wgpu adapter: {}",
adapter_info_summary(&adapter.get_info())
);
#[cfg(not(target_arch = "wasm32"))]
if _available_adapters.len() == 1 {
log::debug!(
"Picked the only available wgpu adapter: {}",
adapter_info_summary(&adapter.get_info())
);
} else {
log::info!(
"There were {} available wgpu adapters: {}",
_available_adapters.len(),
describe_adapters(_available_adapters)
);
log::debug!(
"Picked wgpu adapter: {}",
adapter_info_summary(&adapter.get_info())
);
}
Ok(adapter)
}
impl RenderState {
/// Creates a new [`RenderState`], containing everything needed for drawing egui with wgpu.
///
@@ -100,96 +168,65 @@ impl RenderState {
// This is always an empty list on web.
#[cfg(not(target_arch = "wasm32"))]
let available_adapters = instance.enumerate_adapters(wgpu::Backends::all());
let available_adapters = {
let backends = if let WgpuSetup::CreateNew(create_new) = &config.wgpu_setup {
create_new.instance_descriptor.backends
} else {
wgpu::Backends::all()
};
instance.enumerate_adapters(backends)
};
let (adapter, device, queue) = match config.wgpu_setup.clone() {
WgpuSetup::CreateNew {
supported_backends: _,
WgpuSetup::CreateNew(WgpuSetupCreateNew {
instance_descriptor: _,
power_preference,
native_adapter_selector: _native_adapter_selector,
device_descriptor,
} => {
trace_path,
}) => {
let adapter = {
profiling::scope!("request_adapter");
instance
.request_adapter(&wgpu::RequestAdapterOptions {
#[cfg(target_arch = "wasm32")]
{
request_adapter(instance, power_preference, compatible_surface, &[]).await
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(native_adapter_selector) = _native_adapter_selector {
native_adapter_selector(&available_adapters, compatible_surface)
.map_err(WgpuError::NoSuitableAdapterFound)
} else {
request_adapter(
instance,
power_preference,
compatible_surface,
force_fallback_adapter: false,
})
&available_adapters,
)
.await
.ok_or_else(|| {
#[cfg(not(target_arch = "wasm32"))]
if available_adapters.is_empty() {
log::info!("No wgpu adapters found");
} else if available_adapters.len() == 1 {
log::info!(
"The only available wgpu adapter was not suitable: {}",
adapter_info_summary(&available_adapters[0].get_info())
);
} else {
log::info!(
"No suitable wgpu adapter found out of the {} available ones: {}",
available_adapters.len(),
describe_adapters(&available_adapters)
);
}
}
}?;
WgpuError::NoSuitableAdapterFound
})?
};
#[cfg(target_arch = "wasm32")]
log::debug!(
"Picked wgpu adapter: {}",
adapter_info_summary(&adapter.get_info())
);
#[cfg(not(target_arch = "wasm32"))]
if available_adapters.len() == 1 {
log::debug!(
"Picked the only available wgpu adapter: {}",
adapter_info_summary(&adapter.get_info())
);
} else {
log::info!(
"There were {} available wgpu adapters: {}",
available_adapters.len(),
describe_adapters(&available_adapters)
);
log::debug!(
"Picked wgpu adapter: {}",
adapter_info_summary(&adapter.get_info())
);
}
let trace_path = std::env::var("WGPU_TRACE");
let (device, queue) = {
profiling::scope!("request_device");
adapter
.request_device(
&(*device_descriptor)(&adapter),
trace_path.ok().as_ref().map(std::path::Path::new),
)
.request_device(&(*device_descriptor)(&adapter), trace_path.as_deref())
.await?
};
// On wasm, depending on feature flags, wgpu objects may or may not implement sync.
// It doesn't make sense to switch to Rc for that special usecase, so simply disable the lint.
#[allow(clippy::arc_with_non_send_sync)]
(Arc::new(adapter), Arc::new(device), Arc::new(queue))
(adapter, device, queue)
}
WgpuSetup::Existing {
WgpuSetup::Existing(WgpuSetupExisting {
instance: _,
adapter,
device,
queue,
} => (adapter, device, queue),
}) => (adapter, device, queue),
};
let surface_formats = {
profiling::scope!("get_capabilities");
compatible_surface.map_or_else(
|| vec![TextureFormat::Rgba8Unorm],
|| vec![wgpu::TextureFormat::Rgba8Unorm],
|s| s.get_capabilities(&adapter).formats,
)
};
@@ -209,7 +246,7 @@ impl RenderState {
Ok(Self {
adapter,
#[cfg(not(target_arch = "wasm32"))]
available_adapters: available_adapters.into(),
available_adapters,
device,
queue,
target_format,
@@ -225,14 +262,11 @@ fn describe_adapters(adapters: &[wgpu::Adapter]) -> String {
} else if adapters.len() == 1 {
adapter_info_summary(&adapters[0].get_info())
} else {
let mut list_string = String::new();
for adapter in adapters {
if !list_string.is_empty() {
list_string += ", ";
}
list_string += &format!("{{{}}}", adapter_info_summary(&adapter.get_info()));
}
list_string
adapters
.iter()
.map(|a| format!("{{{}}}", adapter_info_summary(&a.get_info())))
.collect::<Vec<_>>()
.join(", ")
}
}
@@ -245,62 +279,6 @@ pub enum SurfaceErrorAction {
RecreateSurface,
}
#[derive(Clone)]
pub enum WgpuSetup {
/// Construct a wgpu setup using some predefined settings & heuristics.
/// This is the default option. You can customize most behaviours overriding the
/// supported backends, power preferences, and device description.
///
/// This can also be configured with the environment variables:
/// * `WGPU_BACKEND`: `vulkan`, `dx11`, `dx12`, `metal`, `opengl`, `webgpu`
/// * `WGPU_POWER_PREF`: `low`, `high` or `none`
CreateNew {
/// Backends that should be supported (wgpu will pick one of these).
///
/// For instance, if you only want to support WebGL (and not WebGPU),
/// you can set this to [`wgpu::Backends::GL`].
///
/// By default on web, WebGPU will be used if available.
/// WebGL will only be used as a fallback,
/// and only if you have enabled the `webgl` feature of crate `wgpu`.
supported_backends: wgpu::Backends,
/// Power preference for the adapter.
power_preference: wgpu::PowerPreference,
/// Configuration passed on device request, given an adapter
device_descriptor:
Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>,
},
/// Run on an existing wgpu setup.
Existing {
instance: Arc<Instance>,
adapter: Arc<Adapter>,
device: Arc<Device>,
queue: Arc<Queue>,
},
}
impl std::fmt::Debug for WgpuSetup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CreateNew {
supported_backends,
power_preference,
device_descriptor: _,
} => f
.debug_struct("AdapterSelection::Standard")
.field("supported_backends", &supported_backends)
.field("power_preference", &power_preference)
.finish(),
Self::Existing { .. } => f
.debug_struct("AdapterSelection::Existing")
.finish_non_exhaustive(),
}
}
}
/// Configuration for using wgpu with eframe or the egui-wgpu winit feature.
#[derive(Clone)]
pub struct WgpuConfiguration {
@@ -352,42 +330,8 @@ impl Default for WgpuConfiguration {
fn default() -> Self {
Self {
present_mode: wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: None,
// By default, create a new wgpu setup. This will create a new instance, adapter, device and queue.
// This will create an instance for the supported backends (which can be configured by
// `WGPU_BACKEND`), and will pick an adapter by iterating adapters based on their power preference. The power
// preference can also be configured by `WGPU_POWER_PREF`.
wgpu_setup: WgpuSetup::CreateNew {
// Add GL backend, primarily because WebGPU is not stable enough yet.
// (note however, that the GL backend needs to be opted-in via the wgpu feature flag "webgl")
supported_backends: wgpu::util::backend_bits_from_env()
.unwrap_or(wgpu::Backends::PRIMARY | wgpu::Backends::GL),
power_preference: wgpu::util::power_preference_from_env()
.unwrap_or(wgpu::PowerPreference::HighPerformance),
device_descriptor: Arc::new(|adapter| {
let base_limits = if adapter.get_info().backend == wgpu::Backend::Gl {
wgpu::Limits::downlevel_webgl2_defaults()
} else {
wgpu::Limits::default()
};
wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
required_features: wgpu::Features::default(),
required_limits: wgpu::Limits {
// When using a depth buffer, we have to be able to create a texture
// large enough for the entire surface, and we want to support 4k+ displays.
max_texture_dimension_2d: 8192,
..base_limits
},
memory_hints: wgpu::MemoryHints::default(),
}
}),
},
wgpu_setup: Default::default(),
on_surface_error: Arc::new(|err| {
if err == wgpu::SurfaceError::Outdated {
// This error occurs when the app is minimized on Windows.
@@ -468,8 +412,14 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
summary += &format!(", driver_info: {driver_info:?}");
}
if *vendor != 0 {
// TODO(emilk): decode using https://github.com/gfx-rs/wgpu/blob/767ac03245ee937d3dc552edc13fe7ab0a860eec/wgpu-hal/src/auxil/mod.rs#L7
summary += &format!(", vendor: 0x{vendor:04X}");
#[cfg(not(target_arch = "wasm32"))]
{
summary += &format!(", vendor: {} (0x{vendor:04X})", parse_vendor_id(*vendor));
}
#[cfg(target_arch = "wasm32")]
{
summary += &format!(", vendor: 0x{vendor:04X}");
}
}
if *device != 0 {
summary += &format!(", device: 0x{device:02X}");
@@ -477,3 +427,20 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
summary
}
/// Tries to parse the adapter's vendor ID to a human-readable string.
#[cfg(not(target_arch = "wasm32"))]
pub fn parse_vendor_id(vendor_id: u32) -> &'static str {
match vendor_id {
wgpu::hal::auxil::db::amd::VENDOR => "AMD",
wgpu::hal::auxil::db::apple::VENDOR => "Apple",
wgpu::hal::auxil::db::arm::VENDOR => "ARM",
wgpu::hal::auxil::db::broadcom::VENDOR => "Broadcom",
wgpu::hal::auxil::db::imgtec::VENDOR => "Imagination Technologies",
wgpu::hal::auxil::db::intel::VENDOR => "Intel",
wgpu::hal::auxil::db::mesa::VENDOR => "Mesa",
wgpu::hal::auxil::db::nvidia::VENDOR => "NVIDIA",
wgpu::hal::auxil::db::qualcomm::VENDOR => "Qualcomm",
_ => "Unknown",
}
}

View File

@@ -579,14 +579,14 @@ impl Renderer {
let queue_write_data_to_texture = |texture, origin| {
profiling::scope!("write_texture");
queue.write_texture(
wgpu::ImageCopyTexture {
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin,
aspect: wgpu::TextureAspect::All,
},
data_bytes,
wgpu::ImageDataLayout {
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * width),
rows_per_image: Some(height),

View File

@@ -0,0 +1,217 @@
use std::sync::Arc;
#[derive(Clone)]
pub enum WgpuSetup {
/// Construct a wgpu setup using some predefined settings & heuristics.
/// This is the default option. You can customize most behaviours overriding the
/// supported backends, power preferences, and device description.
///
/// By default can also be configured with various environment variables:
/// * `WGPU_BACKEND`: `vulkan`, `dx12`, `metal`, `opengl`, `webgpu`
/// * `WGPU_POWER_PREF`: `low`, `high` or `none`
/// * `WGPU_TRACE`: Path to a file to output a wgpu trace file.
///
/// Each instance flag also comes with an environment variable (for details see [`wgpu::InstanceFlags`]):
/// * `WGPU_VALIDATION`: Enables validation (enabled by default in debug builds).
/// * `WGPU_DEBUG`: Generate debug information in shaders and objects (enabled by default in debug builds).
/// * `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER`: Whether wgpu should expose adapters that run on top of non-compliant adapters.
/// * `WGPU_GPU_BASED_VALIDATION`: Enable GPU-based validation.
CreateNew(WgpuSetupCreateNew),
/// Run on an existing wgpu setup.
Existing(WgpuSetupExisting),
}
impl Default for WgpuSetup {
fn default() -> Self {
Self::CreateNew(WgpuSetupCreateNew::default())
}
}
impl std::fmt::Debug for WgpuSetup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CreateNew(create_new) => f
.debug_tuple("WgpuSetup::CreateNew")
.field(create_new)
.finish(),
Self::Existing { .. } => f.debug_tuple("WgpuSetup::Existing").finish(),
}
}
}
impl WgpuSetup {
/// Creates a new [`wgpu::Instance`] or clones the existing one.
///
/// Does *not* store the wgpu instance, so calling this repeatedly may
/// create a new instance every time!
pub async fn new_instance(&self) -> wgpu::Instance {
match self {
Self::CreateNew(create_new) => {
#[allow(unused_mut)]
let mut backends = create_new.instance_descriptor.backends;
// Don't try WebGPU if we're not in a secure context.
#[cfg(target_arch = "wasm32")]
if backends.contains(wgpu::Backends::BROWSER_WEBGPU) {
let is_secure_context =
wgpu::web_sys::window().is_some_and(|w| w.is_secure_context());
if !is_secure_context {
log::info!(
"WebGPU is only available in secure contexts, i.e. on HTTPS and on localhost."
);
backends.remove(wgpu::Backends::BROWSER_WEBGPU);
}
}
log::debug!("Creating wgpu instance with backends {:?}", backends);
wgpu::util::new_instance_with_webgpu_detection(&create_new.instance_descriptor)
.await
}
Self::Existing(existing) => existing.instance.clone(),
}
}
}
impl From<WgpuSetupCreateNew> for WgpuSetup {
fn from(create_new: WgpuSetupCreateNew) -> Self {
Self::CreateNew(create_new)
}
}
impl From<WgpuSetupExisting> for WgpuSetup {
fn from(existing: WgpuSetupExisting) -> Self {
Self::Existing(existing)
}
}
/// Method for selecting an adapter on native.
///
/// This can be used for fully custom adapter selection.
/// If available, `wgpu::Surface` is passed to allow checking for surface compatibility.
pub type NativeAdapterSelectorMethod = Arc<
dyn Fn(&[wgpu::Adapter], Option<&wgpu::Surface<'_>>) -> Result<wgpu::Adapter, String>
+ Send
+ Sync,
>;
/// Configuration for creating a new wgpu setup.
///
/// Used for [`WgpuSetup::CreateNew`].
pub struct WgpuSetupCreateNew {
/// Instance descriptor for creating a wgpu instance.
///
/// The most important field is [`wgpu::InstanceDescriptor::backends`], which
/// controls which backends are supported (wgpu will pick one of these).
/// If you only want to support WebGL (and not WebGPU),
/// you can set this to [`wgpu::Backends::GL`].
/// By default on web, WebGPU will be used if available.
/// WebGL will only be used as a fallback,
/// and only if you have enabled the `webgl` feature of crate `wgpu`.
pub instance_descriptor: wgpu::InstanceDescriptor,
/// Power preference for the adapter if [`Self::native_adapter_selector`] is not set or targeting web.
pub power_preference: wgpu::PowerPreference,
/// Optional selector for native adapters.
///
/// This field has no effect when targeting web!
/// Otherwise, if set [`Self::power_preference`] is ignored and the adapter is instead selected by this method.
/// Note that [`Self::instance_descriptor`]'s [`wgpu::InstanceDescriptor::backends`]
/// are still used to filter the adapter enumeration in the first place.
///
/// Defaults to `None`.
pub native_adapter_selector: Option<NativeAdapterSelectorMethod>,
/// Configuration passed on device request, given an adapter
pub device_descriptor:
Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>,
/// Option path to output a wgpu trace file.
///
/// This only works if this feature is enabled in `wgpu-core`.
/// Does not work when running with WebGPU.
/// Defaults to the path set in the `WGPU_TRACE` environment variable.
pub trace_path: Option<std::path::PathBuf>,
}
impl Clone for WgpuSetupCreateNew {
fn clone(&self) -> Self {
Self {
instance_descriptor: self.instance_descriptor.clone(),
power_preference: self.power_preference,
native_adapter_selector: self.native_adapter_selector.clone(),
device_descriptor: self.device_descriptor.clone(),
trace_path: self.trace_path.clone(),
}
}
}
impl std::fmt::Debug for WgpuSetupCreateNew {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WgpuSetupCreateNew")
.field("instance_descriptor", &self.instance_descriptor)
.field("power_preference", &self.power_preference)
.field(
"native_adapter_selector",
&self.native_adapter_selector.is_some(),
)
.field("trace_path", &self.trace_path)
.finish()
}
}
impl Default for WgpuSetupCreateNew {
fn default() -> Self {
Self {
instance_descriptor: wgpu::InstanceDescriptor {
// Add GL backend, primarily because WebGPU is not stable enough yet.
// (note however, that the GL backend needs to be opted-in via the wgpu feature flag "webgl")
backends: wgpu::Backends::from_env()
.unwrap_or(wgpu::Backends::PRIMARY | wgpu::Backends::GL),
flags: wgpu::InstanceFlags::from_build_config().with_env(),
backend_options: wgpu::BackendOptions::from_env_or_default(),
},
power_preference: wgpu::PowerPreference::from_env()
.unwrap_or(wgpu::PowerPreference::HighPerformance),
native_adapter_selector: None,
device_descriptor: Arc::new(|adapter| {
let base_limits = if adapter.get_info().backend == wgpu::Backend::Gl {
wgpu::Limits::downlevel_webgl2_defaults()
} else {
wgpu::Limits::default()
};
wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
required_features: wgpu::Features::default(),
required_limits: wgpu::Limits {
// When using a depth buffer, we have to be able to create a texture
// large enough for the entire surface, and we want to support 4k+ displays.
max_texture_dimension_2d: 8192,
..base_limits
},
memory_hints: wgpu::MemoryHints::default(),
}
}),
trace_path: std::env::var("WGPU_TRACE")
.ok()
.map(std::path::PathBuf::from),
}
}
}
/// Configuration for using an existing wgpu setup.
///
/// Used for [`WgpuSetup::Existing`].
#[derive(Clone)]
pub struct WgpuSetupExisting {
pub instance: wgpu::Instance,
pub adapter: wgpu::Adapter,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
}

View File

@@ -27,7 +27,7 @@ pub struct Painter {
depth_format: Option<wgpu::TextureFormat>,
screen_capture_state: Option<CaptureState>,
instance: Arc<wgpu::Instance>,
instance: wgpu::Instance,
render_state: Option<RenderState>,
// Per viewport/window:
@@ -51,7 +51,7 @@ impl Painter {
/// [`set_window()`](Self::set_window) once you have
/// a [`winit::window::Window`] with a valid `.raw_window_handle()`
/// associated.
pub fn new(
pub async fn new(
context: Context,
configuration: WgpuConfiguration,
msaa_samples: u32,
@@ -59,17 +59,8 @@ impl Painter {
support_transparent_backbuffer: bool,
dithering: bool,
) -> Self {
let instance = match &configuration.wgpu_setup {
crate::WgpuSetup::CreateNew {
supported_backends, ..
} => Arc::new(wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: *supported_backends,
..Default::default()
})),
crate::WgpuSetup::Existing { instance, .. } => instance.clone(),
};
let (capture_tx, capture_rx) = capture_channel();
let instance = configuration.wgpu_setup.new_instance().await;
Self {
context,

View File

@@ -5,6 +5,15 @@ This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.31.1 - 2025-03-05
Nothing new
## 0.31.0 - 2025-02-04
* Re-enable IME support on Linux [#5198](https://github.com/emilk/egui/pull/5198) by [@YgorSouza](https://github.com/YgorSouza)
* Update to winit 0.30.7 [#5516](https://github.com/emilk/egui/pull/5516) by [@emilk](https://github.com/emilk)
## 0.30.0 - 2024-12-16
* iOS: Support putting UI next to the dynamic island [#5211](https://github.com/emilk/egui/pull/5211) by [@frederik-uni](https://github.com/frederik-uni)
* Remove implicit `accesskit_winit` feature [#5316](https://github.com/emilk/egui/pull/5316) by [@waywardmonkeys](https://github.com/waywardmonkeys)

View File

@@ -67,7 +67,7 @@ winit = { workspace = true, default-features = false }
#! ### Optional dependencies
# feature accesskit
accesskit_winit = { version = "0.23", optional = true }
accesskit_winit = { workspace = true, optional = true }
bytemuck = { workspace = true, optional = true }

View File

@@ -166,12 +166,14 @@ impl State {
#[cfg(feature = "accesskit")]
pub fn init_accesskit<T: From<accesskit_winit::Event> + Send>(
&mut self,
event_loop: &ActiveEventLoop,
window: &Window,
event_loop_proxy: winit::event_loop::EventLoopProxy<T>,
) {
profiling::function_scope!();
self.accesskit = Some(accesskit_winit::Adapter::with_event_loop_proxy(
event_loop,
window,
event_loop_proxy,
));
@@ -727,7 +729,7 @@ impl State {
// When telling users "Press Ctrl-F to find", this is where we should
// look for the "F" key, because they may have a dvorak layout on
// a qwerty keyboard, and so the logical "F" character may not be located on the physical `KeyCode::KeyF` position.
logical_key,
logical_key: winit_logical_key,
text,
@@ -746,7 +748,7 @@ impl State {
None
};
let logical_key = key_from_winit_key(logical_key);
let logical_key = key_from_winit_key(winit_logical_key);
// Helpful logging to enable when adding new key support
log::trace!(
@@ -789,7 +791,11 @@ impl State {
});
}
if let Some(text) = &text {
if let Some(text) = text
.as_ref()
.map(|t| t.as_str())
.or_else(|| winit_logical_key.to_text())
{
// Make sure there is text, and that it is not control characters
// (e.g. delete is sent as "\u{f728}" on macOS).
if !text.is_empty() && text.chars().all(is_printable_char) {
@@ -803,7 +809,7 @@ impl State {
if pressed && !is_cmd {
self.egui_input
.events
.push(egui::Event::Text(text.to_string()));
.push(egui::Event::Text(text.to_owned()));
}
}
}
@@ -850,6 +856,9 @@ impl State {
egui::OutputCommand::OpenUrl(open_url) => {
open_url_in_browser(&open_url.url);
}
egui::OutputCommand::SetPointerPosition(egui::Pos2 { x, y }) => {
let _ = window.set_cursor_position(winit::dpi::LogicalPosition { x, y });
}
}
}
@@ -1610,6 +1619,7 @@ pub fn create_winit_window_attributes(
// macOS:
fullsize_content_view: _fullsize_content_view,
movable_by_window_background: _movable_by_window_background,
title_shown: _title_shown,
titlebar_buttons_shown: _titlebar_buttons_shown,
titlebar_shown: _titlebar_shown,
@@ -1756,7 +1766,8 @@ pub fn create_winit_window_attributes(
.with_title_hidden(!_title_shown.unwrap_or(true))
.with_titlebar_buttons_hidden(!_titlebar_buttons_shown.unwrap_or(true))
.with_titlebar_transparent(!_titlebar_shown.unwrap_or(true))
.with_fullsize_content_view(_fullsize_content_view.unwrap_or(false));
.with_fullsize_content_view(_fullsize_content_view.unwrap_or(false))
.with_movable_by_window_background(_movable_by_window_background.unwrap_or(false));
}
window_attributes
@@ -1824,6 +1835,9 @@ pub fn apply_viewport_builder_to_window(
let pos = PhysicalPosition::new(pixels_per_point * pos.x, pixels_per_point * pos.y);
window.set_outer_position(pos);
}
if let Some(maximized) = builder.maximized {
window.set_maximized(maximized);
}
}
}

View File

@@ -13,6 +13,8 @@ pub struct WindowSettings {
fullscreen: bool,
maximized: bool,
/// Inner size of window in logical pixels
inner_size_points: Option<egui::Vec2>,
}
@@ -38,6 +40,7 @@ impl WindowSettings {
outer_position_pixels,
fullscreen: window.fullscreen().is_some(),
maximized: window.is_maximized(),
inner_size_points: Some(egui::vec2(
inner_size_points.width,
@@ -80,7 +83,8 @@ impl WindowSettings {
if let Some(inner_size_points) = self.inner_size_points {
viewport_builder = viewport_builder
.with_inner_size(inner_size_points)
.with_fullscreen(self.fullscreen);
.with_fullscreen(self.fullscreen)
.with_maximized(self.maximized);
}
viewport_builder

View File

@@ -84,11 +84,12 @@ emath = { workspace = true, default-features = false }
epaint = { workspace = true, default-features = false }
ahash.workspace = true
bitflags.workspace = true
nohash-hasher.workspace = true
profiling.workspace = true
#! ### Optional dependencies
accesskit = { version = "0.17.0", optional = true }
accesskit = { workspace = true, optional = true }
backtrace = { workspace = true, optional = true }

View File

@@ -5,8 +5,8 @@
use emath::GuiRounding as _;
use crate::{
emath, pos2, Align2, Context, Id, InnerResponse, LayerId, NumExt, Order, Pos2, Rect, Response,
Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState,
emath, pos2, Align2, Context, Id, InnerResponse, LayerId, Layout, NumExt, Order, Pos2, Rect,
Response, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState,
};
/// State of an [`Area`] that is persisted between frames.
@@ -103,10 +103,10 @@ impl AreaState {
///
/// The previous rectangle used by this area can be obtained through [`crate::Memory::area_rect()`].
#[must_use = "You should call .show()"]
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub struct Area {
pub(crate) id: Id,
kind: UiKind,
info: UiStackInfo,
sense: Option<Sense>,
movable: bool,
interactable: bool,
@@ -120,6 +120,7 @@ pub struct Area {
anchor: Option<(Align2, Vec2)>,
new_pos: Option<Pos2>,
fade_in: bool,
layout: Layout,
}
impl WidgetWithState for Area {
@@ -131,7 +132,7 @@ impl Area {
pub fn new(id: Id) -> Self {
Self {
id,
kind: UiKind::GenericArea,
info: UiStackInfo::new(UiKind::GenericArea),
sense: None,
movable: true,
interactable: true,
@@ -145,6 +146,7 @@ impl Area {
pivot: Align2::LEFT_TOP,
anchor: None,
fade_in: true,
layout: Layout::default(),
}
}
@@ -162,7 +164,16 @@ impl Area {
/// Default to [`UiKind::GenericArea`].
#[inline]
pub fn kind(mut self, kind: UiKind) -> Self {
self.kind = kind;
self.info = UiStackInfo::new(kind);
self
}
/// Set the [`UiStackInfo`] of the area's [`Ui`].
///
/// Default to [`UiStackInfo::new(UiKind::GenericArea)`].
#[inline]
pub fn info(mut self, info: UiStackInfo) -> Self {
self.info = info;
self
}
@@ -339,10 +350,17 @@ impl Area {
self.fade_in = fade_in;
self
}
/// Set the layout for the child Ui.
#[inline]
pub fn layout(mut self, layout: Layout) -> Self {
self.layout = layout;
self
}
}
pub(crate) struct Prepared {
kind: UiKind,
info: Option<UiStackInfo>,
layer_id: LayerId,
state: AreaState,
move_response: Response,
@@ -358,6 +376,7 @@ pub(crate) struct Prepared {
sizing_pass: bool,
fade_in: bool,
layout: Layout,
}
impl Area {
@@ -366,7 +385,7 @@ impl Area {
ctx: &Context,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
let prepared = self.begin(ctx);
let mut prepared = self.begin(ctx);
let mut content_ui = prepared.content_ui(ctx);
let inner = add_contents(&mut content_ui);
let response = prepared.end(ctx, content_ui);
@@ -376,7 +395,7 @@ impl Area {
pub(crate) fn begin(self, ctx: &Context) -> Prepared {
let Self {
id,
kind,
info,
sense,
movable,
order,
@@ -390,6 +409,7 @@ impl Area {
constrain,
constrain_rect,
fade_in,
layout,
} = self;
let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect());
@@ -507,7 +527,7 @@ impl Area {
move_response.interact_rect = state.rect();
Prepared {
kind,
info: Some(info),
layer_id,
state,
move_response,
@@ -516,6 +536,7 @@ impl Area {
constrain_rect,
sizing_pass,
fade_in,
layout,
}
}
}
@@ -537,13 +558,15 @@ impl Prepared {
self.constrain_rect
}
pub(crate) fn content_ui(&self, ctx: &Context) -> Ui {
pub(crate) fn content_ui(&mut self, ctx: &Context) -> Ui {
let max_rect = self.state.rect();
let mut ui_builder = UiBuilder::new()
.ui_stack_info(UiStackInfo::new(self.kind))
.ui_stack_info(self.info.take().unwrap_or_default())
.layer_id(self.layer_id)
.max_rect(max_rect);
.max_rect(max_rect)
.layout(self.layout)
.closable();
if !self.enabled {
ui_builder = ui_builder.disabled();
@@ -582,7 +605,7 @@ impl Prepared {
#[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`.
pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response {
let Self {
kind: _,
info: _,
layer_id,
mut state,
move_response: mut response,
@@ -598,6 +621,12 @@ impl Prepared {
response.rect = final_rect;
response.interact_rect = final_rect;
// TODO(lucasmerlin): Can the area response be based on Ui::response? Then this won't be needed
// Bubble up the close event
if content_ui.should_close() {
response.set_close();
}
ctx.memory_mut(|m| m.areas_mut().set_state(layer_id, state));
if sizing_pass {

View File

@@ -0,0 +1,20 @@
use std::sync::atomic::AtomicBool;
#[derive(Debug, Default)]
pub struct ClosableTag {
pub close: AtomicBool,
}
impl ClosableTag {
pub const NAME: &'static str = "egui_close_tag";
/// Set close to `true`
pub fn set_close(&self) {
self.close.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Returns `true` if [`ClosableTag::set_close`] has been called.
pub fn should_close(&self) -> bool {
self.close.load(std::sync::atomic::Ordering::Relaxed)
}
}

View File

@@ -2,9 +2,11 @@ use std::hash::Hash;
use crate::{
emath, epaint, pos2, remap, remap_clamp, vec2, Context, Id, InnerResponse, NumExt, Rect,
Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, UiBuilder, UiKind, UiStackInfo, Vec2,
WidgetInfo, WidgetText, WidgetType,
};
use epaint::Shape;
use emath::GuiRounding as _;
use epaint::{Shape, StrokeKind};
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -202,11 +204,16 @@ impl CollapsingState {
add_body: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let openness = self.openness(ui.ctx());
let builder = UiBuilder::new()
.ui_stack_info(UiStackInfo::new(UiKind::Collapsible))
.closable();
if openness <= 0.0 {
self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring
None
} else if openness < 1.0 {
Some(ui.scope(|child_ui| {
Some(ui.scope_builder(builder, |child_ui| {
let max_height = if self.state.open && self.state.open_height.is_none() {
// First frame of expansion.
// We don't know full height yet, but we will next frame.
@@ -214,7 +221,7 @@ impl CollapsingState {
10.0
} else {
let full_height = self.state.open_height.unwrap_or_default();
remap_clamp(openness, 0.0..=1.0, 0.0..=full_height)
remap_clamp(openness, 0.0..=1.0, 0.0..=full_height).round_ui()
};
let mut clip_rect = child_ui.clip_rect();
@@ -225,6 +232,9 @@ impl CollapsingState {
let mut min_rect = child_ui.min_rect();
self.state.open_height = Some(min_rect.height());
if child_ui.should_close() {
self.state.open = false;
}
self.store(child_ui.ctx()); // remember the height
// Pretend children took up at most `max_height` space:
@@ -233,7 +243,10 @@ impl CollapsingState {
ret
}))
} else {
let ret_response = ui.scope(add_body);
let ret_response = ui.scope_builder(builder, add_body);
if ret_response.response.should_close() {
self.state.open = false;
}
let full_size = ret_response.response.rect.size();
self.state.open_height = Some(full_size.y);
self.store(ui.ctx()); // remember the height
@@ -282,7 +295,7 @@ pub struct HeaderResponse<'ui, HeaderRet> {
header_response: InnerResponse<HeaderRet>,
}
impl<'ui, HeaderRet> HeaderResponse<'ui, HeaderRet> {
impl<HeaderRet> HeaderResponse<'_, HeaderRet> {
pub fn is_open(&self) -> bool {
self.state.is_open()
}
@@ -572,9 +585,10 @@ impl CollapsingHeader {
if ui.visuals().collapsing_header_frame || show_background {
ui.painter().add(epaint::RectShape::new(
header_response.rect.expand(visuals.expansion),
visuals.rounding,
visuals.corner_radius,
visuals.weak_bg_fill,
visuals.bg_stroke,
StrokeKind::Inside,
));
}
@@ -582,8 +596,13 @@ impl CollapsingHeader {
{
let rect = rect.expand(visuals.expansion);
ui.painter()
.rect(rect, visuals.rounding, visuals.bg_fill, visuals.bg_stroke);
ui.painter().rect(
rect,
visuals.corner_radius,
visuals.bg_fill,
visuals.bg_stroke,
StrokeKind::Inside,
);
}
{

View File

@@ -1,23 +1,16 @@
use epaint::Shape;
use crate::{
epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter,
PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui,
UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType,
epaint, style::StyleModifier, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse,
NumExt, Painter, Popup, PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke,
TextStyle, TextWrapMode, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType,
};
#[allow(unused_imports)] // Documentation
use crate::style::Spacing;
/// Indicate whether a popup will be shown above or below the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AboveOrBelow {
Above,
Below,
}
/// A function that paints the [`ComboBox`] icon
pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>;
pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool)>;
/// A drop-down selection menu with a descriptive label.
///
@@ -135,7 +128,6 @@ impl ComboBox {
/// rect: egui::Rect,
/// visuals: &egui::style::WidgetVisuals,
/// _is_open: bool,
/// _above_or_below: egui::AboveOrBelow,
/// ) {
/// let rect = egui::Rect::from_center_size(
/// rect.center(),
@@ -154,10 +146,8 @@ impl ComboBox {
/// .show_ui(ui, |_ui| {});
/// # });
/// ```
pub fn icon(
mut self,
icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
) -> Self {
#[inline]
pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self {
self.icon = Some(Box::new(icon_fn));
self
}
@@ -322,22 +312,6 @@ fn combo_box_dyn<'c, R>(
let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id));
let popup_height = ui.memory(|m| {
m.areas()
.get(popup_id)
.and_then(|state| state.size)
.map_or(100.0, |size| size.y)
});
let above_or_below =
if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height
< ui.ctx().screen_rect().bottom()
{
AboveOrBelow::Below
} else {
AboveOrBelow::Above
};
let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
@@ -385,15 +359,9 @@ fn combo_box_dyn<'c, R>(
icon_rect.expand(visuals.expansion),
visuals,
is_popup_open,
above_or_below,
);
} else {
paint_default_icon(
ui.painter(),
icon_rect.expand(visuals.expansion),
visuals,
above_or_below,
);
paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals);
}
let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
@@ -402,19 +370,16 @@ fn combo_box_dyn<'c, R>(
}
});
if button_response.clicked() {
ui.memory_mut(|mem| mem.toggle_popup(popup_id));
}
let height = height.unwrap_or_else(|| ui.spacing().combo_height);
let inner = crate::popup::popup_above_or_below_widget(
ui,
popup_id,
&button_response,
above_or_below,
close_behavior,
|ui| {
let inner = Popup::menu(&button_response)
.id(popup_id)
.style(StyleModifier::default())
.width(button_response.rect.width())
.close_behavior(close_behavior)
.show(|ui| {
ui.set_min_width(ui.available_width());
ScrollArea::vertical()
.max_height(height)
.show(ui, |ui| {
@@ -427,8 +392,8 @@ fn combo_box_dyn<'c, R>(
menu_contents(ui)
})
.inner
},
);
})
.map(|r| r.inner);
InnerResponse {
inner,
@@ -471,9 +436,10 @@ fn button_frame(
where_to_put_background,
epaint::RectShape::new(
outer_rect.expand(visuals.expansion),
visuals.rounding,
visuals.corner_radius,
visuals.weak_bg_fill,
visuals.bg_stroke,
epaint::StrokeKind::Inside,
),
);
}
@@ -483,33 +449,19 @@ fn button_frame(
response
}
fn paint_default_icon(
painter: &Painter,
rect: Rect,
visuals: &WidgetVisuals,
above_or_below: AboveOrBelow,
) {
fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) {
let rect = Rect::from_center_size(
rect.center(),
vec2(rect.width() * 0.7, rect.height() * 0.45),
);
match above_or_below {
AboveOrBelow::Above => {
// Upward pointing triangle
painter.add(Shape::convex_polygon(
vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}
AboveOrBelow::Below => {
// Downward pointing triangle
painter.add(Shape::convex_polygon(
vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}
}
// Downward pointing triangle
// Previously, we would show an up arrow when we expected the popup to open upwards
// (due to lack of space below the button), but this could look weird in edge cases, so this
// feature was removed. (See https://github.com/emilk/egui/pull/5713#issuecomment-2654420245)
painter.add(Shape::convex_polygon(
vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}

View File

@@ -4,9 +4,45 @@ use crate::{
epaint, layers::ShapeIdx, InnerResponse, Response, Sense, Style, Ui, UiBuilder, UiKind,
UiStackInfo,
};
use epaint::{Color32, Margin, Marginf, Rect, Rounding, Shadow, Shape, Stroke};
use epaint::{Color32, CornerRadius, Margin, MarginF32, Rect, Shadow, Shape, Stroke};
/// Add a background, frame and/or margin to a rectangular background of a [`Ui`].
/// A frame around some content, including margin, colors, etc.
///
/// ## Definitions
/// The total (outer) size of a frame is
/// `content_size + inner_margin + 2 * stroke.width + outer_margin`.
///
/// Everything within the stroke is filled with the fill color (if any).
///
/// ```text
/// +-----------------^-------------------------------------- -+
/// | | outer_margin |
/// | +------------v----^------------------------------+ |
/// | | | stroke width | |
/// | | +------------v---^---------------------+ | |
/// | | | | inner_margin | | |
/// | | | +-----------v----------------+ | | |
/// | | | | ^ | | | |
/// | | | | | | | | |
/// | | | |<------ content_size ------>| | | |
/// | | | | | | | | |
/// | | | | v | | | |
/// | | | +------- content_rect -------+ | | |
/// | | | | | |
/// | | +-------------fill_rect ---------------+ | |
/// | | | |
/// | +----------------- widget_rect ------------------+ |
/// | |
/// +---------------------- outer_rect ------------------------+
/// ```
///
/// The four rectangles, from inside to outside, are:
/// * `content_rect`: the rectangle that is made available to the inner [`Ui`] or widget.
/// * `fill_rect`: the rectangle that is filled with the fill color (inside the stroke, if any).
/// * `widget_rect`: is the interactive part of the widget (what sense clicks etc).
/// * `outer_rect`: what is allocated in the outer [`Ui`], and is what is returned by [`Response::rect`].
///
/// ## Usage
///
/// ```
/// # egui::__run_test_ui(|ui| {
@@ -58,19 +94,50 @@ use epaint::{Color32, Margin, Marginf, Rect, Rounding, Shadow, Shape, Stroke};
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[must_use = "You should call .show()"]
pub struct Frame {
// Fields are ordered inside-out.
// TODO(emilk): add `min_content_size: Vec2`
//
/// Margin within the painted frame.
///
/// Known as `padding` in CSS.
#[doc(alias = "padding")]
pub inner_margin: Margin,
/// Margin outside the painted frame.
pub outer_margin: Margin,
pub rounding: Rounding,
pub shadow: Shadow,
/// The background fill color of the frame, within the [`Self::stroke`].
///
/// Known as `background` in CSS.
#[doc(alias = "background")]
pub fill: Color32,
/// The width and color of the outline around the frame.
///
/// The width of the stroke is part of the total margin/padding of the frame.
#[doc(alias = "border")]
pub stroke: Stroke,
/// The rounding of the _outer_ corner of the [`Self::stroke`]
/// (or, if there is no stroke, the outer corner of [`Self::fill`]).
///
/// In other words, this is the corner radius of the _widget rect_.
pub corner_radius: CornerRadius,
/// Margin outside the painted frame.
///
/// Similar to what is called `margin` in CSS.
/// However, egui does NOT do "Margin Collapse" like in CSS,
/// i.e. when placing two frames next to each other,
/// the distance between their borders is the SUM
/// of their other margins.
/// In CSS the distance would be the MAX of their outer margins.
/// Supporting margin collapse is difficult, and would
/// requires complicating the already complicated egui layout code.
///
/// Consider using [`crate::Spacing::item_spacing`]
/// for adding space between widgets.
pub outer_margin: Margin,
/// Optional drop-shadow behind the frame.
pub shadow: Shadow,
}
#[test]
@@ -85,68 +152,75 @@ fn frame_size() {
);
}
/// ## Constructors
impl Frame {
pub fn none() -> Self {
Self::default()
/// No colors, no margins, no border.
///
/// This is also the default.
pub const NONE: Self = Self {
inner_margin: Margin::ZERO,
stroke: Stroke::NONE,
fill: Color32::TRANSPARENT,
corner_radius: CornerRadius::ZERO,
outer_margin: Margin::ZERO,
shadow: Shadow::NONE,
};
/// No colors, no margins, no border.
///
/// Same as [`Frame::NONE`].
pub const fn new() -> Self {
Self::NONE
}
#[deprecated = "Use `Frame::NONE` or `Frame::new()` instead."]
pub const fn none() -> Self {
Self::NONE
}
/// For when you want to group a few widgets together within a frame.
pub fn group(style: &Style) -> Self {
Self {
inner_margin: Margin::same(6), // same and symmetric looks best in corners when nesting groups
rounding: style.visuals.widgets.noninteractive.rounding,
stroke: style.visuals.widgets.noninteractive.bg_stroke,
..Default::default()
}
Self::new()
.inner_margin(6)
.corner_radius(style.visuals.widgets.noninteractive.corner_radius)
.stroke(style.visuals.widgets.noninteractive.bg_stroke)
}
pub fn side_top_panel(style: &Style) -> Self {
Self {
inner_margin: Margin::symmetric(8, 2),
fill: style.visuals.panel_fill,
..Default::default()
}
Self::new()
.inner_margin(Margin::symmetric(8, 2))
.fill(style.visuals.panel_fill)
}
pub fn central_panel(style: &Style) -> Self {
Self {
inner_margin: Margin::same(8),
fill: style.visuals.panel_fill,
..Default::default()
}
Self::new().inner_margin(8).fill(style.visuals.panel_fill)
}
pub fn window(style: &Style) -> Self {
Self {
inner_margin: style.spacing.window_margin,
rounding: style.visuals.window_rounding,
shadow: style.visuals.window_shadow,
fill: style.visuals.window_fill(),
stroke: style.visuals.window_stroke(),
..Default::default()
}
Self::new()
.inner_margin(style.spacing.window_margin)
.corner_radius(style.visuals.window_corner_radius)
.shadow(style.visuals.window_shadow)
.fill(style.visuals.window_fill())
.stroke(style.visuals.window_stroke())
}
pub fn menu(style: &Style) -> Self {
Self {
inner_margin: style.spacing.menu_margin,
rounding: style.visuals.menu_rounding,
shadow: style.visuals.popup_shadow,
fill: style.visuals.window_fill(),
stroke: style.visuals.window_stroke(),
..Default::default()
}
Self::new()
.inner_margin(style.spacing.menu_margin)
.corner_radius(style.visuals.menu_corner_radius)
.shadow(style.visuals.popup_shadow)
.fill(style.visuals.window_fill())
.stroke(style.visuals.window_stroke())
}
pub fn popup(style: &Style) -> Self {
Self {
inner_margin: style.spacing.menu_margin,
rounding: style.visuals.menu_rounding,
shadow: style.visuals.popup_shadow,
fill: style.visuals.window_fill(),
stroke: style.visuals.window_stroke(),
..Default::default()
}
Self::new()
.inner_margin(style.spacing.menu_margin)
.corner_radius(style.visuals.menu_corner_radius)
.shadow(style.visuals.popup_shadow)
.fill(style.visuals.window_fill())
.stroke(style.visuals.window_stroke())
}
/// A canvas to draw on.
@@ -154,57 +228,90 @@ impl Frame {
/// In bright mode this will be very bright,
/// and in dark mode this will be very dark.
pub fn canvas(style: &Style) -> Self {
Self {
inner_margin: Margin::same(2),
rounding: style.visuals.widgets.noninteractive.rounding,
fill: style.visuals.extreme_bg_color,
stroke: style.visuals.window_stroke(),
..Default::default()
}
Self::new()
.inner_margin(2)
.corner_radius(style.visuals.widgets.noninteractive.corner_radius)
.fill(style.visuals.extreme_bg_color)
.stroke(style.visuals.window_stroke())
}
/// A dark canvas to draw on.
pub fn dark_canvas(style: &Style) -> Self {
Self {
fill: Color32::from_black_alpha(250),
..Self::canvas(style)
}
Self::canvas(style).fill(Color32::from_black_alpha(250))
}
}
/// ## Builders
impl Frame {
#[inline]
pub fn fill(mut self, fill: Color32) -> Self {
self.fill = fill;
self
}
#[inline]
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = stroke.into();
self
}
#[inline]
pub fn rounding(mut self, rounding: impl Into<Rounding>) -> Self {
self.rounding = rounding.into();
self
}
/// Margin within the painted frame.
///
/// Known as `padding` in CSS.
#[doc(alias = "padding")]
#[inline]
pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self {
self.inner_margin = inner_margin.into();
self
}
/// The background fill color of the frame, within the [`Self::stroke`].
///
/// Known as `background` in CSS.
#[doc(alias = "background")]
#[inline]
pub fn fill(mut self, fill: Color32) -> Self {
self.fill = fill;
self
}
/// The width and color of the outline around the frame.
///
/// The width of the stroke is part of the total margin/padding of the frame.
#[inline]
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = stroke.into();
self
}
/// The rounding of the _outer_ corner of the [`Self::stroke`]
/// (or, if there is no stroke, the outer corner of [`Self::fill`]).
///
/// In other words, this is the corner radius of the _widget rect_.
#[inline]
pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
self.corner_radius = corner_radius.into();
self
}
/// The rounding of the _outer_ corner of the [`Self::stroke`]
/// (or, if there is no stroke, the outer corner of [`Self::fill`]).
///
/// In other words, this is the corner radius of the _widget rect_.
#[inline]
#[deprecated = "Renamed to `corner_radius`"]
pub fn rounding(self, corner_radius: impl Into<CornerRadius>) -> Self {
self.corner_radius(corner_radius)
}
/// Margin outside the painted frame.
///
/// Similar to what is called `margin` in CSS.
/// However, egui does NOT do "Margin Collapse" like in CSS,
/// i.e. when placing two frames next to each other,
/// the distance between their borders is the SUM
/// of their other margins.
/// In CSS the distance would be the MAX of their outer margins.
/// Supporting margin collapse is difficult, and would
/// requires complicating the already complicated egui layout code.
///
/// Consider using [`crate::Spacing::item_spacing`]
/// for adding space between widgets.
#[inline]
pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self {
self.outer_margin = outer_margin.into();
self
}
/// Optional drop-shadow behind the frame.
#[inline]
pub fn shadow(mut self, shadow: Shadow) -> Self {
self.shadow = shadow;
@@ -224,11 +331,37 @@ impl Frame {
}
}
/// ## Inspectors
impl Frame {
/// Inner margin plus outer margin.
/// How much extra space the frame uses up compared to the content.
///
/// [`Self::inner_margin`] + [`Self.stroke`]`.width` + [`Self::outer_margin`].
#[inline]
pub fn total_margin(&self) -> Marginf {
Marginf::from(self.inner_margin) + Marginf::from(self.outer_margin)
pub fn total_margin(&self) -> MarginF32 {
MarginF32::from(self.inner_margin)
+ MarginF32::from(self.stroke.width)
+ MarginF32::from(self.outer_margin)
}
/// Calculate the `fill_rect` from the `content_rect`.
///
/// This is the rectangle that is filled with the fill color (inside the stroke, if any).
pub fn fill_rect(&self, content_rect: Rect) -> Rect {
content_rect + self.inner_margin
}
/// Calculate the `widget_rect` from the `content_rect`.
///
/// This is the visible and interactive rectangle.
pub fn widget_rect(&self, content_rect: Rect) -> Rect {
content_rect + self.inner_margin + MarginF32::from(self.stroke.width)
}
/// Calculate the `outer_rect` from the `content_rect`.
///
/// This is what is allocated in the outer [`Ui`], and is what is returned by [`Response::rect`].
pub fn outer_rect(&self, content_rect: Rect) -> Rect {
content_rect + self.inner_margin + MarginF32::from(self.stroke.width) + self.outer_margin
}
}
@@ -259,20 +392,18 @@ impl Frame {
let where_to_put_background = ui.painter().add(Shape::Noop);
let outer_rect_bounds = ui.available_rect_before_wrap();
let mut inner_rect = outer_rect_bounds - self.outer_margin - self.inner_margin;
let mut max_content_rect = outer_rect_bounds - self.total_margin();
// Make sure we don't shrink to the negative:
inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x);
inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y);
max_content_rect.max.x = max_content_rect.max.x.max(max_content_rect.min.x);
max_content_rect.max.y = max_content_rect.max.y.max(max_content_rect.min.y);
let content_ui = ui.new_child(
UiBuilder::new()
.ui_stack_info(UiStackInfo::new(UiKind::Frame).with_frame(self))
.max_rect(inner_rect),
.max_rect(max_content_rect),
);
// content_ui.set_clip_rect(outer_rect_bounds.shrink(self.stroke.width * 0.5)); // Can't do this since we don't know final size yet
Prepared {
frame: self,
where_to_put_background,
@@ -298,32 +429,42 @@ impl Frame {
}
/// Paint this frame as a shape.
///
/// The margin is ignored.
pub fn paint(&self, outer_rect: Rect) -> Shape {
pub fn paint(&self, content_rect: Rect) -> Shape {
let Self {
inner_margin: _,
outer_margin: _,
rounding,
shadow,
fill,
stroke,
corner_radius,
outer_margin: _,
shadow,
} = *self;
let frame_shape = Shape::Rect(epaint::RectShape::new(outer_rect, rounding, fill, stroke));
let widget_rect = self.widget_rect(content_rect);
let frame_shape = Shape::Rect(epaint::RectShape::new(
widget_rect,
corner_radius,
fill,
stroke,
epaint::StrokeKind::Inside,
));
if shadow == Default::default() {
frame_shape
} else {
let shadow = shadow.as_shape(outer_rect, rounding);
let shadow = shadow.as_shape(widget_rect, corner_radius);
Shape::Vec(vec![Shape::from(shadow), frame_shape])
}
}
}
impl Prepared {
fn content_with_margin(&self) -> Rect {
self.content_ui.min_rect() + self.frame.inner_margin + self.frame.outer_margin
fn outer_rect(&self) -> Rect {
let content_rect = self.content_ui.min_rect();
content_rect
+ self.frame.inner_margin
+ MarginF32::from(self.frame.stroke.width)
+ self.frame.outer_margin
}
/// Allocate the space that was used by [`Self::content_ui`].
@@ -332,22 +473,25 @@ impl Prepared {
///
/// This can be called before or after [`Self::paint`].
pub fn allocate_space(&self, ui: &mut Ui) -> Response {
ui.allocate_rect(self.content_with_margin(), Sense::hover())
ui.allocate_rect(self.outer_rect(), Sense::hover())
}
/// Paint the frame.
///
/// This can be called before or after [`Self::allocate_space`].
pub fn paint(&self, ui: &Ui) {
let paint_rect = self.content_ui.min_rect() + self.frame.inner_margin;
let content_rect = self.content_ui.min_rect();
let widget_rect = self.frame.widget_rect(content_rect);
if ui.is_rect_visible(paint_rect) {
let shape = self.frame.paint(paint_rect);
if ui.is_rect_visible(widget_rect) {
let shape = self.frame.paint(content_rect);
ui.painter().set(self.where_to_put_background, shape);
}
}
/// Convenience for calling [`Self::allocate_space`] and [`Self::paint`].
///
/// Returns the outer rect, i.e. including the outer margin.
pub fn end(self, ui: &mut Ui) -> Response {
self.paint(ui);
self.allocate_space(ui)

View File

@@ -0,0 +1,527 @@
use crate::style::StyleModifier;
use crate::{
Button, Color32, Context, Frame, Id, InnerResponse, Layout, Popup, PopupCloseBehavior,
Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget, WidgetText,
};
use emath::{vec2, Align, RectAlign, Vec2};
use epaint::Stroke;
/// Apply a menu style to the [`Style`].
///
/// Mainly removes the background stroke and the inactive background fill.
pub fn menu_style(style: &mut Style) {
style.spacing.button_padding = vec2(2.0, 0.0);
style.visuals.widgets.active.bg_stroke = Stroke::NONE;
style.visuals.widgets.open.bg_stroke = Stroke::NONE;
style.visuals.widgets.hovered.bg_stroke = Stroke::NONE;
style.visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
style.visuals.widgets.inactive.bg_stroke = Stroke::NONE;
}
/// Find the root [`UiStack`] of the menu.
pub fn find_menu_root(ui: &Ui) -> &UiStack {
ui.stack()
.iter()
.find(|stack| {
stack.is_root_ui()
|| [Some(UiKind::Popup), Some(UiKind::Menu)].contains(&stack.kind())
|| stack.info.tags.contains(MenuConfig::MENU_CONFIG_TAG)
})
.expect("We should always find the root")
}
/// Is this Ui part of a menu?
///
/// Returns `false` if this is a menu bar.
/// Should be used to determine if we should show a menu button or submenu button.
pub fn is_in_menu(ui: &Ui) -> bool {
for stack in ui.stack().iter() {
if let Some(config) = stack
.info
.tags
.get_downcast::<MenuConfig>(MenuConfig::MENU_CONFIG_TAG)
{
return !config.bar;
}
if [Some(UiKind::Popup), Some(UiKind::Menu)].contains(&stack.kind()) {
return true;
}
}
false
}
#[derive(Clone, Debug)]
pub struct MenuConfig {
/// Is this a menu bar?
bar: bool,
/// If the user clicks, should we close the menu?
pub close_behavior: PopupCloseBehavior,
/// Override the menu style.
///
/// Default is [`menu_style`].
pub style: StyleModifier,
}
impl Default for MenuConfig {
fn default() -> Self {
Self {
close_behavior: PopupCloseBehavior::default(),
bar: false,
style: menu_style.into(),
}
}
}
impl MenuConfig {
/// The tag used to store the menu config in the [`UiStack`].
pub const MENU_CONFIG_TAG: &'static str = "egui_menu_config";
pub fn new() -> Self {
Self::default()
}
/// If the user clicks, should we close the menu?
#[inline]
pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
self.close_behavior = close_behavior;
self
}
/// Override the menu style.
///
/// Default is [`menu_style`].
#[inline]
pub fn style(mut self, style: impl Into<StyleModifier>) -> Self {
self.style = style.into();
self
}
fn from_stack(stack: &UiStack) -> Self {
stack
.info
.tags
.get_downcast(Self::MENU_CONFIG_TAG)
.cloned()
.unwrap_or_default()
}
/// Find the config for the current menu.
///
/// Returns the default config if no config is found.
pub fn find(ui: &Ui) -> Self {
find_menu_root(ui)
.info
.tags
.get_downcast(Self::MENU_CONFIG_TAG)
.cloned()
.unwrap_or_default()
}
}
#[derive(Clone)]
pub struct MenuState {
pub open_item: Option<Id>,
last_visible_pass: u64,
}
impl MenuState {
pub const ID: &'static str = "menu_state";
/// Find the root of the menu and get the state
pub fn from_ui<R>(ui: &Ui, f: impl FnOnce(&mut Self, &UiStack) -> R) -> R {
let stack = find_menu_root(ui);
Self::from_id(ui.ctx(), stack.id, |state| f(state, stack))
}
/// Get the state via the menus root [`Ui`] id
pub fn from_id<R>(ctx: &Context, id: Id, f: impl FnOnce(&mut Self) -> R) -> R {
let pass_nr = ctx.cumulative_pass_nr();
ctx.data_mut(|data| {
let state = data.get_temp_mut_or_insert_with(id.with(Self::ID), || Self {
open_item: None,
last_visible_pass: pass_nr,
});
// If the menu was closed for at least a frame, reset the open item
if state.last_visible_pass + 1 < pass_nr {
state.open_item = None;
}
state.last_visible_pass = pass_nr;
f(state)
})
}
/// Is the menu with this id the deepest sub menu? (-> no child sub menu is open)
pub fn is_deepest_sub_menu(ctx: &Context, id: Id) -> bool {
Self::from_id(ctx, id, |state| state.open_item.is_none())
}
}
/// Horizontal menu bar where you can add [`MenuButton`]s.
/// The menu bar goes well in a [`crate::TopBottomPanel::top`],
/// but can also be placed in a [`crate::Window`].
/// In the latter case you may want to wrap it in [`Frame`].
#[derive(Clone, Debug)]
pub struct Bar {
config: MenuConfig,
style: StyleModifier,
}
impl Default for Bar {
fn default() -> Self {
Self {
config: MenuConfig::default(),
style: menu_style.into(),
}
}
}
impl Bar {
pub fn new() -> Self {
Self::default()
}
/// Set the style for buttons in the menu bar.
///
/// Doesn't affect the style of submenus, use [`MenuConfig::style`] for that.
/// Default is [`menu_style`].
#[inline]
pub fn style(mut self, style: impl Into<StyleModifier>) -> Self {
self.style = style.into();
self
}
/// Set the config for submenus.
///
/// Note: The config will only be passed when using [`MenuButton`], not via [`Popup::menu`].
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.config = config;
self
}
/// Show the menu bar.
#[inline]
pub fn ui<R>(self, ui: &mut Ui, content: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
let Self { mut config, style } = self;
config.bar = true;
// TODO(lucasmerlin): It'd be nice if we had a ui.horizontal_builder or something
// So we don't need the nested scope here
ui.horizontal(|ui| {
ui.scope_builder(
UiBuilder::new()
.layout(Layout::left_to_right(Align::Center))
.ui_stack_info(
UiStackInfo::new(UiKind::Menu)
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
),
|ui| {
style.apply(ui.style_mut());
// Take full width and fixed height:
let height = ui.spacing().interact_size.y;
ui.set_min_size(vec2(ui.available_width(), height));
content(ui)
},
)
.inner
})
}
}
/// A thin wrapper around a [`Button`] that shows a [`Popup::menu`] when clicked.
///
/// The only thing this does is search for the current menu config (if set via [`Bar`]).
/// If your menu button is not in a [`Bar`] it's fine to use [`Ui::button`] and [`Popup::menu`]
/// directly.
pub struct MenuButton<'a> {
pub button: Button<'a>,
pub config: Option<MenuConfig>,
}
impl<'a> MenuButton<'a> {
pub fn new(text: impl Into<WidgetText>) -> Self {
Self::from_button(Button::new(text))
}
/// Set the config for the menu.
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.config = Some(config);
self
}
/// Create a new menu button from a [`Button`].
#[inline]
pub fn from_button(button: Button<'a>) -> Self {
Self {
button,
config: None,
}
}
/// Show the menu button.
pub fn ui<R>(
self,
ui: &mut Ui,
content: impl FnOnce(&mut Ui) -> R,
) -> (Response, Option<InnerResponse<R>>) {
let response = self.button.ui(ui);
let mut config = self.config.unwrap_or_else(|| MenuConfig::find(ui));
config.bar = false;
let inner = Popup::menu(&response)
.close_behavior(config.close_behavior)
.style(config.style.clone())
.info(
UiStackInfo::new(UiKind::Menu).with_tag_value(MenuConfig::MENU_CONFIG_TAG, config),
)
.show(content);
(response, inner)
}
}
/// A submenu button that shows a [`SubMenu`] if a [`Button`] is hovered.
pub struct SubMenuButton<'a> {
pub button: Button<'a>,
pub sub_menu: SubMenu,
}
impl<'a> SubMenuButton<'a> {
/// The default right arrow symbol: `"⏵"`
pub const RIGHT_ARROW: &'static str = "";
pub fn new(text: impl Into<WidgetText>) -> Self {
Self::from_button(Button::new(text).right_text(""))
}
/// Create a new submenu button from a [`Button`].
///
/// Use [`Button::right_text`] and [`SubMenuButton::RIGHT_ARROW`] to add the default right
/// arrow symbol.
pub fn from_button(button: Button<'a>) -> Self {
Self {
button,
sub_menu: SubMenu::default(),
}
}
/// Set the config for the submenu.
///
/// The close behavior will not affect the current button, but the buttons in the submenu.
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.sub_menu.config = Some(config);
self
}
/// Show the submenu button.
pub fn ui<R>(
self,
ui: &mut Ui,
content: impl FnOnce(&mut Ui) -> R,
) -> (Response, Option<InnerResponse<R>>) {
let my_id = ui.next_auto_id();
let open = MenuState::from_ui(ui, |state, _| {
state.open_item == Some(SubMenu::id_from_widget_id(my_id))
});
let inactive = ui.style().visuals.widgets.inactive;
// TODO(lucasmerlin) add `open` function to `Button`
if open {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
}
let response = self.button.ui(ui);
ui.style_mut().visuals.widgets.inactive = inactive;
let popup_response = self.sub_menu.show(ui, &response, content);
(response, popup_response)
}
}
#[derive(Clone, Debug, Default)]
pub struct SubMenu {
config: Option<MenuConfig>,
}
impl SubMenu {
pub fn new() -> Self {
Self::default()
}
/// Set the config for the submenu.
///
/// The close behavior will not affect the current button, but the buttons in the submenu.
#[inline]
pub fn config(mut self, config: MenuConfig) -> Self {
self.config = Some(config);
self
}
/// Get the id for the submenu from the widget/response id.
pub fn id_from_widget_id(widget_id: Id) -> Id {
widget_id.with("submenu")
}
/// Show the submenu.
pub fn show<R>(
self,
ui: &Ui,
button_response: &Response,
content: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let frame = Frame::menu(ui.style());
let id = Self::id_from_widget_id(button_response.id);
let (open_item, menu_id, parent_config) = MenuState::from_ui(ui, |state, stack| {
(state.open_item, stack.id, MenuConfig::from_stack(stack))
});
let mut menu_config = self.config.unwrap_or_else(|| parent_config.clone());
menu_config.bar = false;
let menu_root_response = ui
.ctx()
.read_response(menu_id)
// Since we are a child of that ui, this should always exist
.unwrap();
let hover_pos = ui.ctx().pointer_hover_pos();
// We don't care if the user is hovering over the border
let menu_rect = menu_root_response.rect - frame.total_margin();
let is_hovering_menu = hover_pos.is_some_and(|pos| {
ui.ctx().layer_id_at(pos) == Some(menu_root_response.layer_id)
&& menu_rect.contains(pos)
});
let is_any_open = open_item.is_some();
let mut is_open = open_item == Some(id);
let mut set_open = None;
// We expand the button rect so there is no empty space where no menu is shown
// TODO(lucasmerlin): Instead, maybe make item_spacing.y 0.0?
let button_rect = button_response
.rect
.expand2(ui.style().spacing.item_spacing / 2.0);
// In theory some other widget could cover the button and this check would still pass
// But since we check if no other menu is open, nothing should be able to cover the button
let is_hovered = hover_pos.is_some_and(|pos| button_rect.contains(pos));
// The clicked handler is there for accessibility (keyboard navigation)
if (!is_any_open && is_hovered) || button_response.clicked() {
set_open = Some(true);
is_open = true;
// Ensure that all other sub menus are closed when we open the menu
MenuState::from_id(ui.ctx(), id, |state| {
state.open_item = None;
});
}
let gap = frame.total_margin().sum().x / 2.0 + 2.0;
let mut response = button_response.clone();
// Expand the button rect so that the button and the first item in the submenu are aligned
let expand = Vec2::new(0.0, frame.total_margin().sum().y / 2.0);
response.interact_rect = response.interact_rect.expand2(expand);
let popup_response = Popup::from_response(&response)
.id(id)
.open(is_open)
.align(RectAlign::RIGHT_START)
.layout(Layout::top_down_justified(Align::Min))
.gap(gap)
.style(menu_config.style.clone())
.frame(frame)
// The close behavior is handled by the menu (see below)
.close_behavior(PopupCloseBehavior::IgnoreClicks)
.info(
UiStackInfo::new(UiKind::Menu)
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, menu_config.clone()),
)
.show(|ui| {
// Ensure our layer stays on top when the button is clicked
if button_response.clicked() || button_response.is_pointer_button_down_on() {
ui.ctx().move_to_top(ui.layer_id());
}
content(ui)
});
if let Some(popup_response) = &popup_response {
// If no child sub menu is open means we must be the deepest child sub menu.
let is_deepest_submenu = MenuState::is_deepest_sub_menu(ui.ctx(), id);
// If the user clicks and the cursor is not hovering over our menu rect, it's
// safe to assume they clicked outside the menu, so we close everything.
// If they were to hover some other parent submenu we wouldn't be open.
// Only edge case is the user hovering this submenu's button, so we also check
// if we clicked outside the parent menu (which we luckily have access to here).
let clicked_outside = is_deepest_submenu
&& popup_response.response.clicked_elsewhere()
&& menu_root_response.clicked_elsewhere();
// We never automatically close when a submenu button is clicked, (so menus work
// on touch devices)
// Luckily we will always be the deepest submenu when a submenu button is clicked,
// so the following check is enough.
let submenu_button_clicked = button_response.clicked();
let clicked_inside = is_deepest_submenu
&& !submenu_button_clicked
&& response.ctx.input(|i| i.pointer.any_click())
&& hover_pos.is_some_and(|pos| popup_response.response.interact_rect.contains(pos));
let click_close = match menu_config.close_behavior {
PopupCloseBehavior::CloseOnClick => clicked_outside || clicked_inside,
PopupCloseBehavior::CloseOnClickOutside => clicked_outside,
PopupCloseBehavior::IgnoreClicks => false,
};
if click_close {
set_open = Some(false);
ui.close();
}
let is_moving_towards_rect = ui.input(|i| {
i.pointer
.is_moving_towards_rect(&popup_response.response.rect)
});
if is_moving_towards_rect {
// We need to repaint while this is true, so we can detect when
// the pointer is no longer moving towards the rect
ui.ctx().request_repaint();
}
let hovering_other_menu_entry = is_open
&& !is_hovered
&& !popup_response.response.contains_pointer()
&& !is_moving_towards_rect
&& is_hovering_menu;
let close_called = popup_response.response.should_close();
// Close the parent ui to e.g. close the popup from where the submenu was opened
if close_called {
ui.close();
}
if hovering_other_menu_entry {
set_open = Some(false);
}
if ui.will_parent_close() {
ui.data_mut(|data| data.remove_by_type::<MenuState>());
}
}
if let Some(set_open) = set_open {
MenuState::from_id(ui.ctx(), menu_id, |state| {
state.open_item = set_open.then_some(id);
});
}
popup_response
}
}

View File

@@ -3,15 +3,20 @@
//! For instance, a [`Frame`] adds a frame and background to some contained UI.
pub(crate) mod area;
pub mod close_tag;
pub mod collapsing_header;
mod combo_box;
pub mod frame;
pub mod menu;
pub mod modal;
pub mod old_popup;
pub mod panel;
pub mod popup;
mod popup;
pub(crate) mod resize;
mod scene;
pub mod scroll_area;
mod sides;
mod tooltip;
pub(crate) mod window;
pub use {
@@ -20,10 +25,13 @@ pub use {
combo_box::*,
frame::Frame,
modal::{Modal, ModalResponse},
old_popup::*,
panel::{CentralPanel, SidePanel, TopBottomPanel},
popup::*,
resize::Resize,
scene::Scene,
scroll_area::ScrollArea,
sides::Sides,
tooltip::*,
window::Window,
};

View File

@@ -4,11 +4,14 @@ use crate::{
use emath::{Align2, Vec2};
/// A modal dialog.
///
/// Similar to a [`crate::Window`] but centered and with a backdrop that
/// blocks input to the rest of the UI.
///
/// You can show multiple modals on top of each other. The topmost modal will always be
/// the most recently shown one.
/// If multiple modals are newly shown in the same frame, the order of the modals not undefined
/// (either first or second could be top).
pub struct Modal {
pub area: Area,
pub backdrop_color: Color32,
@@ -16,7 +19,9 @@ pub struct Modal {
}
impl Modal {
/// Create a new Modal. The id is passed to the area.
/// Create a new Modal.
///
/// The id is passed to the area.
pub fn new(id: Id) -> Self {
Self {
area: Self::default_area(id),
@@ -26,6 +31,7 @@ impl Modal {
}
/// Returns an area customized for a modal.
///
/// Makes these changes to the default area:
/// - sense: hover
/// - anchor: center
@@ -86,11 +92,7 @@ impl Modal {
response,
} = area.show(ctx, |ui| {
let bg_rect = ui.ctx().screen_rect();
let bg_sense = Sense {
click: true,
drag: true,
focusable: false,
};
let bg_sense = Sense::CLICK | Sense::DRAG;
let mut backdrop = ui.new_child(UiBuilder::new().sense(bg_sense).max_rect(bg_rect));
backdrop.set_min_size(bg_rect.size());
ui.painter().rect_filled(bg_rect, 0.0, backdrop_color);
@@ -101,14 +103,9 @@ impl Modal {
// We need the extra scope with the sense since frame can't have a sense and since we
// need to prevent the clicks from passing through to the backdrop.
let inner = ui
.scope_builder(
UiBuilder::new().sense(Sense {
click: true,
drag: true,
focusable: false,
}),
|ui| frame.show(ui, content).inner,
)
.scope_builder(UiBuilder::new().sense(Sense::CLICK | Sense::DRAG), |ui| {
frame.show(ui, content).inner
})
.inner;
(inner, backdrop_response)
@@ -159,7 +156,10 @@ impl<T> ModalResponse<T> {
let escape_clicked =
|| ctx.input_mut(|i| i.consume_key(crate::Modifiers::NONE, crate::Key::Escape));
let ui_close_called = self.response.should_close();
self.backdrop_response.clicked()
|| ui_close_called
|| (self.is_top_modal && !self.any_popup_open && escape_clicked())
}
}

View File

@@ -0,0 +1,212 @@
//! Old and deprecated API for popups. Use [`Popup`] instead.
#![allow(deprecated)]
use crate::containers::tooltip::Tooltip;
use crate::{
Align, Context, Id, LayerId, Layout, Popup, PopupAnchor, PopupCloseBehavior, Pos2, Rect,
Response, Ui, Widget, WidgetText,
};
use emath::RectAlign;
// ----------------------------------------------------------------------------
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// # #[allow(deprecated)]
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
show_tooltip_at_pointer(ctx, parent_layer, widget_id, add_contents)
}
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_at_pointer(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_at_pointer<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
Tooltip::new(widget_id, ctx.clone(), PopupAnchor::Pointer, parent_layer)
.gap(12.0)
.show(add_contents)
.map(|response| response.inner)
}
/// Show a tooltip under the given area.
///
/// If the tooltip does not fit under the area, it tries to place it above it instead.
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_for<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
widget_rect: &Rect,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
Tooltip::new(widget_id, ctx.clone(), *widget_rect, parent_layer)
.show(add_contents)
.map(|response| response.inner)
}
/// Show a tooltip at the given position.
///
/// Returns `None` if the tooltip could not be placed.
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_at<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
suggested_position: Pos2,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
Tooltip::new(widget_id, ctx.clone(), suggested_position, parent_layer)
.show(add_contents)
.map(|response| response.inner)
}
/// Show some text at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_text`].
///
/// See also [`show_tooltip`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_text(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), "Helpful text");
/// }
/// # });
/// ```
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_text(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
text: impl Into<WidgetText>,
) -> Option<()> {
show_tooltip(ctx, parent_layer, widget_id, |ui| {
crate::widgets::Label::new(text).ui(ui);
})
}
/// Was this tooltip visible last frame?
#[deprecated = "Use `Tooltip::was_tooltip_open_last_frame` instead"]
pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool {
Tooltip::was_tooltip_open_last_frame(ctx, widget_id)
}
/// Indicate whether a popup will be shown above or below the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AboveOrBelow {
Above,
Below,
}
/// Helper for [`popup_above_or_below_widget`].
#[deprecated = "Use `egui::Popup` instead"]
pub fn popup_below_widget<R>(
ui: &Ui,
popup_id: Id,
widget_response: &Response,
close_behavior: PopupCloseBehavior,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
popup_above_or_below_widget(
ui,
popup_id,
widget_response,
AboveOrBelow::Below,
close_behavior,
add_contents,
)
}
/// Shows a popup above or below another widget.
///
/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields.
///
/// The opened popup will have a minimum width matching its parent.
///
/// You must open the popup with [`crate::Memory::open_popup`] or [`crate::Memory::toggle_popup`].
///
/// Returns `None` if the popup is not open.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let response = ui.button("Open popup");
/// let popup_id = ui.make_persistent_id("my_unique_id");
/// if response.clicked() {
/// ui.memory_mut(|mem| mem.toggle_popup(popup_id));
/// }
/// let below = egui::AboveOrBelow::Below;
/// let close_on_click_outside = egui::PopupCloseBehavior::CloseOnClickOutside;
/// # #[allow(deprecated)]
/// egui::popup_above_or_below_widget(ui, popup_id, &response, below, close_on_click_outside, |ui| {
/// ui.set_min_width(200.0); // if you want to control the size
/// ui.label("Some more info, or things you can select:");
/// ui.label("…");
/// });
/// # });
/// ```
#[deprecated = "Use `egui::Popup` instead"]
pub fn popup_above_or_below_widget<R>(
_parent_ui: &Ui,
popup_id: Id,
widget_response: &Response,
above_or_below: AboveOrBelow,
close_behavior: PopupCloseBehavior,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let response = Popup::from_response(widget_response)
.layout(Layout::top_down_justified(Align::LEFT))
.open_memory(None)
.close_behavior(close_behavior)
.id(popup_id)
.align(match above_or_below {
AboveOrBelow::Above => RectAlign::TOP_START,
AboveOrBelow::Below => RectAlign::BOTTOM_START,
})
.width(widget_response.rect.width())
.show(|ui| {
ui.set_min_width(ui.available_width());
add_contents(ui)
})?;
Some(response.inner)
}

File diff suppressed because it is too large Load Diff

View File

@@ -356,6 +356,7 @@ impl Resize {
rect,
3.0,
ui.visuals().widgets.noninteractive.bg_stroke,
epaint::StrokeKind::Inside,
));
}

View File

@@ -0,0 +1,221 @@
use core::f32;
use emath::{GuiRounding, Pos2};
use crate::{
emath::TSTransform, InnerResponse, LayerId, Rangef, Rect, Response, Sense, Ui, UiBuilder, Vec2,
};
/// Creates a transformation that fits a given scene rectangle into the available screen size.
///
/// The resulting visual scene bounds can be larger, due to letterboxing.
///
/// Returns the transformation from `scene` to `global` coordinates.
fn fit_to_rect_in_scene(
rect_in_global: Rect,
rect_in_scene: Rect,
zoom_range: Rangef,
) -> TSTransform {
// Compute the scale factor to fit the bounding rectangle into the available screen size:
let scale = rect_in_global.size() / rect_in_scene.size();
// Use the smaller of the two scales to ensure the whole rectangle fits on the screen:
let scale = scale.min_elem();
// Clamp scale to what is allowed
let scale = zoom_range.clamp(scale);
// Compute the translation to center the bounding rect in the screen:
let center_in_global = rect_in_global.center().to_vec2();
let center_scene = rect_in_scene.center().to_vec2();
// Set the transformation to scale and then translate to center.
TSTransform::from_translation(center_in_global - scale * center_scene)
* TSTransform::from_scaling(scale)
}
/// A container that allows you to zoom and pan.
///
/// This is similar to [`crate::ScrollArea`] but:
/// * Supports zooming
/// * Has no scroll bars
/// * Has no limits on the scrolling
#[derive(Clone, Debug)]
#[must_use = "You should call .show()"]
pub struct Scene {
zoom_range: Rangef,
max_inner_size: Vec2,
}
impl Default for Scene {
fn default() -> Self {
Self {
zoom_range: Rangef::new(f32::EPSILON, 1.0),
max_inner_size: Vec2::splat(1000.0),
}
}
}
impl Scene {
#[inline]
pub fn new() -> Self {
Default::default()
}
/// Set the allowed zoom range.
///
/// The default zoom range is `0.0..=1.0`,
/// which mean you zan make things arbitrarily small, but you cannot zoom in past a `1:1` ratio.
///
/// If you want to allow zooming in, you can set the zoom range to `0.0..=f32::INFINITY`.
/// Note that text rendering becomes blurry when you zoom in: <https://github.com/emilk/egui/issues/4813>.
#[inline]
pub fn zoom_range(mut self, zoom_range: impl Into<Rangef>) -> Self {
self.zoom_range = zoom_range.into();
self
}
/// Set the maximum size of the inner [`Ui`] that will be created.
#[inline]
pub fn max_inner_size(mut self, max_inner_size: impl Into<Vec2>) -> Self {
self.max_inner_size = max_inner_size.into();
self
}
/// `scene_rect` contains the view bounds of the inner [`Ui`].
///
/// `scene_rect` will be mutated by any panning/zooming done by the user.
/// If `scene_rect` is somehow invalid (e.g. `Rect::ZERO`),
/// then it will be reset to the inner rect of the inner ui.
///
/// You need to store the `scene_rect` in your state between frames.
pub fn show<R>(
&self,
parent_ui: &mut Ui,
scene_rect: &mut Rect,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
let (outer_rect, _outer_response) =
parent_ui.allocate_exact_size(parent_ui.available_size_before_wrap(), Sense::hover());
let mut to_global = fit_to_rect_in_scene(outer_rect, *scene_rect, self.zoom_range);
let scene_rect_was_good =
to_global.is_valid() && scene_rect.is_finite() && scene_rect.size() != Vec2::ZERO;
let mut inner_rect = *scene_rect;
let ret = self.show_global_transform(parent_ui, outer_rect, &mut to_global, |ui| {
let r = add_contents(ui);
inner_rect = ui.min_rect();
r
});
if ret.response.changed() {
// Only update if changed, both to avoid numeric drift,
// and to avoid expanding the scene rect unnecessarily.
*scene_rect = to_global.inverse() * outer_rect;
}
if !scene_rect_was_good {
// Auto-reset if the transformation goes bad somehow (or started bad).
// Recalculates transform based on inner_rect, resulting in a rect that's the full size of outer_rect but centered on inner_rect.
let to_global = fit_to_rect_in_scene(outer_rect, inner_rect, self.zoom_range);
*scene_rect = to_global.inverse() * outer_rect;
}
ret
}
fn show_global_transform<R>(
&self,
parent_ui: &mut Ui,
outer_rect: Rect,
to_global: &mut TSTransform,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
// Create a new egui paint layer, where we can draw our contents:
let scene_layer_id = LayerId::new(
parent_ui.layer_id().order,
parent_ui.id().with("scene_area"),
);
// Put the layer directly on-top of the main layer of the ui:
parent_ui
.ctx()
.set_sublayer(parent_ui.layer_id(), scene_layer_id);
let mut local_ui = parent_ui.new_child(
UiBuilder::new()
.layer_id(scene_layer_id)
.max_rect(Rect::from_min_size(Pos2::ZERO, self.max_inner_size))
.sense(Sense::click_and_drag()),
);
let mut pan_response = local_ui.response();
// Update the `to_global` transform based on use interaction:
self.register_pan_and_zoom(&local_ui, &mut pan_response, to_global);
// Set a correct global clip rect:
local_ui.set_clip_rect(to_global.inverse() * outer_rect);
// Add the actual contents to the area:
let ret = add_contents(&mut local_ui);
// This ensures we catch clicks/drags/pans anywhere on the background.
local_ui.force_set_min_rect((to_global.inverse() * outer_rect).round_ui());
// Tell egui to apply the transform on the layer:
local_ui
.ctx()
.set_transform_layer(scene_layer_id, *to_global);
InnerResponse {
response: pan_response,
inner: ret,
}
}
/// Helper function to handle pan and zoom interactions on a response.
pub fn register_pan_and_zoom(&self, ui: &Ui, resp: &mut Response, to_global: &mut TSTransform) {
if resp.dragged() {
to_global.translation += to_global.scaling * resp.drag_delta();
resp.mark_changed();
}
if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) {
if resp.contains_pointer() {
let pointer_in_scene = to_global.inverse() * mouse_pos;
let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);
// Most of the time we can return early. This is also important to
// avoid `ui_from_scene` to change slightly due to floating point errors.
if zoom_delta == 1.0 && pan_delta == Vec2::ZERO {
return;
}
if zoom_delta != 1.0 {
// Zoom in on pointer, but only if we are not zoomed in or out too far.
let zoom_delta = zoom_delta.clamp(
self.zoom_range.min / to_global.scaling,
self.zoom_range.max / to_global.scaling,
);
*to_global = *to_global
* TSTransform::from_translation(pointer_in_scene.to_vec2())
* TSTransform::from_scaling(zoom_delta)
* TSTransform::from_translation(-pointer_in_scene.to_vec2());
// Clamp to exact zoom range.
to_global.scaling = self.zoom_range.clamp(to_global.scaling);
}
// Pan:
*to_global = TSTransform::from_translation(pan_delta) * *to_global;
resp.mark_changed();
}
}
}
}

View File

@@ -1,8 +1,8 @@
#![allow(clippy::needless_range_loop)]
use crate::{
emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, vec2, Context, Id, NumExt, Pos2,
Rangef, Rect, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b,
emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, Context, Id, NumExt, Pos2, Rangef,
Rect, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b,
};
#[derive(Clone, Copy, Debug)]
@@ -161,6 +161,9 @@ impl ScrollBarVisibility {
/// ```
///
/// You can scroll to an element using [`crate::Response::scroll_to_me`], [`Ui::scroll_to_cursor`] and [`Ui::scroll_to_rect`].
///
/// ## See also
/// If you want to allow zooming, use [`crate::Scene`].
#[derive(Clone, Debug)]
#[must_use = "You should call .show()"]
pub struct ScrollArea {
@@ -773,7 +776,7 @@ impl ScrollArea {
let rect = Rect::from_x_y_ranges(ui.max_rect().x_range(), y_min..=y_max);
ui.allocate_new_ui(UiBuilder::new().max_rect(rect), |viewport_ui| {
ui.scope_builder(UiBuilder::new().max_rect(rect), |viewport_ui| {
viewport_ui.skip_ahead_auto_ids(min_row); // Make sure we get consistent IDs.
add_contents(viewport_ui, min_row..max_row)
})
@@ -1087,21 +1090,35 @@ impl Prepared {
)
};
let handle_rect = if d == 0 {
Rect::from_min_max(
pos2(from_content(state.offset.x), cross.min),
pos2(from_content(state.offset.x + inner_rect.width()), cross.max),
)
} else {
Rect::from_min_max(
pos2(cross.min, from_content(state.offset.y)),
pos2(
cross.max,
from_content(state.offset.y + inner_rect.height()),
),
)
let calculate_handle_rect = |d, offset: &Vec2| {
let handle_size = if d == 0 {
from_content(offset.x + inner_rect.width()) - from_content(offset.x)
} else {
from_content(offset.y + inner_rect.height()) - from_content(offset.y)
}
.max(scroll_style.handle_min_length);
let handle_start_point = remap_clamp(
offset[d],
0.0..=max_offset[d],
scroll_bar_rect.min[d]..=(scroll_bar_rect.max[d] - handle_size),
);
if d == 0 {
Rect::from_min_max(
pos2(handle_start_point, cross.min),
pos2(handle_start_point + handle_size, cross.max),
)
} else {
Rect::from_min_max(
pos2(cross.min, handle_start_point),
pos2(cross.max, handle_start_point + handle_size),
)
}
};
let handle_rect = calculate_handle_rect(d, &state.offset);
let interact_id = id.with(d);
let sense = if self.scrolling_enabled {
Sense::click_and_drag()
@@ -1130,8 +1147,8 @@ impl Prepared {
let new_handle_top = pointer_pos[d] - *scroll_start_offset_from_top_left;
state.offset[d] = remap(
new_handle_top,
scroll_bar_rect.min[d]..=scroll_bar_rect.max[d],
0.0..=content_size[d],
scroll_bar_rect.min[d]..=(scroll_bar_rect.max[d] - handle_rect.size()[d]),
0.0..=max_offset[d],
);
// some manual action taken, scroll not stuck
@@ -1151,31 +1168,7 @@ impl Prepared {
if ui.is_rect_visible(outer_scroll_bar_rect) {
// Avoid frame-delay by calculating a new handle rect:
let mut handle_rect = if d == 0 {
Rect::from_min_max(
pos2(from_content(state.offset.x), cross.min),
pos2(from_content(state.offset.x + inner_rect.width()), cross.max),
)
} else {
Rect::from_min_max(
pos2(cross.min, from_content(state.offset.y)),
pos2(
cross.max,
from_content(state.offset.y + inner_rect.height()),
),
)
};
let min_handle_size = scroll_style.handle_min_length;
if handle_rect.size()[d] < min_handle_size {
handle_rect = Rect::from_center_size(
handle_rect.center(),
if d == 0 {
vec2(min_handle_size, handle_rect.size().y)
} else {
vec2(handle_rect.size().x, min_handle_size)
},
);
}
let handle_rect = calculate_handle_rect(d, &state.offset);
let visuals = if scrolling_enabled {
// Pick visuals based on interaction with the handle.
@@ -1184,7 +1177,7 @@ impl Prepared {
&& ui.input(|i| {
i.pointer
.latest_pos()
.map_or(false, |p| handle_rect.contains(p))
.is_some_and(|p| handle_rect.contains(p))
});
let visuals = ui.visuals();
if response.is_pointer_button_down_on() {
@@ -1237,7 +1230,7 @@ impl Prepared {
// Background:
ui.painter().add(epaint::Shape::rect_filled(
outer_scroll_bar_rect,
visuals.rounding,
visuals.corner_radius,
ui.visuals()
.extreme_bg_color
.gamma_multiply(background_opacity),
@@ -1246,7 +1239,7 @@ impl Prepared {
// Handle:
ui.painter().add(epaint::Shape::rect_filled(
handle_rect,
visuals.rounding,
visuals.corner_radius,
handle_color.gamma_multiply(handle_opacity),
));
}

View File

@@ -0,0 +1,376 @@
use crate::pass_state::PerWidgetTooltipState;
use crate::{
AreaState, Context, Id, InnerResponse, LayerId, Layout, Order, Popup, PopupAnchor, PopupKind,
Response, Sense,
};
use emath::Vec2;
pub struct Tooltip<'a> {
pub popup: Popup<'a>,
layer_id: LayerId,
widget_id: Id,
}
impl Tooltip<'_> {
/// Show a tooltip that is always open
pub fn new(
widget_id: Id,
ctx: Context,
anchor: impl Into<PopupAnchor>,
layer_id: LayerId,
) -> Self {
Self {
// TODO(lucasmerlin): Set width somehow (we're missing context here)
popup: Popup::new(widget_id, ctx, anchor.into(), layer_id)
.kind(PopupKind::Tooltip)
.gap(4.0)
.sense(Sense::hover()),
layer_id,
widget_id,
}
}
/// Show a tooltip for a widget. Always open (as long as this function is called).
pub fn for_widget(response: &Response) -> Self {
let popup = Popup::from_response(response)
.kind(PopupKind::Tooltip)
.gap(4.0)
.width(response.ctx.style().spacing.tooltip_width)
.sense(Sense::hover());
Self {
popup,
layer_id: response.layer_id,
widget_id: response.id,
}
}
/// Show a tooltip when hovering an enabled widget.
pub fn for_enabled(response: &Response) -> Self {
let mut tooltip = Self::for_widget(response);
tooltip.popup = tooltip
.popup
.open(response.enabled() && Self::should_show_tooltip(response));
tooltip
}
/// Show a tooltip when hovering a disabled widget.
pub fn for_disabled(response: &Response) -> Self {
let mut tooltip = Self::for_widget(response);
tooltip.popup = tooltip
.popup
.open(!response.enabled() && Self::should_show_tooltip(response));
tooltip
}
/// Show the tooltip at the pointer position.
#[inline]
pub fn at_pointer(mut self) -> Self {
self.popup = self.popup.at_pointer();
self
}
/// Set the gap between the tooltip and the anchor
///
/// Default: 5.0
#[inline]
pub fn gap(mut self, gap: f32) -> Self {
self.popup = self.popup.gap(gap);
self
}
/// Set the layout of the tooltip
#[inline]
pub fn layout(mut self, layout: Layout) -> Self {
self.popup = self.popup.layout(layout);
self
}
/// Set the width of the tooltip
#[inline]
pub fn width(mut self, width: f32) -> Self {
self.popup = self.popup.width(width);
self
}
/// Show the tooltip
pub fn show<R>(self, content: impl FnOnce(&mut crate::Ui) -> R) -> Option<InnerResponse<R>> {
let Self {
mut popup,
layer_id: parent_layer,
widget_id,
} = self;
if !popup.is_open() {
return None;
}
let rect = popup.get_anchor_rect()?;
let mut state = popup.ctx().pass_state_mut(|fs| {
// Remember that this is the widget showing the tooltip:
fs.layers
.entry(parent_layer)
.or_default()
.widget_with_tooltip = Some(widget_id);
fs.tooltips
.widget_tooltips
.get(&widget_id)
.copied()
.unwrap_or(PerWidgetTooltipState {
bounding_rect: rect,
tooltip_count: 0,
})
});
let tooltip_area_id = Self::tooltip_id(widget_id, state.tooltip_count);
popup = popup.anchor(state.bounding_rect).id(tooltip_area_id);
let response = popup.show(|ui| {
// By default, the text in tooltips aren't selectable.
// This means that most tooltips aren't interactable,
// which also mean they won't stick around so you can click them.
// Only tooltips that have actual interactive stuff (buttons, links, …)
// will stick around when you try to click them.
ui.style_mut().interaction.selectable_labels = false;
content(ui)
});
// The popup might not be shown on at_pointer if there is no pointer.
if let Some(response) = &response {
state.tooltip_count += 1;
state.bounding_rect = state.bounding_rect.union(response.response.rect);
response
.response
.ctx
.pass_state_mut(|fs| fs.tooltips.widget_tooltips.insert(widget_id, state));
Self::remember_that_tooltip_was_shown(&response.response.ctx);
}
response
}
fn when_was_a_toolip_last_shown_id() -> Id {
Id::new("when_was_a_toolip_last_shown")
}
pub fn seconds_since_last_tooltip(ctx: &Context) -> f32 {
let when_was_a_toolip_last_shown =
ctx.data(|d| d.get_temp::<f64>(Self::when_was_a_toolip_last_shown_id()));
if let Some(when_was_a_toolip_last_shown) = when_was_a_toolip_last_shown {
let now = ctx.input(|i| i.time);
(now - when_was_a_toolip_last_shown) as f32
} else {
f32::INFINITY
}
}
fn remember_that_tooltip_was_shown(ctx: &Context) {
let now = ctx.input(|i| i.time);
ctx.data_mut(|data| data.insert_temp::<f64>(Self::when_was_a_toolip_last_shown_id(), now));
}
/// What is the id of the next tooltip for this widget?
pub fn next_tooltip_id(ctx: &Context, widget_id: Id) -> Id {
let tooltip_count = ctx.pass_state(|fs| {
fs.tooltips
.widget_tooltips
.get(&widget_id)
.map_or(0, |state| state.tooltip_count)
});
Self::tooltip_id(widget_id, tooltip_count)
}
pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
widget_id.with(tooltip_count)
}
/// Should we show a tooltip for this response?
pub fn should_show_tooltip(response: &Response) -> bool {
if response.ctx.memory(|mem| mem.everything_is_visible()) {
return true;
}
let any_open_popups = response.ctx.prev_pass_state(|fs| {
fs.layers
.get(&response.layer_id)
.is_some_and(|layer| !layer.open_popups.is_empty())
});
if any_open_popups {
// Hide tooltips if the user opens a popup (menu, combo-box, etc.) in the same layer.
return false;
}
let style = response.ctx.style();
let tooltip_delay = style.interaction.tooltip_delay;
let tooltip_grace_time = style.interaction.tooltip_grace_time;
let (
time_since_last_scroll,
time_since_last_click,
time_since_last_pointer_movement,
pointer_pos,
pointer_dir,
) = response.ctx.input(|i| {
(
i.time_since_last_scroll(),
i.pointer.time_since_last_click(),
i.pointer.time_since_last_movement(),
i.pointer.hover_pos(),
i.pointer.direction(),
)
});
if time_since_last_scroll < tooltip_delay {
// See https://github.com/emilk/egui/issues/4781
// Note that this means we cannot have `ScrollArea`s in a tooltip.
response
.ctx
.request_repaint_after_secs(tooltip_delay - time_since_last_scroll);
return false;
}
let is_our_tooltip_open = response.is_tooltip_open();
if is_our_tooltip_open {
// Check if we should automatically stay open:
let tooltip_id = Self::next_tooltip_id(&response.ctx, response.id);
let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id);
let tooltip_has_interactive_widget = response.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(tooltip_layer_id)
.any(|w| w.enabled && w.sense.interactive())
});
if tooltip_has_interactive_widget {
// We keep the tooltip open if hovered,
// or if the pointer is on its way to it,
// so that the user can interact with the tooltip
// (i.e. click links that are in it).
if let Some(area) = AreaState::load(&response.ctx, tooltip_id) {
let rect = area.rect();
if let Some(pos) = pointer_pos {
if rect.contains(pos) {
return true; // hovering interactive tooltip
}
if pointer_dir != Vec2::ZERO
&& rect.intersects_ray(pos, pointer_dir.normalized())
{
return true; // on the way to interactive tooltip
}
}
}
}
}
let clicked_more_recently_than_moved =
time_since_last_click < time_since_last_pointer_movement + 0.1;
if clicked_more_recently_than_moved {
// It is common to click a widget and then rest the mouse there.
// It would be annoying to then see a tooltip for it immediately.
// Similarly, clicking should hide the existing tooltip.
// Only hovering should lead to a tooltip, not clicking.
// The offset is only to allow small movement just right after the click.
return false;
}
if is_our_tooltip_open {
// Check if we should automatically stay open:
if pointer_pos.is_some_and(|pointer_pos| response.rect.contains(pointer_pos)) {
// Handle the case of a big tooltip that covers the widget:
return true;
}
}
let is_other_tooltip_open = response.ctx.prev_pass_state(|fs| {
if let Some(already_open_tooltip) = fs
.layers
.get(&response.layer_id)
.and_then(|layer| layer.widget_with_tooltip)
{
already_open_tooltip != response.id
} else {
false
}
});
if is_other_tooltip_open {
// We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself.
return false;
}
// Fast early-outs:
if response.enabled() {
if !response.hovered() || !response.ctx.input(|i| i.pointer.has_pointer()) {
return false;
}
} else if !response
.ctx
.rect_contains_pointer(response.layer_id, response.rect)
{
return false;
}
// There is a tooltip_delay before showing the first tooltip,
// but once one tooltip is show, moving the mouse cursor to
// another widget should show the tooltip for that widget right away.
// Let the user quickly move over some dead space to hover the next thing
let tooltip_was_recently_shown =
Self::seconds_since_last_tooltip(&response.ctx) < tooltip_grace_time;
if !tooltip_was_recently_shown && !is_our_tooltip_open {
if style.interaction.show_tooltips_only_when_still {
// We only show the tooltip when the mouse pointer is still.
if !response
.ctx
.input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO)
{
// wait for mouse to stop
response.ctx.request_repaint();
return false;
}
}
let time_since_last_interaction = time_since_last_scroll
.min(time_since_last_pointer_movement)
.min(time_since_last_click);
let time_til_tooltip = tooltip_delay - time_since_last_interaction;
if 0.0 < time_til_tooltip {
// Wait until the mouse has been still for a while
response.ctx.request_repaint_after_secs(time_til_tooltip);
return false;
}
}
// We don't want tooltips of things while we are dragging them,
// but we do want tooltips while holding down on an item on a touch screen.
if response
.ctx
.input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click)
{
return false;
}
// All checks passed: show the tooltip!
true
}
/// Was this tooltip visible last frame?
pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool {
let primary_tooltip_area_id = Self::tooltip_id(widget_id, 0);
ctx.memory(|mem| {
mem.areas()
.visible_last_frame(&LayerId::new(Order::Tooltip, primary_tooltip_area_id))
})
}
}

View File

@@ -2,15 +2,11 @@
use std::sync::Arc;
use crate::collapsing_header::CollapsingState;
use crate::{
Align, Align2, Context, CursorIcon, Id, InnerResponse, LayerId, NumExt, Order, Response, Sense,
TextStyle, Ui, UiKind, Vec2b, WidgetInfo, WidgetRect, WidgetText, WidgetType,
};
use emath::GuiRounding as _;
use epaint::{
emath, pos2, vec2, Galley, Pos2, Rect, RectShape, Rounding, Roundingf, Shape, Stroke, Vec2,
};
use epaint::{CornerRadiusF32, RectShape};
use crate::collapsing_header::CollapsingState;
use crate::*;
use super::scroll_area::ScrollBarVisibility;
use super::{area, resize, Area, Frame, Resize, ScrollArea};
@@ -419,7 +415,7 @@ impl<'open> Window<'open> {
}
}
impl<'open> Window<'open> {
impl Window<'_> {
/// Returns `None` if the window is not open (if [`Window::open`] was called with `&mut false`).
/// Returns `Some(InnerResponse { inner: None })` if the window is collapsed.
#[inline]
@@ -438,7 +434,7 @@ impl<'open> Window<'open> {
) -> Option<InnerResponse<Option<R>>> {
let Window {
title,
open,
mut open,
area,
frame,
resize,
@@ -452,8 +448,6 @@ impl<'open> Window<'open> {
let header_color =
frame.map_or_else(|| ctx.style().visuals.widgets.open.weak_bg_fill, |f| f.fill);
let mut window_frame = frame.unwrap_or_else(|| Frame::window(&ctx.style()));
// Keep the original inner margin for later use
let window_margin = window_frame.inner_margin;
let is_explicitly_closed = matches!(open, Some(false));
let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible());
@@ -483,15 +477,23 @@ impl<'open> Window<'open> {
area.with_widget_info(|| WidgetInfo::labeled(WidgetType::Window, true, title.text()));
// Calculate roughly how much larger the window size is compared to the inner rect
let (title_bar_height, title_content_spacing) = if with_title_bar {
// Calculate roughly how much larger the full window inner size is compared to the content rect
let (title_bar_height_with_margin, title_content_spacing) = if with_title_bar {
let style = ctx.style();
let spacing = window_margin.sum().y;
let height = ctx.fonts(|f| title.font_height(f, &style)) + spacing;
let half_height = (height / 2.0).round() as _;
window_frame.rounding.ne = window_frame.rounding.ne.clamp(0, half_height);
window_frame.rounding.nw = window_frame.rounding.nw.clamp(0, half_height);
(height, spacing)
let title_bar_inner_height = ctx
.fonts(|fonts| title.font_height(fonts, &style))
.at_least(style.spacing.interact_size.y);
let title_bar_inner_height = title_bar_inner_height + window_frame.inner_margin.sum().y;
let half_height = (title_bar_inner_height / 2.0).round() as _;
window_frame.corner_radius.ne = window_frame.corner_radius.ne.clamp(0, half_height);
window_frame.corner_radius.nw = window_frame.corner_radius.nw.clamp(0, half_height);
let title_content_spacing = if is_collapsed {
0.0
} else {
window_frame.stroke.width
};
(title_bar_inner_height, title_content_spacing)
} else {
(0.0, 0.0)
};
@@ -500,7 +502,8 @@ impl<'open> Window<'open> {
// Prevent window from becoming larger than the constrain rect.
let constrain_rect = area.constrain_rect();
let max_width = constrain_rect.width();
let max_height = constrain_rect.height() - title_bar_height;
let max_height =
constrain_rect.height() - title_bar_height_with_margin - title_content_spacing;
resize.max_size.x = resize.max_size.x.min(max_width);
resize.max_size.y = resize.max_size.y.min(max_height);
}
@@ -508,21 +511,28 @@ impl<'open> Window<'open> {
// First check for resize to avoid frame delay:
let last_frame_outer_rect = area.state().rect();
let resize_interaction = ctx.with_accessibility_parent(area.id(), || {
resize_interaction(ctx, possible, area_layer_id, last_frame_outer_rect)
resize_interaction(
ctx,
possible,
area_layer_id,
last_frame_outer_rect,
window_frame,
)
});
let margins = window_frame.outer_margin.sum()
+ window_frame.inner_margin.sum()
+ vec2(0.0, title_bar_height);
{
let margins = window_frame.total_margin().sum()
+ vec2(0.0, title_bar_height_with_margin + title_content_spacing);
resize_response(
resize_interaction,
ctx,
margins,
area_layer_id,
&mut area,
resize_id,
);
resize_response(
resize_interaction,
ctx,
margins,
area_layer_id,
&mut area,
resize_id,
);
}
let mut area_content_ui = area.content_ui(ctx);
if is_open {
@@ -535,40 +545,43 @@ impl<'open> Window<'open> {
let content_inner = {
ctx.with_accessibility_parent(area.id(), || {
// BEGIN FRAME --------------------------------
let frame_stroke = window_frame.stroke;
let mut frame = window_frame.begin(&mut area_content_ui);
let show_close_button = open.is_some();
let where_to_put_header_background = &area_content_ui.painter().add(Shape::Noop);
// Backup item spacing before the title bar
let item_spacing = frame.content_ui.spacing().item_spacing;
// Use title bar spacing as the item spacing before the content
frame.content_ui.spacing_mut().item_spacing.y = title_content_spacing;
let title_bar = if with_title_bar {
let title_bar = TitleBar::new(
&mut frame.content_ui,
&frame.content_ui,
title,
show_close_button,
&mut collapsing,
collapsible,
window_frame,
title_bar_height_with_margin,
);
resize.min_size.x = resize.min_size.x.at_least(title_bar.rect.width()); // Prevent making window smaller than title bar width
resize.min_size.x = resize.min_size.x.at_least(title_bar.inner_rect.width()); // Prevent making window smaller than title bar width
frame.content_ui.set_min_size(title_bar.inner_rect.size());
// Skip the title bar (and separator):
if is_collapsed {
frame.content_ui.add_space(title_bar.inner_rect.height());
} else {
frame.content_ui.add_space(
title_bar.inner_rect.height()
+ title_content_spacing
+ window_frame.inner_margin.sum().y,
);
}
Some(title_bar)
} else {
None
};
// Remove item spacing after the title bar
frame.content_ui.spacing_mut().item_spacing.y = 0.0;
let (content_inner, mut content_response) = collapsing
let (content_inner, content_response) = collapsing
.show_body_unindented(&mut frame.content_ui, |ui| {
// Restore item spacing for the content
ui.spacing_mut().item_spacing.y = item_spacing.y;
resize.show(ui, |ui| {
if scroll.is_any_scroll_enabled() {
scroll.show(ui, add_contents).inner
@@ -584,26 +597,20 @@ impl<'open> Window<'open> {
&area_content_ui,
&possible,
outer_rect,
frame_stroke,
window_frame.rounding,
&window_frame,
resize_interaction,
);
// END FRAME --------------------------------
if let Some(title_bar) = title_bar {
let mut title_rect = Rect::from_min_size(
outer_rect.min,
Vec2 {
x: outer_rect.size().x,
y: title_bar_height,
},
);
title_rect = title_rect.round_to_pixels(area_content_ui.pixels_per_point());
if let Some(mut title_bar) = title_bar {
title_bar.inner_rect = outer_rect.shrink(window_frame.stroke.width);
title_bar.inner_rect.max.y =
title_bar.inner_rect.min.y + title_bar_height_with_margin;
if on_top && area_content_ui.visuals().window_highlight_topmost {
let mut round = window_frame.rounding;
let mut round =
window_frame.corner_radius - window_frame.stroke.width.round() as u8;
if !is_collapsed {
round.se = 0;
@@ -612,20 +619,22 @@ impl<'open> Window<'open> {
area_content_ui.painter().set(
*where_to_put_header_background,
RectShape::filled(title_rect, round, header_color),
RectShape::filled(title_bar.inner_rect, round, header_color),
);
};
// Fix title bar separator line position
if let Some(response) = &mut content_response {
response.rect.min.y = outer_rect.min.y + title_bar_height;
if false {
ctx.debug_painter().debug_rect(
title_bar.inner_rect,
Color32::LIGHT_BLUE,
"title_bar.rect",
);
}
title_bar.ui(
&mut area_content_ui,
title_rect,
&content_response,
open,
open.as_deref_mut(),
&mut collapsing,
collapsible,
);
@@ -641,6 +650,12 @@ impl<'open> Window<'open> {
let full_response = area.end(ctx, area_content_ui);
if full_response.should_close() {
if let Some(open) = open {
*open = false;
}
}
let inner_response = InnerResponse {
inner: content_inner,
response: full_response,
@@ -653,32 +668,31 @@ fn paint_resize_corner(
ui: &Ui,
possible: &PossibleInteractions,
outer_rect: Rect,
stroke: impl Into<Stroke>,
rounding: impl Into<Rounding>,
window_frame: &Frame,
i: ResizeInteraction,
) {
let inactive_stroke = stroke.into();
let rounding = rounding.into();
let cr = window_frame.corner_radius;
let (corner, radius, corner_response) = if possible.resize_right && possible.resize_bottom {
(Align2::RIGHT_BOTTOM, rounding.se, i.right & i.bottom)
(Align2::RIGHT_BOTTOM, cr.se, i.right & i.bottom)
} else if possible.resize_left && possible.resize_bottom {
(Align2::LEFT_BOTTOM, rounding.sw, i.left & i.bottom)
(Align2::LEFT_BOTTOM, cr.sw, i.left & i.bottom)
} else if possible.resize_left && possible.resize_top {
(Align2::LEFT_TOP, rounding.nw, i.left & i.top)
(Align2::LEFT_TOP, cr.nw, i.left & i.top)
} else if possible.resize_right && possible.resize_top {
(Align2::RIGHT_TOP, rounding.ne, i.right & i.top)
(Align2::RIGHT_TOP, cr.ne, i.right & i.top)
} else {
// We're not in two directions, but it is still nice to tell the user
// we're resizable by painting the resize corner in the expected place
// (i.e. for windows only resizable in one direction):
if possible.resize_right || possible.resize_bottom {
(Align2::RIGHT_BOTTOM, rounding.se, i.right & i.bottom)
(Align2::RIGHT_BOTTOM, cr.se, i.right & i.bottom)
} else if possible.resize_left || possible.resize_bottom {
(Align2::LEFT_BOTTOM, rounding.sw, i.left & i.bottom)
(Align2::LEFT_BOTTOM, cr.sw, i.left & i.bottom)
} else if possible.resize_left || possible.resize_top {
(Align2::LEFT_TOP, rounding.nw, i.left & i.top)
(Align2::LEFT_TOP, cr.nw, i.left & i.top)
} else if possible.resize_right || possible.resize_top {
(Align2::RIGHT_TOP, rounding.ne, i.right & i.top)
(Align2::RIGHT_TOP, cr.ne, i.right & i.top)
} else {
return;
}
@@ -694,11 +708,12 @@ fn paint_resize_corner(
} else if corner_response.hover {
ui.visuals().widgets.hovered.fg_stroke
} else {
inactive_stroke
window_frame.stroke
};
let fill_rect = outer_rect.shrink(window_frame.stroke.width);
let corner_size = Vec2::splat(ui.visuals().resize_corner_size);
let corner_rect = corner.align_size_within_rect(corner_size, outer_rect);
let corner_rect = corner.align_size_within_rect(corner_size, fill_rect);
let corner_rect = corner_rect.translate(-offset * corner.to_sign()); // move away from corner
crate::resize::paint_resize_corner_with_style(ui, &corner_rect, stroke.color, corner);
}
@@ -738,7 +753,11 @@ impl PossibleInteractions {
/// Resizing the window edges.
#[derive(Clone, Copy, Debug)]
struct ResizeInteraction {
start_rect: Rect,
/// Outer rect (outside the stroke)
outer_rect: Rect,
window_frame: Frame,
left: SideResponse,
right: SideResponse,
top: SideResponse,
@@ -835,13 +854,17 @@ fn resize_response(
ctx.memory_mut(|mem| mem.areas_mut().move_to_top(area_layer_id));
}
/// Acts on outer rect (outside the stroke)
fn move_and_resize_window(ctx: &Context, interaction: &ResizeInteraction) -> Option<Rect> {
if !interaction.any_dragged() {
return None;
}
let pointer_pos = ctx.input(|i| i.pointer.interact_pos())?;
let mut rect = interaction.start_rect; // prevent drift
let mut rect = interaction.outer_rect; // prevent drift
// Put the rect in the center of the stroke:
rect = rect.shrink(interaction.window_frame.stroke.width / 2.0);
if interaction.left.drag {
rect.min.x = pointer_pos.x;
@@ -855,6 +878,9 @@ fn move_and_resize_window(ctx: &Context, interaction: &ResizeInteraction) -> Opt
rect.max.y = pointer_pos.y;
}
// Return to having the rect outside the stroke:
rect = rect.expand(interaction.window_frame.stroke.width / 2.0);
Some(rect.round_ui())
}
@@ -862,11 +888,13 @@ fn resize_interaction(
ctx: &Context,
possible: PossibleInteractions,
layer_id: LayerId,
rect: Rect,
outer_rect: Rect,
window_frame: Frame,
) -> ResizeInteraction {
if !possible.resizable() {
return ResizeInteraction {
start_rect: rect,
outer_rect,
window_frame,
left: Default::default(),
right: Default::default(),
top: Default::default(),
@@ -874,6 +902,9 @@ fn resize_interaction(
};
}
// The rect that is in the middle of the stroke:
let rect = outer_rect.shrink(window_frame.stroke.width / 2.0);
let side_response = |rect, id| {
let response = ctx.create_widget(
WidgetRect {
@@ -990,7 +1021,8 @@ fn resize_interaction(
}
let interaction = ResizeInteraction {
start_rect: rect,
outer_rect,
window_frame,
left,
right,
top,
@@ -1026,137 +1058,123 @@ fn paint_frame_interaction(ui: &Ui, rect: Rect, interaction: ResizeInteraction)
bottom = interaction.bottom.hover;
}
let rounding = Roundingf::from(ui.visuals().window_rounding);
let cr = CornerRadiusF32::from(ui.visuals().window_corner_radius);
// Put the rect in the center of the fixed window stroke:
let rect = rect.shrink(interaction.window_frame.stroke.width / 2.0);
// Make sure the inner part of the stroke is at a pixel boundary:
let stroke = visuals.bg_stroke;
let half_stroke = stroke.width / 2.0;
let rect = rect
.shrink(half_stroke)
.round_to_pixels(ui.pixels_per_point())
.expand(half_stroke);
let Rect { min, max } = rect;
let mut points = Vec::new();
if right && !bottom && !top {
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
points.push(pos2(max.x, min.y + cr.ne));
points.push(pos2(max.x, max.y - cr.se));
}
if right && bottom {
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
add_circle_quadrant(
&mut points,
pos2(max.x - rounding.se, max.y - rounding.se),
rounding.se,
0.0,
);
points.push(pos2(max.x, min.y + cr.ne));
points.push(pos2(max.x, max.y - cr.se));
add_circle_quadrant(&mut points, pos2(max.x - cr.se, max.y - cr.se), cr.se, 0.0);
}
if bottom {
points.push(pos2(max.x - rounding.se, max.y));
points.push(pos2(min.x + rounding.sw, max.y));
points.push(pos2(max.x - cr.se, max.y));
points.push(pos2(min.x + cr.sw, max.y));
}
if left && bottom {
add_circle_quadrant(
&mut points,
pos2(min.x + rounding.sw, max.y - rounding.sw),
rounding.sw,
1.0,
);
add_circle_quadrant(&mut points, pos2(min.x + cr.sw, max.y - cr.sw), cr.sw, 1.0);
}
if left {
points.push(pos2(min.x, max.y - rounding.sw));
points.push(pos2(min.x, min.y + rounding.nw));
points.push(pos2(min.x, max.y - cr.sw));
points.push(pos2(min.x, min.y + cr.nw));
}
if left && top {
add_circle_quadrant(
&mut points,
pos2(min.x + rounding.nw, min.y + rounding.nw),
rounding.nw,
2.0,
);
add_circle_quadrant(&mut points, pos2(min.x + cr.nw, min.y + cr.nw), cr.nw, 2.0);
}
if top {
points.push(pos2(min.x + rounding.nw, min.y));
points.push(pos2(max.x - rounding.ne, min.y));
points.push(pos2(min.x + cr.nw, min.y));
points.push(pos2(max.x - cr.ne, min.y));
}
if right && top {
add_circle_quadrant(
&mut points,
pos2(max.x - rounding.ne, min.y + rounding.ne),
rounding.ne,
3.0,
);
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
add_circle_quadrant(&mut points, pos2(max.x - cr.ne, min.y + cr.ne), cr.ne, 3.0);
points.push(pos2(max.x, min.y + cr.ne));
points.push(pos2(max.x, max.y - cr.se));
}
ui.painter().add(Shape::line(points, visuals.bg_stroke));
ui.painter().add(Shape::line(points, stroke));
}
// ----------------------------------------------------------------------------
struct TitleBar {
/// A title Id used for dragging windows
id: Id,
window_frame: Frame,
/// Prepared text in the title
title_galley: Arc<Galley>,
/// Size of the title bar in a collapsed state (if window is collapsible),
/// which includes all necessary space for showing the expand button, the
/// title and the close button.
min_rect: Rect,
/// Size of the title bar in an expanded state. This size become known only
/// after expanding window and painting its content
rect: Rect,
/// after expanding window and painting its content.
///
/// Does not include the stroke, nor the separator line between the title bar and the window contents.
inner_rect: Rect,
}
impl TitleBar {
fn new(
ui: &mut Ui,
ui: &Ui,
title: WidgetText,
show_close_button: bool,
collapsing: &mut CollapsingState,
collapsible: bool,
window_frame: Frame,
title_bar_height_with_margin: f32,
) -> Self {
let inner_response = ui.horizontal(|ui| {
let height = ui
.fonts(|fonts| title.font_height(fonts, ui.style()))
.max(ui.spacing().interact_size.y);
ui.set_min_height(height);
if false {
ui.ctx()
.debug_painter()
.debug_rect(ui.min_rect(), Color32::GREEN, "outer_min_rect");
}
let item_spacing = ui.spacing().item_spacing;
let button_size = Vec2::splat(ui.spacing().icon_width);
let inner_height = title_bar_height_with_margin - window_frame.inner_margin.sum().y;
let pad = ((height - button_size.y) / 2.0).round_ui(); // calculated so that the icon is on the diagonal (if window padding is symmetrical)
let item_spacing = ui.spacing().item_spacing;
let button_size = Vec2::splat(ui.spacing().icon_width.at_most(inner_height));
if collapsible {
ui.add_space(pad);
collapsing.show_default_button_with_size(ui, button_size);
}
let left_pad = ((inner_height - button_size.y) / 2.0).round_ui(); // calculated so that the icon is on the diagonal (if window padding is symmetrical)
let title_galley = title.into_galley(
ui,
Some(crate::TextWrapMode::Extend),
f32::INFINITY,
TextStyle::Heading,
);
let title_galley = title.into_galley(
ui,
Some(crate::TextWrapMode::Extend),
f32::INFINITY,
TextStyle::Heading,
);
let minimum_width = if collapsible || show_close_button {
// If at least one button is shown we make room for both buttons (since title is centered):
2.0 * (pad + button_size.x + item_spacing.x) + title_galley.size().x
} else {
pad + title_galley.size().x + pad
};
let min_rect = Rect::from_min_size(ui.min_rect().min, vec2(minimum_width, height));
let id = ui.advance_cursor_after_rect(min_rect);
let minimum_width = if collapsible || show_close_button {
// If at least one button is shown we make room for both buttons (since title should be centered):
2.0 * (left_pad + button_size.x + item_spacing.x) + title_galley.size().x
} else {
left_pad + title_galley.size().x + left_pad
};
let min_inner_size = vec2(minimum_width, inner_height);
let min_rect = Rect::from_min_size(ui.min_rect().min, min_inner_size);
Self {
id,
title_galley,
min_rect,
rect: Rect::NAN, // Will be filled in later
}
});
if false {
ui.ctx()
.debug_painter()
.debug_rect(min_rect, Color32::LIGHT_BLUE, "min_rect");
}
let title_bar = inner_response.inner;
let rect = inner_response.response.rect;
Self { rect, ..title_bar }
Self {
window_frame,
title_galley,
inner_rect: min_rect, // First estimate - will be refined later
}
}
/// Finishes painting of the title bar when the window content size already known.
@@ -1174,17 +1192,34 @@ impl TitleBar {
/// - `collapsible`: if `true`, double click on the title bar will be handled for a change
/// of `collapsing` state
fn ui(
mut self,
self,
ui: &mut Ui,
outer_rect: Rect,
content_response: &Option<Response>,
open: Option<&mut bool>,
collapsing: &mut CollapsingState,
collapsible: bool,
) {
if let Some(content_response) = &content_response {
// Now we know how large we got to be:
self.rect.max.x = self.rect.max.x.max(content_response.rect.max.x);
let window_frame = self.window_frame;
let title_inner_rect = self.inner_rect;
if false {
ui.ctx()
.debug_painter()
.debug_rect(self.inner_rect, Color32::RED, "TitleBar");
}
if collapsible {
// Show collapse-button:
let button_center = Align2::LEFT_CENTER
.align_size_within_rect(Vec2::splat(self.inner_rect.height()), self.inner_rect)
.center();
let button_size = Vec2::splat(ui.spacing().icon_width);
let button_rect = Rect::from_center_size(button_center, button_size);
let button_rect = button_rect.round_ui();
ui.scope_builder(UiBuilder::new().max_rect(button_rect), |ui| {
collapsing.show_default_button_with_size(ui, button_size);
});
}
if let Some(open) = open {
@@ -1194,9 +1229,9 @@ impl TitleBar {
}
}
let full_top_rect = Rect::from_x_y_ranges(self.rect.x_range(), self.min_rect.y_range());
let text_pos =
emath::align::center_size_in_rect(self.title_galley.size(), full_top_rect).left_top();
emath::align::center_size_in_rect(self.title_galley.size(), title_inner_rect)
.left_top();
let text_pos = text_pos - self.title_galley.rect.min.to_vec2();
ui.painter().galley(
text_pos,
@@ -1205,22 +1240,35 @@ impl TitleBar {
);
if let Some(content_response) = &content_response {
// paint separator between title and content:
let y = content_response.rect.top();
// let y = lerp(self.rect.bottom()..=content_response.rect.top(), 0.5);
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
// Workaround: To prevent border infringement,
// the 0.1 value should ideally be calculated using TessellationOptions::feathering_size_in_pixels
// or we could support selectively disabling feathering on line caps
let x_range = outer_rect.x_range().shrink(0.1);
ui.painter().hline(x_range, y, stroke);
// Paint separator between title and content:
let content_rect = content_response.rect;
if false {
ui.ctx()
.debug_painter()
.debug_rect(content_rect, Color32::RED, "content_rect");
}
let y = title_inner_rect.bottom() + window_frame.stroke.width / 2.0;
// To verify the sanity of this, use a very wide window stroke
ui.painter()
.hline(title_inner_rect.x_range(), y, window_frame.stroke);
}
// Don't cover the close- and collapse buttons:
let double_click_rect = self.rect.shrink2(vec2(32.0, 0.0));
let double_click_rect = title_inner_rect.shrink2(vec2(32.0, 0.0));
if false {
ui.ctx().debug_painter().debug_rect(
double_click_rect,
Color32::GREEN,
"double_click_rect",
);
}
let id = ui.unique_id().with("__window_title_bar");
if ui
.interact(double_click_rect, self.id, Sense::click())
.interact(double_click_rect, id, Sense::click())
.double_clicked()
&& collapsible
{
@@ -1234,16 +1282,12 @@ impl TitleBar {
/// The button is square and its size is determined by the
/// [`crate::style::Spacing::icon_width`] setting.
fn close_button_ui(&self, ui: &mut Ui) -> Response {
let button_center = Align2::RIGHT_CENTER
.align_size_within_rect(Vec2::splat(self.inner_rect.height()), self.inner_rect)
.center();
let button_size = Vec2::splat(ui.spacing().icon_width);
let pad = (self.rect.height() - button_size.y) / 2.0; // calculated so that the icon is on the diagonal (if window padding is symmetrical)
let button_rect = Rect::from_min_size(
pos2(
self.rect.right() - pad - button_size.x,
self.rect.center().y - 0.5 * button_size.y,
),
button_size,
);
let button_rect = Rect::from_center_size(button_center, button_size);
let button_rect = button_rect.round_to_pixels(ui.pixels_per_point());
close_button(ui, button_rect)
}
}

View File

@@ -11,7 +11,7 @@ use epaint::{
tessellator,
text::{FontInsert, FontPriority, Fonts},
util::OrderedFloat,
vec2, ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect,
vec2, ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect, StrokeKind,
TessellationOptions, TextureAtlas, TextureId, Vec2,
};
@@ -26,11 +26,10 @@ use crate::{
load,
load::{Bytes, Loaders, SizedTexture},
memory::{Options, Theme},
menu,
os::OperatingSystem,
output::FullOutput,
pass_state::PassState,
resize, scroll_area,
resize, response, scroll_area,
util::IdTypeMap,
viewport::ViewportClass,
Align2, CursorIcon, DeferredViewportUiCallback, FontDefinitions, Grid, Id, ImmediateViewport,
@@ -83,7 +82,11 @@ impl Default for WrappedTextureManager {
epaint::FontImage::new([0, 0]).into(),
Default::default(),
);
assert_eq!(font_id, TextureId::default());
assert_eq!(
font_id,
TextureId::default(),
"font id should be equal to TextureId::default(), but was {font_id:?}",
);
Self(Arc::new(RwLock::new(tex_mngr)))
}
@@ -211,14 +214,14 @@ impl ContextImpl {
fn requested_immediate_repaint_prev_pass(&self, viewport_id: &ViewportId) -> bool {
self.viewports
.get(viewport_id)
.map_or(false, |v| v.repaint.requested_immediate_repaint_prev_pass())
.is_some_and(|v| v.repaint.requested_immediate_repaint_prev_pass())
}
#[must_use]
fn has_requested_repaint(&self, viewport_id: &ViewportId) -> bool {
self.viewports.get(viewport_id).map_or(false, |v| {
0 < v.repaint.outstanding || v.repaint.repaint_delay < Duration::MAX
})
self.viewports
.get(viewport_id)
.is_some_and(|v| 0 < v.repaint.outstanding || v.repaint.repaint_delay < Duration::MAX)
}
}
@@ -775,7 +778,7 @@ impl Context {
writer(&mut self.0.write())
}
/// Run the ui code for one 1.
/// Run the ui code for one frame.
///
/// At most [`Options::max_passes`] calls will be issued to `run_ui`,
/// and only on the rare occasion that [`Context::request_discard`] is called.
@@ -805,7 +808,11 @@ impl Context {
let max_passes = self.write(|ctx| ctx.memory.options.max_passes.get());
let mut output = FullOutput::default();
debug_assert_eq!(output.platform_output.num_completed_passes, 0);
debug_assert_eq!(
output.platform_output.num_completed_passes, 0,
"output must be fresh, but had {} passes",
output.platform_output.num_completed_passes
);
loop {
profiling::scope!(
@@ -829,7 +836,11 @@ impl Context {
self.begin_pass(new_input.take());
run_ui(self);
output.append(self.end_pass());
debug_assert!(0 < output.platform_output.num_completed_passes);
debug_assert!(
0 < output.platform_output.num_completed_passes,
"Completed passes was lower than 0, was {}",
output.platform_output.num_completed_passes
);
if !output.platform_output.requested_discard() {
break; // no need for another pass
@@ -1087,7 +1098,7 @@ impl Context {
let text = format!("🔥 {text}");
let color = self.style().visuals.error_fg_color;
let painter = self.debug_painter();
painter.rect_stroke(widget_rect, 0.0, (1.0, color));
painter.rect_stroke(widget_rect, 0.0, (1.0, color), StrokeKind::Outside);
let below = widget_rect.bottom() + 32.0 < screen_rect.bottom();
@@ -1151,8 +1162,9 @@ impl Context {
/// same widget, then `allow_focus` should only be true once (like in [`Ui::new`] (true) and [`Ui::remember_min_rect`] (false)).
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_widget(&self, w: WidgetRect, allow_focus: bool) -> Response {
let interested_in_focus =
w.enabled && w.sense.focusable && self.memory(|mem| mem.allows_interaction(w.layer_id));
let interested_in_focus = w.enabled
&& w.sense.is_focusable()
&& self.memory(|mem| mem.allows_interaction(w.layer_id));
// Remember this widget
self.write(|ctx| {
@@ -1173,7 +1185,7 @@ impl Context {
self.memory_mut(|mem| mem.surrender_focus(w.id));
}
if w.sense.interactive() || w.sense.focusable {
if w.sense.interactive() || w.sense.is_focusable() {
self.check_for_id_clash(w.id, w.rect, "widget");
}
@@ -1181,7 +1193,7 @@ impl Context {
let res = self.get_response(w);
#[cfg(feature = "accesskit")]
if allow_focus && w.sense.focusable {
if allow_focus && w.sense.is_focusable() {
// Make sure anything that can receive focus has an AccessKit node.
// TODO(mwcampbell): For nodes that are filled from widget info,
// some information is written to the node twice.
@@ -1196,15 +1208,27 @@ impl Context {
/// This is because widget interaction happens at the start of the pass, using the widget rects from the previous pass.
///
/// If the widget was not visible the previous pass (or this pass), this will return `None`.
///
/// If you try to read a [`Ui`]'s response, while still inside, this will return the [`Rect`] from the previous frame.
pub fn read_response(&self, id: Id) -> Option<Response> {
self.write(|ctx| {
let viewport = ctx.viewport();
viewport
let widget_rect = viewport
.this_pass
.widgets
.get(id)
.or_else(|| viewport.prev_pass.widgets.get(id))
.copied()
.copied();
widget_rect.map(|mut rect| {
// If the Rect is invalid the Ui hasn't registered its final Rect yet.
// We return the Rect from last frame instead.
if !(rect.rect.is_positive() && rect.rect.is_finite()) {
if let Some(prev_rect) = viewport.prev_pass.widgets.get(id) {
rect.rect = prev_rect.rect;
}
}
rect
})
})
.map(|widget_rect| self.get_response(widget_rect))
}
@@ -1213,11 +1237,13 @@ impl Context {
#[deprecated = "Use Response.contains_pointer or Context::read_response instead"]
pub fn widget_contains_pointer(&self, id: Id) -> bool {
self.read_response(id)
.map_or(false, |response| response.contains_pointer)
.is_some_and(|response| response.contains_pointer())
}
/// Do all interaction for an existing widget, without (re-)registering it.
pub(crate) fn get_response(&self, widget_rect: WidgetRect) -> Response {
use response::Flags;
let WidgetRect {
id,
layer_id,
@@ -1237,61 +1263,72 @@ impl Context {
rect,
interact_rect,
sense,
enabled,
contains_pointer: false,
hovered: false,
highlighted,
clicked: false,
fake_primary_click: false,
long_touched: false,
drag_started: false,
dragged: false,
drag_stopped: false,
is_pointer_button_down_on: false,
flags: Flags::empty(),
interact_pointer_pos: None,
changed: false,
intrinsic_size: None,
};
res.flags.set(Flags::ENABLED, enabled);
res.flags.set(Flags::HIGHLIGHTED, highlighted);
self.write(|ctx| {
let viewport = ctx.viewports.entry(ctx.viewport_id()).or_default();
res.contains_pointer = viewport.interact_widgets.contains_pointer.contains(&id);
res.flags.set(
Flags::CONTAINS_POINTER,
viewport.interact_widgets.contains_pointer.contains(&id),
);
let input = &viewport.input;
let memory = &mut ctx.memory;
if enabled
&& sense.click
&& sense.senses_click()
&& memory.has_focus(id)
&& (input.key_pressed(Key::Space) || input.key_pressed(Key::Enter))
{
// Space/enter works like a primary click for e.g. selected buttons
res.fake_primary_click = true;
res.flags.set(Flags::FAKE_PRIMARY_CLICKED, true);
}
#[cfg(feature = "accesskit")]
if enabled
&& sense.click
&& sense.senses_click()
&& input.has_accesskit_action_request(id, accesskit::Action::Click)
{
res.fake_primary_click = true;
res.flags.set(Flags::FAKE_PRIMARY_CLICKED, true);
}
if enabled && sense.click && Some(id) == viewport.interact_widgets.long_touched {
res.long_touched = true;
if enabled && sense.senses_click() && Some(id) == viewport.interact_widgets.long_touched
{
res.flags.set(Flags::LONG_TOUCHED, true);
}
let interaction = memory.interaction();
res.is_pointer_button_down_on = interaction.potential_click_id == Some(id)
|| interaction.potential_drag_id == Some(id);
res.flags.set(
Flags::IS_POINTER_BUTTON_DOWN_ON,
interaction.potential_click_id == Some(id)
|| interaction.potential_drag_id == Some(id),
);
if res.enabled {
res.hovered = viewport.interact_widgets.hovered.contains(&id);
res.dragged = Some(id) == viewport.interact_widgets.dragged;
res.drag_started = Some(id) == viewport.interact_widgets.drag_started;
res.drag_stopped = Some(id) == viewport.interact_widgets.drag_stopped;
if res.enabled() {
res.flags.set(
Flags::HOVERED,
viewport.interact_widgets.hovered.contains(&id),
);
res.flags.set(
Flags::DRAGGED,
Some(id) == viewport.interact_widgets.dragged,
);
res.flags.set(
Flags::DRAG_STARTED,
Some(id) == viewport.interact_widgets.drag_started,
);
res.flags.set(
Flags::DRAG_STOPPED,
Some(id) == viewport.interact_widgets.drag_stopped,
);
}
let clicked = Some(id) == viewport.interact_widgets.clicked;
@@ -1304,20 +1341,22 @@ impl Context {
any_press = true;
}
PointerEvent::Released { click, .. } => {
if enabled && sense.click && clicked && click.is_some() {
res.clicked = true;
if enabled && sense.senses_click() && clicked && click.is_some() {
res.flags.set(Flags::CLICKED, true);
}
res.is_pointer_button_down_on = false;
res.dragged = false;
res.flags.set(Flags::IS_POINTER_BUTTON_DOWN_ON, false);
res.flags.set(Flags::DRAGGED, false);
}
}
}
// is_pointer_button_down_on is false when released, but we want interact_pointer_pos
// to still work.
let is_interacted_with =
res.is_pointer_button_down_on || res.long_touched || clicked || res.drag_stopped;
let is_interacted_with = res.is_pointer_button_down_on()
|| res.long_touched()
|| clicked
|| res.drag_stopped();
if is_interacted_with {
res.interact_pointer_pos = input.pointer.interact_pos();
if let (Some(to_global), Some(pos)) = (
@@ -1330,10 +1369,10 @@ impl Context {
if input.pointer.any_down() && !is_interacted_with {
// We don't hover widgets while interacting with *other* widgets:
res.hovered = false;
res.flags.set(Flags::HOVERED, false);
}
let pointer_pressed_elsewhere = any_press && !res.hovered;
let pointer_pressed_elsewhere = any_press && !res.hovered();
if pointer_pressed_elsewhere && memory.has_focus(id) {
memory.surrender_focus(id);
}
@@ -1455,6 +1494,11 @@ impl Context {
self.send_cmd(crate::OutputCommand::CopyImage(image));
}
/// Set the mouse cursor position (if the platform supports it).
pub fn set_pointer_position(&self, position: Pos2) {
self.send_cmd(crate::OutputCommand::SetPointerPosition(position));
}
/// Format the given shortcut in a human-readable way (e.g. `Ctrl+Shift+X`).
///
/// Can be used to get the text for [`crate::Button::shortcut_text`].
@@ -2152,11 +2196,12 @@ impl Context {
let painter = Painter::new(self.clone(), *layer_id, Rect::EVERYTHING);
for rect in rects {
if rect.sense.interactive() {
let (color, text) = if rect.sense.click && rect.sense.drag {
let (color, text) = if rect.sense.senses_click() && rect.sense.senses_drag()
{
(Color32::from_rgb(0x88, 0, 0x88), "click+drag")
} else if rect.sense.click {
} else if rect.sense.senses_click() {
(Color32::from_rgb(0x88, 0, 0), "click")
} else if rect.sense.drag {
} else if rect.sense.senses_drag() {
(Color32::from_rgb(0, 0, 0x88), "drag")
} else {
// unreachable since we only show interactive
@@ -2532,10 +2577,7 @@ impl Context {
self.input(|i| i.screen_rect()).round_ui()
}
/// How much space is still available after panels has been added.
///
/// This is the "background" area, what egui doesn't cover with panels (but may cover with windows).
/// This is also the area to which windows are constrained.
/// How much space is still available after panels have been added.
pub fn available_rect(&self) -> Rect {
self.pass_state(|s| s.available_rect()).round_ui()
}
@@ -2612,10 +2654,25 @@ impl Context {
}
/// Is an egui context menu open?
///
/// This only works with the old, deprecated [`crate::menu`] API.
#[allow(deprecated)]
#[deprecated = "Use `is_popup_open` instead"]
pub fn is_context_menu_open(&self) -> bool {
self.data(|d| {
d.get_temp::<crate::menu::BarState>(menu::CONTEXT_MENU_ID_STR.into())
.map_or(false, |state| state.has_root())
d.get_temp::<crate::menu::BarState>(crate::menu::CONTEXT_MENU_ID_STR.into())
.is_some_and(|state| state.has_root())
})
}
/// Is a popup or (context) menu open?
///
/// Will return false for [`crate::Tooltip`]s (which are technically popups as well).
pub fn is_popup_open(&self) -> bool {
self.pass_state_mut(|fs| {
fs.layers
.values()
.any(|layer| !layer.open_popups.is_empty())
})
}
}
@@ -2662,7 +2719,8 @@ impl Context {
///
/// Can be used to implement pan and zoom (see relevant demo).
///
/// For a temporary transform, use [`Self::transform_layer_shapes`] instead.
/// For a temporary transform, use [`Self::transform_layer_shapes`] or
/// [`Ui::with_visual_transform`].
pub fn set_transform_layer(&self, layer_id: LayerId, transform: TSTransform) {
self.memory_mut(|m| {
if transform == TSTransform::IDENTITY {
@@ -3131,7 +3189,7 @@ impl Context {
// TODO(emilk): `Sense::hover_highlight()`
let response =
ui.add(Label::new(RichText::new(text).monospace()).sense(Sense::click()));
if response.hovered && is_visible {
if response.hovered() && is_visible {
ui.ctx()
.debug_painter()
.debug_rect(area.rect(), Color32::RED, "");
@@ -3152,13 +3210,14 @@ impl Context {
}
});
#[allow(deprecated)]
ui.horizontal(|ui| {
ui.label(format!(
"{} menu bars",
self.data(|d| d.count::<menu::BarState>())
self.data(|d| d.count::<crate::menu::BarState>())
));
if ui.button("Reset").clicked() {
self.data_mut(|d| d.remove_by_type::<menu::BarState>());
self.data_mut(|d| d.remove_by_type::<crate::menu::BarState>());
}
});
@@ -3225,7 +3284,11 @@ impl Context {
#[cfg(feature = "accesskit")]
self.pass_state_mut(|fs| {
if let Some(state) = fs.accesskit_state.as_mut() {
assert_eq!(state.parent_stack.pop(), Some(_id));
assert_eq!(
state.parent_stack.pop(),
Some(_id),
"Mismatched push/pop in with_accessibility_parent"
);
}
});
@@ -3488,7 +3551,6 @@ impl Context {
/// The loaders of bytes, images, and textures.
pub fn loaders(&self) -> Arc<Loaders> {
profiling::function_scope!();
self.read(|this| this.loaders.clone())
}
}

View File

@@ -214,11 +214,21 @@ pub struct ViewportInfo {
/// The inner rectangle of the native window, in monitor space and ui points scale.
///
/// This is the content rectangle of the viewport.
///
/// **`eframe` notes**:
///
/// On Android / Wayland, this will always be `None` since getting the
/// position of the window is not possible.
pub inner_rect: Option<Rect>,
/// The outer rectangle of the native window, in monitor space and ui points scale.
///
/// This is the content rectangle plus decoration chrome.
///
/// **`eframe` notes**:
///
/// On Android / Wayland, this will always be `None` since getting the
/// position of the window is not possible.
pub outer_rect: Option<Rect>,
/// Are we minimized?
@@ -943,6 +953,13 @@ impl std::ops::BitOr for Modifiers {
}
}
impl std::ops::BitOrAssign for Modifiers {
#[inline]
fn bitor_assign(&mut self, rhs: Self) {
*self = *self | rhs;
}
}
// ----------------------------------------------------------------------------
/// Names of different modifier keys.
@@ -986,7 +1003,7 @@ impl ModifierNames<'static> {
};
}
impl<'a> ModifierNames<'a> {
impl ModifierNames<'_> {
pub fn format(&self, modifiers: &Modifiers, is_mac: bool) -> String {
let mut s = String::new();
@@ -1084,7 +1101,7 @@ impl RawInput {
system_theme,
} = self;
ui.label(format!("Active viwport: {viewport_id:?}"));
ui.label(format!("Active viewport: {viewport_id:?}"));
for (id, viewport) in viewports {
ui.group(|ui| {
ui.label(format!("Viewport {id:?}"));

View File

@@ -49,12 +49,21 @@ pub enum Key {
/// `?`
Questionmark,
// '!'
Exclamationmark,
// `[`
OpenBracket,
// `]`
CloseBracket,
// `{`
OpenCurlyBracket,
// `}`
CloseCurlyBracket,
/// Also known as "backquote" or "grave"
Backtick,
@@ -215,11 +224,14 @@ impl Key {
Self::Semicolon,
Self::OpenBracket,
Self::CloseBracket,
Self::OpenCurlyBracket,
Self::CloseCurlyBracket,
Self::Backtick,
Self::Backslash,
Self::Slash,
Self::Pipe,
Self::Questionmark,
Self::Exclamationmark,
Self::Quote,
// Digits:
Self::Num0,
@@ -341,8 +353,11 @@ impl Key {
"/" | "Slash" => Self::Slash,
"|" | "Pipe" => Self::Pipe,
"?" | "Questionmark" => Self::Questionmark,
"!" | "Exclamationmark" => Self::Exclamationmark,
"[" | "OpenBracket" => Self::OpenBracket,
"]" | "CloseBracket" => Self::CloseBracket,
"{" | "OpenCurlyBracket" => Self::OpenCurlyBracket,
"}" | "CloseCurlyBracket" => Self::CloseCurlyBracket,
"`" | "Backtick" | "Backquote" | "Grave" => Self::Backtick,
"'" | "Quote" => Self::Quote,
@@ -446,8 +461,11 @@ impl Key {
Self::Slash => "/",
Self::Pipe => "|",
Self::Questionmark => "?",
Self::Exclamationmark => "!",
Self::OpenBracket => "[",
Self::CloseBracket => "]",
Self::OpenCurlyBracket => "{",
Self::CloseCurlyBracket => "}",
Self::Backtick => "`",
_ => self.name(),
@@ -490,8 +508,11 @@ impl Key {
Self::Slash => "Slash",
Self::Pipe => "Pipe",
Self::Questionmark => "Questionmark",
Self::Exclamationmark => "Exclamationmark",
Self::OpenBracket => "OpenBracket",
Self::CloseBracket => "CloseBracket",
Self::OpenCurlyBracket => "OpenCurlyBracket",
Self::CloseCurlyBracket => "CloseCurlyBracket",
Self::Backtick => "Backtick",
Self::Quote => "Quote",

View File

@@ -95,6 +95,9 @@ pub enum OutputCommand {
/// Open this url in a browser.
OpenUrl(OpenUrl),
/// Set the mouse cursor position (if the platform supports it).
SetPointerPosition(emath::Pos2),
}
/// The non-rendering part of what egui emits each frame.
@@ -112,7 +115,7 @@ pub struct PlatformOutput {
pub cursor_icon: CursorIcon,
/// If set, open this url.
#[deprecated = "Use `Context::open_url` instead"]
#[deprecated = "Use `Context::open_url` or `PlatformOutput::commands` instead"]
pub open_url: Option<OpenUrl>,
/// If set, put this text in the system clipboard. Ignore if empty.
@@ -126,7 +129,7 @@ pub struct PlatformOutput {
/// }
/// # });
/// ```
#[deprecated = "Use `Context::copy_text` instead"]
#[deprecated = "Use `Context::copy_text` or `PlatformOutput::commands` instead"]
pub copied_text: String,
/// Events that may be useful to e.g. a screen reader.
@@ -539,6 +542,9 @@ pub struct WidgetInfo {
/// Selected range of characters in [`Self::current_text_value`].
pub text_selection: Option<std::ops::RangeInclusive<usize>>,
/// The hint text for text edit fields.
pub hint_text: Option<String>,
}
impl std::fmt::Debug for WidgetInfo {
@@ -552,6 +558,7 @@ impl std::fmt::Debug for WidgetInfo {
selected,
value,
text_selection,
hint_text,
} = self;
let mut s = f.debug_struct("WidgetInfo");
@@ -580,6 +587,9 @@ impl std::fmt::Debug for WidgetInfo {
if let Some(text_selection) = text_selection {
s.field("text_selection", text_selection);
}
if let Some(hint_text) = hint_text {
s.field("hint_text", hint_text);
}
s.finish()
}
@@ -596,6 +606,7 @@ impl WidgetInfo {
selected: None,
value: None,
text_selection: None,
hint_text: None,
}
}
@@ -643,9 +654,11 @@ impl WidgetInfo {
enabled: bool,
prev_text_value: impl ToString,
text_value: impl ToString,
hint_text: impl ToString,
) -> Self {
let text_value = text_value.to_string();
let prev_text_value = prev_text_value.to_string();
let hint_text = hint_text.to_string();
let prev_text_value = if text_value == prev_text_value {
None
} else {
@@ -655,6 +668,7 @@ impl WidgetInfo {
enabled,
current_text_value: Some(text_value),
prev_text_value,
hint_text: Some(hint_text),
..Self::new(WidgetType::TextEdit)
}
}
@@ -684,6 +698,7 @@ impl WidgetInfo {
selected,
value,
text_selection: _,
hint_text: _,
} = self;
// TODO(emilk): localization

View File

@@ -54,7 +54,7 @@ impl<'de> serde::Deserialize<'de> for UserData {
{
struct UserDataVisitor;
impl<'de> serde::de::Visitor<'de> for UserDataVisitor {
impl serde::de::Visitor<'_> for UserDataVisitor {
type Value = UserData;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

View File

@@ -141,7 +141,7 @@ impl DragAndDrop {
pub fn has_any_payload(ctx: &Context) -> bool {
ctx.data(|data| {
let state = data.get_temp::<Self>(Id::NULL);
state.map_or(false, |state| state.payload.is_some())
state.is_some_and(|state| state.payload.is_some())
})
}
}

View File

@@ -208,7 +208,12 @@ impl GridLayout {
if (debug_expand_width && too_wide) || (debug_expand_height && too_high) {
let painter = self.ctx.debug_painter();
painter.rect_stroke(rect, 0.0, (1.0, Color32::LIGHT_BLUE));
painter.rect_stroke(
rect,
0.0,
(1.0, Color32::LIGHT_BLUE),
crate::StrokeKind::Inside,
);
let stroke = Stroke::new(2.5, Color32::from_rgb(200, 0, 0));
let paint_line_seg = |a, b| painter.line_segment([a, b], stroke);
@@ -451,7 +456,7 @@ impl Grid {
ui_builder = ui_builder.sizing_pass().invisible();
}
ui.allocate_new_ui(ui_builder, |ui| {
ui.scope_builder(ui_builder, |ui| {
ui.horizontal(|ui| {
let is_color = color_picker.is_some();
let grid = GridLayout {

View File

@@ -88,7 +88,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked()
{
zoom_in(ui.ctx());
ui.close_menu();
}
if ui
@@ -99,7 +98,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked()
{
zoom_out(ui.ctx());
ui.close_menu();
}
if ui
@@ -110,6 +108,5 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked()
{
ui.ctx().set_zoom_factor(1.0);
ui.close_menu();
}
}

View File

@@ -2,7 +2,7 @@ use ahash::HashMap;
use emath::TSTransform;
use crate::{ahash, emath, LayerId, Pos2, Rect, WidgetRect, WidgetRects};
use crate::{ahash, emath, id::IdSet, LayerId, Pos2, Rect, Sense, WidgetRect, WidgetRects};
/// Result of a hit-test against [`WidgetRects`].
///
@@ -128,11 +128,28 @@ pub fn hit_test(
// the `enabled` flag everywhere:
for w in &mut close {
if !w.enabled {
w.sense.click = false;
w.sense.drag = false;
w.sense -= Sense::CLICK;
w.sense -= Sense::DRAG;
}
}
// Find widgets which are hidden behind another widget and discard them.
// This is the case when a widget fully contains another widget and is on a different layer.
// It prevents "hovering through" widgets when there is a clickable widget behind.
let mut hidden = IdSet::default();
for (i, current) in close.iter().enumerate().rev() {
for next in &close[i + 1..] {
if next.interact_rect.contains_rect(current.interact_rect)
&& current.layer_id != next.layer_id
{
hidden.insert(current.id);
}
}
}
close.retain(|c| !hidden.contains(&c.id));
let mut hits = hit_test_on_close(&close, pos);
hits.contains_pointer = close
@@ -158,11 +175,17 @@ pub fn hit_test(
restore_widget_rect(wr);
}
if let Some(wr) = &mut hits.drag {
debug_assert!(wr.sense.drag);
debug_assert!(
wr.sense.senses_drag(),
"We should only return drag hits if they sense drag"
);
restore_widget_rect(wr);
}
if let Some(wr) = &mut hits.click {
debug_assert!(wr.sense.click);
debug_assert!(
wr.sense.senses_click(),
"We should only return click hits if they sense click"
);
restore_widget_rect(wr);
}
}
@@ -179,8 +202,16 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
#![allow(clippy::collapsible_else_if)]
// First find the best direct hits:
let hit_click = find_closest_within(close.iter().copied().filter(|w| w.sense.click), pos, 0.0);
let hit_drag = find_closest_within(close.iter().copied().filter(|w| w.sense.drag), pos, 0.0);
let hit_click = find_closest_within(
close.iter().copied().filter(|w| w.sense.senses_click()),
pos,
0.0,
);
let hit_drag = find_closest_within(
close.iter().copied().filter(|w| w.sense.senses_drag()),
pos,
0.0,
);
match (hit_click, hit_drag) {
(None, None) => {
@@ -190,14 +221,14 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
close
.iter()
.copied()
.filter(|w| w.sense.click || w.sense.drag),
.filter(|w| w.sense.senses_click() || w.sense.senses_drag()),
pos,
);
if let Some(closest) = closest {
WidgetHits {
click: closest.sense.click.then_some(closest),
drag: closest.sense.drag.then_some(closest),
click: closest.sense.senses_click().then_some(closest),
drag: closest.sense.senses_drag().then_some(closest),
..Default::default()
}
} else {
@@ -218,9 +249,12 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
// or a moveable window.
// It could also be something small, like a slider, or panel resize handle.
let closest_click = find_closest(close.iter().copied().filter(|w| w.sense.click), pos);
let closest_click = find_closest(
close.iter().copied().filter(|w| w.sense.senses_click()),
pos,
);
if let Some(closest_click) = closest_click {
if closest_click.sense.drag {
if closest_click.sense.senses_drag() {
// We have something close that sense both clicks and drag.
// Should we use it over the direct drag-hit?
if hit_drag
@@ -244,7 +278,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
}
}
} else {
// These is a close pure-click widget.
// This is a close pure-click widget.
// However, we should be careful to only return two different widgets
// when it is absolutely not going to confuse the user.
if hit_drag
@@ -277,7 +311,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
close
.iter()
.copied()
.filter(|w| w.sense.drag && w.id != hit_drag.id),
.filter(|w| w.sense.senses_drag() && w.id != hit_drag.id),
pos,
);
@@ -331,7 +365,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
let click_is_on_top_of_drag = drag_idx < click_idx;
if click_is_on_top_of_drag {
if hit_click.sense.drag {
if hit_click.sense.senses_drag() {
// The top thing senses both clicks and drags.
WidgetHits {
click: Some(hit_click),
@@ -349,7 +383,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
}
}
} else {
if hit_drag.sense.click {
if hit_drag.sense.senses_click() {
// The top thing senses both clicks and drags.
WidgetHits {
click: Some(hit_drag),
@@ -393,7 +427,7 @@ fn find_closest_within(
if dist_sq == closest_dist_sq {
// It's a tie! Pick the thin candidate over the thick one.
// This makes it easier to hit a thin resize-handle, for instance:
if should_prioritizie_hits_on_back(closest.interact_rect, widget.interact_rect) {
if should_prioritize_hits_on_back(closest.interact_rect, widget.interact_rect) {
continue;
}
}
@@ -409,12 +443,12 @@ fn find_closest_within(
closest
}
/// Should we prioritizie hits on `back` over those on `front`?
/// Should we prioritize hits on `back` over those on `front`?
///
/// `back` should be behind the `front` widget.
///
/// Returns true if `back` is a small hit-target and `front` is not.
fn should_prioritizie_hits_on_back(back: Rect, front: Rect) -> bool {
fn should_prioritize_hits_on_back(back: Rect, front: Rect) -> bool {
if front.contains_rect(back) {
return false; // back widget is fully occluded; no way to hit it
}
@@ -484,7 +518,7 @@ mod tests {
assert_eq!(hits.click.unwrap().id, Id::new("click-and-drag"));
assert_eq!(hits.drag.unwrap().id, Id::new("click-and-drag"));
// Close hit - should still ignore the drag-background so as not to confuse the userr:
// Close hit - should still ignore the drag-background so as not to confuse the user:
let hits = hit_test_on_close(&widgets, pos2(105.0, 5.0));
assert_eq!(hits.click.unwrap().id, Id::new("click-and-drag"));
assert_eq!(hits.drag.unwrap().id, Id::new("click-and-drag"));

View File

@@ -33,6 +33,8 @@ use std::num::NonZeroU64;
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Id(NonZeroU64);
impl nohash_hasher::IsEnabled for Id {}
impl Id {
/// A special [`Id`], in particular as a key to [`crate::Memory::data`]
/// for when there is no particular widget to attach the data.
@@ -112,77 +114,8 @@ fn id_size() {
// ----------------------------------------------------------------------------
// Idea taken from the `nohash_hasher` crate.
#[derive(Default)]
pub struct IdHasher(u64);
impl std::hash::Hasher for IdHasher {
fn write(&mut self, _: &[u8]) {
unreachable!("Invalid use of IdHasher");
}
fn write_u8(&mut self, _n: u8) {
unreachable!("Invalid use of IdHasher");
}
fn write_u16(&mut self, _n: u16) {
unreachable!("Invalid use of IdHasher");
}
fn write_u32(&mut self, _n: u32) {
unreachable!("Invalid use of IdHasher");
}
#[inline(always)]
fn write_u64(&mut self, n: u64) {
self.0 = n;
}
fn write_usize(&mut self, _n: usize) {
unreachable!("Invalid use of IdHasher");
}
fn write_i8(&mut self, _n: i8) {
unreachable!("Invalid use of IdHasher");
}
fn write_i16(&mut self, _n: i16) {
unreachable!("Invalid use of IdHasher");
}
fn write_i32(&mut self, _n: i32) {
unreachable!("Invalid use of IdHasher");
}
fn write_i64(&mut self, _n: i64) {
unreachable!("Invalid use of IdHasher");
}
fn write_isize(&mut self, _n: isize) {
unreachable!("Invalid use of IdHasher");
}
#[inline(always)]
fn finish(&self) -> u64 {
self.0
}
}
#[derive(Copy, Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct BuildIdHasher {}
impl std::hash::BuildHasher for BuildIdHasher {
type Hasher = IdHasher;
#[inline(always)]
fn build_hasher(&self) -> IdHasher {
IdHasher::default()
}
}
/// `IdSet` is a `HashSet<Id>` optimized by knowing that [`Id`] has good entropy, and doesn't need more hashing.
pub type IdSet = std::collections::HashSet<Id, BuildIdHasher>;
pub type IdSet = nohash_hasher::IntSet<Id>;
/// `IdMap<V>` is a `HashMap<Id, V>` optimized by knowing that [`Id`] has good entropy, and doesn't need more hashing.
pub type IdMap<V> = std::collections::HashMap<Id, V, BuildIdHasher>;
pub type IdMap<V> = nohash_hasher::IntMap<Id, V>;

View File

@@ -362,6 +362,14 @@ impl InputState {
Event::Zoom(factor) => {
zoom_factor_delta *= *factor;
}
Event::WindowFocused(false) => {
// Example: pressing `Cmd+S` brings up a save-dialog (e.g. using rfd),
// but we get no key-up event for the `S` key (in winit).
// This leads to `S` being mistakenly marked as down when we switch back to the app.
// So we take the safe route and just clear all the keys and modifiers when
// the app loses focus.
keys_down.clear();
}
_ => {}
}
}
@@ -919,6 +927,7 @@ impl PointerState {
self.motion = Some(Vec2::ZERO);
}
let mut clear_history_after_velocity_calculation = false;
for event in &new.events {
match event {
Event::PointerMoved(pos) => {
@@ -1005,7 +1014,10 @@ impl PointerState {
// When dragging a slider and the mouse leaves the viewport, we still want the drag to work,
// so we don't treat this as a `PointerEvent::Released`.
// NOTE: we do NOT clear `self.interact_pos` here. It will be cleared next frame.
self.pos_history.clear();
// Delay the clearing until after the final velocity calculation, so we can
// get the final velocity when `drag_stopped` is true.
clear_history_after_velocity_calculation = true;
}
Event::MouseMoved(delta) => *self.motion.get_or_insert(Vec2::ZERO) += *delta,
_ => {}
@@ -1036,6 +1048,9 @@ impl PointerState {
if self.velocity != Vec2::ZERO {
self.last_move_time = time;
}
if clear_history_after_velocity_calculation {
self.pos_history.clear();
}
self.direction = self.pos_history.velocity().unwrap_or_default().normalized();
@@ -1303,7 +1318,7 @@ impl PointerState {
self.started_decidedly_dragging
&& !self.has_moved_too_much_for_a_click
&& self.button_down(PointerButton::Primary)
&& self.press_start_time.map_or(false, |press_start_time| {
&& self.press_start_time.is_some_and(|press_start_time| {
self.time - press_start_time > self.input_options.max_click_duration
})
}
@@ -1325,6 +1340,18 @@ impl PointerState {
pub fn middle_down(&self) -> bool {
self.button_down(PointerButton::Middle)
}
/// Is the mouse moving in the direction of the given rect?
pub fn is_moving_towards_rect(&self, rect: &Rect) -> bool {
if self.is_still() {
return false;
}
if let Some(pos) = self.hover_pos() {
return rect.intersects_ray(pos, self.direction());
}
false
}
}
impl InputState {

View File

@@ -192,14 +192,14 @@ pub(crate) fn interact(
// Check if we started dragging something new:
if let Some(widget) = interaction.potential_drag_id.and_then(|id| widgets.get(id)) {
if widget.enabled {
let is_dragged = if widget.sense.click && widget.sense.drag {
let is_dragged = if widget.sense.senses_click() && widget.sense.senses_drag() {
// This widget is sensitive to both clicks and drags.
// When the mouse first is pressed, it could be either,
// so we postpone the decision until we know.
input.pointer.is_decidedly_dragging()
} else {
// This widget is just sensitive to drags, so we can mark it as dragged right away:
widget.sense.drag
widget.sense.senses_drag()
};
if is_dragged {
@@ -271,7 +271,7 @@ pub(crate) fn interact(
let mut hovered: IdSet = hits.click.iter().chain(&hits.drag).map(|w| w.id).collect();
for w in &hits.contains_pointer {
let is_interactive = w.sense.click || w.sense.drag;
let is_interactive = w.sense.senses_click() || w.sense.senses_drag();
if is_interactive {
// The only interactive widgets we mark as hovered are the ones
// in `hits.click` and `hits.drag`!

View File

@@ -160,7 +160,7 @@ impl Widget for &mut epaint::TessellationOptions {
.on_hover_text("Apply feathering to smooth out the edges of shapes. Turn off for small performance gain.");
if *feathering {
ui.add(crate::DragValue::new(feathering_size_in_pixels).range(0.0..=10.0).speed(0.1).suffix(" px"));
ui.add(crate::DragValue::new(feathering_size_in_pixels).range(0.0..=10.0).speed(0.025).suffix(" px"));
}
});

View File

@@ -71,9 +71,17 @@ impl Region {
}
pub fn sanity_check(&self) {
debug_assert!(!self.min_rect.any_nan());
debug_assert!(!self.max_rect.any_nan());
debug_assert!(!self.cursor.any_nan());
debug_assert!(
!self.min_rect.any_nan(),
"min rect has Nan: {:?}",
self.min_rect
);
debug_assert!(
!self.max_rect.any_nan(),
"max rect has Nan: {:?}",
self.max_rect
);
debug_assert!(!self.cursor.any_nan(), "cursor has Nan: {:?}", self.cursor);
}
}
@@ -394,8 +402,8 @@ impl Layout {
/// ## Doing layout
impl Layout {
pub fn align_size_within_rect(&self, size: Vec2, outer: Rect) -> Rect {
debug_assert!(size.x >= 0.0 && size.y >= 0.0);
debug_assert!(!outer.is_negative());
debug_assert!(size.x >= 0.0 && size.y >= 0.0, "Negative size: {size:?}");
debug_assert!(!outer.is_negative(), "Negative outer: {outer:?}");
self.align2().align_size_within_rect(size, outer).round_ui()
}
@@ -421,7 +429,7 @@ impl Layout {
}
pub(crate) fn region_from_max_rect(&self, max_rect: Rect) -> Region {
debug_assert!(!max_rect.any_nan());
debug_assert!(!max_rect.any_nan(), "max_rect is not NaN: {max_rect:?}");
let mut region = Region {
min_rect: Rect::NOTHING, // temporary
max_rect,
@@ -454,8 +462,8 @@ impl Layout {
/// Given the cursor in the region, how much space is available
/// for the next widget?
fn available_from_cursor_max_rect(&self, cursor: Rect, max_rect: Rect) -> Rect {
debug_assert!(!cursor.any_nan());
debug_assert!(!max_rect.any_nan());
debug_assert!(!cursor.any_nan(), "cursor is NaN: {cursor:?}");
debug_assert!(!max_rect.any_nan(), "max_rect is NaN: {max_rect:?}");
// NOTE: in normal top-down layout the cursor has moved below the current max_rect,
// but the available shouldn't be negative.
@@ -509,7 +517,7 @@ impl Layout {
avail.max.y = y;
}
debug_assert!(!avail.any_nan());
debug_assert!(!avail.any_nan(), "avail is NaN: {avail:?}");
avail
}
@@ -520,7 +528,10 @@ impl Layout {
/// Use `justify_and_align` to get the inner `widget_rect`.
pub(crate) fn next_frame(&self, region: &Region, child_size: Vec2, spacing: Vec2) -> Rect {
region.sanity_check();
debug_assert!(child_size.x >= 0.0 && child_size.y >= 0.0);
debug_assert!(
child_size.x >= 0.0 && child_size.y >= 0.0,
"Negative size: {child_size:?}"
);
if self.main_wrap {
let available_size = self.available_rect_before_wrap(region).size();
@@ -600,7 +611,10 @@ impl Layout {
fn next_frame_ignore_wrap(&self, region: &Region, child_size: Vec2) -> Rect {
region.sanity_check();
debug_assert!(child_size.x >= 0.0 && child_size.y >= 0.0);
debug_assert!(
child_size.x >= 0.0 && child_size.y >= 0.0,
"Negative size: {child_size:?}"
);
let available_rect = self.available_rect_before_wrap(region);
@@ -633,16 +647,19 @@ impl Layout {
frame_rect = frame_rect.translate(Vec2::Y * (region.cursor.top() - frame_rect.top()));
}
debug_assert!(!frame_rect.any_nan());
debug_assert!(!frame_rect.is_negative());
debug_assert!(!frame_rect.any_nan(), "frame_rect is NaN: {frame_rect:?}");
debug_assert!(!frame_rect.is_negative(), "frame_rect is negative");
frame_rect.round_ui()
}
/// Apply justify (fill width/height) and/or alignment after calling `next_space`.
pub(crate) fn justify_and_align(&self, frame: Rect, mut child_size: Vec2) -> Rect {
debug_assert!(child_size.x >= 0.0 && child_size.y >= 0.0);
debug_assert!(!frame.is_negative());
debug_assert!(
child_size.x >= 0.0 && child_size.y >= 0.0,
"Negative size: {child_size:?}"
);
debug_assert!(!frame.is_negative(), "frame is negative");
if self.horizontal_justify() {
child_size.x = child_size.x.at_least(frame.width()); // fill full width
@@ -660,8 +677,8 @@ impl Layout {
) -> Rect {
let frame = self.next_frame_ignore_wrap(region, size);
let rect = self.align_size_within_rect(size, frame);
debug_assert!(!rect.any_nan());
debug_assert!(!rect.is_negative());
debug_assert!(!rect.any_nan(), "rect is NaN: {rect:?}");
debug_assert!(!rect.is_negative(), "rect is negative: {rect:?}");
rect
}
@@ -704,7 +721,7 @@ impl Layout {
widget_rect: Rect,
item_spacing: Vec2,
) {
debug_assert!(!cursor.any_nan());
debug_assert!(!cursor.any_nan(), "cursor is NaN: {cursor:?}");
if self.main_wrap {
if cursor.intersects(frame_rect.shrink(1.0)) {
// make row/column larger if necessary

View File

@@ -3,7 +3,7 @@
//! Try the live web demo: <https://www.egui.rs/#demo>. Read more about egui at <https://github.com/emilk/egui>.
//!
//! `egui` is in heavy development, with each new version having breaking changes.
//! You need to have rust 1.80.0 or later to use `egui`.
//! You need to have rust 1.81.0 or later to use `egui`.
//!
//! To quickly get started with egui, you can take a look at [`eframe_template`](https://github.com/emilk/eframe_template)
//! which uses [`eframe`](https://docs.rs/eframe).
@@ -422,12 +422,13 @@ pub mod layers;
mod layout;
pub mod load;
mod memory;
#[deprecated = "Use `egui::containers::menu` instead"]
pub mod menu;
pub mod os;
mod painter;
mod pass_state;
pub(crate) mod placer;
mod response;
pub mod response;
mod sense;
pub mod style;
pub mod text_selection;
@@ -458,18 +459,19 @@ pub use epaint::emath;
pub use ecolor::hex_color;
pub use ecolor::{Color32, Rgba};
pub use emath::{
lerp, pos2, remap, remap_clamp, vec2, Align, Align2, NumExt, Pos2, Rangef, Rect, Vec2, Vec2b,
lerp, pos2, remap, remap_clamp, vec2, Align, Align2, NumExt, Pos2, Rangef, Rect, RectAlign,
Vec2, Vec2b,
};
pub use epaint::{
mutex,
text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak},
textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta},
ClippedPrimitive, ColorImage, FontImage, ImageData, Margin, Mesh, PaintCallback,
PaintCallbackInfo, Rounding, Shadow, Shape, Stroke, TextureHandle, TextureId,
ClippedPrimitive, ColorImage, CornerRadius, FontImage, ImageData, Margin, Mesh, PaintCallback,
PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId,
};
pub mod text {
pub use crate::text_selection::{CCursorRange, CursorRange};
pub use crate::text_selection::CCursorRange;
pub use epaint::text::{
cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob,
LayoutSection, TextFormat, TextWrapping, TAB_SIZE,
@@ -510,6 +512,9 @@ pub use self::{
widgets::*,
};
#[deprecated = "Renamed to CornerRadius"]
pub type Rounding = CornerRadius;
// ----------------------------------------------------------------------------
/// Helper function that adds a label when compiling with debug assertions enabled.
@@ -538,7 +543,7 @@ pub fn warn_if_debug_build(ui: &mut crate::Ui) {
/// ui.add(
/// egui::Image::new(egui::include_image!("../assets/ferris.png"))
/// .max_width(200.0)
/// .rounding(10.0),
/// .corner_radius(10),
/// );
///
/// let image_source: egui::ImageSource = egui::include_image!("../assets/ferris.png");
@@ -625,9 +630,6 @@ pub mod special_emojis {
/// The Github logo.
pub const GITHUB: char = '';
/// The Twitter bird.
pub const TWITTER: char = '';
/// The word `git`.
pub const GIT: char = '';

View File

@@ -1,3 +1,5 @@
use std::borrow::Cow;
use super::{
BytesLoader, Context, HashMap, ImagePoll, Mutex, SizeHint, SizedTexture, TextureHandle,
TextureLoadResult, TextureLoader, TextureOptions, TexturePoll,
@@ -5,7 +7,7 @@ use super::{
#[derive(Default)]
pub struct DefaultTextureLoader {
cache: Mutex<HashMap<(String, TextureOptions), TextureHandle>>,
cache: Mutex<HashMap<(Cow<'static, str>, TextureOptions), TextureHandle>>,
}
impl TextureLoader for DefaultTextureLoader {
@@ -21,7 +23,7 @@ impl TextureLoader for DefaultTextureLoader {
size_hint: SizeHint,
) -> TextureLoadResult {
let mut cache = self.cache.lock();
if let Some(handle) = cache.get(&(uri.into(), texture_options)) {
if let Some(handle) = cache.get(&(Cow::Borrowed(uri), texture_options)) {
let texture = SizedTexture::from_handle(handle);
Ok(TexturePoll::Ready { texture })
} else {
@@ -30,7 +32,7 @@ impl TextureLoader for DefaultTextureLoader {
ImagePoll::Ready { image } => {
let handle = ctx.load_texture(uri, image, texture_options);
let texture = SizedTexture::from_handle(&handle);
cache.insert((uri.into(), texture_options), handle);
cache.insert((Cow::Owned(uri.to_owned()), texture_options), handle);
let reduce_texture_memory = ctx.options(|o| o.reduce_texture_memory);
if reduce_texture_memory {
let loaders = ctx.loaders();

View File

@@ -89,8 +89,12 @@ pub struct Memory {
/// Which popup-window is open (if any)?
/// Could be a combo box, color picker, menu, etc.
/// Optionally stores the position of the popup (usually this would be the position where
/// the user clicked).
/// If position is [`None`], the popup position will be calculated based on some configuration
/// (e.g. relative to some other widget).
#[cfg_attr(feature = "persistence", serde(skip))]
popup: Option<Id>,
popup: Option<OpenPopup>,
#[cfg_attr(feature = "persistence", serde(skip))]
everything_is_visible: bool,
@@ -318,7 +322,7 @@ impl Default for Options {
Self {
dark_style: std::sync::Arc::new(Theme::Dark.default_style()),
light_style: std::sync::Arc::new(Theme::Light.default_style()),
theme_preference: ThemePreference::System,
theme_preference: Default::default(),
fallback_theme: Theme::Dark,
system_theme: None,
zoom_factor: 1.0,
@@ -803,6 +807,15 @@ impl Memory {
self.caches.update();
self.areas_mut().end_pass();
self.focus_mut().end_pass(used_ids);
// Clean up abandoned popups.
if let Some(popup) = &mut self.popup {
if popup.open_this_frame {
popup.open_this_frame = false;
} else {
self.popup = None;
}
}
}
pub(crate) fn set_viewport_id(&mut self, viewport_id: ViewportId) {
@@ -1064,13 +1077,37 @@ impl Memory {
}
}
/// State of an open popup.
#[derive(Clone, Copy, Debug)]
struct OpenPopup {
/// Id of the popup.
id: Id,
/// Optional position of the popup.
pos: Option<Pos2>,
/// Whether this popup was still open this frame. Otherwise it's considered abandoned and `Memory::popup` will be cleared.
open_this_frame: bool,
}
impl OpenPopup {
/// Create a new `OpenPopup`.
fn new(id: Id, pos: Option<Pos2>) -> Self {
Self {
id,
pos,
open_this_frame: true,
}
}
}
/// ## Popups
/// Popups are things like combo-boxes, color pickers, menus etc.
/// Only one can be open at a time.
impl Memory {
/// Is the given popup open?
pub fn is_popup_open(&self, popup_id: Id) -> bool {
self.popup == Some(popup_id) || self.everything_is_visible()
self.popup.is_some_and(|state| state.id == popup_id) || self.everything_is_visible()
}
/// Is any popup open?
@@ -1079,21 +1116,56 @@ impl Memory {
}
/// Open the given popup and close all others.
///
/// Note that you must call `keep_popup_open` on subsequent frames as long as the popup is open.
pub fn open_popup(&mut self, popup_id: Id) {
self.popup = Some(popup_id);
self.popup = Some(OpenPopup::new(popup_id, None));
}
/// Close the open popup, if any.
pub fn close_popup(&mut self) {
/// Popups must call this every frame while open.
///
/// This is needed because in some cases popups can go away without `close_popup` being
/// called. For example, when a context menu is open and the underlying widget stops
/// being rendered.
pub fn keep_popup_open(&mut self, popup_id: Id) {
if let Some(state) = self.popup.as_mut() {
if state.id == popup_id {
state.open_this_frame = true;
}
}
}
/// Open the popup and remember its position.
pub fn open_popup_at(&mut self, popup_id: Id, pos: impl Into<Option<Pos2>>) {
self.popup = Some(OpenPopup::new(popup_id, pos.into()));
}
/// Get the position for this popup.
pub fn popup_position(&self, id: Id) -> Option<Pos2> {
self.popup
.and_then(|state| if state.id == id { state.pos } else { None })
}
/// Close any currently open popup.
pub fn close_all_popups(&mut self) {
self.popup = None;
}
/// Close the given popup, if it is open.
///
/// See also [`Self::close_all_popups`] if you want to close any / all currently open popups.
pub fn close_popup(&mut self, popup_id: Id) {
if self.is_popup_open(popup_id) {
self.popup = None;
}
}
/// Toggle the given popup between closed and open.
///
/// Note: At most, only one popup can be open at a time.
pub fn toggle_popup(&mut self, popup_id: Id) {
if self.is_popup_open(popup_id) {
self.close_popup();
self.close_popup(popup_id);
} else {
self.open_popup(popup_id);
}
@@ -1175,17 +1247,19 @@ impl Areas {
///
/// May return [`std::cmp::Ordering::Equal`] if the layers are not in the order list.
pub(crate) fn compare_order(&self, a: LayerId, b: LayerId) -> std::cmp::Ordering {
if let (Some(a), Some(b)) = (self.order_map.get(&a), self.order_map.get(&b)) {
a.cmp(b)
} else {
a.order.cmp(&b.order)
// Sort by layer `order` first and use `order_map` to resolve disputes.
// If `order_map` only contains one layer ID, then the other one will be
// lower because `None < Some(x)`.
match a.order.cmp(&b.order) {
std::cmp::Ordering::Equal => self.order_map.get(&a).cmp(&self.order_map.get(&b)),
cmp => cmp,
}
}
pub(crate) fn set_state(&mut self, layer_id: LayerId, state: area::AreaState) {
self.visible_areas_current_frame.insert(layer_id);
self.areas.insert(layer_id.id, state);
if !self.order.iter().any(|x| *x == layer_id) {
if !self.order.contains(&layer_id) {
self.order.push(layer_id);
}
}
@@ -1351,3 +1425,47 @@ fn memory_impl_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Memory>();
}
#[test]
fn order_map_total_ordering() {
let mut layers = [
LayerId::new(Order::Tooltip, Id::new("a")),
LayerId::new(Order::Background, Id::new("b")),
LayerId::new(Order::Background, Id::new("c")),
LayerId::new(Order::Tooltip, Id::new("d")),
LayerId::new(Order::Background, Id::new("e")),
LayerId::new(Order::Background, Id::new("f")),
LayerId::new(Order::Tooltip, Id::new("g")),
];
let mut areas = Areas::default();
// skip some of the layers
for &layer in &layers[3..] {
areas.set_state(layer, crate::AreaState::default());
}
areas.end_pass(); // sort layers
// Sort layers
layers.sort_by(|&a, &b| areas.compare_order(a, b));
// Assert that `areas.compare_order()` forms a total ordering
let mut equivalence_classes = vec![0];
let mut i = 0;
for l in layers.windows(2) {
assert!(l[0].order <= l[1].order, "does not follow LayerId.order");
if areas.compare_order(l[0], l[1]) != std::cmp::Ordering::Equal {
i += 1;
}
equivalence_classes.push(i);
}
assert_eq!(layers.len(), equivalence_classes.len());
for (&l1, c1) in std::iter::zip(&layers, &equivalence_classes) {
for (&l2, c2) in std::iter::zip(&layers, &equivalence_classes) {
assert_eq!(
c1.cmp(c2),
areas.compare_order(l1, l2),
"not a total ordering",
);
}
}
}

View File

@@ -66,7 +66,7 @@ impl Theme {
}
/// The user's theme preference.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum ThemePreference {
/// Dark mode: light text on a dark background.
@@ -76,6 +76,7 @@ pub enum ThemePreference {
Light,
/// Follow the system's theme preference.
#[default]
System,
}

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
//! Menu bar functionality (very basic so far).
//!
//! Usage:
@@ -75,11 +76,13 @@ impl std::ops::DerefMut for BarState {
}
fn set_menu_style(style: &mut Style) {
style.spacing.button_padding = vec2(2.0, 0.0);
style.visuals.widgets.active.bg_stroke = Stroke::NONE;
style.visuals.widgets.hovered.bg_stroke = Stroke::NONE;
style.visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
style.visuals.widgets.inactive.bg_stroke = Stroke::NONE;
if style.compact_menu_style {
style.spacing.button_padding = vec2(2.0, 0.0);
style.visuals.widgets.active.bg_stroke = Stroke::NONE;
style.visuals.widgets.hovered.bg_stroke = Stroke::NONE;
style.visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
style.visuals.widgets.inactive.bg_stroke = Stroke::NONE;
}
}
/// The menu bar goes well in a [`crate::TopBottomPanel::top`],
@@ -146,7 +149,7 @@ pub fn menu_image_button<R>(
/// Opens on hover.
///
/// Returns `None` if the menu is not open.
pub(crate) fn submenu_button<R>(
pub fn submenu_button<R>(
ui: &mut Ui,
parent_state: Arc<RwLock<MenuState>>,
title: impl Into<WidgetText>,
@@ -267,7 +270,7 @@ fn stationary_menu_button_impl<'c, R>(
pub(crate) const CONTEXT_MENU_ID_STR: &str = "__egui::context_menu";
/// Response to secondary clicks (right-clicks) by showing the given menu.
pub(crate) fn context_menu(
pub fn context_menu(
response: &Response,
add_contents: impl FnOnce(&mut Ui),
) -> Option<InnerResponse<()>> {
@@ -282,7 +285,7 @@ pub(crate) fn context_menu(
}
/// Returns `true` if the context menu is opened for this widget.
pub(crate) fn context_menu_opened(response: &Response) -> bool {
pub fn context_menu_opened(response: &Response) -> bool {
let menu_id = Id::new(CONTEXT_MENU_ID_STR);
let bar_state = BarState::load(&response.ctx, menu_id);
bar_state.is_menu_open(response.id)
@@ -364,7 +367,10 @@ impl MenuRoot {
let menu_state = self.menu_state.read();
let escape_pressed = button.ctx.input(|i| i.key_pressed(Key::Escape));
if menu_state.response.is_close() || escape_pressed {
if menu_state.response.is_close()
|| escape_pressed
|| inner_response.response.should_close()
{
return (MenuResponse::Close, Some(inner_response));
}
}
@@ -580,7 +586,7 @@ impl SubMenuButton {
if ui.visuals().button_frame {
ui.painter().rect_filled(
rect.expand(visuals.expansion),
visuals.rounding,
visuals.corner_radius,
visuals.weak_bg_fill,
);
}
@@ -667,6 +673,9 @@ impl MenuState {
) -> Option<R> {
let (sub_response, response) = self.submenu(id).map(|sub| {
let inner_response = menu_popup(ctx, parent_layer, sub, id, add_contents);
if inner_response.response.should_close() {
sub.write().close();
}
(sub.read().response, inner_response.inner)
})?;
self.cascade_close_response(sub_response);
@@ -679,7 +688,7 @@ impl MenuState {
|| self
.sub_menu
.as_ref()
.map_or(false, |(_, sub)| sub.read().area_contains(pos))
.is_some_and(|(_, sub)| sub.read().area_contains(pos))
}
fn next_entry_index(&mut self) -> usize {

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use emath::GuiRounding as _;
use epaint::{
text::{Fonts, Galley, LayoutJob},
CircleShape, ClippedShape, PathStroke, RectShape, Rounding, Shape, Stroke,
CircleShape, ClippedShape, CornerRadius, PathStroke, RectShape, Shape, Stroke, StrokeKind,
};
use crate::{
@@ -301,6 +301,7 @@ impl Painter {
0.0,
color.additive().linear_multiply(0.015),
(1.0, color),
StrokeKind::Outside,
);
self.text(
rect.min,
@@ -407,34 +408,41 @@ impl Painter {
})
}
/// The stroke extends _outside_ the [`Rect`].
/// See also [`Self::rect_filled`] and [`Self::rect_stroke`].
pub fn rect(
&self,
rect: Rect,
rounding: impl Into<Rounding>,
corner_radius: impl Into<CornerRadius>,
fill_color: impl Into<Color32>,
stroke: impl Into<Stroke>,
stroke_kind: StrokeKind,
) -> ShapeIdx {
self.add(RectShape::new(rect, rounding, fill_color, stroke))
self.add(RectShape::new(
rect,
corner_radius,
fill_color,
stroke,
stroke_kind,
))
}
pub fn rect_filled(
&self,
rect: Rect,
rounding: impl Into<Rounding>,
corner_radius: impl Into<CornerRadius>,
fill_color: impl Into<Color32>,
) -> ShapeIdx {
self.add(RectShape::filled(rect, rounding, fill_color))
self.add(RectShape::filled(rect, corner_radius, fill_color))
}
/// The stroke extends _outside_ the [`Rect`].
pub fn rect_stroke(
&self,
rect: Rect,
rounding: impl Into<Rounding>,
corner_radius: impl Into<CornerRadius>,
stroke: impl Into<Stroke>,
stroke_kind: StrokeKind,
) -> ShapeIdx {
self.add(RectShape::stroke(rect, rounding, stroke))
self.add(RectShape::stroke(rect, corner_radius, stroke, stroke_kind))
}
/// Show an arrow starting at `origin` and going in the direction of `vec`, with the length `vec.length()`.
@@ -463,7 +471,7 @@ impl Painter {
/// # egui::__run_test_ui(|ui| {
/// # let rect = egui::Rect::from_min_size(Default::default(), egui::Vec2::splat(100.0));
/// egui::Image::new(egui::include_image!("../assets/ferris.png"))
/// .rounding(5.0)
/// .corner_radius(5)
/// .tint(egui::Color32::LIGHT_BLUE)
/// .paint_at(ui, rect);
/// # });

View File

@@ -1,4 +1,4 @@
use ahash::{HashMap, HashSet};
use ahash::HashMap;
use crate::{id::IdSet, style, Align, Id, IdMap, LayerId, Rangef, Rect, Vec2, WidgetRects};
@@ -34,7 +34,7 @@ pub struct PerLayerState {
/// Is there any open popup (menus, combo-boxes, etc)?
///
/// Does NOT include tooltips.
pub open_popups: HashSet<Id>,
pub open_popups: IdSet,
/// Which widget is showing a tooltip (if any)?
///
@@ -121,7 +121,13 @@ impl DebugRect {
Color32::LIGHT_BLUE
};
let rect_bg_color = Color32::BLUE.gamma_multiply(0.5);
painter.rect(rect, 0.0, rect_bg_color, (1.0, rect_fg_color));
painter.rect(
rect,
0.0,
rect_bg_color,
(1.0, rect_fg_color),
crate::StrokeKind::Outside,
);
}
if !callstack.is_empty() {
@@ -157,7 +163,13 @@ impl DebugRect {
text_bg_color
};
let text_rect = Rect::from_min_size(text_pos, galley.size());
painter.rect(text_rect, 0.0, text_bg_color, (1.0, text_rect_stroke_color));
painter.rect(
text_rect,
0.0,
text_bg_color,
(1.0, text_rect_stroke_color),
crate::StrokeKind::Middle,
);
painter.galley(text_pos, galley, text_color);
if is_clicking {
@@ -190,7 +202,6 @@ pub struct PassState {
/// Starts off as the `screen_rect`, shrinks as panels are added.
/// The [`crate::CentralPanel`] does not change this.
/// This is the area available to Window's.
pub available_rect: Rect,
/// Starts off as the `screen_rect`, shrinks as panels are added.
@@ -291,8 +302,6 @@ impl PassState {
}
/// How much space is still available after panels has been added.
/// This is the "background" area, what egui doesn't cover with panels (but may cover with windows).
/// This is also the area to which windows are constrained.
pub(crate) fn available_rect(&self) -> Rect {
debug_assert!(
self.available_rect.is_finite(),

View File

@@ -133,8 +133,8 @@ impl Placer {
/// Apply justify or alignment after calling `next_space`.
pub(crate) fn justify_and_align(&self, rect: Rect, child_size: Vec2) -> Rect {
debug_assert!(!rect.any_nan());
debug_assert!(!child_size.any_nan());
debug_assert!(!rect.any_nan(), "rect: {rect:?}");
debug_assert!(!child_size.any_nan(), "child_size is NaN: {child_size:?}");
if let Some(grid) = &self.grid {
grid.justify_and_align(rect, child_size)
@@ -164,8 +164,11 @@ impl Placer {
widget_rect: Rect,
item_spacing: Vec2,
) {
debug_assert!(!frame_rect.any_nan());
debug_assert!(!widget_rect.any_nan());
debug_assert!(!frame_rect.any_nan(), "frame_rect: {frame_rect:?}");
debug_assert!(
!widget_rect.any_nan(),
"widget_rect is NaN: {widget_rect:?}"
);
self.region.sanity_check();
if let Some(grid) = &mut self.grid {
@@ -281,7 +284,7 @@ impl Placer {
if let Some(grid) = &self.grid {
let rect = grid.next_cell(self.cursor(), Vec2::splat(0.0));
painter.rect_stroke(rect, 1.0, stroke);
painter.rect_stroke(rect, 1.0, stroke, epaint::StrokeKind::Inside);
let align = Align2::CENTER_CENTER;
painter.debug_text(align.pos_in_rect(&rect), align, stroke.color, text);
} else {

View File

@@ -2,21 +2,21 @@ use std::{any::Any, sync::Arc};
use crate::{
emath::{Align, Pos2, Rect, Vec2},
menu, pass_state, AreaState, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui,
WidgetRect, WidgetText,
pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, Tooltip,
Ui, WidgetRect, WidgetText,
};
// ----------------------------------------------------------------------------
/// The result of adding a widget to a [`Ui`].
///
/// A [`Response`] lets you know whether or not a widget is being hovered, clicked or dragged.
/// A [`Response`] lets you know whether a widget is being hovered, clicked or dragged.
/// It also lets you easily show a tooltip on hover.
///
/// Whenever something gets added to a [`Ui`], a [`Response`] object is returned.
/// [`ui.add`] returns a [`Response`], as does [`ui.button`], and all similar shortcuts.
///
/// ⚠️ The `Response` contains a clone of [`Context`], and many methods lock the `Context`.
/// It can therefor be a deadlock to use `Context` from within a context-locking closures,
/// It can therefore be a deadlock to use `Context` from within a context-locking closures,
/// such as [`Context::input`].
#[derive(Clone, Debug)]
pub struct Response {
@@ -50,78 +50,12 @@ pub struct Response {
/// (that is handled by the `Painter` directly).
pub sense: Sense,
/// Was the widget enabled?
/// If `false`, there was no interaction attempted (not even hover).
#[doc(hidden)]
pub enabled: bool,
// OUT:
/// The pointer is above this widget with no other blocking it.
#[doc(hidden)]
pub contains_pointer: bool,
/// The pointer is hovering above this widget or the widget was clicked/tapped this frame.
#[doc(hidden)]
pub hovered: bool,
/// The widget is highlighted via a call to [`Self::highlight`] or [`Context::highlight_widget`].
#[doc(hidden)]
pub highlighted: bool,
/// This widget was clicked this frame.
///
/// Which pointer and how many times we don't know,
/// and ask [`crate::InputState`] about at runtime.
///
/// This is only set to true if the widget was clicked
/// by an actual mouse.
#[doc(hidden)]
pub clicked: bool,
/// This widget should act as if clicked due
/// to something else than a click.
///
/// This is set to true if the widget has keyboard focus and
/// the user hit the Space or Enter key.
#[doc(hidden)]
pub fake_primary_click: bool,
/// This widget was long-pressed on a touch screen to simulate a secondary click.
#[doc(hidden)]
pub long_touched: bool,
/// The widget started being dragged this frame.
#[doc(hidden)]
pub drag_started: bool,
/// The widget is being dragged.
#[doc(hidden)]
pub dragged: bool,
/// The widget was being dragged, but now it has been released.
#[doc(hidden)]
pub drag_stopped: bool,
/// Is the pointer button currently down on this widget?
/// This is true if the pointer is pressing down or dragging a widget
#[doc(hidden)]
pub is_pointer_button_down_on: bool,
/// Where the pointer (mouse/touch) were when when this widget was clicked or dragged.
/// Where the pointer (mouse/touch) were when this widget was clicked or dragged.
/// `None` if the widget is not being interacted with.
#[doc(hidden)]
pub interact_pointer_pos: Option<Pos2>,
/// Was the underlying data changed?
///
/// e.g. the slider was dragged, text was entered in a [`TextEdit`](crate::TextEdit) etc.
/// Always `false` for something like a [`Button`](crate::Button).
///
/// Note that this can be `true` even if the user did not interact with the widget,
/// for instance if an existing slider value was clamped to the given range.
#[doc(hidden)]
pub changed: bool,
/// The intrinsic / desired size of the widget.
///
/// For a button, this will be the size of the label + the frames padding,
@@ -133,6 +67,76 @@ pub struct Response {
/// for improved layouting.
/// See for instance [`egui_flex`](https://github.com/lucasmerlin/hello_egui/tree/main/crates/egui_flex).
pub intrinsic_size: Option<Vec2>,
#[doc(hidden)]
pub flags: Flags,
}
/// A bit set for various boolean properties of `Response`.
#[doc(hidden)]
#[derive(Copy, Clone, Debug)]
pub struct Flags(u16);
bitflags::bitflags! {
impl Flags: u16 {
/// Was the widget enabled?
/// If `false`, there was no interaction attempted (not even hover).
const ENABLED = 1<<0;
/// The pointer is above this widget with no other blocking it.
const CONTAINS_POINTER = 1<<1;
/// The pointer is hovering above this widget or the widget was clicked/tapped this frame.
const HOVERED = 1<<2;
/// The widget is highlighted via a call to [`Response::highlight`] or
/// [`Context::highlight_widget`].
const HIGHLIGHTED = 1<<3;
/// This widget was clicked this frame.
///
/// Which pointer and how many times we don't know,
/// and ask [`crate::InputState`] about at runtime.
///
/// This is only set to true if the widget was clicked
/// by an actual mouse.
const CLICKED = 1<<4;
/// This widget should act as if clicked due
/// to something else than a click.
///
/// This is set to true if the widget has keyboard focus and
/// the user hit the Space or Enter key.
const FAKE_PRIMARY_CLICKED = 1<<5;
/// This widget was long-pressed on a touch screen to simulate a secondary click.
const LONG_TOUCHED = 1<<6;
/// The widget started being dragged this frame.
const DRAG_STARTED = 1<<7;
/// The widget is being dragged.
const DRAGGED = 1<<8;
/// The widget was being dragged, but now it has been released.
const DRAG_STOPPED = 1<<9;
/// Is the pointer button currently down on this widget?
/// This is true if the pointer is pressing down or dragging a widget
const IS_POINTER_BUTTON_DOWN_ON = 1<<10;
/// Was the underlying data changed?
///
/// e.g. the slider was dragged, text was entered in a [`TextEdit`](crate::TextEdit) etc.
/// Always `false` for something like a [`Button`](crate::Button).
///
/// Note that this can be `true` even if the user did not interact with the widget,
/// for instance if an existing slider value was clamped to the given range.
const CHANGED = 1<<11;
/// Should this container be closed?
const CLOSE = 1<<12;
}
}
impl Response {
@@ -150,7 +154,7 @@ impl Response {
/// You can use [`Self::interact`] to sense more things *after* adding a widget.
#[inline(always)]
pub fn clicked(&self) -> bool {
self.fake_primary_click || self.clicked_by(PointerButton::Primary)
self.flags.contains(Flags::FAKE_PRIMARY_CLICKED) || self.clicked_by(PointerButton::Primary)
}
/// Returns true if this widget was clicked this frame by the given mouse button.
@@ -163,7 +167,7 @@ impl Response {
/// Use [`Self::secondary_clicked`] instead to also detect that.
#[inline]
pub fn clicked_by(&self, button: PointerButton) -> bool {
self.clicked && self.ctx.input(|i| i.pointer.button_clicked(button))
self.flags.contains(Flags::CLICKED) && self.ctx.input(|i| i.pointer.button_clicked(button))
}
/// Returns true if this widget was clicked this frame by the secondary mouse button (e.g. the right mouse button).
@@ -171,7 +175,7 @@ impl Response {
/// This also returns true if the widget was pressed-and-held on a touch screen.
#[inline]
pub fn secondary_clicked(&self) -> bool {
self.long_touched || self.clicked_by(PointerButton::Secondary)
self.flags.contains(Flags::LONG_TOUCHED) || self.clicked_by(PointerButton::Secondary)
}
/// Was this long-pressed on a touch screen?
@@ -179,7 +183,7 @@ impl Response {
/// Usually you want to check [`Self::secondary_clicked`] instead.
#[inline]
pub fn long_touched(&self) -> bool {
self.long_touched
self.flags.contains(Flags::LONG_TOUCHED)
}
/// Returns true if this widget was clicked this frame by the middle mouse button.
@@ -203,38 +207,47 @@ impl Response {
/// Returns true if this widget was double-clicked this frame by the given button.
#[inline]
pub fn double_clicked_by(&self, button: PointerButton) -> bool {
self.clicked && self.ctx.input(|i| i.pointer.button_double_clicked(button))
self.flags.contains(Flags::CLICKED)
&& self.ctx.input(|i| i.pointer.button_double_clicked(button))
}
/// Returns true if this widget was triple-clicked this frame by the given button.
#[inline]
pub fn triple_clicked_by(&self, button: PointerButton) -> bool {
self.clicked && self.ctx.input(|i| i.pointer.button_triple_clicked(button))
self.flags.contains(Flags::CLICKED)
&& self.ctx.input(|i| i.pointer.button_triple_clicked(button))
}
/// `true` if there was a click *outside* the rect of this widget.
///
/// Clicks on widgets contained in this one counts as clicks inside this widget,
/// so that clicking a button in an area will not be considered as clicking "elsewhere" from the area.
///
/// Clicks on other layers above this widget *will* be considered as clicking elsewhere.
pub fn clicked_elsewhere(&self) -> bool {
let (pointer_interact_pos, any_click) = self
.ctx
.input(|i| (i.pointer.interact_pos(), i.pointer.any_click()));
// We do not use self.clicked(), because we want to catch all clicks within our frame,
// even if we aren't clickable (or even enabled).
// This is important for windows and such that should close then the user clicks elsewhere.
self.ctx.input(|i| {
let pointer = &i.pointer;
if pointer.any_click() {
if self.contains_pointer || self.hovered {
false
} else if let Some(pos) = pointer.interact_pos() {
!self.interact_rect.contains(pos)
if any_click {
if self.contains_pointer() || self.hovered() {
false
} else if let Some(pos) = pointer_interact_pos {
let layer_under_pointer = self.ctx.layer_id_at(pos);
if layer_under_pointer != Some(self.layer_id) {
true
} else {
false // clicked without a pointer, weird
!self.interact_rect.contains(pos)
}
} else {
false
false // clicked without a pointer, weird
}
})
} else {
false
}
}
/// Was the widget enabled?
@@ -242,7 +255,7 @@ impl Response {
/// and the widget should be drawn in a gray disabled look.
#[inline(always)]
pub fn enabled(&self) -> bool {
self.enabled
self.flags.contains(Flags::ENABLED)
}
/// The pointer is hovering above this widget or the widget was clicked/tapped this frame.
@@ -251,7 +264,7 @@ impl Response {
/// `hovered` is always `false` for disabled widgets.
#[inline(always)]
pub fn hovered(&self) -> bool {
self.hovered
self.flags.contains(Flags::HOVERED)
}
/// Returns true if the pointer is contained by the response rect, and no other widget is covering it.
@@ -264,14 +277,14 @@ impl Response {
/// [`Self::contains_pointer`] also checks that no other widget is covering this response rectangle.
#[inline(always)]
pub fn contains_pointer(&self) -> bool {
self.contains_pointer
self.flags.contains(Flags::CONTAINS_POINTER)
}
/// The widget is highlighted via a call to [`Self::highlight`] or [`Context::highlight_widget`].
#[doc(hidden)]
#[inline(always)]
pub fn highlighted(&self) -> bool {
self.highlighted
self.flags.contains(Flags::HIGHLIGHTED)
}
/// This widget has the keyboard focus (i.e. is receiving key presses).
@@ -316,7 +329,7 @@ impl Response {
self.ctx.memory_mut(|mem| mem.surrender_focus(self.id));
}
/// Did a drag on this widgets begin this frame?
/// Did a drag on this widget begin this frame?
///
/// This is only true if the widget sense drags.
/// If the widget also senses clicks, this will only become true if the pointer has moved a bit.
@@ -324,10 +337,10 @@ impl Response {
/// This will only be true for a single frame.
#[inline]
pub fn drag_started(&self) -> bool {
self.drag_started
self.flags.contains(Flags::DRAG_STARTED)
}
/// Did a drag on this widgets by the button begin this frame?
/// Did a drag on this widget by the button begin this frame?
///
/// This is only true if the widget sense drags.
/// If the widget also senses clicks, this will only become true if the pointer has moved a bit.
@@ -354,7 +367,7 @@ impl Response {
/// You can use [`Self::interact`] to sense more things *after* adding a widget.
#[inline(always)]
pub fn dragged(&self) -> bool {
self.dragged
self.flags.contains(Flags::DRAGGED)
}
/// See [`Self::dragged`].
@@ -366,7 +379,7 @@ impl Response {
/// The widget was being dragged, but now it has been released.
#[inline]
pub fn drag_stopped(&self) -> bool {
self.drag_stopped
self.flags.contains(Flags::DRAG_STOPPED)
}
/// The widget was being dragged by the button, but now it has been released.
@@ -378,7 +391,7 @@ impl Response {
#[inline]
#[deprecated = "Renamed 'drag_stopped'"]
pub fn drag_released(&self) -> bool {
self.drag_stopped
self.drag_stopped()
}
/// The widget was being dragged by the button, but now it has been released.
@@ -422,7 +435,7 @@ impl Response {
crate::DragAndDrop::set_payload(&self.ctx, payload);
}
if self.hovered() && !self.sense.click {
if self.hovered() && !self.sense.senses_click() {
// Things that can be drag-dropped should use the Grab cursor icon,
// but if the thing is _also_ clickable, that can be annoying.
self.ctx.set_cursor_icon(CursorIcon::Grab);
@@ -460,7 +473,7 @@ impl Response {
}
}
/// Where the pointer (mouse/touch) were when when this widget was clicked or dragged.
/// Where the pointer (mouse/touch) were when this widget was clicked or dragged.
///
/// `None` if the widget is not being interacted with.
#[inline]
@@ -492,7 +505,7 @@ impl Response {
/// This could also be thought of as "is this widget being interacted with?".
#[inline(always)]
pub fn is_pointer_button_down_on(&self) -> bool {
self.is_pointer_button_down_on
self.flags.contains(Flags::IS_POINTER_BUTTON_DOWN_ON)
}
/// Was the underlying data changed?
@@ -510,7 +523,7 @@ impl Response {
/// for instance if an existing slider value was clamped to the given range.
#[inline(always)]
pub fn changed(&self) -> bool {
self.changed
self.flags.contains(Flags::CHANGED)
}
/// Report the data shown by this widget changed.
@@ -519,10 +532,25 @@ impl Response {
/// e.g. checkboxes, sliders etc.
///
/// This should be called when the *content* changes, but not when the view does.
/// So we call this when the text of a [`crate::TextEdit`], but not when the cursors changes.
/// So we call this when the text of a [`crate::TextEdit`], but not when the cursor changes.
#[inline(always)]
pub fn mark_changed(&mut self) {
self.changed = true;
self.flags.set(Flags::CHANGED, true);
}
/// Should the container be closed?
///
/// Will e.g. be set by calling [`Ui::close`] in a child [`Ui`] or by calling
/// [`Self::set_close`].
pub fn should_close(&self) -> bool {
self.flags.contains(Flags::CLOSE)
}
/// Set the [`Flags::CLOSE`] flag.
///
/// Can be used to e.g. signal that a container should be closed.
pub fn set_close(&mut self) {
self.flags.set(Flags::CLOSE, true);
}
/// Show this UI if the widget was hovered (i.e. a tooltip).
@@ -547,36 +575,22 @@ impl Response {
/// ```
#[doc(alias = "tooltip")]
pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.enabled && self.should_show_hover_ui() {
self.show_tooltip_ui(add_contents);
}
Tooltip::for_enabled(&self).show(add_contents);
self
}
/// Show this UI when hovering if the widget is disabled.
pub fn on_disabled_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if !self.enabled && self.should_show_hover_ui() {
crate::containers::show_tooltip_for(
&self.ctx,
self.layer_id,
self.id,
&self.rect,
add_contents,
);
}
Tooltip::for_disabled(&self).show(add_contents);
self
}
/// Like `on_hover_ui`, but show the ui next to cursor.
pub fn on_hover_ui_at_pointer(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.enabled && self.should_show_hover_ui() {
crate::containers::show_tooltip_at_pointer(
&self.ctx,
self.layer_id,
self.id,
add_contents,
);
}
Tooltip::for_enabled(&self)
.at_pointer()
.gap(12.0)
.show(add_contents);
self
}
@@ -584,13 +598,9 @@ impl Response {
///
/// This can be used to give attention to a widget during a tutorial.
pub fn show_tooltip_ui(&self, add_contents: impl FnOnce(&mut Ui)) {
crate::containers::show_tooltip_for(
&self.ctx,
self.layer_id,
self.id,
&self.rect,
add_contents,
);
Popup::from_response(self)
.kind(PopupKind::Tooltip)
.show(add_contents);
}
/// Always show this tooltip, even if disabled and the user isn't hovering it.
@@ -604,180 +614,7 @@ impl Response {
/// Was the tooltip open last frame?
pub fn is_tooltip_open(&self) -> bool {
crate::popup::was_tooltip_open_last_frame(&self.ctx, self.id)
}
fn should_show_hover_ui(&self) -> bool {
if self.ctx.memory(|mem| mem.everything_is_visible()) {
return true;
}
let any_open_popups = self.ctx.prev_pass_state(|fs| {
fs.layers
.get(&self.layer_id)
.map_or(false, |layer| !layer.open_popups.is_empty())
});
if any_open_popups {
// Hide tooltips if the user opens a popup (menu, combo-box, etc) in the same layer.
return false;
}
let style = self.ctx.style();
let tooltip_delay = style.interaction.tooltip_delay;
let tooltip_grace_time = style.interaction.tooltip_grace_time;
let (
time_since_last_scroll,
time_since_last_click,
time_since_last_pointer_movement,
pointer_pos,
pointer_dir,
) = self.ctx.input(|i| {
(
i.time_since_last_scroll(),
i.pointer.time_since_last_click(),
i.pointer.time_since_last_movement(),
i.pointer.hover_pos(),
i.pointer.direction(),
)
});
if time_since_last_scroll < tooltip_delay {
// See https://github.com/emilk/egui/issues/4781
// Note that this means we cannot have `ScrollArea`s in a tooltip.
self.ctx
.request_repaint_after_secs(tooltip_delay - time_since_last_scroll);
return false;
}
let is_our_tooltip_open = self.is_tooltip_open();
if is_our_tooltip_open {
// Check if we should automatically stay open:
let tooltip_id = crate::next_tooltip_id(&self.ctx, self.id);
let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id);
let tooltip_has_interactive_widget = self.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(tooltip_layer_id)
.any(|w| w.enabled && w.sense.interactive())
});
if tooltip_has_interactive_widget {
// We keep the tooltip open if hovered,
// or if the pointer is on its way to it,
// so that the user can interact with the tooltip
// (i.e. click links that are in it).
if let Some(area) = AreaState::load(&self.ctx, tooltip_id) {
let rect = area.rect();
if let Some(pos) = pointer_pos {
if rect.contains(pos) {
return true; // hovering interactive tooltip
}
if pointer_dir != Vec2::ZERO
&& rect.intersects_ray(pos, pointer_dir.normalized())
{
return true; // on the way to interactive tooltip
}
}
}
}
}
let clicked_more_recently_than_moved =
time_since_last_click < time_since_last_pointer_movement + 0.1;
if clicked_more_recently_than_moved {
// It is common to click a widget and then rest the mouse there.
// It would be annoying to then see a tooltip for it immediately.
// Similarly, clicking should hide the existing tooltip.
// Only hovering should lead to a tooltip, not clicking.
// The offset is only to allow small movement just right after the click.
return false;
}
if is_our_tooltip_open {
// Check if we should automatically stay open:
if pointer_pos.is_some_and(|pointer_pos| self.rect.contains(pointer_pos)) {
// Handle the case of a big tooltip that covers the widget:
return true;
}
}
let is_other_tooltip_open = self.ctx.prev_pass_state(|fs| {
if let Some(already_open_tooltip) = fs
.layers
.get(&self.layer_id)
.and_then(|layer| layer.widget_with_tooltip)
{
already_open_tooltip != self.id
} else {
false
}
});
if is_other_tooltip_open {
// We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself.
return false;
}
// Fast early-outs:
if self.enabled {
if !self.hovered || !self.ctx.input(|i| i.pointer.has_pointer()) {
return false;
}
} else if !self.ctx.rect_contains_pointer(self.layer_id, self.rect) {
return false;
}
// There is a tooltip_delay before showing the first tooltip,
// but once one tooltips is show, moving the mouse cursor to
// another widget should show the tooltip for that widget right away.
// Let the user quickly move over some dead space to hover the next thing
let tooltip_was_recently_shown =
crate::popup::seconds_since_last_tooltip(&self.ctx) < tooltip_grace_time;
if !tooltip_was_recently_shown && !is_our_tooltip_open {
if style.interaction.show_tooltips_only_when_still {
// We only show the tooltip when the mouse pointer is still.
if !self
.ctx
.input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO)
{
// wait for mouse to stop
self.ctx.request_repaint();
return false;
}
}
let time_since_last_interaction = time_since_last_scroll
.min(time_since_last_pointer_movement)
.min(time_since_last_click);
let time_til_tooltip = tooltip_delay - time_since_last_interaction;
if 0.0 < time_til_tooltip {
// Wait until the mouse has been still for a while
self.ctx.request_repaint_after_secs(time_til_tooltip);
return false;
}
}
// We don't want tooltips of things while we are dragging them,
// but we do want tooltips while holding down on an item on a touch screen.
if self
.ctx
.input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click)
{
return false;
}
// All checks passed: show the tooltip!
true
Tooltip::was_tooltip_open_last_frame(&self.ctx, self.id)
}
/// Like `on_hover_text`, but show the text next to cursor.
@@ -817,7 +654,7 @@ impl Response {
#[inline]
pub fn highlight(mut self) -> Self {
self.ctx.highlight_widget(self.id);
self.highlighted = true;
self.flags.set(Flags::HIGHLIGHTED, true);
self
}
@@ -888,7 +725,7 @@ impl Response {
rect: self.rect,
interact_rect: self.interact_rect,
sense: self.sense | sense,
enabled: self.enabled,
enabled: self.enabled(),
},
true,
)
@@ -951,7 +788,7 @@ impl Response {
Some(OutputEvent::TripleClicked(make_info()))
} else if self.gained_focus() {
Some(OutputEvent::FocusGained(make_info()))
} else if self.changed {
} else if self.changed() {
Some(OutputEvent::ValueChanged(make_info()))
} else {
None
@@ -983,7 +820,7 @@ impl Response {
#[cfg(feature = "accesskit")]
pub(crate) fn fill_accesskit_node_common(&self, builder: &mut accesskit::Node) {
if !self.enabled {
if !self.enabled() {
builder.set_disabled();
}
builder.set_bounds(accesskit::Rect {
@@ -992,10 +829,10 @@ impl Response {
x1: self.rect.max.x.into(),
y1: self.rect.max.y.into(),
});
if self.sense.focusable {
if self.sense.is_focusable() {
builder.add_action(accesskit::Action::Focus);
}
if self.sense.click {
if self.sense.senses_click() {
builder.add_action(accesskit::Action::Click);
}
}
@@ -1056,6 +893,9 @@ impl Response {
// Indeterminate state
builder.set_toggled(Toggled::Mixed);
}
if let Some(hint_text) = info.hint_text {
builder.set_placeholder(hint_text);
}
}
/// Associate a label with a control for accessibility.
@@ -1094,22 +934,22 @@ impl Response {
/// let response = ui.add(Label::new("Right-click me!").sense(Sense::click()));
/// response.context_menu(|ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// # });
/// ```
///
/// See also: [`Ui::menu_button`] and [`Ui::close_menu`].
/// See also: [`Ui::menu_button`] and [`Ui::close`].
pub fn context_menu(&self, add_contents: impl FnOnce(&mut Ui)) -> Option<InnerResponse<()>> {
menu::context_menu(self, add_contents)
Popup::context_menu(self).show(add_contents)
}
/// Returns whether a context menu is currently open for this widget.
///
/// See [`Self::context_menu`].
pub fn context_menu_opened(&self) -> bool {
menu::context_menu_opened(self)
Popup::context_menu(self).is_open()
}
/// Draw a debug rectangle over the response displaying the response's id and whether it is
@@ -1125,9 +965,9 @@ impl Response {
pub fn paint_debug_info(&self) {
self.ctx.debug_painter().debug_rect(
self.rect,
if self.hovered {
if self.hovered() {
crate::Color32::DARK_GREEN
} else if self.enabled {
} else if self.enabled() {
crate::Color32::BLUE
} else {
crate::Color32::RED
@@ -1145,7 +985,10 @@ impl Response {
///
/// You may not call [`Self::interact`] on the resulting `Response`.
pub fn union(&self, other: Self) -> Self {
assert!(self.ctx == other.ctx);
assert!(
self.ctx == other.ctx,
"Responses must be from the same `Context`"
);
debug_assert!(
self.layer_id == other.layer_id,
"It makes no sense to combine Responses from two different layers"
@@ -1157,20 +1000,8 @@ impl Response {
rect: self.rect.union(other.rect),
interact_rect: self.interact_rect.union(other.interact_rect),
sense: self.sense.union(other.sense),
enabled: self.enabled || other.enabled,
contains_pointer: self.contains_pointer || other.contains_pointer,
hovered: self.hovered || other.hovered,
highlighted: self.highlighted || other.highlighted,
clicked: self.clicked || other.clicked,
fake_primary_click: self.fake_primary_click || other.fake_primary_click,
long_touched: self.long_touched || other.long_touched,
drag_started: self.drag_started || other.drag_started,
dragged: self.dragged || other.dragged,
drag_stopped: self.drag_stopped || other.drag_stopped,
is_pointer_button_down_on: self.is_pointer_button_down_on
|| other.is_pointer_button_down_on,
flags: self.flags | other.flags,
interact_pointer_pos: self.interact_pointer_pos.or(other.interact_pointer_pos),
changed: self.changed || other.changed,
intrinsic_size: None,
}
}

View File

@@ -1,36 +1,37 @@
/// What sort of interaction is a widget sensitive to?
#[derive(Clone, Copy, Eq, PartialEq)]
// #[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Sense {
/// Buttons, sliders, windows, …
pub click: bool,
pub struct Sense(u8);
/// Sliders, windows, scroll bars, scroll areas, …
pub drag: bool,
bitflags::bitflags! {
impl Sense: u8 {
/// This widget wants focus.
///
/// Anything interactive + labels that can be focused
/// for the benefit of screen readers.
pub focusable: bool,
const HOVER = 0;
/// Buttons, sliders, windows, …
const CLICK = 1<<0;
/// Sliders, windows, scroll bars, scroll areas, …
const DRAG = 1<<1;
/// This widget wants focus.
///
/// Anything interactive + labels that can be focused
/// for the benefit of screen readers.
const FOCUSABLE = 1<<2;
}
}
impl std::fmt::Debug for Sense {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
click,
drag,
focusable,
} = self;
write!(f, "Sense {{")?;
if *click {
if self.senses_click() {
write!(f, " click")?;
}
if *drag {
if self.senses_drag() {
write!(f, " drag")?;
}
if *focusable {
if self.is_focusable() {
write!(f, " focusable")?;
}
write!(f, " }}")
@@ -42,42 +43,26 @@ impl Sense {
#[doc(alias = "none")]
#[inline]
pub fn hover() -> Self {
Self {
click: false,
drag: false,
focusable: false,
}
Self::empty()
}
/// Senses no clicks or drags, but can be focused with the keyboard.
/// Used for labels that can be focused for the benefit of screen readers.
#[inline]
pub fn focusable_noninteractive() -> Self {
Self {
click: false,
drag: false,
focusable: true,
}
Self::FOCUSABLE
}
/// Sense clicks and hover, but not drags.
#[inline]
pub fn click() -> Self {
Self {
click: true,
drag: false,
focusable: true,
}
Self::CLICK | Self::FOCUSABLE
}
/// Sense drags and hover, but not clicks.
#[inline]
pub fn drag() -> Self {
Self {
click: false,
drag: true,
focusable: true,
}
Self::DRAG | Self::FOCUSABLE
}
/// Sense both clicks, drags and hover (e.g. a slider or window).
@@ -90,43 +75,27 @@ impl Sense {
/// See [`crate::PointerState::is_decidedly_dragging`] for details.
#[inline]
pub fn click_and_drag() -> Self {
Self {
click: true,
drag: true,
focusable: true,
}
}
/// The logical "or" of two [`Sense`]s.
#[must_use]
#[inline]
pub fn union(self, other: Self) -> Self {
Self {
click: self.click | other.click,
drag: self.drag | other.drag,
focusable: self.focusable | other.focusable,
}
Self::CLICK | Self::FOCUSABLE | Self::DRAG
}
/// Returns true if we sense either clicks or drags.
#[inline]
pub fn interactive(&self) -> bool {
self.click || self.drag
self.intersects(Self::CLICK | Self::DRAG)
}
}
impl std::ops::BitOr for Sense {
type Output = Self;
#[inline]
fn bitor(self, rhs: Self) -> Self {
self.union(rhs)
pub fn senses_click(&self) -> bool {
self.contains(Self::CLICK)
}
}
impl std::ops::BitOrAssign for Sense {
#[inline]
fn bitor_assign(&mut self, rhs: Self) {
*self = self.union(rhs);
pub fn senses_drag(&self) -> bool {
self.contains(Self::DRAG)
}
#[inline]
pub fn is_focusable(&self) -> bool {
self.contains(Self::FOCUSABLE)
}
}

View File

@@ -2,10 +2,9 @@
#![allow(clippy::if_same_then_else)]
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use emath::Align;
use epaint::{text::FontTweak, Rounding, Shadow, Stroke};
use epaint::{text::FontTweak, CornerRadius, Shadow, Stroke};
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use crate::{
ecolor::Color32,
@@ -174,6 +173,49 @@ impl From<TextStyle> for FontSelection {
// ----------------------------------------------------------------------------
/// Utility to modify a [`Style`] in some way.
/// Constructed via [`StyleModifier::from`] from a `Fn(&mut Style)` or a [`Style`].
#[derive(Clone, Default)]
pub struct StyleModifier(Option<Arc<dyn Fn(&mut Style) + Send + Sync>>);
impl std::fmt::Debug for StyleModifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("StyleModifier")
}
}
impl<T> From<T> for StyleModifier
where
T: Fn(&mut Style) + Send + Sync + 'static,
{
fn from(f: T) -> Self {
Self(Some(Arc::new(f)))
}
}
impl From<Style> for StyleModifier {
fn from(style: Style) -> Self {
Self(Some(Arc::new(move |s| *s = style.clone())))
}
}
impl StyleModifier {
/// Create a new [`StyleModifier`] from a function.
pub fn new(f: impl Fn(&mut Style) + Send + Sync + 'static) -> Self {
Self::from(f)
}
/// Apply the modification to the given [`Style`].
/// Usually used with [`Ui::style_mut`].
pub fn apply(&self, style: &mut Style) {
if let Some(f) = &self.0 {
f(style);
}
}
}
// ----------------------------------------------------------------------------
/// Specifies the look and feel of egui.
///
/// You can change the visuals of a [`Ui`] with [`Ui::style_mut`]
@@ -290,6 +332,9 @@ pub struct Style {
/// The animation that should be used when scrolling a [`crate::ScrollArea`] using e.g. [`Ui::scroll_to_rect`].
pub scroll_animation: ScrollAnimation,
/// Use a more compact style for menus.
pub compact_menu_style: bool,
}
#[test]
@@ -915,7 +960,7 @@ pub struct Visuals {
/// A good color for error text (e.g. red).
pub error_fg_color: Color32,
pub window_rounding: Rounding,
pub window_corner_radius: CornerRadius,
pub window_shadow: Shadow,
pub window_fill: Color32,
pub window_stroke: Stroke,
@@ -923,7 +968,7 @@ pub struct Visuals {
/// Highlight the topmost window.
pub window_highlight_topmost: bool,
pub menu_rounding: Rounding,
pub menu_corner_radius: CornerRadius,
/// Panel background color
pub panel_fill: Color32,
@@ -944,7 +989,7 @@ pub struct Visuals {
/// Show a background behind collapsing headers.
pub collapsing_header_frame: bool,
/// Draw a vertical lien left of indented region, in e.g. [`crate::CollapsingHeader`].
/// Draw a vertical line left of indented region, in e.g. [`crate::CollapsingHeader`].
pub indent_has_left_vline: bool,
/// Whether or not Grids and Tables should be striped by default
@@ -1107,7 +1152,7 @@ pub struct WidgetVisuals {
pub bg_stroke: Stroke,
/// Button frames etc.
pub rounding: Rounding,
pub corner_radius: CornerRadius,
/// Stroke and text color of the interactive part of a component (button text, slider grab, check-mark, …).
pub fg_stroke: Stroke,
@@ -1121,6 +1166,11 @@ impl WidgetVisuals {
pub fn text_color(&self) -> Color32 {
self.fg_stroke.color
}
#[deprecated = "Renamed to corner_radius"]
pub fn rounding(&self) -> CornerRadius {
self.corner_radius
}
}
/// Options for help debug egui by adding extra visualization
@@ -1230,6 +1280,7 @@ impl Default for Style {
url_in_tooltip: false,
always_scroll_the_only_direction: false,
scroll_animation: ScrollAnimation::default(),
compact_menu_style: true,
}
}
}
@@ -1291,7 +1342,7 @@ impl Visuals {
warn_fg_color: Color32::from_rgb(255, 143, 0), // orange
error_fg_color: Color32::from_rgb(255, 0, 0), // red
window_rounding: Rounding::same(6),
window_corner_radius: CornerRadius::same(6),
window_shadow: Shadow {
offset: [10, 20],
blur: 15,
@@ -1302,7 +1353,7 @@ impl Visuals {
window_stroke: Stroke::new(1.0, Color32::from_gray(60)),
window_highlight_topmost: true,
menu_rounding: Rounding::same(6),
menu_corner_radius: CornerRadius::same(6),
panel_fill: Color32::from_gray(27),
@@ -1412,7 +1463,7 @@ impl Widgets {
bg_fill: Color32::from_gray(27),
bg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // separators, indentation lines
fg_stroke: Stroke::new(1.0, Color32::from_gray(140)), // normal text color
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 0.0,
},
inactive: WidgetVisuals {
@@ -1420,7 +1471,7 @@ impl Widgets {
bg_fill: Color32::from_gray(60), // checkbox background
bg_stroke: Default::default(),
fg_stroke: Stroke::new(1.0, Color32::from_gray(180)), // button text
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 0.0,
},
hovered: WidgetVisuals {
@@ -1428,7 +1479,7 @@ impl Widgets {
bg_fill: Color32::from_gray(70),
bg_stroke: Stroke::new(1.0, Color32::from_gray(150)), // e.g. hover over window edge or button
fg_stroke: Stroke::new(1.5, Color32::from_gray(240)),
rounding: Rounding::same(3),
corner_radius: CornerRadius::same(3),
expansion: 1.0,
},
active: WidgetVisuals {
@@ -1436,7 +1487,7 @@ impl Widgets {
bg_fill: Color32::from_gray(55),
bg_stroke: Stroke::new(1.0, Color32::WHITE),
fg_stroke: Stroke::new(2.0, Color32::WHITE),
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 1.0,
},
open: WidgetVisuals {
@@ -1444,7 +1495,7 @@ impl Widgets {
bg_fill: Color32::from_gray(27),
bg_stroke: Stroke::new(1.0, Color32::from_gray(60)),
fg_stroke: Stroke::new(1.0, Color32::from_gray(210)),
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 0.0,
},
}
@@ -1457,7 +1508,7 @@ impl Widgets {
bg_fill: Color32::from_gray(248),
bg_stroke: Stroke::new(1.0, Color32::from_gray(190)), // separators, indentation lines
fg_stroke: Stroke::new(1.0, Color32::from_gray(80)), // normal text color
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 0.0,
},
inactive: WidgetVisuals {
@@ -1465,7 +1516,7 @@ impl Widgets {
bg_fill: Color32::from_gray(230), // checkbox background
bg_stroke: Default::default(),
fg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // button text
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 0.0,
},
hovered: WidgetVisuals {
@@ -1473,7 +1524,7 @@ impl Widgets {
bg_fill: Color32::from_gray(220),
bg_stroke: Stroke::new(1.0, Color32::from_gray(105)), // e.g. hover over window edge or button
fg_stroke: Stroke::new(1.5, Color32::BLACK),
rounding: Rounding::same(3),
corner_radius: CornerRadius::same(3),
expansion: 1.0,
},
active: WidgetVisuals {
@@ -1481,7 +1532,7 @@ impl Widgets {
bg_fill: Color32::from_gray(165),
bg_stroke: Stroke::new(1.0, Color32::BLACK),
fg_stroke: Stroke::new(2.0, Color32::BLACK),
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 1.0,
},
open: WidgetVisuals {
@@ -1489,7 +1540,7 @@ impl Widgets {
bg_fill: Color32::from_gray(220),
bg_stroke: Stroke::new(1.0, Color32::from_gray(160)),
fg_stroke: Stroke::new(1.0, Color32::BLACK),
rounding: Rounding::same(2),
corner_radius: CornerRadius::same(2),
expansion: 0.0,
},
}
@@ -1531,6 +1582,7 @@ impl Style {
url_in_tooltip,
always_scroll_the_only_direction,
scroll_animation,
compact_menu_style,
} = self;
crate::Grid::new("_options").show(ui, |ui| {
@@ -1636,6 +1688,8 @@ impl Style {
#[cfg(debug_assertions)]
ui.collapsing("🐛 Debug", |ui| debug.ui(ui));
ui.checkbox(compact_menu_style, "Compact menu style");
ui.checkbox(explanation_tooltips, "Explanation tooltips")
.on_hover_text(
"Show explanatory text when hovering DragValue:s and other egui widgets",
@@ -1924,7 +1978,7 @@ impl WidgetVisuals {
weak_bg_fill,
bg_fill: mandatory_bg_fill,
bg_stroke,
rounding,
corner_radius,
fg_stroke,
expansion,
} = self;
@@ -1948,8 +2002,8 @@ impl WidgetVisuals {
ui.add(bg_stroke);
ui.end_row();
ui.label("Rounding");
ui.add(rounding);
ui.label("Corner radius");
ui.add(corner_radius);
ui.end_row();
ui.label("Foreground stroke (text)");
@@ -1978,13 +2032,13 @@ impl Visuals {
warn_fg_color,
error_fg_color,
window_rounding,
window_corner_radius,
window_shadow,
window_fill,
window_stroke,
window_highlight_topmost,
menu_rounding,
menu_corner_radius,
panel_fill,
@@ -2066,8 +2120,8 @@ impl Visuals {
ui.add(window_stroke);
ui.end_row();
ui.label("Rounding");
ui.add(window_rounding);
ui.label("Corner radius");
ui.add(window_corner_radius);
ui.end_row();
ui.label("Shadow");
@@ -2084,8 +2138,8 @@ impl Visuals {
.spacing([12.0, 8.0])
.striped(true)
.show(ui, |ui| {
ui.label("Rounding");
ui.add(menu_rounding);
ui.label("Corner radius");
ui.add(menu_corner_radius);
ui.end_row();
ui.label("Shadow");
@@ -2388,7 +2442,7 @@ impl Widget for &mut Margin {
}
}
impl Widget for &mut Rounding {
impl Widget for &mut CornerRadius {
fn ui(self, ui: &mut Ui) -> Response {
let mut same = self.is_same();
@@ -2398,37 +2452,39 @@ impl Widget for &mut Rounding {
let mut cr = self.nw;
ui.add(DragValue::new(&mut cr).range(0.0..=f32::INFINITY));
*self = Rounding::same(cr);
*self = CornerRadius::same(cr);
})
.response
} else {
ui.vertical(|ui| {
ui.checkbox(&mut same, "same");
crate::Grid::new("rounding").num_columns(2).show(ui, |ui| {
ui.label("NW");
ui.add(DragValue::new(&mut self.nw).range(0.0..=f32::INFINITY));
ui.end_row();
crate::Grid::new("Corner radius")
.num_columns(2)
.show(ui, |ui| {
ui.label("NW");
ui.add(DragValue::new(&mut self.nw).range(0.0..=f32::INFINITY));
ui.end_row();
ui.label("NE");
ui.add(DragValue::new(&mut self.ne).range(0.0..=f32::INFINITY));
ui.end_row();
ui.label("NE");
ui.add(DragValue::new(&mut self.ne).range(0.0..=f32::INFINITY));
ui.end_row();
ui.label("SW");
ui.add(DragValue::new(&mut self.sw).range(0.0..=f32::INFINITY));
ui.end_row();
ui.label("SW");
ui.add(DragValue::new(&mut self.sw).range(0.0..=f32::INFINITY));
ui.end_row();
ui.label("SE");
ui.add(DragValue::new(&mut self.se).range(0.0..=f32::INFINITY));
ui.end_row();
});
ui.label("SE");
ui.add(DragValue::new(&mut self.se).range(0.0..=f32::INFINITY));
ui.end_row();
});
})
.response
};
// Apply the checkbox:
if same {
*self = Rounding::from(self.average());
*self = CornerRadius::from(self.average());
} else {
// Make sure we aren't same:
if self.is_same() {
@@ -2513,7 +2569,7 @@ impl Widget for &mut crate::Frame {
let crate::Frame {
inner_margin,
outer_margin,
rounding,
corner_radius,
shadow,
fill,
stroke,
@@ -2533,8 +2589,8 @@ impl Widget for &mut crate::Frame {
ui.push_id("outer", |ui| ui.add(outer_margin));
ui.end_row();
ui.label("Rounding");
ui.add(rounding);
ui.label("Corner radius");
ui.add(corner_radius);
ui.end_row();
ui.label("Shadow");

View File

@@ -1,22 +1,24 @@
use crate::{Context, Galley, Id, Pos2};
use emath::TSTransform;
use super::{text_cursor_state::is_word_char, CursorRange};
use crate::{Context, Galley, Id};
use super::{text_cursor_state::is_word_char, CCursorRange};
/// Update accesskit with the current text state.
pub fn update_accesskit_for_text_widget(
ctx: &Context,
widget_id: Id,
cursor_range: Option<CursorRange>,
cursor_range: Option<CCursorRange>,
role: accesskit::Role,
galley_pos: Pos2,
global_from_galley: TSTransform,
galley: &Galley,
) {
let parent_id = ctx.accesskit_node_builder(widget_id, |builder| {
let parent_id = widget_id;
if let Some(cursor_range) = &cursor_range {
let anchor = &cursor_range.secondary.rcursor;
let focus = &cursor_range.primary.rcursor;
let anchor = galley.layout_from_cursor(cursor_range.secondary);
let focus = galley.layout_from_cursor(cursor_range.primary);
builder.set_text_selection(accesskit::TextSelection {
anchor: accesskit::TextPosition {
node: parent_id.with(anchor.row).accesskit_id(),
@@ -43,7 +45,7 @@ pub fn update_accesskit_for_text_widget(
let row_id = parent_id.with(row_index);
ctx.accesskit_node_builder(row_id, |builder| {
builder.set_role(accesskit::Role::TextRun);
let rect = row.rect().translate(galley_pos.to_vec2());
let rect = global_from_galley * row.rect();
builder.set_bounds(accesskit::Rect {
x0: rect.min.x.into(),
y0: rect.min.y.into(),

View File

@@ -1,41 +1,45 @@
use epaint::{
text::cursor::{CCursor, Cursor, PCursor},
Galley,
};
use epaint::{text::cursor::CCursor, Galley};
use crate::{os::OperatingSystem, Event, Id, Key, Modifiers};
use super::text_cursor_state::{ccursor_next_word, ccursor_previous_word, slice_char_range};
/// A selected text range (could be a range of length zero).
///
/// The selection is based on character count (NOT byte count!).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct CursorRange {
pub struct CCursorRange {
/// When selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves.
/// Note that the two ends can come in any order, and also be equal (no selection).
pub primary: Cursor,
pub primary: CCursor,
/// When selecting with a mouse, this is where the mouse was first pressed.
/// This part of the cursor does not move when shift is down.
pub secondary: Cursor,
pub secondary: CCursor,
/// Saved horizontal position of the cursor.
pub h_pos: Option<f32>,
}
impl CursorRange {
impl CCursorRange {
/// The empty range.
#[inline]
pub fn one(cursor: Cursor) -> Self {
pub fn one(ccursor: CCursor) -> Self {
Self {
primary: cursor,
secondary: cursor,
primary: ccursor,
secondary: ccursor,
h_pos: None,
}
}
#[inline]
pub fn two(min: Cursor, max: Cursor) -> Self {
pub fn two(min: impl Into<CCursor>, max: impl Into<CCursor>) -> Self {
Self {
primary: max,
secondary: min,
primary: max.into(),
secondary: min.into(),
h_pos: None,
}
}
@@ -44,39 +48,31 @@ impl CursorRange {
Self::two(galley.begin(), galley.end())
}
pub fn as_ccursor_range(&self) -> CCursorRange {
CCursorRange {
primary: self.primary.ccursor,
secondary: self.secondary.ccursor,
}
}
/// The range of selected character indices.
pub fn as_sorted_char_range(&self) -> std::ops::Range<usize> {
let [start, end] = self.sorted_cursors();
std::ops::Range {
start: start.ccursor.index,
end: end.ccursor.index,
start: start.index,
end: end.index,
}
}
/// True if the selected range contains no characters.
#[inline]
pub fn is_empty(&self) -> bool {
self.primary.ccursor == self.secondary.ccursor
self.primary == self.secondary
}
/// Is `self` a super-set of the other range?
pub fn contains(&self, other: &Self) -> bool {
pub fn contains(&self, other: Self) -> bool {
let [self_min, self_max] = self.sorted_cursors();
let [other_min, other_max] = other.sorted_cursors();
self_min.ccursor.index <= other_min.ccursor.index
&& other_max.ccursor.index <= self_max.ccursor.index
self_min.index <= other_min.index && other_max.index <= self_max.index
}
/// If there is a selection, None is returned.
/// If the two ends are the same, that is returned.
pub fn single(&self) -> Option<Cursor> {
pub fn single(&self) -> Option<CCursor> {
if self.is_empty() {
Some(self.primary)
} else {
@@ -84,25 +80,16 @@ impl CursorRange {
}
}
#[inline]
pub fn is_sorted(&self) -> bool {
let p = self.primary.ccursor;
let s = self.secondary.ccursor;
let p = self.primary;
let s = self.secondary;
(p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row)
}
pub fn sorted(self) -> Self {
if self.is_sorted() {
self
} else {
Self {
primary: self.secondary,
secondary: self.primary,
}
}
}
/// Returns the two ends ordered.
pub fn sorted_cursors(&self) -> [Cursor; 2] {
/// returns the two ends ordered
#[inline]
pub fn sorted_cursors(&self) -> [CCursor; 2] {
if self.is_sorted() {
[self.primary, self.secondary]
} else {
@@ -110,9 +97,15 @@ impl CursorRange {
}
}
#[inline]
#[deprecated = "Use `self.sorted_cursors` instead."]
pub fn sorted(&self) -> [CCursor; 2] {
self.sorted_cursors()
}
pub fn slice_str<'s>(&self, text: &'s str) -> &'s str {
let [min, max] = self.sorted_cursors();
slice_char_range(text, min.ccursor.index..max.ccursor.index)
slice_char_range(text, min.index..max.index)
}
/// Check for key presses that are moving the cursor.
@@ -146,7 +139,14 @@ impl CursorRange {
| Key::ArrowDown
| Key::Home
| Key::End => {
move_single_cursor(os, &mut self.primary, galley, key, modifiers);
move_single_cursor(
os,
&mut self.primary,
&mut self.h_pos,
galley,
key,
modifiers,
);
if !modifiers.shift {
self.secondary = self.primary;
}
@@ -156,7 +156,14 @@ impl CursorRange {
Key::P | Key::N | Key::B | Key::F | Key::A | Key::E
if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift =>
{
move_single_cursor(os, &mut self.primary, galley, key, modifiers);
move_single_cursor(
os,
&mut self.primary,
&mut self.h_pos,
galley,
key,
modifiers,
);
self.secondary = self.primary;
true
}
@@ -196,8 +203,9 @@ impl CursorRange {
ccursor_from_accesskit_text_position(_widget_id, galley, &selection.anchor);
if let (Some(primary), Some(secondary)) = (primary, secondary) {
*self = Self {
primary: galley.from_ccursor(primary),
secondary: galley.from_ccursor(secondary),
primary,
secondary,
h_pos: None,
};
return true;
}
@@ -210,71 +218,6 @@ impl CursorRange {
}
}
/// A selected text range (could be a range of length zero).
///
/// The selection is based on character count (NOT byte count!).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct CCursorRange {
/// When selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves.
/// Note that the two ends can come in any order, and also be equal (no selection).
pub primary: CCursor,
/// When selecting with a mouse, this is where the mouse was first pressed.
/// This part of the cursor does not move when shift is down.
pub secondary: CCursor,
}
impl CCursorRange {
/// The empty range.
#[inline]
pub fn one(ccursor: CCursor) -> Self {
Self {
primary: ccursor,
secondary: ccursor,
}
}
#[inline]
pub fn two(min: impl Into<CCursor>, max: impl Into<CCursor>) -> Self {
Self {
primary: max.into(),
secondary: min.into(),
}
}
#[inline]
pub fn is_sorted(&self) -> bool {
let p = self.primary;
let s = self.secondary;
(p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row)
}
/// returns the two ends ordered
#[inline]
pub fn sorted(&self) -> [CCursor; 2] {
if self.is_sorted() {
[self.primary, self.secondary]
} else {
[self.secondary, self.primary]
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PCursorRange {
/// When selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves.
/// Note that the two ends can come in any order, and also be equal (no selection).
pub primary: PCursor,
/// When selecting with a mouse, this is where the mouse was first pressed.
/// This part of the cursor does not move when shift is down.
pub secondary: PCursor,
}
// ----------------------------------------------------------------------------
#[cfg(feature = "accesskit")]
@@ -304,78 +247,83 @@ fn ccursor_from_accesskit_text_position(
/// Move a text cursor based on keyboard
fn move_single_cursor(
os: OperatingSystem,
cursor: &mut Cursor,
cursor: &mut CCursor,
h_pos: &mut Option<f32>,
galley: &Galley,
key: Key,
modifiers: &Modifiers,
) {
if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift {
match key {
Key::A => *cursor = galley.cursor_begin_of_row(cursor),
Key::E => *cursor = galley.cursor_end_of_row(cursor),
Key::P => *cursor = galley.cursor_up_one_row(cursor),
Key::N => *cursor = galley.cursor_down_one_row(cursor),
Key::B => *cursor = galley.cursor_left_one_character(cursor),
Key::F => *cursor = galley.cursor_right_one_character(cursor),
_ => (),
}
return;
}
match key {
Key::ArrowLeft => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
*cursor = galley.from_ccursor(ccursor_previous_word(galley, cursor.ccursor));
} else if modifiers.mac_cmd {
*cursor = galley.cursor_begin_of_row(cursor);
} else {
*cursor = galley.cursor_left_one_character(cursor);
let (new_cursor, new_h_pos) =
if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift {
match key {
Key::A => (galley.cursor_begin_of_row(cursor), None),
Key::E => (galley.cursor_end_of_row(cursor), None),
Key::P => galley.cursor_up_one_row(cursor, *h_pos),
Key::N => galley.cursor_down_one_row(cursor, *h_pos),
Key::B => (galley.cursor_left_one_character(cursor), None),
Key::F => (galley.cursor_right_one_character(cursor), None),
_ => return,
}
}
Key::ArrowRight => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
*cursor = galley.from_ccursor(ccursor_next_word(galley, cursor.ccursor));
} else if modifiers.mac_cmd {
*cursor = galley.cursor_end_of_row(cursor);
} else {
*cursor = galley.cursor_right_one_character(cursor);
}
}
Key::ArrowUp => {
if modifiers.command {
// mac and windows behavior
*cursor = galley.begin();
} else {
*cursor = galley.cursor_up_one_row(cursor);
}
}
Key::ArrowDown => {
if modifiers.command {
// mac and windows behavior
*cursor = galley.end();
} else {
*cursor = galley.cursor_down_one_row(cursor);
}
}
} else {
match key {
Key::ArrowLeft => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
(ccursor_previous_word(galley, *cursor), None)
} else if modifiers.mac_cmd {
(galley.cursor_begin_of_row(cursor), None)
} else {
(galley.cursor_left_one_character(cursor), None)
}
}
Key::ArrowRight => {
if modifiers.alt || modifiers.ctrl {
// alt on mac, ctrl on windows
(ccursor_next_word(galley, *cursor), None)
} else if modifiers.mac_cmd {
(galley.cursor_end_of_row(cursor), None)
} else {
(galley.cursor_right_one_character(cursor), None)
}
}
Key::ArrowUp => {
if modifiers.command {
// mac and windows behavior
(galley.begin(), None)
} else {
galley.cursor_up_one_row(cursor, *h_pos)
}
}
Key::ArrowDown => {
if modifiers.command {
// mac and windows behavior
(galley.end(), None)
} else {
galley.cursor_down_one_row(cursor, *h_pos)
}
}
Key::Home => {
if modifiers.ctrl {
// windows behavior
*cursor = galley.begin();
} else {
*cursor = galley.cursor_begin_of_row(cursor);
}
}
Key::End => {
if modifiers.ctrl {
// windows behavior
*cursor = galley.end();
} else {
*cursor = galley.cursor_end_of_row(cursor);
}
}
Key::Home => {
if modifiers.ctrl {
// windows behavior
(galley.begin(), None)
} else {
(galley.cursor_begin_of_row(cursor), None)
}
}
Key::End => {
if modifiers.ctrl {
// windows behavior
(galley.end(), None)
} else {
(galley.cursor_end_of_row(cursor), None)
}
}
_ => unreachable!(),
}
_ => unreachable!(),
}
};
*cursor = new_cursor;
*h_pos = new_h_pos;
}

View File

@@ -1,5 +1,7 @@
use std::sync::Arc;
use emath::TSTransform;
use crate::{
layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event,
Galley, Id, LayerId, Pos2, Rect, Response, Ui,
@@ -8,7 +10,7 @@ use crate::{
use super::{
text_cursor_state::cursor_rect,
visuals::{paint_text_selection, RowVertexIndices},
CursorRange, TextCursorState,
TextCursorState,
};
/// Turn on to help debug this
@@ -25,9 +27,14 @@ struct WidgetTextCursor {
}
impl WidgetTextCursor {
fn new(widget_id: Id, cursor: impl Into<CCursor>, galley_pos: Pos2, galley: &Galley) -> Self {
fn new(
widget_id: Id,
cursor: impl Into<CCursor>,
global_from_galley: TSTransform,
galley: &Galley,
) -> Self {
let ccursor = cursor.into();
let pos = pos_in_galley(galley_pos, galley, ccursor);
let pos = global_from_galley * pos_in_galley(galley, ccursor);
Self {
widget_id,
ccursor,
@@ -36,8 +43,8 @@ impl WidgetTextCursor {
}
}
fn pos_in_galley(galley_pos: Pos2, galley: &Galley, ccursor: CCursor) -> Pos2 {
galley_pos + galley.pos_from_ccursor(ccursor).center().to_vec2()
fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 {
galley.pos_from_cursor(ccursor).center()
}
impl std::fmt::Debug for WidgetTextCursor {
@@ -231,8 +238,7 @@ impl LabelSelectionState {
self.selection = None;
}
fn copy_text(&mut self, galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange) {
let new_galley_rect = Rect::from_min_size(galley_pos, galley.size());
fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CCursorRange) {
let new_text = selected_text(galley, cursor_range);
if new_text.is_empty() {
return;
@@ -266,7 +272,7 @@ impl LabelSelectionState {
let new_text_starts_with_space_or_punctuation = new_text
.chars()
.next()
.map_or(false, |c| c.is_whitespace() || c.is_ascii_punctuation());
.is_some_and(|c| c.is_whitespace() || c.is_ascii_punctuation());
if existing_ends_with_space == Some(false) && !new_text_starts_with_space_or_punctuation
{
@@ -311,7 +317,7 @@ impl LabelSelectionState {
&mut self,
ui: &Ui,
response: &Response,
galley_pos: Pos2,
global_from_galley: TSTransform,
galley: &Galley,
) -> TextCursorState {
let Some(selection) = &mut self.selection else {
@@ -324,6 +330,8 @@ impl LabelSelectionState {
return TextCursorState::default();
}
let galley_from_global = global_from_galley.inverse();
let multi_widget_text_select = ui.style().interaction.multi_widget_text_select;
let may_select_widget =
@@ -331,7 +339,8 @@ impl LabelSelectionState {
if self.is_dragging && may_select_widget {
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
let galley_rect = Rect::from_min_size(galley_pos, galley.size());
let galley_rect =
global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
let galley_rect = galley_rect.intersect(ui.clip_rect());
let is_in_same_column = galley_rect
@@ -345,7 +354,7 @@ impl LabelSelectionState {
let new_primary = if response.contains_pointer() {
// Dragging into this widget - easy case:
Some(galley.cursor_from_pos(pointer_pos - galley_pos))
Some(galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2()))
} else if is_in_same_column
&& !self.has_reached_primary
&& selection.primary.pos.y <= selection.secondary.pos.y
@@ -379,7 +388,7 @@ impl LabelSelectionState {
if let Some(new_primary) = new_primary {
selection.primary =
WidgetTextCursor::new(response.id, new_primary, galley_pos, galley);
WidgetTextCursor::new(response.id, new_primary, global_from_galley, galley);
// We don't want the latency of `drag_started`.
let drag_started = ui.input(|i| i.pointer.any_pressed());
@@ -405,11 +414,12 @@ impl LabelSelectionState {
let has_secondary = response.id == selection.secondary.widget_id;
if has_primary {
selection.primary.pos = pos_in_galley(galley_pos, galley, selection.primary.ccursor);
selection.primary.pos =
global_from_galley * pos_in_galley(galley, selection.primary.ccursor);
}
if has_secondary {
selection.secondary.pos =
pos_in_galley(galley_pos, galley, selection.secondary.ccursor);
global_from_galley * pos_in_galley(galley, selection.secondary.ccursor);
}
self.has_reached_primary |= has_primary;
@@ -426,7 +436,11 @@ impl LabelSelectionState {
match (primary, secondary) {
(Some(primary), Some(secondary)) => {
// This is the only selected label.
TextCursorState::from(CCursorRange { primary, secondary })
TextCursorState::from(CCursorRange {
primary,
secondary,
h_pos: None,
})
}
(Some(primary), None) => {
@@ -435,12 +449,16 @@ impl LabelSelectionState {
// Secondary was before primary.
// Select everything up to the cursor.
// We assume normal left-to-right and top-down layout order here.
galley.begin().ccursor
galley.begin()
} else {
// Select everything from the cursor onward:
galley.end().ccursor
galley.end()
};
TextCursorState::from(CCursorRange { primary, secondary })
TextCursorState::from(CCursorRange {
primary,
secondary,
h_pos: None,
})
}
(None, Some(secondary)) => {
@@ -449,12 +467,16 @@ impl LabelSelectionState {
// Primary was before secondary.
// Select everything up to the cursor.
// We assume normal left-to-right and top-down layout order here.
galley.begin().ccursor
galley.begin()
} else {
// Select everything from the cursor onward:
galley.end().ccursor
galley.end()
};
TextCursorState::from(CCursorRange { primary, secondary })
TextCursorState::from(CCursorRange {
primary,
secondary,
h_pos: None,
})
}
(None, None) => {
@@ -482,12 +504,22 @@ impl LabelSelectionState {
&mut self,
ui: &Ui,
response: &Response,
galley_pos: Pos2,
galley_pos_in_layer: Pos2,
galley: &mut Arc<Galley>,
) -> Vec<RowVertexIndices> {
let widget_id = response.id;
if response.hovered {
let global_from_layer = ui
.ctx()
.layer_transform_to_global(ui.layer_id())
.unwrap_or_default();
let layer_from_galley = TSTransform::from_translation(galley_pos_in_layer.to_vec2());
let galley_from_layer = layer_from_galley.inverse();
let layer_from_global = global_from_layer.inverse();
let galley_from_global = galley_from_layer * layer_from_global;
let global_from_galley = global_from_layer * layer_from_galley;
if response.hovered() {
ui.ctx().set_cursor_icon(CursorIcon::Text);
}
@@ -496,13 +528,14 @@ impl LabelSelectionState {
let old_selection = self.selection;
let mut cursor_state = self.cursor_for(ui, response, galley_pos, galley);
let mut cursor_state = self.cursor_for(ui, response, global_from_galley, galley);
let old_range = cursor_state.range(galley);
let old_range = cursor_state.char_range();
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
if response.contains_pointer() {
let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos);
let cursor_at_pointer =
galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2());
// This is where we handle start-of-drag and double-click-to-select.
// Actual drag-to-select happens elsewhere.
@@ -511,8 +544,8 @@ impl LabelSelectionState {
}
}
if let Some(mut cursor_range) = cursor_state.range(galley) {
let galley_rect = Rect::from_min_size(galley_pos, galley.size());
if let Some(mut cursor_range) = cursor_state.char_range() {
let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size());
self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect);
if let Some(selection) = &self.selection {
@@ -522,14 +555,14 @@ impl LabelSelectionState {
}
if got_copy_event(ui.ctx()) {
self.copy_text(galley_pos, galley, &cursor_range);
self.copy_text(galley_rect, galley, &cursor_range);
}
cursor_state.set_range(Some(cursor_range));
cursor_state.set_char_range(Some(cursor_range));
}
// Look for changes due to keyboard and/or mouse interaction:
let new_range = cursor_state.range(galley);
let new_range = cursor_state.char_range();
let selection_changed = old_range != new_range;
if let (true, Some(range)) = (selection_changed, new_range) {
@@ -544,23 +577,32 @@ impl LabelSelectionState {
if primary_changed || !ui.style().interaction.multi_widget_text_select {
selection.primary =
WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley);
WidgetTextCursor::new(widget_id, range.primary, global_from_galley, galley);
self.has_reached_primary = true;
}
if secondary_changed || !ui.style().interaction.multi_widget_text_select {
selection.secondary =
WidgetTextCursor::new(widget_id, range.secondary, galley_pos, galley);
selection.secondary = WidgetTextCursor::new(
widget_id,
range.secondary,
global_from_galley,
galley,
);
self.has_reached_secondary = true;
}
} else {
// Start of a new selection
self.selection = Some(CurrentSelection {
layer_id: response.layer_id,
primary: WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley),
primary: WidgetTextCursor::new(
widget_id,
range.primary,
global_from_galley,
galley,
),
secondary: WidgetTextCursor::new(
widget_id,
range.secondary,
galley_pos,
global_from_galley,
galley,
),
});
@@ -583,14 +625,14 @@ impl LabelSelectionState {
// Scroll to keep primary cursor in view:
let row_height = estimate_row_height(galley);
let primary_cursor_rect =
cursor_rect(galley_pos, galley, &range.primary, row_height);
global_from_galley * cursor_rect(galley, &range.primary, row_height);
ui.scroll_to_rect(primary_cursor_rect, None);
}
}
}
}
let cursor_range = cursor_state.range(galley);
let cursor_range = cursor_state.char_range();
let mut new_vertex_indices = vec![];
@@ -609,7 +651,7 @@ impl LabelSelectionState {
response.id,
cursor_range,
accesskit::Role::Label,
galley_pos,
global_from_galley,
galley,
);
@@ -630,7 +672,7 @@ fn process_selection_key_events(
ctx: &Context,
galley: &Galley,
widget_id: Id,
cursor_range: &mut CursorRange,
cursor_range: &mut CCursorRange,
) -> bool {
let os = ctx.os();
@@ -647,10 +689,10 @@ fn process_selection_key_events(
changed
}
fn selected_text(galley: &Galley, cursor_range: &CursorRange) -> String {
fn selected_text(galley: &Galley, cursor_range: &CCursorRange) -> String {
// This logic means we can select everything in an elided label (including the `…`)
// and still copy the entire un-elided text!
let everything_is_selected = cursor_range.contains(&CursorRange::select_all(galley));
let everything_is_selected = cursor_range.contains(CCursorRange::select_all(galley));
let copy_everything = cursor_range.is_empty() || everything_is_selected;

View File

@@ -8,6 +8,6 @@ mod label_text_selection;
pub mod text_cursor_state;
pub mod visuals;
pub use cursor_range::{CCursorRange, CursorRange, PCursorRange};
pub use cursor_range::CCursorRange;
pub use label_text_selection::LabelSelectionState;
pub use text_cursor_state::TextCursorState;

View File

@@ -1,13 +1,10 @@
//! Text cursor changes/interaction, without modifying the text.
use epaint::text::{
cursor::{CCursor, Cursor},
Galley,
};
use epaint::text::{cursor::CCursor, Galley};
use crate::{epaint, NumExt, Pos2, Rect, Response, Ui};
use crate::{epaint, NumExt, Rect, Response, Ui};
use super::{CCursorRange, CursorRange};
use super::CCursorRange;
/// The state of a text cursor selection.
///
@@ -16,29 +13,12 @@ use super::{CCursorRange, CursorRange};
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct TextCursorState {
cursor_range: Option<CursorRange>,
/// This is what is easiest to work with when editing text,
/// so users are more likely to read/write this.
ccursor_range: Option<CCursorRange>,
}
impl From<CursorRange> for TextCursorState {
fn from(cursor_range: CursorRange) -> Self {
Self {
cursor_range: Some(cursor_range),
ccursor_range: Some(CCursorRange {
primary: cursor_range.primary.ccursor,
secondary: cursor_range.secondary.ccursor,
}),
}
}
}
impl From<CCursorRange> for TextCursorState {
fn from(ccursor_range: CCursorRange) -> Self {
Self {
cursor_range: None,
ccursor_range: Some(ccursor_range),
}
}
@@ -46,50 +26,18 @@ impl From<CCursorRange> for TextCursorState {
impl TextCursorState {
pub fn is_empty(&self) -> bool {
self.cursor_range.is_none() && self.ccursor_range.is_none()
self.ccursor_range.is_none()
}
/// The currently selected range of characters.
pub fn char_range(&self) -> Option<CCursorRange> {
self.ccursor_range.or_else(|| {
self.cursor_range
.map(|cursor_range| cursor_range.as_ccursor_range())
})
}
pub fn range(&self, galley: &Galley) -> Option<CursorRange> {
self.cursor_range
.map(|cursor_range| {
// We only use the PCursor (paragraph number, and character offset within that paragraph).
// This is so that if we resize the [`TextEdit`] region, and text wrapping changes,
// we keep the same byte character offset from the beginning of the text,
// even though the number of rows changes
// (each paragraph can be several rows, due to word wrapping).
// The column (character offset) should be able to extend beyond the last word so that we can
// go down and still end up on the same column when we return.
CursorRange {
primary: galley.from_pcursor(cursor_range.primary.pcursor),
secondary: galley.from_pcursor(cursor_range.secondary.pcursor),
}
})
.or_else(|| {
self.ccursor_range.map(|ccursor_range| CursorRange {
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
})
})
self.ccursor_range
}
/// Sets the currently selected range of characters.
pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) {
self.cursor_range = None;
self.ccursor_range = ccursor_range;
}
pub fn set_range(&mut self, cursor_range: Option<CursorRange>) {
self.cursor_range = cursor_range;
self.ccursor_range = None;
}
}
impl TextCursorState {
@@ -100,7 +48,7 @@ impl TextCursorState {
&mut self,
ui: &Ui,
response: &Response,
cursor_at_pointer: Cursor,
cursor_at_pointer: CCursor,
galley: &Galley,
is_being_dragged: bool,
) -> bool {
@@ -108,39 +56,33 @@ impl TextCursorState {
if response.double_clicked() {
// Select word:
let ccursor_range = select_word_at(text, cursor_at_pointer.ccursor);
self.set_range(Some(CursorRange {
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
}));
let ccursor_range = select_word_at(text, cursor_at_pointer);
self.set_char_range(Some(ccursor_range));
true
} else if response.triple_clicked() {
// Select line:
let ccursor_range = select_line_at(text, cursor_at_pointer.ccursor);
self.set_range(Some(CursorRange {
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
}));
let ccursor_range = select_line_at(text, cursor_at_pointer);
self.set_char_range(Some(ccursor_range));
true
} else if response.sense.drag {
} else if response.sense.senses_drag() {
if response.hovered() && ui.input(|i| i.pointer.any_pressed()) {
// The start of a drag (or a click).
if ui.input(|i| i.modifiers.shift) {
if let Some(mut cursor_range) = self.range(galley) {
if let Some(mut cursor_range) = self.char_range() {
cursor_range.primary = cursor_at_pointer;
self.set_range(Some(cursor_range));
self.set_char_range(Some(cursor_range));
} else {
self.set_range(Some(CursorRange::one(cursor_at_pointer)));
self.set_char_range(Some(CCursorRange::one(cursor_at_pointer)));
}
} else {
self.set_range(Some(CursorRange::one(cursor_at_pointer)));
self.set_char_range(Some(CCursorRange::one(cursor_at_pointer)));
}
true
} else if is_being_dragged {
// Drag to select text:
if let Some(mut cursor_range) = self.range(galley) {
if let Some(mut cursor_range) = self.char_range() {
cursor_range.primary = cursor_at_pointer;
self.set_range(Some(cursor_range));
self.set_char_range(Some(cursor_range));
}
true
} else {
@@ -329,20 +271,25 @@ pub fn byte_index_from_char_index(s: &str, char_index: usize) -> usize {
}
pub fn slice_char_range(s: &str, char_range: std::ops::Range<usize>) -> &str {
assert!(char_range.start <= char_range.end);
assert!(
char_range.start <= char_range.end,
"Invalid range, start must be less than end, but start = {}, end = {}",
char_range.start,
char_range.end
);
let start_byte = byte_index_from_char_index(s, char_range.start);
let end_byte = byte_index_from_char_index(s, char_range.end);
&s[start_byte..end_byte]
}
/// The thin rectangle of one end of the selection, e.g. the primary cursor.
pub fn cursor_rect(galley_pos: Pos2, galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect {
let mut cursor_pos = galley
.pos_from_cursor(cursor)
.translate(galley_pos.to_vec2());
cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
/// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates.
pub fn cursor_rect(galley: &Galley, cursor: &CCursor, row_height: f32) -> Rect {
let mut cursor_pos = galley.pos_from_cursor(*cursor);
// Handle completely empty galleys
cursor_pos = cursor_pos.expand(1.5);
// slightly above/below row
cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
cursor_pos
}

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::{pos2, vec2, Galley, Painter, Rect, Ui, Visuals};
use super::CursorRange;
use super::CCursorRange;
#[derive(Clone, Debug)]
pub struct RowVertexIndices {
@@ -14,7 +14,7 @@ pub struct RowVertexIndices {
pub fn paint_text_selection(
galley: &mut Arc<Galley>,
visuals: &Visuals,
cursor_range: &CursorRange,
cursor_range: &CCursorRange,
mut new_vertex_indices: Option<&mut Vec<RowVertexIndices>>,
) {
if cursor_range.is_empty() {
@@ -27,8 +27,8 @@ pub fn paint_text_selection(
let color = visuals.selection.bg_fill;
let [min, max] = cursor_range.sorted_cursors();
let min = min.rcursor;
let max = max.rcursor;
let min = galley.layout_from_cursor(min);
let max = galley.layout_from_cursor(max);
for ri in min.row..=max.row {
let row = Arc::make_mut(&mut galley.rows[ri].row);
@@ -60,7 +60,11 @@ pub fn paint_text_selection(
// Start by appending the selection rectangle to end of the mesh, as two triangles (= 6 indices):
let num_indices_before = mesh.indices.len();
mesh.add_colored_rect(rect, color);
assert_eq!(num_indices_before + 6, mesh.indices.len());
assert_eq!(
num_indices_before + 6,
mesh.indices.len(),
"We expect exactly 6 new indices"
);
// Copy out the new triangles:
let selection_triangles = [

View File

@@ -1,11 +1,14 @@
#![warn(missing_docs)] // Let's keep `Ui` well-documented.
#![allow(clippy::use_self)]
use std::{any::Any, hash::Hash, sync::Arc};
use emath::GuiRounding as _;
use epaint::mutex::RwLock;
use std::{any::Any, hash::Hash, sync::Arc};
use crate::close_tag::ClosableTag;
use crate::containers::menu;
#[cfg(debug_assertions)]
use crate::Stroke;
use crate::{
containers::{CollapsingHeader, CollapsingResponse, Frame},
ecolor::Hsva,
@@ -13,8 +16,6 @@ use crate::{
epaint::text::Fonts,
grid,
layout::{Direction, Layout},
menu,
menu::MenuState,
pass_state,
placer::Placer,
pos2, style,
@@ -26,11 +27,9 @@ use crate::{
},
Align, Color32, Context, CursorIcon, DragAndDrop, Id, InnerResponse, InputState, LayerId,
Memory, Order, Painter, PlatformOutput, Pos2, Rangef, Rect, Response, Rgba, RichText, Sense,
Style, TextStyle, TextWrapMode, UiBuilder, UiStack, UiStackInfo, Vec2, WidgetRect, WidgetText,
Style, TextStyle, TextWrapMode, UiBuilder, UiKind, UiStack, UiStackInfo, Vec2, WidgetRect,
WidgetText,
};
#[cfg(debug_assertions)]
use crate::Stroke;
// ----------------------------------------------------------------------------
/// This is what you use to place widgets.
@@ -99,7 +98,8 @@ pub struct Ui {
sizing_pass: bool,
/// Indicates whether this Ui belongs to a Menu.
menu_state: Option<Arc<RwLock<MenuState>>>,
#[allow(deprecated)]
menu_state: Option<Arc<RwLock<crate::menu::MenuState>>>,
/// The [`UiStack`] for this [`Ui`].
stack: Arc<UiStack>,
@@ -286,7 +286,7 @@ impl Ui {
}
}
debug_assert!(!max_rect.any_nan());
debug_assert!(!max_rect.any_nan(), "max_rect is NaN: {max_rect:?}");
let stable_id = self.id.with(id_salt);
let unique_id = stable_id.with(self.next_auto_id_salt);
let next_auto_id_salt = unique_id.value().wrapping_add(1);
@@ -914,14 +914,20 @@ impl Ui {
/// Set the minimum width of the ui.
/// This can't shrink the ui, only make it larger.
pub fn set_min_width(&mut self, width: f32) {
debug_assert!(0.0 <= width);
debug_assert!(
0.0 <= width,
"Negative width makes no sense, but got: {width}"
);
self.placer.set_min_width(width);
}
/// Set the minimum height of the ui.
/// This can't shrink the ui, only make it larger.
pub fn set_min_height(&mut self, height: f32) {
debug_assert!(0.0 <= height);
debug_assert!(
0.0 <= height,
"Negative height makes no sense, but got: {height}"
);
self.placer.set_min_height(height);
}
@@ -1095,7 +1101,8 @@ impl Ui {
// This is the inverse of Context::read_response. We prefer a response
// based on last frame's widget rect since the one from this frame is Rect::NOTHING until
// Ui::interact_bg is called or the Ui is dropped.
self.ctx()
let mut response = self
.ctx()
.viewport(|viewport| {
viewport
.prev_pass
@@ -1107,7 +1114,11 @@ impl Ui {
.map(|widget_rect| self.ctx().get_response(widget_rect))
.expect(
"Since we always call Context::create_widget in Ui::new, this should never be None",
)
);
if self.should_close() {
response.set_close();
}
response
}
/// Update the [`WidgetRect`] created in [`Ui::new`] or [`Ui::new_child`] with the current
@@ -1121,7 +1132,7 @@ impl Ui {
fs.used_ids.remove(&self.unique_id);
});
// This will update the WidgetRect that was first created in `Ui::new`.
self.ctx().create_widget(
let mut response = self.ctx().create_widget(
WidgetRect {
id: self.unique_id,
layer_id: self.layer_id(),
@@ -1131,7 +1142,11 @@ impl Ui {
enabled: self.enabled,
},
false,
)
);
if self.should_close() {
response.set_close();
}
response
}
/// Interact with the background of this [`Ui`],
@@ -1165,6 +1180,104 @@ impl Ui {
pub fn ui_contains_pointer(&self) -> bool {
self.rect_contains_pointer(self.min_rect())
}
/// Find and close the first closable parent.
///
/// Use [`UiBuilder::closable`] to make a [`Ui`] closable.
/// You can then use [`Ui::should_close`] to check if it should be closed.
///
/// This is implemented for all egui containers, e.g. [`crate::Popup`], [`crate::Modal`],
/// [`crate::Area`], [`crate::Window`], [`crate::CollapsingHeader`], etc.
///
/// What exactly happens when you close a container depends on the container implementation.
/// [`crate::Area`] e.g. will return true from it's [`Response::should_close`] method.
///
/// If you want to close a specific kind of container, use [`Ui::close_kind`] instead.
///
/// Also note that this won't bubble up across [`crate::Area`]s. If needed, you can check
/// `response.should_close()` and close the parent manually. ([`menu`] does this for example).
///
/// See also:
/// - [`Ui::close_kind`]
/// - [`Ui::should_close`]
/// - [`Ui::will_parent_close`]
pub fn close(&self) {
let tag = self.stack.iter().find_map(|stack| {
stack
.info
.tags
.get_downcast::<ClosableTag>(ClosableTag::NAME)
});
if let Some(tag) = tag {
tag.set_close();
} else {
#[cfg(feature = "log")]
log::warn!("Called ui.close() on a Ui that has no closable parent.");
}
}
/// Find and close the first closable parent of a specific [`UiKind`].
///
/// This is useful if you want to e.g. close a [`crate::Window`]. Since it contains a
/// `Collapsible`, [`Ui::close`] would close the `Collapsible` instead.
/// You can close the [`crate::Window`] by calling `ui.close_kind(UiKind::Window)`.
///
/// See also:
/// - [`Ui::close`]
/// - [`Ui::should_close`]
/// - [`Ui::will_parent_close`]
pub fn close_kind(&self, ui_kind: UiKind) {
let tag = self
.stack
.iter()
.filter(|stack| stack.info.kind == Some(ui_kind))
.find_map(|stack| {
stack
.info
.tags
.get_downcast::<ClosableTag>(ClosableTag::NAME)
});
if let Some(tag) = tag {
tag.set_close();
} else {
#[cfg(feature = "log")]
log::warn!("Called ui.close_kind({ui_kind:?}) on ui with no such closable parent.");
}
}
/// Was [`Ui::close`] called on this [`Ui`] or any of its children?
/// Only works if the [`Ui`] was created with [`UiBuilder::closable`].
///
/// You can also check via this [`Ui`]'s [`Response::should_close`].
///
/// See also:
/// - [`Ui::will_parent_close`]
/// - [`Ui::close`]
/// - [`Ui::close_kind`]
/// - [`Response::should_close`]
pub fn should_close(&self) -> bool {
self.stack
.info
.tags
.get_downcast(ClosableTag::NAME)
.is_some_and(|tag: &ClosableTag| tag.should_close())
}
/// Will this [`Ui`] or any of its parents close this frame?
///
/// See also
/// - [`Ui::should_close`]
/// - [`Ui::close`]
/// - [`Ui::close_kind`]
pub fn will_parent_close(&self) -> bool {
self.stack.iter().any(|stack| {
stack
.info
.tags
.get_downcast::<ClosableTag>(ClosableTag::NAME)
.is_some_and(|tag| tag.should_close())
})
}
}
/// # Allocating space: where do I put my widgets?
@@ -1186,7 +1299,7 @@ impl Ui {
/// # egui::__run_test_ui(|ui| {
/// let response = ui.allocate_response(egui::vec2(100.0, 200.0), egui::Sense::click());
/// if response.clicked() { /* … */ }
/// ui.painter().rect_stroke(response.rect, 0.0, (1.0, egui::Color32::WHITE));
/// ui.painter().rect_stroke(response.rect, 0.0, (1.0, egui::Color32::WHITE), egui::StrokeKind::Inside);
/// # });
/// ```
pub fn allocate_response(&mut self, desired_size: Vec2, sense: Sense) -> Response {
@@ -1253,8 +1366,12 @@ impl Ui {
let debug_expand_height = self.style().debug.show_expand_height;
if (debug_expand_width && too_wide) || (debug_expand_height && too_high) {
self.painter
.rect_stroke(rect, 0.0, (1.0, Color32::LIGHT_BLUE));
self.painter.rect_stroke(
rect,
0.0,
(1.0, Color32::LIGHT_BLUE),
crate::StrokeKind::Inside,
);
let stroke = Stroke::new(2.5, Color32::from_rgb(200, 0, 0));
let paint_line_seg = |a, b| self.painter().line_segment([a, b], stroke);
@@ -1288,7 +1405,7 @@ impl Ui {
fn allocate_space_impl(&mut self, desired_size: Vec2) -> Rect {
let item_spacing = self.spacing().item_spacing;
let frame_rect = self.placer.next_space(desired_size, item_spacing);
debug_assert!(!frame_rect.any_nan());
debug_assert!(!frame_rect.any_nan(), "frame_rect is nan in allocate_space");
let widget_rect = self.placer.justify_and_align(frame_rect, desired_size);
self.placer
@@ -1311,7 +1428,7 @@ impl Ui {
/// Allocate a rect without interacting with it.
pub fn advance_cursor_after_rect(&mut self, rect: Rect) -> Id {
debug_assert!(!rect.any_nan());
debug_assert!(!rect.any_nan(), "rect is nan in advance_cursor_after_rect");
let rect = rect.round_ui();
let item_spacing = self.spacing().item_spacing;
@@ -1383,11 +1500,14 @@ impl Ui {
layout: Layout,
add_contents: Box<dyn FnOnce(&mut Self) -> R + 'c>,
) -> InnerResponse<R> {
debug_assert!(desired_size.x >= 0.0 && desired_size.y >= 0.0);
debug_assert!(
desired_size.x >= 0.0 && desired_size.y >= 0.0,
"Negative desired size: {desired_size:?}"
);
let item_spacing = self.spacing().item_spacing;
let frame_rect = self.placer.next_space(desired_size, item_spacing);
let child_rect = self.placer.justify_and_align(frame_rect, desired_size);
self.allocate_new_ui(
self.scope_dyn(
UiBuilder::new().max_rect(child_rect).layout(layout),
add_contents,
)
@@ -1404,7 +1524,7 @@ impl Ui {
max_rect: Rect,
add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<R> {
self.allocate_new_ui(UiBuilder::new().max_rect(max_rect), add_contents)
self.scope_builder(UiBuilder::new().max_rect(max_rect), add_contents)
}
/// Allocated space (`UiBuilder::max_rect`) and then add content to it.
@@ -1412,27 +1532,13 @@ impl Ui {
/// If the contents overflow, more space will be allocated.
/// When finished, the amount of space actually used (`min_rect`) will be allocated in the parent.
/// So you can request a lot of space and then use less.
#[deprecated = "Use `scope_builder` instead"]
pub fn allocate_new_ui<R>(
&mut self,
ui_builder: UiBuilder,
add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<R> {
self.allocate_new_ui_dyn(ui_builder, Box::new(add_contents))
}
fn allocate_new_ui_dyn<'c, R>(
&mut self,
ui_builder: UiBuilder,
add_contents: Box<dyn FnOnce(&mut Self) -> R + 'c>,
) -> InnerResponse<R> {
let mut child_ui = self.new_child(ui_builder);
let inner = add_contents(&mut child_ui);
let rect = child_ui.min_rect();
let item_spacing = self.spacing().item_spacing;
self.placer.advance_after_rects(rect, rect, item_spacing);
register_rect(self, rect);
let response = self.interact(rect, child_ui.unique_id, Sense::hover());
InnerResponse::new(inner, response)
self.scope_dyn(ui_builder, Box::new(add_contents))
}
/// Convenience function to get a region to paint on.
@@ -1638,7 +1744,7 @@ impl Ui {
///
/// See also [`Self::add`] and [`Self::add_sized`].
pub fn put(&mut self, max_rect: Rect, widget: impl Widget) -> Response {
self.allocate_new_ui(
self.scope_builder(
UiBuilder::new()
.max_rect(max_rect)
.layout(Layout::centered_and_justified(Direction::TopDown)),
@@ -1771,12 +1877,14 @@ impl Ui {
/// Add extra space before the next widget.
///
/// The direction is dependent on the layout.
/// This will be in addition to the [`crate::style::Spacing::item_spacing`].
///
/// This will be in addition to the [`crate::style::Spacing::item_spacing`]
/// that is always added, but `item_spacing` won't be added _again_ by `add_space`.
///
/// [`Self::min_rect`] will expand to contain the space.
#[inline]
pub fn add_space(&mut self, amount: f32) {
self.placer.advance_cursor(amount);
self.placer.advance_cursor(amount.round_ui());
}
/// Show some text.
@@ -2077,7 +2185,7 @@ impl Ui {
// only touch `*radians` if we actually changed the degree value
if degrees != radians.to_degrees() {
*radians = degrees.to_radians();
response.changed = true;
response.mark_changed();
}
response
@@ -2100,7 +2208,7 @@ impl Ui {
// only touch `*radians` if we actually changed the value
if taus != *radians / TAU {
*radians = taus * TAU;
response.changed = true;
response.mark_changed();
}
response
@@ -2121,7 +2229,7 @@ impl Ui {
/// ui.add(
/// egui::Image::new(egui::include_image!("../assets/ferris.png"))
/// .max_width(200.0)
/// .rounding(10.0),
/// .corner_radius(10),
/// );
/// # });
/// ```
@@ -2141,18 +2249,21 @@ impl Ui {
/// # Colors
impl Ui {
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
pub fn color_edit_button_srgba(&mut self, srgba: &mut Color32) -> Response {
color_picker::color_edit_button_srgba(self, srgba, color_picker::Alpha::BlendOrAdditive)
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
pub fn color_edit_button_hsva(&mut self, hsva: &mut Hsva) -> Response {
color_picker::color_edit_button_hsva(self, hsva, color_picker::Alpha::BlendOrAdditive)
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
/// The given color is in `sRGB` space.
pub fn color_edit_button_srgb(&mut self, srgb: &mut [u8; 3]) -> Response {
@@ -2160,6 +2271,7 @@ impl Ui {
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
/// The given color is in linear RGB space.
pub fn color_edit_button_rgb(&mut self, rgb: &mut [f32; 3]) -> Response {
@@ -2167,6 +2279,7 @@ impl Ui {
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
/// The given color is in `sRGBA` space with premultiplied alpha
pub fn color_edit_button_srgba_premultiplied(&mut self, srgba: &mut [u8; 4]) -> Response {
@@ -2177,6 +2290,7 @@ impl Ui {
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
/// The given color is in `sRGBA` space without premultiplied alpha.
/// If unsure, what "premultiplied alpha" is, then this is probably the function you want to use.
@@ -2189,6 +2303,7 @@ impl Ui {
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
/// The given color is in linear RGBA space with premultiplied alpha
pub fn color_edit_button_rgba_premultiplied(&mut self, rgba_premul: &mut [f32; 4]) -> Response {
@@ -2208,6 +2323,7 @@ impl Ui {
}
/// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown.
/// The given color is in linear RGBA space without premultiplied alpha.
/// If unsure, what "premultiplied alpha" is, then this is probably the function you want to use.
@@ -2527,7 +2643,7 @@ impl Ui {
/// See also [`Self::with_layout`] for more options.
#[inline]
pub fn vertical<R>(&mut self, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
self.allocate_new_ui(
self.scope_builder(
UiBuilder::new().layout(Layout::top_down(Align::Min)),
add_contents,
)
@@ -2549,7 +2665,7 @@ impl Ui {
&mut self,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
self.allocate_new_ui(
self.scope_builder(
UiBuilder::new().layout(Layout::top_down(Align::Center)),
add_contents,
)
@@ -2570,7 +2686,7 @@ impl Ui {
&mut self,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
self.allocate_new_ui(
self.scope_builder(
UiBuilder::new().layout(Layout::top_down(Align::Center).with_cross_justify(true)),
add_contents,
)
@@ -2596,7 +2712,7 @@ impl Ui {
layout: Layout,
add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<R> {
self.allocate_new_ui(UiBuilder::new().layout(layout), add_contents)
self.scope_builder(UiBuilder::new().layout(layout), add_contents)
}
/// This will make the next added widget centered and justified in the available space.
@@ -2606,7 +2722,7 @@ impl Ui {
&mut self,
add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<R> {
self.allocate_new_ui(
self.scope_builder(
UiBuilder::new().layout(Layout::centered_and_justified(Direction::TopDown)),
add_contents,
)
@@ -2894,14 +3010,16 @@ impl Ui {
/// Close the menu we are in (including submenus), if any.
///
/// See also: [`Self::menu_button`] and [`Response::context_menu`].
pub fn close_menu(&mut self) {
if let Some(menu_state) = &mut self.menu_state {
menu_state.write().close();
}
self.menu_state = None;
#[deprecated = "Use `ui.close()` or `ui.close_kind(UiKind::Menu)` instead"]
pub fn close_menu(&self) {
self.close_kind(UiKind::Menu);
}
pub(crate) fn set_menu_state(&mut self, menu_state: Option<Arc<RwLock<MenuState>>>) {
#[allow(deprecated)]
pub(crate) fn set_menu_state(
&mut self,
menu_state: Option<Arc<RwLock<crate::menu::MenuState>>>,
) {
self.menu_state = menu_state;
}
@@ -2915,24 +3033,25 @@ impl Ui {
/// ui.menu_button("My menu", |ui| {
/// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// });
/// # });
/// ```
///
/// See also: [`Self::close_menu`] and [`Response::context_menu`].
/// See also: [`Self::close`] and [`Response::context_menu`].
pub fn menu_button<R>(
&mut self,
title: impl Into<WidgetText>,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() {
menu::submenu_button(self, menu_state, title, add_contents)
let (response, inner) = if menu::is_in_menu(self) {
menu::SubMenuButton::new(title).ui(self, add_contents)
} else {
menu::menu_button(self, title, add_contents)
}
menu::MenuButton::new(title).ui(self, add_contents)
};
InnerResponse::new(inner.map(|i| i.inner), response)
}
/// Create a menu button with an image that when clicked will show the given menu.
@@ -2946,7 +3065,7 @@ impl Ui {
/// ui.menu_image_button(title, img, |ui| {
/// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// });
@@ -2954,18 +3073,22 @@ impl Ui {
/// ```
///
///
/// See also: [`Self::close_menu`] and [`Response::context_menu`].
/// See also: [`Self::close`] and [`Response::context_menu`].
#[inline]
pub fn menu_image_button<'a, R>(
&mut self,
image: impl Into<Image<'a>>,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() {
menu::submenu_button(self, menu_state, String::new(), add_contents)
let (response, inner) = if menu::is_in_menu(self) {
menu::SubMenuButton::from_button(
Button::image(image).right_text(menu::SubMenuButton::RIGHT_ARROW),
)
.ui(self, add_contents)
} else {
menu::menu_custom_button(self, Button::image(image), add_contents)
}
menu::MenuButton::from_button(Button::image(image)).ui(self, add_contents)
};
InnerResponse::new(inner.map(|i| i.inner), response)
}
/// Create a menu button with an image and a text that when clicked will show the given menu.
@@ -2980,14 +3103,14 @@ impl Ui {
/// ui.menu_image_text_button(img, title, |ui| {
/// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// });
/// # });
/// ```
///
/// See also: [`Self::close_menu`] and [`Response::context_menu`].
/// See also: [`Self::close`] and [`Response::context_menu`].
#[inline]
pub fn menu_image_text_button<'a, R>(
&mut self,
@@ -2995,11 +3118,16 @@ impl Ui {
title: impl Into<WidgetText>,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() {
menu::submenu_button(self, menu_state, title, add_contents)
let (response, inner) = if menu::is_in_menu(self) {
menu::SubMenuButton::from_button(
Button::image_and_text(image, title).right_text(menu::SubMenuButton::RIGHT_ARROW),
)
.ui(self, add_contents)
} else {
menu::menu_custom_button(self, Button::image_and_text(image, title), add_contents)
}
menu::MenuButton::from_button(Button::image_and_text(image, title))
.ui(self, add_contents)
};
InnerResponse::new(inner.map(|i| i.inner), response)
}
}

View File

@@ -1,9 +1,9 @@
use std::{hash::Hash, sync::Arc};
use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo};
use crate::close_tag::ClosableTag;
#[allow(unused_imports)] // Used for doclinks
use crate::Ui;
use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo};
/// Build a [`Ui`] as the child of another [`Ui`].
///
@@ -125,6 +125,7 @@ impl UiBuilder {
}
/// Set if you want sense clicks and/or drags. Default is [`Sense::hover`].
///
/// The sense will be registered below the Senses of any widgets contained in this [`Ui`], so
/// if the user clicks a button contained within this [`Ui`], that button will receive the click
/// instead.
@@ -135,4 +136,19 @@ impl UiBuilder {
self.sense = Some(sense);
self
}
/// Make this [`Ui`] closable.
///
/// Calling [`Ui::close`] in a child [`Ui`] will mark this [`Ui`] for closing.
/// After [`Ui::close`] was called, [`Ui::should_close`] and [`crate::Response::should_close`] will
/// return `true` (for this frame).
///
/// This works by adding a [`ClosableTag`] to the [`UiStackInfo`].
#[inline]
pub fn closable(mut self) -> Self {
self.ui_stack_info
.tags
.insert(ClosableTag::NAME, Some(Arc::new(ClosableTag::default())));
self
}
}

View File

@@ -53,6 +53,9 @@ pub enum UiKind {
/// An [`crate::Area`] that is not of any other kind.
GenericArea,
/// A collapsible container, e.g. a [`crate::CollapsingHeader`].
Collapsible,
}
impl UiKind {
@@ -81,6 +84,7 @@ impl UiKind {
| Self::Frame
| Self::ScrollArea
| Self::Resize
| Self::Collapsible
| Self::TableCell => false,
Self::Window
@@ -229,13 +233,13 @@ impl UiStack {
/// Is this [`crate::Ui`] a panel?
#[inline]
pub fn is_panel_ui(&self) -> bool {
self.kind().map_or(false, |kind| kind.is_panel())
self.kind().is_some_and(|kind| kind.is_panel())
}
/// Is this [`crate::Ui`] an [`crate::Area`]?
#[inline]
pub fn is_area_ui(&self) -> bool {
self.kind().map_or(false, |kind| kind.is_area())
self.kind().is_some_and(|kind| kind.is_area())
}
/// Is this a root [`crate::Ui`], i.e. created with [`crate::Ui::new()`]?
@@ -285,4 +289,4 @@ impl<'a> Iterator for UiStackIterator<'a> {
}
}
impl<'a> FusedIterator for UiStackIterator<'a> {}
impl FusedIterator for UiStackIterator<'_> {}

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