1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 14:50:03 -04:00

Merge branch 'main' into lucas/malmal/main

This commit is contained in:
lucasmerlin
2026-03-24 20:18:46 +01:00
257 changed files with 4285 additions and 2054 deletions

View File

@@ -4,17 +4,23 @@ on:
pull_request_target: pull_request_target:
types: [opened, reopened, synchronize] types: [opened, reopened, synchronize]
permissions:
issues: write
jobs: jobs:
check-source-branch: check-source-branch:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check PR source branch - name: Check PR source branch
env:
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: | run: |
# Check if PR is from a fork # Check if PR is from a fork
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then if [[ "$IS_FORK" == "true" ]]; then
# Check if PR is from the master/main branch of a fork # Check if PR is from the master/main branch of a fork
if [[ "${{ github.event.pull_request.head.ref }}" == "master" || "${{ github.event.pull_request.head.ref }}" == "main" ]]; then if [[ "$HEAD_REF" == "master" || "$HEAD_REF" == "main" ]]; then
echo "ERROR: Pull requests from the master/main branch of forks are not allowed, because it prevents maintainers from contributing to your PR" echo "ERROR: Pull requests from the master/main branch of forks are not allowed, because it prevents maintainers from contributing to your PR"
echo "Please create a feature branch in your fork and submit the PR from that branch instead." echo "Please create a feature branch in your fork and submit the PR from that branch instead."
exit 1 exit 1

View File

@@ -94,7 +94,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.100" # Keep wasm-bindgen version in sync in: setup_web.sh, Cargo.toml, Cargo.lock, rust.yml version: "0.2.108" # Keep wasm-bindgen version in sync in: setup_web.sh, Cargo.toml, Cargo.lock, rust.yml
- run: ./scripts/wasm_bindgen_check.sh --skip-setup - run: ./scripts/wasm_bindgen_check.sh --skip-setup

View File

@@ -9,6 +9,7 @@ isse = "isse" # part of @IsseW username
tye = "tye" # part of @tye-exe username tye = "tye" # part of @tye-exe 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 typ = "typ" # Often used because `type` is a keyword in Rust
wdth = "wdth" # The `wdth` tag is used in variable fonts
# I mistype these so often # I mistype these so often
tesalator = "tessellator" tesalator = "tessellator"
@@ -20,6 +21,8 @@ teselation = "tessellation"
tessalation = "tessellation" tessalation = "tessellation"
tesselation = "tessellation" tesselation = "tessellation"
# For consistency
postfix = "suffix"
# Use the more common spelling # Use the more common spelling
adaptor = "adapter" adaptor = "adapter"

1268
Cargo.lock

File diff suppressed because it is too large Load Diff

View File

