1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-31 13:50:04 -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 * text=auto eol=lf
Cargo.lock linguist-generated=false Cargo.lock linguist-generated=false
*.png filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
# Exclude some small files from LFS: # Exclude some small files from LFS:
crates/eframe/data/* !filter !diff !merge text=auto eol=lf crates/eframe/data/* !filter !diff !merge text=auto eol=lf

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ jobs:
exclude_pattern=$(printf "|^%s" "${exclude_paths[@]}" | sed 's/^|//') 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 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 exit 1
fi fi
done done

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ on: [pull_request]
jobs: jobs:
typos: typos:
# https://github.com/crate-ci/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 # install and run locally: cargo install typos-cli && typos
name: typos name: typos
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -14,15 +14,7 @@ jobs:
- name: Check spelling of entire workspace - name: Check spelling of entire workspace
uses: crate-ci/typos@master 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: linkinator:
name: linkinator name: linkinator
runs-on: ubuntu-latest runs-on: ubuntu-latest

1
.gitignore vendored
View File

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

View File

@@ -6,6 +6,17 @@
ime = "ime" # Input Method Editor ime = "ime" # Input Method Editor
nknown = "nknown" # part of @55nknown username nknown = "nknown" # part of @55nknown username
ro = "ro" # read-only, also part of the username @Phen-Ro 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] [files]
extend-exclude = ["web_demo/egui_demo_app.js"] # auto-generated extend-exclude = ["web_demo/egui_demo_app.js"] # auto-generated

View File

@@ -33,7 +33,11 @@
"--all-features", "--all-features",
], ],
"rust-analyzer.showUnlinkedFileNotification": false, "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)`. // 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. // Don't forget to put it in a comment again before committing.
// "rust-analyzer.cargo.target": "wasm32-unknown-unknown", // "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. 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 ## 0.30.0 - 2024-12-16 - Modals and better layer support
### ✨ Highlights ### ✨ 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`. You can test your code locally by running `./scripts/check.sh`.
There are snapshots test that might need to be updated. 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. 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). For more info about the tests see [egui_kittest](./crates/egui_kittest/README.md).
Snapshots and other big files are stored with git lfs. See [Working with git lfs](#working-with-git-lfs) for more info.
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 .`.
If you see an `InvalidSignature` error when running snapshot tests, it's probably a problem related to git-lfs. 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! 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. 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 ## PR review
Most PR reviews are done by me, Emil, but I very much appreciate any help I can get reviewing PRs! 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] [workspace.package]
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.80" rust-version = "1.81"
version = "0.30.0" version = "0.31.1"
[profile.release] [profile.release]
@@ -55,24 +55,27 @@ opt-level = 2
[workspace.dependencies] [workspace.dependencies]
emath = { version = "0.30.0", path = "crates/emath", default-features = false } emath = { version = "0.31.1", path = "crates/emath", default-features = false }
ecolor = { version = "0.30.0", path = "crates/ecolor", default-features = false } ecolor = { version = "0.31.1", path = "crates/ecolor", default-features = false }
epaint = { version = "0.30.0", path = "crates/epaint", default-features = false } epaint = { version = "0.31.1", path = "crates/epaint", default-features = false }
epaint_default_fonts = { version = "0.30.0", path = "crates/epaint_default_fonts" } epaint_default_fonts = { version = "0.31.1", path = "crates/epaint_default_fonts" }
egui = { version = "0.30.0", path = "crates/egui", default-features = false } egui = { version = "0.31.1", path = "crates/egui", default-features = false }
egui-winit = { version = "0.30.0", path = "crates/egui-winit", default-features = false } egui-winit = { version = "0.31.1", path = "crates/egui-winit", default-features = false }
egui_extras = { version = "0.30.0", path = "crates/egui_extras", default-features = false } egui_extras = { version = "0.31.1", path = "crates/egui_extras", default-features = false }
egui-wgpu = { version = "0.30.0", path = "crates/egui-wgpu", default-features = false } egui-wgpu = { version = "0.31.1", path = "crates/egui-wgpu", default-features = false }
egui_demo_lib = { version = "0.30.0", path = "crates/egui_demo_lib", default-features = false } egui_demo_lib = { version = "0.31.1", path = "crates/egui_demo_lib", default-features = false }
egui_glow = { version = "0.30.0", path = "crates/egui_glow", default-features = false } egui_glow = { version = "0.31.1", path = "crates/egui_glow", default-features = false }
egui_kittest = { version = "0.30.0", path = "crates/egui_kittest", default-features = false } egui_kittest = { version = "0.31.1", path = "crates/egui_kittest", default-features = false }
eframe = { version = "0.30.0", path = "crates/eframe", 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 = [ 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 "no-rng", # we don't need DOS-protection, so we let users opt-in to it instead
"std", "std",
] } ] }
backtrace = "0.3" backtrace = "0.3"
bitflags = "2.6"
bytemuck = "1.7.2" bytemuck = "1.7.2"
criterion = { version = "0.5.1", default-features = false } criterion = { version = "0.5.1", default-features = false }
dify = { version = "0.7", 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 } glutin-winit = { version = "0.5.0", default-features = false }
home = "0.5.9" home = "0.5.9"
image = { version = "0.25", default-features = false } 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"] } log = { version = "0.4", features = ["std"] }
nohash-hasher = "0.2" nohash-hasher = "0.2"
parking_lot = "0.12" parking_lot = "0.12"
@@ -99,7 +102,7 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
web-sys = "0.3.70" web-sys = "0.3.70"
web-time = "1.1.0" # Timekeeping for native and web 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" windows-sys = "0.59"
winit = { version = "0.30.7", default-features = false } winit = { version = "0.30.7", default-features = false }
@@ -113,6 +116,7 @@ rust_2018_idioms = { level = "warn", priority = -1 }
rust_2021_prelude_collisions = "warn" rust_2021_prelude_collisions = "warn"
semicolon_in_expressions_from_macros = "warn" semicolon_in_expressions_from_macros = "warn"
trivial_numeric_casts = "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 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_extern_crates = "warn"
unused_import_braces = "warn" unused_import_braces = "warn"
@@ -202,8 +206,8 @@ match_same_arms = "warn"
match_wild_err_arm = "warn" match_wild_err_arm = "warn"
match_wildcard_for_single_variants = "warn" match_wildcard_for_single_variants = "warn"
mem_forget = "warn" mem_forget = "warn"
mismatched_target_os = "warn"
mismatching_type_param_order = "warn" mismatching_type_param_order = "warn"
missing_assert_message = "warn"
missing_enforced_import_renames = "warn" missing_enforced_import_renames = "warn"
missing_errors_doc = "warn" missing_errors_doc = "warn"
missing_safety_doc = "warn" missing_safety_doc = "warn"
@@ -271,7 +275,6 @@ zero_sized_map_values = "warn"
# TODO(emilk): maybe enable more of these lints? # TODO(emilk): maybe enable more of these lints?
iter_over_hash_type = "allow" iter_over_hash_type = "allow"
missing_assert_message = "allow"
should_panic_without_expect = "allow" should_panic_without_expect = "allow"
too_many_lines = "allow" too_many_lines = "allow"
unwrap_used = "allow" # TODO(emilk): We really wanna warn on this one unwrap_used = "allow" # TODO(emilk): We really wanna warn on this one

View File

@@ -11,7 +11,7 @@
<div align="center"> <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> egui development is sponsored by [Rerun](https://www.rerun.io/), a startup building<br>
an SDK for visualizing streams of multimodal data. 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")); 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: ## Sections:
@@ -136,23 +136,23 @@ Still, egui can be used to create professional looking applications, like [the R
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. 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: 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 ## Dependencies
`egui` has a minimal set of default dependencies: `egui` has a minimal set of default dependencies:
* [`ab_glyph`](https://crates.io/crates/ab_glyph) * [`ab_glyph`](https://crates.io/crates/ab_glyph)
* [`ahash`](https://crates.io/crates/ahash) * [`ahash`](https://crates.io/crates/ahash)
* [`bitflags`](https://crates.io/crates/bitflags)
* [`nohash-hasher`](https://crates.io/crates/nohash-hasher) * [`nohash-hasher`](https://crates.io/crates/nohash-hasher)
* [`parking_lot`](https://crates.io/crates/parking_lot) * [`parking_lot`](https://crates.io/crates/parking_lot)
Heavier dependencies are kept out of `egui`, even as opt-in. 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. To load images into `egui` you can use the official [`egui_extras`](https://github.com/emilk/egui/tree/master/crates/egui_extras) crate.
@@ -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): 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`? ### 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: 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"> <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> egui development is sponsored by [Rerun](https://www.rerun.io/), a startup building<br>
an SDK for visualizing streams of multimodal data. 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 ## Release testing
* [ ] `cargo r -p egui_demo_app` and click around for while * [ ] `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 `eframe_template` and test
* [ ] update `egui_plot` and test * [ ] update `egui_plot` and test
* [ ] update `egui_table` and test
* [ ] update `egui_tiles` and test * [ ] update `egui_tiles` and test
* [ ] test with Rerun * [ ] test with Rerun
* [ ] `./scripts/check.sh` * [ ] `./scripts/check.sh`
* [ ] check that CI is green * [ ] check that CI is green
## Preparation ## Preparation
* [ ] make sure there are no important unmerged PRs
* [ ] run `scripts/generate_example_screenshots.sh` if needed * [ ] run `scripts/generate_example_screenshots.sh` if needed
* [ ] write a short release note that fits in a tweet * [ ] write a short release note that fits in a bluesky post
* [ ] record gif for `CHANGELOG.md` release note (and later twitter post) * [ ] record gif for `CHANGELOG.md` release note (and later bluesky post)
* [ ] update changelogs using `scripts/generate_changelog.py --version 0.x.0 --write` * [ ] update changelogs using `scripts/generate_changelog.py --version 0.x.0 --write`
* [ ] bump version numbers in workspace `Cargo.toml` * [ ] 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. 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` * [ ] Run `typos`
* [ ] `git commit -m 'Release 0.x.0 - summary'` * [ ] `git commit -m 'Release 0.x.0 - <release title>'`
* [ ] `cargo publish` (see below) * [ ] `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` * [ ] `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` * [ ] merge release PR or push to `master`
* [ ] check that CI is green * [ ] 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 && cargo publish --quiet) && echo "✅ egui"
(cd crates/egui-winit && cargo publish --quiet) && echo "✅ egui-winit" (cd crates/egui-winit && cargo publish --quiet) && echo "✅ egui-winit"
(cd crates/egui-wgpu && cargo publish --quiet) && echo "✅ egui-wgpu" (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_kittest && cargo publish --quiet) && echo "✅ egui_kittest"
(cd crates/egui_extras && cargo publish --quiet) && echo "✅ egui_extras" (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_demo_lib && cargo publish --quiet) && echo "✅ egui_demo_lib"
(cd crates/egui_glow && cargo publish --quiet) && echo "✅ egui_glow" (cd crates/egui_glow && cargo publish --quiet) && echo "✅ egui_glow"
(cd crates/eframe && cargo publish --quiet) && echo "✅ eframe"
``` ```
\<continue with the checklist above\>
## Announcements ## Announcements
* [ ] [twitter](https://x.com/ernerfeldt/status/1772665412225823105) * [ ] [Bluesky](https://bsky.app/profile/ernerfeldt.bsky.social)
* [ ] egui discord * [ ] egui discord
* [ ] [r/rust](https://www.reddit.com/r/rust/comments/1bocr5s/announcing_egui_027_with_improved_menus_and/) * [ ] [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/) * [ ] [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_plot`
* [ ] publish new `egui_table` * [ ] publish new `egui_table`
* [ ] publish new `egui_tiles` * [ ] 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: # Section identical to scripts/clippy_wasm/clippy.toml:
msrv = "1.80" msrv = "1.81"
allow-unwrap-in-tests = true 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. 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 ## 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) * 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) * 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. 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 /// Instead of manipulating this directly it is often better
/// to first convert it to either [`Rgba`] or [`crate::Hsva`]. /// to first convert it to either [`Rgba`] or [`crate::Hsva`].
/// ///
/// Internally this uses 0-255 gamma space `sRGBA` color with premultiplied alpha. /// Internally this uses 0-255 gamma space `sRGBA` color with _premultiplied alpha_.
/// Alpha channel is in linear space.
/// ///
/// 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)] #[repr(C)]
#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[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]); pub struct Color32(pub(crate) [u8; 4]);
impl std::fmt::Debug for Color32 { impl std::fmt::Debug for Color32 {
/// Prints the contents with premultiplied alpha!
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [r, g, b, a] = self.0; let [r, g, b, a] = self.0;
write!(f, "#{r:02X}_{g:02X}_{b:02X}_{a:02X}") 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 RED: Self = Self::from_rgb(255, 0, 0);
pub const LIGHT_RED: Self = Self::from_rgb(255, 128, 128); 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 YELLOW: Self = Self::from_rgb(255, 255, 0);
pub const ORANGE: Self = Self::from_rgb(255, 165, 0); pub const ORANGE: Self = Self::from_rgb(255, 165, 0);
pub const LIGHT_YELLOW: Self = Self::from_rgb(255, 255, 0xE0); pub const LIGHT_YELLOW: Self = Self::from_rgb(255, 255, 0xE0);
pub const KHAKI: Self = Self::from_rgb(240, 230, 140); 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 BLUE: Self = Self::from_rgb(0, 0, 255);
pub const LIGHT_BLUE: Self = Self::from_rgb(0xAD, 0xD8, 0xE6); 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 GOLD: Self = Self::from_rgb(255, 215, 0);
pub const DEBUG_COLOR: Self = Self::from_rgba_premultiplied(0, 200, 0, 128); pub const DEBUG_COLOR: Self = Self::from_rgba_premultiplied(0, 200, 0, 128);
@@ -85,41 +105,49 @@ impl Color32 {
#[deprecated = "Renamed to PLACEHOLDER"] #[deprecated = "Renamed to PLACEHOLDER"]
pub const TEMPORARY_COLOR: Self = Self::PLACEHOLDER; pub const TEMPORARY_COLOR: Self = Self::PLACEHOLDER;
/// From RGB with alpha of 255 (opaque).
#[inline] #[inline]
pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self { pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 255]) Self([r, g, b, 255])
} }
/// From RGB into an additive color (will make everything it blend with brighter).
#[inline] #[inline]
pub const fn from_rgb_additive(r: u8, g: u8, b: u8) -> Self { pub const fn from_rgb_additive(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 0]) Self([r, g, b, 0])
} }
/// From `sRGBA` with premultiplied alpha. /// From `sRGBA` with premultiplied alpha.
///
/// You likely want to use [`Self::from_rgba_unmultiplied`] instead.
#[inline] #[inline]
pub const fn from_rgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self { pub const fn from_rgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
Self([r, g, b, a]) 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] #[inline]
pub fn from_rgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self { pub fn from_rgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
use std::sync::OnceLock; use std::sync::OnceLock;
match a { match a {
// common-case optimization // common-case optimization:
0 => Self::TRANSPARENT, 0 => Self::TRANSPARENT,
// common-case optimization
// common-case optimization:
255 => Self::from_rgb(r, g, b), 255 => Self::from_rgb(r, g, b),
a => { a => {
static LOOKUP_TABLE: OnceLock<Box<[u8]>> = OnceLock::new(); static LOOKUP_TABLE: OnceLock<Box<[u8]>> = OnceLock::new();
let lut = LOOKUP_TABLE.get_or_init(|| { let lut = LOOKUP_TABLE.get_or_init(|| {
use crate::{gamma_u8_from_linear_f32, linear_f32_from_gamma_u8};
(0..=u16::MAX) (0..=u16::MAX)
.map(|i| { .map(|i| {
let [value, alpha] = i.to_ne_bytes(); let [value, alpha] = i.to_ne_bytes();
let value_lin = linear_f32_from_gamma_u8(value); fast_round(value as f32 * linear_f32_from_linear_u8(alpha))
let alpha_lin = linear_f32_from_linear_u8(alpha);
gamma_u8_from_linear_f32(value_lin * alpha_lin)
}) })
.collect() .collect()
}); });
@@ -131,22 +159,26 @@ impl Color32 {
} }
} }
/// Opaque gray.
#[doc(alias = "from_grey")] #[doc(alias = "from_grey")]
#[inline] #[inline]
pub const fn from_gray(l: u8) -> Self { pub const fn from_gray(l: u8) -> Self {
Self([l, l, l, 255]) Self([l, l, l, 255])
} }
/// Black with the given opacity.
#[inline] #[inline]
pub const fn from_black_alpha(a: u8) -> Self { pub const fn from_black_alpha(a: u8) -> Self {
Self([0, 0, 0, a]) Self([0, 0, 0, a])
} }
/// White with the given opacity.
#[inline] #[inline]
pub fn from_white_alpha(a: u8) -> Self { 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] #[inline]
pub const fn from_additive_luminance(l: u8) -> Self { pub const fn from_additive_luminance(l: u8) -> Self {
Self([l, l, l, 0]) Self([l, l, l, 0])
@@ -157,21 +189,25 @@ impl Color32 {
self.a() == 255 self.a() == 255
} }
/// Red component multiplied by alpha.
#[inline] #[inline]
pub const fn r(&self) -> u8 { pub const fn r(&self) -> u8 {
self.0[0] self.0[0]
} }
/// Green component multiplied by alpha.
#[inline] #[inline]
pub const fn g(&self) -> u8 { pub const fn g(&self) -> u8 {
self.0[1] self.0[1]
} }
/// Blue component multiplied by alpha.
#[inline] #[inline]
pub const fn b(&self) -> u8 { pub const fn b(&self) -> u8 {
self.0[2] self.0[2]
} }
/// Alpha (opacity).
#[inline] #[inline]
pub const fn a(&self) -> u8 { pub const fn a(&self) -> u8 {
self.0[3] self.0[3]
@@ -208,9 +244,26 @@ impl Color32 {
(self.r(), self.g(), self.b(), self.a()) (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] #[inline]
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] { 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. /// 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`]. /// This is perceptually even, and faster that [`Self::linear_multiply`].
#[inline] #[inline]
pub fn gamma_multiply(self, factor: f32) -> Self { 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; let Self([r, g, b, a]) = self;
Self([ Self([
(r as f32 * factor + 0.5) as u8, (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. /// Multiply with 0.5 to make color half as opaque in linear space.
/// ///
/// This is using linear space, which is not perceptually even. /// This is using linear space, which is not perceptually even.
/// You likely want to use [`Self::gamma_multiply`] instead. /// You likely want to use [`Self::gamma_multiply`] instead.
#[inline] #[inline]
pub fn linear_multiply(self, factor: f32) -> Self { 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 // As an unfortunate side-effect of using premultiplied alpha
// we need a somewhat expensive conversion to linear space and back. // we need a somewhat expensive conversion to linear space and back.
Rgba::from(self).multiply(factor).into() Rgba::from(self).multiply(factor).into()
@@ -268,6 +344,19 @@ impl Color32 {
fast_round(lerp((self[3] as f32)..=(other[3] as f32), t)), 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 { 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) let [r, gb] = u16::from_str_radix(s, 16)
.map_err(ParseHexColorError::InvalidInt)? .map_err(ParseHexColorError::InvalidInt)?
.to_be_bytes(); .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))) Ok(Self::Hex3(Color32::from_rgb(r, g, b)))
} }
4 => { 4 => {
let [r_g, b_a] = u16::from_str_radix(s, 16) let [r_g, b_a] = u16::from_str_radix(s, 16)
.map_err(ParseHexColorError::InvalidInt)? .map_err(ParseHexColorError::InvalidInt)?
.to_be_bytes(); .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))) Ok(Self::Hex4(Color32::from_rgba_unmultiplied(r, g, b, a)))
} }
6 => { 6 => {
@@ -207,17 +208,22 @@ mod tests {
#[test] #[test]
fn hex_string_round_trip() { fn hex_string_round_trip() {
use Color32 as C;
let cases = [ let cases = [
C::from_rgba_unmultiplied(10, 20, 30, 0), [0, 20, 30, 0],
C::from_rgba_unmultiplied(10, 20, 30, 40), [10, 0, 30, 40],
C::from_rgba_unmultiplied(10, 20, 30, 255), [10, 100, 200, 0],
C::from_rgba_unmultiplied(0, 20, 30, 0), [10, 100, 200, 100],
C::from_rgba_unmultiplied(10, 0, 30, 40), [10, 100, 200, 200],
C::from_rgba_unmultiplied(10, 20, 0, 255), [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 { for [r, g, b, a] in cases {
assert_eq!(C::from_hex(color.to_hex().as_str()), Ok(color)); 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::{ use crate::{
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8, gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_u8_from_linear_f32, Color32, Rgba,
linear_u8_from_linear_f32, Color32, Rgba,
}; };
/// Hue, saturation, value, alpha. All in the range [0, 1]. /// Hue, saturation, value, alpha. All in the range [0, 1].
@@ -29,30 +28,20 @@ impl Hsva {
/// From `sRGBA` with premultiplied alpha /// From `sRGBA` with premultiplied alpha
#[inline] #[inline]
pub fn from_srgba_premultiplied([r, g, b, a]: [u8; 4]) -> Self { pub fn from_srgba_premultiplied([r, g, b, a]: [u8; 4]) -> Self {
Self::from_rgba_premultiplied( Self::from(Color32::from_rgba_premultiplied(r, g, b, a))
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
linear_f32_from_linear_u8(a),
)
} }
/// From `sRGBA` without premultiplied alpha /// From `sRGBA` without premultiplied alpha
#[inline] #[inline]
pub fn from_srgba_unmultiplied([r, g, b, a]: [u8; 4]) -> Self { pub fn from_srgba_unmultiplied([r, g, b, a]: [u8; 4]) -> Self {
Self::from_rgba_unmultiplied( Self::from(Color32::from_rgba_unmultiplied(r, g, b, a))
linear_f32_from_gamma_u8(r),
linear_f32_from_gamma_u8(g),
linear_f32_from_gamma_u8(b),
linear_f32_from_linear_u8(a),
)
} }
/// From linear RGBA with premultiplied alpha /// From linear RGBA with premultiplied alpha
#[inline] #[inline]
pub fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self { pub fn from_rgba_premultiplied(r: f32, g: f32, b: f32, a: f32) -> Self {
#![allow(clippy::many_single_char_names)] #![allow(clippy::many_single_char_names)]
if a == 0.0 { if a <= 0.0 {
if r == 0.0 && b == 0.0 && a == 0.0 { if r == 0.0 && b == 0.0 && a == 0.0 {
Self::default() Self::default()
} else { } else {
@@ -152,13 +141,7 @@ impl Hsva {
#[inline] #[inline]
pub fn to_srgba_premultiplied(&self) -> [u8; 4] { pub fn to_srgba_premultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_premultiplied(); Color32::from(*self).to_array()
[
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g),
gamma_u8_from_linear_f32(b),
linear_u8_from_linear_f32(a),
]
} }
/// To gamma-space 0-255. /// To gamma-space 0-255.

View File

@@ -1,9 +1,20 @@
//! Color conversions and types. //! 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 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`]. //! 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 //! ## Feature flags
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] #![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
//! //!
@@ -39,23 +50,46 @@ pub use hex_color_runtime::*;
impl From<Color32> for Rgba { impl From<Color32> for Rgba {
fn from(srgba: Color32) -> Self { fn from(srgba: Color32) -> Self {
Self([ let [r, g, b, a] = srgba.to_array();
linear_f32_from_gamma_u8(srgba.0[0]), if a == 0 {
linear_f32_from_gamma_u8(srgba.0[1]), // Additive, or completely transparent
linear_f32_from_gamma_u8(srgba.0[2]), Self([
linear_f32_from_linear_u8(srgba.0[3]), 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 { impl From<Rgba> for Color32 {
fn from(rgba: Rgba) -> Self { fn from(rgba: Rgba) -> Self {
Self([ let [r, g, b, a] = rgba.to_array();
gamma_u8_from_linear_f32(rgba.0[0]), if a == 0.0 {
gamma_u8_from_linear_f32(rgba.0[1]), // Additive, or completely transparent
gamma_u8_from_linear_f32(rgba.0[2]), Self([
linear_u8_from_linear_f32(rgba.0[3]), 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::{ use crate::Color32;
gamma_u8_from_linear_f32, linear_f32_from_gamma_u8, linear_f32_from_linear_u8,
linear_u8_from_linear_f32,
};
/// 0-1 linear space `RGBA` color with premultiplied alpha. /// 0-1 linear space `RGBA` color with premultiplied alpha.
///
/// See [`crate::Color32`] for explanation of what "premultiplied alpha" means.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -70,20 +69,12 @@ impl Rgba {
#[inline] #[inline]
pub fn from_srgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self { pub fn from_srgba_premultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r); Self::from(Color32::from_rgba_premultiplied(r, g, b, a))
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)
} }
#[inline] #[inline]
pub fn from_srgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self { pub fn from_srgba_unmultiplied(r: u8, g: u8, b: u8, a: u8) -> Self {
let r = linear_f32_from_gamma_u8(r); Self::from(Color32::from_rgba_unmultiplied(r, g, b, a))
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)
} }
#[inline] #[inline]
@@ -99,15 +90,24 @@ impl Rgba {
#[inline] #[inline]
pub fn from_luminance_alpha(l: f32, a: f32) -> Self { pub fn from_luminance_alpha(l: f32, a: f32) -> Self {
debug_assert!(0.0 <= l && l <= 1.0); debug_assert!(
debug_assert!(0.0 <= a && a <= 1.0); 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]) Self([l * a, l * a, l * a, a])
} }
/// Transparent black /// Transparent black
#[inline] #[inline]
pub fn from_black_alpha(a: f32) -> Self { 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]) Self([0.0, 0.0, 0.0, a])
} }
@@ -211,13 +211,12 @@ impl Rgba {
/// unmultiply the alpha /// unmultiply the alpha
#[inline] #[inline]
pub fn to_srgba_unmultiplied(&self) -> [u8; 4] { pub fn to_srgba_unmultiplied(&self) -> [u8; 4] {
let [r, g, b, a] = self.to_rgba_unmultiplied(); crate::Color32::from(*self).to_srgba_unmultiplied()
[ }
gamma_u8_from_linear_f32(r),
gamma_u8_from_linear_f32(g), /// Blend two colors in linear space, so that `self` is behind the argument.
gamma_u8_from_linear_f32(b), pub fn blend(self, on_top: Self) -> Self {
linear_u8_from_linear_f32(a.abs()), 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 # Changelog for eframe
All notable changes to the `eframe` crate. 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. 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. 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 ## 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. 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: # mac:
[target.'cfg(any(target_os = "macos"))'.dependencies] [target.'cfg(any(target_os = "macos"))'.dependencies]
objc2 = "0.5.1" objc2 = "0.5.1"
objc2-foundation = { version = "0.2.0", features = [ objc2-foundation = { version = "0.2.0", default-features = false, features = [
"std",
"block2", "block2",
"NSData", "NSData",
"NSString", "NSString",
] } ] }
objc2-app-kit = { version = "0.2.0", features = [ objc2-app-kit = { version = "0.2.0", default-features = false, features = [
"std",
"NSApplication", "NSApplication",
"NSImage", "NSImage",
"NSMenu", "NSMenu",
@@ -209,6 +211,7 @@ percent-encoding = "2.1"
wasm-bindgen.workspace = true wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [ web-sys = { workspace = true, features = [
"AddEventListenerOptions",
"BinaryType", "BinaryType",
"Blob", "Blob",
"BlobPropertyBag", "BlobPropertyBag",
@@ -250,6 +253,7 @@ web-sys = { workspace = true, features = [
"ResizeObserverEntry", "ResizeObserverEntry",
"ResizeObserverOptions", "ResizeObserverOptions",
"ResizeObserverSize", "ResizeObserverSize",
"ShadowRoot",
"Storage", "Storage",
"Touch", "Touch",
"TouchEvent", "TouchEvent",

View File

@@ -878,20 +878,6 @@ pub trait Storage {
fn flush(&mut self); 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. /// Get and deserialize the [RON](https://github.com/ron-rs/ron) stored at the given key.
#[cfg(feature = "ron")] #[cfg(feature = "ron")]
pub fn get_value<T: serde::de::DeserializeOwned>(storage: &dyn Storage, key: &str) -> Option<T> { 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" { extern "C" {
fn wcslen(buf: *const u16) -> usize; fn wcslen(buf: *const u16) -> usize;
} }
unsafe { let mut path_raw = ptr::null_mut();
let mut path = ptr::null_mut();
match SHGetKnownFolderPath( // 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, &FOLDERID_RoamingAppData,
KF_FLAG_DONT_VERIFY as u32, KF_FLAG_DONT_VERIFY as u32,
std::ptr::null_mut(), std::ptr::null_mut(),
&mut path, &mut path_raw,
) { )
S_OK => { };
let path_slice = slice::from_raw_parts(path, wcslen(path));
let s = OsString::from_wide(&path_slice); let path = if result == S_OK {
CoTaskMemFree(path.cast()); // SAFETY: SHGetKnownFolderPath indicated success and is supposed to allocate a nullterminated string for us.
Some(PathBuf::from(s)) let path_slice = unsafe { slice::from_raw_parts(path_raw, wcslen(path_raw)) };
} Some(PathBuf::from(OsString::from_wide(path_slice)))
_ => { } else {
// Free any allocated memory even on failure. A null ptr is a no-op for `CoTaskMemFree`. None
CoTaskMemFree(path.cast()); };
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"))] #[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. /// 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 { pub struct FileStorage {
ron_filepath: PathBuf, ron_filepath: PathBuf,
kv: HashMap<String, String>, kv: HashMap<String, String>,

View File

@@ -272,7 +272,7 @@ impl<'app> GlowWinitApp<'app> {
.. ..
} = viewport } = 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> { fn egui_ctx(&self) -> Option<&egui::Context> {
self.running.as_ref().map(|r| &r.integration.egui_ctx) 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()) .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) { fn save_and_destroy(&mut self) {
if let Some(mut running) = self.running.take() { if let Some(mut running) = self.running.take() {
profiling::function_scope!(); profiling::function_scope!();
@@ -413,7 +427,7 @@ impl<'app> WinitApp for GlowWinitApp<'app> {
if let Some(running) = &mut self.running { if let Some(running) = &mut self.running {
running.glutin.borrow_mut().on_suspend()?; running.glutin.borrow_mut().on_suspend()?;
} }
Ok(EventResult::Wait) Ok(EventResult::Save)
} }
fn device_event( fn device_event(
@@ -479,7 +493,7 @@ impl<'app> WinitApp for GlowWinitApp<'app> {
} }
} }
impl<'app> GlowWinitRunning<'app> { impl GlowWinitRunning<'_> {
fn run_ui_and_paint( fn run_ui_and_paint(
&mut self, &mut self,
event_loop: &ActiveEventLoop, event_loop: &ActiveEventLoop,
@@ -1214,10 +1228,12 @@ impl GlutinWindowContext {
.expect("viewport doesn't exist") .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> { fn window(&self, viewport_id: ViewportId) -> Arc<Window> {
self.viewport(viewport_id) self.window_opt(viewport_id)
.window
.clone()
.expect("winit window doesn't exist") .expect("winit window doesn't exist")
} }

View File

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

View File

@@ -183,7 +183,7 @@ impl<'app> WgpuWinitApp<'app> {
) -> crate::Result<&mut WgpuWinitRunning<'app>> { ) -> crate::Result<&mut WgpuWinitRunning<'app>> {
profiling::function_scope!(); profiling::function_scope!();
#[allow(unsafe_code, unused_mut, unused_unsafe)] #[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(), egui_ctx.clone(),
self.native_options.wgpu_options.clone(), self.native_options.wgpu_options.clone(),
self.native_options.multisampling.max(1) as _, 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.viewport.transparent.unwrap_or(false),
self.native_options.dithering, self.native_options.dithering,
); ));
let window = Arc::new(window); let window = Arc::new(window);
@@ -249,7 +249,7 @@ impl<'app> WgpuWinitApp<'app> {
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
{ {
let event_loop_proxy = self.repaint_proxy.lock().clone(); 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) 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> { fn egui_ctx(&self) -> Option<&egui::Context> {
self.running.as_ref().map(|r| &r.integration.egui_ctx) 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) { fn save_and_destroy(&mut self) {
if let Some(mut running) = self.running.take() { if let Some(mut running) = self.running.take() {
running.save_and_destroy(); running.save_and_destroy();
@@ -415,7 +422,7 @@ impl<'app> WinitApp for WgpuWinitApp<'app> {
fn suspended(&mut self, _: &ActiveEventLoop) -> crate::Result<EventResult> { fn suspended(&mut self, _: &ActiveEventLoop) -> crate::Result<EventResult> {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
self.drop_window()?; self.drop_window()?;
Ok(EventResult::Wait) Ok(EventResult::Save)
} }
fn device_event( 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) { fn save_and_destroy(&mut self) {
profiling::function_scope!(); profiling::function_scope!();
let mut shared = self.shared.borrow_mut(); self.save();
if let Some(Viewport { window, .. }) = shared.viewports.get(&ViewportId::ROOT) {
self.integration.save(self.app.as_mut(), window.as_deref());
}
#[cfg(feature = "glow")] #[cfg(feature = "glow")]
self.app.on_exit(None); self.app.on_exit(None);
@@ -502,6 +519,7 @@ impl<'app> WgpuWinitRunning<'app> {
#[cfg(not(feature = "glow"))] #[cfg(not(feature = "glow"))]
self.app.on_exit(); self.app.on_exit();
let mut shared = self.shared.borrow_mut();
shared.painter.destroy(); shared.painter.destroy();
} }

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ impl AppRunner {
egui_ctx.options_mut(|o| { egui_ctx.options_mut(|o| {
// On web by default egui follows the zoom factor of the browser, // 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`]. // A user can still zoom egui separately by calling [`egui::Context::set_zoom_factor`].
o.zoom_with_keyboard = false; o.zoom_with_keyboard = false;
o.zoom_factor = 1.0; 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 canvas_size = super::canvas_size_in_points(self.canvas(), self.egui_ctx());
let mut raw_input = self.input.new_frame(canvas_size); 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); self.app.raw_input_hook(&self.egui_ctx, &mut raw_input);
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| { let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {
@@ -324,6 +336,9 @@ impl AppRunner {
egui::OutputCommand::OpenUrl(open_url) => { egui::OutputCommand::OpenUrl(open_url) => {
super::open_url(&open_url.url, open_url.new_tab); 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::{ use super::{
button_from_mouse_event, location_hash, modifiers_from_kb_event, modifiers_from_mouse_event, 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, modifiers_from_wheel_event, native_pixels_per_point, pos_from_mouse_event,
push_touches, text_from_keyboard_event, theme_from_dark_mode, translate_key, AppRunner, prefers_color_scheme_dark, primary_touch_pos, push_touches, text_from_keyboard_event,
Closure, JsCast, JsValue, WebRunner, 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` // TODO(emilk): there are more calls to `prevent_default` and `stop_propagation`
// than what is probably needed. // than what is probably needed.
@@ -215,9 +219,16 @@ fn should_prevent_default_for_key(
// * cmd-shift-C (debug tools) // * cmd-shift-C (debug tools)
// * cmd/ctrl-c/v/x (lest we prevent copy/paste/cut events) // * 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. // Prevent cmd/ctrl plus these keys from triggering the default browser action:
if egui_key == egui::Key::P && (modifiers.ctrl || modifiers.command || modifiers.mac_cmd) { let keys = [
return true; 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() { 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(); 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"] { for event_name in &["load", "pagehide", "pageshow"] {
runner_ref.add_event_listener(window, event_name, move |_: web_sys::Event, runner| { 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(); runner.needs_repaint.repaint_asap();
})?; })?;
} }
@@ -380,6 +398,48 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
Ok(()) 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( fn install_color_scheme_change_event(
runner_ref: &WebRunner, runner_ref: &WebRunner,
window: &web_sys::Window, 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. /// 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). /// Pass in the position in browser viewport coordinates (usually event.clientX/Y).
fn is_interested_in_pointer_event(runner: &AppRunner, pos: egui::Pos2) -> bool { fn is_interested_in_pointer_event(runner: &AppRunner, pos: egui::Pos2) -> bool {
let document = web_sys::window().unwrap().document().unwrap(); let root_node = runner.canvas().get_root_node();
let is_hovering_canvas = document
.element_from_point(pos.x, pos.y) let element_at_point = if let Some(document) = root_node.dyn_ref::<Document>() {
.is_some_and(|element| element.eq(runner.canvas())); 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 let is_pointer_down = runner
.egui_ctx() .egui_ctx()
.input(|i| i.pointer.any_down() || i.any_touches()); .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(()) Ok(())
} }
/// Install a `ResizeObserver` to observe changes to the size of the canvas. /// A `ResizeObserver` is used 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.
/// ///
/// The resize observer is called the by the browser at `observe` time, instead of just on the first actual resize. /// 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, /// 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). /// to avoid [#4622](https://github.com/emilk/egui/issues/4622).
pub(crate) fn install_resize_observer(runner_ref: &WebRunner) -> Result<(), JsValue> { pub struct ResizeObserverContext {
let closure = Closure::wrap(Box::new({ observer: web_sys::ResizeObserver,
let runner_ref = runner_ref.clone();
move |entries: js_sys::Array| { // Kept so it is not dropped until we are done with it.
// Only call the wrapped closure if the egui code has not panicked _closure: Closure<dyn FnMut(js_sys::Array)>,
if let Some(mut runner_lock) = runner_ref.try_lock() { }
let canvas = runner_lock.canvas();
let (width, height) = match get_display_size(&entries) { impl Drop for ResizeObserverContext {
Ok(v) => v, fn drop(&mut self) {
Err(err) => { self.observer.disconnect();
log::error!("{}", super::string_from_js_value(&err)); }
return; }
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_width(width); canvas.set_height(height);
canvas.set_height(height);
// force an immediate repaint // force an immediate repaint
runner_lock.needs_repaint.repaint_asap(); runner_lock.needs_repaint.repaint_asap();
paint_if_needed(&mut runner_lock); paint_if_needed(&mut runner_lock);
drop(runner_lock); drop(runner_lock);
// we rely on the resize observer to trigger the first `request_animation_frame`: // we rely on the resize observer to trigger the first `request_animation_frame`:
if let Err(err) = runner_ref.request_animation_frame() { if let Err(err) = runner_ref.request_animation_frame() {
log::error!("{}", super::string_from_js_value(&err)); 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 observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
let options = web_sys::ResizeObserverOptions::new();
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox); Ok(Self {
if let Some(runner_lock) = runner_ref.try_lock() { observer,
observer.observe_with_options(runner_lock.canvas(), &options); _closure: closure,
drop(runner_lock); })
runner_ref.set_resize_observer(observer, 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: // 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(); width = size.inline_size();
height = size.block_size(); height = size.block_size();
dpr = 1.0; // no need to apply 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()) { } else if JsValue::from_str("contentBoxSize").js_in(entry.as_ref()) {
let content_box_size = entry.content_box_size(); let content_box_size = entry.content_box_size();
let idx0 = content_box_size.at(0); 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(); width = size.inline_size();
height = size.block_size(); height = size.block_size();
} }
if DEBUG_RESIZE {
log::info!("contentBoxSize {width}x{height}");
}
} else { } else {
// legacy // legacy
let content_rect = entry.content_rect(); let content_rect = entry.content_rect();

View File

@@ -41,7 +41,7 @@ pub(crate) type ActiveWebPainter = web_painter_wgpu::WebPainterWgpu;
pub use backend::*; pub use backend::*;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::MediaQueryList; use web_sys::{Document, MediaQueryList, Node};
use input::{ use input::{
button_from_mouse_event, modifiers_from_kb_event, modifiers_from_mouse_event, 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 { pub(crate) fn string_from_js_value(value: &JsValue) -> String {
value.as_string().unwrap_or_else(|| format!("{value:#?}")) 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 /// - `<a>`/`<area>` with an `href` attribute
/// - `<input>`/`<select>`/`<textarea>`/`<button>` which aren't `disabled` /// - `<input>`/`<select>`/`<textarea>`/`<button>` which aren't `disabled`
/// - any other element with a `tabindex` attribute /// - any other element with a `tabindex` attribute
pub(crate) fn focused_element() -> Option<web_sys::Element> { pub(crate) fn focused_element(root: &Node) -> Option<web_sys::Element> {
web_sys::window()? if let Some(document) = root.dyn_ref::<Document>() {
.document()? document.active_element()
.active_element()? } else if let Some(shadow) = root.dyn_ref::<web_sys::ShadowRoot>() {
.dyn_into() shadow.active_element()
.ok() } else {
None
}
} }
pub(crate) fn has_focus<T: JsCast>(element: &T) -> bool { pub(crate) fn has_focus<T: JsCast>(element: &T) -> bool {
fn try_has_focus<T: JsCast>(element: &T) -> Option<bool> { fn try_has_focus<T: JsCast>(element: &T) -> Option<bool> {
let element = element.dyn_ref::<web_sys::Element>()?; 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) Some(element == &focused_element)
} }
try_has_focus(element).unwrap_or(false) 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 { 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( egui::vec2(
canvas.width() as f32 / pixels_per_point, canvas.width() as f32 / pixels_per_point,
canvas.height() 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() .decode_utf8_lossy()
.to_string() .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. /// 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. /// with an added callstack.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PanicSummary { pub struct PanicSummary {
@@ -66,7 +66,7 @@ pub struct PanicSummary {
impl PanicSummary { impl PanicSummary {
/// Construct a summary from a panic. /// 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 message = info.to_string();
let callstack = Error::new().stack(); let callstack = Error::new().stack();
Self { message, callstack } Self { message, callstack }

View File

@@ -4,6 +4,7 @@
use std::cell::Cell; use std::cell::Cell;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Document, Node};
use super::{AppRunner, WebRunner}; use super::{AppRunner, WebRunner};
@@ -14,15 +15,16 @@ pub struct TextAgent {
impl TextAgent { impl TextAgent {
/// Attach the agent to the document. /// 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(); let document = web_sys::window().unwrap().document().unwrap();
// create an `<input>` element // create an `<input>` element
let input = document let input = document
.create_element("input")? .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_type("text");
input.set_autofocus(true);
input.set_attribute("autocapitalize", "off")?; input.set_attribute("autocapitalize", "off")?;
// append it to `<body>` and hide it outside of the viewport // 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("position", "absolute")?;
style.set_property("top", "0")?; style.set_property("top", "0")?;
style.set_property("left", "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 // attach event listeners

View File

@@ -4,7 +4,7 @@ use super::web_painter::WebPainter;
use crate::WebOptions; use crate::WebOptions;
use egui::{Event, UserData, ViewportId}; use egui::{Event, UserData, ViewportId};
use egui_wgpu::capture::{capture_channel, CaptureReceiver, CaptureSender, CaptureState}; 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 wasm_bindgen::JsValue;
use web_sys::HtmlCanvasElement; use web_sys::HtmlCanvasElement;
@@ -63,49 +63,7 @@ impl WebPainterWgpu {
) -> Result<Self, String> { ) -> Result<Self, String> {
log::debug!("Creating wgpu painter"); log::debug!("Creating wgpu painter");
let instance = match &options.wgpu_options.wgpu_setup { let instance = options.wgpu_options.wgpu_setup.new_instance().await;
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 surface = instance let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
.map_err(|err| format!("failed to create wgpu surface: {err}"))?; .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 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 /// 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. /// If we ever panic during running, this `RefCell` is poisoned.
/// So before we use it, we need to check [`Self::panic_handler`]. /// 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. /// 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 /// 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 { Self {
panic_handler, panic_handler,
runner: Rc::new(RefCell::new(None)), app_runner: Rc::new(RefCell::new(None)),
events_to_unsubscribe: Rc::new(RefCell::new(Default::default())), events_to_unsubscribe: Rc::new(RefCell::new(Default::default())),
frame: Default::default(), frame: Default::default(),
resize_observer: Default::default(), resize_observer: Default::default(),
@@ -58,28 +62,36 @@ impl WebRunner {
) -> Result<(), JsValue> { ) -> Result<(), JsValue> {
self.destroy(); 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. // Make sure the canvas can be given focus.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex // 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: // 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)?; // First set up the app runner:
let text_agent = TextAgent::attach(self, canvas.get_root_node())?;
// The resize observer handles calling `request_animation_frame` to start the render loop. let app_runner =
events::install_resize_observer(self)?; 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(()) Ok(())
} }
@@ -109,10 +121,7 @@ impl WebRunner {
} }
} }
if let Some(context) = self.resize_observer.take() { self.resize_observer.replace(None);
context.resize_observer.disconnect();
drop(context.closure);
}
} }
/// Shut down eframe and clean up resources. /// Shut down eframe and clean up resources.
@@ -124,7 +133,7 @@ impl WebRunner {
window.cancel_animation_frame(frame.id).ok(); 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(); runner.destroy();
} }
} }
@@ -138,7 +147,7 @@ impl WebRunner {
self.unsubscribe_from_all_events(); self.unsubscribe_from_all_events();
None None
} else { } 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() }) std::cell::RefMut::filter_map(lock, |lock| -> Option<&mut AppRunner> { lock.as_mut() })
.ok() .ok()
} }
@@ -166,20 +175,45 @@ impl WebRunner {
event_name: &'static str, event_name: &'static str,
mut closure: impl FnMut(E, &mut AppRunner) + 'static, mut closure: impl FnMut(E, &mut AppRunner) + 'static,
) -> Result<(), wasm_bindgen::JsValue> { ) -> 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 // Create a JS closure based on the FnMut provided
let closure = Closure::wrap(Box::new(move |event: web_sys::Event| { let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
// Only call the wrapped closure if the egui code has not panicked // 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 // Cast the event to the expected event type
let event = event.unchecked_into::<E>(); 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)>); }) as Box<dyn FnMut(web_sys::Event)>);
// Add the event listener to the target // 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 { let handle = TargetEvent {
target: target.clone(), target: target.clone(),
@@ -208,13 +242,13 @@ impl WebRunner {
let window = web_sys::window().unwrap(); let window = web_sys::window().unwrap();
let closure = Closure::once({ let closure = Closure::once({
let runner_ref = self.clone(); let web_runner = self.clone();
move || { move || {
// We can paint now, so clear the animation frame. // We can paint now, so clear the animation frame.
// This drops the `closure` and allows another // This drops the `closure` and allows another
// animation frame to be scheduled // animation frame to be scheduled
let _ = runner_ref.frame.take(); let _ = web_runner.frame.take();
events::paint_and_schedule(&runner_ref) events::paint_and_schedule(&web_runner)
} }
}); });
@@ -226,19 +260,6 @@ impl WebRunner {
Ok(()) 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>>, _closure: Closure<dyn FnMut() -> Result<(), JsValue>>,
} }
struct ResizeObserverContext {
resize_observer: web_sys::ResizeObserver,
closure: Closure<dyn FnMut(js_sys::Array)>,
}
struct TargetEvent { struct TargetEvent {
target: web_sys::EventTarget, target: web_sys::EventTarget,
event_name: String, 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. 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 ## 0.30.0 - 2024-12-16
* Fix docs.rs build [#5204](https://github.com/emilk/egui/pull/5204) by [@lucasmerlin](https://github.com/lucasmerlin) * 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) * 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( encoder.copy_texture_to_buffer(
tex.as_image_copy(), tex.as_image_copy(),
wgpu::ImageCopyBuffer { wgpu::TexelCopyBufferInfo {
buffer: &buffer, buffer: &buffer,
layout: wgpu::ImageDataLayout { layout: wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(padding.padded_bytes_per_row), bytes_per_row: Some(padding.padded_bytes_per_row),
rows_per_image: None, 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`]. /// Low-level painting of [`egui`](https://github.com/emilk/egui) on [`wgpu`].
mod renderer; mod renderer;
mod setup;
pub use renderer::*; pub use renderer::*;
use wgpu::{Adapter, Device, Instance, Queue, TextureFormat}; pub use setup::{NativeAdapterSelectorMethod, WgpuSetup, WgpuSetupCreateNew, WgpuSetupExisting};
/// Helpers for capturing screenshots of the UI. /// Helpers for capturing screenshots of the UI.
pub mod capture; pub mod capture;
@@ -40,8 +42,8 @@ use epaint::mutex::RwLock;
/// An error produced by egui-wgpu. /// An error produced by egui-wgpu.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum WgpuError { pub enum WgpuError {
#[error("Failed to create wgpu adapter, no suitable adapter found.")] #[error("Failed to create wgpu adapter, no suitable adapter found: {0}")]
NoSuitableAdapterFound, NoSuitableAdapterFound(String),
#[error("There was no valid format for the surface at all.")] #[error("There was no valid format for the surface at all.")]
NoSurfaceFormatsAvailable, NoSurfaceFormatsAvailable,
@@ -61,20 +63,20 @@ pub enum WgpuError {
#[derive(Clone)] #[derive(Clone)]
pub struct RenderState { pub struct RenderState {
/// Wgpu adapter used for rendering. /// Wgpu adapter used for rendering.
pub adapter: Arc<wgpu::Adapter>, pub adapter: wgpu::Adapter,
/// All the available adapters. /// All the available adapters.
/// ///
/// This is not available on web. /// This is not available on web.
/// On web, we always select WebGPU is available, then fall back to WebGL if not. /// On web, we always select WebGPU is available, then fall back to WebGL if not.
#[cfg(not(target_arch = "wasm32"))] #[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. /// 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. /// 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. /// The target texture format used for presenting to the window.
pub target_format: wgpu::TextureFormat, pub target_format: wgpu::TextureFormat,
@@ -83,6 +85,72 @@ pub struct RenderState {
pub renderer: Arc<RwLock<Renderer>>, 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 { impl RenderState {
/// Creates a new [`RenderState`], containing everything needed for drawing egui with wgpu. /// 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. // This is always an empty list on web.
#[cfg(not(target_arch = "wasm32"))] #[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() { let (adapter, device, queue) = match config.wgpu_setup.clone() {
WgpuSetup::CreateNew { WgpuSetup::CreateNew(WgpuSetupCreateNew {
supported_backends: _, instance_descriptor: _,
power_preference, power_preference,
native_adapter_selector: _native_adapter_selector,
device_descriptor, device_descriptor,
} => { trace_path,
}) => {
let adapter = { let adapter = {
profiling::scope!("request_adapter"); #[cfg(target_arch = "wasm32")]
instance {
.request_adapter(&wgpu::RequestAdapterOptions { 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, power_preference,
compatible_surface, compatible_surface,
force_fallback_adapter: false, &available_adapters,
}) )
.await .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) = { let (device, queue) = {
profiling::scope!("request_device"); profiling::scope!("request_device");
adapter adapter
.request_device( .request_device(&(*device_descriptor)(&adapter), trace_path.as_deref())
&(*device_descriptor)(&adapter),
trace_path.ok().as_ref().map(std::path::Path::new),
)
.await? .await?
}; };
// On wasm, depending on feature flags, wgpu objects may or may not implement sync. (adapter, device, queue)
// 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))
} }
WgpuSetup::Existing { WgpuSetup::Existing(WgpuSetupExisting {
instance: _, instance: _,
adapter, adapter,
device, device,
queue, queue,
} => (adapter, device, queue), }) => (adapter, device, queue),
}; };
let surface_formats = { let surface_formats = {
profiling::scope!("get_capabilities"); profiling::scope!("get_capabilities");
compatible_surface.map_or_else( compatible_surface.map_or_else(
|| vec![TextureFormat::Rgba8Unorm], || vec![wgpu::TextureFormat::Rgba8Unorm],
|s| s.get_capabilities(&adapter).formats, |s| s.get_capabilities(&adapter).formats,
) )
}; };
@@ -209,7 +246,7 @@ impl RenderState {
Ok(Self { Ok(Self {
adapter, adapter,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
available_adapters: available_adapters.into(), available_adapters,
device, device,
queue, queue,
target_format, target_format,
@@ -225,14 +262,11 @@ fn describe_adapters(adapters: &[wgpu::Adapter]) -> String {
} else if adapters.len() == 1 { } else if adapters.len() == 1 {
adapter_info_summary(&adapters[0].get_info()) adapter_info_summary(&adapters[0].get_info())
} else { } else {
let mut list_string = String::new(); adapters
for adapter in adapters { .iter()
if !list_string.is_empty() { .map(|a| format!("{{{}}}", adapter_info_summary(&a.get_info())))
list_string += ", "; .collect::<Vec<_>>()
} .join(", ")
list_string += &format!("{{{}}}", adapter_info_summary(&adapter.get_info()));
}
list_string
} }
} }
@@ -245,62 +279,6 @@ pub enum SurfaceErrorAction {
RecreateSurface, 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. /// Configuration for using wgpu with eframe or the egui-wgpu winit feature.
#[derive(Clone)] #[derive(Clone)]
pub struct WgpuConfiguration { pub struct WgpuConfiguration {
@@ -352,42 +330,8 @@ impl Default for WgpuConfiguration {
fn default() -> Self { fn default() -> Self {
Self { Self {
present_mode: wgpu::PresentMode::AutoVsync, present_mode: wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: None, desired_maximum_frame_latency: None,
wgpu_setup: Default::default(),
// 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(),
}
}),
},
on_surface_error: Arc::new(|err| { on_surface_error: Arc::new(|err| {
if err == wgpu::SurfaceError::Outdated { if err == wgpu::SurfaceError::Outdated {
// This error occurs when the app is minimized on Windows. // 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:?}"); summary += &format!(", driver_info: {driver_info:?}");
} }
if *vendor != 0 { if *vendor != 0 {
// TODO(emilk): decode using https://github.com/gfx-rs/wgpu/blob/767ac03245ee937d3dc552edc13fe7ab0a860eec/wgpu-hal/src/auxil/mod.rs#L7 #[cfg(not(target_arch = "wasm32"))]
summary += &format!(", vendor: 0x{vendor:04X}"); {
summary += &format!(", vendor: {} (0x{vendor:04X})", parse_vendor_id(*vendor));
}
#[cfg(target_arch = "wasm32")]
{
summary += &format!(", vendor: 0x{vendor:04X}");
}
} }
if *device != 0 { if *device != 0 {
summary += &format!(", device: 0x{device:02X}"); summary += &format!(", device: 0x{device:02X}");
@@ -477,3 +427,20 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
summary 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| { let queue_write_data_to_texture = |texture, origin| {
profiling::scope!("write_texture"); profiling::scope!("write_texture");
queue.write_texture( queue.write_texture(
wgpu::ImageCopyTexture { wgpu::TexelCopyTextureInfo {
texture, texture,
mip_level: 0, mip_level: 0,
origin, origin,
aspect: wgpu::TextureAspect::All, aspect: wgpu::TextureAspect::All,
}, },
data_bytes, data_bytes,
wgpu::ImageDataLayout { wgpu::TexelCopyBufferLayout {
offset: 0, offset: 0,
bytes_per_row: Some(4 * width), bytes_per_row: Some(4 * width),
rows_per_image: Some(height), 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>, depth_format: Option<wgpu::TextureFormat>,
screen_capture_state: Option<CaptureState>, screen_capture_state: Option<CaptureState>,
instance: Arc<wgpu::Instance>, instance: wgpu::Instance,
render_state: Option<RenderState>, render_state: Option<RenderState>,
// Per viewport/window: // Per viewport/window:
@@ -51,7 +51,7 @@ impl Painter {
/// [`set_window()`](Self::set_window) once you have /// [`set_window()`](Self::set_window) once you have
/// a [`winit::window::Window`] with a valid `.raw_window_handle()` /// a [`winit::window::Window`] with a valid `.raw_window_handle()`
/// associated. /// associated.
pub fn new( pub async fn new(
context: Context, context: Context,
configuration: WgpuConfiguration, configuration: WgpuConfiguration,
msaa_samples: u32, msaa_samples: u32,
@@ -59,17 +59,8 @@ impl Painter {
support_transparent_backbuffer: bool, support_transparent_backbuffer: bool,
dithering: bool, dithering: bool,
) -> Self { ) -> 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 (capture_tx, capture_rx) = capture_channel();
let instance = configuration.wgpu_setup.new_instance().await;
Self { Self {
context, 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. 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 ## 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) * 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) * 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 #! ### Optional dependencies
# feature accesskit # feature accesskit
accesskit_winit = { version = "0.23", optional = true } accesskit_winit = { workspace = true, optional = true }
bytemuck = { workspace = true, optional = true } bytemuck = { workspace = true, optional = true }

View File

@@ -166,12 +166,14 @@ impl State {
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
pub fn init_accesskit<T: From<accesskit_winit::Event> + Send>( pub fn init_accesskit<T: From<accesskit_winit::Event> + Send>(
&mut self, &mut self,
event_loop: &ActiveEventLoop,
window: &Window, window: &Window,
event_loop_proxy: winit::event_loop::EventLoopProxy<T>, event_loop_proxy: winit::event_loop::EventLoopProxy<T>,
) { ) {
profiling::function_scope!(); profiling::function_scope!();
self.accesskit = Some(accesskit_winit::Adapter::with_event_loop_proxy( self.accesskit = Some(accesskit_winit::Adapter::with_event_loop_proxy(
event_loop,
window, window,
event_loop_proxy, event_loop_proxy,
)); ));
@@ -727,7 +729,7 @@ impl State {
// When telling users "Press Ctrl-F to find", this is where we should // 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 // 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. // 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, text,
@@ -746,7 +748,7 @@ impl State {
None 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 // Helpful logging to enable when adding new key support
log::trace!( 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 // Make sure there is text, and that it is not control characters
// (e.g. delete is sent as "\u{f728}" on macOS). // (e.g. delete is sent as "\u{f728}" on macOS).
if !text.is_empty() && text.chars().all(is_printable_char) { if !text.is_empty() && text.chars().all(is_printable_char) {
@@ -803,7 +809,7 @@ impl State {
if pressed && !is_cmd { if pressed && !is_cmd {
self.egui_input self.egui_input
.events .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) => { egui::OutputCommand::OpenUrl(open_url) => {
open_url_in_browser(&open_url.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: // macOS:
fullsize_content_view: _fullsize_content_view, fullsize_content_view: _fullsize_content_view,
movable_by_window_background: _movable_by_window_background,
title_shown: _title_shown, title_shown: _title_shown,
titlebar_buttons_shown: _titlebar_buttons_shown, titlebar_buttons_shown: _titlebar_buttons_shown,
titlebar_shown: _titlebar_shown, titlebar_shown: _titlebar_shown,
@@ -1756,7 +1766,8 @@ pub fn create_winit_window_attributes(
.with_title_hidden(!_title_shown.unwrap_or(true)) .with_title_hidden(!_title_shown.unwrap_or(true))
.with_titlebar_buttons_hidden(!_titlebar_buttons_shown.unwrap_or(true)) .with_titlebar_buttons_hidden(!_titlebar_buttons_shown.unwrap_or(true))
.with_titlebar_transparent(!_titlebar_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 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); let pos = PhysicalPosition::new(pixels_per_point * pos.x, pixels_per_point * pos.y);
window.set_outer_position(pos); 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, fullscreen: bool,
maximized: bool,
/// Inner size of window in logical pixels /// Inner size of window in logical pixels
inner_size_points: Option<egui::Vec2>, inner_size_points: Option<egui::Vec2>,
} }
@@ -38,6 +40,7 @@ impl WindowSettings {
outer_position_pixels, outer_position_pixels,
fullscreen: window.fullscreen().is_some(), fullscreen: window.fullscreen().is_some(),
maximized: window.is_maximized(),
inner_size_points: Some(egui::vec2( inner_size_points: Some(egui::vec2(
inner_size_points.width, inner_size_points.width,
@@ -80,7 +83,8 @@ impl WindowSettings {
if let Some(inner_size_points) = self.inner_size_points { if let Some(inner_size_points) = self.inner_size_points {
viewport_builder = viewport_builder viewport_builder = viewport_builder
.with_inner_size(inner_size_points) .with_inner_size(inner_size_points)
.with_fullscreen(self.fullscreen); .with_fullscreen(self.fullscreen)
.with_maximized(self.maximized);
} }
viewport_builder viewport_builder

View File

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

View File

@@ -5,8 +5,8 @@
use emath::GuiRounding as _; use emath::GuiRounding as _;
use crate::{ use crate::{
emath, pos2, Align2, Context, Id, InnerResponse, LayerId, NumExt, Order, Pos2, Rect, Response, emath, pos2, Align2, Context, Id, InnerResponse, LayerId, Layout, NumExt, Order, Pos2, Rect,
Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState, Response, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState,
}; };
/// State of an [`Area`] that is persisted between frames. /// 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()`]. /// The previous rectangle used by this area can be obtained through [`crate::Memory::area_rect()`].
#[must_use = "You should call .show()"] #[must_use = "You should call .show()"]
#[derive(Clone, Copy, Debug)] #[derive(Clone, Debug)]
pub struct Area { pub struct Area {
pub(crate) id: Id, pub(crate) id: Id,
kind: UiKind, info: UiStackInfo,
sense: Option<Sense>, sense: Option<Sense>,
movable: bool, movable: bool,
interactable: bool, interactable: bool,
@@ -120,6 +120,7 @@ pub struct Area {
anchor: Option<(Align2, Vec2)>, anchor: Option<(Align2, Vec2)>,
new_pos: Option<Pos2>, new_pos: Option<Pos2>,
fade_in: bool, fade_in: bool,
layout: Layout,
} }
impl WidgetWithState for Area { impl WidgetWithState for Area {
@@ -131,7 +132,7 @@ impl Area {
pub fn new(id: Id) -> Self { pub fn new(id: Id) -> Self {
Self { Self {
id, id,
kind: UiKind::GenericArea, info: UiStackInfo::new(UiKind::GenericArea),
sense: None, sense: None,
movable: true, movable: true,
interactable: true, interactable: true,
@@ -145,6 +146,7 @@ impl Area {
pivot: Align2::LEFT_TOP, pivot: Align2::LEFT_TOP,
anchor: None, anchor: None,
fade_in: true, fade_in: true,
layout: Layout::default(),
} }
} }
@@ -162,7 +164,16 @@ impl Area {
/// Default to [`UiKind::GenericArea`]. /// Default to [`UiKind::GenericArea`].
#[inline] #[inline]
pub fn kind(mut self, kind: UiKind) -> Self { 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 self
} }
@@ -339,10 +350,17 @@ impl Area {
self.fade_in = fade_in; self.fade_in = fade_in;
self 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 { pub(crate) struct Prepared {
kind: UiKind, info: Option<UiStackInfo>,
layer_id: LayerId, layer_id: LayerId,
state: AreaState, state: AreaState,
move_response: Response, move_response: Response,
@@ -358,6 +376,7 @@ pub(crate) struct Prepared {
sizing_pass: bool, sizing_pass: bool,
fade_in: bool, fade_in: bool,
layout: Layout,
} }
impl Area { impl Area {
@@ -366,7 +385,7 @@ impl Area {
ctx: &Context, ctx: &Context,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> { ) -> InnerResponse<R> {
let prepared = self.begin(ctx); let mut prepared = self.begin(ctx);
let mut content_ui = prepared.content_ui(ctx); let mut content_ui = prepared.content_ui(ctx);
let inner = add_contents(&mut content_ui); let inner = add_contents(&mut content_ui);
let response = prepared.end(ctx, content_ui); let response = prepared.end(ctx, content_ui);
@@ -376,7 +395,7 @@ impl Area {
pub(crate) fn begin(self, ctx: &Context) -> Prepared { pub(crate) fn begin(self, ctx: &Context) -> Prepared {
let Self { let Self {
id, id,
kind, info,
sense, sense,
movable, movable,
order, order,
@@ -390,6 +409,7 @@ impl Area {
constrain, constrain,
constrain_rect, constrain_rect,
fade_in, fade_in,
layout,
} = self; } = self;
let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect()); let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect());
@@ -507,7 +527,7 @@ impl Area {
move_response.interact_rect = state.rect(); move_response.interact_rect = state.rect();
Prepared { Prepared {
kind, info: Some(info),
layer_id, layer_id,
state, state,
move_response, move_response,
@@ -516,6 +536,7 @@ impl Area {
constrain_rect, constrain_rect,
sizing_pass, sizing_pass,
fade_in, fade_in,
layout,
} }
} }
} }
@@ -537,13 +558,15 @@ impl Prepared {
self.constrain_rect 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 max_rect = self.state.rect();
let mut ui_builder = UiBuilder::new() 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) .layer_id(self.layer_id)
.max_rect(max_rect); .max_rect(max_rect)
.layout(self.layout)
.closable();
if !self.enabled { if !self.enabled {
ui_builder = ui_builder.disabled(); ui_builder = ui_builder.disabled();
@@ -582,7 +605,7 @@ impl Prepared {
#[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`. #[allow(clippy::needless_pass_by_value)] // intentional to swallow up `content_ui`.
pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response { pub(crate) fn end(self, ctx: &Context, content_ui: Ui) -> Response {
let Self { let Self {
kind: _, info: _,
layer_id, layer_id,
mut state, mut state,
move_response: mut response, move_response: mut response,
@@ -598,6 +621,12 @@ impl Prepared {
response.rect = final_rect; response.rect = final_rect;
response.interact_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)); ctx.memory_mut(|m| m.areas_mut().set_state(layer_id, state));
if sizing_pass { 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::{ use crate::{
emath, epaint, pos2, remap, remap_clamp, vec2, Context, Id, InnerResponse, NumExt, Rect, 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)] #[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@@ -202,11 +204,16 @@ impl CollapsingState {
add_body: impl FnOnce(&mut Ui) -> R, add_body: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> { ) -> Option<InnerResponse<R>> {
let openness = self.openness(ui.ctx()); let openness = self.openness(ui.ctx());
let builder = UiBuilder::new()
.ui_stack_info(UiStackInfo::new(UiKind::Collapsible))
.closable();
if openness <= 0.0 { if openness <= 0.0 {
self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring
None None
} else if openness < 1.0 { } 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() { let max_height = if self.state.open && self.state.open_height.is_none() {
// First frame of expansion. // First frame of expansion.
// We don't know full height yet, but we will next frame. // We don't know full height yet, but we will next frame.
@@ -214,7 +221,7 @@ impl CollapsingState {
10.0 10.0
} else { } else {
let full_height = self.state.open_height.unwrap_or_default(); 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(); let mut clip_rect = child_ui.clip_rect();
@@ -225,6 +232,9 @@ impl CollapsingState {
let mut min_rect = child_ui.min_rect(); let mut min_rect = child_ui.min_rect();
self.state.open_height = Some(min_rect.height()); 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 self.store(child_ui.ctx()); // remember the height
// Pretend children took up at most `max_height` space: // Pretend children took up at most `max_height` space:
@@ -233,7 +243,10 @@ impl CollapsingState {
ret ret
})) }))
} else { } 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(); let full_size = ret_response.response.rect.size();
self.state.open_height = Some(full_size.y); self.state.open_height = Some(full_size.y);
self.store(ui.ctx()); // remember the height self.store(ui.ctx()); // remember the height
@@ -282,7 +295,7 @@ pub struct HeaderResponse<'ui, HeaderRet> {
header_response: InnerResponse<HeaderRet>, header_response: InnerResponse<HeaderRet>,
} }
impl<'ui, HeaderRet> HeaderResponse<'ui, HeaderRet> { impl<HeaderRet> HeaderResponse<'_, HeaderRet> {
pub fn is_open(&self) -> bool { pub fn is_open(&self) -> bool {
self.state.is_open() self.state.is_open()
} }
@@ -572,9 +585,10 @@ impl CollapsingHeader {
if ui.visuals().collapsing_header_frame || show_background { if ui.visuals().collapsing_header_frame || show_background {
ui.painter().add(epaint::RectShape::new( ui.painter().add(epaint::RectShape::new(
header_response.rect.expand(visuals.expansion), header_response.rect.expand(visuals.expansion),
visuals.rounding, visuals.corner_radius,
visuals.weak_bg_fill, visuals.weak_bg_fill,
visuals.bg_stroke, visuals.bg_stroke,
StrokeKind::Inside,
)); ));
} }
@@ -582,8 +596,13 @@ impl CollapsingHeader {
{ {
let rect = rect.expand(visuals.expansion); let rect = rect.expand(visuals.expansion);
ui.painter() ui.painter().rect(
.rect(rect, visuals.rounding, visuals.bg_fill, visuals.bg_stroke); rect,
visuals.corner_radius,
visuals.bg_fill,
visuals.bg_stroke,
StrokeKind::Inside,
);
} }
{ {

View File

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

View File

@@ -4,9 +4,45 @@ use crate::{
epaint, layers::ShapeIdx, InnerResponse, Response, Sense, Style, Ui, UiBuilder, UiKind, epaint, layers::ShapeIdx, InnerResponse, Response, Sense, Style, Ui, UiBuilder, UiKind,
UiStackInfo, 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| { /// # 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))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[must_use = "You should call .show()"] #[must_use = "You should call .show()"]
pub struct Frame { pub struct Frame {
// Fields are ordered inside-out.
// TODO(emilk): add `min_content_size: Vec2`
//
/// Margin within the painted frame. /// Margin within the painted frame.
///
/// Known as `padding` in CSS.
#[doc(alias = "padding")]
pub inner_margin: Margin, pub inner_margin: Margin,
/// Margin outside the painted frame. /// The background fill color of the frame, within the [`Self::stroke`].
pub outer_margin: Margin, ///
/// Known as `background` in CSS.
pub rounding: Rounding, #[doc(alias = "background")]
pub shadow: Shadow,
pub fill: Color32, 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, 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] #[test]
@@ -85,68 +152,75 @@ fn frame_size() {
); );
} }
/// ## Constructors
impl Frame { impl Frame {
pub fn none() -> Self { /// No colors, no margins, no border.
Self::default() ///
/// 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. /// For when you want to group a few widgets together within a frame.
pub fn group(style: &Style) -> Self { pub fn group(style: &Style) -> Self {
Self { Self::new()
inner_margin: Margin::same(6), // same and symmetric looks best in corners when nesting groups .inner_margin(6)
rounding: style.visuals.widgets.noninteractive.rounding, .corner_radius(style.visuals.widgets.noninteractive.corner_radius)
stroke: style.visuals.widgets.noninteractive.bg_stroke, .stroke(style.visuals.widgets.noninteractive.bg_stroke)
..Default::default()
}
} }
pub fn side_top_panel(style: &Style) -> Self { pub fn side_top_panel(style: &Style) -> Self {
Self { Self::new()
inner_margin: Margin::symmetric(8, 2), .inner_margin(Margin::symmetric(8, 2))
fill: style.visuals.panel_fill, .fill(style.visuals.panel_fill)
..Default::default()
}
} }
pub fn central_panel(style: &Style) -> Self { pub fn central_panel(style: &Style) -> Self {
Self { Self::new().inner_margin(8).fill(style.visuals.panel_fill)
inner_margin: Margin::same(8),
fill: style.visuals.panel_fill,
..Default::default()
}
} }
pub fn window(style: &Style) -> Self { pub fn window(style: &Style) -> Self {
Self { Self::new()
inner_margin: style.spacing.window_margin, .inner_margin(style.spacing.window_margin)
rounding: style.visuals.window_rounding, .corner_radius(style.visuals.window_corner_radius)
shadow: style.visuals.window_shadow, .shadow(style.visuals.window_shadow)
fill: style.visuals.window_fill(), .fill(style.visuals.window_fill())
stroke: style.visuals.window_stroke(), .stroke(style.visuals.window_stroke())
..Default::default()
}
} }
pub fn menu(style: &Style) -> Self { pub fn menu(style: &Style) -> Self {
Self { Self::new()
inner_margin: style.spacing.menu_margin, .inner_margin(style.spacing.menu_margin)
rounding: style.visuals.menu_rounding, .corner_radius(style.visuals.menu_corner_radius)
shadow: style.visuals.popup_shadow, .shadow(style.visuals.popup_shadow)
fill: style.visuals.window_fill(), .fill(style.visuals.window_fill())
stroke: style.visuals.window_stroke(), .stroke(style.visuals.window_stroke())
..Default::default()
}
} }
pub fn popup(style: &Style) -> Self { pub fn popup(style: &Style) -> Self {
Self { Self::new()
inner_margin: style.spacing.menu_margin, .inner_margin(style.spacing.menu_margin)
rounding: style.visuals.menu_rounding, .corner_radius(style.visuals.menu_corner_radius)
shadow: style.visuals.popup_shadow, .shadow(style.visuals.popup_shadow)
fill: style.visuals.window_fill(), .fill(style.visuals.window_fill())
stroke: style.visuals.window_stroke(), .stroke(style.visuals.window_stroke())
..Default::default()
}
} }
/// A canvas to draw on. /// A canvas to draw on.
@@ -154,57 +228,90 @@ impl Frame {
/// In bright mode this will be very bright, /// In bright mode this will be very bright,
/// and in dark mode this will be very dark. /// and in dark mode this will be very dark.
pub fn canvas(style: &Style) -> Self { pub fn canvas(style: &Style) -> Self {
Self { Self::new()
inner_margin: Margin::same(2), .inner_margin(2)
rounding: style.visuals.widgets.noninteractive.rounding, .corner_radius(style.visuals.widgets.noninteractive.corner_radius)
fill: style.visuals.extreme_bg_color, .fill(style.visuals.extreme_bg_color)
stroke: style.visuals.window_stroke(), .stroke(style.visuals.window_stroke())
..Default::default()
}
} }
/// A dark canvas to draw on. /// A dark canvas to draw on.
pub fn dark_canvas(style: &Style) -> Self { pub fn dark_canvas(style: &Style) -> Self {
Self { Self::canvas(style).fill(Color32::from_black_alpha(250))
fill: Color32::from_black_alpha(250),
..Self::canvas(style)
}
} }
} }
/// ## Builders
impl Frame { 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. /// Margin within the painted frame.
///
/// Known as `padding` in CSS.
#[doc(alias = "padding")]
#[inline] #[inline]
pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self { pub fn inner_margin(mut self, inner_margin: impl Into<Margin>) -> Self {
self.inner_margin = inner_margin.into(); self.inner_margin = inner_margin.into();
self 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. /// 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] #[inline]
pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self { pub fn outer_margin(mut self, outer_margin: impl Into<Margin>) -> Self {
self.outer_margin = outer_margin.into(); self.outer_margin = outer_margin.into();
self self
} }
/// Optional drop-shadow behind the frame.
#[inline] #[inline]
pub fn shadow(mut self, shadow: Shadow) -> Self { pub fn shadow(mut self, shadow: Shadow) -> Self {
self.shadow = shadow; self.shadow = shadow;
@@ -224,11 +331,37 @@ impl Frame {
} }
} }
/// ## Inspectors
impl Frame { 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] #[inline]
pub fn total_margin(&self) -> Marginf { pub fn total_margin(&self) -> MarginF32 {
Marginf::from(self.inner_margin) + Marginf::from(self.outer_margin) 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 where_to_put_background = ui.painter().add(Shape::Noop);
let outer_rect_bounds = ui.available_rect_before_wrap(); 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: // Make sure we don't shrink to the negative:
inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x); max_content_rect.max.x = max_content_rect.max.x.max(max_content_rect.min.x);
inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y); max_content_rect.max.y = max_content_rect.max.y.max(max_content_rect.min.y);
let content_ui = ui.new_child( let content_ui = ui.new_child(
UiBuilder::new() UiBuilder::new()
.ui_stack_info(UiStackInfo::new(UiKind::Frame).with_frame(self)) .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 { Prepared {
frame: self, frame: self,
where_to_put_background, where_to_put_background,
@@ -298,32 +429,42 @@ impl Frame {
} }
/// Paint this frame as a shape. /// Paint this frame as a shape.
/// pub fn paint(&self, content_rect: Rect) -> Shape {
/// The margin is ignored.
pub fn paint(&self, outer_rect: Rect) -> Shape {
let Self { let Self {
inner_margin: _, inner_margin: _,
outer_margin: _,
rounding,
shadow,
fill, fill,
stroke, stroke,
corner_radius,
outer_margin: _,
shadow,
} = *self; } = *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() { if shadow == Default::default() {
frame_shape frame_shape
} else { } 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]) Shape::Vec(vec![Shape::from(shadow), frame_shape])
} }
} }
} }
impl Prepared { impl Prepared {
fn content_with_margin(&self) -> Rect { fn outer_rect(&self) -> Rect {
self.content_ui.min_rect() + self.frame.inner_margin + self.frame.outer_margin 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`]. /// 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`]. /// This can be called before or after [`Self::paint`].
pub fn allocate_space(&self, ui: &mut Ui) -> Response { 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. /// Paint the frame.
/// ///
/// This can be called before or after [`Self::allocate_space`]. /// This can be called before or after [`Self::allocate_space`].
pub fn paint(&self, ui: &Ui) { 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) { if ui.is_rect_visible(widget_rect) {
let shape = self.frame.paint(paint_rect); let shape = self.frame.paint(content_rect);
ui.painter().set(self.where_to_put_background, shape); ui.painter().set(self.where_to_put_background, shape);
} }
} }
/// Convenience for calling [`Self::allocate_space`] and [`Self::paint`]. /// 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 { pub fn end(self, ui: &mut Ui) -> Response {
self.paint(ui); self.paint(ui);
self.allocate_space(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. //! For instance, a [`Frame`] adds a frame and background to some contained UI.
pub(crate) mod area; pub(crate) mod area;
pub mod close_tag;
pub mod collapsing_header; pub mod collapsing_header;
mod combo_box; mod combo_box;
pub mod frame; pub mod frame;
pub mod menu;
pub mod modal; pub mod modal;
pub mod old_popup;
pub mod panel; pub mod panel;
pub mod popup; mod popup;
pub(crate) mod resize; pub(crate) mod resize;
mod scene;
pub mod scroll_area; pub mod scroll_area;
mod sides; mod sides;
mod tooltip;
pub(crate) mod window; pub(crate) mod window;
pub use { pub use {
@@ -20,10 +25,13 @@ pub use {
combo_box::*, combo_box::*,
frame::Frame, frame::Frame,
modal::{Modal, ModalResponse}, modal::{Modal, ModalResponse},
old_popup::*,
panel::{CentralPanel, SidePanel, TopBottomPanel}, panel::{CentralPanel, SidePanel, TopBottomPanel},
popup::*, popup::*,
resize::Resize, resize::Resize,
scene::Scene,
scroll_area::ScrollArea, scroll_area::ScrollArea,
sides::Sides, sides::Sides,
tooltip::*,
window::Window, window::Window,
}; };

View File

@@ -4,11 +4,14 @@ use crate::{
use emath::{Align2, Vec2}; use emath::{Align2, Vec2};
/// A modal dialog. /// A modal dialog.
///
/// Similar to a [`crate::Window`] but centered and with a backdrop that /// Similar to a [`crate::Window`] but centered and with a backdrop that
/// blocks input to the rest of the UI. /// blocks input to the rest of the UI.
/// ///
/// You can show multiple modals on top of each other. The topmost modal will always be /// You can show multiple modals on top of each other. The topmost modal will always be
/// the most recently shown one. /// 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 struct Modal {
pub area: Area, pub area: Area,
pub backdrop_color: Color32, pub backdrop_color: Color32,
@@ -16,7 +19,9 @@ pub struct Modal {
} }
impl 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 { pub fn new(id: Id) -> Self {
Self { Self {
area: Self::default_area(id), area: Self::default_area(id),
@@ -26,6 +31,7 @@ impl Modal {
} }
/// Returns an area customized for a modal. /// Returns an area customized for a modal.
///
/// Makes these changes to the default area: /// Makes these changes to the default area:
/// - sense: hover /// - sense: hover
/// - anchor: center /// - anchor: center
@@ -86,11 +92,7 @@ impl Modal {
response, response,
} = area.show(ctx, |ui| { } = area.show(ctx, |ui| {
let bg_rect = ui.ctx().screen_rect(); let bg_rect = ui.ctx().screen_rect();
let bg_sense = Sense { let bg_sense = Sense::CLICK | Sense::DRAG;
click: true,
drag: true,
focusable: false,
};
let mut backdrop = ui.new_child(UiBuilder::new().sense(bg_sense).max_rect(bg_rect)); let mut backdrop = ui.new_child(UiBuilder::new().sense(bg_sense).max_rect(bg_rect));
backdrop.set_min_size(bg_rect.size()); backdrop.set_min_size(bg_rect.size());
ui.painter().rect_filled(bg_rect, 0.0, backdrop_color); 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 // 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. // need to prevent the clicks from passing through to the backdrop.
let inner = ui let inner = ui
.scope_builder( .scope_builder(UiBuilder::new().sense(Sense::CLICK | Sense::DRAG), |ui| {
UiBuilder::new().sense(Sense { frame.show(ui, content).inner
click: true, })
drag: true,
focusable: false,
}),
|ui| frame.show(ui, content).inner,
)
.inner; .inner;
(inner, backdrop_response) (inner, backdrop_response)
@@ -159,7 +156,10 @@ impl<T> ModalResponse<T> {
let escape_clicked = let escape_clicked =
|| ctx.input_mut(|i| i.consume_key(crate::Modifiers::NONE, crate::Key::Escape)); || 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() self.backdrop_response.clicked()
|| ui_close_called
|| (self.is_top_modal && !self.any_popup_open && escape_clicked()) || (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, rect,
3.0, 3.0,
ui.visuals().widgets.noninteractive.bg_stroke, 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)] #![allow(clippy::needless_range_loop)]
use crate::{ use crate::{
emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, vec2, Context, Id, NumExt, Pos2, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, Context, Id, NumExt, Pos2, Rangef,
Rangef, Rect, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b, Rect, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b,
}; };
#[derive(Clone, Copy, Debug)] #[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`]. /// 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)] #[derive(Clone, Debug)]
#[must_use = "You should call .show()"] #[must_use = "You should call .show()"]
pub struct ScrollArea { 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); 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. viewport_ui.skip_ahead_auto_ids(min_row); // Make sure we get consistent IDs.
add_contents(viewport_ui, min_row..max_row) add_contents(viewport_ui, min_row..max_row)
}) })
@@ -1087,21 +1090,35 @@ impl Prepared {
) )
}; };
let handle_rect = if d == 0 { let calculate_handle_rect = |d, offset: &Vec2| {
Rect::from_min_max( let handle_size = if d == 0 {
pos2(from_content(state.offset.x), cross.min), from_content(offset.x + inner_rect.width()) - from_content(offset.x)
pos2(from_content(state.offset.x + inner_rect.width()), cross.max), } else {
) from_content(offset.y + inner_rect.height()) - from_content(offset.y)
} else { }
Rect::from_min_max( .max(scroll_style.handle_min_length);
pos2(cross.min, from_content(state.offset.y)),
pos2( let handle_start_point = remap_clamp(
cross.max, offset[d],
from_content(state.offset.y + inner_rect.height()), 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 interact_id = id.with(d);
let sense = if self.scrolling_enabled { let sense = if self.scrolling_enabled {
Sense::click_and_drag() Sense::click_and_drag()
@@ -1130,8 +1147,8 @@ impl Prepared {
let new_handle_top = pointer_pos[d] - *scroll_start_offset_from_top_left; let new_handle_top = pointer_pos[d] - *scroll_start_offset_from_top_left;
state.offset[d] = remap( state.offset[d] = remap(
new_handle_top, new_handle_top,
scroll_bar_rect.min[d]..=scroll_bar_rect.max[d], scroll_bar_rect.min[d]..=(scroll_bar_rect.max[d] - handle_rect.size()[d]),
0.0..=content_size[d], 0.0..=max_offset[d],
); );
// some manual action taken, scroll not stuck // some manual action taken, scroll not stuck
@@ -1151,31 +1168,7 @@ impl Prepared {
if ui.is_rect_visible(outer_scroll_bar_rect) { if ui.is_rect_visible(outer_scroll_bar_rect) {
// Avoid frame-delay by calculating a new handle rect: // Avoid frame-delay by calculating a new handle rect:
let mut handle_rect = if d == 0 { let handle_rect = calculate_handle_rect(d, &state.offset);
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 visuals = if scrolling_enabled { let visuals = if scrolling_enabled {
// Pick visuals based on interaction with the handle. // Pick visuals based on interaction with the handle.
@@ -1184,7 +1177,7 @@ impl Prepared {
&& ui.input(|i| { && ui.input(|i| {
i.pointer i.pointer
.latest_pos() .latest_pos()
.map_or(false, |p| handle_rect.contains(p)) .is_some_and(|p| handle_rect.contains(p))
}); });
let visuals = ui.visuals(); let visuals = ui.visuals();
if response.is_pointer_button_down_on() { if response.is_pointer_button_down_on() {
@@ -1237,7 +1230,7 @@ impl Prepared {
// Background: // Background:
ui.painter().add(epaint::Shape::rect_filled( ui.painter().add(epaint::Shape::rect_filled(
outer_scroll_bar_rect, outer_scroll_bar_rect,
visuals.rounding, visuals.corner_radius,
ui.visuals() ui.visuals()
.extreme_bg_color .extreme_bg_color
.gamma_multiply(background_opacity), .gamma_multiply(background_opacity),
@@ -1246,7 +1239,7 @@ impl Prepared {
// Handle: // Handle:
ui.painter().add(epaint::Shape::rect_filled( ui.painter().add(epaint::Shape::rect_filled(
handle_rect, handle_rect,
visuals.rounding, visuals.corner_radius,
handle_color.gamma_multiply(handle_opacity), 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 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 emath::GuiRounding as _;
use epaint::{ use epaint::{CornerRadiusF32, RectShape};
emath, pos2, vec2, Galley, Pos2, Rect, RectShape, Rounding, Roundingf, Shape, Stroke, Vec2,
}; use crate::collapsing_header::CollapsingState;
use crate::*;
use super::scroll_area::ScrollBarVisibility; use super::scroll_area::ScrollBarVisibility;
use super::{area, resize, Area, Frame, Resize, ScrollArea}; 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 `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. /// Returns `Some(InnerResponse { inner: None })` if the window is collapsed.
#[inline] #[inline]
@@ -438,7 +434,7 @@ impl<'open> Window<'open> {
) -> Option<InnerResponse<Option<R>>> { ) -> Option<InnerResponse<Option<R>>> {
let Window { let Window {
title, title,
open, mut open,
area, area,
frame, frame,
resize, resize,
@@ -452,8 +448,6 @@ impl<'open> Window<'open> {
let header_color = let header_color =
frame.map_or_else(|| ctx.style().visuals.widgets.open.weak_bg_fill, |f| f.fill); 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())); 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_explicitly_closed = matches!(open, Some(false));
let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible()); 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())); 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 // Calculate roughly how much larger the full window inner size is compared to the content rect
let (title_bar_height, title_content_spacing) = if with_title_bar { let (title_bar_height_with_margin, title_content_spacing) = if with_title_bar {
let style = ctx.style(); let style = ctx.style();
let spacing = window_margin.sum().y; let title_bar_inner_height = ctx
let height = ctx.fonts(|f| title.font_height(f, &style)) + spacing; .fonts(|fonts| title.font_height(fonts, &style))
let half_height = (height / 2.0).round() as _; .at_least(style.spacing.interact_size.y);
window_frame.rounding.ne = window_frame.rounding.ne.clamp(0, half_height); let title_bar_inner_height = title_bar_inner_height + window_frame.inner_margin.sum().y;
window_frame.rounding.nw = window_frame.rounding.nw.clamp(0, half_height); let half_height = (title_bar_inner_height / 2.0).round() as _;
(height, spacing) 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 { } else {
(0.0, 0.0) (0.0, 0.0)
}; };
@@ -500,7 +502,8 @@ impl<'open> Window<'open> {
// Prevent window from becoming larger than the constrain rect. // Prevent window from becoming larger than the constrain rect.
let constrain_rect = area.constrain_rect(); let constrain_rect = area.constrain_rect();
let max_width = constrain_rect.width(); 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.x = resize.max_size.x.min(max_width);
resize.max_size.y = resize.max_size.y.min(max_height); 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: // First check for resize to avoid frame delay:
let last_frame_outer_rect = area.state().rect(); let last_frame_outer_rect = area.state().rect();
let resize_interaction = ctx.with_accessibility_parent(area.id(), || { 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() let margins = window_frame.total_margin().sum()
+ vec2(0.0, title_bar_height); + vec2(0.0, title_bar_height_with_margin + title_content_spacing);
resize_response( resize_response(
resize_interaction, resize_interaction,
ctx, ctx,
margins, margins,
area_layer_id, area_layer_id,
&mut area, &mut area,
resize_id, resize_id,
); );
}
let mut area_content_ui = area.content_ui(ctx); let mut area_content_ui = area.content_ui(ctx);
if is_open { if is_open {
@@ -535,40 +545,43 @@ impl<'open> Window<'open> {
let content_inner = { let content_inner = {
ctx.with_accessibility_parent(area.id(), || { ctx.with_accessibility_parent(area.id(), || {
// BEGIN FRAME -------------------------------- // BEGIN FRAME --------------------------------
let frame_stroke = window_frame.stroke;
let mut frame = window_frame.begin(&mut area_content_ui); let mut frame = window_frame.begin(&mut area_content_ui);
let show_close_button = open.is_some(); let show_close_button = open.is_some();
let where_to_put_header_background = &area_content_ui.painter().add(Shape::Noop); 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 = if with_title_bar {
let title_bar = TitleBar::new( let title_bar = TitleBar::new(
&mut frame.content_ui, &frame.content_ui,
title, title,
show_close_button, show_close_button,
&mut collapsing,
collapsible, 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) Some(title_bar)
} else { } else {
None None
}; };
// Remove item spacing after the title bar let (content_inner, content_response) = collapsing
frame.content_ui.spacing_mut().item_spacing.y = 0.0;
let (content_inner, mut content_response) = collapsing
.show_body_unindented(&mut frame.content_ui, |ui| { .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| { resize.show(ui, |ui| {
if scroll.is_any_scroll_enabled() { if scroll.is_any_scroll_enabled() {
scroll.show(ui, add_contents).inner scroll.show(ui, add_contents).inner
@@ -584,26 +597,20 @@ impl<'open> Window<'open> {
&area_content_ui, &area_content_ui,
&possible, &possible,
outer_rect, outer_rect,
frame_stroke, &window_frame,
window_frame.rounding,
resize_interaction, resize_interaction,
); );
// END FRAME -------------------------------- // END FRAME --------------------------------
if let Some(title_bar) = title_bar { if let Some(mut title_bar) = title_bar {
let mut title_rect = Rect::from_min_size( title_bar.inner_rect = outer_rect.shrink(window_frame.stroke.width);
outer_rect.min, title_bar.inner_rect.max.y =
Vec2 { title_bar.inner_rect.min.y + title_bar_height_with_margin;
x: outer_rect.size().x,
y: title_bar_height,
},
);
title_rect = title_rect.round_to_pixels(area_content_ui.pixels_per_point());
if on_top && area_content_ui.visuals().window_highlight_topmost { 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 { if !is_collapsed {
round.se = 0; round.se = 0;
@@ -612,20 +619,22 @@ impl<'open> Window<'open> {
area_content_ui.painter().set( area_content_ui.painter().set(
*where_to_put_header_background, *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 false {
if let Some(response) = &mut content_response { ctx.debug_painter().debug_rect(
response.rect.min.y = outer_rect.min.y + title_bar_height; title_bar.inner_rect,
Color32::LIGHT_BLUE,
"title_bar.rect",
);
} }
title_bar.ui( title_bar.ui(
&mut area_content_ui, &mut area_content_ui,
title_rect,
&content_response, &content_response,
open, open.as_deref_mut(),
&mut collapsing, &mut collapsing,
collapsible, collapsible,
); );
@@ -641,6 +650,12 @@ impl<'open> Window<'open> {
let full_response = area.end(ctx, area_content_ui); 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 { let inner_response = InnerResponse {
inner: content_inner, inner: content_inner,
response: full_response, response: full_response,
@@ -653,32 +668,31 @@ fn paint_resize_corner(
ui: &Ui, ui: &Ui,
possible: &PossibleInteractions, possible: &PossibleInteractions,
outer_rect: Rect, outer_rect: Rect,
stroke: impl Into<Stroke>, window_frame: &Frame,
rounding: impl Into<Rounding>,
i: ResizeInteraction, i: ResizeInteraction,
) { ) {
let inactive_stroke = stroke.into(); let cr = window_frame.corner_radius;
let rounding = rounding.into();
let (corner, radius, corner_response) = if possible.resize_right && possible.resize_bottom { 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 { } 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 { } 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 { } 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 { } else {
// We're not in two directions, but it is still nice to tell the user // 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 // we're resizable by painting the resize corner in the expected place
// (i.e. for windows only resizable in one direction): // (i.e. for windows only resizable in one direction):
if possible.resize_right || possible.resize_bottom { 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 { } 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 { } 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 { } 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 { } else {
return; return;
} }
@@ -694,11 +708,12 @@ fn paint_resize_corner(
} else if corner_response.hover { } else if corner_response.hover {
ui.visuals().widgets.hovered.fg_stroke ui.visuals().widgets.hovered.fg_stroke
} else { } 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_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 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); crate::resize::paint_resize_corner_with_style(ui, &corner_rect, stroke.color, corner);
} }
@@ -738,7 +753,11 @@ impl PossibleInteractions {
/// Resizing the window edges. /// Resizing the window edges.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct ResizeInteraction { struct ResizeInteraction {
start_rect: Rect, /// Outer rect (outside the stroke)
outer_rect: Rect,
window_frame: Frame,
left: SideResponse, left: SideResponse,
right: SideResponse, right: SideResponse,
top: SideResponse, top: SideResponse,
@@ -835,13 +854,17 @@ fn resize_response(
ctx.memory_mut(|mem| mem.areas_mut().move_to_top(area_layer_id)); 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> { fn move_and_resize_window(ctx: &Context, interaction: &ResizeInteraction) -> Option<Rect> {
if !interaction.any_dragged() { if !interaction.any_dragged() {
return None; return None;
} }
let pointer_pos = ctx.input(|i| i.pointer.interact_pos())?; 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 { if interaction.left.drag {
rect.min.x = pointer_pos.x; 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; 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()) Some(rect.round_ui())
} }
@@ -862,11 +888,13 @@ fn resize_interaction(
ctx: &Context, ctx: &Context,
possible: PossibleInteractions, possible: PossibleInteractions,
layer_id: LayerId, layer_id: LayerId,
rect: Rect, outer_rect: Rect,
window_frame: Frame,
) -> ResizeInteraction { ) -> ResizeInteraction {
if !possible.resizable() { if !possible.resizable() {
return ResizeInteraction { return ResizeInteraction {
start_rect: rect, outer_rect,
window_frame,
left: Default::default(), left: Default::default(),
right: Default::default(), right: Default::default(),
top: 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 side_response = |rect, id| {
let response = ctx.create_widget( let response = ctx.create_widget(
WidgetRect { WidgetRect {
@@ -990,7 +1021,8 @@ fn resize_interaction(
} }
let interaction = ResizeInteraction { let interaction = ResizeInteraction {
start_rect: rect, outer_rect,
window_frame,
left, left,
right, right,
top, top,
@@ -1026,137 +1058,123 @@ fn paint_frame_interaction(ui: &Ui, rect: Rect, interaction: ResizeInteraction)
bottom = interaction.bottom.hover; 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 Rect { min, max } = rect;
let mut points = Vec::new(); let mut points = Vec::new();
if right && !bottom && !top { if right && !bottom && !top {
points.push(pos2(max.x, min.y + rounding.ne)); points.push(pos2(max.x, min.y + cr.ne));
points.push(pos2(max.x, max.y - rounding.se)); points.push(pos2(max.x, max.y - cr.se));
} }
if right && bottom { if right && bottom {
points.push(pos2(max.x, min.y + rounding.ne)); points.push(pos2(max.x, min.y + cr.ne));
points.push(pos2(max.x, max.y - rounding.se)); points.push(pos2(max.x, max.y - cr.se));
add_circle_quadrant( add_circle_quadrant(&mut points, pos2(max.x - cr.se, max.y - cr.se), cr.se, 0.0);
&mut points,
pos2(max.x - rounding.se, max.y - rounding.se),
rounding.se,
0.0,
);
} }
if bottom { if bottom {
points.push(pos2(max.x - rounding.se, max.y)); points.push(pos2(max.x - cr.se, max.y));
points.push(pos2(min.x + rounding.sw, max.y)); points.push(pos2(min.x + cr.sw, max.y));
} }
if left && bottom { if left && bottom {
add_circle_quadrant( add_circle_quadrant(&mut points, pos2(min.x + cr.sw, max.y - cr.sw), cr.sw, 1.0);
&mut points,
pos2(min.x + rounding.sw, max.y - rounding.sw),
rounding.sw,
1.0,
);
} }
if left { if left {
points.push(pos2(min.x, max.y - rounding.sw)); points.push(pos2(min.x, max.y - cr.sw));
points.push(pos2(min.x, min.y + rounding.nw)); points.push(pos2(min.x, min.y + cr.nw));
} }
if left && top { if left && top {
add_circle_quadrant( add_circle_quadrant(&mut points, pos2(min.x + cr.nw, min.y + cr.nw), cr.nw, 2.0);
&mut points,
pos2(min.x + rounding.nw, min.y + rounding.nw),
rounding.nw,
2.0,
);
} }
if top { if top {
points.push(pos2(min.x + rounding.nw, min.y)); points.push(pos2(min.x + cr.nw, min.y));
points.push(pos2(max.x - rounding.ne, min.y)); points.push(pos2(max.x - cr.ne, min.y));
} }
if right && top { if right && top {
add_circle_quadrant( add_circle_quadrant(&mut points, pos2(max.x - cr.ne, min.y + cr.ne), cr.ne, 3.0);
&mut points, points.push(pos2(max.x, min.y + cr.ne));
pos2(max.x - rounding.ne, min.y + rounding.ne), points.push(pos2(max.x, max.y - cr.se));
rounding.ne,
3.0,
);
points.push(pos2(max.x, min.y + rounding.ne));
points.push(pos2(max.x, max.y - rounding.se));
} }
ui.painter().add(Shape::line(points, visuals.bg_stroke));
ui.painter().add(Shape::line(points, stroke));
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
struct TitleBar { struct TitleBar {
/// A title Id used for dragging windows window_frame: Frame,
id: Id,
/// Prepared text in the title /// Prepared text in the title
title_galley: Arc<Galley>, 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 /// Size of the title bar in an expanded state. This size become known only
/// after expanding window and painting its content /// after expanding window and painting its content.
rect: Rect, ///
/// Does not include the stroke, nor the separator line between the title bar and the window contents.
inner_rect: Rect,
} }
impl TitleBar { impl TitleBar {
fn new( fn new(
ui: &mut Ui, ui: &Ui,
title: WidgetText, title: WidgetText,
show_close_button: bool, show_close_button: bool,
collapsing: &mut CollapsingState,
collapsible: bool, collapsible: bool,
window_frame: Frame,
title_bar_height_with_margin: f32,
) -> Self { ) -> Self {
let inner_response = ui.horizontal(|ui| { if false {
let height = ui ui.ctx()
.fonts(|fonts| title.font_height(fonts, ui.style())) .debug_painter()
.max(ui.spacing().interact_size.y); .debug_rect(ui.min_rect(), Color32::GREEN, "outer_min_rect");
ui.set_min_height(height); }
let item_spacing = ui.spacing().item_spacing; let inner_height = title_bar_height_with_margin - window_frame.inner_margin.sum().y;
let button_size = Vec2::splat(ui.spacing().icon_width);
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 { 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)
ui.add_space(pad);
collapsing.show_default_button_with_size(ui, button_size);
}
let title_galley = title.into_galley( let title_galley = title.into_galley(
ui, ui,
Some(crate::TextWrapMode::Extend), Some(crate::TextWrapMode::Extend),
f32::INFINITY, f32::INFINITY,
TextStyle::Heading, TextStyle::Heading,
); );
let minimum_width = if collapsible || show_close_button { 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): // If at least one button is shown we make room for both buttons (since title should be centered):
2.0 * (pad + button_size.x + item_spacing.x) + title_galley.size().x 2.0 * (left_pad + button_size.x + item_spacing.x) + title_galley.size().x
} else { } else {
pad + title_galley.size().x + pad left_pad + title_galley.size().x + left_pad
}; };
let min_rect = Rect::from_min_size(ui.min_rect().min, vec2(minimum_width, height)); let min_inner_size = vec2(minimum_width, inner_height);
let id = ui.advance_cursor_after_rect(min_rect); let min_rect = Rect::from_min_size(ui.min_rect().min, min_inner_size);
Self { if false {
id, ui.ctx()
title_galley, .debug_painter()
min_rect, .debug_rect(min_rect, Color32::LIGHT_BLUE, "min_rect");
rect: Rect::NAN, // Will be filled in later }
}
});
let title_bar = inner_response.inner; Self {
let rect = inner_response.response.rect; window_frame,
title_galley,
Self { rect, ..title_bar } inner_rect: min_rect, // First estimate - will be refined later
}
} }
/// Finishes painting of the title bar when the window content size already known. /// 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 /// - `collapsible`: if `true`, double click on the title bar will be handled for a change
/// of `collapsing` state /// of `collapsing` state
fn ui( fn ui(
mut self, self,
ui: &mut Ui, ui: &mut Ui,
outer_rect: Rect,
content_response: &Option<Response>, content_response: &Option<Response>,
open: Option<&mut bool>, open: Option<&mut bool>,
collapsing: &mut CollapsingState, collapsing: &mut CollapsingState,
collapsible: bool, collapsible: bool,
) { ) {
if let Some(content_response) = &content_response { let window_frame = self.window_frame;
// Now we know how large we got to be: let title_inner_rect = self.inner_rect;
self.rect.max.x = self.rect.max.x.max(content_response.rect.max.x);
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 { 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 = 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(); let text_pos = text_pos - self.title_galley.rect.min.to_vec2();
ui.painter().galley( ui.painter().galley(
text_pos, text_pos,
@@ -1205,22 +1240,35 @@ impl TitleBar {
); );
if let Some(content_response) = &content_response { if let Some(content_response) = &content_response {
// paint separator between title and content: // Paint separator between title and content:
let y = content_response.rect.top(); let content_rect = content_response.rect;
// let y = lerp(self.rect.bottom()..=content_response.rect.top(), 0.5); if false {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke; ui.ctx()
// Workaround: To prevent border infringement, .debug_painter()
// the 0.1 value should ideally be calculated using TessellationOptions::feathering_size_in_pixels .debug_rect(content_rect, Color32::RED, "content_rect");
// or we could support selectively disabling feathering on line caps }
let x_range = outer_rect.x_range().shrink(0.1); let y = title_inner_rect.bottom() + window_frame.stroke.width / 2.0;
ui.painter().hline(x_range, y, stroke);
// 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: // 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 if ui
.interact(double_click_rect, self.id, Sense::click()) .interact(double_click_rect, id, Sense::click())
.double_clicked() .double_clicked()
&& collapsible && collapsible
{ {
@@ -1234,16 +1282,12 @@ impl TitleBar {
/// The button is square and its size is determined by the /// The button is square and its size is determined by the
/// [`crate::style::Spacing::icon_width`] setting. /// [`crate::style::Spacing::icon_width`] setting.
fn close_button_ui(&self, ui: &mut Ui) -> Response { 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 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_center_size(button_center, button_size);
let button_rect = Rect::from_min_size( let button_rect = button_rect.round_to_pixels(ui.pixels_per_point());
pos2(
self.rect.right() - pad - button_size.x,
self.rect.center().y - 0.5 * button_size.y,
),
button_size,
);
close_button(ui, button_rect) close_button(ui, button_rect)
} }
} }

View File

@@ -11,7 +11,7 @@ use epaint::{
tessellator, tessellator,
text::{FontInsert, FontPriority, Fonts}, text::{FontInsert, FontPriority, Fonts},
util::OrderedFloat, util::OrderedFloat,
vec2, ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect, vec2, ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect, StrokeKind,
TessellationOptions, TextureAtlas, TextureId, Vec2, TessellationOptions, TextureAtlas, TextureId, Vec2,
}; };
@@ -26,11 +26,10 @@ use crate::{
load, load,
load::{Bytes, Loaders, SizedTexture}, load::{Bytes, Loaders, SizedTexture},
memory::{Options, Theme}, memory::{Options, Theme},
menu,
os::OperatingSystem, os::OperatingSystem,
output::FullOutput, output::FullOutput,
pass_state::PassState, pass_state::PassState,
resize, scroll_area, resize, response, scroll_area,
util::IdTypeMap, util::IdTypeMap,
viewport::ViewportClass, viewport::ViewportClass,
Align2, CursorIcon, DeferredViewportUiCallback, FontDefinitions, Grid, Id, ImmediateViewport, Align2, CursorIcon, DeferredViewportUiCallback, FontDefinitions, Grid, Id, ImmediateViewport,
@@ -83,7 +82,11 @@ impl Default for WrappedTextureManager {
epaint::FontImage::new([0, 0]).into(), epaint::FontImage::new([0, 0]).into(),
Default::default(), 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))) Self(Arc::new(RwLock::new(tex_mngr)))
} }
@@ -211,14 +214,14 @@ impl ContextImpl {
fn requested_immediate_repaint_prev_pass(&self, viewport_id: &ViewportId) -> bool { fn requested_immediate_repaint_prev_pass(&self, viewport_id: &ViewportId) -> bool {
self.viewports self.viewports
.get(viewport_id) .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] #[must_use]
fn has_requested_repaint(&self, viewport_id: &ViewportId) -> bool { fn has_requested_repaint(&self, viewport_id: &ViewportId) -> bool {
self.viewports.get(viewport_id).map_or(false, |v| { self.viewports
0 < v.repaint.outstanding || v.repaint.repaint_delay < Duration::MAX .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()) 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`, /// At most [`Options::max_passes`] calls will be issued to `run_ui`,
/// and only on the rare occasion that [`Context::request_discard`] is called. /// 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 max_passes = self.write(|ctx| ctx.memory.options.max_passes.get());
let mut output = FullOutput::default(); 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 { loop {
profiling::scope!( profiling::scope!(
@@ -829,7 +836,11 @@ impl Context {
self.begin_pass(new_input.take()); self.begin_pass(new_input.take());
run_ui(self); run_ui(self);
output.append(self.end_pass()); 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() { if !output.platform_output.requested_discard() {
break; // no need for another pass break; // no need for another pass
@@ -1087,7 +1098,7 @@ impl Context {
let text = format!("🔥 {text}"); let text = format!("🔥 {text}");
let color = self.style().visuals.error_fg_color; let color = self.style().visuals.error_fg_color;
let painter = self.debug_painter(); 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(); 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)). /// 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)] #[allow(clippy::too_many_arguments)]
pub(crate) fn create_widget(&self, w: WidgetRect, allow_focus: bool) -> Response { pub(crate) fn create_widget(&self, w: WidgetRect, allow_focus: bool) -> Response {
let interested_in_focus = let interested_in_focus = w.enabled
w.enabled && w.sense.focusable && self.memory(|mem| mem.allows_interaction(w.layer_id)); && w.sense.is_focusable()
&& self.memory(|mem| mem.allows_interaction(w.layer_id));
// Remember this widget // Remember this widget
self.write(|ctx| { self.write(|ctx| {
@@ -1173,7 +1185,7 @@ impl Context {
self.memory_mut(|mem| mem.surrender_focus(w.id)); 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"); self.check_for_id_clash(w.id, w.rect, "widget");
} }
@@ -1181,7 +1193,7 @@ impl Context {
let res = self.get_response(w); let res = self.get_response(w);
#[cfg(feature = "accesskit")] #[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. // Make sure anything that can receive focus has an AccessKit node.
// TODO(mwcampbell): For nodes that are filled from widget info, // TODO(mwcampbell): For nodes that are filled from widget info,
// some information is written to the node twice. // 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. /// 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 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> { pub fn read_response(&self, id: Id) -> Option<Response> {
self.write(|ctx| { self.write(|ctx| {
let viewport = ctx.viewport(); let viewport = ctx.viewport();
viewport let widget_rect = viewport
.this_pass .this_pass
.widgets .widgets
.get(id) .get(id)
.or_else(|| viewport.prev_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)) .map(|widget_rect| self.get_response(widget_rect))
} }
@@ -1213,11 +1237,13 @@ impl Context {
#[deprecated = "Use Response.contains_pointer or Context::read_response instead"] #[deprecated = "Use Response.contains_pointer or Context::read_response instead"]
pub fn widget_contains_pointer(&self, id: Id) -> bool { pub fn widget_contains_pointer(&self, id: Id) -> bool {
self.read_response(id) 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. /// Do all interaction for an existing widget, without (re-)registering it.
pub(crate) fn get_response(&self, widget_rect: WidgetRect) -> Response { pub(crate) fn get_response(&self, widget_rect: WidgetRect) -> Response {
use response::Flags;
let WidgetRect { let WidgetRect {
id, id,
layer_id, layer_id,
@@ -1237,61 +1263,72 @@ impl Context {
rect, rect,
interact_rect, interact_rect,
sense, sense,
enabled, flags: Flags::empty(),
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,
interact_pointer_pos: None, interact_pointer_pos: None,
changed: false,
intrinsic_size: None, intrinsic_size: None,
}; };
res.flags.set(Flags::ENABLED, enabled);
res.flags.set(Flags::HIGHLIGHTED, highlighted);
self.write(|ctx| { self.write(|ctx| {
let viewport = ctx.viewports.entry(ctx.viewport_id()).or_default(); 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 input = &viewport.input;
let memory = &mut ctx.memory; let memory = &mut ctx.memory;
if enabled if enabled
&& sense.click && sense.senses_click()
&& memory.has_focus(id) && memory.has_focus(id)
&& (input.key_pressed(Key::Space) || input.key_pressed(Key::Enter)) && (input.key_pressed(Key::Space) || input.key_pressed(Key::Enter))
{ {
// Space/enter works like a primary click for e.g. selected buttons // 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")] #[cfg(feature = "accesskit")]
if enabled if enabled
&& sense.click && sense.senses_click()
&& input.has_accesskit_action_request(id, accesskit::Action::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 { if enabled && sense.senses_click() && Some(id) == viewport.interact_widgets.long_touched
res.long_touched = true; {
res.flags.set(Flags::LONG_TOUCHED, true);
} }
let interaction = memory.interaction(); let interaction = memory.interaction();
res.is_pointer_button_down_on = interaction.potential_click_id == Some(id) res.flags.set(
|| interaction.potential_drag_id == Some(id); Flags::IS_POINTER_BUTTON_DOWN_ON,
interaction.potential_click_id == Some(id)
|| interaction.potential_drag_id == Some(id),
);
if res.enabled { if res.enabled() {
res.hovered = viewport.interact_widgets.hovered.contains(&id); res.flags.set(
res.dragged = Some(id) == viewport.interact_widgets.dragged; Flags::HOVERED,
res.drag_started = Some(id) == viewport.interact_widgets.drag_started; viewport.interact_widgets.hovered.contains(&id),
res.drag_stopped = Some(id) == viewport.interact_widgets.drag_stopped; );
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; let clicked = Some(id) == viewport.interact_widgets.clicked;
@@ -1304,20 +1341,22 @@ impl Context {
any_press = true; any_press = true;
} }
PointerEvent::Released { click, .. } => { PointerEvent::Released { click, .. } => {
if enabled && sense.click && clicked && click.is_some() { if enabled && sense.senses_click() && clicked && click.is_some() {
res.clicked = true; res.flags.set(Flags::CLICKED, true);
} }
res.is_pointer_button_down_on = false; res.flags.set(Flags::IS_POINTER_BUTTON_DOWN_ON, false);
res.dragged = false; res.flags.set(Flags::DRAGGED, false);
} }
} }
} }
// is_pointer_button_down_on is false when released, but we want interact_pointer_pos // is_pointer_button_down_on is false when released, but we want interact_pointer_pos
// to still work. // to still work.
let is_interacted_with = let is_interacted_with = res.is_pointer_button_down_on()
res.is_pointer_button_down_on || res.long_touched || clicked || res.drag_stopped; || res.long_touched()
|| clicked
|| res.drag_stopped();
if is_interacted_with { if is_interacted_with {
res.interact_pointer_pos = input.pointer.interact_pos(); res.interact_pointer_pos = input.pointer.interact_pos();
if let (Some(to_global), Some(pos)) = ( if let (Some(to_global), Some(pos)) = (
@@ -1330,10 +1369,10 @@ impl Context {
if input.pointer.any_down() && !is_interacted_with { if input.pointer.any_down() && !is_interacted_with {
// We don't hover widgets while interacting with *other* widgets: // 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) { if pointer_pressed_elsewhere && memory.has_focus(id) {
memory.surrender_focus(id); memory.surrender_focus(id);
} }
@@ -1455,6 +1494,11 @@ impl Context {
self.send_cmd(crate::OutputCommand::CopyImage(image)); 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`). /// 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`]. /// 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); let painter = Painter::new(self.clone(), *layer_id, Rect::EVERYTHING);
for rect in rects { for rect in rects {
if rect.sense.interactive() { 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") (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") (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") (Color32::from_rgb(0, 0, 0x88), "drag")
} else { } else {
// unreachable since we only show interactive // unreachable since we only show interactive
@@ -2532,10 +2577,7 @@ impl Context {
self.input(|i| i.screen_rect()).round_ui() self.input(|i| i.screen_rect()).round_ui()
} }
/// How much space is still available after panels has been added. /// How much space is still available after panels have 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 fn available_rect(&self) -> Rect { pub fn available_rect(&self) -> Rect {
self.pass_state(|s| s.available_rect()).round_ui() self.pass_state(|s| s.available_rect()).round_ui()
} }
@@ -2612,10 +2654,25 @@ impl Context {
} }
/// Is an egui context menu open? /// 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 { pub fn is_context_menu_open(&self) -> bool {
self.data(|d| { self.data(|d| {
d.get_temp::<crate::menu::BarState>(menu::CONTEXT_MENU_ID_STR.into()) d.get_temp::<crate::menu::BarState>(crate::menu::CONTEXT_MENU_ID_STR.into())
.map_or(false, |state| state.has_root()) .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). /// 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) { pub fn set_transform_layer(&self, layer_id: LayerId, transform: TSTransform) {
self.memory_mut(|m| { self.memory_mut(|m| {
if transform == TSTransform::IDENTITY { if transform == TSTransform::IDENTITY {
@@ -3131,7 +3189,7 @@ impl Context {
// TODO(emilk): `Sense::hover_highlight()` // TODO(emilk): `Sense::hover_highlight()`
let response = let response =
ui.add(Label::new(RichText::new(text).monospace()).sense(Sense::click())); ui.add(Label::new(RichText::new(text).monospace()).sense(Sense::click()));
if response.hovered && is_visible { if response.hovered() && is_visible {
ui.ctx() ui.ctx()
.debug_painter() .debug_painter()
.debug_rect(area.rect(), Color32::RED, ""); .debug_rect(area.rect(), Color32::RED, "");
@@ -3152,13 +3210,14 @@ impl Context {
} }
}); });
#[allow(deprecated)]
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(format!( ui.label(format!(
"{} menu bars", "{} menu bars",
self.data(|d| d.count::<menu::BarState>()) self.data(|d| d.count::<crate::menu::BarState>())
)); ));
if ui.button("Reset").clicked() { 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")] #[cfg(feature = "accesskit")]
self.pass_state_mut(|fs| { self.pass_state_mut(|fs| {
if let Some(state) = fs.accesskit_state.as_mut() { 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. /// The loaders of bytes, images, and textures.
pub fn loaders(&self) -> Arc<Loaders> { pub fn loaders(&self) -> Arc<Loaders> {
profiling::function_scope!();
self.read(|this| this.loaders.clone()) 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. /// The inner rectangle of the native window, in monitor space and ui points scale.
/// ///
/// This is the content rectangle of the viewport. /// 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>, pub inner_rect: Option<Rect>,
/// The outer rectangle of the native window, in monitor space and ui points scale. /// The outer rectangle of the native window, in monitor space and ui points scale.
/// ///
/// This is the content rectangle plus decoration chrome. /// 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>, pub outer_rect: Option<Rect>,
/// Are we minimized? /// 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. /// 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 { pub fn format(&self, modifiers: &Modifiers, is_mac: bool) -> String {
let mut s = String::new(); let mut s = String::new();
@@ -1084,7 +1101,7 @@ impl RawInput {
system_theme, system_theme,
} = self; } = self;
ui.label(format!("Active viwport: {viewport_id:?}")); ui.label(format!("Active viewport: {viewport_id:?}"));
for (id, viewport) in viewports { for (id, viewport) in viewports {
ui.group(|ui| { ui.group(|ui| {
ui.label(format!("Viewport {id:?}")); ui.label(format!("Viewport {id:?}"));

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ impl<'de> serde::Deserialize<'de> for UserData {
{ {
struct UserDataVisitor; struct UserDataVisitor;
impl<'de> serde::de::Visitor<'de> for UserDataVisitor { impl serde::de::Visitor<'_> for UserDataVisitor {
type Value = UserData; type Value = UserData;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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 { pub fn has_any_payload(ctx: &Context) -> bool {
ctx.data(|data| { ctx.data(|data| {
let state = data.get_temp::<Self>(Id::NULL); 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) { if (debug_expand_width && too_wide) || (debug_expand_height && too_high) {
let painter = self.ctx.debug_painter(); 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 stroke = Stroke::new(2.5, Color32::from_rgb(200, 0, 0));
let paint_line_seg = |a, b| painter.line_segment([a, b], stroke); 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_builder = ui_builder.sizing_pass().invisible();
} }
ui.allocate_new_ui(ui_builder, |ui| { ui.scope_builder(ui_builder, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let is_color = color_picker.is_some(); let is_color = color_picker.is_some();
let grid = GridLayout { let grid = GridLayout {

View File

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

View File

@@ -2,7 +2,7 @@ use ahash::HashMap;
use emath::TSTransform; 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`]. /// Result of a hit-test against [`WidgetRects`].
/// ///
@@ -128,11 +128,28 @@ pub fn hit_test(
// the `enabled` flag everywhere: // the `enabled` flag everywhere:
for w in &mut close { for w in &mut close {
if !w.enabled { if !w.enabled {
w.sense.click = false; w.sense -= Sense::CLICK;
w.sense.drag = false; 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); let mut hits = hit_test_on_close(&close, pos);
hits.contains_pointer = close hits.contains_pointer = close
@@ -158,11 +175,17 @@ pub fn hit_test(
restore_widget_rect(wr); restore_widget_rect(wr);
} }
if let Some(wr) = &mut hits.drag { 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); restore_widget_rect(wr);
} }
if let Some(wr) = &mut hits.click { 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); restore_widget_rect(wr);
} }
} }
@@ -179,8 +202,16 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
#![allow(clippy::collapsible_else_if)] #![allow(clippy::collapsible_else_if)]
// First find the best direct hits: // 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_click = find_closest_within(
let hit_drag = find_closest_within(close.iter().copied().filter(|w| w.sense.drag), pos, 0.0); 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) { match (hit_click, hit_drag) {
(None, None) => { (None, None) => {
@@ -190,14 +221,14 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
close close
.iter() .iter()
.copied() .copied()
.filter(|w| w.sense.click || w.sense.drag), .filter(|w| w.sense.senses_click() || w.sense.senses_drag()),
pos, pos,
); );
if let Some(closest) = closest { if let Some(closest) = closest {
WidgetHits { WidgetHits {
click: closest.sense.click.then_some(closest), click: closest.sense.senses_click().then_some(closest),
drag: closest.sense.drag.then_some(closest), drag: closest.sense.senses_drag().then_some(closest),
..Default::default() ..Default::default()
} }
} else { } else {
@@ -218,9 +249,12 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
// or a moveable window. // or a moveable window.
// It could also be something small, like a slider, or panel resize handle. // 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 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. // We have something close that sense both clicks and drag.
// Should we use it over the direct drag-hit? // Should we use it over the direct drag-hit?
if hit_drag if hit_drag
@@ -244,7 +278,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
} }
} }
} else { } 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 // However, we should be careful to only return two different widgets
// when it is absolutely not going to confuse the user. // when it is absolutely not going to confuse the user.
if hit_drag if hit_drag
@@ -277,7 +311,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
close close
.iter() .iter()
.copied() .copied()
.filter(|w| w.sense.drag && w.id != hit_drag.id), .filter(|w| w.sense.senses_drag() && w.id != hit_drag.id),
pos, 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; let click_is_on_top_of_drag = drag_idx < click_idx;
if click_is_on_top_of_drag { 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. // The top thing senses both clicks and drags.
WidgetHits { WidgetHits {
click: Some(hit_click), click: Some(hit_click),
@@ -349,7 +383,7 @@ fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
} }
} }
} else { } else {
if hit_drag.sense.click { if hit_drag.sense.senses_click() {
// The top thing senses both clicks and drags. // The top thing senses both clicks and drags.
WidgetHits { WidgetHits {
click: Some(hit_drag), click: Some(hit_drag),
@@ -393,7 +427,7 @@ fn find_closest_within(
if dist_sq == closest_dist_sq { if dist_sq == closest_dist_sq {
// It's a tie! Pick the thin candidate over the thick one. // It's a tie! Pick the thin candidate over the thick one.
// This makes it easier to hit a thin resize-handle, for instance: // 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; continue;
} }
} }
@@ -409,12 +443,12 @@ fn find_closest_within(
closest 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. /// `back` should be behind the `front` widget.
/// ///
/// Returns true if `back` is a small hit-target and `front` is not. /// 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) { if front.contains_rect(back) {
return false; // back widget is fully occluded; no way to hit it 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.click.unwrap().id, Id::new("click-and-drag"));
assert_eq!(hits.drag.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)); 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.click.unwrap().id, Id::new("click-and-drag"));
assert_eq!(hits.drag.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))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Id(NonZeroU64); pub struct Id(NonZeroU64);
impl nohash_hasher::IsEnabled for Id {}
impl Id { impl Id {
/// A special [`Id`], in particular as a key to [`crate::Memory::data`] /// A special [`Id`], in particular as a key to [`crate::Memory::data`]
/// for when there is no particular widget to attach the 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. /// `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. /// `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) => { Event::Zoom(factor) => {
zoom_factor_delta *= *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); self.motion = Some(Vec2::ZERO);
} }
let mut clear_history_after_velocity_calculation = false;
for event in &new.events { for event in &new.events {
match event { match event {
Event::PointerMoved(pos) => { 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, // 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`. // 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. // 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, Event::MouseMoved(delta) => *self.motion.get_or_insert(Vec2::ZERO) += *delta,
_ => {} _ => {}
@@ -1036,6 +1048,9 @@ impl PointerState {
if self.velocity != Vec2::ZERO { if self.velocity != Vec2::ZERO {
self.last_move_time = time; 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(); self.direction = self.pos_history.velocity().unwrap_or_default().normalized();
@@ -1303,7 +1318,7 @@ impl PointerState {
self.started_decidedly_dragging self.started_decidedly_dragging
&& !self.has_moved_too_much_for_a_click && !self.has_moved_too_much_for_a_click
&& self.button_down(PointerButton::Primary) && 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 self.time - press_start_time > self.input_options.max_click_duration
}) })
} }
@@ -1325,6 +1340,18 @@ impl PointerState {
pub fn middle_down(&self) -> bool { pub fn middle_down(&self) -> bool {
self.button_down(PointerButton::Middle) 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 { impl InputState {

View File

@@ -192,14 +192,14 @@ pub(crate) fn interact(
// Check if we started dragging something new: // Check if we started dragging something new:
if let Some(widget) = interaction.potential_drag_id.and_then(|id| widgets.get(id)) { if let Some(widget) = interaction.potential_drag_id.and_then(|id| widgets.get(id)) {
if widget.enabled { 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. // This widget is sensitive to both clicks and drags.
// When the mouse first is pressed, it could be either, // When the mouse first is pressed, it could be either,
// so we postpone the decision until we know. // so we postpone the decision until we know.
input.pointer.is_decidedly_dragging() input.pointer.is_decidedly_dragging()
} else { } else {
// This widget is just sensitive to drags, so we can mark it as dragged right away: // 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 { 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(); let mut hovered: IdSet = hits.click.iter().chain(&hits.drag).map(|w| w.id).collect();
for w in &hits.contains_pointer { 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 { if is_interactive {
// The only interactive widgets we mark as hovered are the ones // The only interactive widgets we mark as hovered are the ones
// in `hits.click` and `hits.drag`! // 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."); .on_hover_text("Apply feathering to smooth out the edges of shapes. Turn off for small performance gain.");
if *feathering { 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) { pub fn sanity_check(&self) {
debug_assert!(!self.min_rect.any_nan()); debug_assert!(
debug_assert!(!self.max_rect.any_nan()); !self.min_rect.any_nan(),
debug_assert!(!self.cursor.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 /// ## Doing layout
impl Layout { impl Layout {
pub fn align_size_within_rect(&self, size: Vec2, outer: Rect) -> Rect { pub fn align_size_within_rect(&self, size: Vec2, outer: Rect) -> Rect {
debug_assert!(size.x >= 0.0 && size.y >= 0.0); debug_assert!(size.x >= 0.0 && size.y >= 0.0, "Negative size: {size:?}");
debug_assert!(!outer.is_negative()); debug_assert!(!outer.is_negative(), "Negative outer: {outer:?}");
self.align2().align_size_within_rect(size, outer).round_ui() 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 { 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 { let mut region = Region {
min_rect: Rect::NOTHING, // temporary min_rect: Rect::NOTHING, // temporary
max_rect, max_rect,
@@ -454,8 +462,8 @@ impl Layout {
/// Given the cursor in the region, how much space is available /// Given the cursor in the region, how much space is available
/// for the next widget? /// for the next widget?
fn available_from_cursor_max_rect(&self, cursor: Rect, max_rect: Rect) -> Rect { fn available_from_cursor_max_rect(&self, cursor: Rect, max_rect: Rect) -> Rect {
debug_assert!(!cursor.any_nan()); debug_assert!(!cursor.any_nan(), "cursor is NaN: {cursor:?}");
debug_assert!(!max_rect.any_nan()); 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, // NOTE: in normal top-down layout the cursor has moved below the current max_rect,
// but the available shouldn't be negative. // but the available shouldn't be negative.
@@ -509,7 +517,7 @@ impl Layout {
avail.max.y = y; avail.max.y = y;
} }
debug_assert!(!avail.any_nan()); debug_assert!(!avail.any_nan(), "avail is NaN: {avail:?}");
avail avail
} }
@@ -520,7 +528,10 @@ impl Layout {
/// Use `justify_and_align` to get the inner `widget_rect`. /// Use `justify_and_align` to get the inner `widget_rect`.
pub(crate) fn next_frame(&self, region: &Region, child_size: Vec2, spacing: Vec2) -> Rect { pub(crate) fn next_frame(&self, region: &Region, child_size: Vec2, spacing: Vec2) -> Rect {
region.sanity_check(); 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 { if self.main_wrap {
let available_size = self.available_rect_before_wrap(region).size(); 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 { fn next_frame_ignore_wrap(&self, region: &Region, child_size: Vec2) -> Rect {
region.sanity_check(); 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); 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())); frame_rect = frame_rect.translate(Vec2::Y * (region.cursor.top() - frame_rect.top()));
} }
debug_assert!(!frame_rect.any_nan()); debug_assert!(!frame_rect.any_nan(), "frame_rect is NaN: {frame_rect:?}");
debug_assert!(!frame_rect.is_negative()); debug_assert!(!frame_rect.is_negative(), "frame_rect is negative");
frame_rect.round_ui() frame_rect.round_ui()
} }
/// Apply justify (fill width/height) and/or alignment after calling `next_space`. /// 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 { 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!(
debug_assert!(!frame.is_negative()); 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() { if self.horizontal_justify() {
child_size.x = child_size.x.at_least(frame.width()); // fill full width child_size.x = child_size.x.at_least(frame.width()); // fill full width
@@ -660,8 +677,8 @@ impl Layout {
) -> Rect { ) -> Rect {
let frame = self.next_frame_ignore_wrap(region, size); let frame = self.next_frame_ignore_wrap(region, size);
let rect = self.align_size_within_rect(size, frame); let rect = self.align_size_within_rect(size, frame);
debug_assert!(!rect.any_nan()); debug_assert!(!rect.any_nan(), "rect is NaN: {rect:?}");
debug_assert!(!rect.is_negative()); debug_assert!(!rect.is_negative(), "rect is negative: {rect:?}");
rect rect
} }
@@ -704,7 +721,7 @@ impl Layout {
widget_rect: Rect, widget_rect: Rect,
item_spacing: Vec2, item_spacing: Vec2,
) { ) {
debug_assert!(!cursor.any_nan()); debug_assert!(!cursor.any_nan(), "cursor is NaN: {cursor:?}");
if self.main_wrap { if self.main_wrap {
if cursor.intersects(frame_rect.shrink(1.0)) { if cursor.intersects(frame_rect.shrink(1.0)) {
// make row/column larger if necessary // 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>. //! 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. //! `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) //! 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). //! which uses [`eframe`](https://docs.rs/eframe).
@@ -422,12 +422,13 @@ pub mod layers;
mod layout; mod layout;
pub mod load; pub mod load;
mod memory; mod memory;
#[deprecated = "Use `egui::containers::menu` instead"]
pub mod menu; pub mod menu;
pub mod os; pub mod os;
mod painter; mod painter;
mod pass_state; mod pass_state;
pub(crate) mod placer; pub(crate) mod placer;
mod response; pub mod response;
mod sense; mod sense;
pub mod style; pub mod style;
pub mod text_selection; pub mod text_selection;
@@ -458,18 +459,19 @@ pub use epaint::emath;
pub use ecolor::hex_color; pub use ecolor::hex_color;
pub use ecolor::{Color32, Rgba}; pub use ecolor::{Color32, Rgba};
pub use emath::{ 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::{ pub use epaint::{
mutex, mutex,
text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak}, text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak},
textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta}, textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta},
ClippedPrimitive, ColorImage, FontImage, ImageData, Margin, Mesh, PaintCallback, ClippedPrimitive, ColorImage, CornerRadius, FontImage, ImageData, Margin, Mesh, PaintCallback,
PaintCallbackInfo, Rounding, Shadow, Shape, Stroke, TextureHandle, TextureId, PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId,
}; };
pub mod text { pub mod text {
pub use crate::text_selection::{CCursorRange, CursorRange}; pub use crate::text_selection::CCursorRange;
pub use epaint::text::{ pub use epaint::text::{
cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob, cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob,
LayoutSection, TextFormat, TextWrapping, TAB_SIZE, LayoutSection, TextFormat, TextWrapping, TAB_SIZE,
@@ -510,6 +512,9 @@ pub use self::{
widgets::*, widgets::*,
}; };
#[deprecated = "Renamed to CornerRadius"]
pub type Rounding = CornerRadius;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Helper function that adds a label when compiling with debug assertions enabled. /// 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( /// ui.add(
/// egui::Image::new(egui::include_image!("../assets/ferris.png")) /// egui::Image::new(egui::include_image!("../assets/ferris.png"))
/// .max_width(200.0) /// .max_width(200.0)
/// .rounding(10.0), /// .corner_radius(10),
/// ); /// );
/// ///
/// let image_source: egui::ImageSource = egui::include_image!("../assets/ferris.png"); /// let image_source: egui::ImageSource = egui::include_image!("../assets/ferris.png");
@@ -625,9 +630,6 @@ pub mod special_emojis {
/// The Github logo. /// The Github logo.
pub const GITHUB: char = ''; pub const GITHUB: char = '';
/// The Twitter bird.
pub const TWITTER: char = '';
/// The word `git`. /// The word `git`.
pub const GIT: char = ''; pub const GIT: char = '';

View File

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

View File

@@ -89,8 +89,12 @@ pub struct Memory {
/// Which popup-window is open (if any)? /// Which popup-window is open (if any)?
/// Could be a combo box, color picker, menu, etc. /// 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))] #[cfg_attr(feature = "persistence", serde(skip))]
popup: Option<Id>, popup: Option<OpenPopup>,
#[cfg_attr(feature = "persistence", serde(skip))] #[cfg_attr(feature = "persistence", serde(skip))]
everything_is_visible: bool, everything_is_visible: bool,
@@ -318,7 +322,7 @@ impl Default for Options {
Self { Self {
dark_style: std::sync::Arc::new(Theme::Dark.default_style()), dark_style: std::sync::Arc::new(Theme::Dark.default_style()),
light_style: std::sync::Arc::new(Theme::Light.default_style()), light_style: std::sync::Arc::new(Theme::Light.default_style()),
theme_preference: ThemePreference::System, theme_preference: Default::default(),
fallback_theme: Theme::Dark, fallback_theme: Theme::Dark,
system_theme: None, system_theme: None,
zoom_factor: 1.0, zoom_factor: 1.0,
@@ -803,6 +807,15 @@ impl Memory {
self.caches.update(); self.caches.update();
self.areas_mut().end_pass(); self.areas_mut().end_pass();
self.focus_mut().end_pass(used_ids); 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) { 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
/// Popups are things like combo-boxes, color pickers, menus etc. /// Popups are things like combo-boxes, color pickers, menus etc.
/// Only one can be open at a time. /// Only one can be open at a time.
impl Memory { impl Memory {
/// Is the given popup open? /// Is the given popup open?
pub fn is_popup_open(&self, popup_id: Id) -> bool { 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? /// Is any popup open?
@@ -1079,21 +1116,56 @@ impl Memory {
} }
/// Open the given popup and close all others. /// 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) { 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. /// Popups must call this every frame while open.
pub fn close_popup(&mut self) { ///
/// 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; 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. /// Toggle the given popup between closed and open.
/// ///
/// Note: At most, only one popup can be open at a time. /// Note: At most, only one popup can be open at a time.
pub fn toggle_popup(&mut self, popup_id: Id) { pub fn toggle_popup(&mut self, popup_id: Id) {
if self.is_popup_open(popup_id) { if self.is_popup_open(popup_id) {
self.close_popup(); self.close_popup(popup_id);
} else { } else {
self.open_popup(popup_id); 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. /// 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 { 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)) { // Sort by layer `order` first and use `order_map` to resolve disputes.
a.cmp(b) // If `order_map` only contains one layer ID, then the other one will be
} else { // lower because `None < Some(x)`.
a.order.cmp(&b.order) 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) { pub(crate) fn set_state(&mut self, layer_id: LayerId, state: area::AreaState) {
self.visible_areas_current_frame.insert(layer_id); self.visible_areas_current_frame.insert(layer_id);
self.areas.insert(layer_id.id, state); 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); self.order.push(layer_id);
} }
} }
@@ -1351,3 +1425,47 @@ fn memory_impl_send_sync() {
fn assert_send_sync<T: Send + Sync>() {} fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Memory>(); 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. /// 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))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum ThemePreference { pub enum ThemePreference {
/// Dark mode: light text on a dark background. /// Dark mode: light text on a dark background.
@@ -76,6 +76,7 @@ pub enum ThemePreference {
Light, Light,
/// Follow the system's theme preference. /// Follow the system's theme preference.
#[default]
System, System,
} }

View File

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

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use emath::GuiRounding as _; use emath::GuiRounding as _;
use epaint::{ use epaint::{
text::{Fonts, Galley, LayoutJob}, text::{Fonts, Galley, LayoutJob},
CircleShape, ClippedShape, PathStroke, RectShape, Rounding, Shape, Stroke, CircleShape, ClippedShape, CornerRadius, PathStroke, RectShape, Shape, Stroke, StrokeKind,
}; };
use crate::{ use crate::{
@@ -301,6 +301,7 @@ impl Painter {
0.0, 0.0,
color.additive().linear_multiply(0.015), color.additive().linear_multiply(0.015),
(1.0, color), (1.0, color),
StrokeKind::Outside,
); );
self.text( self.text(
rect.min, 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( pub fn rect(
&self, &self,
rect: Rect, rect: Rect,
rounding: impl Into<Rounding>, corner_radius: impl Into<CornerRadius>,
fill_color: impl Into<Color32>, fill_color: impl Into<Color32>,
stroke: impl Into<Stroke>, stroke: impl Into<Stroke>,
stroke_kind: StrokeKind,
) -> ShapeIdx { ) -> 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( pub fn rect_filled(
&self, &self,
rect: Rect, rect: Rect,
rounding: impl Into<Rounding>, corner_radius: impl Into<CornerRadius>,
fill_color: impl Into<Color32>, fill_color: impl Into<Color32>,
) -> ShapeIdx { ) -> 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( pub fn rect_stroke(
&self, &self,
rect: Rect, rect: Rect,
rounding: impl Into<Rounding>, corner_radius: impl Into<CornerRadius>,
stroke: impl Into<Stroke>, stroke: impl Into<Stroke>,
stroke_kind: StrokeKind,
) -> ShapeIdx { ) -> 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()`. /// 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| { /// # egui::__run_test_ui(|ui| {
/// # let rect = egui::Rect::from_min_size(Default::default(), egui::Vec2::splat(100.0)); /// # let rect = egui::Rect::from_min_size(Default::default(), egui::Vec2::splat(100.0));
/// egui::Image::new(egui::include_image!("../assets/ferris.png")) /// egui::Image::new(egui::include_image!("../assets/ferris.png"))
/// .rounding(5.0) /// .corner_radius(5)
/// .tint(egui::Color32::LIGHT_BLUE) /// .tint(egui::Color32::LIGHT_BLUE)
/// .paint_at(ui, rect); /// .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}; 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)? /// Is there any open popup (menus, combo-boxes, etc)?
/// ///
/// Does NOT include tooltips. /// Does NOT include tooltips.
pub open_popups: HashSet<Id>, pub open_popups: IdSet,
/// Which widget is showing a tooltip (if any)? /// Which widget is showing a tooltip (if any)?
/// ///
@@ -121,7 +121,13 @@ impl DebugRect {
Color32::LIGHT_BLUE Color32::LIGHT_BLUE
}; };
let rect_bg_color = Color32::BLUE.gamma_multiply(0.5); 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() { if !callstack.is_empty() {
@@ -157,7 +163,13 @@ impl DebugRect {
text_bg_color text_bg_color
}; };
let text_rect = Rect::from_min_size(text_pos, galley.size()); 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); painter.galley(text_pos, galley, text_color);
if is_clicking { if is_clicking {
@@ -190,7 +202,6 @@ pub struct PassState {
/// Starts off as the `screen_rect`, shrinks as panels are added. /// Starts off as the `screen_rect`, shrinks as panels are added.
/// The [`crate::CentralPanel`] does not change this. /// The [`crate::CentralPanel`] does not change this.
/// This is the area available to Window's.
pub available_rect: Rect, pub available_rect: Rect,
/// Starts off as the `screen_rect`, shrinks as panels are added. /// 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. /// 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 { pub(crate) fn available_rect(&self) -> Rect {
debug_assert!( debug_assert!(
self.available_rect.is_finite(), self.available_rect.is_finite(),

View File

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

View File

@@ -2,21 +2,21 @@ use std::{any::Any, sync::Arc};
use crate::{ use crate::{
emath::{Align, Pos2, Rect, Vec2}, emath::{Align, Pos2, Rect, Vec2},
menu, pass_state, AreaState, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense, Tooltip,
WidgetRect, WidgetText, Ui, WidgetRect, WidgetText,
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// The result of adding a widget to a [`Ui`]. /// 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. /// It also lets you easily show a tooltip on hover.
/// ///
/// Whenever something gets added to a [`Ui`], a [`Response`] object is returned. /// 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. /// [`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`. /// ⚠️ 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`]. /// such as [`Context::input`].
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Response { pub struct Response {
@@ -50,78 +50,12 @@ pub struct Response {
/// (that is handled by the `Painter` directly). /// (that is handled by the `Painter` directly).
pub sense: Sense, pub sense: Sense,
/// Was the widget enabled?
/// If `false`, there was no interaction attempted (not even hover).
#[doc(hidden)]
pub enabled: bool,
// OUT: // OUT:
/// The pointer is above this widget with no other blocking it. /// Where the pointer (mouse/touch) were when this widget was clicked or dragged.
#[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.
/// `None` if the widget is not being interacted with. /// `None` if the widget is not being interacted with.
#[doc(hidden)] #[doc(hidden)]
pub interact_pointer_pos: Option<Pos2>, 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. /// The intrinsic / desired size of the widget.
/// ///
/// For a button, this will be the size of the label + the frames padding, /// For a button, this will be the size of the label + the frames padding,
@@ -133,6 +67,76 @@ pub struct Response {
/// for improved layouting. /// for improved layouting.
/// See for instance [`egui_flex`](https://github.com/lucasmerlin/hello_egui/tree/main/crates/egui_flex). /// See for instance [`egui_flex`](https://github.com/lucasmerlin/hello_egui/tree/main/crates/egui_flex).
pub intrinsic_size: Option<Vec2>, 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 { impl Response {
@@ -150,7 +154,7 @@ impl Response {
/// You can use [`Self::interact`] to sense more things *after* adding a widget. /// You can use [`Self::interact`] to sense more things *after* adding a widget.
#[inline(always)] #[inline(always)]
pub fn clicked(&self) -> bool { 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. /// 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. /// Use [`Self::secondary_clicked`] instead to also detect that.
#[inline] #[inline]
pub fn clicked_by(&self, button: PointerButton) -> bool { 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). /// 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. /// This also returns true if the widget was pressed-and-held on a touch screen.
#[inline] #[inline]
pub fn secondary_clicked(&self) -> bool { 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? /// Was this long-pressed on a touch screen?
@@ -179,7 +183,7 @@ impl Response {
/// Usually you want to check [`Self::secondary_clicked`] instead. /// Usually you want to check [`Self::secondary_clicked`] instead.
#[inline] #[inline]
pub fn long_touched(&self) -> bool { 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. /// 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. /// Returns true if this widget was double-clicked this frame by the given button.
#[inline] #[inline]
pub fn double_clicked_by(&self, button: PointerButton) -> bool { 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. /// Returns true if this widget was triple-clicked this frame by the given button.
#[inline] #[inline]
pub fn triple_clicked_by(&self, button: PointerButton) -> bool { 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. /// `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, /// 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. /// 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 { 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, // 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). // even if we aren't clickable (or even enabled).
// This is important for windows and such that should close then the user clicks elsewhere. // This is important for windows and such that should close then the user clicks elsewhere.
self.ctx.input(|i| { if any_click {
let pointer = &i.pointer; if self.contains_pointer() || self.hovered() {
false
if pointer.any_click() { } else if let Some(pos) = pointer_interact_pos {
if self.contains_pointer || self.hovered { let layer_under_pointer = self.ctx.layer_id_at(pos);
false if layer_under_pointer != Some(self.layer_id) {
} else if let Some(pos) = pointer.interact_pos() { true
!self.interact_rect.contains(pos)
} else { } else {
false // clicked without a pointer, weird !self.interact_rect.contains(pos)
} }
} else { } else {
false false // clicked without a pointer, weird
} }
}) } else {
false
}
} }
/// Was the widget enabled? /// Was the widget enabled?
@@ -242,7 +255,7 @@ impl Response {
/// and the widget should be drawn in a gray disabled look. /// and the widget should be drawn in a gray disabled look.
#[inline(always)] #[inline(always)]
pub fn enabled(&self) -> bool { 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. /// 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. /// `hovered` is always `false` for disabled widgets.
#[inline(always)] #[inline(always)]
pub fn hovered(&self) -> bool { 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. /// 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. /// [`Self::contains_pointer`] also checks that no other widget is covering this response rectangle.
#[inline(always)] #[inline(always)]
pub fn contains_pointer(&self) -> bool { 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`]. /// The widget is highlighted via a call to [`Self::highlight`] or [`Context::highlight_widget`].
#[doc(hidden)] #[doc(hidden)]
#[inline(always)] #[inline(always)]
pub fn highlighted(&self) -> bool { pub fn highlighted(&self) -> bool {
self.highlighted self.flags.contains(Flags::HIGHLIGHTED)
} }
/// This widget has the keyboard focus (i.e. is receiving key presses). /// 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)); 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. /// 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. /// 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. /// This will only be true for a single frame.
#[inline] #[inline]
pub fn drag_started(&self) -> bool { 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. /// 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. /// 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. /// You can use [`Self::interact`] to sense more things *after* adding a widget.
#[inline(always)] #[inline(always)]
pub fn dragged(&self) -> bool { pub fn dragged(&self) -> bool {
self.dragged self.flags.contains(Flags::DRAGGED)
} }
/// See [`Self::dragged`]. /// See [`Self::dragged`].
@@ -366,7 +379,7 @@ impl Response {
/// The widget was being dragged, but now it has been released. /// The widget was being dragged, but now it has been released.
#[inline] #[inline]
pub fn drag_stopped(&self) -> bool { 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. /// The widget was being dragged by the button, but now it has been released.
@@ -378,7 +391,7 @@ impl Response {
#[inline] #[inline]
#[deprecated = "Renamed 'drag_stopped'"] #[deprecated = "Renamed 'drag_stopped'"]
pub fn drag_released(&self) -> bool { 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. /// 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); 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, // Things that can be drag-dropped should use the Grab cursor icon,
// but if the thing is _also_ clickable, that can be annoying. // but if the thing is _also_ clickable, that can be annoying.
self.ctx.set_cursor_icon(CursorIcon::Grab); 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. /// `None` if the widget is not being interacted with.
#[inline] #[inline]
@@ -492,7 +505,7 @@ impl Response {
/// This could also be thought of as "is this widget being interacted with?". /// This could also be thought of as "is this widget being interacted with?".
#[inline(always)] #[inline(always)]
pub fn is_pointer_button_down_on(&self) -> bool { 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? /// Was the underlying data changed?
@@ -510,7 +523,7 @@ impl Response {
/// for instance if an existing slider value was clamped to the given range. /// for instance if an existing slider value was clamped to the given range.
#[inline(always)] #[inline(always)]
pub fn changed(&self) -> bool { pub fn changed(&self) -> bool {
self.changed self.flags.contains(Flags::CHANGED)
} }
/// Report the data shown by this widget changed. /// Report the data shown by this widget changed.
@@ -519,10 +532,25 @@ impl Response {
/// e.g. checkboxes, sliders etc. /// e.g. checkboxes, sliders etc.
/// ///
/// This should be called when the *content* changes, but not when the view does. /// 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)] #[inline(always)]
pub fn mark_changed(&mut self) { 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). /// Show this UI if the widget was hovered (i.e. a tooltip).
@@ -547,36 +575,22 @@ impl Response {
/// ``` /// ```
#[doc(alias = "tooltip")] #[doc(alias = "tooltip")]
pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self { pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.enabled && self.should_show_hover_ui() { Tooltip::for_enabled(&self).show(add_contents);
self.show_tooltip_ui(add_contents);
}
self self
} }
/// Show this UI when hovering if the widget is disabled. /// Show this UI when hovering if the widget is disabled.
pub fn on_disabled_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self { pub fn on_disabled_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if !self.enabled && self.should_show_hover_ui() { Tooltip::for_disabled(&self).show(add_contents);
crate::containers::show_tooltip_for(
&self.ctx,
self.layer_id,
self.id,
&self.rect,
add_contents,
);
}
self self
} }
/// Like `on_hover_ui`, but show the ui next to cursor. /// 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 { pub fn on_hover_ui_at_pointer(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.enabled && self.should_show_hover_ui() { Tooltip::for_enabled(&self)
crate::containers::show_tooltip_at_pointer( .at_pointer()
&self.ctx, .gap(12.0)
self.layer_id, .show(add_contents);
self.id,
add_contents,
);
}
self self
} }
@@ -584,13 +598,9 @@ impl Response {
/// ///
/// This can be used to give attention to a widget during a tutorial. /// 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)) { pub fn show_tooltip_ui(&self, add_contents: impl FnOnce(&mut Ui)) {
crate::containers::show_tooltip_for( Popup::from_response(self)
&self.ctx, .kind(PopupKind::Tooltip)
self.layer_id, .show(add_contents);
self.id,
&self.rect,
add_contents,
);
} }
/// Always show this tooltip, even if disabled and the user isn't hovering it. /// 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? /// Was the tooltip open last frame?
pub fn is_tooltip_open(&self) -> bool { pub fn is_tooltip_open(&self) -> bool {
crate::popup::was_tooltip_open_last_frame(&self.ctx, self.id) Tooltip::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
} }
/// Like `on_hover_text`, but show the text next to cursor. /// Like `on_hover_text`, but show the text next to cursor.
@@ -817,7 +654,7 @@ impl Response {
#[inline] #[inline]
pub fn highlight(mut self) -> Self { pub fn highlight(mut self) -> Self {
self.ctx.highlight_widget(self.id); self.ctx.highlight_widget(self.id);
self.highlighted = true; self.flags.set(Flags::HIGHLIGHTED, true);
self self
} }
@@ -888,7 +725,7 @@ impl Response {
rect: self.rect, rect: self.rect,
interact_rect: self.interact_rect, interact_rect: self.interact_rect,
sense: self.sense | sense, sense: self.sense | sense,
enabled: self.enabled, enabled: self.enabled(),
}, },
true, true,
) )
@@ -951,7 +788,7 @@ impl Response {
Some(OutputEvent::TripleClicked(make_info())) Some(OutputEvent::TripleClicked(make_info()))
} else if self.gained_focus() { } else if self.gained_focus() {
Some(OutputEvent::FocusGained(make_info())) Some(OutputEvent::FocusGained(make_info()))
} else if self.changed { } else if self.changed() {
Some(OutputEvent::ValueChanged(make_info())) Some(OutputEvent::ValueChanged(make_info()))
} else { } else {
None None
@@ -983,7 +820,7 @@ impl Response {
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
pub(crate) fn fill_accesskit_node_common(&self, builder: &mut accesskit::Node) { pub(crate) fn fill_accesskit_node_common(&self, builder: &mut accesskit::Node) {
if !self.enabled { if !self.enabled() {
builder.set_disabled(); builder.set_disabled();
} }
builder.set_bounds(accesskit::Rect { builder.set_bounds(accesskit::Rect {
@@ -992,10 +829,10 @@ impl Response {
x1: self.rect.max.x.into(), x1: self.rect.max.x.into(),
y1: self.rect.max.y.into(), y1: self.rect.max.y.into(),
}); });
if self.sense.focusable { if self.sense.is_focusable() {
builder.add_action(accesskit::Action::Focus); builder.add_action(accesskit::Action::Focus);
} }
if self.sense.click { if self.sense.senses_click() {
builder.add_action(accesskit::Action::Click); builder.add_action(accesskit::Action::Click);
} }
} }
@@ -1056,6 +893,9 @@ impl Response {
// Indeterminate state // Indeterminate state
builder.set_toggled(Toggled::Mixed); 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. /// 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())); /// let response = ui.add(Label::new("Right-click me!").sense(Sense::click()));
/// response.context_menu(|ui| { /// response.context_menu(|ui| {
/// if ui.button("Close the menu").clicked() { /// 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<()>> { 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. /// Returns whether a context menu is currently open for this widget.
/// ///
/// See [`Self::context_menu`]. /// See [`Self::context_menu`].
pub fn context_menu_opened(&self) -> bool { 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 /// 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) { pub fn paint_debug_info(&self) {
self.ctx.debug_painter().debug_rect( self.ctx.debug_painter().debug_rect(
self.rect, self.rect,
if self.hovered { if self.hovered() {
crate::Color32::DARK_GREEN crate::Color32::DARK_GREEN
} else if self.enabled { } else if self.enabled() {
crate::Color32::BLUE crate::Color32::BLUE
} else { } else {
crate::Color32::RED crate::Color32::RED
@@ -1145,7 +985,10 @@ impl Response {
/// ///
/// You may not call [`Self::interact`] on the resulting `Response`. /// You may not call [`Self::interact`] on the resulting `Response`.
pub fn union(&self, other: Self) -> Self { 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!( debug_assert!(
self.layer_id == other.layer_id, self.layer_id == other.layer_id,
"It makes no sense to combine Responses from two different layers" "It makes no sense to combine Responses from two different layers"
@@ -1157,20 +1000,8 @@ impl Response {
rect: self.rect.union(other.rect), rect: self.rect.union(other.rect),
interact_rect: self.interact_rect.union(other.interact_rect), interact_rect: self.interact_rect.union(other.interact_rect),
sense: self.sense.union(other.sense), sense: self.sense.union(other.sense),
enabled: self.enabled || other.enabled, flags: self.flags | other.flags,
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,
interact_pointer_pos: self.interact_pointer_pos.or(other.interact_pointer_pos), interact_pointer_pos: self.interact_pointer_pos.or(other.interact_pointer_pos),
changed: self.changed || other.changed,
intrinsic_size: None, intrinsic_size: None,
} }
} }

View File

@@ -1,36 +1,37 @@
/// What sort of interaction is a widget sensitive to? /// What sort of interaction is a widget sensitive to?
#[derive(Clone, Copy, Eq, PartialEq)] #[derive(Clone, Copy, Eq, PartialEq)]
// #[cfg_attr(feature = "serde", derive(serde::Serialize))] // #[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Sense { pub struct Sense(u8);
/// Buttons, sliders, windows, …
pub click: bool,
/// Sliders, windows, scroll bars, scroll areas, … bitflags::bitflags! {
pub drag: bool, impl Sense: u8 {
/// This widget wants focus. const HOVER = 0;
///
/// Anything interactive + labels that can be focused /// Buttons, sliders, windows, …
/// for the benefit of screen readers. const CLICK = 1<<0;
pub focusable: bool,
/// 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 { impl std::fmt::Debug for Sense {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
click,
drag,
focusable,
} = self;
write!(f, "Sense {{")?; write!(f, "Sense {{")?;
if *click { if self.senses_click() {
write!(f, " click")?; write!(f, " click")?;
} }
if *drag { if self.senses_drag() {
write!(f, " drag")?; write!(f, " drag")?;
} }
if *focusable { if self.is_focusable() {
write!(f, " focusable")?; write!(f, " focusable")?;
} }
write!(f, " }}") write!(f, " }}")
@@ -42,42 +43,26 @@ impl Sense {
#[doc(alias = "none")] #[doc(alias = "none")]
#[inline] #[inline]
pub fn hover() -> Self { pub fn hover() -> Self {
Self { Self::empty()
click: false,
drag: false,
focusable: false,
}
} }
/// Senses no clicks or drags, but can be focused with the keyboard. /// 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. /// Used for labels that can be focused for the benefit of screen readers.
#[inline] #[inline]
pub fn focusable_noninteractive() -> Self { pub fn focusable_noninteractive() -> Self {
Self { Self::FOCUSABLE
click: false,
drag: false,
focusable: true,
}
} }
/// Sense clicks and hover, but not drags. /// Sense clicks and hover, but not drags.
#[inline] #[inline]
pub fn click() -> Self { pub fn click() -> Self {
Self { Self::CLICK | Self::FOCUSABLE
click: true,
drag: false,
focusable: true,
}
} }
/// Sense drags and hover, but not clicks. /// Sense drags and hover, but not clicks.
#[inline] #[inline]
pub fn drag() -> Self { pub fn drag() -> Self {
Self { Self::DRAG | Self::FOCUSABLE
click: false,
drag: true,
focusable: true,
}
} }
/// Sense both clicks, drags and hover (e.g. a slider or window). /// 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. /// See [`crate::PointerState::is_decidedly_dragging`] for details.
#[inline] #[inline]
pub fn click_and_drag() -> Self { pub fn click_and_drag() -> Self {
Self { Self::CLICK | Self::FOCUSABLE | Self::DRAG
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,
}
} }
/// Returns true if we sense either clicks or drags. /// Returns true if we sense either clicks or drags.
#[inline] #[inline]
pub fn interactive(&self) -> bool { 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] #[inline]
fn bitor(self, rhs: Self) -> Self { pub fn senses_click(&self) -> bool {
self.union(rhs) self.contains(Self::CLICK)
} }
}
impl std::ops::BitOrAssign for Sense {
#[inline] #[inline]
fn bitor_assign(&mut self, rhs: Self) { pub fn senses_drag(&self) -> bool {
*self = self.union(rhs); 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)] #![allow(clippy::if_same_then_else)]
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use emath::Align; 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::{ use crate::{
ecolor::Color32, 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. /// Specifies the look and feel of egui.
/// ///
/// You can change the visuals of a [`Ui`] with [`Ui::style_mut`] /// 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`]. /// The animation that should be used when scrolling a [`crate::ScrollArea`] using e.g. [`Ui::scroll_to_rect`].
pub scroll_animation: ScrollAnimation, pub scroll_animation: ScrollAnimation,
/// Use a more compact style for menus.
pub compact_menu_style: bool,
} }
#[test] #[test]
@@ -915,7 +960,7 @@ pub struct Visuals {
/// A good color for error text (e.g. red). /// A good color for error text (e.g. red).
pub error_fg_color: Color32, pub error_fg_color: Color32,
pub window_rounding: Rounding, pub window_corner_radius: CornerRadius,
pub window_shadow: Shadow, pub window_shadow: Shadow,
pub window_fill: Color32, pub window_fill: Color32,
pub window_stroke: Stroke, pub window_stroke: Stroke,
@@ -923,7 +968,7 @@ pub struct Visuals {
/// Highlight the topmost window. /// Highlight the topmost window.
pub window_highlight_topmost: bool, pub window_highlight_topmost: bool,
pub menu_rounding: Rounding, pub menu_corner_radius: CornerRadius,
/// Panel background color /// Panel background color
pub panel_fill: Color32, pub panel_fill: Color32,
@@ -944,7 +989,7 @@ pub struct Visuals {
/// Show a background behind collapsing headers. /// Show a background behind collapsing headers.
pub collapsing_header_frame: bool, 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, pub indent_has_left_vline: bool,
/// Whether or not Grids and Tables should be striped by default /// Whether or not Grids and Tables should be striped by default
@@ -1107,7 +1152,7 @@ pub struct WidgetVisuals {
pub bg_stroke: Stroke, pub bg_stroke: Stroke,
/// Button frames etc. /// 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, …). /// Stroke and text color of the interactive part of a component (button text, slider grab, check-mark, …).
pub fg_stroke: Stroke, pub fg_stroke: Stroke,
@@ -1121,6 +1166,11 @@ impl WidgetVisuals {
pub fn text_color(&self) -> Color32 { pub fn text_color(&self) -> Color32 {
self.fg_stroke.color 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 /// Options for help debug egui by adding extra visualization
@@ -1230,6 +1280,7 @@ impl Default for Style {
url_in_tooltip: false, url_in_tooltip: false,
always_scroll_the_only_direction: false, always_scroll_the_only_direction: false,
scroll_animation: ScrollAnimation::default(), scroll_animation: ScrollAnimation::default(),
compact_menu_style: true,
} }
} }
} }
@@ -1291,7 +1342,7 @@ impl Visuals {
warn_fg_color: Color32::from_rgb(255, 143, 0), // orange warn_fg_color: Color32::from_rgb(255, 143, 0), // orange
error_fg_color: Color32::from_rgb(255, 0, 0), // red error_fg_color: Color32::from_rgb(255, 0, 0), // red
window_rounding: Rounding::same(6), window_corner_radius: CornerRadius::same(6),
window_shadow: Shadow { window_shadow: Shadow {
offset: [10, 20], offset: [10, 20],
blur: 15, blur: 15,
@@ -1302,7 +1353,7 @@ impl Visuals {
window_stroke: Stroke::new(1.0, Color32::from_gray(60)), window_stroke: Stroke::new(1.0, Color32::from_gray(60)),
window_highlight_topmost: true, window_highlight_topmost: true,
menu_rounding: Rounding::same(6), menu_corner_radius: CornerRadius::same(6),
panel_fill: Color32::from_gray(27), panel_fill: Color32::from_gray(27),
@@ -1412,7 +1463,7 @@ impl Widgets {
bg_fill: Color32::from_gray(27), bg_fill: Color32::from_gray(27),
bg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // separators, indentation lines 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 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, expansion: 0.0,
}, },
inactive: WidgetVisuals { inactive: WidgetVisuals {
@@ -1420,7 +1471,7 @@ impl Widgets {
bg_fill: Color32::from_gray(60), // checkbox background bg_fill: Color32::from_gray(60), // checkbox background
bg_stroke: Default::default(), bg_stroke: Default::default(),
fg_stroke: Stroke::new(1.0, Color32::from_gray(180)), // button text fg_stroke: Stroke::new(1.0, Color32::from_gray(180)), // button text
rounding: Rounding::same(2), corner_radius: CornerRadius::same(2),
expansion: 0.0, expansion: 0.0,
}, },
hovered: WidgetVisuals { hovered: WidgetVisuals {
@@ -1428,7 +1479,7 @@ impl Widgets {
bg_fill: Color32::from_gray(70), bg_fill: Color32::from_gray(70),
bg_stroke: Stroke::new(1.0, Color32::from_gray(150)), // e.g. hover over window edge or button 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)), fg_stroke: Stroke::new(1.5, Color32::from_gray(240)),
rounding: Rounding::same(3), corner_radius: CornerRadius::same(3),
expansion: 1.0, expansion: 1.0,
}, },
active: WidgetVisuals { active: WidgetVisuals {
@@ -1436,7 +1487,7 @@ impl Widgets {
bg_fill: Color32::from_gray(55), bg_fill: Color32::from_gray(55),
bg_stroke: Stroke::new(1.0, Color32::WHITE), bg_stroke: Stroke::new(1.0, Color32::WHITE),
fg_stroke: Stroke::new(2.0, Color32::WHITE), fg_stroke: Stroke::new(2.0, Color32::WHITE),
rounding: Rounding::same(2), corner_radius: CornerRadius::same(2),
expansion: 1.0, expansion: 1.0,
}, },
open: WidgetVisuals { open: WidgetVisuals {
@@ -1444,7 +1495,7 @@ impl Widgets {
bg_fill: Color32::from_gray(27), bg_fill: Color32::from_gray(27),
bg_stroke: Stroke::new(1.0, Color32::from_gray(60)), bg_stroke: Stroke::new(1.0, Color32::from_gray(60)),
fg_stroke: Stroke::new(1.0, Color32::from_gray(210)), fg_stroke: Stroke::new(1.0, Color32::from_gray(210)),
rounding: Rounding::same(2), corner_radius: CornerRadius::same(2),
expansion: 0.0, expansion: 0.0,
}, },
} }
@@ -1457,7 +1508,7 @@ impl Widgets {
bg_fill: Color32::from_gray(248), bg_fill: Color32::from_gray(248),
bg_stroke: Stroke::new(1.0, Color32::from_gray(190)), // separators, indentation lines 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 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, expansion: 0.0,
}, },
inactive: WidgetVisuals { inactive: WidgetVisuals {
@@ -1465,7 +1516,7 @@ impl Widgets {
bg_fill: Color32::from_gray(230), // checkbox background bg_fill: Color32::from_gray(230), // checkbox background
bg_stroke: Default::default(), bg_stroke: Default::default(),
fg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // button text fg_stroke: Stroke::new(1.0, Color32::from_gray(60)), // button text
rounding: Rounding::same(2), corner_radius: CornerRadius::same(2),
expansion: 0.0, expansion: 0.0,
}, },
hovered: WidgetVisuals { hovered: WidgetVisuals {
@@ -1473,7 +1524,7 @@ impl Widgets {
bg_fill: Color32::from_gray(220), bg_fill: Color32::from_gray(220),
bg_stroke: Stroke::new(1.0, Color32::from_gray(105)), // e.g. hover over window edge or button 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), fg_stroke: Stroke::new(1.5, Color32::BLACK),
rounding: Rounding::same(3), corner_radius: CornerRadius::same(3),
expansion: 1.0, expansion: 1.0,
}, },
active: WidgetVisuals { active: WidgetVisuals {
@@ -1481,7 +1532,7 @@ impl Widgets {
bg_fill: Color32::from_gray(165), bg_fill: Color32::from_gray(165),
bg_stroke: Stroke::new(1.0, Color32::BLACK), bg_stroke: Stroke::new(1.0, Color32::BLACK),
fg_stroke: Stroke::new(2.0, Color32::BLACK), fg_stroke: Stroke::new(2.0, Color32::BLACK),
rounding: Rounding::same(2), corner_radius: CornerRadius::same(2),
expansion: 1.0, expansion: 1.0,
}, },
open: WidgetVisuals { open: WidgetVisuals {
@@ -1489,7 +1540,7 @@ impl Widgets {
bg_fill: Color32::from_gray(220), bg_fill: Color32::from_gray(220),
bg_stroke: Stroke::new(1.0, Color32::from_gray(160)), bg_stroke: Stroke::new(1.0, Color32::from_gray(160)),
fg_stroke: Stroke::new(1.0, Color32::BLACK), fg_stroke: Stroke::new(1.0, Color32::BLACK),
rounding: Rounding::same(2), corner_radius: CornerRadius::same(2),
expansion: 0.0, expansion: 0.0,
}, },
} }
@@ -1531,6 +1582,7 @@ impl Style {
url_in_tooltip, url_in_tooltip,
always_scroll_the_only_direction, always_scroll_the_only_direction,
scroll_animation, scroll_animation,
compact_menu_style,
} = self; } = self;
crate::Grid::new("_options").show(ui, |ui| { crate::Grid::new("_options").show(ui, |ui| {
@@ -1636,6 +1688,8 @@ impl Style {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
ui.collapsing("🐛 Debug", |ui| debug.ui(ui)); ui.collapsing("🐛 Debug", |ui| debug.ui(ui));
ui.checkbox(compact_menu_style, "Compact menu style");
ui.checkbox(explanation_tooltips, "Explanation tooltips") ui.checkbox(explanation_tooltips, "Explanation tooltips")
.on_hover_text( .on_hover_text(
"Show explanatory text when hovering DragValue:s and other egui widgets", "Show explanatory text when hovering DragValue:s and other egui widgets",
@@ -1924,7 +1978,7 @@ impl WidgetVisuals {
weak_bg_fill, weak_bg_fill,
bg_fill: mandatory_bg_fill, bg_fill: mandatory_bg_fill,
bg_stroke, bg_stroke,
rounding, corner_radius,
fg_stroke, fg_stroke,
expansion, expansion,
} = self; } = self;
@@ -1948,8 +2002,8 @@ impl WidgetVisuals {
ui.add(bg_stroke); ui.add(bg_stroke);
ui.end_row(); ui.end_row();
ui.label("Rounding"); ui.label("Corner radius");
ui.add(rounding); ui.add(corner_radius);
ui.end_row(); ui.end_row();
ui.label("Foreground stroke (text)"); ui.label("Foreground stroke (text)");
@@ -1978,13 +2032,13 @@ impl Visuals {
warn_fg_color, warn_fg_color,
error_fg_color, error_fg_color,
window_rounding, window_corner_radius,
window_shadow, window_shadow,
window_fill, window_fill,
window_stroke, window_stroke,
window_highlight_topmost, window_highlight_topmost,
menu_rounding, menu_corner_radius,
panel_fill, panel_fill,
@@ -2066,8 +2120,8 @@ impl Visuals {
ui.add(window_stroke); ui.add(window_stroke);
ui.end_row(); ui.end_row();
ui.label("Rounding"); ui.label("Corner radius");
ui.add(window_rounding); ui.add(window_corner_radius);
ui.end_row(); ui.end_row();
ui.label("Shadow"); ui.label("Shadow");
@@ -2084,8 +2138,8 @@ impl Visuals {
.spacing([12.0, 8.0]) .spacing([12.0, 8.0])
.striped(true) .striped(true)
.show(ui, |ui| { .show(ui, |ui| {
ui.label("Rounding"); ui.label("Corner radius");
ui.add(menu_rounding); ui.add(menu_corner_radius);
ui.end_row(); ui.end_row();
ui.label("Shadow"); 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 { fn ui(self, ui: &mut Ui) -> Response {
let mut same = self.is_same(); let mut same = self.is_same();
@@ -2398,37 +2452,39 @@ impl Widget for &mut Rounding {
let mut cr = self.nw; let mut cr = self.nw;
ui.add(DragValue::new(&mut cr).range(0.0..=f32::INFINITY)); ui.add(DragValue::new(&mut cr).range(0.0..=f32::INFINITY));
*self = Rounding::same(cr); *self = CornerRadius::same(cr);
}) })
.response .response
} else { } else {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.checkbox(&mut same, "same"); ui.checkbox(&mut same, "same");
crate::Grid::new("rounding").num_columns(2).show(ui, |ui| { crate::Grid::new("Corner radius")
ui.label("NW"); .num_columns(2)
ui.add(DragValue::new(&mut self.nw).range(0.0..=f32::INFINITY)); .show(ui, |ui| {
ui.end_row(); ui.label("NW");
ui.add(DragValue::new(&mut self.nw).range(0.0..=f32::INFINITY));
ui.end_row();
ui.label("NE"); ui.label("NE");
ui.add(DragValue::new(&mut self.ne).range(0.0..=f32::INFINITY)); ui.add(DragValue::new(&mut self.ne).range(0.0..=f32::INFINITY));
ui.end_row(); ui.end_row();
ui.label("SW"); ui.label("SW");
ui.add(DragValue::new(&mut self.sw).range(0.0..=f32::INFINITY)); ui.add(DragValue::new(&mut self.sw).range(0.0..=f32::INFINITY));
ui.end_row(); ui.end_row();
ui.label("SE"); ui.label("SE");
ui.add(DragValue::new(&mut self.se).range(0.0..=f32::INFINITY)); ui.add(DragValue::new(&mut self.se).range(0.0..=f32::INFINITY));
ui.end_row(); ui.end_row();
}); });
}) })
.response .response
}; };
// Apply the checkbox: // Apply the checkbox:
if same { if same {
*self = Rounding::from(self.average()); *self = CornerRadius::from(self.average());
} else { } else {
// Make sure we aren't same: // Make sure we aren't same:
if self.is_same() { if self.is_same() {
@@ -2513,7 +2569,7 @@ impl Widget for &mut crate::Frame {
let crate::Frame { let crate::Frame {
inner_margin, inner_margin,
outer_margin, outer_margin,
rounding, corner_radius,
shadow, shadow,
fill, fill,
stroke, stroke,
@@ -2533,8 +2589,8 @@ impl Widget for &mut crate::Frame {
ui.push_id("outer", |ui| ui.add(outer_margin)); ui.push_id("outer", |ui| ui.add(outer_margin));
ui.end_row(); ui.end_row();
ui.label("Rounding"); ui.label("Corner radius");
ui.add(rounding); ui.add(corner_radius);
ui.end_row(); ui.end_row();
ui.label("Shadow"); 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. /// Update accesskit with the current text state.
pub fn update_accesskit_for_text_widget( pub fn update_accesskit_for_text_widget(
ctx: &Context, ctx: &Context,
widget_id: Id, widget_id: Id,
cursor_range: Option<CursorRange>, cursor_range: Option<CCursorRange>,
role: accesskit::Role, role: accesskit::Role,
galley_pos: Pos2, global_from_galley: TSTransform,
galley: &Galley, galley: &Galley,
) { ) {
let parent_id = ctx.accesskit_node_builder(widget_id, |builder| { let parent_id = ctx.accesskit_node_builder(widget_id, |builder| {
let parent_id = widget_id; let parent_id = widget_id;
if let Some(cursor_range) = &cursor_range { if let Some(cursor_range) = &cursor_range {
let anchor = &cursor_range.secondary.rcursor; let anchor = galley.layout_from_cursor(cursor_range.secondary);
let focus = &cursor_range.primary.rcursor; let focus = galley.layout_from_cursor(cursor_range.primary);
builder.set_text_selection(accesskit::TextSelection { builder.set_text_selection(accesskit::TextSelection {
anchor: accesskit::TextPosition { anchor: accesskit::TextPosition {
node: parent_id.with(anchor.row).accesskit_id(), 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); let row_id = parent_id.with(row_index);
ctx.accesskit_node_builder(row_id, |builder| { ctx.accesskit_node_builder(row_id, |builder| {
builder.set_role(accesskit::Role::TextRun); 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 { builder.set_bounds(accesskit::Rect {
x0: rect.min.x.into(), x0: rect.min.x.into(),
y0: rect.min.y.into(), y0: rect.min.y.into(),

View File

@@ -1,41 +1,45 @@
use epaint::{ use epaint::{text::cursor::CCursor, Galley};
text::cursor::{CCursor, Cursor, PCursor},
Galley,
};
use crate::{os::OperatingSystem, Event, Id, Key, Modifiers}; use crate::{os::OperatingSystem, Event, Id, Key, Modifiers};
use super::text_cursor_state::{ccursor_next_word, ccursor_previous_word, slice_char_range}; 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). /// 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)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[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 selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves. /// 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). /// 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. /// 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. /// 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. /// The empty range.
#[inline] #[inline]
pub fn one(cursor: Cursor) -> Self { pub fn one(ccursor: CCursor) -> Self {
Self { Self {
primary: cursor, primary: ccursor,
secondary: cursor, secondary: ccursor,
h_pos: None,
} }
} }
#[inline] #[inline]
pub fn two(min: Cursor, max: Cursor) -> Self { pub fn two(min: impl Into<CCursor>, max: impl Into<CCursor>) -> Self {
Self { Self {
primary: max, primary: max.into(),
secondary: min, secondary: min.into(),
h_pos: None,
} }
} }
@@ -44,39 +48,31 @@ impl CursorRange {
Self::two(galley.begin(), galley.end()) 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. /// The range of selected character indices.
pub fn as_sorted_char_range(&self) -> std::ops::Range<usize> { pub fn as_sorted_char_range(&self) -> std::ops::Range<usize> {
let [start, end] = self.sorted_cursors(); let [start, end] = self.sorted_cursors();
std::ops::Range { std::ops::Range {
start: start.ccursor.index, start: start.index,
end: end.ccursor.index, end: end.index,
} }
} }
/// True if the selected range contains no characters. /// True if the selected range contains no characters.
#[inline] #[inline]
pub fn is_empty(&self) -> bool { 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? /// 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 [self_min, self_max] = self.sorted_cursors();
let [other_min, other_max] = other.sorted_cursors(); let [other_min, other_max] = other.sorted_cursors();
self_min.ccursor.index <= other_min.ccursor.index self_min.index <= other_min.index && other_max.index <= self_max.index
&& other_max.ccursor.index <= self_max.ccursor.index
} }
/// If there is a selection, None is returned. /// If there is a selection, None is returned.
/// If the two ends are the same, that 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() { if self.is_empty() {
Some(self.primary) Some(self.primary)
} else { } else {
@@ -84,25 +80,16 @@ impl CursorRange {
} }
} }
#[inline]
pub fn is_sorted(&self) -> bool { pub fn is_sorted(&self) -> bool {
let p = self.primary.ccursor; let p = self.primary;
let s = self.secondary.ccursor; let s = self.secondary;
(p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row) (p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row)
} }
pub fn sorted(self) -> Self { /// returns the two ends ordered
if self.is_sorted() { #[inline]
self pub fn sorted_cursors(&self) -> [CCursor; 2] {
} else {
Self {
primary: self.secondary,
secondary: self.primary,
}
}
}
/// Returns the two ends ordered.
pub fn sorted_cursors(&self) -> [Cursor; 2] {
if self.is_sorted() { if self.is_sorted() {
[self.primary, self.secondary] [self.primary, self.secondary]
} else { } 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 { pub fn slice_str<'s>(&self, text: &'s str) -> &'s str {
let [min, max] = self.sorted_cursors(); 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. /// Check for key presses that are moving the cursor.
@@ -146,7 +139,14 @@ impl CursorRange {
| Key::ArrowDown | Key::ArrowDown
| Key::Home | Key::Home
| Key::End => { | 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 { if !modifiers.shift {
self.secondary = self.primary; self.secondary = self.primary;
} }
@@ -156,7 +156,14 @@ impl CursorRange {
Key::P | Key::N | Key::B | Key::F | Key::A | Key::E Key::P | Key::N | Key::B | Key::F | Key::A | Key::E
if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift => 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; self.secondary = self.primary;
true true
} }
@@ -196,8 +203,9 @@ impl CursorRange {
ccursor_from_accesskit_text_position(_widget_id, galley, &selection.anchor); ccursor_from_accesskit_text_position(_widget_id, galley, &selection.anchor);
if let (Some(primary), Some(secondary)) = (primary, secondary) { if let (Some(primary), Some(secondary)) = (primary, secondary) {
*self = Self { *self = Self {
primary: galley.from_ccursor(primary), primary,
secondary: galley.from_ccursor(secondary), secondary,
h_pos: None,
}; };
return true; 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")] #[cfg(feature = "accesskit")]
@@ -304,78 +247,83 @@ fn ccursor_from_accesskit_text_position(
/// Move a text cursor based on keyboard /// Move a text cursor based on keyboard
fn move_single_cursor( fn move_single_cursor(
os: OperatingSystem, os: OperatingSystem,
cursor: &mut Cursor, cursor: &mut CCursor,
h_pos: &mut Option<f32>,
galley: &Galley, galley: &Galley,
key: Key, key: Key,
modifiers: &Modifiers, modifiers: &Modifiers,
) { ) {
if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift { let (new_cursor, new_h_pos) =
match key { if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift {
Key::A => *cursor = galley.cursor_begin_of_row(cursor), match key {
Key::E => *cursor = galley.cursor_end_of_row(cursor), Key::A => (galley.cursor_begin_of_row(cursor), None),
Key::P => *cursor = galley.cursor_up_one_row(cursor), Key::E => (galley.cursor_end_of_row(cursor), None),
Key::N => *cursor = galley.cursor_down_one_row(cursor), Key::P => galley.cursor_up_one_row(cursor, *h_pos),
Key::B => *cursor = galley.cursor_left_one_character(cursor), Key::N => galley.cursor_down_one_row(cursor, *h_pos),
Key::F => *cursor = galley.cursor_right_one_character(cursor), Key::B => (galley.cursor_left_one_character(cursor), None),
_ => (), Key::F => (galley.cursor_right_one_character(cursor), None),
} _ => return,
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);
} }
} } else {
Key::ArrowRight => { match key {
if modifiers.alt || modifiers.ctrl { Key::ArrowLeft => {
// alt on mac, ctrl on windows if modifiers.alt || modifiers.ctrl {
*cursor = galley.from_ccursor(ccursor_next_word(galley, cursor.ccursor)); // alt on mac, ctrl on windows
} else if modifiers.mac_cmd { (ccursor_previous_word(galley, *cursor), None)
*cursor = galley.cursor_end_of_row(cursor); } else if modifiers.mac_cmd {
} else { (galley.cursor_begin_of_row(cursor), None)
*cursor = galley.cursor_right_one_character(cursor); } else {
} (galley.cursor_left_one_character(cursor), None)
} }
Key::ArrowUp => { }
if modifiers.command { Key::ArrowRight => {
// mac and windows behavior if modifiers.alt || modifiers.ctrl {
*cursor = galley.begin(); // alt on mac, ctrl on windows
} else { (ccursor_next_word(galley, *cursor), None)
*cursor = galley.cursor_up_one_row(cursor); } else if modifiers.mac_cmd {
} (galley.cursor_end_of_row(cursor), None)
} } else {
Key::ArrowDown => { (galley.cursor_right_one_character(cursor), None)
if modifiers.command { }
// mac and windows behavior }
*cursor = galley.end(); Key::ArrowUp => {
} else { if modifiers.command {
*cursor = galley.cursor_down_one_row(cursor); // 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 => { Key::Home => {
if modifiers.ctrl { if modifiers.ctrl {
// windows behavior // windows behavior
*cursor = galley.begin(); (galley.begin(), None)
} else { } else {
*cursor = galley.cursor_begin_of_row(cursor); (galley.cursor_begin_of_row(cursor), None)
} }
} }
Key::End => { Key::End => {
if modifiers.ctrl { if modifiers.ctrl {
// windows behavior // windows behavior
*cursor = galley.end(); (galley.end(), None)
} else { } else {
*cursor = galley.cursor_end_of_row(cursor); (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 std::sync::Arc;
use emath::TSTransform;
use crate::{ use crate::{
layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event, layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event,
Galley, Id, LayerId, Pos2, Rect, Response, Ui, Galley, Id, LayerId, Pos2, Rect, Response, Ui,
@@ -8,7 +10,7 @@ use crate::{
use super::{ use super::{
text_cursor_state::cursor_rect, text_cursor_state::cursor_rect,
visuals::{paint_text_selection, RowVertexIndices}, visuals::{paint_text_selection, RowVertexIndices},
CursorRange, TextCursorState, TextCursorState,
}; };
/// Turn on to help debug this /// Turn on to help debug this
@@ -25,9 +27,14 @@ struct WidgetTextCursor {
} }
impl 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 ccursor = cursor.into();
let pos = pos_in_galley(galley_pos, galley, ccursor); let pos = global_from_galley * pos_in_galley(galley, ccursor);
Self { Self {
widget_id, widget_id,
ccursor, ccursor,
@@ -36,8 +43,8 @@ impl WidgetTextCursor {
} }
} }
fn pos_in_galley(galley_pos: Pos2, galley: &Galley, ccursor: CCursor) -> Pos2 { fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 {
galley_pos + galley.pos_from_ccursor(ccursor).center().to_vec2() galley.pos_from_cursor(ccursor).center()
} }
impl std::fmt::Debug for WidgetTextCursor { impl std::fmt::Debug for WidgetTextCursor {
@@ -231,8 +238,7 @@ impl LabelSelectionState {
self.selection = None; self.selection = None;
} }
fn copy_text(&mut self, galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange) { fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CCursorRange) {
let new_galley_rect = Rect::from_min_size(galley_pos, galley.size());
let new_text = selected_text(galley, cursor_range); let new_text = selected_text(galley, cursor_range);
if new_text.is_empty() { if new_text.is_empty() {
return; return;
@@ -266,7 +272,7 @@ impl LabelSelectionState {
let new_text_starts_with_space_or_punctuation = new_text let new_text_starts_with_space_or_punctuation = new_text
.chars() .chars()
.next() .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 if existing_ends_with_space == Some(false) && !new_text_starts_with_space_or_punctuation
{ {
@@ -311,7 +317,7 @@ impl LabelSelectionState {
&mut self, &mut self,
ui: &Ui, ui: &Ui,
response: &Response, response: &Response,
galley_pos: Pos2, global_from_galley: TSTransform,
galley: &Galley, galley: &Galley,
) -> TextCursorState { ) -> TextCursorState {
let Some(selection) = &mut self.selection else { let Some(selection) = &mut self.selection else {
@@ -324,6 +330,8 @@ impl LabelSelectionState {
return TextCursorState::default(); return TextCursorState::default();
} }
let galley_from_global = global_from_galley.inverse();
let multi_widget_text_select = ui.style().interaction.multi_widget_text_select; let multi_widget_text_select = ui.style().interaction.multi_widget_text_select;
let may_select_widget = let may_select_widget =
@@ -331,7 +339,8 @@ impl LabelSelectionState {
if self.is_dragging && may_select_widget { if self.is_dragging && may_select_widget {
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { 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 galley_rect = galley_rect.intersect(ui.clip_rect());
let is_in_same_column = galley_rect let is_in_same_column = galley_rect
@@ -345,7 +354,7 @@ impl LabelSelectionState {
let new_primary = if response.contains_pointer() { let new_primary = if response.contains_pointer() {
// Dragging into this widget - easy case: // 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 } else if is_in_same_column
&& !self.has_reached_primary && !self.has_reached_primary
&& selection.primary.pos.y <= selection.secondary.pos.y && selection.primary.pos.y <= selection.secondary.pos.y
@@ -379,7 +388,7 @@ impl LabelSelectionState {
if let Some(new_primary) = new_primary { if let Some(new_primary) = new_primary {
selection.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`. // We don't want the latency of `drag_started`.
let drag_started = ui.input(|i| i.pointer.any_pressed()); 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; let has_secondary = response.id == selection.secondary.widget_id;
if has_primary { 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 { if has_secondary {
selection.secondary.pos = 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; self.has_reached_primary |= has_primary;
@@ -426,7 +436,11 @@ impl LabelSelectionState {
match (primary, secondary) { match (primary, secondary) {
(Some(primary), Some(secondary)) => { (Some(primary), Some(secondary)) => {
// This is the only selected label. // This is the only selected label.
TextCursorState::from(CCursorRange { primary, secondary }) TextCursorState::from(CCursorRange {
primary,
secondary,
h_pos: None,
})
} }
(Some(primary), None) => { (Some(primary), None) => {
@@ -435,12 +449,16 @@ impl LabelSelectionState {
// Secondary was before primary. // Secondary was before primary.
// Select everything up to the cursor. // Select everything up to the cursor.
// We assume normal left-to-right and top-down layout order here. // We assume normal left-to-right and top-down layout order here.
galley.begin().ccursor galley.begin()
} else { } else {
// Select everything from the cursor onward: // 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)) => { (None, Some(secondary)) => {
@@ -449,12 +467,16 @@ impl LabelSelectionState {
// Primary was before secondary. // Primary was before secondary.
// Select everything up to the cursor. // Select everything up to the cursor.
// We assume normal left-to-right and top-down layout order here. // We assume normal left-to-right and top-down layout order here.
galley.begin().ccursor galley.begin()
} else { } else {
// Select everything from the cursor onward: // 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) => { (None, None) => {
@@ -482,12 +504,22 @@ impl LabelSelectionState {
&mut self, &mut self,
ui: &Ui, ui: &Ui,
response: &Response, response: &Response,
galley_pos: Pos2, galley_pos_in_layer: Pos2,
galley: &mut Arc<Galley>, galley: &mut Arc<Galley>,
) -> Vec<RowVertexIndices> { ) -> Vec<RowVertexIndices> {
let widget_id = response.id; 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); ui.ctx().set_cursor_icon(CursorIcon::Text);
} }
@@ -496,13 +528,14 @@ impl LabelSelectionState {
let old_selection = self.selection; 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 let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
if response.contains_pointer() { 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. // This is where we handle start-of-drag and double-click-to-select.
// Actual drag-to-select happens elsewhere. // Actual drag-to-select happens elsewhere.
@@ -511,8 +544,8 @@ impl LabelSelectionState {
} }
} }
if let Some(mut cursor_range) = cursor_state.range(galley) { if let Some(mut cursor_range) = cursor_state.char_range() {
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());
self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect); self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect);
if let Some(selection) = &self.selection { if let Some(selection) = &self.selection {
@@ -522,14 +555,14 @@ impl LabelSelectionState {
} }
if got_copy_event(ui.ctx()) { 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: // 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; let selection_changed = old_range != new_range;
if let (true, Some(range)) = (selection_changed, 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 { if primary_changed || !ui.style().interaction.multi_widget_text_select {
selection.primary = 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; self.has_reached_primary = true;
} }
if secondary_changed || !ui.style().interaction.multi_widget_text_select { if secondary_changed || !ui.style().interaction.multi_widget_text_select {
selection.secondary = selection.secondary = WidgetTextCursor::new(
WidgetTextCursor::new(widget_id, range.secondary, galley_pos, galley); widget_id,
range.secondary,
global_from_galley,
galley,
);
self.has_reached_secondary = true; self.has_reached_secondary = true;
} }
} else { } else {
// Start of a new selection // Start of a new selection
self.selection = Some(CurrentSelection { self.selection = Some(CurrentSelection {
layer_id: response.layer_id, 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( secondary: WidgetTextCursor::new(
widget_id, widget_id,
range.secondary, range.secondary,
galley_pos, global_from_galley,
galley, galley,
), ),
}); });
@@ -583,14 +625,14 @@ impl LabelSelectionState {
// Scroll to keep primary cursor in view: // Scroll to keep primary cursor in view:
let row_height = estimate_row_height(galley); let row_height = estimate_row_height(galley);
let primary_cursor_rect = 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); 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![]; let mut new_vertex_indices = vec![];
@@ -609,7 +651,7 @@ impl LabelSelectionState {
response.id, response.id,
cursor_range, cursor_range,
accesskit::Role::Label, accesskit::Role::Label,
galley_pos, global_from_galley,
galley, galley,
); );
@@ -630,7 +672,7 @@ fn process_selection_key_events(
ctx: &Context, ctx: &Context,
galley: &Galley, galley: &Galley,
widget_id: Id, widget_id: Id,
cursor_range: &mut CursorRange, cursor_range: &mut CCursorRange,
) -> bool { ) -> bool {
let os = ctx.os(); let os = ctx.os();
@@ -647,10 +689,10 @@ fn process_selection_key_events(
changed 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 `…`) // This logic means we can select everything in an elided label (including the `…`)
// and still copy the entire un-elided text! // 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; 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 text_cursor_state;
pub mod visuals; pub mod visuals;
pub use cursor_range::{CCursorRange, CursorRange, PCursorRange}; pub use cursor_range::CCursorRange;
pub use label_text_selection::LabelSelectionState; pub use label_text_selection::LabelSelectionState;
pub use text_cursor_state::TextCursorState; pub use text_cursor_state::TextCursorState;

View File

@@ -1,13 +1,10 @@
//! Text cursor changes/interaction, without modifying the text. //! Text cursor changes/interaction, without modifying the text.
use epaint::text::{ use epaint::text::{cursor::CCursor, Galley};
cursor::{CCursor, Cursor},
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. /// 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", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub struct TextCursorState { 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>, 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 { impl From<CCursorRange> for TextCursorState {
fn from(ccursor_range: CCursorRange) -> Self { fn from(ccursor_range: CCursorRange) -> Self {
Self { Self {
cursor_range: None,
ccursor_range: Some(ccursor_range), ccursor_range: Some(ccursor_range),
} }
} }
@@ -46,50 +26,18 @@ impl From<CCursorRange> for TextCursorState {
impl TextCursorState { impl TextCursorState {
pub fn is_empty(&self) -> bool { 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. /// The currently selected range of characters.
pub fn char_range(&self) -> Option<CCursorRange> { pub fn char_range(&self) -> Option<CCursorRange> {
self.ccursor_range.or_else(|| { self.ccursor_range
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),
})
})
} }
/// Sets the currently selected range of characters. /// Sets the currently selected range of characters.
pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) { pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) {
self.cursor_range = None;
self.ccursor_range = ccursor_range; 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 { impl TextCursorState {
@@ -100,7 +48,7 @@ impl TextCursorState {
&mut self, &mut self,
ui: &Ui, ui: &Ui,
response: &Response, response: &Response,
cursor_at_pointer: Cursor, cursor_at_pointer: CCursor,
galley: &Galley, galley: &Galley,
is_being_dragged: bool, is_being_dragged: bool,
) -> bool { ) -> bool {
@@ -108,39 +56,33 @@ impl TextCursorState {
if response.double_clicked() { if response.double_clicked() {
// Select word: // Select word:
let ccursor_range = select_word_at(text, cursor_at_pointer.ccursor); let ccursor_range = select_word_at(text, cursor_at_pointer);
self.set_range(Some(CursorRange { self.set_char_range(Some(ccursor_range));
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
}));
true true
} else if response.triple_clicked() { } else if response.triple_clicked() {
// Select line: // Select line:
let ccursor_range = select_line_at(text, cursor_at_pointer.ccursor); let ccursor_range = select_line_at(text, cursor_at_pointer);
self.set_range(Some(CursorRange { self.set_char_range(Some(ccursor_range));
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
}));
true true
} else if response.sense.drag { } else if response.sense.senses_drag() {
if response.hovered() && ui.input(|i| i.pointer.any_pressed()) { if response.hovered() && ui.input(|i| i.pointer.any_pressed()) {
// The start of a drag (or a click). // The start of a drag (or a click).
if ui.input(|i| i.modifiers.shift) { 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; cursor_range.primary = cursor_at_pointer;
self.set_range(Some(cursor_range)); self.set_char_range(Some(cursor_range));
} else { } else {
self.set_range(Some(CursorRange::one(cursor_at_pointer))); self.set_char_range(Some(CCursorRange::one(cursor_at_pointer)));
} }
} else { } else {
self.set_range(Some(CursorRange::one(cursor_at_pointer))); self.set_char_range(Some(CCursorRange::one(cursor_at_pointer)));
} }
true true
} else if is_being_dragged { } else if is_being_dragged {
// Drag to select text: // 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; cursor_range.primary = cursor_at_pointer;
self.set_range(Some(cursor_range)); self.set_char_range(Some(cursor_range));
} }
true true
} else { } 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 { 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 start_byte = byte_index_from_char_index(s, char_range.start);
let end_byte = byte_index_from_char_index(s, char_range.end); let end_byte = byte_index_from_char_index(s, char_range.end);
&s[start_byte..end_byte] &s[start_byte..end_byte]
} }
/// The thin rectangle of one end of the selection, e.g. the primary cursor. /// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates.
pub fn cursor_rect(galley_pos: Pos2, galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect { pub fn cursor_rect(galley: &Galley, cursor: &CCursor, row_height: f32) -> Rect {
let mut cursor_pos = galley let mut cursor_pos = galley.pos_from_cursor(*cursor);
.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);
// Handle completely empty galleys // Handle completely empty galleys
cursor_pos = cursor_pos.expand(1.5); cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
// slightly above/below row
cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
cursor_pos cursor_pos
} }

View File

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

View File

@@ -1,11 +1,14 @@
#![warn(missing_docs)] // Let's keep `Ui` well-documented. #![warn(missing_docs)] // Let's keep `Ui` well-documented.
#![allow(clippy::use_self)] #![allow(clippy::use_self)]
use std::{any::Any, hash::Hash, sync::Arc};
use emath::GuiRounding as _; use emath::GuiRounding as _;
use epaint::mutex::RwLock; 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::{ use crate::{
containers::{CollapsingHeader, CollapsingResponse, Frame}, containers::{CollapsingHeader, CollapsingResponse, Frame},
ecolor::Hsva, ecolor::Hsva,
@@ -13,8 +16,6 @@ use crate::{
epaint::text::Fonts, epaint::text::Fonts,
grid, grid,
layout::{Direction, Layout}, layout::{Direction, Layout},
menu,
menu::MenuState,
pass_state, pass_state,
placer::Placer, placer::Placer,
pos2, style, pos2, style,
@@ -26,11 +27,9 @@ use crate::{
}, },
Align, Color32, Context, CursorIcon, DragAndDrop, Id, InnerResponse, InputState, LayerId, Align, Color32, Context, CursorIcon, DragAndDrop, Id, InnerResponse, InputState, LayerId,
Memory, Order, Painter, PlatformOutput, Pos2, Rangef, Rect, Response, Rgba, RichText, Sense, 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. /// This is what you use to place widgets.
@@ -99,7 +98,8 @@ pub struct Ui {
sizing_pass: bool, sizing_pass: bool,
/// Indicates whether this Ui belongs to a Menu. /// 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`]. /// The [`UiStack`] for this [`Ui`].
stack: Arc<UiStack>, 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 stable_id = self.id.with(id_salt);
let unique_id = stable_id.with(self.next_auto_id_salt); let unique_id = stable_id.with(self.next_auto_id_salt);
let next_auto_id_salt = unique_id.value().wrapping_add(1); let next_auto_id_salt = unique_id.value().wrapping_add(1);
@@ -914,14 +914,20 @@ impl Ui {
/// Set the minimum width of the ui. /// Set the minimum width of the ui.
/// This can't shrink the ui, only make it larger. /// This can't shrink the ui, only make it larger.
pub fn set_min_width(&mut self, width: f32) { 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); self.placer.set_min_width(width);
} }
/// Set the minimum height of the ui. /// Set the minimum height of the ui.
/// This can't shrink the ui, only make it larger. /// This can't shrink the ui, only make it larger.
pub fn set_min_height(&mut self, height: f32) { 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); self.placer.set_min_height(height);
} }
@@ -1095,7 +1101,8 @@ impl Ui {
// This is the inverse of Context::read_response. We prefer a response // 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 // 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. // Ui::interact_bg is called or the Ui is dropped.
self.ctx() let mut response = self
.ctx()
.viewport(|viewport| { .viewport(|viewport| {
viewport viewport
.prev_pass .prev_pass
@@ -1107,7 +1114,11 @@ impl Ui {
.map(|widget_rect| self.ctx().get_response(widget_rect)) .map(|widget_rect| self.ctx().get_response(widget_rect))
.expect( .expect(
"Since we always call Context::create_widget in Ui::new, this should never be None", "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 /// 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); fs.used_ids.remove(&self.unique_id);
}); });
// This will update the WidgetRect that was first created in `Ui::new`. // This will update the WidgetRect that was first created in `Ui::new`.
self.ctx().create_widget( let mut response = self.ctx().create_widget(
WidgetRect { WidgetRect {
id: self.unique_id, id: self.unique_id,
layer_id: self.layer_id(), layer_id: self.layer_id(),
@@ -1131,7 +1142,11 @@ impl Ui {
enabled: self.enabled, enabled: self.enabled,
}, },
false, false,
) );
if self.should_close() {
response.set_close();
}
response
} }
/// Interact with the background of this [`Ui`], /// Interact with the background of this [`Ui`],
@@ -1165,6 +1180,104 @@ impl Ui {
pub fn ui_contains_pointer(&self) -> bool { pub fn ui_contains_pointer(&self) -> bool {
self.rect_contains_pointer(self.min_rect()) 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? /// # Allocating space: where do I put my widgets?
@@ -1186,7 +1299,7 @@ impl Ui {
/// # egui::__run_test_ui(|ui| { /// # egui::__run_test_ui(|ui| {
/// let response = ui.allocate_response(egui::vec2(100.0, 200.0), egui::Sense::click()); /// let response = ui.allocate_response(egui::vec2(100.0, 200.0), egui::Sense::click());
/// if response.clicked() { /* … */ } /// 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 { 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; let debug_expand_height = self.style().debug.show_expand_height;
if (debug_expand_width && too_wide) || (debug_expand_height && too_high) { if (debug_expand_width && too_wide) || (debug_expand_height && too_high) {
self.painter self.painter.rect_stroke(
.rect_stroke(rect, 0.0, (1.0, Color32::LIGHT_BLUE)); rect,
0.0,
(1.0, Color32::LIGHT_BLUE),
crate::StrokeKind::Inside,
);
let stroke = Stroke::new(2.5, Color32::from_rgb(200, 0, 0)); 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); 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 { fn allocate_space_impl(&mut self, desired_size: Vec2) -> Rect {
let item_spacing = self.spacing().item_spacing; let item_spacing = self.spacing().item_spacing;
let frame_rect = self.placer.next_space(desired_size, 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); let widget_rect = self.placer.justify_and_align(frame_rect, desired_size);
self.placer self.placer
@@ -1311,7 +1428,7 @@ impl Ui {
/// Allocate a rect without interacting with it. /// Allocate a rect without interacting with it.
pub fn advance_cursor_after_rect(&mut self, rect: Rect) -> Id { 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 rect = rect.round_ui();
let item_spacing = self.spacing().item_spacing; let item_spacing = self.spacing().item_spacing;
@@ -1383,11 +1500,14 @@ impl Ui {
layout: Layout, layout: Layout,
add_contents: Box<dyn FnOnce(&mut Self) -> R + 'c>, add_contents: Box<dyn FnOnce(&mut Self) -> R + 'c>,
) -> InnerResponse<R> { ) -> 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 item_spacing = self.spacing().item_spacing;
let frame_rect = self.placer.next_space(desired_size, 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); 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), UiBuilder::new().max_rect(child_rect).layout(layout),
add_contents, add_contents,
) )
@@ -1404,7 +1524,7 @@ impl Ui {
max_rect: Rect, max_rect: Rect,
add_contents: impl FnOnce(&mut Self) -> R, add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<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. /// 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. /// 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. /// 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. /// So you can request a lot of space and then use less.
#[deprecated = "Use `scope_builder` instead"]
pub fn allocate_new_ui<R>( pub fn allocate_new_ui<R>(
&mut self, &mut self,
ui_builder: UiBuilder, ui_builder: UiBuilder,
add_contents: impl FnOnce(&mut Self) -> R, add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<R> { ) -> InnerResponse<R> {
self.allocate_new_ui_dyn(ui_builder, Box::new(add_contents)) self.scope_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)
} }
/// Convenience function to get a region to paint on. /// Convenience function to get a region to paint on.
@@ -1638,7 +1744,7 @@ impl Ui {
/// ///
/// See also [`Self::add`] and [`Self::add_sized`]. /// See also [`Self::add`] and [`Self::add_sized`].
pub fn put(&mut self, max_rect: Rect, widget: impl Widget) -> Response { pub fn put(&mut self, max_rect: Rect, widget: impl Widget) -> Response {
self.allocate_new_ui( self.scope_builder(
UiBuilder::new() UiBuilder::new()
.max_rect(max_rect) .max_rect(max_rect)
.layout(Layout::centered_and_justified(Direction::TopDown)), .layout(Layout::centered_and_justified(Direction::TopDown)),
@@ -1771,12 +1877,14 @@ impl Ui {
/// Add extra space before the next widget. /// Add extra space before the next widget.
/// ///
/// The direction is dependent on the layout. /// 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. /// [`Self::min_rect`] will expand to contain the space.
#[inline] #[inline]
pub fn add_space(&mut self, amount: f32) { pub fn add_space(&mut self, amount: f32) {
self.placer.advance_cursor(amount); self.placer.advance_cursor(amount.round_ui());
} }
/// Show some text. /// Show some text.
@@ -2077,7 +2185,7 @@ impl Ui {
// only touch `*radians` if we actually changed the degree value // only touch `*radians` if we actually changed the degree value
if degrees != radians.to_degrees() { if degrees != radians.to_degrees() {
*radians = degrees.to_radians(); *radians = degrees.to_radians();
response.changed = true; response.mark_changed();
} }
response response
@@ -2100,7 +2208,7 @@ impl Ui {
// only touch `*radians` if we actually changed the value // only touch `*radians` if we actually changed the value
if taus != *radians / TAU { if taus != *radians / TAU {
*radians = taus * TAU; *radians = taus * TAU;
response.changed = true; response.mark_changed();
} }
response response
@@ -2121,7 +2229,7 @@ impl Ui {
/// ui.add( /// ui.add(
/// egui::Image::new(egui::include_image!("../assets/ferris.png")) /// egui::Image::new(egui::include_image!("../assets/ferris.png"))
/// .max_width(200.0) /// .max_width(200.0)
/// .rounding(10.0), /// .corner_radius(10),
/// ); /// );
/// # }); /// # });
/// ``` /// ```
@@ -2141,18 +2249,21 @@ impl Ui {
/// # Colors /// # Colors
impl Ui { impl Ui {
/// Shows a button with the given color. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
pub fn color_edit_button_srgba(&mut self, srgba: &mut Color32) -> Response { pub fn color_edit_button_srgba(&mut self, srgba: &mut Color32) -> Response {
color_picker::color_edit_button_srgba(self, srgba, color_picker::Alpha::BlendOrAdditive) color_picker::color_edit_button_srgba(self, srgba, color_picker::Alpha::BlendOrAdditive)
} }
/// Shows a button with the given color. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
pub fn color_edit_button_hsva(&mut self, hsva: &mut Hsva) -> Response { pub fn color_edit_button_hsva(&mut self, hsva: &mut Hsva) -> Response {
color_picker::color_edit_button_hsva(self, hsva, color_picker::Alpha::BlendOrAdditive) color_picker::color_edit_button_hsva(self, hsva, color_picker::Alpha::BlendOrAdditive)
} }
/// Shows a button with the given color. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
/// The given color is in `sRGB` space. /// The given color is in `sRGB` space.
pub fn color_edit_button_srgb(&mut self, srgb: &mut [u8; 3]) -> Response { 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. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
/// The given color is in linear RGB space. /// The given color is in linear RGB space.
pub fn color_edit_button_rgb(&mut self, rgb: &mut [f32; 3]) -> Response { 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. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
/// The given color is in `sRGBA` space with premultiplied alpha /// The given color is in `sRGBA` space with premultiplied alpha
pub fn color_edit_button_srgba_premultiplied(&mut self, srgba: &mut [u8; 4]) -> Response { 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. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
/// The given color is in `sRGBA` space without premultiplied alpha. /// 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. /// 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. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
/// The given color is in linear RGBA space with premultiplied alpha /// 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 { 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. /// Shows a button with the given color.
///
/// If the user clicks the button, a full color picker is shown. /// If the user clicks the button, a full color picker is shown.
/// The given color is in linear RGBA space without premultiplied alpha. /// 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. /// 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. /// See also [`Self::with_layout`] for more options.
#[inline] #[inline]
pub fn vertical<R>(&mut self, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> { 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)), UiBuilder::new().layout(Layout::top_down(Align::Min)),
add_contents, add_contents,
) )
@@ -2549,7 +2665,7 @@ impl Ui {
&mut self, &mut self,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> { ) -> InnerResponse<R> {
self.allocate_new_ui( self.scope_builder(
UiBuilder::new().layout(Layout::top_down(Align::Center)), UiBuilder::new().layout(Layout::top_down(Align::Center)),
add_contents, add_contents,
) )
@@ -2570,7 +2686,7 @@ impl Ui {
&mut self, &mut self,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> { ) -> InnerResponse<R> {
self.allocate_new_ui( self.scope_builder(
UiBuilder::new().layout(Layout::top_down(Align::Center).with_cross_justify(true)), UiBuilder::new().layout(Layout::top_down(Align::Center).with_cross_justify(true)),
add_contents, add_contents,
) )
@@ -2596,7 +2712,7 @@ impl Ui {
layout: Layout, layout: Layout,
add_contents: impl FnOnce(&mut Self) -> R, add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<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. /// This will make the next added widget centered and justified in the available space.
@@ -2606,7 +2722,7 @@ impl Ui {
&mut self, &mut self,
add_contents: impl FnOnce(&mut Self) -> R, add_contents: impl FnOnce(&mut Self) -> R,
) -> InnerResponse<R> { ) -> InnerResponse<R> {
self.allocate_new_ui( self.scope_builder(
UiBuilder::new().layout(Layout::centered_and_justified(Direction::TopDown)), UiBuilder::new().layout(Layout::centered_and_justified(Direction::TopDown)),
add_contents, add_contents,
) )
@@ -2894,14 +3010,16 @@ impl Ui {
/// Close the menu we are in (including submenus), if any. /// Close the menu we are in (including submenus), if any.
/// ///
/// See also: [`Self::menu_button`] and [`Response::context_menu`]. /// See also: [`Self::menu_button`] and [`Response::context_menu`].
pub fn close_menu(&mut self) { #[deprecated = "Use `ui.close()` or `ui.close_kind(UiKind::Menu)` instead"]
if let Some(menu_state) = &mut self.menu_state { pub fn close_menu(&self) {
menu_state.write().close(); self.close_kind(UiKind::Menu);
}
self.menu_state = None;
} }
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; self.menu_state = menu_state;
} }
@@ -2915,24 +3033,25 @@ impl Ui {
/// ui.menu_button("My menu", |ui| { /// ui.menu_button("My menu", |ui| {
/// ui.menu_button("My sub-menu", |ui| { /// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() { /// 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>( pub fn menu_button<R>(
&mut self, &mut self,
title: impl Into<WidgetText>, title: impl Into<WidgetText>,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> { ) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() { let (response, inner) = if menu::is_in_menu(self) {
menu::submenu_button(self, menu_state, title, add_contents) menu::SubMenuButton::new(title).ui(self, add_contents)
} else { } 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. /// 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_image_button(title, img, |ui| {
/// ui.menu_button("My sub-menu", |ui| { /// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() { /// 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] #[inline]
pub fn menu_image_button<'a, R>( pub fn menu_image_button<'a, R>(
&mut self, &mut self,
image: impl Into<Image<'a>>, image: impl Into<Image<'a>>,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> { ) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() { let (response, inner) = if menu::is_in_menu(self) {
menu::submenu_button(self, menu_state, String::new(), add_contents) menu::SubMenuButton::from_button(
Button::image(image).right_text(menu::SubMenuButton::RIGHT_ARROW),
)
.ui(self, add_contents)
} else { } 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. /// 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_image_text_button(img, title, |ui| {
/// ui.menu_button("My sub-menu", |ui| { /// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() { /// 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] #[inline]
pub fn menu_image_text_button<'a, R>( pub fn menu_image_text_button<'a, R>(
&mut self, &mut self,
@@ -2995,11 +3118,16 @@ impl Ui {
title: impl Into<WidgetText>, title: impl Into<WidgetText>,
add_contents: impl FnOnce(&mut Ui) -> R, add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<Option<R>> { ) -> InnerResponse<Option<R>> {
if let Some(menu_state) = self.menu_state.clone() { let (response, inner) = if menu::is_in_menu(self) {
menu::submenu_button(self, menu_state, title, add_contents) menu::SubMenuButton::from_button(
Button::image_and_text(image, title).right_text(menu::SubMenuButton::RIGHT_ARROW),
)
.ui(self, add_contents)
} else { } 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 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 #[allow(unused_imports)] // Used for doclinks
use crate::Ui; use crate::Ui;
use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo};
/// Build a [`Ui`] as the child of another [`Ui`]. /// 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`]. /// 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 /// 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 /// if the user clicks a button contained within this [`Ui`], that button will receive the click
/// instead. /// instead.
@@ -135,4 +136,19 @@ impl UiBuilder {
self.sense = Some(sense); self.sense = Some(sense);
self 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. /// An [`crate::Area`] that is not of any other kind.
GenericArea, GenericArea,
/// A collapsible container, e.g. a [`crate::CollapsingHeader`].
Collapsible,
} }
impl UiKind { impl UiKind {
@@ -81,6 +84,7 @@ impl UiKind {
| Self::Frame | Self::Frame
| Self::ScrollArea | Self::ScrollArea
| Self::Resize | Self::Resize
| Self::Collapsible
| Self::TableCell => false, | Self::TableCell => false,
Self::Window Self::Window
@@ -229,13 +233,13 @@ impl UiStack {
/// Is this [`crate::Ui`] a panel? /// Is this [`crate::Ui`] a panel?
#[inline] #[inline]
pub fn is_panel_ui(&self) -> bool { 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`]? /// Is this [`crate::Ui`] an [`crate::Area`]?
#[inline] #[inline]
pub fn is_area_ui(&self) -> bool { 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()`]? /// 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