@@ -68,9 +68,9 @@ egui_glow = { version = "0.33.3", path = "crates/egui_glow", default-features =
egui_kittest = { version = "0.33.3", path = "crates/egui_kittest", default-features = false } egui_kittest = { version = "0.33.3", path = "crates/egui_kittest", default-features = false }
eframe = { version = "0.33.3", path = "crates/eframe", default-features = false } eframe = { version = "0.33.3", path = "crates/eframe", default-features = false }
accesskit = "0.21.1" accesskit = "0.24.0"
accesskit_consumer = "0.30.1" accesskit_consumer = "0.35.0"
accesskit_winit = "0.29.1" accesskit_winit = "0.32.0"
ahash = { version = "0.8.12", default-features = false, features = [ ahash = { version = "0.8.12", 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",
@@ -80,33 +80,34 @@ arboard = { version = "3.6.1", default-features = false }
backtrace = "0.3.76" backtrace = "0.3.76"
bitflags = "2.9.4" bitflags = "2.9.4"
bytemuck = "1.24.0" bytemuck = "1.24.0"
chrono = { version = "0.4.42", default-features = false }
cint = "0.3.1" cint = "0.3.1"
color-hex = "0.2.0" color-hex = "0.2.0"
criterion = { version = "0.7.0", default-features = false } criterion = { version = "0.7.0", default-features = false }
dify = { version = "0.7.4", default-features = false } dify = { version = "0.8", default-features = false }
directories = "6.0.0" directories = "6.0.0"
document-features = "0.2.11" document-features = "0.2.11"
ehttp = { version = "0.6.0", default-features = false } ehttp = { version = "0.7.1", default-features = false }
enum-map = "2.7.3" enum-map = "2.7.3"
env_logger = { version = "0.11.8", default-features = false } env_logger = { version = "0.11.8", default-features = false }
glow = "0.16.0" font-types = { version = "0.11.0", default-features = false, features = ["std"] }
glow = "0.17.0"
glutin = { version = "0.32.3", default-features = false } glutin = { version = "0.32.3", 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.6", default-features = false } image = { version = "0.25.6", default-features = false }
jiff = { version = "0.2.23", default-features = false }
js-sys = "0.3.77" js-sys = "0.3.77"
kittest = { version = "0.3.0" } kittest = { version = "0.4.0" }
log = { version = "0.4.28", features = ["std"] } log = { version = "0.4.28", features = ["std"] }
memoffset = "0.9.1" memoffset = "0.9.1"
mimalloc = "0.1.48" mimalloc = "0.1.48"
mime_guess2 = { version = "2.3.1", default-features = false } mime_guess2 = { version = "2.3.1", default-features = false }
mint = "0.5.9" mint = "0.5.9"
nohash-hasher = "0.2.0" nohash-hasher = "0.2.0"
objc2 = "0.5.2" objc2 = "0.6.4"
objc2-app-kit = { version = "0.2.2", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false }
objc2-foundation = { version = "0.2.2", default-features = false } objc2-foundation = { version = "0.3.2", default-features = false }
objc2-ui-kit = { version = "0.2.2", default-features = false } objc2-ui-kit = { version = "0.3.2", default-features = false }
open = "5.3.2" open = "5.3.2"
parking_lot = "0.12.5" parking_lot = "0.12.5"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
@@ -119,34 +120,33 @@ rand = "0.9.2"
raw-window-handle = "0.6.2" raw-window-handle = "0.6.2"
rayon = "1.11.0" rayon = "1.11.0"
resvg = { version = "0.45.1", default-features = false } resvg = { version = "0.45.1", default-features = false }
rfd = "0.15.4" rfd = "0.17.2"
ron = "0.11.0" ron = "0.12.0"
self_cell = "1.2.1" self_cell = "1.2.1"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
similar-asserts = "1.7.0" similar-asserts = "1.7.0"
skrifa = { version = "0.37.0", default-features = false, features = ["std", "autohint_shaping"] } skrifa = { version = "0.40.0", default-features = false, features = ["std", "autohint_shaping"] }
smallvec = "1.15.1" smallvec = "1.15.1"
smithay-clipboard = "0.7.2" smithay-clipboard = "0.7.2"
static_assertions = "1.1.0" static_assertions = "1.1.0"
syntect = { version = "5.3.0", default-features = false } syntect = { version = "5.3.0", default-features = false }
tempfile = "3.23.0" tempfile = "3.23.0"
thiserror = "2.0.17" thiserror = "2.0.17"
tokio = "1.47.1" tokio = "1.49"
toml = "0.8" toml = {version = "1.0.0", default-features = false }
type-map = "0.5.1" type-map = "0.5.1"
unicode_names2 = { version = "2.0.0", default-features = false } unicode_names2 = { version = "2.0.0", default-features = false }
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
vello_cpu = { version = "0.0.4", default-features = false, features = ["std"] } vello_cpu = { version = "0.0.6", default-features = false, features = ["std", "u8_pipeline", "f32_pipeline"] }
wasm-bindgen = "0.2.100" # Keep wasm-bindgen version in sync in: setup_web.sh, Cargo.toml, Cargo.lock, rust.yml wasm-bindgen = "0.2.108" # Keep wasm-bindgen version in sync in: setup_web.sh, Cargo.toml, Cargo.lock, rust.yml. Don't update this spuriously, because of https://github.com/rerun-io/rerun/issues/8766
wasm-bindgen-futures = "0.4.0" wasm-bindgen-futures = "0.4.58"
wayland-cursor = { version = "0.31.11", default-features = false } wayland-cursor = { version = "0.31.11", default-features = false }
web-sys = "0.3.77" web-sys = "0.3.77"
web-time = "1.1.0" # Timekeeping for native and web web-time = "1.1.0" # Timekeeping for native and web
webbrowser = "1.0.5" webbrowser = "1.0.5"
wgpu = { version = "27.0.1", default-features = false, features = ["std"] } wgpu = { version = "29.0.0", default-features = false, features = ["std"] }
windows-sys = "0.61.2" windows-sys = "0.61.2"
winit = { version = "0.30.12", default-features = false } winit = { version = "0.30.13", default-features = false }
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "deny" unsafe_code = "deny"

View File

@@ -10,16 +10,14 @@
[![Discord](https://img.shields.io/discord/900275882684477440?label=egui%20discord)](https://discord.gg/JFcEma9bJq) [![Discord](https://img.shields.io/discord/900275882684477440?label=egui%20discord)](https://discord.gg/JFcEma9bJq)
<br/>
<div align="center"> <div align="center">
<a href="https://www.rerun.io/"><img src="https://github.com/user-attachments/assets/78e79463-4357-461b-bbd1-31aa5ef5e1a2" width="250"></a> <a href="https://www.egui.rs/"><img src="https://github.com/user-attachments/assets/cfaf1d43-9338-490f-ae82-99b420baa1b0" width="400"></a>
egui development is sponsored by [Rerun](https://www.rerun.io/), a startup building<br> <br/>
an SDK for visualizing streams of multimodal data.
</div>
---
👉 [Click to run the web demo](https://www.egui.rs/#demo) 👈 👉 [Click to run the web demo](https://www.egui.rs/#demo) 👈
</div>
egui (pronounced "e-gooey") is a simple, fast, and highly portable immediate mode GUI library for Rust. egui runs on the web, natively, and [in your favorite game engine](#integrations). egui (pronounced "e-gooey") is a simple, fast, and highly portable immediate mode GUI library for Rust. egui runs on the web, natively, and [in your favorite game engine](#integrations).

View File

@@ -11,7 +11,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui" repository = "https://github.com/emilk/egui"
categories = ["mathematics", "encoding"] categories = ["mathematics", "encoding"]
keywords = ["gui", "color", "conversion", "gamedev", "images"] keywords = ["gui", "color", "conversion", "gamedev", "images"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[lints] [lints]
workspace = true workspace = true

View File

@@ -11,7 +11,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui/tree/main/crates/eframe" repository = "https://github.com/emilk/egui/tree/main/crates/eframe"
categories = ["gui", "game-development"] categories = ["gui", "game-development"]
keywords = ["egui", "gui", "gamedev"] keywords = ["egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml", "data/icon.png"] include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml", "data/icon.png"]
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
@@ -169,7 +169,10 @@ objc2-foundation = { workspace = true, default-features = false, features = [
objc2-app-kit = { workspace = true, default-features = false, features = [ objc2-app-kit = { workspace = true, default-features = false, features = [
"std", "std",
"NSApplication", "NSApplication",
"NSBitmapImageRep",
"NSGraphics",
"NSImage", "NSImage",
"NSImageRep",
"NSMenu", "NSMenu",
"NSMenuItem", "NSMenuItem",
"NSResponder", "NSResponder",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -72,7 +72,8 @@ pub struct CreationContext<'s> {
/// The `get_proc_address` wrapper of underlying GL context /// The `get_proc_address` wrapper of underlying GL context
#[cfg(feature = "glow")] #[cfg(feature = "glow")]
pub get_proc_address: Option<&'s dyn Fn(&std::ffi::CStr) -> *const std::ffi::c_void>, pub get_proc_address:
Option<std::sync::Arc<dyn Fn(&std::ffi::CStr) -> *const std::ffi::c_void + Send + Sync>>,
/// The underlying WGPU render state. /// The underlying WGPU render state.
/// ///

View File

@@ -204,7 +204,7 @@ fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconS
use crate::icon_data::IconDataExt as _; use crate::icon_data::IconDataExt as _;
profiling::function_scope!(); profiling::function_scope!();
use objc2::ClassType as _; use objc2::AnyThread as _;
use objc2_app_kit::{NSApplication, NSImage}; use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString; use objc2_foundation::NSString;

View File

@@ -265,6 +265,7 @@ impl EpiIntegration {
app: &mut dyn epi::App, app: &mut dyn epi::App,
viewport_ui_cb: Option<&DeferredViewportUiCallback>, viewport_ui_cb: Option<&DeferredViewportUiCallback>,
mut raw_input: egui::RawInput, mut raw_input: egui::RawInput,
is_visible: bool,
) -> egui::FullOutput { ) -> egui::FullOutput {
raw_input.time = Some(self.beginning.elapsed().as_secs_f64()); raw_input.time = Some(self.beginning.elapsed().as_secs_f64());
@@ -275,14 +276,17 @@ impl EpiIntegration {
let full_output = self.egui_ctx.run_ui(raw_input, |ui| { let full_output = self.egui_ctx.run_ui(raw_input, |ui| {
if let Some(viewport_ui_cb) = viewport_ui_cb { if let Some(viewport_ui_cb) = viewport_ui_cb {
// Child viewport // Child viewport
if is_visible {
profiling::scope!("viewport_callback"); profiling::scope!("viewport_callback");
viewport_ui_cb(ui); viewport_ui_cb(ui);
}
} else { } else {
{ {
profiling::scope!("App::logic"); profiling::scope!("App::logic");
app.logic(ui.ctx(), &mut self.frame); app.logic(ui.ctx(), &mut self.frame);
} }
if is_visible {
{ {
profiling::scope!("App::update"); profiling::scope!("App::update");
#[expect(deprecated)] #[expect(deprecated)]
@@ -294,6 +298,7 @@ impl EpiIntegration {
app.ui(ui, &mut self.frame); app.ui(ui, &mut self.frame);
} }
} }
}
}); });
let is_root_viewport = viewport_ui_cb.is_none(); let is_root_viewport = viewport_ui_cb.is_none();

View File

@@ -34,7 +34,7 @@ use egui_winit::accesskit_winit;
use crate::{ use crate::{
App, AppCreator, CreationContext, NativeOptions, Result, Storage, App, AppCreator, CreationContext, NativeOptions, Result, Storage,
native::epi_integration::EpiIntegration, native::{epi_integration::EpiIntegration, winit_integration::is_invisible_or_minimized},
}; };
use super::{ use super::{
@@ -294,14 +294,15 @@ impl<'app> GlowWinitApp<'app> {
// Use latest raw_window_handle for eframe compatibility // Use latest raw_window_handle for eframe compatibility
use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _}; use raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _};
let get_proc_address = |addr: &_| glutin.get_proc_address(addr); let gl_config = glutin.gl_config.clone();
let get_proc_address = move |addr: &_| gl_config.display().get_proc_address(addr);
let window = glutin.window(ViewportId::ROOT); let window = glutin.window(ViewportId::ROOT);
let cc = CreationContext { let cc = CreationContext {
egui_ctx: integration.egui_ctx.clone(), egui_ctx: integration.egui_ctx.clone(),
integration_info: integration.frame.info().clone(), integration_info: integration.frame.info().clone(),
storage: integration.frame.storage(), storage: integration.frame.storage(),
gl: Some(gl), gl: Some(gl),
get_proc_address: Some(&get_proc_address), get_proc_address: Some(Arc::new(get_proc_address)),
#[cfg(feature = "wgpu_no_default_features")] #[cfg(feature = "wgpu_no_default_features")]
wgpu_render_state: None, wgpu_render_state: None,
raw_display_handle: window.display_handle().map(|h| h.as_raw()), raw_display_handle: window.display_handle().map(|h| h.as_raw()),
@@ -466,12 +467,21 @@ impl WinitApp for GlowWinitApp<'_> {
if let Some(viewport) = glutin if let Some(viewport) = glutin
.focused_viewport .focused_viewport
.and_then(|viewport| glutin.viewports.get_mut(&viewport)) .and_then(|viewport| glutin.viewports.get_mut(&viewport))
&& let Some(window) = viewport.window.as_ref()
{ {
if let Some(egui_winit) = viewport.egui_winit.as_mut() { if !window.has_focus()
egui_winit.on_mouse_motion(delta); && !viewport
.egui_winit
.as_ref()
.map(|state| state.is_any_pointer_button_down())
.unwrap_or(false)
{
return Ok(EventResult::Wait);
} }
if let Some(window) = viewport.window.as_ref() { if let Some(egui_winit) = viewport.egui_winit.as_mut()
&& egui_winit.on_mouse_motion(delta)
{
return Ok(EventResult::RepaintNext(window.id())); return Ok(EventResult::RepaintNext(window.id()));
} }
} }
@@ -554,7 +564,7 @@ impl GlowWinitRunning<'_> {
} }
} }
let (raw_input, viewport_ui_cb) = { let (raw_input, viewport_ui_cb, is_visible) = {
let mut glutin = self.glutin.borrow_mut(); let mut glutin = self.glutin.borrow_mut();
let egui_ctx = glutin.egui_ctx.clone(); let egui_ctx = glutin.egui_ctx.clone();
let Some(viewport) = glutin.viewports.get_mut(&viewport_id) else { let Some(viewport) = glutin.viewports.get_mut(&viewport_id) else {
@@ -565,6 +575,8 @@ impl GlowWinitRunning<'_> {
}; };
egui_winit::update_viewport_info(&mut viewport.info, &egui_ctx, window, false); egui_winit::update_viewport_info(&mut viewport.info, &egui_ctx, window, false);
let is_visible = viewport.info.visible().unwrap_or(true);
let Some(egui_winit) = viewport.egui_winit.as_mut() else { let Some(egui_winit) = viewport.egui_winit.as_mut() else {
return Ok(EventResult::Wait); return Ok(EventResult::Wait);
}; };
@@ -580,7 +592,7 @@ impl GlowWinitRunning<'_> {
.map(|(id, viewport)| (*id, viewport.info.clone())) .map(|(id, viewport)| (*id, viewport.info.clone()))
.collect(); .collect();
(raw_input, viewport_ui_cb) (raw_input, viewport_ui_cb, is_visible)
}; };
// HACK: In order to get the right clear_color, the system theme needs to be set, which // HACK: In order to get the right clear_color, the system theme needs to be set, which
@@ -596,7 +608,7 @@ impl GlowWinitRunning<'_> {
let has_many_viewports = self.glutin.borrow().viewports.len() > 1; let has_many_viewports = self.glutin.borrow().viewports.len() > 1;
let clear_before_update = !has_many_viewports; // HACK: for some reason, an early clear doesn't "take" on Mac with multiple viewports. let clear_before_update = !has_many_viewports; // HACK: for some reason, an early clear doesn't "take" on Mac with multiple viewports.
if clear_before_update { if is_visible && clear_before_update {
// clear before we call update, so users can paint between clear-color and egui windows: // clear before we call update, so users can paint between clear-color and egui windows:
let mut glutin = self.glutin.borrow_mut(); let mut glutin = self.glutin.borrow_mut();
@@ -631,9 +643,12 @@ impl GlowWinitRunning<'_> {
// The update function, which could call immediate viewports, // The update function, which could call immediate viewports,
// so make sure we don't hold any locks here required by the immediate viewports rendeer. // so make sure we don't hold any locks here required by the immediate viewports rendeer.
let full_output = let full_output = self.integration.update(
self.integration self.app.as_mut(),
.update(self.app.as_mut(), viewport_ui_cb.as_deref(), raw_input); viewport_ui_cb.as_deref(),
raw_input,
is_visible,
);
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -676,6 +691,7 @@ impl GlowWinitRunning<'_> {
egui_winit.handle_platform_output(&window, platform_output); egui_winit.handle_platform_output(&window, platform_output);
if is_visible {
let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point); let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point);
{ {
@@ -756,6 +772,7 @@ impl GlowWinitRunning<'_> {
{ {
save_screenshot_and_exit(&path, &painter, screen_size_in_pixels); save_screenshot_and_exit(&path, &painter, screen_size_in_pixels);
} }
}
glutin.handle_viewport_output(event_loop, &integration.egui_ctx, &viewport_output); glutin.handle_viewport_output(event_loop, &integration.egui_ctx, &viewport_output);
@@ -763,9 +780,11 @@ impl GlowWinitRunning<'_> {
integration.maybe_autosave(app.as_mut(), Some(&window)); integration.maybe_autosave(app.as_mut(), Some(&window));
if window.is_minimized() == Some(true) { if is_invisible_or_minimized(&window) {
// On Mac, a minimized Window uses up all CPU: // On Mac, a minimized Window uses up all CPU:
// https://github.com/emilk/egui/issues/325 // https://github.com/emilk/egui/issues/325
// On Windows, an invisible window also uses up all CPU:
// https://github.com/emilk/egui/issues/7776
profiling::scope!("minimized_sleep"); profiling::scope!("minimized_sleep");
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
} }
@@ -831,6 +850,14 @@ impl GlowWinitRunning<'_> {
} }
} }
winit::event::WindowEvent::Occluded(is_occluded) => {
if let Some(viewport_id) = viewport_id
&& let Some(viewport) = glutin.viewports.get_mut(&viewport_id)
{
viewport.info.occluded = Some(*is_occluded);
}
}
winit::event::WindowEvent::CloseRequested => { winit::event::WindowEvent::CloseRequested => {
if viewport_id == Some(ViewportId::ROOT) && self.integration.should_close() { if viewport_id == Some(ViewportId::ROOT) && self.integration.should_close() {
log::debug!( log::debug!(

View File

@@ -1,4 +1,4 @@
use std::time::Instant; use std::time::{Duration, Instant};
use winit::{ use winit::{
application::ApplicationHandler, application::ApplicationHandler,
@@ -11,9 +11,20 @@ use ahash::HashMap;
use super::winit_integration::{UserEvent, WinitApp}; use super::winit_integration::{UserEvent, WinitApp};
use crate::{ use crate::{
Result, epi, Result, epi,
native::{event_loop_context, winit_integration::EventResult}, native::{
event_loop_context,
winit_integration::{EventResult, is_invisible_or_minimized},
},
}; };
/// Minimum interval between repaints for invisible windows.
///
/// On Windows, invisible windows don't receive `RedrawRequested` events,
/// so we throttle their repaints to avoid busy-looping while still
/// processing viewport commands like `Visible(true)`.
/// See <https://github.com/emilk/egui/issues/7776>.
const INVISIBLE_WINDOW_REPAINT_INTERVAL: Duration = Duration::from_millis(100);
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
fn create_event_loop(native_options: &mut epi::NativeOptions) -> Result<EventLoop<UserEvent>> { fn create_event_loop(native_options: &mut epi::NativeOptions) -> Result<EventLoop<UserEvent>> {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@@ -177,23 +188,54 @@ impl<T: WinitApp> WinitAppWrapper<T> {
fn check_redraw_requests(&mut self, event_loop: &ActiveEventLoop) { fn check_redraw_requests(&mut self, event_loop: &ActiveEventLoop) {
let now = Instant::now(); let now = Instant::now();
let mut invisible_window_ids = Vec::new();
self.windows_next_repaint_times self.windows_next_repaint_times
.retain(|window_id, repaint_time| { .retain(|window_id, repaint_time| {
if now < *repaint_time { if now < *repaint_time {
return true; // not yet ready return true; // not yet ready
} }
event_loop.set_control_flow(ControlFlow::Poll);
if let Some(window) = self.winit_app.window(*window_id) { if let Some(window) = self.winit_app.window(*window_id) {
// On Windows, invisible windows don't receive RedrawRequested
// events, so pending viewport commands (e.g. Visible(true)) would
// never be processed. We collect these windows to paint them
// directly below.
// See: https://github.com/emilk/egui/issues/5229
if is_invisible_or_minimized(&window) {
invisible_window_ids.push(*window_id);
} else {
log::trace!("request_redraw for {window_id:?}"); log::trace!("request_redraw for {window_id:?}");
event_loop.set_control_flow(ControlFlow::Poll);
window.request_redraw(); window.request_redraw();
}
} else { } else {
log::trace!("No window found for {window_id:?}"); log::trace!("No window found for {window_id:?}");
} }
false false
}); });
// Paint invisible windows directly, since they won't receive
// RedrawRequested events on Windows. This ensures that viewport
// commands like Visible(true) are still processed.
for window_id in &invisible_window_ids {
let event_result = self.winit_app.run_ui_and_paint(event_loop, *window_id);
self.handle_event_result(event_loop, event_result);
}
// Throttle any already-scheduled repaints for invisible windows
// to avoid busy-looping. If no repaint was requested by the app,
// the window will simply sleep.
// See: https://github.com/emilk/egui/issues/7776
if !invisible_window_ids.is_empty() {
let next_paint = Instant::now() + INVISIBLE_WINDOW_REPAINT_INTERVAL;
for window_id in &invisible_window_ids {
self.windows_next_repaint_times
.entry(*window_id)
.and_modify(|t| *t = (*t).min(next_paint));
}
}
let next_repaint_time = self.windows_next_repaint_times.values().min().copied(); let next_repaint_time = self.windows_next_repaint_times.values().min().copied();
if let Some(next_repaint_time) = next_repaint_time { if let Some(next_repaint_time) = next_repaint_time {
event_loop.set_control_flow(ControlFlow::WaitUntil(next_repaint_time)); event_loop.set_control_flow(ControlFlow::WaitUntil(next_repaint_time));
@@ -270,6 +312,16 @@ impl<T: WinitApp> ApplicationHandler<UserEvent> for WinitAppWrapper<T> {
if let Some(window_id) = if let Some(window_id) =
self.winit_app.window_id_from_viewport_id(viewport_id) self.winit_app.window_id_from_viewport_id(viewport_id)
{ {
// Throttle repaints for invisible windows to prevent
// high CPU usage on Windows.
// See: https://github.com/emilk/egui/issues/7776
let when = if let Some(window) = self.winit_app.window(window_id)
&& is_invisible_or_minimized(&window)
{
when.max(Instant::now() + INVISIBLE_WINDOW_REPAINT_INTERVAL)
} else {
when
};
Ok(EventResult::RepaintAt(window_id, when)) Ok(EventResult::RepaintAt(window_id, when))
} else { } else {
Ok(EventResult::Wait) Ok(EventResult::Wait)

View File

@@ -27,7 +27,10 @@ use winit_integration::UserEvent;
use crate::{ use crate::{
App, AppCreator, CreationContext, NativeOptions, Result, Storage, App, AppCreator, CreationContext, NativeOptions, Result, Storage,
native::{epi_integration::EpiIntegration, winit_integration::EventResult}, native::{
epi_integration::EpiIntegration,
winit_integration::{EventResult, is_invisible_or_minimized},
},
}; };
use super::{epi_integration, event_loop_context, winit_integration, winit_integration::WinitApp}; use super::{epi_integration, event_loop_context, winit_integration, winit_integration::WinitApp};
@@ -184,9 +187,17 @@ impl<'app> WgpuWinitApp<'app> {
builder: ViewportBuilder, builder: ViewportBuilder,
) -> crate::Result<&mut WgpuWinitRunning<'app>> { ) -> crate::Result<&mut WgpuWinitRunning<'app>> {
profiling::function_scope!(); profiling::function_scope!();
// Inject the display handle into the wgpu setup so that wgpu can create
// surfaces on platforms that require it (e.g. GLES on Wayland).
let mut wgpu_options = self.native_options.wgpu_options.clone();
if let egui_wgpu::WgpuSetup::CreateNew(ref mut create_new) = wgpu_options.wgpu_setup
&& create_new.display_handle.is_none()
{
create_new.display_handle = Some(Box::new(event_loop.owned_display_handle()));
}
let mut painter = pollster::block_on(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(), wgpu_options,
self.native_options.viewport.transparent.unwrap_or(false), self.native_options.viewport.transparent.unwrap_or(false),
egui_wgpu::RendererOptions { egui_wgpu::RendererOptions {
msaa_samples: self.native_options.multisampling as _, msaa_samples: self.native_options.multisampling as _,
@@ -474,12 +485,21 @@ impl WinitApp for WgpuWinitApp<'_> {
if let Some(viewport) = shared if let Some(viewport) = shared
.focused_viewport .focused_viewport
.and_then(|viewport| shared.viewports.get_mut(&viewport)) .and_then(|viewport| shared.viewports.get_mut(&viewport))
&& let Some(window) = viewport.window.as_ref()
{ {
if let Some(egui_winit) = viewport.egui_winit.as_mut() { if !window.has_focus()
egui_winit.on_mouse_motion(delta); && !viewport
.egui_winit
.as_ref()
.map(|state| state.is_any_pointer_button_down())
.unwrap_or(false)
{
return Ok(EventResult::Wait);
} }
if let Some(window) = viewport.window.as_ref() { if let Some(egui_winit) = viewport.egui_winit.as_mut()
&& egui_winit.on_mouse_motion(delta)
{
return Ok(EventResult::RepaintNext(window.id())); return Ok(EventResult::RepaintNext(window.id()));
} }
} }
@@ -584,7 +604,7 @@ impl WgpuWinitRunning<'_> {
let mut frame_timer = crate::stopwatch::Stopwatch::new(); let mut frame_timer = crate::stopwatch::Stopwatch::new();
frame_timer.start(); frame_timer.start();
let (viewport_ui_cb, raw_input) = { let (viewport_ui_cb, raw_input, is_visible) = {
profiling::scope!("Prepare"); profiling::scope!("Prepare");
let mut shared_lock = shared.borrow_mut(); let mut shared_lock = shared.borrow_mut();
@@ -628,6 +648,8 @@ impl WgpuWinitRunning<'_> {
}; };
egui_winit::update_viewport_info(info, &integration.egui_ctx, window, false); egui_winit::update_viewport_info(info, &integration.egui_ctx, window, false);
let is_visible = viewport.info.visible().unwrap_or(true);
{ {
profiling::scope!("set_window"); profiling::scope!("set_window");
pollster::block_on(painter.set_window(viewport_id, Some(Arc::clone(window))))?; pollster::block_on(painter.set_window(viewport_id, Some(Arc::clone(window))))?;
@@ -648,14 +670,19 @@ impl WgpuWinitRunning<'_> {
painter.handle_screenshots(&mut raw_input.events); painter.handle_screenshots(&mut raw_input.events);
(viewport_ui_cb, raw_input) (viewport_ui_cb, raw_input, is_visible)
}; };
// ------------------------------------------------------------ // ------------------------------------------------------------
// Runs the update, which could call immediate viewports, // Runs the update, which could call immediate viewports,
// so make sure we hold no locks here! // so make sure we hold no locks here!
let full_output = integration.update(app.as_mut(), viewport_ui_cb.as_deref(), raw_input); let full_output = integration.update(
app.as_mut(),
viewport_ui_cb.as_deref(),
raw_input,
is_visible,
);
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -696,6 +723,7 @@ impl WgpuWinitRunning<'_> {
egui_winit.handle_platform_output(window, platform_output); egui_winit.handle_platform_output(window, platform_output);
let vsync_secs = if is_visible {
let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point); let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point);
let mut screenshot_commands = vec![]; let mut screenshot_commands = vec![];
@@ -743,6 +771,11 @@ impl WgpuWinitRunning<'_> {
integration.post_rendering(window); integration.post_rendering(window);
vsync_secs
} else {
0.0
};
let active_viewports_ids: ViewportIdSet = viewport_output.keys().copied().collect(); let active_viewports_ids: ViewportIdSet = viewport_output.keys().copied().collect();
handle_viewport_output( handle_viewport_output(
@@ -768,10 +801,12 @@ impl WgpuWinitRunning<'_> {
integration.maybe_autosave(app.as_mut(), window.map(|w| w.as_ref())); integration.maybe_autosave(app.as_mut(), window.map(|w| w.as_ref()));
if let Some(window) = window if let Some(window) = window
&& window.is_minimized() == Some(true) && is_invisible_or_minimized(window)
{ {
// On Mac, a minimized Window uses up all CPU: // On Mac, a minimized Window uses up all CPU:
// https://github.com/emilk/egui/issues/325 // https://github.com/emilk/egui/issues/325
// On Windows, an invisible window also uses up all CPU:
// https://github.com/emilk/egui/issues/7776
profiling::scope!("minimized_sleep"); profiling::scope!("minimized_sleep");
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
} }
@@ -863,6 +898,14 @@ impl WgpuWinitRunning<'_> {
} }
} }
winit::event::WindowEvent::Occluded(is_occluded) => {
if let Some(viewport_id) = viewport_id
&& let Some(viewport) = shared.viewports.get_mut(&viewport_id)
{
viewport.info.occluded = Some(*is_occluded);
}
}
winit::event::WindowEvent::CloseRequested => { winit::event::WindowEvent::CloseRequested => {
if viewport_id == Some(ViewportId::ROOT) && integration.should_close() { if viewport_id == Some(ViewportId::ROOT) && integration.should_close() {
log::debug!( log::debug!(

View File

@@ -9,6 +9,14 @@ use egui::ViewportId;
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
use egui_winit::accesskit_winit; use egui_winit::accesskit_winit;
/// Returns `true` if the window is invisible or minimized.
///
/// These windows don't receive `RedrawRequested` events on Windows,
/// so they need special handling to keep processing viewport commands.
pub fn is_invisible_or_minimized(window: &Window) -> bool {
window.is_visible() == Some(false) || window.is_minimized() == Some(true)
}
/// Create an egui context, restoring it from storage if possible. /// Create an egui context, restoring it from storage if possible.
pub fn create_egui_context(storage: Option<&dyn crate::Storage>) -> egui::Context { pub fn create_egui_context(storage: Option<&dyn crate::Storage>) -> egui::Context {
profiling::function_scope!(); profiling::function_scope!();

View File

@@ -274,13 +274,21 @@ impl AppRunner {
self.app.raw_input_hook(&self.egui_ctx, &mut raw_input); self.app.raw_input_hook(&self.egui_ctx, &mut raw_input);
let is_visible = raw_input
.viewports
.get(&egui::ViewportId::ROOT)
.and_then(|v| v.visible())
.unwrap_or(true);
let full_output = self.egui_ctx.run_ui(raw_input, |ui| { let full_output = self.egui_ctx.run_ui(raw_input, |ui| {
self.app.logic(ui.ctx(), &mut self.frame); self.app.logic(ui.ctx(), &mut self.frame);
if is_visible {
#[expect(deprecated)] #[expect(deprecated)]
self.app.update(ui.ctx(), &mut self.frame); self.app.update(ui.ctx(), &mut self.frame);
self.app.ui(ui, &mut self.frame); self.app.ui(ui, &mut self.frame);
}
}); });
let egui::FullOutput { let egui::FullOutput {
platform_output, platform_output,
@@ -311,9 +319,11 @@ impl AppRunner {
} }
self.handle_platform_output(platform_output); self.handle_platform_output(platform_output);
if is_visible {
self.textures_delta.append(textures_delta); self.textures_delta.append(textures_delta);
self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point)); self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point));
} }
}
/// Paint the results of the last call to [`Self::logic`]. /// Paint the results of the last call to [`Self::logic`].
pub fn paint(&mut self) { pub fn paint(&mut self) {

View File

@@ -31,11 +31,18 @@ impl WebInput {
time: Some(super::now_sec()), time: Some(super::now_sec()),
..self.raw.take() ..self.raw.take()
}; };
raw_input let viewport = raw_input
.viewports .viewports
.entry(egui::ViewportId::ROOT) .entry(egui::ViewportId::ROOT)
.or_default() .or_default();
.native_pixels_per_point = Some(super::native_pixels_per_point()); viewport.native_pixels_per_point = Some(super::native_pixels_per_point());
// A hidden browser tab is effectively occluded.
let hidden = web_sys::window()
.and_then(|w| w.document())
.is_some_and(|doc| doc.hidden());
viewport.occluded = Some(hidden);
raw_input raw_input
} }

View File

@@ -38,6 +38,7 @@ mod web_painter_wgpu;
pub use backend::*; pub use backend::*;
use egui::Theme; use egui::Theme;
use js_sys::Object;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Document, MediaQueryList, Node}; use web_sys::{Document, MediaQueryList, Node};
@@ -370,5 +371,5 @@ pub fn percent_decode(s: &str) -> String {
/// Are we running inside the Safari browser? /// Are we running inside the Safari browser?
pub fn is_safari_browser() -> bool { pub fn is_safari_browser() -> bool {
web_sys::window().is_some_and(|window| window.has_own_property(&JsValue::from("safari"))) web_sys::window().is_some_and(|window| Object::has_own(&window, &JsValue::from("safari")))
} }

View File

@@ -15,13 +15,32 @@ pub(crate) struct WebPainterWgpu {
surface: wgpu::Surface<'static>, surface: wgpu::Surface<'static>,
surface_configuration: wgpu::SurfaceConfiguration, surface_configuration: wgpu::SurfaceConfiguration,
render_state: Option<RenderState>, render_state: Option<RenderState>,
on_surface_error: Arc<dyn Fn(wgpu::SurfaceError) -> SurfaceErrorAction>, on_surface_status: Arc<dyn Fn(&wgpu::CurrentSurfaceTexture) -> SurfaceErrorAction>,
depth_stencil_format: Option<wgpu::TextureFormat>, depth_stencil_format: Option<wgpu::TextureFormat>,
depth_texture_view: Option<wgpu::TextureView>, depth_texture_view: Option<wgpu::TextureView>,
screen_capture_state: Option<CaptureState>, screen_capture_state: Option<CaptureState>,
capture_tx: CaptureSender, capture_tx: CaptureSender,
capture_rx: CaptureReceiver, capture_rx: CaptureReceiver,
ctx: egui::Context, ctx: egui::Context,
needs_reconfigure: bool,
}
/// Owned web display handle that is `Send + Sync`.
///
/// `DisplayHandle` from `raw-window-handle` is `!Send`/`!Sync` because the enum
/// contains platform variants with raw pointers. On web the handle is always empty,
/// so this wrapper is safe.
#[cfg(target_arch = "wasm32")]
#[derive(Clone, Debug)]
struct WebDisplay;
#[cfg(target_arch = "wasm32")]
impl egui_wgpu::wgpu::rwh::HasDisplayHandle for WebDisplay {
fn display_handle(
&self,
) -> Result<egui_wgpu::wgpu::rwh::DisplayHandle<'_>, egui_wgpu::wgpu::rwh::HandleError> {
Ok(egui_wgpu::wgpu::rwh::DisplayHandle::web())
}
} }
impl WebPainterWgpu { impl WebPainterWgpu {
@@ -63,7 +82,17 @@ impl WebPainterWgpu {
) -> Result<Self, String> { ) -> Result<Self, String> {
log::debug!("Creating wgpu painter"); log::debug!("Creating wgpu painter");
let instance = options.wgpu_options.wgpu_setup.new_instance().await; // Inject the display handle into the wgpu setup so that wgpu can create surfaces on WebGL.
let mut wgpu_options = options.wgpu_options.clone();
if let egui_wgpu::WgpuSetup::CreateNew(ref mut create_new) = wgpu_options.wgpu_setup
&& create_new.display_handle.is_none()
{
// Force WebGL, useful for quick & dirty testing:
//create_new.instance_descriptor.backends = wgpu::Backends::GL;
create_new.display_handle = Some(Box::new(WebDisplay));
}
let instance = wgpu_options.wgpu_setup.new_instance().await;
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}"))?;
@@ -71,7 +100,7 @@ impl WebPainterWgpu {
let depth_stencil_format = egui_wgpu::depth_format_from_bits(options.depth_buffer, 0); let depth_stencil_format = egui_wgpu::depth_format_from_bits(options.depth_buffer, 0);
let render_state = RenderState::create( let render_state = RenderState::create(
&options.wgpu_options, &wgpu_options,
&instance, &instance,
Some(&surface), Some(&surface),
egui_wgpu::RendererOptions { egui_wgpu::RendererOptions {
@@ -89,7 +118,7 @@ impl WebPainterWgpu {
let surface_configuration = wgpu::SurfaceConfiguration { let surface_configuration = wgpu::SurfaceConfiguration {
format: render_state.target_format, format: render_state.target_format,
present_mode: options.wgpu_options.present_mode, present_mode: wgpu_options.present_mode,
view_formats: vec![render_state.target_format], view_formats: vec![render_state.target_format],
..default_configuration ..default_configuration
}; };
@@ -105,11 +134,12 @@ impl WebPainterWgpu {
surface_configuration, surface_configuration,
depth_stencil_format, depth_stencil_format,
depth_texture_view: None, depth_texture_view: None,
on_surface_error: Arc::clone(&options.wgpu_options.on_surface_error) as _, on_surface_status: Arc::clone(&wgpu_options.on_surface_status) as _,
screen_capture_state: None, screen_capture_state: None,
capture_tx, capture_tx,
capture_rx, capture_rx,
ctx, ctx,
needs_reconfigure: false,
}) })
} }
} }
@@ -195,18 +225,28 @@ impl WebPainter for WebPainterWgpu {
); );
} }
if self.needs_reconfigure {
self.surface
.configure(&render_state.device, &self.surface_configuration);
self.needs_reconfigure = false;
}
let output_frame = match self.surface.get_current_texture() { let output_frame = match self.surface.get_current_texture() {
Ok(frame) => frame, wgpu::CurrentSurfaceTexture::Success(frame) => frame,
Err(err) => match (*self.on_surface_error)(err) { wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
self.needs_reconfigure = true;
frame
}
other => {
match (*self.on_surface_status)(&other) {
SurfaceErrorAction::RecreateSurface => { SurfaceErrorAction::RecreateSurface => {
self.surface self.surface
.configure(&render_state.device, &self.surface_configuration); .configure(&render_state.device, &self.surface_configuration);
}
SurfaceErrorAction::SkipFrame => {}
}
return Ok(()); return Ok(());
} }
SurfaceErrorAction::SkipFrame => {
return Ok(());
}
},
}; };
{ {
@@ -268,6 +308,7 @@ impl WebPainter for WebPainterWgpu {
label: Some("egui_render"), label: Some("egui_render"),
occlusion_query_set: None, occlusion_query_set: None,
timestamp_writes: None, timestamp_writes: None,
multiview_mask: None,
}); });
// Forgetting the pass' lifetime means that we are no longer compile-time protected from // Forgetting the pass' lifetime means that we are no longer compile-time protected from
@@ -280,15 +321,13 @@ impl WebPainter for WebPainterWgpu {
); );
} }
let mut capture_buffer = None; let capture_buffer = if capture
&& let Some(capture_state) = &mut self.screen_capture_state
if capture && let Some(capture_state) = &mut self.screen_capture_state { {
capture_buffer = Some(capture_state.copy_textures( Some(capture_state.copy_textures(&render_state.device, &output_frame, &mut encoder))
&render_state.device, } else {
&output_frame, None
&mut encoder, };
));
}
Some((output_frame, capture_buffer)) Some((output_frame, capture_buffer))
}; };

View File

@@ -15,7 +15,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui/tree/main/crates/egui-wgpu" repository = "https://github.com/emilk/egui/tree/main/crates/egui-wgpu"
categories = ["gui", "game-development"] categories = ["gui", "game-development"]
keywords = ["wgpu", "egui", "gui", "gamedev"] keywords = ["wgpu", "egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "**/*.wgsl", "Cargo.toml"] include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "**/*.wgsl", "Cargo.toml"]
[lints] [lints]
workspace = true workspace = true

View File

@@ -47,7 +47,7 @@ impl CaptureState {
}, },
depth_stencil: None, depth_stencil: None,
multisample: MultisampleState::default(), multisample: MultisampleState::default(),
multiview: None, multiview_mask: None,
cache: None, cache: None,
}); });
@@ -165,6 +165,7 @@ impl CaptureState {
depth_stencil_attachment: None, depth_stencil_attachment: None,
occlusion_query_set: None, occlusion_query_set: None,
timestamp_writes: None, timestamp_writes: None,
multiview_mask: None,
}); });
pass.set_pipeline(&self.pipeline); pass.set_pipeline(&self.pipeline);

View File

@@ -24,7 +24,10 @@ mod renderer;
mod setup; mod setup;
pub use renderer::*; pub use renderer::*;
pub use setup::{NativeAdapterSelectorMethod, WgpuSetup, WgpuSetupCreateNew, WgpuSetupExisting}; pub use setup::{
EguiDisplayHandle, NativeAdapterSelectorMethod, WgpuSetup, WgpuSetupCreateNew,
WgpuSetupExisting,
};
/// Helpers for capturing screenshots of the UI. /// Helpers for capturing screenshots of the UI.
#[cfg(feature = "capture")] #[cfg(feature = "capture")]
@@ -185,12 +188,13 @@ impl RenderState {
wgpu::Backends::all() wgpu::Backends::all()
}; };
instance.enumerate_adapters(backends) instance.enumerate_adapters(backends).await
}; };
let (adapter, device, queue) = match config.wgpu_setup.clone() { let (adapter, device, queue) = match config.wgpu_setup.clone() {
WgpuSetup::CreateNew(WgpuSetupCreateNew { WgpuSetup::CreateNew(WgpuSetupCreateNew {
instance_descriptor: _, instance_descriptor: _,
display_handle: _,
power_preference, power_preference,
native_adapter_selector: _native_adapter_selector, native_adapter_selector: _native_adapter_selector,
device_descriptor, device_descriptor,
@@ -272,7 +276,7 @@ fn describe_adapters(adapters: &[wgpu::Adapter]) -> String {
} }
} }
/// Specifies which action should be taken as consequence of a [`wgpu::SurfaceError`] /// Specifies which action should be taken as consequence of a surface error.
pub enum SurfaceErrorAction { pub enum SurfaceErrorAction {
/// Do nothing and skip the current frame. /// Do nothing and skip the current frame.
SkipFrame, SkipFrame,
@@ -299,8 +303,15 @@ pub struct WgpuConfiguration {
/// How to create the wgpu adapter & device /// How to create the wgpu adapter & device
pub wgpu_setup: WgpuSetup, pub wgpu_setup: WgpuSetup,
/// Callback for surface errors. /// Callback for surface status changes.
pub on_surface_error: Arc<dyn Fn(wgpu::SurfaceError) -> SurfaceErrorAction + Send + Sync>, ///
/// Called with the [`wgpu::CurrentSurfaceTexture`] result whenever acquiring a frame
/// does not return [`wgpu::CurrentSurfaceTexture::Success`]. For
/// [`wgpu::CurrentSurfaceTexture::Suboptimal`], egui uses the frame as-is and
/// defers surface reconfiguration to the next frame — the callback is not invoked
/// in that case either.
pub on_surface_status:
Arc<dyn Fn(&wgpu::CurrentSurfaceTexture) -> SurfaceErrorAction + Send + Sync>,
} }
#[test] #[test]
@@ -315,7 +326,7 @@ impl std::fmt::Debug for WgpuConfiguration {
present_mode, present_mode,
desired_maximum_frame_latency, desired_maximum_frame_latency,
wgpu_setup, wgpu_setup,
on_surface_error: _, on_surface_status: _,
} = self; } = self;
f.debug_struct("WgpuConfiguration") f.debug_struct("WgpuConfiguration")
.field("present_mode", &present_mode) .field("present_mode", &present_mode)
@@ -333,15 +344,25 @@ impl Default for WgpuConfiguration {
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(), // No display handle available at this point — callers should replace this with
on_surface_error: Arc::new(|err| { // `WgpuSetup::from_display_handle(...)` before creating the instance if one is available.
if err == wgpu::SurfaceError::Outdated { wgpu_setup: WgpuSetup::without_display_handle(),
on_surface_status: Arc::new(|status| {
match status {
wgpu::CurrentSurfaceTexture::Outdated => {
// This error occurs when the app is minimized on Windows. // This error occurs when the app is minimized on Windows.
// Silently return here to prevent spamming the console with: // Silently return here to prevent spamming the console with:
// "The underlying surface has changed, and therefore the swap chain must be updated" // "The underlying surface has changed, and therefore the swap chain must be updated"
} else {
log::warn!("Dropped frame with error: {err}");
} }
wgpu::CurrentSurfaceTexture::Occluded => {
// This error occurs when the application is occluded (e.g. minimized or behind another window).
log::debug!("Dropped frame with error: {status:?}");
}
_ => {
log::warn!("Dropped frame with error: {status:?}");
}
}
SurfaceErrorAction::SkipFrame SurfaceErrorAction::SkipFrame
}), }),
} }
@@ -395,6 +416,10 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
driver, driver,
driver_info, driver_info,
backend, backend,
device_pci_bus_id,
subgroup_min_size,
subgroup_max_size,
transient_saves_memory,
} = &info; } = &info;
// Example values: // Example values:
@@ -426,6 +451,13 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
if *device != 0 { if *device != 0 {
summary += &format!(", device: 0x{device:02X}"); summary += &format!(", device: 0x{device:02X}");
} }
if !device_pci_bus_id.is_empty() {
summary += &format!(", pci_bus_id: {device_pci_bus_id:?}");
}
if *subgroup_min_size != 0 || *subgroup_max_size != 0 {
summary += &format!(", subgroup_size: {subgroup_min_size}..={subgroup_max_size}");
}
summary += &format!(", transient_saves_memory: {transient_saves_memory}");
summary summary
} }

View File

@@ -1,5 +1,3 @@
#![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps
use std::{borrow::Cow, num::NonZeroU64, ops::Range}; use std::{borrow::Cow, num::NonZeroU64, ops::Range};
use ahash::HashMap; use ahash::HashMap;
@@ -352,16 +350,19 @@ impl Renderer {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("egui_pipeline_layout"), label: Some("egui_pipeline_layout"),
bind_group_layouts: &[&uniform_bind_group_layout, &texture_bind_group_layout], bind_group_layouts: &[
push_constant_ranges: &[], Some(&uniform_bind_group_layout),
Some(&texture_bind_group_layout),
],
immediate_size: 0,
}); });
let depth_stencil = options let depth_stencil = options
.depth_stencil_format .depth_stencil_format
.map(|format| wgpu::DepthStencilState { .map(|format| wgpu::DepthStencilState {
format, format,
depth_write_enabled: false, depth_write_enabled: Some(false),
depth_compare: wgpu::CompareFunction::Always, depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState::default(), stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(), bias: wgpu::DepthBiasState::default(),
}); });
@@ -426,7 +427,7 @@ impl Renderer {
})], })],
compilation_options: wgpu::PipelineCompilationOptions::default() compilation_options: wgpu::PipelineCompilationOptions::default()
}), }),
multiview: None, multiview_mask: None,
cache: None, cache: None,
} }
) )
@@ -469,6 +470,9 @@ impl Renderer {
/// The render pass internally keeps all referenced resources alive as long as necessary. /// The render pass internally keeps all referenced resources alive as long as necessary.
/// The only consequence of `forget_lifetime` is that any operation on the parent encoder will cause a runtime error /// The only consequence of `forget_lifetime` is that any operation on the parent encoder will cause a runtime error
/// instead of a compile time error. /// instead of a compile time error.
///
/// # Panic
/// Always ensure that [`Renderer::update_buffers`] has been called otherwise calling [`Renderer::render`] will panic!
pub fn render( pub fn render(
&self, &self,
render_pass: &mut wgpu::RenderPass<'static>, render_pass: &mut wgpu::RenderPass<'static>,
@@ -513,8 +517,12 @@ impl Renderer {
// Skip rendering zero-sized clip areas. // Skip rendering zero-sized clip areas.
if let Primitive::Mesh(_) = primitive { if let Primitive::Mesh(_) = primitive {
// If this is a mesh, we need to advance the index and vertex buffer iterators: // If this is a mesh, we need to advance the index and vertex buffer iterators:
index_buffer_slices.next().unwrap(); index_buffer_slices
vertex_buffer_slices.next().unwrap(); .next()
.expect("You must call .update_buffers() before .render()");
vertex_buffer_slices
.next()
.expect("You must call .update_buffers() before .render()");
} }
continue; continue;
} }
@@ -524,8 +532,12 @@ impl Renderer {
match primitive { match primitive {
Primitive::Mesh(mesh) => { Primitive::Mesh(mesh) => {
let index_buffer_slice = index_buffer_slices.next().unwrap(); let index_buffer_slice = index_buffer_slices
let vertex_buffer_slice = vertex_buffer_slices.next().unwrap(); .next()
.expect("You must call .update_buffers() before .render()");
let vertex_buffer_slice = vertex_buffer_slices
.next()
.expect("You must call .update_buffers() before .render()");
if let Some(Texture { bind_group, .. }) = self.textures.get(&mesh.texture_id) { if let Some(Texture { bind_group, .. }) = self.textures.get(&mesh.texture_id) {
render_pass.set_bind_group(1, bind_group, &[]); render_pass.set_bind_group(1, bind_group, &[]);
@@ -951,6 +963,7 @@ impl Renderer {
let index_buffer_staging = queue.write_buffer_with( let index_buffer_staging = queue.write_buffer_with(
&self.index_buffer.buffer, &self.index_buffer.buffer,
0, 0,
#[expect(clippy::unwrap_used)] // Checked above
NonZeroU64::new(required_index_buffer_size).unwrap(), NonZeroU64::new(required_index_buffer_size).unwrap(),
); );
@@ -968,7 +981,8 @@ impl Renderer {
Primitive::Mesh(mesh) => { Primitive::Mesh(mesh) => {
let size = mesh.indices.len() * std::mem::size_of::<u32>(); let size = mesh.indices.len() * std::mem::size_of::<u32>();
let slice = index_offset..(size + index_offset); let slice = index_offset..(size + index_offset);
index_buffer_staging[slice.clone()] index_buffer_staging
.slice(slice.clone())
.copy_from_slice(bytemuck::cast_slice(&mesh.indices)); .copy_from_slice(bytemuck::cast_slice(&mesh.indices));
self.index_buffer.slices.push(slice); self.index_buffer.slices.push(slice);
index_offset += size; index_offset += size;
@@ -994,6 +1008,7 @@ impl Renderer {
let vertex_buffer_staging = queue.write_buffer_with( let vertex_buffer_staging = queue.write_buffer_with(
&self.vertex_buffer.buffer, &self.vertex_buffer.buffer,
0, 0,
#[expect(clippy::unwrap_used)] // Checked above
NonZeroU64::new(required_vertex_buffer_size).unwrap(), NonZeroU64::new(required_vertex_buffer_size).unwrap(),
); );
@@ -1011,7 +1026,8 @@ impl Renderer {
Primitive::Mesh(mesh) => { Primitive::Mesh(mesh) => {
let size = mesh.vertices.len() * std::mem::size_of::<Vertex>(); let size = mesh.vertices.len() * std::mem::size_of::<Vertex>();
let slice = vertex_offset..(size + vertex_offset); let slice = vertex_offset..(size + vertex_offset);
vertex_buffer_staging[slice.clone()] vertex_buffer_staging
.slice(slice.clone())
.copy_from_slice(bytemuck::cast_slice(&mesh.vertices)); .copy_from_slice(bytemuck::cast_slice(&mesh.vertices));
self.vertex_buffer.slices.push(slice); self.vertex_buffer.slices.push(slice);
vertex_offset += size; vertex_offset += size;

View File

@@ -1,5 +1,43 @@
use std::sync::Arc; use std::sync::Arc;
/// A cloneable display handle for use with [`wgpu::InstanceDescriptor`].
///
/// [`wgpu::InstanceDescriptor`] stores its display handle as a non-cloneable
/// `Box<dyn WgpuHasDisplayHandle>`. This trait wraps it so it can be cloned
/// alongside the rest of the egui wgpu configuration.
///
/// Automatically implemented for all types that satisfy the bounds
/// (including [`winit::event_loop::OwnedDisplayHandle`]).
pub trait EguiDisplayHandle:
wgpu::rwh::HasDisplayHandle + std::fmt::Debug + Send + Sync + 'static
{
/// Clone into a `Box<dyn WgpuHasDisplayHandle>` for [`wgpu::InstanceDescriptor::display`].
fn clone_for_wgpu(&self) -> Box<dyn wgpu::wgt::WgpuHasDisplayHandle>;
/// Clone into a new `Box<dyn EguiDisplayHandle>`.
fn clone_display_handle(&self) -> Box<dyn EguiDisplayHandle>;
}
impl Clone for Box<dyn EguiDisplayHandle> {
fn clone(&self) -> Self {
// We need to deref here, otherwise this causes infinite recursion stack overflow.
(**self).clone_display_handle()
}
}
impl<T> EguiDisplayHandle for T
where
T: wgpu::rwh::HasDisplayHandle + Clone + std::fmt::Debug + Send + Sync + 'static,
{
fn clone_for_wgpu(&self) -> Box<dyn wgpu::wgt::WgpuHasDisplayHandle> {
Box::new(self.clone())
}
fn clone_display_handle(&self) -> Box<dyn EguiDisplayHandle> {
Box::new(self.clone())
}
}
#[derive(Clone)] #[derive(Clone)]
pub enum WgpuSetup { pub enum WgpuSetup {
/// Construct a wgpu setup using some predefined settings & heuristics. /// Construct a wgpu setup using some predefined settings & heuristics.
@@ -22,9 +60,19 @@ pub enum WgpuSetup {
Existing(WgpuSetupExisting), Existing(WgpuSetupExisting),
} }
impl Default for WgpuSetup { impl WgpuSetup {
fn default() -> Self { /// Creates a new [`WgpuSetup::CreateNew`] with the given display handle.
Self::CreateNew(WgpuSetupCreateNew::default()) ///
/// See [`WgpuSetupCreateNew::from_display_handle`] for details.
pub fn from_display_handle(display_handle: impl EguiDisplayHandle) -> Self {
Self::CreateNew(WgpuSetupCreateNew::from_display_handle(display_handle))
}
/// Creates a new [`WgpuSetup::CreateNew`] without a display handle.
///
/// See [`WgpuSetupCreateNew::without_display_handle`] for details.
pub fn without_display_handle() -> Self {
Self::CreateNew(WgpuSetupCreateNew::without_display_handle())
} }
} }
@@ -65,8 +113,18 @@ impl WgpuSetup {
} }
log::debug!("Creating wgpu instance with backends {backends:?}"); log::debug!("Creating wgpu instance with backends {backends:?}");
wgpu::util::new_instance_with_webgpu_detection(&create_new.instance_descriptor) let desc = &create_new.instance_descriptor;
.await let descriptor = wgpu::InstanceDescriptor {
backends: desc.backends,
flags: desc.flags,
backend_options: desc.backend_options.clone(),
memory_budget_thresholds: desc.memory_budget_thresholds,
display: create_new
.display_handle
.as_ref()
.map(|handle| handle.clone_for_wgpu()),
};
wgpu::util::new_instance_with_webgpu_detection(descriptor).await
} }
Self::Existing(existing) => existing.instance.clone(), Self::Existing(existing) => existing.instance.clone(),
} }
@@ -98,18 +156,35 @@ pub type NativeAdapterSelectorMethod = Arc<
/// Configuration for creating a new wgpu setup. /// Configuration for creating a new wgpu setup.
/// ///
/// Used for [`WgpuSetup::CreateNew`]. /// Used for [`WgpuSetup::CreateNew`].
pub struct WgpuSetupCreateNew {
/// Instance descriptor for creating a wgpu instance.
/// ///
/// The most important field is [`wgpu::InstanceDescriptor::backends`], which /// Prefer [`Self::from_display_handle`] when you have a display handle available.
/// controls which backends are supported (wgpu will pick one of these). /// Most platforms work without one, but some (e.g. Wayland with GLES, or WebGL)
/// If you only want to support WebGL (and not WebGPU), /// require it, so providing one ensures maximum compatibility.
/// you can set this to [`wgpu::Backends::GL`]. /// With winit, pass [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle).
/// By default on web, WebGPU will be used if available. ///
/// WebGL will only be used as a fallback, /// Note: The display handle is stored in [`Self::display_handle`] rather than in
/// and only if you have enabled the `webgl` feature of crate `wgpu`. /// [`Self::instance_descriptor`] so the config can be cloned
/// ([`wgpu::InstanceDescriptor`] is not `Clone`). It is injected at instance creation time.
pub struct WgpuSetupCreateNew {
/// Descriptor for the wgpu instance.
///
/// Leave [`wgpu::InstanceDescriptor::display`] as `None` — use [`Self::display_handle`]
/// instead (injected at instance creation time).
///
/// The most important field is [`wgpu::InstanceDescriptor::backends`], which controls
/// which backends are supported (wgpu will pick one of these). For example, set it to
/// [`wgpu::Backends::GL`] to use only WebGL. By default on web, WebGPU is preferred
/// with WebGL as a fallback (requires the `webgl` feature of crate `wgpu`).
pub instance_descriptor: wgpu::InstanceDescriptor, pub instance_descriptor: wgpu::InstanceDescriptor,
/// Display handle passed to wgpu at instance creation time.
///
/// Required on some platforms (e.g. Wayland with GLES, WebGL); optional elsewhere.
/// With winit, use [`winit::event_loop::OwnedDisplayHandle`].
///
/// `eframe` 's winit & web integrations will attempt to fill the display handle automatically if it is left empty.
pub display_handle: Option<Box<dyn EguiDisplayHandle>>,
/// Power preference for the adapter if [`Self::native_adapter_selector`] is not set or targeting web. /// Power preference for the adapter if [`Self::native_adapter_selector`] is not set or targeting web.
pub power_preference: wgpu::PowerPreference, pub power_preference: wgpu::PowerPreference,
@@ -128,32 +203,37 @@ pub struct WgpuSetupCreateNew {
Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>, Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>,
} }
impl Clone for WgpuSetupCreateNew { impl WgpuSetupCreateNew {
fn clone(&self) -> Self { /// Creates a new configuration with the given display handle.
///
/// This is the recommended constructor. Most platforms (Windows, macOS/iOS, Android, web)
/// work fine without a display handle, but some (e.g. Wayland on Linux with GLES) require
/// one. Providing it unconditionally ensures your app works everywhere.
///
/// If you don't have a display handle available, use [`Self::without_display_handle`]
/// instead — it will still work on the majority of platforms.
///
/// With winit, pass [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle).
pub fn from_display_handle(display_handle: impl EguiDisplayHandle) -> Self {
Self { Self {
instance_descriptor: self.instance_descriptor.clone(), display_handle: Some(Box::new(display_handle)),
power_preference: self.power_preference, ..Self::without_display_handle()
native_adapter_selector: self.native_adapter_selector.clone(),
device_descriptor: Arc::clone(&self.device_descriptor),
}
}
}
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(),
)
.finish()
} }
} }
impl Default for WgpuSetupCreateNew { /// Creates a new configuration without a display handle.
fn default() -> Self { ///
/// A display handle is not required for headless operation (offscreen rendering, tests,
/// compute-only workloads). It also isn't needed on most platforms even when presenting
/// to a window — only some configurations (e.g. Wayland on Linux with GLES) require one.
///
/// If you do have a display handle available, prefer [`Self::from_display_handle`] for
/// maximum compatibility.
///
/// With winit you can obtain one via [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle).
///
/// `eframe` 's winit & web integrations will attempt to fill the display handle automatically if it is left empty.
pub fn without_display_handle() -> Self {
Self { Self {
instance_descriptor: wgpu::InstanceDescriptor { instance_descriptor: wgpu::InstanceDescriptor {
// Add GL backend, primarily because WebGPU is not stable enough yet. // Add GL backend, primarily because WebGPU is not stable enough yet.
@@ -163,8 +243,11 @@ impl Default for WgpuSetupCreateNew {
flags: wgpu::InstanceFlags::from_build_config().with_env(), flags: wgpu::InstanceFlags::from_build_config().with_env(),
backend_options: wgpu::BackendOptions::from_env_or_default(), backend_options: wgpu::BackendOptions::from_env_or_default(),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
display: None,
}, },
display_handle: None,
power_preference: wgpu::PowerPreference::from_env() power_preference: wgpu::PowerPreference::from_env()
.unwrap_or(wgpu::PowerPreference::HighPerformance), .unwrap_or(wgpu::PowerPreference::HighPerformance),
@@ -192,6 +275,39 @@ impl Default for WgpuSetupCreateNew {
} }
} }
impl Clone for WgpuSetupCreateNew {
fn clone(&self) -> Self {
let desc = &self.instance_descriptor;
Self {
instance_descriptor: wgpu::InstanceDescriptor {
backends: desc.backends,
flags: desc.flags,
backend_options: desc.backend_options.clone(),
memory_budget_thresholds: desc.memory_budget_thresholds,
display: None,
},
display_handle: self.display_handle.clone(),
power_preference: self.power_preference,
native_adapter_selector: self.native_adapter_selector.clone(),
device_descriptor: Arc::clone(&self.device_descriptor),
}
}
}
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("display_handle", &self.display_handle)
.field("power_preference", &self.power_preference)
.field(
"native_adapter_selector",
&self.native_adapter_selector.is_some(),
)
.finish()
}
}
/// Configuration for using an existing wgpu setup. /// Configuration for using an existing wgpu setup.
/// ///
/// Used for [`WgpuSetup::Existing`]. /// Used for [`WgpuSetup::Existing`].

View File

@@ -17,6 +17,7 @@ struct SurfaceState {
width: u32, width: u32,
height: u32, height: u32,
resizing: bool, resizing: bool,
needs_reconfigure: bool,
} }
/// Everything you need to paint egui with [`wgpu`] on [`winit`]. /// Everything you need to paint egui with [`wgpu`] on [`winit`].
@@ -234,6 +235,7 @@ impl Painter {
height: size.height, height: size.height,
alpha_mode, alpha_mode,
resizing: false, resizing: false,
needs_reconfigure: false,
}, },
); );
let Some(width) = NonZeroU32::new(size.width) else { let Some(width) = NonZeroU32::new(size.width) else {
@@ -362,14 +364,13 @@ impl Painter {
#[cfg(all(target_os = "macos", feature = "macos-window-resize-jitter-fix"))] #[cfg(all(target_os = "macos", feature = "macos-window-resize-jitter-fix"))]
{ {
// SAFETY: The cast is checked with if condition. If the used backend is not metal // SAFETY: The cast is checked with if condition. If the used backend is not metal
// it gracefully fails. The pointer casts are valid as it's 1-to-1 type mapping. // it gracefully fails.
// This is how wgpu currently exposes this backend-specific flag.
unsafe { unsafe {
if let Some(hal_surface) = state.surface.as_hal::<wgpu::hal::api::Metal>() { if let Some(hal_surface) = state.surface.as_hal::<wgpu::hal::api::Metal>() {
let raw = hal_surface
std::ptr::from_ref::<wgpu::hal::metal::Surface>(&*hal_surface).cast_mut(); .render_layer()
.lock()
(*raw).present_with_transaction = resizing; .setPresentsWithTransaction(resizing);
Self::configure_surface( Self::configure_surface(
state, state,
@@ -421,13 +422,41 @@ impl Painter {
) -> f32 { ) -> f32 {
profiling::function_scope!(); profiling::function_scope!();
/// Guard to ensure that commands are always submitted to the renderer queue
/// so that calls to [`write_buffer()`](https://docs.rs/wgpu/latest/wgpu/struct.Queue.html#method.write_buffer)
/// are completed even if we take a codepath which doesn't submit commands and avoids
/// internal buffers growing indefinitely.
///
/// This may happen, for example, if no output frame is resolved.
/// See <https://github.com/emilk/egui/pull/7928> for full context.
struct RendererQueueGuard<'q> {
queue: &'q wgpu::Queue,
commands_submitted: bool,
}
impl Drop for RendererQueueGuard<'_> {
fn drop(&mut self) {
// Only submit an empty command buffer array if no commands were
// explicitly submitted.
if !self.commands_submitted {
self.queue.submit([]);
}
}
}
let capture = !capture_data.is_empty(); let capture = !capture_data.is_empty();
let mut vsync_sec = 0.0; let mut vsync_sec = 0.0;
let Some(render_state) = self.render_state.as_mut() else { let Some(render_state) = self.render_state.as_mut() else {
return vsync_sec; return vsync_sec;
}; };
let Some(surface_state) = self.surfaces.get(&viewport_id) else {
let mut render_queue_guard = RendererQueueGuard {
queue: &render_state.queue,
commands_submitted: false,
};
let Some(surface_state) = self.surfaces.get_mut(&viewport_id) else {
return vsync_sec; return vsync_sec;
}; };
@@ -464,6 +493,11 @@ impl Painter {
) )
}; };
if surface_state.needs_reconfigure {
Self::configure_surface(surface_state, render_state, &self.configuration);
surface_state.needs_reconfigure = false;
}
let output_frame = { let output_frame = {
profiling::scope!("get_current_texture"); profiling::scope!("get_current_texture");
// This is what vsync-waiting happens on my Mac. // This is what vsync-waiting happens on my Mac.
@@ -474,16 +508,20 @@ impl Painter {
}; };
let output_frame = match output_frame { let output_frame = match output_frame {
Ok(frame) => frame, wgpu::CurrentSurfaceTexture::Success(frame) => frame,
Err(err) => match (*self.configuration.on_surface_error)(err) { wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
surface_state.needs_reconfigure = true;
frame
}
other => {
match (*self.configuration.on_surface_status)(&other) {
SurfaceErrorAction::RecreateSurface => { SurfaceErrorAction::RecreateSurface => {
Self::configure_surface(surface_state, render_state, &self.configuration); Self::configure_surface(surface_state, render_state, &self.configuration);
}
SurfaceErrorAction::SkipFrame => {}
}
return vsync_sec; return vsync_sec;
} }
SurfaceErrorAction::SkipFrame => {
return vsync_sec;
}
},
}; };
let mut capture_buffer = None; let mut capture_buffer = None;
@@ -554,6 +592,7 @@ impl Painter {
}), }),
timestamp_writes: None, timestamp_writes: None,
occlusion_query_set: None, occlusion_query_set: None,
multiview_mask: None,
}); });
// Forgetting the pass' lifetime means that we are no longer compile-time protected from // Forgetting the pass' lifetime means that we are no longer compile-time protected from
@@ -590,6 +629,9 @@ impl Painter {
vsync_sec += start.elapsed().as_secs_f32(); vsync_sec += start.elapsed().as_secs_f32();
}; };
// Ensure that the queue guard does not do unnecessary work when dropped
render_queue_guard.commands_submitted = true;
// Free textures marked for destruction **after** queue submit since they might still be used in the current frame. // Free textures marked for destruction **after** queue submit since they might still be used in the current frame.
// Calling `wgpu::Texture::destroy` on a texture that is still in use would invalidate the command buffer(s) it is used in. // Calling `wgpu::Texture::destroy` on a texture that is still in use would invalidate the command buffer(s) it is used in.
// However, once we called `wgpu::Queue::submit`, it is up for wgpu to determine how long the underlying gpu resource has to live. // However, once we called `wgpu::Queue::submit`, it is up for wgpu to determine how long the underlying gpu resource has to live.

View File

@@ -11,7 +11,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui/tree/main/crates/egui-winit" repository = "https://github.com/emilk/egui/tree/main/crates/egui-winit"
categories = ["gui", "game-development"] categories = ["gui", "game-development"]
keywords = ["winit", "egui", "gui", "gamedev"] keywords = ["winit", "egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[lints] [lints]
workspace = true workspace = true
@@ -81,6 +81,7 @@ objc2.workspace = true
objc2-foundation = { workspace = true, features = ["std", "NSThread"] } objc2-foundation = { workspace = true, features = ["std", "NSThread"] }
objc2-ui-kit = { workspace = true, features = [ objc2-ui-kit = { workspace = true, features = [
"std", "std",
"objc2-core-foundation",
"UIApplication", "UIApplication",
"UIGeometry", "UIGeometry",
"UIResponder", "UIResponder",

View File

@@ -65,13 +65,12 @@ impl Clipboard {
feature = "smithay-clipboard" feature = "smithay-clipboard"
))] ))]
if let Some(clipboard) = &mut self.smithay { if let Some(clipboard) = &mut self.smithay {
return match clipboard.load() { match clipboard.load() {
Ok(text) => Some(text), Ok(text) => return Some(text),
Err(err) => { Err(err) => {
log::error!("smithay paste error: {err}"); log::error!("smithay paste error: {err}");
None
} }
}; }
} }
#[cfg(all( #[cfg(all(

View File

@@ -9,6 +9,9 @@
#![expect(clippy::manual_range_contains)] #![expect(clippy::manual_range_contains)]
#[cfg(target_os = "windows")]
use std::collections::HashSet;
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
pub use accesskit_winit; pub use accesskit_winit;
pub use egui; pub use egui;
@@ -102,10 +105,16 @@ pub struct State {
has_sent_ime_enabled: bool, has_sent_ime_enabled: bool,
#[cfg(feature = "accesskit")] #[cfg(feature = "accesskit")]
accesskit: Option<accesskit_winit::Adapter>, pub accesskit: Option<accesskit_winit::Adapter>,
allow_ime: bool, allow_ime: bool,
ime_rect_px: Option<egui::Rect>, ime_rect_px: Option<egui::Rect>,
/// Used by [`State::try_on_ime_processed_keyboard_input`] to track key
/// release events that should be filtered out. See comments in that method
/// for details.
#[cfg(target_os = "windows")]
pressed_processed_physical_keys: HashSet<winit::keyboard::PhysicalKey>,
} }
impl State { impl State {
@@ -148,6 +157,8 @@ impl State {
allow_ime: false, allow_ime: false,
ime_rect_px: None, ime_rect_px: None,
#[cfg(target_os = "windows")]
pressed_processed_physical_keys: HashSet::new(),
}; };
slf.egui_input slf.egui_input
@@ -364,20 +375,27 @@ impl State {
is_synthetic, is_synthetic,
.. ..
} => { } => {
if *is_synthetic && event.state == ElementState::Pressed {
// Winit generates fake "synthetic" KeyboardInput events when the focus // Winit generates fake "synthetic" KeyboardInput events when the focus
// is changed to the window, or away from it. Synthetic key presses // is changed to the window, or away from it. Synthetic key presses
// represent no real key presses and should be ignored. // represent no real key presses and should be ignored.
// See https://github.com/rust-windowing/winit/issues/3543 // See https://github.com/rust-windowing/winit/issues/3543
if *is_synthetic && event.state == ElementState::Pressed {
EventResponse { EventResponse {
repaint: true, repaint: true,
consumed: false, consumed: false,
} }
} else {
let egui_wants_keyboard_input = self.egui_ctx.egui_wants_keyboard_input();
if let Some(response) =
self.try_on_ime_processed_keyboard_input(event, egui_wants_keyboard_input)
{
response
} else { } else {
self.on_keyboard_input(event); self.on_keyboard_input(event);
// When pressing the Tab key, egui focuses the first focusable element, hence Tab always consumes. // When pressing the Tab key, egui focuses the first focusable element, hence Tab always consumes.
let consumed = self.egui_ctx.egui_wants_keyboard_input() let consumed = egui_wants_keyboard_input
|| event.logical_key || event.logical_key
== winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab); == winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab);
EventResponse { EventResponse {
@@ -386,6 +404,7 @@ impl State {
} }
} }
} }
}
WindowEvent::Focused(focused) => { WindowEvent::Focused(focused) => {
let focused = if cfg!(target_os = "macos") { let focused = if cfg!(target_os = "macos") {
// TODO(emilk): remove this work-around once we update winit // TODO(emilk): remove this work-around once we update winit
@@ -526,6 +545,91 @@ impl State {
} }
} }
#[cfg(not(target_os = "windows"))]
#[expect(clippy::unused_self, clippy::needless_pass_by_ref_mut)]
#[inline(always)]
fn try_on_ime_processed_keyboard_input(
&mut self,
_event: &winit::event::KeyEvent,
_egui_wants_keyboard_input: bool,
) -> Option<EventResponse> {
// `KeyboardInput` events processed by the IME are not emitted by
// `winit` on non-Windows platforms, so we don't need to do anything
// here.
None
}
#[cfg(target_os = "windows")]
#[inline(always)]
fn try_on_ime_processed_keyboard_input(
&mut self,
event: &winit::event::KeyEvent,
egui_wants_keyboard_input: bool,
) -> Option<EventResponse> {
if !self.allow_ime {
None
} else if event.logical_key == winit::keyboard::NamedKey::Process {
// On Windows, the current version of `winit` (0.30.12) has a bug
// where `KeyboardInput` events processed by the IME are still
// emitted. [^1]
//
// As a workaround, we detect these events by checking whether their
// `logical_key` is `winit::keyboard::NamedKey::Process`, and filter
// them out to keep behavior consistent with other platforms.
//
// `winit::keyboard::NamedKey::Process` is not documented in
// `winit`. Reading through its source code, we find that it is
// mapped from `VK_PROCESSKEY` on Windows [^2]. (On an unrelated
// note, Web is the only other platform that also uses it [^3].)
// According to Microsoft, “the IME sets the virtual key value
// to `VK_PROCESSKEY` after processing a key input message” [^4].
// See also [^5].
// (I can't find a documentation page dedicated to this value.)
//
// TODO(umajho): Remove this workaround once the `winit` bug is fixed
// and we've updated to a version that includes the fix. NOTE: Don't
// forget to also remove the `pressed_processed_physical_keys` field
// and its related code.
//
// [^1]: https://github.com/rust-windowing/winit/issues/4508
// [^2]: https://github.com/rust-windowing/winit/blob/e9809ef54b18499bb4f2cac945719ecc2a61061b/src/platform_impl/windows/keyboard_layout.rs#L946
// [^3]: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
// [^4]: https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetvirtualkey#remarks
// [^5]: https://learn.microsoft.com/en-us/windows/win32/learnwin32/keyboard-input#character-messages
self.pressed_processed_physical_keys
.insert(event.physical_key);
Some(EventResponse {
repaint: false,
consumed: egui_wants_keyboard_input,
})
} else if event.state == ElementState::Released
&& self
.pressed_processed_physical_keys
.remove(&event.physical_key)
{
// Unlike key-presses, we can not tell whether a key-release event
// is processed by the IME or not by looking at its `logical_key`,
// because their `logical_key` is the original value (e.g.
// `winit::keyboard::Key::Character(…)`) rather than
// `winit::keyboard::Key::Named(winit::keyboard::NamedKey::Process)`.
// (See the screencast for Windows in [^1].)
// So we track the physical keys of processed key-presses and
// filter out the corresponding key-releases.
//
// [^1]: https://github.com/rust-windowing/winit/issues/4508
Some(EventResponse {
repaint: false,
consumed: egui_wants_keyboard_input,
})
} else {
None
}
}
/// ## NOTE /// ## NOTE
/// ///
/// on Mac even Cmd-C is pressed during ime, a `c` is pushed to Preedit. /// on Mac even Cmd-C is pressed during ime, a `c` is pushed to Preedit.
@@ -548,23 +652,23 @@ impl State {
/// ///
/// | Setup | Events in Order | /// | Setup | Events in Order |
/// | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | /// | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
/// | a-macos15-apple_shuangpin | `Predict("", None)` -> `Commit("测试")` | /// | a-macos15-apple_shuangpin | `Preedit("", None)` -> `Commit("测试")` |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", None)` -> `Commit("测试")` -> `Predict("", Some(0, 0))` -> `Predict("", None)` (duplicate until `TextEdit` blurred) | /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", None)` -> `Commit("测试")` -> `Preedit("", Some(0, 0))` -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
/// | c-windows11-ms_pinyin | `Predict("测试", Some(…))` -> `Predict("", None)` -> `Commit("测试")` -> `Disabled` | /// | c-windows11-ms_pinyin | `Preedit("测试", Some(…))` -> `Preedit("", None)` -> `Commit("测试")` -> `Disabled` |
/// ///
/// #### Situation: pressed backspace to delete the last character in the prediction /// #### Situation: pressed backspace to delete the last character in the composition
/// ///
/// | Setup | Events in Order | /// | Setup | Events in Order |
/// | a-macos15-apple_shuangpin | `Predict("", None)` | /// | a-macos15-apple_shuangpin | `Preedit("", None)` |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", Some(0, 0))` -> `Predict("", None)` (duplicate until `TextEdit` blurred) | /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", Some(0, 0))` -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
/// | c-windows11-ms_pinyin | `Predict("", Some(0, 0))` -> `Predict("", None)` -> `Commit("")` -> `Disabled` | /// | c-windows11-ms_pinyin | `Preedit("", Some(0, 0))` -> `Preedit("", None)` -> `Commit("")` -> `Disabled` |
/// ///
/// #### Situation: clicked somewhere else while there is an active composition with the prediction "ce" /// #### Situation: clicked somewhere else while there is an active composition with the pre-edit text "ce"
/// ///
/// | Setup | Events in Order | /// | Setup | Events in Order |
/// | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | /// | ------------------------------------------- | ------------------------------------------------------------------------------------------------- |
/// | a-macos15-apple_shuangpin | nothing emitted | /// | a-macos15-apple_shuangpin | nothing emitted |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", Some(0, 0))` (duplicate) -> `Predict("", None)` (duplicate until `TextEdit` blurred) | /// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", Some(0, 0))` (duplicate) -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
/// | c-windows11-ms_pinyin | nothing emitted | /// | c-windows11-ms_pinyin | nothing emitted |
fn on_ime(&mut self, ime: &winit::event::Ime) { fn on_ime(&mut self, ime: &winit::event::Ime) {
// // code for inspecting ime events emitted by winit: // // code for inspecting ime events emitted by winit:
@@ -610,15 +714,26 @@ impl State {
self.ime_event_disable(); self.ime_event_disable();
} }
winit::event::Ime::Preedit(_, None) => { winit::event::Ime::Preedit(_, None) => {
// we need to emit this on macOS, since winit doesn't emit if cfg!(target_os = "macos") {
// `Predict("", Some(0, 0))` before this event on macOS when the // On macOS, when the user presses backspace to delete the
// user deletes the last character in the prediction with the // last character in an IME composition, `winit` only emits
// backspace key. Without this, only `egui::ImeEvent::Disabled` // `winit::event::Ime::Preedit("", None)` without a
// is emitted here, leading to the last character being left in // preceding `winit::event::Ime::Preedit("", Some(0, 0))`.
// TextEdit in such situation. //
// The current implementation of `egui::TextEdit` relies on
// receiving an `egui::ImeEvent::Preedit("")` to remove the
// last character in the composition in this case, so we
// emit it here.
//
// This is guarded to macOS-only, as applying it on other
// platforms is unnecessary and can cause undesired
// behavior.
// See: https://github.com/emilk/egui/pull/7973
self.egui_input self.egui_input
.events .events
.push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new()))); .push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new())));
}
self.ime_event_disable(); self.ime_event_disable();
} }
} }
@@ -640,11 +755,27 @@ impl State {
self.has_sent_ime_enabled = false; self.has_sent_ime_enabled = false;
} }
pub fn on_mouse_motion(&mut self, delta: (f64, f64)) { /// Returns `true` if the event was sent to egui.
pub fn on_mouse_motion(&mut self, delta: (f64, f64)) -> bool {
if !self.is_pointer_in_window() && !self.any_pointer_button_down {
return false;
}
self.egui_input.events.push(egui::Event::MouseMoved(Vec2 { self.egui_input.events.push(egui::Event::MouseMoved(Vec2 {
x: delta.0 as f32, x: delta.0 as f32,
y: delta.1 as f32, y: delta.1 as f32,
})); }));
true
}
/// Returns `true` when the pointer is currently inside the window.
pub fn is_pointer_in_window(&self) -> bool {
self.pointer_pos_in_points.is_some()
}
/// Returns `true` if any pointer button is currently held down.
pub fn is_any_pointer_button_down(&self) -> bool {
self.any_pointer_button_down
} }
/// Call this when there is a new [`accesskit::ActionRequest`]. /// Call this when there is a new [`accesskit::ActionRequest`].
@@ -974,6 +1105,16 @@ impl State {
let allow_ime = ime.is_some(); let allow_ime = ime.is_some();
if self.allow_ime != allow_ime { if self.allow_ime != allow_ime {
self.allow_ime = allow_ime; self.allow_ime = allow_ime;
#[cfg(target_os = "windows")]
if !self.allow_ime {
// Defensively clear the set to avoid unexpected behavior.
//
// We don't do the same in `ime_event_disable` because the key
// release events for IME confirmation keys arrive after
// `winit::event::Ime::Disabled`.
self.pressed_processed_physical_keys.clear();
}
profiling::scope!("set_ime_allowed"); profiling::scope!("set_ime_allowed");
window.set_ime_allowed(allow_ime); window.set_ime_allowed(allow_ime);
} }

View File

@@ -36,8 +36,8 @@ mod ios {
| UISceneActivationState::ForegroundInactive | UISceneActivationState::ForegroundInactive
) )
{ {
// Safe to cast, the class kind was checked above // SAFETY: class kind was checked above with `isKindOfClass`
let window_scene = Retained::cast::<UIWindowScene>(scene.clone()); let window_scene = Retained::cast_unchecked::<UIWindowScene>(scene.clone());
if let Some(window) = window_scene.keyWindow() { if let Some(window) = window_scene.keyWindow() {
let insets = window.safeAreaInsets(); let insets = window.safeAreaInsets();
return SafeAreaInsets(MarginF32 { return SafeAreaInsets(MarginF32 {

View File

@@ -11,7 +11,7 @@ readme = "../../README.md"
repository = "https://github.com/emilk/egui" repository = "https://github.com/emilk/egui"
categories = ["gui", "game-development"] categories = ["gui", "game-development"]
keywords = ["gui", "imgui", "immediate", "portable", "gamedev"] keywords = ["gui", "imgui", "immediate", "portable", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[lints] [lints]
workspace = true workspace = true

View File

@@ -1,10 +1,24 @@
use crate::{AtomKind, FontSelection, Id, SizedAtom, Ui}; use crate::{AtomKind, FontSelection, Id, IntoSizedArgs, IntoSizedResult, SizedAtom, Ui};
use emath::{NumExt as _, Vec2}; use emath::{Align2, NumExt as _, Vec2};
use epaint::text::TextWrapMode; use epaint::text::TextWrapMode;
/// A low-level ui building block. /// A low-level ui building block.
/// ///
/// Implements [`From`] for [`String`], [`str`], [`crate::Image`] and much more for convenience. /// This can be a piece of text, an image, or even a custom widget.
/// It can be decorated with various layout hints, such as `grow`, `shrink`, `align`, and more.
///
/// `Atom` implements [`From`] for [`String`], [`str`], [`crate::Image`] and much more for convenience.
///
/// Many widgets take an `impl` [`crate::IntoAtoms`] parameter,
/// which allows you to easily create atoms from tuples of text, images, and other atoms:
/// ```
/// # use egui::{Vec2, AtomExt, AtomKind, Atom, Image, Id};
/// # egui::__run_test_ui(|ui| {
/// let image = egui::include_image!("../../../eframe/data/icon.png");
/// ui.button((image, "Click me!"));
/// # });
/// ```
///
/// You can directly call the `atom_*` methods on anything that implements `Into<Atom>`. /// You can directly call the `atom_*` methods on anything that implements `Into<Atom>`.
/// ``` /// ```
/// # use egui::{Image, emath::Vec2}; /// # use egui::{Image, emath::Vec2};
@@ -14,6 +28,9 @@ use epaint::text::TextWrapMode;
/// ``` /// ```
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Atom<'a> { pub struct Atom<'a> {
/// See [`crate::AtomExt::atom_id`]
pub id: Option<Id>,
/// See [`crate::AtomExt::atom_size`] /// See [`crate::AtomExt::atom_size`]
pub size: Option<Vec2>, pub size: Option<Vec2>,
@@ -26,17 +43,22 @@ pub struct Atom<'a> {
/// See [`crate::AtomExt::atom_shrink`] /// See [`crate::AtomExt::atom_shrink`]
pub shrink: bool, pub shrink: bool,
/// The atom type /// See [`crate::AtomExt::atom_align`]
pub align: Align2,
/// The atom type / content
pub kind: AtomKind<'a>, pub kind: AtomKind<'a>,
} }
impl Default for Atom<'_> { impl Default for Atom<'_> {
fn default() -> Self { fn default() -> Self {
Atom { Atom {
id: None,
size: None, size: None,
max_size: Vec2::INFINITY, max_size: Vec2::INFINITY,
grow: false, grow: false,
shrink: false, shrink: false,
align: Align2::CENTER_CENTER,
kind: AtomKind::Empty, kind: AtomKind::Empty,
} }
} }
@@ -54,11 +76,27 @@ impl<'a> Atom<'a> {
} }
} }
/// Create a [`AtomKind::Custom`] with a specific size. /// Create an [`AtomKind::Empty`] with a specific size.
///
/// Example:
/// ```
/// # use egui::{AtomExt, AtomKind, Atom, Button, Id, __run_test_ui};
/// # use emath::Vec2;
/// # __run_test_ui(|ui| {
/// let id = Id::new("my_button");
/// let response = Button::new(("Hi!", Atom::custom(id, Vec2::splat(18.0)))).atom_ui(ui);
///
/// let rect = response.rect(id);
/// if let Some(rect) = rect {
/// ui.place(rect, Button::new("⏵"));
/// }
/// # });
/// ```
pub fn custom(id: Id, size: impl Into<Vec2>) -> Self { pub fn custom(id: Id, size: impl Into<Vec2>) -> Self {
Atom { Atom {
size: Some(size.into()), size: Some(size.into()),
kind: AtomKind::Custom(id), kind: AtomKind::Empty,
id: Some(id),
..Default::default() ..Default::default()
} }
} }
@@ -82,19 +120,32 @@ impl<'a> Atom<'a> {
wrap_mode = Some(TextWrapMode::Truncate); wrap_mode = Some(TextWrapMode::Truncate);
} }
let (intrinsic, kind) = self let id = self.id;
.kind
.into_sized(ui, available_size, wrap_mode, fallback_font); let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
let IntoSizedResult {
intrinsic_size,
sized,
} = self.kind.into_sized(
ui,
IntoSizedArgs {
available_size,
wrap_mode,
fallback_font,
},
);
let size = self let size = self
.size .size
.map_or_else(|| kind.size(), |s| s.at_most(self.max_size)); .map_or_else(|| sized.size(), |s| s.at_most(self.max_size));
SizedAtom { SizedAtom {
id,
size, size,
intrinsic_size: intrinsic.at_least(self.size.unwrap_or_default()), intrinsic_size: intrinsic_size.at_least(self.size.unwrap_or_default()),
grow: self.grow, grow: self.grow,
kind, align: self.align,
kind: sized,
} }
} }
} }

View File

@@ -1,10 +1,16 @@
use crate::{Atom, FontSelection, Ui}; use crate::{Atom, FontSelection, Id, Ui};
use emath::Vec2; use emath::Vec2;
/// A trait for conveniently building [`Atom`]s. /// A trait for conveniently building [`Atom`]s.
/// ///
/// The functions are prefixed with `atom_` to avoid conflicts with e.g. [`crate::RichText::size`]. /// The functions are prefixed with `atom_` to avoid conflicts with e.g. [`crate::RichText::size`].
pub trait AtomExt<'a> { pub trait AtomExt<'a> {
/// Set the [`Id`] for custom rendering.
///
/// You can get the [`crate::Rect`] with the [`Id`] from [`crate::AtomLayoutResponse`] and use a
/// [`crate::Painter`] or [`Ui::place`] to add/draw some custom content.
fn atom_id(self, id: Id) -> Atom<'a>;
/// Set the atom to a fixed size. /// Set the atom to a fixed size.
/// ///
/// If [`Atom::grow`] is `true`, this will be the minimum width. /// If [`Atom::grow`] is `true`, this will be the minimum width.
@@ -63,12 +69,23 @@ pub trait AtomExt<'a> {
let height = ui.fonts_mut(|f| f.row_height(&font_id)); let height = ui.fonts_mut(|f| f.row_height(&font_id));
self.atom_max_height(height) self.atom_max_height(height)
} }
/// Sets the [`emath::Align2`] of a single atom within its available space.
///
/// Defaults to center-center.
fn atom_align(self, align: emath::Align2) -> Atom<'a>;
} }
impl<'a, T> AtomExt<'a> for T impl<'a, T> AtomExt<'a> for T
where where
T: Into<Atom<'a>> + Sized, T: Into<Atom<'a>> + Sized,
{ {
fn atom_id(self, id: Id) -> Atom<'a> {
let mut atom = self.into();
atom.id = Some(id);
atom
}
fn atom_size(self, size: Vec2) -> Atom<'a> { fn atom_size(self, size: Vec2) -> Atom<'a> {
let mut atom = self.into(); let mut atom = self.into();
atom.size = Some(size); atom.size = Some(size);
@@ -104,4 +121,10 @@ where
atom.max_size.y = max_height; atom.max_size.y = max_height;
atom atom
} }
fn atom_align(self, align: emath::Align2) -> Atom<'a> {
let mut atom = self.into();
atom.align = align;
atom
}
} }

View File

@@ -1,9 +1,28 @@
use crate::{FontSelection, Id, Image, ImageSource, SizedAtomKind, Ui, WidgetText}; use crate::{FontSelection, Image, ImageSource, SizedAtomKind, Ui, WidgetText};
use emath::Vec2; use emath::Vec2;
use epaint::text::TextWrapMode; use epaint::text::TextWrapMode;
use std::fmt::Debug;
/// Args passed when sizing an [`super::Atom`]
pub struct IntoSizedArgs {
pub available_size: Vec2,
pub wrap_mode: TextWrapMode,
pub fallback_font: FontSelection,
}
/// Result returned when sizing an [`super::Atom`]
pub struct IntoSizedResult<'a> {
pub intrinsic_size: Vec2,
pub sized: SizedAtomKind<'a>,
}
/// See [`AtomKind::Closure`]
// We need 'static in the result (or need to introduce another lifetime on the enum).
// Otherwise, a single 'static Atom would force the closure to be 'static.
pub type AtomClosure<'a> = Box<dyn FnOnce(&Ui, IntoSizedArgs) -> IntoSizedResult<'static> + 'a>;
/// The different kinds of [`crate::Atom`]s. /// The different kinds of [`crate::Atom`]s.
#[derive(Clone, Default, Debug)] #[derive(Default)]
pub enum AtomKind<'a> { pub enum AtomKind<'a> {
/// Empty, that can be used with [`crate::AtomExt::atom_grow`] to reserve space. /// Empty, that can be used with [`crate::AtomExt::atom_grow`] to reserve space.
#[default] #[default]
@@ -38,37 +57,57 @@ pub enum AtomKind<'a> {
/// default font height, which is convenient for icons. /// default font height, which is convenient for icons.
Image(Image<'a>), Image(Image<'a>),
/// For custom rendering. /// A custom closure that produces a sized atom.
/// ///
/// You can get the [`crate::Rect`] with the [`Id`] from [`crate::AtomLayoutResponse`] and use a /// The vec2 passed in is the available size to this atom. The returned vec2 should be the
/// [`crate::Painter`] or [`Ui::place`] to add/draw some custom content. /// preferred / intrinsic size.
/// ///
/// Example: /// Note: This api is experimental, expect breaking changes here.
/// ``` /// When cloning, this will be cloned as [`AtomKind::Empty`].
/// # use egui::{AtomExt, AtomKind, Atom, Button, Id, __run_test_ui}; Closure(AtomClosure<'a>),
/// # use emath::Vec2; }
/// # __run_test_ui(|ui| {
/// let id = Id::new("my_button"); impl Clone for AtomKind<'_> {
/// let response = Button::new(("Hi!", Atom::custom(id, Vec2::splat(18.0)))).atom_ui(ui); fn clone(&self) -> Self {
/// match self {
/// let rect = response.rect(id); AtomKind::Empty => AtomKind::Empty,
/// if let Some(rect) = rect { AtomKind::Text(text) => AtomKind::Text(text.clone()),
/// ui.place(rect, Button::new("⏵")); AtomKind::Image(image) => AtomKind::Image(image.clone()),
/// } AtomKind::Closure(_) => {
/// # }); log::warn!("Cannot clone atom closures");
/// ``` AtomKind::Empty
Custom(Id), }
}
}
}
impl Debug for AtomKind<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AtomKind::Empty => write!(f, "AtomKind::Empty"),
AtomKind::Text(text) => write!(f, "AtomKind::Text({text:?})"),
AtomKind::Image(image) => write!(f, "AtomKind::Image({image:?})"),
AtomKind::Closure(_) => write!(f, "AtomKind::Closure(<closure>)"),
}
}
} }
impl<'a> AtomKind<'a> { impl<'a> AtomKind<'a> {
/// See [`Self::Text`]
pub fn text(text: impl Into<WidgetText>) -> Self { pub fn text(text: impl Into<WidgetText>) -> Self {
AtomKind::Text(text.into()) AtomKind::Text(text.into())
} }
/// See [`Self::Image`]
pub fn image(image: impl Into<Image<'a>>) -> Self { pub fn image(image: impl Into<Image<'a>>) -> Self {
AtomKind::Image(image.into()) AtomKind::Image(image.into())
} }
/// See [`Self::Closure`]
pub fn closure(func: impl FnOnce(&Ui, IntoSizedArgs) -> IntoSizedResult<'static> + 'a) -> Self {
AtomKind::Closure(Box::new(func))
}
/// Turn this [`AtomKind`] into a [`SizedAtomKind`]. /// Turn this [`AtomKind`] into a [`SizedAtomKind`].
/// ///
/// This converts [`WidgetText`] into [`crate::Galley`] and tries to load and size [`Image`]. /// This converts [`WidgetText`] into [`crate::Galley`] and tries to load and size [`Image`].
@@ -76,23 +115,40 @@ impl<'a> AtomKind<'a> {
pub fn into_sized( pub fn into_sized(
self, self,
ui: &Ui, ui: &Ui,
available_size: Vec2, IntoSizedArgs {
wrap_mode: Option<TextWrapMode>, available_size,
fallback_font: FontSelection, wrap_mode,
) -> (Vec2, SizedAtomKind<'a>) { fallback_font,
}: IntoSizedArgs,
) -> IntoSizedResult<'a> {
match self { match self {
AtomKind::Text(text) => { AtomKind::Text(text) => {
let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
let galley = text.into_galley(ui, Some(wrap_mode), available_size.x, fallback_font); let galley = text.into_galley(ui, Some(wrap_mode), available_size.x, fallback_font);
(galley.intrinsic_size(), SizedAtomKind::Text(galley)) IntoSizedResult {
intrinsic_size: galley.intrinsic_size(),
sized: SizedAtomKind::Text(galley),
}
} }
AtomKind::Image(image) => { AtomKind::Image(image) => {
let size = image.load_and_calc_size(ui, available_size); let size = image.load_and_calc_size(ui, available_size);
let size = size.unwrap_or(Vec2::ZERO); let size = size.unwrap_or(Vec2::ZERO);
(size, SizedAtomKind::Image(image, size)) IntoSizedResult {
intrinsic_size: size,
sized: SizedAtomKind::Image { image, size },
} }
AtomKind::Custom(id) => (Vec2::ZERO, SizedAtomKind::Custom(id)), }
AtomKind::Empty => (Vec2::ZERO, SizedAtomKind::Empty), AtomKind::Empty => IntoSizedResult {
intrinsic_size: Vec2::ZERO,
sized: SizedAtomKind::Empty { size: None },
},
AtomKind::Closure(func) => func(
ui,
IntoSizedArgs {
available_size,
wrap_mode,
fallback_font,
},
),
} }
} }
} }

View File

@@ -38,6 +38,7 @@ pub struct AtomLayout<'a> {
fallback_text_color: Option<Color32>, fallback_text_color: Option<Color32>,
fallback_font: Option<FontSelection>, fallback_font: Option<FontSelection>,
min_size: Vec2, min_size: Vec2,
max_size: Vec2,
wrap_mode: Option<TextWrapMode>, wrap_mode: Option<TextWrapMode>,
align2: Option<Align2>, align2: Option<Align2>,
} }
@@ -59,6 +60,7 @@ impl<'a> AtomLayout<'a> {
fallback_text_color: None, fallback_text_color: None,
fallback_font: None, fallback_font: None,
min_size: Vec2::ZERO, min_size: Vec2::ZERO,
max_size: Vec2::INFINITY,
wrap_mode: None, wrap_mode: None,
align2: None, align2: None,
} }
@@ -113,6 +115,33 @@ impl<'a> AtomLayout<'a> {
self self
} }
/// Set the maximum size of the Widget.
///
/// By default, the size is limited by the available size in the [`Ui`].
#[inline]
pub fn max_size(mut self, size: Vec2) -> Self {
self.max_size = size;
self
}
/// Set the maximum width of the Widget.
///
/// By default, the width is limited by the available width in the [`Ui`].
#[inline]
pub fn max_width(mut self, width: f32) -> Self {
self.max_size.x = width;
self
}
/// Set the maximum height of the Widget.
///
/// By default, the height is limited by the available height in the [`Ui`].
#[inline]
pub fn max_height(mut self, height: f32) -> Self {
self.max_size.y = height;
self
}
/// Set the [`Id`] used to allocate a [`Response`]. /// Set the [`Id`] used to allocate a [`Response`].
#[inline] #[inline]
pub fn id(mut self, id: Id) -> Self { pub fn id(mut self, id: Id) -> Self {
@@ -161,6 +190,7 @@ impl<'a> AtomLayout<'a> {
sense, sense,
fallback_text_color, fallback_text_color,
min_size, min_size,
mut max_size,
wrap_mode, wrap_mode,
align2, align2,
fallback_font, fallback_font,
@@ -173,7 +203,7 @@ impl<'a> AtomLayout<'a> {
// If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`. // If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`.
// If none is found, mark the first text item as `shrink`. // If none is found, mark the first text item as `shrink`.
if wrap_mode != TextWrapMode::Extend { if wrap_mode != TextWrapMode::Extend {
let any_shrink = atoms.iter().any(|a| a.shrink); let any_shrink = atoms.any_shrink();
if !any_shrink { if !any_shrink {
let first_text = atoms let first_text = atoms
.iter_mut() .iter_mut()
@@ -190,8 +220,16 @@ impl<'a> AtomLayout<'a> {
fallback_text_color.unwrap_or_else(|| ui.style().visuals.text_color()); fallback_text_color.unwrap_or_else(|| ui.style().visuals.text_color());
let gap = gap.unwrap_or_else(|| ui.spacing().icon_spacing); let gap = gap.unwrap_or_else(|| ui.spacing().icon_spacing);
// max_size has no effect in justified layouts. If we'd limit the available size here,
// the content would be sized differently than the frame which would look weird.
if ui.layout().horizontal_justify() {
max_size.x = f32::INFINITY;
}
let available_size = ui.available_size().at_most(max_size);
// The size available for the content // The size available for the content
let available_inner_size = ui.available_size() - frame.total_margin().sum(); let available_inner_size = available_size - frame.total_margin().sum();
let mut desired_width = 0.0; let mut desired_width = 0.0;
@@ -280,8 +318,9 @@ impl<'a> AtomLayout<'a> {
let (_, rect) = ui.allocate_space(frame_size); let (_, rect) = ui.allocate_space(frame_size);
let mut response = ui.interact(rect, id, sense); let mut response = ui.interact(rect, id, sense);
response.intrinsic_size = response.set_intrinsic_size(
Some((Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size)); (Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size),
);
AllocatedAtomLayout { AllocatedAtomLayout {
sized_atoms: sized_items, sized_atoms: sized_items,
@@ -321,7 +360,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
pub fn iter_images(&self) -> impl Iterator<Item = &Image<'atom>> { pub fn iter_images(&self) -> impl Iterator<Item = &Image<'atom>> {
self.iter_kinds().filter_map(|kind| { self.iter_kinds().filter_map(|kind| {
if let SizedAtomKind::Image(image, _) = kind { if let SizedAtomKind::Image { image, size: _ } = kind {
Some(image) Some(image)
} else { } else {
None None
@@ -331,7 +370,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
pub fn iter_images_mut(&mut self) -> impl Iterator<Item = &mut Image<'atom>> { pub fn iter_images_mut(&mut self) -> impl Iterator<Item = &mut Image<'atom>> {
self.iter_kinds_mut().filter_map(|kind| { self.iter_kinds_mut().filter_map(|kind| {
if let SizedAtomKind::Image(image, _) = kind { if let SizedAtomKind::Image { image, size: _ } = kind {
Some(image) Some(image)
} else { } else {
None None
@@ -373,8 +412,11 @@ impl<'atom> AllocatedAtomLayout<'atom> {
F: FnMut(Image<'atom>) -> Image<'atom>, F: FnMut(Image<'atom>) -> Image<'atom>,
{ {
self.map_kind(|kind| { self.map_kind(|kind| {
if let SizedAtomKind::Image(image, size) = kind { if let SizedAtomKind::Image { image, size } = kind {
SizedAtomKind::Image(f(image), size) SizedAtomKind::Image {
image: f(image),
size,
}
} else { } else {
kind kind
} }
@@ -422,25 +464,24 @@ impl<'atom> AllocatedAtomLayout<'atom> {
.with_min_x(cursor) .with_min_x(cursor)
.with_max_x(cursor + size.x + growth); .with_max_x(cursor + size.x + growth);
cursor = frame.right() + gap; cursor = frame.right() + gap;
let rect = sized.align.align_size_within_rect(size, frame);
let align = Align2::CENTER_CENTER; if let Some(id) = sized.id {
let rect = align.align_size_within_rect(size, frame);
match sized.kind {
SizedAtomKind::Text(galley) => {
ui.painter().galley(rect.min, galley, fallback_text_color);
}
SizedAtomKind::Image(image, _) => {
image.paint_at(ui, rect);
}
SizedAtomKind::Custom(id) => {
debug_assert!( debug_assert!(
!response.custom_rects.iter().any(|(i, _)| *i == id), !response.custom_rects.iter().any(|(i, _)| *i == id),
"Duplicate custom id" "Duplicate custom id"
); );
response.custom_rects.push((id, rect)); response.custom_rects.push((id, rect));
} }
SizedAtomKind::Empty => {}
match sized.kind {
SizedAtomKind::Text(galley) => {
ui.painter().galley(rect.min, galley, fallback_text_color);
}
SizedAtomKind::Image { image, size: _ } => {
image.paint_at(ui, rect);
}
SizedAtomKind::Empty { .. } => {}
} }
} }
@@ -450,7 +491,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
/// Response from a [`AtomLayout::show`] or [`AllocatedAtomLayout::paint`]. /// Response from a [`AtomLayout::show`] or [`AllocatedAtomLayout::paint`].
/// ///
/// Use [`AtomLayoutResponse::rect`] to get the response rects from [`AtomKind::Custom`]. /// Use [`AtomLayoutResponse::rect`] to get the response rects from [`crate::Atom::custom`].
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AtomLayoutResponse { pub struct AtomLayoutResponse {
pub response: Response, pub response: Response,
@@ -470,7 +511,7 @@ impl AtomLayoutResponse {
self.custom_rects.iter().copied() self.custom_rects.iter().copied()
} }
/// Use this together with [`AtomKind::Custom`] to add custom painting / child widgets. /// Use this together with [`crate::Atom::custom`] to add custom painting / child widgets.
/// ///
/// NOTE: Don't `unwrap` rects, they might be empty when the widget is not visible. /// NOTE: Don't `unwrap` rects, they might be empty when the widget is not visible.
pub fn rect(&self, id: Id) -> Option<Rect> { pub fn rect(&self, id: Id) -> Option<Rect> {
@@ -480,6 +521,20 @@ impl AtomLayoutResponse {
} }
} }
impl Deref for AtomLayoutResponse {
type Target = Response;
fn deref(&self) -> &Self::Target {
&self.response
}
}
impl DerefMut for AtomLayoutResponse {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.response
}
}
impl Widget for AtomLayout<'_> { impl Widget for AtomLayout<'_> {
fn ui(self, ui: &mut Ui) -> Response { fn ui(self, ui: &mut Ui) -> Response {
self.show(ui).response self.show(ui).response

View File

@@ -8,6 +8,15 @@ use std::ops::{Deref, DerefMut};
pub(crate) const ATOMS_SMALL_VEC_SIZE: usize = 2; pub(crate) const ATOMS_SMALL_VEC_SIZE: usize = 2;
/// A list of [`Atom`]s. /// A list of [`Atom`]s.
///
/// Many widgets take an `impl` [`IntoAtoms`] parameter,
/// which allows you to easily create atoms from tuples of text, images, and other atoms:
/// ```
/// # use egui::{AtomExt, AtomKind, Atom, Image, Id, Vec2};
/// # egui::__run_test_ui(|ui| {
/// let image = egui::include_image!("../../../eframe/data/icon.png");
/// ui.button((image, "Click me!"));
/// # });
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Atoms<'a>(SmallVec<[Atom<'a>; ATOMS_SMALL_VEC_SIZE]>); pub struct Atoms<'a>(SmallVec<[Atom<'a>; ATOMS_SMALL_VEC_SIZE]>);
@@ -21,11 +30,26 @@ impl<'a> Atoms<'a> {
self.0.push(atom.into()); self.0.push(atom.into());
} }
/// Extend the list of atoms by appending more atoms to the right side.
///
/// If you have weird lifetime issues with this, use [`Self::push_right`] in a loop instead.
pub fn extend_right(&mut self, atoms: Self) {
self.0.extend(atoms.0);
}
/// Insert a new [`Atom`] at the beginning of the list (left side). /// Insert a new [`Atom`] at the beginning of the list (left side).
pub fn push_left(&mut self, atom: impl Into<Atom<'a>>) { pub fn push_left(&mut self, atom: impl Into<Atom<'a>>) {
self.0.insert(0, atom.into()); self.0.insert(0, atom.into());
} }
/// Extend the list of atoms by prepending more atoms to the left side.
///
/// If you have weird lifetime issues with this, use [`Self::push_left`] in a loop instead.
pub fn extend_left(&mut self, mut atoms: Self) {
std::mem::swap(&mut atoms.0, &mut self.0);
self.0.extend(atoms.0);
}
/// Concatenate and return the text contents. /// Concatenate and return the text contents.
// TODO(lucasmerlin): It might not always make sense to return the concatenated text, e.g. // TODO(lucasmerlin): It might not always make sense to return the concatenated text, e.g.
// in a submenu button there is a right text '⏵' which is now passed to the screen reader. // in a submenu button there is a right text '⏵' which is now passed to the screen reader.
@@ -54,6 +78,11 @@ impl<'a> Atoms<'a> {
string string
} }
/// Do any of the atoms have shrink set to `true`?
pub fn any_shrink(&self) -> bool {
self.iter().any(|a| a.shrink)
}
pub fn iter_kinds(&self) -> impl Iterator<Item = &AtomKind<'a>> { pub fn iter_kinds(&self) -> impl Iterator<Item = &AtomKind<'a>> {
self.0.iter().map(|atom| &atom.kind) self.0.iter().map(|atom| &atom.kind)
} }
@@ -172,6 +201,16 @@ where
} }
/// Trait for turning a tuple of [`Atom`]s into [`Atoms`]. /// Trait for turning a tuple of [`Atom`]s into [`Atoms`].
///
/// Many widgets take an `impl` [`IntoAtoms`] parameter,
/// which allows you to easily create atoms from tuples of text, images, and other atoms:
/// ```
/// # use egui::{AtomExt, AtomKind, Atom, Image, Id, Vec2};
/// # egui::__run_test_ui(|ui| {
/// let image = egui::include_image!("../../../eframe/data/icon.png");
/// ui.button((image, "Click me!"));
/// # });
/// ```
pub trait IntoAtoms<'a> { pub trait IntoAtoms<'a> {
fn collect(self, atoms: &mut Atoms<'a>); fn collect(self, atoms: &mut Atoms<'a>);

View File

@@ -4,6 +4,8 @@ use emath::Vec2;
/// A [`crate::Atom`] which has been sized. /// A [`crate::Atom`] which has been sized.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SizedAtom<'a> { pub struct SizedAtom<'a> {
pub id: Option<crate::Id>,
pub(crate) grow: bool, pub(crate) grow: bool,
/// The size of the atom. /// The size of the atom.
@@ -15,6 +17,9 @@ pub struct SizedAtom<'a> {
/// Intrinsic size of the atom. This is used to calculate `Response::intrinsic_size`. /// Intrinsic size of the atom. This is used to calculate `Response::intrinsic_size`.
pub intrinsic_size: Vec2, pub intrinsic_size: Vec2,
/// How will the atom be aligned in its available space?
pub align: emath::Align2,
pub kind: SizedAtomKind<'a>, pub kind: SizedAtomKind<'a>,
} }

View File

@@ -1,16 +1,20 @@
use crate::{Id, Image}; use crate::Image;
use emath::Vec2; use emath::Vec2;
use epaint::Galley; use epaint::Galley;
use std::sync::Arc; use std::sync::Arc;
/// A sized [`crate::AtomKind`]. /// A sized [`crate::AtomKind`].
#[derive(Clone, Default, Debug)] #[derive(Clone, Debug)]
pub enum SizedAtomKind<'a> { pub enum SizedAtomKind<'a> {
#[default] Empty { size: Option<Vec2> },
Empty,
Text(Arc<Galley>), Text(Arc<Galley>),
Image(Image<'a>, Vec2), Image { image: Image<'a>, size: Vec2 },
Custom(Id), }
impl Default for SizedAtomKind<'_> {
fn default() -> Self {
Self::Empty { size: None }
}
} }
impl SizedAtomKind<'_> { impl SizedAtomKind<'_> {
@@ -18,8 +22,8 @@ impl SizedAtomKind<'_> {
pub fn size(&self) -> Vec2 { pub fn size(&self) -> Vec2 {
match self { match self {
SizedAtomKind::Text(galley) => galley.size(), SizedAtomKind::Text(galley) => galley.size(),
SizedAtomKind::Image(_, size) => *size, SizedAtomKind::Image { image: _, size } => *size,
SizedAtomKind::Empty | SizedAtomKind::Custom(_) => Vec2::ZERO, SizedAtomKind::Empty { size } => size.unwrap_or_default(),
} }
} }
} }

View File

@@ -516,6 +516,7 @@ impl Area {
let move_response = ctx.create_widget( let move_response = ctx.create_widget(
WidgetRect { WidgetRect {
id: interact_id, id: interact_id,
parent_id: id,
layer_id, layer_id,
rect: state.rect(), rect: state.rect(),
interact_rect: state.rect().intersect(constrain_rect), interact_rect: state.rect().intersect(constrain_rect),

View File

@@ -10,7 +10,7 @@
use crate::style::StyleModifier; use crate::style::StyleModifier;
use crate::{ use crate::{
Button, Color32, Context, Frame, Id, InnerResponse, IntoAtoms, Layout, Popup, Button, Color32, Context, Frame, Id, InnerResponse, IntoAtoms, Layout, PointerButton, Popup,
PopupCloseBehavior, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget as _, PopupCloseBehavior, Response, Style, Ui, UiBuilder, UiKind, UiStack, UiStackInfo, Widget as _,
}; };
use emath::{Align, RectAlign, Vec2, vec2}; use emath::{Align, RectAlign, Vec2, vec2};
@@ -458,6 +458,7 @@ impl SubMenu {
let is_any_open = open_item.is_some(); let is_any_open = open_item.is_some();
let mut is_open = open_item == Some(id); let mut is_open = open_item == Some(id);
let was_open = is_open;
let mut set_open = None; let mut set_open = None;
// We expand the button rect so there is no empty space where no menu is shown // We expand the button rect so there is no empty space where no menu is shown
@@ -470,9 +471,21 @@ impl SubMenu {
// But since we check if no other menu is open, nothing should be able to cover the button // 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)); let is_hovered = hover_pos.is_some_and(|pos| button_rect.contains(pos));
// `clicked` includes keyboard and accessibility click actions.
// We want Enter/Space to toggle an already open submenu, while pointer clicks should keep
// the submenu open (for touch and pointer interactions).
let clicked = button_response.clicked();
let clicked_by_pointer = button_response.clicked_by(PointerButton::Primary);
let clicked_by_keyboard_or_access = clicked && !clicked_by_pointer;
if ui.is_enabled() && is_open && clicked_by_keyboard_or_access {
set_open = Some(false);
is_open = false;
}
// The clicked handler is there for accessibility (keyboard navigation) // The clicked handler is there for accessibility (keyboard navigation)
let should_open = let should_open =
ui.is_enabled() && (button_response.clicked() || (is_hovered && !is_any_open)); ui.is_enabled() && ((!was_open && clicked) || (is_hovered && !is_any_open));
if should_open { if should_open {
set_open = Some(true); set_open = Some(true);
is_open = true; is_open = true;

View File

@@ -561,7 +561,11 @@ impl Panel {
let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded);
// Get either the fake or the real panel to animate // Get either the fake or the real panel to animate
let animated_panel = self.get_animated_panel(ui.ctx(), is_expanded)?; let Some(animated_panel) = self.get_animated_panel(ui.ctx(), is_expanded) else {
// Make sure the ids of the next widgets are the same whether we show the panel or not:
ui.skip_ahead_auto_ids(1);
return None;
};
if how_expanded < 1.0 { if how_expanded < 1.0 {
// Show a fake panel in this in-between animation state: // Show a fake panel in this in-between animation state:

View File

@@ -962,6 +962,7 @@ fn do_resize_interaction(
WidgetRect { WidgetRect {
layer_id, layer_id,
id, id,
parent_id: layer_id.id,
rect, rect,
interact_rect: rect, interact_rect: rect,
sense: Sense::DRAG, // Don't use Sense::drag() since we don't want these to be focusable sense: Sense::DRAG, // Don't use Sense::drag() since we don't want these to be focusable

View File

@@ -1360,6 +1360,7 @@ impl Context {
let WidgetRect { let WidgetRect {
id, id,
parent_id: _,
layer_id, layer_id,
rect, rect,
interact_rect, interact_rect,
@@ -1378,8 +1379,8 @@ impl Context {
interact_rect, interact_rect,
sense, sense,
flags: Flags::empty(), flags: Flags::empty(),
interact_pointer_pos: None, interact_pointer_pos_or_nan: Pos2::NAN,
intrinsic_size: None, intrinsic_size_or_nan: Vec2::NAN,
}; };
res.flags.set(Flags::ENABLED, enabled); res.flags.set(Flags::ENABLED, enabled);
@@ -1470,14 +1471,11 @@ impl Context {
|| res.long_touched() || res.long_touched()
|| clicked || clicked
|| res.drag_stopped(); || res.drag_stopped();
if is_interacted_with { if is_interacted_with && let Some(mut pos) = input.pointer.interact_pos() {
res.interact_pointer_pos = input.pointer.interact_pos(); if let Some(to_global) = memory.to_global.get(&res.layer_id) {
if let (Some(to_global), Some(pos)) = ( pos = to_global.inverse() * pos;
memory.to_global.get(&res.layer_id),
&mut res.interact_pointer_pos,
) {
*pos = to_global.inverse() * *pos;
} }
res.interact_pointer_pos_or_nan = pos;
} }
if input.pointer.any_down() && !is_interacted_with { if input.pointer.any_down() && !is_interacted_with {
@@ -2397,6 +2395,12 @@ impl Context {
crate::gui_zoom::zoom_with_keyboard(self); crate::gui_zoom::zoom_with_keyboard(self);
} }
for shortcut in self.options(|o| o.quit_shortcuts.clone()) {
if self.input_mut(|i| i.consume_shortcut(&shortcut)) {
self.send_viewport_cmd(ViewportCommand::Close);
}
}
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
self.debug_painting(); self.debug_painting();
@@ -2616,6 +2620,7 @@ impl ContextImpl {
platform_output.accesskit_update = Some(accesskit::TreeUpdate { platform_output.accesskit_update = Some(accesskit::TreeUpdate {
nodes, nodes,
tree: Some(accesskit::Tree::new(root_id)), tree: Some(accesskit::Tree::new(root_id)),
tree_id: accesskit::TreeId::ROOT,
focus: focus_id, focus: focus_id,
}); });
} }
@@ -2635,6 +2640,19 @@ impl ContextImpl {
} }
} }
#[cfg(debug_assertions)]
let shapes = if self.memory.options.style().debug.warn_if_rect_changes_id {
let mut shapes = shapes;
warn_if_rect_changes_id(
&mut shapes,
&viewport.prev_pass.widgets,
&viewport.this_pass.widgets,
);
shapes
} else {
shapes
};
std::mem::swap(&mut viewport.prev_pass, &mut viewport.this_pass); std::mem::swap(&mut viewport.prev_pass, &mut viewport.this_pass);
if repaint_needed { if repaint_needed {
@@ -3262,7 +3280,7 @@ impl Context {
for (name, data) in &mut font_definitions.font_data { for (name, data) in &mut font_definitions.font_data {
ui.collapsing(name, |ui| { ui.collapsing(name, |ui| {
let mut tweak = data.tweak; let mut tweak = data.tweak.clone();
if tweak.ui(ui).changed() { if tweak.ui(ui).changed() {
Arc::make_mut(data).tweak = tweak; Arc::make_mut(data).tweak = tweak;
changed = true; changed = true;
@@ -4236,6 +4254,112 @@ fn context_impl_send_sync() {
assert_send_sync::<Context>(); assert_send_sync::<Context>();
} }
/// Check if any [`Rect`] appears with different [`Id`]s between two passes.
///
/// This helps detect cases where the same screen area is claimed by different widget ids
/// across passes, which is often a sign of id instability.
#[cfg(debug_assertions)]
fn warn_if_rect_changes_id(
out_shapes: &mut Vec<ClippedShape>,
prev_widgets: &crate::WidgetRects,
new_widgets: &crate::WidgetRects,
) {
profiling::function_scope!();
use std::collections::BTreeMap;
/// A wrapper around [`Rect`] that implements [`Ord`] using the bit representation of its floats.
#[derive(Clone, Copy, PartialEq, Eq)]
struct OrderedRect(Rect);
impl PartialOrd for OrderedRect {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for OrderedRect {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
let lhs = self.0;
let rhs = other.0;
lhs.min
.x
.to_bits()
.cmp(&rhs.min.x.to_bits())
.then(lhs.min.y.to_bits().cmp(&rhs.min.y.to_bits()))
.then(lhs.max.x.to_bits().cmp(&rhs.max.x.to_bits()))
.then(lhs.max.y.to_bits().cmp(&rhs.max.y.to_bits()))
}
}
fn create_lookup<'a>(
widgets: impl Iterator<Item = &'a WidgetRect>,
) -> BTreeMap<OrderedRect, Vec<&'a WidgetRect>> {
let mut lookup: BTreeMap<OrderedRect, Vec<&'a WidgetRect>> = BTreeMap::default();
for w in widgets {
lookup.entry(OrderedRect(w.rect)).or_default().push(w);
}
lookup
}
for (layer_id, new_layer_widgets) in new_widgets.layers() {
let prev = create_lookup(prev_widgets.get_layer(*layer_id));
let new = create_lookup(new_layer_widgets.iter());
for (hashable_rect, new_at_rect) in new {
let Some(prev_at_rect) = prev.get(&hashable_rect) else {
continue; // this rect did not exist in the previous pass
};
if prev_at_rect
.iter()
.any(|w| new_at_rect.iter().any(|nw| nw.id == w.id))
{
continue; // at least one id stayed the same, so this is not an id change
}
// Only warn if at least one of the previous ids is gone from this layer entirely.
// If they all still exist (just at a different rect), then the rect match
// is just a coincidence caused by widgets shifting (e.g. a window being dragged).
if prev_at_rect.iter().all(|w| new_widgets.contains(w.id)) {
continue;
}
// Only warn if at least one widget has the same parent_id in both frames.
// If all parent_ids changed too, this is a cascading id shift, not a widget bug.
if !prev_at_rect
.iter()
.any(|pw| new_at_rect.iter().any(|nw| nw.parent_id == pw.parent_id))
{
continue;
}
let rect = new_at_rect[0].rect;
log::warn!(
"Widget rect {rect:?} changed id between passes: prev ids: {:?}, new ids: {:?}",
prev_at_rect
.iter()
.map(|w| w.id.short_debug_format())
.collect::<Vec<_>>(),
new_at_rect
.iter()
.map(|w| w.id.short_debug_format())
.collect::<Vec<_>>(),
);
out_shapes.push(ClippedShape {
clip_rect: Rect::EVERYTHING,
shape: epaint::Shape::rect_stroke(
rect,
0,
(2.0, Color32::RED),
StrokeKind::Outside,
),
});
}
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::Context; use super::Context;

View File

@@ -253,9 +253,28 @@ pub struct ViewportInfo {
/// ///
/// This should be the same as [`RawInput::focused`]. /// This should be the same as [`RawInput::focused`].
pub focused: Option<bool>, pub focused: Option<bool>,
/// Is the window fully occluded (completely covered) by another window?
///
/// Not all platforms support this.
/// On platforms that don't, this will be `None` or `Some(false)`.
pub occluded: Option<bool>,
} }
impl ViewportInfo { impl ViewportInfo {
/// Is the window considered visible for rendering purposes?
///
/// A window is not visible if it is minimized or occluded.
/// When not visible, the UI is not painted and rendering is skipped,
/// but application logic may still be executed by some integrations.
pub fn visible(&self) -> Option<bool> {
match (self.minimized, self.occluded) {
(Some(true), _) | (_, Some(true)) => Some(false),
(Some(false), Some(false)) => Some(true),
(_, None) | (None, _) => None,
}
}
/// This viewport has been told to close. /// This viewport has been told to close.
/// ///
/// If this is the root viewport, the application will exit /// If this is the root viewport, the application will exit
@@ -282,6 +301,7 @@ impl ViewportInfo {
maximized: self.maximized, maximized: self.maximized,
fullscreen: self.fullscreen, fullscreen: self.fullscreen,
focused: self.focused, focused: self.focused,
occluded: self.occluded,
} }
} }
@@ -298,6 +318,7 @@ impl ViewportInfo {
maximized, maximized,
fullscreen, fullscreen,
focused, focused,
occluded,
} = self; } = self;
crate::Grid::new("viewport_info").show(ui, |ui| { crate::Grid::new("viewport_info").show(ui, |ui| {
@@ -345,6 +366,16 @@ impl ViewportInfo {
ui.label(opt_as_str(focused)); ui.label(opt_as_str(focused));
ui.end_row(); ui.end_row();
ui.label("Occluded:");
ui.label(opt_as_str(occluded));
ui.end_row();
let visible = self.visible();
ui.label("Visible:");
ui.label(opt_as_str(&visible));
ui.end_row();
fn opt_rect_as_string(v: &Option<Rect>) -> String { fn opt_rect_as_string(v: &Option<Rect>) -> String {
v.as_ref().map_or(String::new(), |r| { v.as_ref().map_or(String::new(), |r| {
format!("Pos: {:?}, size: {:?}", r.min, r.size()) format!("Pos: {:?}, size: {:?}", r.min, r.size())
@@ -410,6 +441,10 @@ pub enum Event {
Text(String), Text(String),
/// A key was pressed or released. /// A key was pressed or released.
///
/// ## Note for integration authors
///
/// Key events that has been processed by IMEs should not be sent to `egui`.
Key { Key {
/// Most of the time, it's the logical key, heeding the active keymap -- for instance, if the user has Dvorak /// Most of the time, it's the logical key, heeding the active keymap -- for instance, if the user has Dvorak
/// keyboard layout, it will be taken into account. /// keyboard layout, it will be taken into account.

View File

@@ -450,6 +450,7 @@ mod tests {
fn wr(id: Id, sense: Sense, rect: Rect) -> WidgetRect { fn wr(id: Id, sense: Sense, rect: Rect) -> WidgetRect {
WidgetRect { WidgetRect {
id, id,
parent_id: Id::NULL,
layer_id: LayerId::background(), layer_id: LayerId::background(),
rect, rect,
interact_rect: rect, interact_rect: rect,

View File

@@ -79,7 +79,7 @@ impl Id {
self.0.get() self.0.get()
} }
pub(crate) fn accesskit_id(&self) -> accesskit::NodeId { pub fn accesskit_id(&self) -> accesskit::NodeId {
self.value().into() self.value().into()
} }

View File

@@ -661,6 +661,8 @@ impl InputState {
if self.pointer.wants_repaint() if self.pointer.wants_repaint()
|| self.wheel.unprocessed_wheel_delta.abs().max_elem() > 0.2 || self.wheel.unprocessed_wheel_delta.abs().max_elem() > 0.2
|| !self.events.is_empty() || !self.events.is_empty()
|| !self.raw.hovered_files.is_empty()
|| !self.raw.dropped_files.is_empty()
{ {
// Immediate repaint // Immediate repaint
return Some(Duration::ZERO); return Some(Duration::ZERO);
@@ -869,7 +871,8 @@ impl InputState {
let accesskit_id = id.accesskit_id(); let accesskit_id = id.accesskit_id();
self.events.iter().filter_map(move |event| { self.events.iter().filter_map(move |event| {
if let Event::AccessKitActionRequest(request) = event if let Event::AccessKitActionRequest(request) = event
&& request.target == accesskit_id && request.target_node == accesskit_id
&& request.target_tree == accesskit::TreeId::ROOT
&& request.action == action && request.action == action
{ {
return Some(request); return Some(request);
@@ -886,7 +889,8 @@ impl InputState {
let accesskit_id = id.accesskit_id(); let accesskit_id = id.accesskit_id();
self.events.retain(|event| { self.events.retain(|event| {
if let Event::AccessKitActionRequest(request) = event if let Event::AccessKitActionRequest(request) = event
&& request.target == accesskit_id && request.target_node == accesskit_id
&& request.target_tree == accesskit::TreeId::ROOT
{ {
return !consume(request); return !consume(request);
} }

View File

@@ -623,12 +623,24 @@ impl Layout {
if (self.is_vertical() && self.horizontal_align() == Align::Center) if (self.is_vertical() && self.horizontal_align() == Align::Center)
|| self.horizontal_justify() || self.horizontal_justify()
{ {
frame_size.x = frame_size.x.max(available_rect.width()); // fill full width // For wrapping layouts, fill the current column width, not the entire layout width.
let width = if self.main_wrap {
region.cursor.width()
} else {
available_rect.width()
};
frame_size.x = frame_size.x.max(width); // fill full width
} }
if (self.is_horizontal() && self.vertical_align() == Align::Center) if (self.is_horizontal() && self.vertical_align() == Align::Center)
|| self.vertical_justify() || self.vertical_justify()
{ {
frame_size.y = frame_size.y.max(available_rect.height()); // fill full height // For wrapping layouts, fill the current row height, not the entire layout height.
let height = if self.main_wrap {
region.cursor.height()
} else {
available_rect.height()
};
frame_size.y = frame_size.y.max(height); // fill full height
} }
let align2 = match self.main_dir { let align2 = match self.main_dir {
@@ -791,14 +803,14 @@ impl Layout {
let new_top = region.cursor.bottom() + spacing.y; let new_top = region.cursor.bottom() + spacing.y;
region.cursor = Rect::from_min_max( region.cursor = Rect::from_min_max(
pos2(region.max_rect.left(), new_top), pos2(region.max_rect.left(), new_top),
pos2(INFINITY, new_top + region.cursor.height()), pos2(INFINITY, new_top),
); );
} }
Direction::RightToLeft => { Direction::RightToLeft => {
let new_top = region.cursor.bottom() + spacing.y; let new_top = region.cursor.bottom() + spacing.y;
region.cursor = Rect::from_min_max( region.cursor = Rect::from_min_max(
pos2(-INFINITY, new_top), pos2(-INFINITY, new_top),
pos2(region.max_rect.right(), new_top + region.cursor.height()), pos2(region.max_rect.right(), new_top),
); );
} }
Direction::TopDown | Direction::BottomUp => {} Direction::TopDown | Direction::BottomUp => {}

View File

@@ -685,7 +685,7 @@ pub fn __run_test_ctx(mut run_ui: impl FnMut(&Context)) {
} }
/// For use in tests; especially doctests. /// For use in tests; especially doctests.
pub fn __run_test_ui(add_contents: impl Fn(&mut Ui)) { pub fn __run_test_ui(mut add_contents: impl FnMut(&mut Ui)) {
let ctx = Context::default(); let ctx = Context::default();
ctx.set_fonts(FontDefinitions::empty()); // prevent fonts from being loaded (save CPU time) ctx.set_fonts(FontDefinitions::empty()); // prevent fonts from being loaded (save CPU time)
let _ = ctx.run_ui(Default::default(), |ui| { let _ = ctx.run_ui(Default::default(), |ui| {

View File

@@ -234,6 +234,16 @@ pub struct Options {
#[cfg_attr(feature = "serde", serde(skip))] #[cfg_attr(feature = "serde", serde(skip))]
pub zoom_with_keyboard: bool, pub zoom_with_keyboard: bool,
/// Keyboard shortcuts to close the application.
///
/// Pressing any of these will send [`crate::ViewportCommand::Close`]
/// to the root viewport.
///
/// Defaults to `Cmd-Q` (which is Ctrl-Q on Linux/Windows, Cmd-Q on Mac).
/// Set to empty to disable.
#[cfg_attr(feature = "serde", serde(skip))]
pub quit_shortcuts: Vec<crate::KeyboardShortcut>,
/// Controls the tessellator. /// Controls the tessellator.
pub tessellation_options: epaint::TessellationOptions, pub tessellation_options: epaint::TessellationOptions,
@@ -304,6 +314,10 @@ impl Default for Options {
system_theme: None, system_theme: None,
zoom_factor: 1.0, zoom_factor: 1.0,
zoom_with_keyboard: true, zoom_with_keyboard: true,
quit_shortcuts: vec![crate::KeyboardShortcut::new(
crate::Modifiers::COMMAND,
crate::Key::Q,
)],
tessellation_options: Default::default(), tessellation_options: Default::default(),
repaint_on_widget_change: false, repaint_on_widget_change: false,
@@ -363,6 +377,7 @@ impl Options {
system_theme: _, system_theme: _,
zoom_factor, zoom_factor,
zoom_with_keyboard, zoom_with_keyboard,
quit_shortcuts: _, // not shown in ui
tessellation_options, tessellation_options,
repaint_on_widget_change, repaint_on_widget_change,
max_passes, max_passes,
@@ -564,11 +579,13 @@ impl Focus {
if let crate::Event::AccessKitActionRequest(accesskit::ActionRequest { if let crate::Event::AccessKitActionRequest(accesskit::ActionRequest {
action: accesskit::Action::Focus, action: accesskit::Action::Focus,
target, target_node,
target_tree,
data: None, data: None,
}) = event }) = event
&& *target_tree == accesskit::TreeId::ROOT
{ {
self.id_requested_by_accesskit = Some(*target); self.id_requested_by_accesskit = Some(*target_node);
} }
} }
} }

View File

@@ -55,7 +55,7 @@ pub struct Response {
/// Where the pointer (mouse/touch) were 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.
#[doc(hidden)] #[doc(hidden)]
pub interact_pointer_pos: Option<Pos2>, pub interact_pointer_pos_or_nan: Pos2,
/// The intrinsic / desired size of the widget. /// The intrinsic / desired size of the widget.
/// ///
@@ -67,12 +67,22 @@ pub struct Response {
/// At the time of writing, this is only used by external crates /// At the time of writing, this is only used by external crates
/// 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>, #[doc(hidden)]
pub intrinsic_size_or_nan: Vec2,
#[doc(hidden)] #[doc(hidden)]
pub flags: Flags, pub flags: Flags,
} }
#[test]
fn test_response_size() {
assert_eq!(
std::mem::size_of::<Response>(),
88,
"Keep Response small, because we create them often, and we want to keep it lean and fast"
);
}
/// A bit set for various boolean properties of `Response`. /// A bit set for various boolean properties of `Response`.
#[doc(hidden)] #[doc(hidden)]
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
@@ -141,6 +151,22 @@ bitflags::bitflags! {
} }
impl Response { impl Response {
/// The [`Id`] of the parent [`crate::Ui`] that hosts this widget.
///
/// Looks up the [`WidgetRect`] from the current (or previous) pass.
pub fn parent_id(&self) -> Id {
let id = self.ctx.viewport(|viewport| {
viewport
.this_pass
.widgets
.get(self.id)
.or_else(|| viewport.prev_pass.widgets.get(self.id))
.map(|w| w.parent_id)
});
debug_assert!(id.is_some(), "WidgetRect for Response not found!");
id.unwrap_or(Id::NULL)
}
/// Returns true if this widget was clicked this frame by the primary button. /// Returns true if this widget was clicked this frame by the primary button.
/// ///
/// A click is registered when the mouse or touch is released within /// A click is registered when the mouse or touch is released within
@@ -489,7 +515,26 @@ impl Response {
/// `None` if the widget is not being interacted with. /// `None` if the widget is not being interacted with.
#[inline] #[inline]
pub fn interact_pointer_pos(&self) -> Option<Pos2> { pub fn interact_pointer_pos(&self) -> Option<Pos2> {
self.interact_pointer_pos let pos = self.interact_pointer_pos_or_nan;
if pos.any_nan() { None } else { Some(pos) }
}
/// The intrinsic / desired size of the widget.
///
/// This is the size that a non-wrapped, non-truncated, non-justified version of the widget
/// would have.
///
/// If this is `None`, use [`Self::rect`] instead.
#[inline]
pub fn intrinsic_size(&self) -> Option<Vec2> {
let size = self.intrinsic_size_or_nan;
if size.any_nan() { None } else { Some(size) }
}
/// Set the intrinsic / desired size of the widget.
#[inline]
pub fn set_intrinsic_size(&mut self, size: Vec2) {
self.intrinsic_size_or_nan = size;
} }
/// If it is a good idea to show a tooltip, where is pointer? /// If it is a good idea to show a tooltip, where is pointer?
@@ -732,6 +777,7 @@ impl Response {
WidgetRect { WidgetRect {
layer_id: self.layer_id, layer_id: self.layer_id,
id: self.id, id: self.id,
parent_id: self.parent_id(),
rect: self.rect, rect: self.rect,
interact_rect: self.interact_rect, interact_rect: self.interact_rect,
sense: self.sense | sense, sense: self.sense | sense,
@@ -1007,8 +1053,10 @@ impl Response {
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),
flags: self.flags | other.flags, flags: self.flags | other.flags,
interact_pointer_pos: self.interact_pointer_pos.or(other.interact_pointer_pos), interact_pointer_pos_or_nan: self
intrinsic_size: None, .interact_pointer_pos()
.unwrap_or(other.interact_pointer_pos_or_nan),
intrinsic_size_or_nan: Vec2::NAN,
} }
} }
} }

View File

@@ -1,7 +1,11 @@
//! egui theme (spacing, colors, etc). //! egui theme (spacing, colors, etc).
use emath::Align; use emath::Align;
use epaint::{AlphaFromCoverage, CornerRadius, Shadow, Stroke, TextOptions, text::FontTweak}; use epaint::{
AlphaFromCoverage, CornerRadius, Shadow, Stroke, TextOptions,
mutex::Mutex,
text::{FontTweak, Tag},
};
use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
use crate::{ use crate::{
@@ -1319,6 +1323,10 @@ pub struct DebugOptions {
/// Show interesting widgets under the mouse cursor. /// Show interesting widgets under the mouse cursor.
pub show_widget_hits: bool, pub show_widget_hits: bool,
/// Show a warning if the same `Rect` had different `Id` and the same parent `Id` on the
/// previous frame.
pub warn_if_rect_changes_id: bool,
/// If true, highlight widgets that are not aligned to [`emath::GUI_ROUNDING`]. /// If true, highlight widgets that are not aligned to [`emath::GUI_ROUNDING`].
/// ///
/// See [`emath::GuiRounding`] for more. /// See [`emath::GuiRounding`] for more.
@@ -1345,6 +1353,7 @@ impl Default for DebugOptions {
show_resize: false, show_resize: false,
show_interactive_widgets: false, show_interactive_widgets: false,
show_widget_hits: false, show_widget_hits: false,
warn_if_rect_changes_id: cfg!(debug_assertions),
show_unaligned: cfg!(debug_assertions), show_unaligned: cfg!(debug_assertions),
show_focused_widget: false, show_focused_widget: false,
} }
@@ -2526,6 +2535,7 @@ impl DebugOptions {
show_resize, show_resize,
show_interactive_widgets, show_interactive_widgets,
show_widget_hits, show_widget_hits,
warn_if_rect_changes_id,
show_unaligned, show_unaligned,
show_focused_widget, show_focused_widget,
} = self; } = self;
@@ -2557,6 +2567,11 @@ impl DebugOptions {
ui.checkbox(show_widget_hits, "Show widgets under mouse pointer"); ui.checkbox(show_widget_hits, "Show widgets under mouse pointer");
ui.checkbox(
warn_if_rect_changes_id,
"Warn if a Rect changes Id between frames",
);
ui.checkbox( ui.checkbox(
show_unaligned, show_unaligned,
"Show rectangles not aligned to integer point coordinates", "Show rectangles not aligned to integer point coordinates",
@@ -2876,7 +2891,7 @@ impl Widget for &mut crate::Frame {
impl Widget for &mut FontTweak { impl Widget for &mut FontTweak {
fn ui(self, ui: &mut Ui) -> Response { fn ui(self, ui: &mut Ui) -> Response {
let original: FontTweak = *self; let original: FontTweak = self.clone();
let mut response = Grid::new("font_tweak") let mut response = Grid::new("font_tweak")
.num_columns(2) .num_columns(2)
@@ -2886,6 +2901,7 @@ impl Widget for &mut FontTweak {
y_offset_factor, y_offset_factor,
y_offset, y_offset,
hinting_override, hinting_override,
coords,
} = self; } = self;
ui.label("Scale"); ui.label("Scale");
@@ -2913,6 +2929,50 @@ impl Widget for &mut FontTweak {
ui.selectable_value(hinting_override, Some(true), "Enable"); ui.selectable_value(hinting_override, Some(true), "Enable");
ui.selectable_value(hinting_override, Some(false), "Disable"); ui.selectable_value(hinting_override, Some(false), "Disable");
}); });
ui.end_row();
ui.label("coords");
ui.end_row();
let mut to_remove = None;
for (i, (tag, value)) in coords.as_mut().iter_mut().enumerate() {
let tag_text = ui.ctx().data_mut(|data| {
let tag = *tag;
Arc::clone(data.get_temp_mut_or_insert_with(ui.id().with(i), move || {
Arc::new(Mutex::new(tag.to_string()))
}))
});
let tag_text = &mut *tag_text.lock();
let response = ui.text_edit_singleline(tag_text);
if response.changed()
&& let Ok(new_tag) = Tag::new_checked(tag_text.as_bytes())
{
*tag = new_tag;
}
// Reset stale text when not actively editing
// (e.g. after an item was removed and indices shifted)
if !response.has_focus()
&& Tag::new_checked(tag_text.as_bytes()).ok() != Some(*tag)
{
*tag_text = tag.to_string();
}
ui.add(DragValue::new(value));
if ui.small_button("🗑").clicked() {
to_remove = Some(i);
}
ui.end_row();
}
if let Some(i) = to_remove {
coords.remove(i);
}
if ui.button("Add coord").clicked() {
coords.push(b"wght", 0.0);
}
if ui.button("Clear coords").clicked() {
coords.clear();
}
ui.end_row();
if ui.button("Reset").clicked() { if ui.button("Reset").clicked() {
*self = Default::default(); *self = Default::default();

View File

@@ -4,6 +4,26 @@ use crate::{Context, Galley, Id};
use super::{CCursorRange, text_cursor_state::is_word_char}; use super::{CCursorRange, text_cursor_state::is_word_char};
/// AccessKit's `word_starts` uses `u8` indices, so text runs cannot exceed this length.
pub(crate) const MAX_CHARS_PER_TEXT_RUN: usize = 255;
/// Convert a (row, column) layout cursor position to a text run node ID and character index,
/// accounting for rows that are split into multiple text runs.
fn text_run_position(parent_id: Id, row: usize, column: usize) -> accesskit::TextPosition {
// When column lands exactly on a chunk boundary (e.g., 255), it refers to
// the end of the previous chunk, not the start of a new one.
let chunk_index = if column > 0 && column.is_multiple_of(MAX_CHARS_PER_TEXT_RUN) {
column / MAX_CHARS_PER_TEXT_RUN - 1
} else {
column / MAX_CHARS_PER_TEXT_RUN
};
let character_index = column - chunk_index * MAX_CHARS_PER_TEXT_RUN;
accesskit::TextPosition {
node: parent_id.with(row).with(chunk_index).accesskit_id(),
character_index,
}
}
/// 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,
@@ -20,14 +40,8 @@ pub fn update_accesskit_for_text_widget(
let anchor = galley.layout_from_cursor(cursor_range.secondary); let anchor = galley.layout_from_cursor(cursor_range.secondary);
let focus = galley.layout_from_cursor(cursor_range.primary); 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: text_run_position(parent_id, anchor.row, anchor.column),
node: parent_id.with(anchor.row).accesskit_id(), focus: text_run_position(parent_id, focus.row, focus.column),
character_index: anchor.column,
},
focus: accesskit::TextPosition {
node: parent_id.with(focus.row).accesskit_id(),
character_index: focus.column,
},
}); });
} }
@@ -40,39 +54,25 @@ pub fn update_accesskit_for_text_widget(
return; return;
}; };
let mut prev_row_ended_with_newline = true;
for (row_index, row) in galley.rows.iter().enumerate() { for (row_index, row) in galley.rows.iter().enumerate() {
let row_id = parent_id.with(row_index);
ctx.register_accesskit_parent(row_id, parent_id);
ctx.accesskit_node_builder(row_id, |builder| {
builder.set_role(accesskit::Role::TextRun);
let rect = global_from_galley * row.rect_without_leading_space();
builder.set_bounds(accesskit::Rect {
x0: rect.min.x.into(),
y0: rect.min.y.into(),
x1: rect.max.x.into(),
y1: rect.max.y.into(),
});
builder.set_text_direction(accesskit::TextDirection::LeftToRight);
// TODO(mwcampbell): Set more node fields for the row
// once AccessKit adapters expose text formatting info.
let glyph_count = row.glyphs.len(); let glyph_count = row.glyphs.len();
let mut value = String::new(); let mut value = String::with_capacity(glyph_count);
value.reserve(glyph_count);
let mut character_lengths = Vec::<u8>::with_capacity(glyph_count); let mut character_lengths = Vec::<u8>::with_capacity(glyph_count);
let mut character_positions = Vec::<f32>::with_capacity(glyph_count); let mut character_positions = Vec::<f32>::with_capacity(glyph_count);
let mut character_widths = Vec::<f32>::with_capacity(glyph_count); let mut character_widths = Vec::<f32>::with_capacity(glyph_count);
let mut word_lengths = Vec::<u8>::new(); let mut word_starts = Vec::<usize>::new();
let mut was_at_word_end = false; // For soft-wrapped continuation rows, treat the start as a word
let mut last_word_start = 0usize; // boundary so the first word character gets a `word_starts` entry.
// Paragraph-starting runs (first row or after a newline) get an
// implicit word start from AccessKit, so they don't need this.
let mut was_at_word_end = !prev_row_ended_with_newline;
for glyph in &row.glyphs { for glyph in &row.glyphs {
let is_word_char = is_word_char(glyph.chr); let is_word_char = is_word_char(glyph.chr);
if is_word_char && was_at_word_end { if is_word_char && was_at_word_end {
word_lengths.push((character_lengths.len() - last_word_start) as _); word_starts.push(character_lengths.len());
last_word_start = character_lengths.len();
} }
was_at_word_end = !is_word_char; was_at_word_end = !is_word_char;
let old_len = value.len(); let old_len = value.len();
@@ -88,13 +88,110 @@ pub fn update_accesskit_for_text_widget(
character_positions.push(row.size.x); character_positions.push(row.size.x);
character_widths.push(0.0); character_widths.push(0.0);
} }
word_lengths.push((character_lengths.len() - last_word_start) as _);
let total_chars = character_lengths.len();
if total_chars <= MAX_CHARS_PER_TEXT_RUN {
let run_id = parent_id.with(row_index).with(0usize);
ctx.register_accesskit_parent(run_id, parent_id);
ctx.accesskit_node_builder(run_id, |builder| {
builder.set_role(accesskit::Role::TextRun);
builder.set_text_direction(accesskit::TextDirection::LeftToRight);
// TODO(mwcampbell): Set more node fields for the row
// once AccessKit adapters expose text formatting info.
let rect = global_from_galley * row.rect_without_leading_space();
builder.set_bounds(accesskit::Rect {
x0: rect.min.x.into(),
y0: rect.min.y.into(),
x1: rect.max.x.into(),
y1: rect.max.y.into(),
});
builder.set_value(value); builder.set_value(value);
builder.set_character_lengths(character_lengths); builder.set_character_lengths(character_lengths);
let pos_offset = character_positions.first().copied().unwrap_or(0.0);
for p in &mut character_positions {
*p -= pos_offset;
}
builder.set_character_positions(character_positions); builder.set_character_positions(character_positions);
builder.set_character_widths(character_widths); builder.set_character_widths(character_widths);
builder.set_word_lengths(word_lengths);
let chunk_word_starts: Vec<u8> = word_starts.iter().map(|&ws| ws as u8).collect();
builder.set_word_starts(chunk_word_starts);
});
} else {
let num_chunks = total_chars.div_ceil(MAX_CHARS_PER_TEXT_RUN);
let mut byte_offset = 0usize;
for chunk_idx in 0..num_chunks {
let char_start = chunk_idx * MAX_CHARS_PER_TEXT_RUN;
let char_end = (char_start + MAX_CHARS_PER_TEXT_RUN).min(total_chars);
let byte_start = byte_offset;
let chunk_byte_len: usize = character_lengths[char_start..char_end]
.iter()
.map(|&l| l as usize)
.sum();
let byte_end = byte_start + chunk_byte_len;
byte_offset = byte_end;
let run_id = parent_id.with(row_index).with(chunk_idx);
ctx.register_accesskit_parent(run_id, parent_id);
ctx.accesskit_node_builder(run_id, |builder| {
builder.set_role(accesskit::Role::TextRun);
builder.set_text_direction(accesskit::TextDirection::LeftToRight);
// TODO(mwcampbell): Set more node fields for the row
// once AccessKit adapters expose text formatting info.
if chunk_idx > 0 {
let prev_id = parent_id.with(row_index).with(chunk_idx - 1);
builder.set_previous_on_line(prev_id.accesskit_id());
}
if chunk_idx + 1 < num_chunks {
let next_id = parent_id.with(row_index).with(chunk_idx + 1);
builder.set_next_on_line(next_id.accesskit_id());
}
let row_rect = row.rect_without_leading_space();
let chunk_x0 = row.pos.x + character_positions[char_start];
let chunk_x1 = row.pos.x
+ character_positions[char_end - 1]
+ character_widths[char_end - 1];
let chunk_rect = emath::Rect::from_min_max(
emath::pos2(chunk_x0, row_rect.min.y),
emath::pos2(chunk_x1, row_rect.max.y),
);
let rect = global_from_galley * chunk_rect;
builder.set_bounds(accesskit::Rect {
x0: rect.min.x.into(),
y0: rect.min.y.into(),
x1: rect.max.x.into(),
y1: rect.max.y.into(),
});
builder.set_value(value[byte_start..byte_end].to_owned());
builder.set_character_lengths(character_lengths[char_start..char_end].to_vec());
let pos_offset = character_positions[char_start];
let chunk_positions: Vec<f32> = character_positions[char_start..char_end]
.iter()
.map(|&p| p - pos_offset)
.collect();
builder.set_character_positions(chunk_positions);
builder.set_character_widths(character_widths[char_start..char_end].to_vec());
let chunk_word_starts: Vec<u8> = word_starts
.iter()
.filter(|&&ws| ws >= char_start && ws < char_end)
.map(|&ws| (ws - char_start) as u8)
.collect();
builder.set_word_starts(chunk_word_starts);
}); });
} }
} }
prev_row_ended_with_newline = row.ends_with_newline;
}
}

View File

@@ -192,10 +192,13 @@ impl CCursorRange {
Event::AccessKitActionRequest(accesskit::ActionRequest { Event::AccessKitActionRequest(accesskit::ActionRequest {
action: accesskit::Action::SetTextSelection, action: accesskit::Action::SetTextSelection,
target, target_node,
target_tree,
data: Some(accesskit::ActionData::SetTextSelection(selection)), data: Some(accesskit::ActionData::SetTextSelection(selection)),
}) => { }) => {
if _widget_id.accesskit_id() == *target { if _widget_id.accesskit_id() == *target_node
&& *target_tree == accesskit::TreeId::ROOT
{
let primary = let primary =
ccursor_from_accesskit_text_position(_widget_id, galley, &selection.focus); ccursor_from_accesskit_text_position(_widget_id, galley, &selection.focus);
let secondary = let secondary =
@@ -224,18 +227,31 @@ fn ccursor_from_accesskit_text_position(
galley: &Galley, galley: &Galley,
position: &accesskit::TextPosition, position: &accesskit::TextPosition,
) -> Option<CCursor> { ) -> Option<CCursor> {
use super::accesskit_text::MAX_CHARS_PER_TEXT_RUN;
let mut total_length = 0usize; let mut total_length = 0usize;
for (i, row) in galley.rows.iter().enumerate() { for (i, row) in galley.rows.iter().enumerate() {
let row_id = id.with(i); let row_chars = row.glyphs.len() + (row.ends_with_newline as usize);
if row_id.accesskit_id() == position.node { let num_chunks = if row_chars == 0 {
1
} else {
row_chars.div_ceil(MAX_CHARS_PER_TEXT_RUN)
};
for chunk_idx in 0..num_chunks {
let run_id = id.with(i).with(chunk_idx);
if run_id.accesskit_id() == position.node {
let column = chunk_idx * MAX_CHARS_PER_TEXT_RUN + position.character_index;
return Some(CCursor { return Some(CCursor {
index: total_length + position.character_index, index: total_length + column,
prefer_next_row: !(position.character_index == row.glyphs.len() prefer_next_row: !(column == row.glyphs.len()
&& !row.ends_with_newline && !row.ends_with_newline
&& (i + 1) < galley.rows.len()), && (i + 1) < galley.rows.len()),
}); });
} }
total_length += row.glyphs.len() + (row.ends_with_newline as usize); }
total_length += row_chars;
} }
None None
} }

View File

@@ -67,9 +67,7 @@ pub fn paint_text_selection(
let first_vertex_index = row let first_vertex_index = row
.glyphs .glyphs
.get(first_glyph_index) .get(first_glyph_index)
.map_or(row.visuals.glyph_vertex_range.start, |g| { .map_or(row.visuals.glyph_vertex_range.end, |g| g.first_vertex as _);
g.first_vertex as _
});
let last_vertex_index = row let last_vertex_index = row
.glyphs .glyphs
.get(last_glyph_index) .get(last_glyph_index)

View File

@@ -173,6 +173,7 @@ impl Ui {
ui.ctx().create_widget( ui.ctx().create_widget(
WidgetRect { WidgetRect {
id: ui.unique_id, id: ui.unique_id,
parent_id: ui.id,
layer_id: ui.layer_id(), layer_id: ui.layer_id(),
rect: start_rect, rect: start_rect,
interact_rect: start_rect, interact_rect: start_rect,
@@ -339,6 +340,7 @@ impl Ui {
child_ui.ctx().create_widget( child_ui.ctx().create_widget(
WidgetRect { WidgetRect {
id: child_ui.unique_id, id: child_ui.unique_id,
parent_id: self.id,
layer_id: child_ui.layer_id(), layer_id: child_ui.layer_id(),
rect: start_rect, rect: start_rect,
interact_rect: start_rect, interact_rect: start_rect,
@@ -1043,6 +1045,7 @@ impl Ui {
self.ctx().create_widget( self.ctx().create_widget(
WidgetRect { WidgetRect {
id, id,
parent_id: self.id,
layer_id: self.layer_id(), layer_id: self.layer_id(),
rect, rect,
interact_rect: self.clip_rect().intersect(rect), interact_rect: self.clip_rect().intersect(rect),
@@ -1112,6 +1115,7 @@ impl Ui {
let mut response = self.ctx().create_widget( let mut response = self.ctx().create_widget(
WidgetRect { WidgetRect {
id: self.unique_id, id: self.unique_id,
parent_id: self.id,
layer_id: self.layer_id(), layer_id: self.layer_id(),
rect: self.min_rect(), rect: self.min_rect(),
interact_rect: self.clip_rect().intersect(self.min_rect()), interact_rect: self.clip_rect().intersect(self.min_rect()),
@@ -1281,7 +1285,7 @@ impl Ui {
pub fn allocate_response(&mut self, desired_size: Vec2, sense: Sense) -> Response { pub fn allocate_response(&mut self, desired_size: Vec2, sense: Sense) -> Response {
let (id, rect) = self.allocate_space(desired_size); let (id, rect) = self.allocate_space(desired_size);
let mut response = self.interact(rect, id, sense); let mut response = self.interact(rect, id, sense);
response.intrinsic_size = Some(desired_size); response.set_intrinsic_size(desired_size);
response response
} }

View File

@@ -54,8 +54,8 @@ impl UiBuilder {
/// ///
/// This is a shortcut for `.id_salt(my_id).global_scope(true)`. /// This is a shortcut for `.id_salt(my_id).global_scope(true)`.
#[inline] #[inline]
pub fn id(mut self, id: impl Hash) -> Self { pub fn id(mut self, id: Id) -> Self {
self.id_salt = Some(Id::new(id)); self.id_salt = Some(id);
self.global_scope = true; self.global_scope = true;
self self
} }

View File

@@ -17,6 +17,12 @@ pub struct WidgetRect {
/// You can ensure globally unique ids using [`crate::Ui::push_id`]. /// You can ensure globally unique ids using [`crate::Ui::push_id`].
pub id: Id, pub id: Id,
/// The [`Id`] of the parent [`crate::Ui`] that hosts this widget.
///
/// Used by debug checks to distinguish true id-instability from
/// cascading id shifts caused by a parent Ui's auto-id changing.
pub parent_id: Id,
/// What layer the widget is on. /// What layer the widget is on.
pub layer_id: LayerId, pub layer_id: LayerId,
@@ -46,6 +52,7 @@ impl WidgetRect {
pub fn transform(self, transform: emath::TSTransform) -> Self { pub fn transform(self, transform: emath::TSTransform) -> Self {
let Self { let Self {
id, id,
parent_id,
layer_id, layer_id,
rect, rect,
interact_rect, interact_rect,
@@ -54,6 +61,7 @@ impl WidgetRect {
} = self; } = self;
Self { Self {
id, id,
parent_id,
layer_id, layer_id,
rect: transform * rect, rect: transform * rect,
interact_rect: transform * interact_rect, interact_rect: transform * interact_rect,

View File

@@ -1,5 +1,5 @@
use emath::GuiRounding as _; use emath::GuiRounding as _;
use epaint::text::TextFormat; use epaint::text::{IntoTag, TextFormat, VariationCoords};
use std::fmt::Formatter; use std::fmt::Formatter;
use std::{borrow::Cow, sync::Arc}; use std::{borrow::Cow, sync::Arc};
@@ -34,6 +34,7 @@ pub struct RichText {
background_color: Color32, background_color: Color32,
expand_bg: f32, expand_bg: f32,
text_color: Option<Color32>, text_color: Option<Color32>,
coords: VariationCoords,
code: bool, code: bool,
strong: bool, strong: bool,
weak: bool, weak: bool,
@@ -55,6 +56,7 @@ impl Default for RichText {
background_color: Default::default(), background_color: Default::default(),
expand_bg: 1.0, expand_bg: 1.0,
text_color: Default::default(), text_color: Default::default(),
coords: Default::default(),
code: Default::default(), code: Default::default(),
strong: Default::default(), strong: Default::default(),
weak: Default::default(), weak: Default::default(),
@@ -196,6 +198,23 @@ impl RichText {
self self
} }
/// Add a variation coordinate.
#[inline]
pub fn variation(mut self, tag: impl IntoTag, coord: f32) -> Self {
self.coords.push(tag, coord);
self
}
/// Override the variation coordinates completely.
#[inline]
pub fn variations<T: IntoTag>(
mut self,
variations: impl IntoIterator<Item = (T, f32)>,
) -> Self {
self.coords = VariationCoords::new(variations);
self
}
/// Override the [`TextStyle`]. /// Override the [`TextStyle`].
#[inline] #[inline]
pub fn text_style(mut self, text_style: TextStyle) -> Self { pub fn text_style(mut self, text_style: TextStyle) -> Self {
@@ -391,6 +410,7 @@ impl RichText {
background_color, background_color,
expand_bg, expand_bg,
text_color: _, // already used by `get_text_color` text_color: _, // already used by `get_text_color`
coords,
code, code,
strong: _, // already used by `get_text_color` strong: _, // already used by `get_text_color`
weak: _, // already used by `get_text_color` weak: _, // already used by `get_text_color`
@@ -449,6 +469,7 @@ impl RichText {
line_height, line_height,
color: text_color, color: text_color,
background: background_color, background: background_color,
coords,
italics, italics,
underline, underline,
strikethrough, strikethrough,

View File

@@ -240,6 +240,18 @@ impl<'a> Button<'a> {
self self
} }
/// Show some text on the left side of the button.
#[inline]
pub fn left_text(mut self, left_text: impl IntoAtoms<'a>) -> Self {
self.layout.push_left(Atom::grow());
for atom in left_text.into_atoms() {
self.layout.push_left(atom);
}
self
}
/// Show some text on the right side of the button. /// Show some text on the right side of the button.
#[inline] #[inline]
pub fn right_text(mut self, right_text: impl IntoAtoms<'a>) -> Self { pub fn right_text(mut self, right_text: impl IntoAtoms<'a>) -> Self {
@@ -259,6 +271,13 @@ impl<'a> Button<'a> {
self self
} }
/// Set the gap between atoms.
#[inline]
pub fn gap(mut self, gap: f32) -> Self {
self.layout = self.layout.gap(gap);
self
}
/// Show the button and return a [`AtomLayoutResponse`] for painting custom contents. /// Show the button and return a [`AtomLayoutResponse`] for painting custom contents.
pub fn atom_ui(self, ui: &mut Ui) -> AtomLayoutResponse { pub fn atom_ui(self, ui: &mut Ui) -> AtomLayoutResponse {
let Button { let Button {

View File

@@ -1,11 +1,10 @@
#![expect(clippy::needless_pass_by_value)] // False positives with `impl ToString`
use std::{cmp::Ordering, ops::RangeInclusive};
use crate::{ use crate::{
Button, CursorIcon, Id, Key, MINUS_CHAR_STR, Modifiers, NumExt as _, Response, RichText, Sense, Atom, AtomExt as _, AtomKind, Atoms, Button, CursorIcon, Id, IntoAtoms, Key, MINUS_CHAR_STR,
TextEdit, TextWrapMode, Ui, Widget, WidgetInfo, emath, text, Modifiers, NumExt as _, Response, RichText, Sense, TextEdit, TextWrapMode, Ui, Widget,
WidgetInfo, emath, text,
}; };
use emath::Vec2;
use std::{cmp::Ordering, ops::RangeInclusive};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -38,8 +37,7 @@ fn set(get_set_value: &mut GetSetValue<'_>, value: f64) {
pub struct DragValue<'a> { pub struct DragValue<'a> {
get_set_value: GetSetValue<'a>, get_set_value: GetSetValue<'a>,
speed: f64, speed: f64,
prefix: String, atoms: Atoms<'a>,
suffix: String,
range: RangeInclusive<f64>, range: RangeInclusive<f64>,
clamp_existing_to_range: bool, clamp_existing_to_range: bool,
min_decimals: usize, min_decimals: usize,
@@ -50,6 +48,8 @@ pub struct DragValue<'a> {
} }
impl<'a> DragValue<'a> { impl<'a> DragValue<'a> {
const ATOM_ID: &'static str = "drag_item";
pub fn new<Num: emath::Numeric>(value: &'a mut Num) -> Self { pub fn new<Num: emath::Numeric>(value: &'a mut Num) -> Self {
let slf = Self::from_get_set(move |v: Option<f64>| { let slf = Self::from_get_set(move |v: Option<f64>| {
if let Some(v) = v { if let Some(v) = v {
@@ -66,11 +66,12 @@ impl<'a> DragValue<'a> {
} }
pub fn from_get_set(get_set_value: impl 'a + FnMut(Option<f64>) -> f64) -> Self { pub fn from_get_set(get_set_value: impl 'a + FnMut(Option<f64>) -> f64) -> Self {
let atoms = Atoms::new(Atom::custom(Id::new(Self::ATOM_ID), Vec2::ZERO).atom_grow(true));
Self { Self {
get_set_value: Box::new(get_set_value), get_set_value: Box::new(get_set_value),
speed: 1.0, speed: 1.0,
prefix: Default::default(), atoms,
suffix: Default::default(),
range: f64::NEG_INFINITY..=f64::INFINITY, range: f64::NEG_INFINITY..=f64::INFINITY,
clamp_existing_to_range: true, clamp_existing_to_range: true,
min_decimals: 0, min_decimals: 0,
@@ -164,15 +165,15 @@ impl<'a> DragValue<'a> {
/// Show a prefix before the number, e.g. "x: " /// Show a prefix before the number, e.g. "x: "
#[inline] #[inline]
pub fn prefix(mut self, prefix: impl ToString) -> Self { pub fn prefix(mut self, prefix: impl IntoAtoms<'a>) -> Self {
self.prefix = prefix.to_string(); self.atoms.extend_left(prefix.into_atoms());
self self
} }
/// Add a suffix to the number, this can be e.g. a unit ("°" or " m") /// Add a suffix to the number, this can be e.g. a unit ("°" or " m")
#[inline] #[inline]
pub fn suffix(mut self, suffix: impl ToString) -> Self { pub fn suffix(mut self, suffix: impl IntoAtoms<'a>) -> Self {
self.suffix = suffix.to_string(); self.atoms.extend_right(suffix.into_atoms());
self self
} }
@@ -433,8 +434,7 @@ impl Widget for DragValue<'_> {
speed, speed,
range, range,
clamp_existing_to_range, clamp_existing_to_range,
prefix, mut atoms,
suffix,
min_decimals, min_decimals,
max_decimals, max_decimals,
custom_formatter, custom_formatter,
@@ -442,6 +442,23 @@ impl Widget for DragValue<'_> {
update_while_editing, update_while_editing,
} = self; } = self;
let mut prefix_text = String::new();
let mut suffix_text = String::new();
let mut past_value = false;
let atom_id = Id::new(Self::ATOM_ID);
for atom in atoms.iter() {
if atom.id == Some(atom_id) {
past_value = true;
}
if let AtomKind::Text(text) = &atom.kind {
if past_value {
suffix_text.push_str(text.text());
} else {
prefix_text.push_str(text.text());
}
}
}
let shift = ui.input(|i| i.modifiers.shift_only()); let shift = ui.input(|i| i.modifiers.shift_only());
// The widget has the same ID whether it's in edit or button mode. // The widget has the same ID whether it's in edit or button mode.
let id = ui.next_auto_id(); let id = ui.next_auto_id();
@@ -543,8 +560,6 @@ impl Widget for DragValue<'_> {
} }
} }
// some clones below are redundant if AccessKit is disabled
#[expect(clippy::redundant_clone)]
let mut response = if is_kb_editing { let mut response = if is_kb_editing {
let mut value_text = ui let mut value_text = ui
.data_mut(|data| data.remove_temp::<String>(id)) .data_mut(|data| data.remove_temp::<String>(id))
@@ -586,12 +601,19 @@ impl Widget for DragValue<'_> {
ui.data_mut(|data| data.insert_temp(id, value_text)); ui.data_mut(|data| data.insert_temp(id, value_text));
response response
} else { } else {
let button = Button::new( atoms.map_atoms(|atom| {
RichText::new(format!("{}{}{}", prefix, value_text.clone(), suffix)) if atom.id == Some(atom_id) {
.text_style(text_style), RichText::new(value_text.clone())
) .text_style(text_style.clone())
.into()
} else {
atom
}
});
let button = Button::new(atoms)
.wrap_mode(TextWrapMode::Extend) .wrap_mode(TextWrapMode::Extend)
.sense(Sense::click_and_drag()) .sense(Sense::click_and_drag())
.gap(0.0)
.min_size(ui.spacing().interact_size); // TODO(emilk): find some more generic solution to `min_size` .min_size(ui.spacing().interact_size); // TODO(emilk): find some more generic solution to `min_size`
let cursor_icon = if value <= *range.start() { let cursor_icon = if value <= *range.start() {
@@ -607,10 +629,8 @@ impl Widget for DragValue<'_> {
if ui.style().explanation_tooltips { if ui.style().explanation_tooltips {
response = response.on_hover_text(format!( response = response.on_hover_text(format!(
"{}{}{}\nDrag to edit or click to enter a value.\nPress 'Shift' while dragging for better control.", "{}\nDrag to edit or click to enter a value.\nPress 'Shift' while dragging for better control.",
prefix,
value as f32, // Show full precision value on-hover. TODO(emilk): figure out f64 vs f32 value as f32, // Show full precision value on-hover. TODO(emilk): figure out f64 vs f32
suffix
)); ));
} }
@@ -704,7 +724,7 @@ impl Widget for DragValue<'_> {
// The value is exposed as a string by the text edit widget // The value is exposed as a string by the text edit widget
// when in edit mode. // when in edit mode.
if !is_kb_editing { if !is_kb_editing {
let value_text = format!("{prefix}{value_text}{suffix}"); let value_text = format!("{prefix_text}{value_text}{suffix_text}");
builder.set_value(value_text); builder.set_value(value_text);
} }
}); });

View File

@@ -220,7 +220,7 @@ impl Label {
.rect_without_leading_space() .rect_without_leading_space()
.translate(pos.to_vec2()); .translate(pos.to_vec2());
let mut response = ui.allocate_rect(rect, sense); let mut response = ui.allocate_rect(rect, sense);
response.intrinsic_size = Some(galley.intrinsic_size()); response.set_intrinsic_size(galley.intrinsic_size());
for placed_row in galley.rows.iter().skip(1) { for placed_row in galley.rows.iter().skip(1) {
let rect = placed_row.rect().translate(pos.to_vec2()); let rect = placed_row.rect().translate(pos.to_vec2());
response |= ui.allocate_rect(rect, sense); response |= ui.allocate_rect(rect, sense);
@@ -256,7 +256,7 @@ impl Label {
let galley = ui.fonts_mut(|fonts| fonts.layout_job(layout_job)); let galley = ui.fonts_mut(|fonts| fonts.layout_job(layout_job));
let (rect, mut response) = ui.allocate_exact_size(galley.size(), sense); let (rect, mut response) = ui.allocate_exact_size(galley.size(), sense);
response.intrinsic_size = Some(galley.intrinsic_size()); response.set_intrinsic_size(galley.intrinsic_size());
let galley_pos = match galley.job.halign { let galley_pos = match galley.job.halign {
Align::LEFT => rect.left_top(), Align::LEFT => rect.left_top(),
Align::Center => rect.center_top(), Align::Center => rect.center_top(),

View File

@@ -1,15 +1,13 @@
use std::sync::Arc; use std::sync::Arc;
use emath::{Rect, TSTransform}; use emath::{Rect, TSTransform};
use epaint::{ use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
StrokeKind,
text::{Galley, LayoutJob, cursor::CCursor},
};
use crate::{ use crate::{
Align, Align2, Color32, Context, CursorIcon, Event, EventFilter, FontSelection, Id, ImeEvent, Align, Align2, Atom, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, CursorIcon,
Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, Shape, TextBuffer, Event, EventFilter, FontSelection, Frame, Id, ImeEvent, IntoAtoms, IntoSizedResult, Key,
TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetWithState, epaint, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, SizedAtomKind, TextBuffer,
TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
os::OperatingSystem, os::OperatingSystem,
output::OutputEvent, output::OutputEvent,
response, text_selection, response, text_selection,
@@ -67,15 +65,16 @@ type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"] #[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct TextEdit<'t> { pub struct TextEdit<'t> {
text: &'t mut dyn TextBuffer, text: &'t mut dyn TextBuffer,
hint_text: WidgetText, prefix: Atoms<'static>,
hint_text_font: Option<FontSelection>, suffix: Atoms<'static>,
hint_text: Atoms<'static>,
id: Option<Id>, id: Option<Id>,
id_salt: Option<Id>, id_salt: Option<Id>,
font_selection: FontSelection, font_selection: FontSelection,
text_color: Option<Color32>, text_color: Option<Color32>,
layouter: Option<LayouterFn<'t>>, layouter: Option<LayouterFn<'t>>,
password: bool, password: bool,
frame: bool, frame: Option<Frame>,
margin: Margin, margin: Margin,
multiline: bool, multiline: bool,
interactive: bool, interactive: bool,
@@ -120,15 +119,16 @@ impl<'t> TextEdit<'t> {
pub fn multiline(text: &'t mut dyn TextBuffer) -> Self { pub fn multiline(text: &'t mut dyn TextBuffer) -> Self {
Self { Self {
text, text,
prefix: Default::default(),
suffix: Default::default(),
hint_text: Default::default(), hint_text: Default::default(),
hint_text_font: None,
id: None, id: None,
id_salt: None, id_salt: None,
font_selection: Default::default(), font_selection: Default::default(),
text_color: None, text_color: None,
layouter: None, layouter: None,
password: false, password: false,
frame: true, frame: None,
margin: Margin::symmetric(4, 2), margin: Margin::symmetric(4, 2),
multiline: true, multiline: true,
interactive: true, interactive: true,
@@ -202,8 +202,22 @@ impl<'t> TextEdit<'t> {
/// # }); /// # });
/// ``` /// ```
#[inline] #[inline]
pub fn hint_text(mut self, hint_text: impl Into<WidgetText>) -> Self { pub fn hint_text(mut self, hint_text: impl IntoAtoms<'static>) -> Self {
self.hint_text = hint_text.into(); self.hint_text = hint_text.into_atoms();
self
}
/// Add a prefix to the text edit. This will always be shown before the editable text.
#[inline]
pub fn prefix(mut self, prefix: impl IntoAtoms<'static>) -> Self {
self.prefix = prefix.into_atoms();
self
}
/// Add a suffix to the text edit. This will always be shown after the editable text.
#[inline]
pub fn suffix(mut self, suffix: impl IntoAtoms<'static>) -> Self {
self.suffix = suffix.into_atoms();
self self
} }
@@ -215,13 +229,6 @@ impl<'t> TextEdit<'t> {
self self
} }
/// Set a specific style for the hint text.
#[inline]
pub fn hint_text_font(mut self, hint_text_font: impl Into<FontSelection>) -> Self {
self.hint_text_font = Some(hint_text_font.into());
self
}
/// If true, hide the letters from view and prevent copying from the field. /// If true, hide the letters from view and prevent copying from the field.
#[inline] #[inline]
pub fn password(mut self, password: bool) -> Self { pub fn password(mut self, password: bool) -> Self {
@@ -290,10 +297,10 @@ impl<'t> TextEdit<'t> {
self self
} }
/// Default is `true`. If set to `false` there will be no frame showing that this is editable text! /// Customize the [`Frame`] around the text edit.
#[inline] #[inline]
pub fn frame(mut self, frame: bool) -> Self { pub fn frame(mut self, frame: Frame) -> Self {
self.frame = frame; self.frame = Some(frame);
self self
} }
@@ -402,7 +409,7 @@ impl<'t> TextEdit<'t> {
impl Widget for TextEdit<'_> { impl Widget for TextEdit<'_> {
fn ui(self, ui: &mut Ui) -> Response { fn ui(self, ui: &mut Ui) -> Response {
self.show(ui).response self.show(ui).response.response
} }
} }
@@ -423,63 +430,18 @@ impl TextEdit<'_> {
/// # }); /// # });
/// ``` /// ```
pub fn show(self, ui: &mut Ui) -> TextEditOutput { pub fn show(self, ui: &mut Ui) -> TextEditOutput {
let is_mutable = self.text.is_mutable();
let frame = self.frame;
let where_to_put_background = ui.painter().add(Shape::Noop);
let background_color = self
.background_color
.unwrap_or_else(|| ui.visuals().text_edit_bg_color());
let output = self.show_content(ui);
if frame {
let visuals = ui.style().interact(&output.response);
let frame_rect = output.response.rect.expand(visuals.expansion);
let shape = if is_mutable {
if output.response.has_focus() {
epaint::RectShape::new(
frame_rect,
visuals.corner_radius,
background_color,
ui.visuals().selection.stroke,
StrokeKind::Inside,
)
} else {
epaint::RectShape::new(
frame_rect,
visuals.corner_radius,
background_color,
visuals.bg_stroke, // TODO(emilk): we want to show something here, or a text-edit field doesn't "pop".
StrokeKind::Inside,
)
}
} else {
let visuals = &ui.style().visuals.widgets.inactive;
epaint::RectShape::stroke(
frame_rect,
visuals.corner_radius,
visuals.bg_stroke, // TODO(emilk): we want to show something here, or a text-edit field doesn't "pop".
StrokeKind::Inside,
)
};
ui.painter().set(where_to_put_background, shape);
}
output
}
fn show_content(self, ui: &mut Ui) -> TextEditOutput {
let TextEdit { let TextEdit {
text, text,
hint_text, prefix,
hint_text_font, suffix,
mut hint_text,
id, id,
id_salt, id_salt,
font_selection, font_selection,
text_color, text_color,
layouter, layouter,
password, password,
frame: _, frame,
margin, margin,
multiline, multiline,
interactive, interactive,
@@ -492,7 +454,7 @@ impl TextEdit<'_> {
clip_text, clip_text,
char_limit, char_limit,
return_key, return_key,
background_color: _, background_color,
} = self; } = self;
let text_color = text_color let text_color = text_color
@@ -501,18 +463,16 @@ impl TextEdit<'_> {
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()); .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
let prev_text = text.as_str().to_owned(); let prev_text = text.as_str().to_owned();
let hint_text_str = hint_text.text().to_owned(); let hint_text_str = hint_text.text().unwrap_or_default().to_string();
let font_id = font_selection.resolve(ui.style()); let font_id = font_selection.resolve(ui.style());
let row_height = ui.fonts_mut(|f| f.row_height(&font_id)); let row_height = ui.fonts_mut(|f| f.row_height(&font_id));
const MIN_WIDTH: f32 = 24.0; // Never make a [`TextEdit`] more narrow than this. const MIN_WIDTH: f32 = 24.0; // Never make a [`TextEdit`] more narrow than this.
let available_width = (ui.available_width() - margin.sum().x).at_least(MIN_WIDTH); let available_width = ui.available_width().at_least(MIN_WIDTH);
let desired_width = desired_width.unwrap_or_else(|| ui.spacing().text_edit_width); let desired_width = desired_width
let wrap_width = if ui.layout().horizontal_justify() { .unwrap_or_else(|| ui.spacing().text_edit_width)
available_width .at_least(min_size.x);
} else { let allocate_width = desired_width.at_most(available_width);
desired_width.min(available_width)
};
let font_id_clone = font_id.clone(); let font_id_clone = font_id.clone();
let mut default_layouter = move |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| { let mut default_layouter = move |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| {
@@ -527,27 +487,18 @@ impl TextEdit<'_> {
let layouter = layouter.unwrap_or(&mut default_layouter); let layouter = layouter.unwrap_or(&mut default_layouter);
let mut galley = layouter(ui, text, wrap_width); let min_inner_height = (desired_height_rows.at_least(1) as f32) * row_height;
let desired_inner_width = if clip_text {
wrap_width // visual clipping with scroll in singleline input.
} else {
galley.size().x.max(wrap_width)
};
let desired_height = (desired_height_rows.at_least(1) as f32) * row_height;
let desired_inner_size = vec2(desired_inner_width, galley.size().y.max(desired_height));
let desired_outer_size = (desired_inner_size + margin.sum()).at_least(min_size);
let (auto_id, outer_rect) = ui.allocate_space(desired_outer_size);
let rect = outer_rect - margin; // inner rect (excluding frame/margin).
let id = id.unwrap_or_else(|| { let id = id.unwrap_or_else(|| {
if let Some(id_salt) = id_salt { if let Some(id_salt) = id_salt {
ui.make_persistent_id(id_salt) ui.make_persistent_id(id_salt)
} else { } else {
auto_id // Since we are only storing the cursor a persistent Id is not super important // Since we are only storing the cursor a persistent Id is not super important
let id = ui.next_auto_id();
ui.skip_ahead_auto_ids(1);
id
} }
}); });
let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
// On touch screens (e.g. mobile in `eframe` web), should // On touch screens (e.g. mobile in `eframe` web), should
// dragging select text, or scroll the enclosing [`ScrollArea`] (if any)? // dragging select text, or scroll the enclosing [`ScrollArea`] (if any)?
@@ -565,12 +516,218 @@ impl TextEdit<'_> {
} else { } else {
Sense::hover() Sense::hover()
}; };
let mut response = ui.interact(outer_rect, id, sense);
response.intrinsic_size = Some(Vec2::new(desired_width, desired_outer_size.y));
// Don't sent `OutputEvent::Clicked` when a user presses the space bar let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
let mut cursor_range = None;
let mut prev_cursor_range = None;
let mut text_changed = false;
let text_mutable = text.is_mutable();
let mut handle_events = |ui: &Ui, galley: &mut Arc<Galley>, layouter, wrap_width, text| {
if interactive && ui.memory(|mem| mem.has_focus(id)) {
ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter));
let default_cursor_range = if cursor_at_end {
CCursorRange::one(galley.end())
} else {
CCursorRange::default()
};
prev_cursor_range = state.cursor.range(galley);
let (changed, new_cursor_range) = events(
ui,
&mut state,
text,
galley,
layouter,
id,
wrap_width,
multiline,
password,
default_cursor_range,
char_limit,
event_filter,
return_key,
);
if changed {
text_changed = true;
}
cursor_range = Some(new_cursor_range);
}
};
// We need to calculate the galley within the atom closure, so we can calculate it based on
// the available width (in case of wrapping multiline text edits). But we show it later,
// so we can clip it to the available size. Thus, extract it from the atom closure here.
let mut get_galley = None;
let inner_rect_id = Id::new("text_edit_rect");
let mut response = {
let any_shrink = hint_text.any_shrink();
// Ideally we could just do `let mut atoms = prefix` here, but that won't compile
// but due to servo/rust-smallvec#146 (also see the comment below).
let mut atoms: Atoms<'_> = Atoms::new(());
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
// smallvec 2.0. Using `extend_right` here won't compile, due to lifetime issues.
for atom in prefix {
atoms.push_right(atom);
}
if text.as_str().is_empty() && !hint_text.is_empty() {
// Add hint_text (if any):
let mut shrunk = any_shrink;
let mut first = true;
// Since we can't set a fallback color per atom, we have to override it here.
// Sucks, since it means users won't be able to override it.
hint_text.map_texts(|t| t.color(ui.style().visuals.weak_text_color()));
for mut atom in hint_text {
if !shrunk && matches!(atom.kind, AtomKind::Text(_)) {
// elide the hint_text if needed
atom = atom.atom_shrink(true);
shrunk = true;
}
if first {
// The first atom in the hint text gets inner_rect_id, so we can know
// where to paint the cursor
atom = atom.atom_id(inner_rect_id);
first = false;
}
// The hint text should be shown left top instead of centered (important for
// multi line text edits)
atoms.push_right(atom.atom_align(Align2::LEFT_TOP));
}
// Calculate the empty galley, so it can be read later. The available width is
// technically wrong, but doesn't matter since the galley is empty
let available_width = allocate_width - margin.sum().x;
let galley = layouter(ui, text, available_width);
// We can't update the galley immediately here, since it would show both hint text
// and the newly typed letter. So we pass a clone instead, and accept having a frame
// delay on the very first keystroke.
let mut galley_clone = Arc::clone(&galley);
handle_events(ui, &mut galley_clone, layouter, available_width, text);
get_galley = Some(galley);
} else {
// We need a closure here, so we can calculate the galley based on the available
// width (after adding suffix and prefix), for correct wrapping in multi line text
// edits
atoms.push_right(
AtomKind::closure(|ui, args| {
let mut galley = layouter(ui, text, args.available_size.x);
// Handling events here allows us to update the galley immediately on
// keystrokes, avoiding frame delays, and ensuring the scroll_to within
// ScrollAreas works correctly.
handle_events(ui, &mut galley, layouter, args.available_size.x, text);
let intrinsic_size = galley.intrinsic_size();
let mut size = galley.size();
size.y = size.y.at_least(min_inner_height);
if clip_text {
size.x = size.x.at_most(args.available_size.x);
}
// We paint the galley later, so we can do clipping and offsetting
get_galley = Some(galley);
IntoSizedResult {
intrinsic_size,
sized: SizedAtomKind::Empty { size: Some(size) },
}
})
.atom_id(inner_rect_id)
.atom_shrink(clip_text),
);
}
// Ensure the suffix is always right-aligned
if !suffix.is_empty() {
atoms.push_right(Atom::grow());
}
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
// smallvec 2.0. Using `extend_right` here won't compile, due to lifetime issues.
for atom in suffix {
atoms.push_right(atom);
}
let custom_frame = frame.is_some();
let frame = frame.unwrap_or_else(|| Frame::new().inner_margin(margin));
let min_height = min_inner_height + frame.total_margin().sum().y;
// This wrap mode only affects the hint_text
let wrap_mode = if multiline {
TextWrapMode::Wrap
} else {
TextWrapMode::Truncate
};
let mut allocated = AtomLayout::new(atoms)
.id(id)
.min_size(Vec2::new(allocate_width, min_height))
.max_width(allocate_width)
.sense(sense)
.frame(frame)
.align2(Align2::LEFT_TOP)
.wrap_mode(wrap_mode)
.allocate(ui);
allocated.frame = if !custom_frame {
let visuals = ui.style().interact(&allocated.response);
let background_color =
background_color.unwrap_or_else(|| ui.visuals().text_edit_bg_color());
let (corner_radius, background_color, stroke) = if text_mutable {
if allocated.response.has_focus() {
(
visuals.corner_radius,
background_color,
ui.visuals().selection.stroke,
)
} else {
(visuals.corner_radius, background_color, visuals.bg_stroke)
}
} else {
let visuals = &ui.style().visuals.widgets.inactive;
(
visuals.corner_radius,
Color32::TRANSPARENT,
visuals.bg_stroke,
)
};
allocated
.frame
.fill(background_color)
.corner_radius(corner_radius)
.inner_margin(
allocated.frame.inner_margin
+ Margin::same((visuals.expansion - stroke.width).round() as i8),
)
.outer_margin(Margin::same(-(visuals.expansion as i8)))
.stroke(stroke)
} else {
allocated.frame
};
allocated.paint(ui)
};
let inner_rect = response.rect(inner_rect_id).unwrap_or(Rect::ZERO);
// Our atom closure was now called, so the galley should always be available here
let mut galley = get_galley.expect("Galley should be available here");
// Don't send `OutputEvent::Clicked` when a user presses the space bar
response.flags -= response::Flags::FAKE_PRIMARY_CLICKED; response.flags -= response::Flags::FAKE_PRIMARY_CLICKED;
let text_clip_rect = rect; let text_clip_rect = inner_rect;
let painter = ui.painter_at(text_clip_rect.expand(1.0)); // expand to avoid clipping cursor let painter = ui.painter_at(text_clip_rect.expand(1.0)); // expand to avoid clipping cursor
if interactive && let Some(pointer_pos) = response.interact_pointer_pos() { if interactive && let Some(pointer_pos) = response.interact_pointer_pos() {
@@ -581,19 +738,19 @@ impl TextEdit<'_> {
// TODO(emilk): drag selected text to either move or clone (ctrl on windows, alt on mac) // TODO(emilk): drag selected text to either move or clone (ctrl on windows, alt on mac)
let cursor_at_pointer = let cursor_at_pointer =
galley.cursor_from_pos(pointer_pos - rect.min + state.text_offset); galley.cursor_from_pos(pointer_pos - inner_rect.min + state.text_offset);
if ui.visuals().text_cursor.preview if ui.visuals().text_cursor.preview
&& response.hovered() && response.hovered()
&& ui.input(|i| i.pointer.is_moving()) && ui.input(|i| i.pointer.is_moving())
{ {
// text cursor preview: // text cursor preview:
let cursor_rect = TSTransform::from_translation(rect.min.to_vec2()) let cursor_rect = TSTransform::from_translation(inner_rect.min.to_vec2())
* cursor_rect(&galley, &cursor_at_pointer, row_height); * cursor_rect(&galley, &cursor_at_pointer, row_height);
text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect); text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect);
} }
let is_being_dragged = ui.ctx().is_being_dragged(response.id); let is_being_dragged = ui.is_being_dragged(response.id);
let did_interact = state.cursor.pointer_interaction( let did_interact = state.cursor.pointer_interaction(
ui, ui,
&response, &response,
@@ -613,44 +770,15 @@ impl TextEdit<'_> {
ui.set_cursor_icon(CursorIcon::Text); ui.set_cursor_icon(CursorIcon::Text);
} }
let mut cursor_range = None; if text_changed {
let prev_cursor_range = state.cursor.range(&galley);
if interactive && ui.memory(|mem| mem.has_focus(id)) {
ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter));
let default_cursor_range = if cursor_at_end {
CCursorRange::one(galley.end())
} else {
CCursorRange::default()
};
let (changed, new_cursor_range) = events(
ui,
&mut state,
text,
&mut galley,
layouter,
id,
wrap_width,
multiline,
password,
default_cursor_range,
char_limit,
event_filter,
return_key,
);
if changed {
response.mark_changed(); response.mark_changed();
} }
cursor_range = Some(new_cursor_range);
}
let mut galley_pos = align let mut galley_pos = align
.align_size_within_rect(galley.size(), rect) .align_size_within_rect(galley.size(), inner_rect)
.intersect(rect) // limit pos to the response rect area .intersect(inner_rect) // limit pos to the response rect area
.min; .min;
let align_offset = rect.left_top() - galley_pos; let align_offset = inner_rect.left_top() - galley_pos;
// Visual clipping for singleline text editor with text larger than width // Visual clipping for singleline text editor with text larger than width
if clip_text && align_offset.x == 0.0 { if clip_text && align_offset.x == 0.0 {
@@ -660,18 +788,18 @@ impl TextEdit<'_> {
}; };
let mut offset_x = state.text_offset.x; let mut offset_x = state.text_offset.x;
let visible_range = offset_x..=offset_x + desired_inner_size.x; let visible_range = offset_x..=offset_x + inner_rect.width();
if !visible_range.contains(&cursor_pos) { if !visible_range.contains(&cursor_pos) {
if cursor_pos < *visible_range.start() { if cursor_pos < *visible_range.start() {
offset_x = cursor_pos; offset_x = cursor_pos;
} else { } else {
offset_x = cursor_pos - desired_inner_size.x; offset_x = cursor_pos - inner_rect.width();
} }
} }
offset_x = offset_x offset_x = offset_x
.at_most(galley.size().x - desired_inner_size.x) .at_most(galley.size().x - inner_rect.width())
.at_least(0.0); .at_least(0.0);
state.text_offset = vec2(offset_x, align_offset.y); state.text_offset = vec2(offset_x, align_offset.y);
@@ -688,32 +816,7 @@ impl TextEdit<'_> {
false false
}; };
if ui.is_rect_visible(rect) { if ui.is_rect_visible(inner_rect) {
if text.as_str().is_empty() && !hint_text.is_empty() {
let hint_text_color = ui.visuals().weak_text_color();
let hint_text_font_id = hint_text_font.unwrap_or_else(|| font_id.into());
let galley = if multiline {
hint_text.into_galley(
ui,
Some(TextWrapMode::Wrap),
desired_inner_size.x,
hint_text_font_id,
)
} else {
hint_text.into_galley(
ui,
Some(TextWrapMode::Extend),
f32::INFINITY,
hint_text_font_id,
)
};
let galley_pos = align
.align_size_within_rect(galley.size(), rect)
.intersect(rect)
.min;
painter.galley(galley_pos, galley, hint_text_color);
}
let has_focus = ui.memory(|mem| mem.has_focus(id)); let has_focus = ui.memory(|mem| mem.has_focus(id));
if has_focus && let Some(cursor_range) = state.cursor.range(&galley) { if has_focus && let Some(cursor_range) = state.cursor.range(&galley) {
@@ -721,45 +824,11 @@ impl TextEdit<'_> {
paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None); paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None);
} }
// Allocate additional space if edits were made this frame that changed the size. This is important so that, painter.galley(
// if there's a ScrollArea, it can properly scroll to the cursor. galley_pos - vec2(galley.rect.left(), 0.0),
// Condition `!clip_text` is important to avoid breaking layout for `TextEdit::singleline` (PR #5640) Arc::clone(&galley),
if !clip_text text_color,
&& let extra_size = galley.size() - rect.size()
&& (extra_size.x > 0.0 || extra_size.y > 0.0)
{
match ui.layout().main_dir() {
crate::Direction::LeftToRight | crate::Direction::TopDown => {
ui.allocate_rect(
Rect::from_min_size(outer_rect.max, extra_size),
Sense::hover(),
); );
}
crate::Direction::RightToLeft => {
ui.allocate_rect(
Rect::from_min_size(
emath::pos2(outer_rect.min.x - extra_size.x, outer_rect.max.y),
extra_size,
),
Sense::hover(),
);
}
crate::Direction::BottomUp => {
ui.allocate_rect(
Rect::from_min_size(
emath::pos2(outer_rect.min.x, outer_rect.max.y - extra_size.y),
extra_size,
),
Sense::hover(),
);
}
}
} else {
// Avoid an ID shift during this pass if the textedit grow
ui.skip_ahead_auto_ids(1);
}
painter.galley(galley_pos, Arc::clone(&galley), text_color);
if has_focus && let Some(cursor_range) = state.cursor.range(&galley) { if has_focus && let Some(cursor_range) = state.cursor.range(&galley) {
let primary_cursor_rect = cursor_rect(&galley, &cursor_range.primary, row_height) let primary_cursor_rect = cursor_rect(&galley, &cursor_range.primary, row_height)
@@ -767,7 +836,7 @@ impl TextEdit<'_> {
if response.changed() || selection_changed { if response.changed() || selection_changed {
// Scroll to keep primary cursor in view: // Scroll to keep primary cursor in view:
ui.scroll_to_rect(primary_cursor_rect + margin, None); ui.scroll_to_rect(primary_cursor_rect, None);
} }
if text.is_mutable() && interactive { if text.is_mutable() && interactive {
@@ -796,9 +865,9 @@ impl TextEdit<'_> {
.layer_transform_to_global(ui.layer_id()) .layer_transform_to_global(ui.layer_id())
.unwrap_or_default(); .unwrap_or_default();
ui.ctx().output_mut(|o| { ui.output_mut(|o| {
o.ime = Some(crate::output::IMEOutput { o.ime = Some(crate::output::IMEOutput {
rect: to_global * rect, rect: to_global * inner_rect,
cursor_rect: to_global * primary_cursor_rect, cursor_rect: to_global * primary_cursor_rect,
}); });
}); });
@@ -846,7 +915,6 @@ impl TextEdit<'_> {
}); });
} }
{
let role = if password { let role = if password {
accesskit::Role::PasswordInput accesskit::Role::PasswordInput
} else if multiline { } else if multiline {
@@ -863,7 +931,6 @@ impl TextEdit<'_> {
TSTransform::from_translation(galley_pos.to_vec2()), TSTransform::from_translation(galley_pos.to_vec2()),
&galley, &galley,
); );
}
TextEditOutput { TextEditOutput {
response, response,
@@ -911,7 +978,7 @@ fn events(
event_filter: EventFilter, event_filter: EventFilter,
return_key: Option<KeyboardShortcut>, return_key: Option<KeyboardShortcut>,
) -> (bool, CCursorRange) { ) -> (bool, CCursorRange) {
let os = ui.ctx().os(); let os = ui.os();
let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range);
@@ -930,13 +997,7 @@ fn events(
let mut any_change = false; let mut any_change = false;
let mut events = ui.input(|i| i.filtered_events(&event_filter)); let events = ui.input(|i| i.filtered_events(&event_filter));
if state.ime_enabled {
remove_ime_incompatible_events(&mut events);
// Process IME events first:
events.sort_by_key(|e| !matches!(e, Event::Ime(_)));
}
for event in &events { for event in &events {
let did_mutate_text = match event { let did_mutate_text = match event {
@@ -1066,26 +1127,36 @@ fn events(
} => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key),
Event::Ime(ime_event) => { Event::Ime(ime_event) => {
/// Empty prediction can be produced with [`ImeEvent::Preedit`] /// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")`
/// or [`ImeEvent::Commit`] when user press backspace or escape /// might be emitted from different integrations to signify that
/// during IME, so this function should be called in both cases /// the current IME composition should be cleared.
/// to clear current text.
/// ///
/// Example platforms where only `ImeEvent::Preedit("")` of /// Example integrations where only `ImeEvent::Preedit("")` of
/// those two events is emitted when the last character in the /// those two events is emitted when the last character is
/// prediction is deleted: /// deleted with a backspace:
/// - macOS 15.7.3. /// - `egui-winit` on macOS 15.7.3.
/// - Debian13 with gnome48 and wayland. /// - `egui-winit` on Debian13 with gnome48 and wayland.
/// ///
/// An example platform where only `ImeEvent::Commit("")` of /// An example integration where only `ImeEvent::Commit("")` of
/// those two events is emitted when the last character in the /// those two events is emitted when the last character is
/// prediction is deleted: /// deleted with a backspace:
/// - Safari 26.2 (on macOS 15.7.3). /// - `eframe`'s web integration on Safari 26.2 (on macOS
fn clear_prediction( /// 15.7.3).
///
/// ## Note
///
/// The term “pre-edit string” is used by X11 and Wayland, and
/// we use “pre-edit text” and “pre-edit range” here in the
/// same manner.
/// See: <https://wayland.app/protocols/input-method-unstable-v2>
///
/// We previously referred to “pre-edit text” as “prediction”,
/// which is not standard and can mean different things.
fn clear_preedit_text(
text: &mut dyn TextBuffer, text: &mut dyn TextBuffer,
cursor_range: &CCursorRange, preedit_range: &CCursorRange,
) -> CCursor { ) -> CCursor {
text.delete_selected(cursor_range) text.delete_selected(preedit_range)
} }
match ime_event { match ime_event {
@@ -1094,33 +1165,33 @@ fn events(
state.ime_cursor_range = cursor_range; state.ime_cursor_range = cursor_range;
None None
} }
ImeEvent::Preedit(text_mark) => { ImeEvent::Preedit(preedit_text) => {
if text_mark == "\n" || text_mark == "\r" { if preedit_text == "\n" || preedit_text == "\r" {
None None
} else { } else {
let mut ccursor = clear_prediction(text, &cursor_range); let mut ccursor = clear_preedit_text(text, &cursor_range);
let start_cursor = ccursor; let start_cursor = ccursor;
if !text_mark.is_empty() { if !preedit_text.is_empty() {
text.insert_text_at(&mut ccursor, text_mark, char_limit); text.insert_text_at(&mut ccursor, preedit_text, char_limit);
} }
state.ime_cursor_range = cursor_range; state.ime_cursor_range = cursor_range;
Some(CCursorRange::two(start_cursor, ccursor)) Some(CCursorRange::two(start_cursor, ccursor))
} }
} }
ImeEvent::Commit(prediction) => { ImeEvent::Commit(commit_text) => {
if prediction == "\n" || prediction == "\r" { if commit_text == "\n" || commit_text == "\r" {
None None
} else { } else {
state.ime_enabled = false; state.ime_enabled = false;
let mut ccursor = clear_prediction(text, &cursor_range); let mut ccursor = clear_preedit_text(text, &cursor_range);
if !prediction.is_empty() if !commit_text.is_empty()
&& cursor_range.secondary.index && cursor_range.secondary.index
== state.ime_cursor_range.secondary.index == state.ime_cursor_range.secondary.index
{ {
text.insert_text_at(&mut ccursor, prediction, char_limit); text.insert_text_at(&mut ccursor, commit_text, char_limit);
} }
Some(CCursorRange::one(ccursor)) Some(CCursorRange::one(ccursor))
@@ -1159,27 +1230,6 @@ fn events(
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
fn remove_ime_incompatible_events(events: &mut Vec<Event>) {
// Remove key events which cause problems while 'IME' is being used.
// See https://github.com/emilk/egui/pull/4509
events.retain(|event| {
!matches!(
event,
Event::Key { repeat: true, .. }
| Event::Key {
key: Key::Backspace
| Key::ArrowUp
| Key::ArrowDown
| Key::ArrowLeft
| Key::ArrowRight,
..
}
)
});
}
// ----------------------------------------------------------------------------
/// Returns `Some(new_cursor)` if we did mutate `text`. /// Returns `Some(new_cursor)` if we did mutate `text`.
fn check_for_mutating_key_press( fn check_for_mutating_key_press(
os: OperatingSystem, os: OperatingSystem,

View File

@@ -5,7 +5,7 @@ use crate::text::CCursorRange;
/// The output from a [`TextEdit`](crate::TextEdit). /// The output from a [`TextEdit`](crate::TextEdit).
pub struct TextEditOutput { pub struct TextEditOutput {
/// The interaction response. /// The interaction response.
pub response: crate::Response, pub response: crate::AtomLayoutResponse,
/// How the text was displayed. /// How the text was displayed.
pub galley: Arc<crate::Galley>, pub galley: Arc<crate::Galley>,

View File

@@ -25,10 +25,10 @@ crate-type = ["cdylib", "rlib"]
[features] [features]
default = ["wgpu", "persistence"] default = ["wgpu", "persistence"]
# image_viewer adds about 0.9 MB of WASM
web_app = ["http", "persistence"] web_app = ["http", "persistence"]
accessibility_inspector = ["dep:accesskit", "dep:accesskit_consumer", "eframe/accesskit"] accessibility_inspector = ["dep:accesskit", "dep:accesskit_consumer", "eframe/accesskit"]
easymark = [] # easymark is off by default, because it a pretty shitty markup language
http = ["ehttp", "image/jpeg", "poll-promise", "egui_extras/image"] http = ["ehttp", "image/jpeg", "poll-promise", "egui_extras/image"]
image_viewer = ["image/jpeg", "egui_extras/all_loaders", "rfd"] image_viewer = ["image/jpeg", "egui_extras/all_loaders", "rfd"]
persistence = ["eframe/persistence", "egui_extras/serde", "egui/persistence", "serde"] persistence = ["eframe/persistence", "egui_extras/serde", "egui/persistence", "serde"]
@@ -42,15 +42,15 @@ wayland = ["eframe/wayland"]
x11 = ["eframe/x11"] x11 = ["eframe/x11"]
[dependencies] [dependencies]
chrono = { workspace = true, features = ["js-sys", "wasmbind"] }
eframe = { workspace = true, default-features = false, features = ["web_screen_reader"] } eframe = { workspace = true, default-features = false, features = ["web_screen_reader"] }
egui = { workspace = true, features = ["callstack", "default"] } egui = { workspace = true, features = ["callstack", "default"] }
egui_demo_lib = { workspace = true, features = ["default", "chrono"] } egui_demo_lib = { workspace = true, features = ["default", "jiff"] }
egui_extras = { workspace = true, features = ["default", "image"] } egui_extras = { workspace = true, features = ["default", "image"] }
image = { workspace = true, default-features = false, features = [ image = { workspace = true, default-features = false, features = [
# Ensure we can display the test images # Ensure we can display the test images
"png", "png",
] } ] }
jiff = { workspace = true, features = ["std", "tz-system", "js"] }
log.workspace = true log.workspace = true
profiling.workspace = true profiling.workspace = true

View File

@@ -1,7 +1,7 @@
use std::mem; use std::mem;
use accesskit::{Action, ActionRequest, NodeId}; use accesskit::{Action, ActionRequest};
use accesskit_consumer::{FilterResult, Node, Tree, TreeChangeHandler}; use accesskit_consumer::{FilterResult, Node, NodeId, Tree, TreeChangeHandler};
use eframe::epaint::text::TextWrapMode; use eframe::epaint::text::TextWrapMode;
use egui::{ use egui::{
@@ -25,7 +25,7 @@ use egui::{
pub struct AccessibilityInspectorPlugin { pub struct AccessibilityInspectorPlugin {
pub open: bool, pub open: bool,
tree: Option<accesskit_consumer::Tree>, tree: Option<accesskit_consumer::Tree>,
selected_node: Option<Id>, selected_node: Option<NodeId>,
queued_action: Option<ActionRequest>, queued_action: Option<ActionRequest>,
} }
@@ -113,13 +113,17 @@ impl AccessibilityInspectorPlugin {
Id::new("Accessibility Inspector") Id::new("Accessibility Inspector")
} }
fn selection_ui(&mut self, ui: &mut Ui, selected_node: Id) { fn selection_ui(&mut self, ui: &mut Ui, selected_node: NodeId) {
ui.separator(); ui.separator();
if let Some(tree) = &self.tree if let Some(tree) = &self.tree
&& let Some(node) = tree.state().node_by_id(NodeId::from(selected_node.value())) && let Some(node) = tree.state().node_by_id(selected_node)
{ {
let node_response = ui.ctx().read_response(selected_node); // Safety: This is safe since the `accesskit::NodeId` was created from an `egui::Id`.
#[expect(unsafe_code)]
let egui_node_id = unsafe { Id::from_high_entropy_bits(node.locate().0.0) };
let node_response = ui.ctx().read_response(egui_node_id);
if let Some(widget_response) = node_response { if let Some(widget_response) = node_response {
ui.debug_painter().debug_rect( ui.debug_painter().debug_rect(
@@ -174,8 +178,10 @@ impl AccessibilityInspectorPlugin {
if node.supports_action(action, &|_node| FilterResult::Include) if node.supports_action(action, &|_node| FilterResult::Include)
&& ui.button(format!("{action:?}")).clicked() && ui.button(format!("{action:?}")).clicked()
{ {
let (target_node, target_tree) = node.locate();
let action_request = ActionRequest { let action_request = ActionRequest {
target: node.id(), target_node,
target_tree,
action, action,
data: None, data: None,
}; };
@@ -188,8 +194,8 @@ impl AccessibilityInspectorPlugin {
} }
} }
fn node_ui(ui: &mut Ui, node: &Node<'_>, selected_node: &mut Option<Id>) { fn node_ui(ui: &mut Ui, node: &Node<'_>, selected_node: &mut Option<NodeId>) {
if node.id() == Self::id().value().into() if node.locate() == (Self::id().value().into(), accesskit::TreeId::ROOT)
|| node || node
.value() .value()
.as_deref() .as_deref()
@@ -200,12 +206,12 @@ impl AccessibilityInspectorPlugin {
let label = node let label = node
.label() .label()
.or_else(|| node.value()) .or_else(|| node.value())
.unwrap_or_else(|| node.id().0.to_string()); .unwrap_or_else(|| node.locate().0.0.to_string());
let label = format!("({:?}) {}", node.role(), label); let label = format!("({:?}) {}", node.role(), label);
// Safety: This is safe since the `accesskit::NodeId` was created from an `egui::Id`. // Safety: This is safe since the `accesskit::NodeId` was created from an `egui::Id`.
#[expect(unsafe_code)] #[expect(unsafe_code)]
let egui_node_id = unsafe { Id::from_high_entropy_bits(node.id().0) }; let egui_node_id = unsafe { Id::from_high_entropy_bits(node.locate().0.0) };
ui.push_id(node.id(), |ui| { ui.push_id(node.id(), |ui| {
let child_count = node.children().len(); let child_count = node.children().len();
@@ -228,7 +234,7 @@ impl AccessibilityInspectorPlugin {
collapsing.set_open(!collapsing.is_open()); collapsing.set_open(!collapsing.is_open());
} }
let label_response = let label_response =
ui.selectable_value(selected_node, Some(egui_node_id), label.clone()); ui.selectable_value(selected_node, Some(node.id()), label.clone());
if label_response.hovered() { if label_response.hovered() {
let widget_response = ui.ctx().read_response(egui_node_id); let widget_response = ui.ctx().read_response(egui_node_id);

View File

@@ -40,8 +40,8 @@ impl Custom3d {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("custom3d"), label: Some("custom3d"),
bind_group_layouts: &[&bind_group_layout], bind_group_layouts: &[Some(&bind_group_layout)],
push_constant_ranges: &[], immediate_size: 0,
}); });
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -62,7 +62,7 @@ impl Custom3d {
primitive: wgpu::PrimitiveState::default(), primitive: wgpu::PrimitiveState::default(),
depth_stencil: None, depth_stencil: None,
multisample: wgpu::MultisampleState::default(), multisample: wgpu::MultisampleState::default(),
multiview: None, multiview_mask: None,
cache: None, cache: None,
}); });

View File

@@ -219,6 +219,10 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
driver, driver,
driver_info, driver_info,
backend, backend,
device_pci_bus_id,
subgroup_min_size,
subgroup_max_size,
transient_saves_memory,
} = &info; } = &info;
// Example values: // Example values:
@@ -261,6 +265,19 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
ui.label(format!("0x{device:02X}")); ui.label(format!("0x{device:02X}"));
ui.end_row(); ui.end_row();
} }
if !device_pci_bus_id.is_empty() {
ui.label("PCI Bus ID:");
ui.label(device_pci_bus_id.as_str());
ui.end_row();
}
if *subgroup_min_size != 0 || *subgroup_max_size != 0 {
ui.label("Subgroup size:");
ui.label(format!("{subgroup_min_size}..={subgroup_max_size}"));
ui.end_row();
}
ui.label("Transient saves memory:");
ui.label(format!("{transient_saves_memory}"));
ui.end_row();
}); });
}; };

View File

@@ -9,9 +9,10 @@ pub use wrap_app::{Anchor, WrapApp};
/// Time of day as seconds since midnight. Used for clock in demo app. /// Time of day as seconds since midnight. Used for clock in demo app.
pub(crate) fn seconds_since_midnight() -> f64 { pub(crate) fn seconds_since_midnight() -> f64 {
use chrono::Timelike as _; jiff::Zoned::now()
let time = chrono::Local::now().time(); .time()
time.num_seconds_from_midnight() as f64 + 1e-9 * (time.nanosecond() as f64) .duration_since(jiff::civil::Time::midnight())
.as_secs_f64()
} }
/// Trait that wraps different parts of the demo app. /// Trait that wraps different parts of the demo app.

View File

@@ -8,12 +8,14 @@ use core::any::Any;
use crate::DemoApp; use crate::DemoApp;
#[cfg(feature = "easymark")]
#[derive(Default)] #[derive(Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
struct EasyMarkApp { struct EasyMarkApp {
editor: egui_demo_lib::easy_mark::EasyMarkEditor, editor: egui_demo_lib::easy_mark::EasyMarkEditor,
} }
#[cfg(feature = "easymark")]
impl DemoApp for EasyMarkApp { impl DemoApp for EasyMarkApp {
fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { fn demo_ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.editor.panels(ui); self.editor.panels(ui);
@@ -152,12 +154,18 @@ enum Command {
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub struct State { pub struct State {
demo: DemoWindows, demo: DemoWindows,
#[cfg(feature = "easymark")]
easy_mark_editor: EasyMarkApp, easy_mark_editor: EasyMarkApp,
#[cfg(feature = "http")] #[cfg(feature = "http")]
http: crate::apps::HttpApp, http: crate::apps::HttpApp,
#[cfg(feature = "image_viewer")] #[cfg(feature = "image_viewer")]
image_viewer: crate::apps::ImageViewer, image_viewer: crate::apps::ImageViewer,
pub clock: FractalClockApp, pub clock: FractalClockApp,
rendering_test: ColorTestApp, rendering_test: ColorTestApp,
selected_anchor: Anchor, selected_anchor: Anchor,
@@ -212,6 +220,7 @@ impl WrapApp {
Anchor::Demo, Anchor::Demo,
&mut self.state.demo as &mut dyn DemoApp, &mut self.state.demo as &mut dyn DemoApp,
), ),
#[cfg(feature = "easymark")]
( (
"🖹 EasyMark editor", "🖹 EasyMark editor",
Anchor::EasyMarkEditor, Anchor::EasyMarkEditor,
@@ -400,6 +409,8 @@ impl WrapApp {
} }
fn bar_contents(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame, cmd: &mut Command) { fn bar_contents(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame, cmd: &mut Command) {
ui.add_space(8.0);
egui::widgets::global_theme_preference_switch(ui); egui::widgets::global_theme_preference_switch(ui);
ui.separator(); ui.separator();

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5878bc5beaf4406c24f23d900aa9ac7c5507e44cb3ade83b743b8b62e7da1615 oid sha256:63021012cccfca02d09aa424333453140ae4da3ae58fa32b422f6152ba25741c
size 335355 size 335394

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:706ad012e52a8c51175b050b985cca88e2cb306b24f618b7391641397d17cd28 oid sha256:4470063fe210d2e5170d6609c2603fff1984b8ee76fb65a1f60a1c4cfdf46ce8
size 92804 size 92796

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4135662f2b60a10ef8c3b155172d7a3edcf24a625d8286aeaad0614aa8819893 oid sha256:b9ad01a55950f96a3ae9e48a2c026143d11ffee62bff4f83b4529cd884ce11f0
size 169604 size 169683

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:509020d8885b718900e534c9948cb95ae88e1eee9e113bdfb77a2f75b9a68f7b oid sha256:6030f2f3da3dbbdf8bf3eaf429f222acffb624c7696b654d8b6e64273d49be58
size 96703 size 99008

View File

@@ -11,7 +11,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui/tree/main/crates/egui_demo_lib" repository = "https://github.com/emilk/egui/tree/main/crates/egui_demo_lib"
categories = ["gui", "graphics"] categories = ["gui", "graphics"]
keywords = ["glow", "egui", "gui", "gamedev"] keywords = ["glow", "egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml", "data/*"] include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml", "data/*"]
[lints] [lints]
workspace = true workspace = true
@@ -26,7 +26,7 @@ rustdoc-args = ["--generate-link-to-definition"]
[features] [features]
default = [] default = []
chrono = ["egui_extras/datepicker", "dep:chrono"] jiff = ["egui_extras/datepicker", "dep:jiff"]
## Allow serialization using [`serde`](https://docs.rs/serde). ## Allow serialization using [`serde`](https://docs.rs/serde).
serde = ["egui/serde", "dep:serde", "egui_extras/serde"] serde = ["egui/serde", "dep:serde", "egui_extras/serde"]
@@ -42,7 +42,7 @@ egui_extras = { workspace = true, features = ["image", "svg"] }
unicode_names2.workspace = true # this old version has fewer dependencies unicode_names2.workspace = true # this old version has fewer dependencies
#! ### Optional dependencies #! ### Optional dependencies
chrono = { workspace = true, optional = true, features = ["js-sys", "wasmbind"] } jiff = { workspace = true, optional = true, features = ["std", "js"] }
## Enable this when generating docs. ## Enable this when generating docs.
document-features = { workspace = true, optional = true } document-features = { workspace = true, optional = true }
serde = { workspace = true, optional = true } serde = { workspace = true, optional = true }

View File

@@ -0,0 +1,21 @@
<svg width="1165" height="366" viewBox="0 0 1165 366" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2005_93)">
<mask id="mask0_2005_93" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="334" y="0" width="831" height="366">
<path d="M1165 0H334V365.17H1165V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_2005_93)">
<path d="M334.059 166.33C334.679 134.02 349.889 103.38 376.099 84.4502C390.759 73.8602 409.699 68.8302 427.749 68.9302C472.239 69.1802 500.199 89.0902 514.439 126.26C518.609 137.17 520.839 145.47 521.289 157.13C521.629 166.3 521.789 178.32 521.829 183.4C521.829 184.74 520.769 185.81 519.429 185.81H382.519C380.709 185.81 379.249 187.31 379.309 189.12C380.349 220.05 396.199 237.77 427.749 238.44C447.819 238.87 463.969 230.46 470.749 216.17C471.259 215.08 472.379 214.41 473.599 214.41H517.259C518.809 214.41 519.989 215.88 519.589 217.41C510.809 251.9 478.299 279.88 427.749 280.6C359.269 280.43 338.449 230.44 334.299 182.61C333.939 178.51 333.939 172.29 334.049 166.32V166.34L334.059 166.33ZM469.049 146.33C470.989 146.33 472.479 144.62 472.229 142.69C469.219 119.35 452.109 106.3 427.969 106.3C400.019 106.3 385.689 118.56 380.089 142.35C379.619 144.38 381.119 146.32 383.199 146.32H469.049V146.33Z" fill="white"/>
<path d="M685.599 75.4002V79.9202C685.599 82.3602 682.999 83.9002 680.859 82.7302C670.869 75.0802 658.989 68.9002 637.819 68.9102C591.729 68.9602 552.329 100.48 547.649 145.55C546.819 153.48 546.669 183.26 546.859 186.07C550.349 242.31 566.189 280.84 636.229 280.84C661.839 280.84 670.739 272.74 681.219 266.01C683.319 265.14 685.629 266.71 685.629 268.98V317.05C712.179 317.05 733.709 289.78 733.709 263.22V75.4002C733.709 73.6302 732.279 72.1902 730.499 72.1902H688.839C687.069 72.1902 685.629 73.6202 685.629 75.3902L685.599 75.4002ZM685.229 190.32C685.229 195.53 684.519 202.49 682.979 207.58C676.579 228.66 662.369 239.82 636.519 239.82C612.679 239.82 595.699 226.2 592.379 202.84C592.029 200.44 591.899 198.01 591.899 195.58V155.56C591.899 150.25 592.609 144.96 594.149 139.88C600.549 118.8 614.759 106.82 640.609 106.82C664.449 106.82 681.429 121.26 684.749 144.63C685.089 147.03 685.229 149.46 685.229 151.89V190.34V190.32Z" fill="white"/>
<path d="M561.44 317.09H685.63V320.3C685.63 345.09 665.54 365.17 640.76 365.17H561.44C559.67 365.17 558.23 363.73 558.23 361.96V320.3C558.23 318.53 559.67 317.09 561.44 317.09Z" fill="white"/>
<path d="M1097.7 72.1301C1099.47 72.1301 1100.92 73.5301 1100.92 75.3001C1100.92 86.6501 1100.9 205.37 1100.9 226.49C1100.9 228.26 1102.34 229.7 1104.11 229.7H1161.79C1163.56 229.7 1165 231.14 1165 232.91V274.78C1165 276.55 1163.57 277.99 1161.79 277.99H991.928C990.158 277.99 988.718 276.56 988.718 274.78V232.9C988.718 231.13 990.148 229.69 991.928 229.69H1049.61C1051.38 229.69 1052.81 228.26 1052.81 226.48L1052.78 120.18C1052.78 118.41 1051.34 116.97 1049.57 116.97H991.878C990.108 116.97 988.668 115.54 988.668 113.76V75.3101C988.668 73.5401 990.098 72.1001 991.878 72.1001H1097.7V72.1201V72.1301Z" fill="white"/>
<path d="M910.9 72.1299C909.13 72.1299 907.69 73.5699 907.69 75.3399V192.16C907.69 195.39 907.46 198.84 907.07 202.11C906.32 208.25 904.46 214.24 901.28 219.55C893.34 232.89 879.58 239.42 858.99 239.82C835.72 240.27 818.54 225.08 815.21 202.11C814.87 199.76 814.39 194.57 814.39 192.16V75.3399C814.39 73.5699 812.96 72.1299 811.18 72.1299H768.39C766.62 72.1299 765.18 73.5699 765.18 75.3399V188.89C765.18 240.58 788.39 282.7 860.25 281.02C883.37 277.82 890.5 272.68 903.44 266.05C905.56 264.96 908.09 266.52 908.09 268.91L907.97 274.61C907.97 276.38 909.41 277.82 911.18 277.82H952.89C954.66 277.82 956.1 276.38 956.1 274.61V75.3399C956.1 73.5699 954.67 72.1299 952.89 72.1299H910.9Z" fill="white"/>
<path d="M1097.66 0H1056C1054.22 0 1052.79 1.43 1052.79 3.21V44.88C1052.79 46.65 1054.22 48.09 1056 48.09H1097.66C1099.43 48.09 1100.87 46.66 1100.87 44.88V3.21C1100.87 1.44 1099.43 0 1097.66 0Z" fill="white"/>
</g>
<path d="M165.96 72C171.15 72 175.95 74.8 178.55 79.34L229.02 167.66C231.62 172.2 231.62 177.8 229.02 182.34L178.55 270.66C175.95 275.2 171.15 278 165.96 278H65.01C59.82 278 55.02 275.2 52.42 270.66L1.95 182.34C-0.65 177.8 -0.65 172.2 1.95 167.66L52.43 79.34C55.03 74.8 59.82 72 65.02 72H165.97H165.96ZM99.88 249.29C99.88 251.36 101.56 253.03 103.63 253.03H127.56C129.51 253.03 131.09 251.45 131.09 249.5V221.82C131.09 204.58 117.12 190.61 99.88 190.61V249.29ZM40.99 159.39C39.04 159.39 37.46 160.97 37.46 162.92V186.85C37.46 188.92 39.14 190.6 41.21 190.6H99.89C99.89 173.36 85.92 159.39 68.68 159.39H41H40.99ZM131.09 159.39C131.09 176.63 145.06 190.6 162.3 190.6H189.98C191.93 190.6 193.51 189.02 193.51 187.07V163.14C193.51 161.07 191.83 159.39 189.76 159.39H131.08H131.09ZM103.41 96.97C101.46 96.97 99.88 98.55 99.88 100.5V128.18C99.88 145.42 113.85 159.39 131.09 159.39V100.71C131.09 98.64 129.41 96.96 127.34 96.96H103.41V96.97Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2005_93">
<rect width="1165" height="366" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
<rect x="0.1" y="0.1" width="15.8" height="15.8" rx="2.75714" fill="black" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-000000, #000000);"/>
<rect x="0.1" y="0.1" width="15.8" height="15.8" rx="2.75714" stroke="white" stroke-width="0.2" data-darkreader-inline-stroke="" style="--darkreader-inline-stroke: var(--darkreader-text-ffffff, #e8e6e3);"/>
<path d="M10.6112 2.54492C10.8843 2.54492 11.1367 2.69172 11.2733 2.93066L13.9286 7.5752C14.065 7.81401 14.0649 8.10877 13.9286 8.34766L11.2733 12.9932C11.1367 13.232 10.8843 13.3789 10.6112 13.3789H5.30162C5.02857 13.3789 4.77605 13.2321 4.63951 12.9932L1.98521 8.34766C1.8487 8.10872 1.84866 7.81412 1.98521 7.5752L4.63951 2.93066C4.77605 2.69176 5.02855 2.54497 5.30162 2.54492H10.6112ZM7.13658 11.8691C7.13674 11.9778 7.22514 12.0654 7.33384 12.0654H8.59263C8.69507 12.0652 8.77818 11.9824 8.77818 11.8799V10.4238C8.77797 9.51742 8.04301 8.78231 7.13658 8.78223V11.8691ZM4.03892 7.14062C3.93634 7.1407 3.85337 7.22456 3.85337 7.32715V8.58594C3.85355 8.6946 3.94195 8.78223 4.05064 8.78223H7.13658C7.13637 7.87582 6.40142 7.1407 5.49498 7.14062H4.03892ZM8.7772 7.14062C8.7772 8.04726 9.51217 8.78223 10.4188 8.78223H11.8749C11.9773 8.78201 12.0604 8.69918 12.0604 8.59668V7.33789C12.0604 7.22913 11.9719 7.14062 11.8631 7.14062H8.7772ZM7.32212 3.85742C7.21955 3.85749 7.13658 3.94135 7.13658 4.04395V5.49902C7.13658 6.40566 7.87154 7.14062 8.77818 7.14062V4.05469C8.77816 3.94591 8.68971 3.85742 8.58091 3.85742H7.32212Z" fill="white" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-ffffff, #181a1b);"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
viewBox="0 0 44 44" width="32" height="32">
<g fill="none" stroke="white" stroke-width="3">
<circle cx="22" cy="22" r="19"/>
<path d="M 22,2 V 41"/>
<path d="M 22,2 V 22" transform="rotate(135, 22, 22)"/>
<path d="M 22,2 V 22" transform="rotate(225, 22, 22)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 437 B

View File

@@ -27,20 +27,30 @@ impl crate::View for About {
fn ui(&mut self, ui: &mut egui::Ui) { fn ui(&mut self, ui: &mut egui::Ui) {
use egui::special_emojis::{OS_APPLE, OS_LINUX, OS_WINDOWS}; use egui::special_emojis::{OS_APPLE, OS_LINUX, OS_WINDOWS};
ui.heading("egui"); ui.vertical_centered(|ui| {
ui.add_space(4.0);
let egui_icon = egui::include_image!("../../data/egui-logo.svg");
ui.add(
egui::Image::new(egui_icon.clone())
.max_height(30.0)
.tint(ui.visuals().strong_text_color()),
);
ui.add_space(4.0);
});
ui.label(format!( ui.label(format!(
"egui is an immediate mode GUI library written in Rust. egui runs both on the web and natively on {}{}{}. \ "egui is an immediate mode GUI library written in Rust. egui runs natively on {}{}{}, and \
On the web it is compiled to WebAssembly and rendered with WebGL.{}", on the web it is compiled to WebAssembly and rendered with WebGL or WebGPU.{}",
OS_APPLE, OS_LINUX, OS_WINDOWS, OS_APPLE, OS_LINUX, OS_WINDOWS,
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
" Everything you see is rendered as textured triangles. There is no DOM, HTML, JS or CSS. Just Rust." " Everything you see is rendered as textured triangles. There is no DOM, HTML, JS or CSS. Just Rust."
} else {""} } else {""}
)); ));
ui.label("egui is designed to be easy to use, portable, and fast.");
ui.add_space(12.0); ui.add_space(12.0);
ui.label("egui is easy to use, portable, and fast.");
ui.heading("Immediate mode"); ui.add_space(12.0);
about_immediate_mode(ui); about_immediate_mode(ui);
ui.add_space(12.0); ui.add_space(12.0);
@@ -52,12 +62,12 @@ impl crate::View for About {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().item_spacing.x = 0.0;
ui.label("egui development is sponsored by "); ui.weak("egui development is sponsored by ");
ui.hyperlink_to("Rerun.io", "https://www.rerun.io/"); ui.hyperlink_to("Rerun.io", "https://www.rerun.io/");
ui.label(", a startup building an SDK for visualizing streams of multimodal data. "); ui.weak(", a startup building a data platform for robotics. ");
ui.label("For an example of a real-world egui app, see "); ui.weak("For an example of a professional egui app, run ");
ui.hyperlink_to("rerun.io/viewer", "https://www.rerun.io/viewer"); ui.hyperlink_to("rerun.io/viewer", "https://www.rerun.io/viewer");
ui.label(" (runs in your browser)."); ui.weak(" (in your browser!).");
}); });
ui.add_space(12.0); ui.add_space(12.0);
@@ -73,9 +83,7 @@ fn about_immediate_mode(ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().item_spacing.x = 0.0;
ui.label("Immediate mode is a GUI paradigm that lets you create a GUI with less code and simpler control flow. For example, this is how you create a "); ui.label("This is how you create a button in egui:");
let _ = ui.small_button("button");
ui.label(" in egui:");
}); });
ui.add_space(8.0); ui.add_space(8.0);

View File

@@ -119,21 +119,30 @@ impl Default for DemoGroups {
} }
impl DemoGroups { impl DemoGroups {
pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) { pub fn about_egui_checkbox(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) {
let Self { let Self { about, .. } = self;
about,
demos,
tests,
} = self;
{
let mut is_open = open.contains(about.name()); let mut is_open = open.contains(about.name());
ui.toggle_value(&mut is_open, about.name()); ui.toggle_value(&mut is_open, about.name());
set_open(open, about.name(), is_open); set_open(open, about.name(), is_open);
} }
ui.separator();
pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) {
let Self {
about: _,
demos,
tests,
} = self;
ui.vertical_centered(|ui| {
ui.strong("Demos");
});
demos.checkboxes(ui, open); demos.checkboxes(ui, open);
ui.separator(); ui.separator();
ui.vertical_centered(|ui| {
ui.strong("Tests");
});
tests.checkboxes(ui, open); tests.checkboxes(ui, open);
} }
@@ -267,22 +276,20 @@ impl DemoWindows {
.default_size(160.0) .default_size(160.0)
.min_size(160.0) .min_size(160.0)
.show_inside(ui, |ui| { .show_inside(ui, |ui| {
ui.vertical_centered_justified(|ui| {
ui.add_space(4.0); ui.add_space(4.0);
ui.vertical_centered(|ui| { ui.add(
ui.heading("✒ egui demos"); egui::Image::new(egui::include_image!("../../data/egui-logo.svg"))
.max_height(32.0)
.tint(ui.visuals().strong_text_color()),
);
ui.add_space(4.0);
self.groups.about_egui_checkbox(ui, &mut self.open);
}); });
ui.separator(); ui.add_space(4.0);
use egui::special_emojis::GITHUB;
ui.hyperlink_to(
format!("{GITHUB} egui on GitHub"),
"https://github.com/emilk/egui",
);
ui.hyperlink_to(
"@ernerfeldt.bsky.social",
"https://bsky.app/profile/ernerfeldt.bsky.social",
);
ui.separator(); ui.separator();

View File

@@ -30,7 +30,7 @@ impl crate::View for SvgTest {
fn ui(&mut self, ui: &mut egui::Ui) { fn ui(&mut self, ui: &mut egui::Ui) {
let Self { color } = self; let Self { color } = self;
ui.color_edit_button_srgba(color); ui.color_edit_button_srgba(color);
let img_src = egui::include_image!("../../../data/peace.svg"); let img_src = egui::include_image!("../../../data/icon.svg");
// First paint a small version, sized the same as the source… // First paint a small version, sized the same as the source…
ui.add( ui.add(

View File

@@ -297,6 +297,7 @@ fn rect_shape_ui(ui: &mut egui::Ui, shape: &mut RectShape) {
blur_width, blur_width,
round_to_pixels, round_to_pixels,
brush: _, brush: _,
angle: _,
} = shape; } = shape;
let round_to_pixels = round_to_pixels.get_or_insert(true); let round_to_pixels = round_to_pixels.get_or_insert(true);

View File

@@ -66,7 +66,8 @@ impl crate::View for TextEditDemo {
egui::Label::new("Press ctrl+Y to toggle the case of selected text (cmd+Y on Mac)"), egui::Label::new("Press ctrl+Y to toggle the case of selected text (cmd+Y on Mac)"),
); );
if ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Y)) if output.response.has_focus()
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Y))
&& let Some(text_cursor_range) = output.cursor_range && let Some(text_cursor_range) = output.cursor_range
{ {
use egui::TextBuffer as _; use egui::TextBuffer as _;

View File

@@ -19,11 +19,11 @@ pub struct WidgetGallery {
color: egui::Color32, color: egui::Color32,
animate_progress_bar: bool, animate_progress_bar: bool,
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
#[cfg_attr(feature = "serde", serde(skip))] #[cfg_attr(feature = "serde", serde(skip))]
date: Option<chrono::NaiveDate>, date: Option<jiff::civil::Date>,
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
with_date_button: bool, with_date_button: bool,
} }
@@ -39,19 +39,19 @@ impl Default for WidgetGallery {
string: Default::default(), string: Default::default(),
color: egui::Color32::LIGHT_BLUE.linear_multiply(0.5), color: egui::Color32::LIGHT_BLUE.linear_multiply(0.5),
animate_progress_bar: false, animate_progress_bar: false,
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
date: None, date: None,
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
with_date_button: true, with_date_button: true,
} }
} }
} }
impl WidgetGallery { impl WidgetGallery {
#[allow(clippy::allow_attributes, unused_mut)] // if not chrono #[allow(clippy::allow_attributes, unused_mut)] // if not jiff
#[inline] #[inline]
pub fn with_date_button(mut self, _with_date_button: bool) -> Self { pub fn with_date_button(mut self, _with_date_button: bool) -> Self {
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
{ {
self.with_date_button = _with_date_button; self.with_date_button = _with_date_button;
} }
@@ -140,9 +140,9 @@ impl WidgetGallery {
string, string,
color, color,
animate_progress_bar, animate_progress_bar,
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
date, date,
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
with_date_button, with_date_button,
} = self; } = self;
@@ -229,7 +229,7 @@ impl WidgetGallery {
ui.end_row(); ui.end_row();
ui.add(doc_link_label("Image", "Image")); ui.add(doc_link_label("Image", "Image"));
let egui_icon = egui::include_image!("../../data/icon.png"); let egui_icon = egui::include_image!("../../data/icon.svg");
ui.add(egui::Image::new(egui_icon.clone())); ui.add(egui::Image::new(egui_icon.clone()));
ui.end_row(); ui.end_row();
@@ -237,17 +237,14 @@ impl WidgetGallery {
"Button with image", "Button with image",
"Button::image_and_text", "Button::image_and_text",
)); ));
if ui if ui.button((egui_icon, "Click me!")).clicked() {
.add(egui::Button::image_and_text(egui_icon, "Click me!"))
.clicked()
{
*boolean = !*boolean; *boolean = !*boolean;
} }
ui.end_row(); ui.end_row();
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
if *with_date_button { if *with_date_button {
let date = date.get_or_insert_with(|| chrono::offset::Utc::now().date_naive()); let date = date.get_or_insert_with(|| jiff::Zoned::now().date());
ui.add(doc_link_label_with_crate( ui.add(doc_link_label_with_crate(
"egui_extras", "egui_extras",
"DatePickerButton", "DatePickerButton",
@@ -305,7 +302,7 @@ fn doc_link_label_with_crate<'a>(
} }
} }
#[cfg(feature = "chrono")] #[cfg(feature = "jiff")]
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -317,7 +314,7 @@ mod tests {
pub fn should_match_screenshot() { pub fn should_match_screenshot() {
let mut demo = WidgetGallery { let mut demo = WidgetGallery {
// If we don't set a fixed date, the snapshot test will fail. // If we don't set a fixed date, the snapshot test will fail.
date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), date: Some(jiff::civil::date(2024, 1, 1)),
..Default::default() ..Default::default()
}; };

View File

@@ -59,10 +59,13 @@ fn test_italics() {
#[test] #[test]
fn test_text_selection() { fn test_text_selection() {
let mut results = egui_kittest::SnapshotResults::new();
for (test_idx, drag_start_x) in [0.2_f32, 0.9].into_iter().enumerate() {
let mut harness = Harness::builder().build_ui(|ui| { let mut harness = Harness::builder().build_ui(|ui| {
let visuals = ui.visuals_mut(); let visuals = ui.visuals_mut();
visuals.selection.bg_fill = Color32::LIGHT_GREEN; visuals.selection.bg_fill = Color32::LIGHT_GREEN;
visuals.selection.stroke.color = Color32::DARK_BLUE; visuals.selection.stroke.color = Color32::RED;
ui.label("Some varied ☺ text :)\nAnd it has a second line!"); ui.label("Some varied ☺ text :)\nAnd it has a second line!");
}); });
@@ -71,9 +74,12 @@ fn test_text_selection() {
// Drag to select text: // Drag to select text:
let label = harness.get_by_role(Role::Label); let label = harness.get_by_role(Role::Label);
harness.drag_at(label.rect().lerp_inside([0.2, 0.25])); harness.drag_at(label.rect().lerp_inside([drag_start_x, 0.25]));
harness.drop_at(label.rect().lerp_inside([0.6, 0.75])); harness.drop_at(label.rect().lerp_inside([0.6, 0.75]));
harness.run(); harness.run();
harness.snapshot("text_selection"); harness.snapshot(format!("text_selection_{test_idx}"));
results.extend_harness(&mut harness);
}
} }

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:52d2233594c4bad348f5479dcfad9576ee5fd7d49faedb6f5ba74b374cdaf3ad oid sha256:24f4a9745c60c0353ece5f8fc48200671dcb185f4f0b964bbe66bf4a2fe71d7a
size 26977 size 27067

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:47f6cd15b88df83a9b2d8538e424041e661712f2e85312166a581f69f1254643 oid sha256:75a9cd9a3315b236c23a53e890de1a821d39c3327813d06df85ba86d2ed50cc7
size 26839 size 26887

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9302478abb0b86fae1af3af45d91f032272a56a2098405525d08aba4f9534644 oid sha256:b849adf0ff9a06e1f7bfa22ef32b13224c7e62429cf675286110729156434d12
size 76103 size 76531

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6927950ccbc5c81d6fbfe0a90ddd79a4306518caced14bb60debd30c7e41d326 oid sha256:d4e33c7f817100d8414bba245ee7886354b86109f383d59e87a197e39501f0a0
size 62604 size 62604

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:366d18457aabf1ebdd42fdbce8819cc67a4f59db85c452623b02ee1d0e8fc50a oid sha256:93fcc271831167cb077f3de0a9f0e27037f9e5a2ce94e056bd6f1ede9890cb7e
size 27817 size 27818

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3c5e803659e936268b476690427ef6a6802f477e078dc956a9d1c857b48da868 oid sha256:8c630df841e98043132b920338793745549116890c1bc52d8d10b0def896a1e6
size 114409 size 114409

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f5e67baf0696792e50f7ab3121874d055ddee2de0514712aacbf8e135ec4743d oid sha256:20ea4f93ee50c7a3585aef74c66d7700083ac1c16519b0704b70387849d9d2bc
size 25425 size 25057

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:55c5fb90736a31fbccd72be5994fc8c62b4b9da9842ad1e6bb795a1e1461a6f8 oid sha256:1b72a4c0e6d441190a7a156b8bba709e81b6c1fe7b0eacedc1ee7a3bfcf881f6
size 98780 size 99297

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a4080ee1a16eea16c8f4246fe3e760ade7d0289b30d88068d1e49ffb88d88dca oid sha256:08c40934d4bd2a239bdcc1928d1e5eba56bac03fdded2c85cf47b020d669f07e
size 18280 size 18281

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:600d9e0fc193396f36b599e4bfad2547128160d2e56dc2a989cb5f978d5115ae oid sha256:82878e4150e38fdc4b2e78203c8c661c2d9e716ab32595c298392faf6ba96105
size 113797 size 113803

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3ae5e843cc9d847b0f3c4092f55b914699adb506cb807b0a97bfc4ec7d94537b oid sha256:58cd3aba4392332a45f57c7dd90a9b5da386cb396c0c6319e7a7dae71e03ff30
size 22613 size 22563

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b0d38cb1eebf3ce7d661d094175b425db2b9eccc5e439b14256c5d801d4454d4 oid sha256:26ffcf6b71108b82ce15d4cf3f9dd0ce9fe0b9563f02725fef1b74f40e749439
size 47285 size 47281

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:00e4c7659cd50044d473dd2c138392f78ac7eba27f2b52bae61246f5dc5b2782 oid sha256:faedf9631149e231d510165215c24fccec50502d58000d5f893aa047a637a68f
size 23156 size 23148

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:56b44d26946770c0878e11e3197633697ad339a7e8fcffe7279a6b4c45cd3582 oid sha256:b6b4c2e55c02fa4caf5f9f8bd2d8c0311cc4cbcf1fc2f568fe112e8e6125c675
size 65384 size 65308

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