1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -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:
types: [opened, reopened, synchronize]
permissions:
issues: write
jobs:
check-source-branch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check PR source branch
env:
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
# 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
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 "Please create a feature branch in your fork and submit the PR from that branch instead."
exit 1

View File

@@ -94,7 +94,7 @@ jobs:
- name: wasm-bindgen
uses: jetli/wasm-bindgen-action@v0.1.0
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

View File

@@ -9,6 +9,7 @@ isse = "isse" # part of @IsseW username
tye = "tye" # part of @tye-exe username
ro = "ro" # read-only, also part of the username @Phen-Ro
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
tesalator = "tessellator"
@@ -20,6 +21,8 @@ teselation = "tessellation"
tessalation = "tessellation"
tesselation = "tessellation"
# For consistency
postfix = "suffix"
# Use the more common spelling
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 }
eframe = { version = "0.33.3", path = "crates/eframe", default-features = false }
accesskit = "0.21.1"
accesskit_consumer = "0.30.1"
accesskit_winit = "0.29.1"
accesskit = "0.24.0"
accesskit_consumer = "0.35.0"
accesskit_winit = "0.32.0"
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
"std",
@@ -80,33 +80,34 @@ arboard = { version = "3.6.1", default-features = false }
backtrace = "0.3.76"
bitflags = "2.9.4"
bytemuck = "1.24.0"
chrono = { version = "0.4.42", default-features = false }
cint = "0.3.1"
color-hex = "0.2.0"
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"
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"
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-winit = { version = "0.5.0", default-features = false }
home = "0.5.9"
image = { version = "0.25.6", default-features = false }
jiff = { version = "0.2.23", default-features = false }
js-sys = "0.3.77"
kittest = { version = "0.3.0" }
kittest = { version = "0.4.0" }
log = { version = "0.4.28", features = ["std"] }
memoffset = "0.9.1"
mimalloc = "0.1.48"
mime_guess2 = { version = "2.3.1", default-features = false }
mint = "0.5.9"
nohash-hasher = "0.2.0"
objc2 = "0.5.2"
objc2-app-kit = { version = "0.2.2", default-features = false }
objc2-foundation = { version = "0.2.2", default-features = false }
objc2-ui-kit = { version = "0.2.2", default-features = false }
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", default-features = false }
objc2-foundation = { version = "0.3.2", default-features = false }
objc2-ui-kit = { version = "0.3.2", default-features = false }
open = "5.3.2"
parking_lot = "0.12.5"
percent-encoding = "2.3.2"
@@ -119,34 +120,33 @@ rand = "0.9.2"
raw-window-handle = "0.6.2"
rayon = "1.11.0"
resvg = { version = "0.45.1", default-features = false }
rfd = "0.15.4"
ron = "0.11.0"
rfd = "0.17.2"
ron = "0.12.0"
self_cell = "1.2.1"
serde = { version = "1.0.228", features = ["derive"] }
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"
smithay-clipboard = "0.7.2"
static_assertions = "1.1.0"
syntect = { version = "5.3.0", default-features = false }
tempfile = "3.23.0"
thiserror = "2.0.17"
tokio = "1.47.1"
toml = "0.8"
tokio = "1.49"
toml = {version = "1.0.0", default-features = false }
type-map = "0.5.1"
unicode_names2 = { version = "2.0.0", default-features = false }
unicode-segmentation = "1.12.0"
vello_cpu = { version = "0.0.4", default-features = false, features = ["std"] }
wasm-bindgen = "0.2.100" # Keep wasm-bindgen version in sync in: setup_web.sh, Cargo.toml, Cargo.lock, rust.yml
wasm-bindgen-futures = "0.4.0"
vello_cpu = { version = "0.0.6", default-features = false, features = ["std", "u8_pipeline", "f32_pipeline"] }
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.58"
wayland-cursor = { version = "0.31.11", default-features = false }
web-sys = "0.3.77"
web-time = "1.1.0" # Timekeeping for native and web
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"
winit = { version = "0.30.12", default-features = false }
winit = { version = "0.30.13", default-features = false }
[workspace.lints.rust]
unsafe_code = "deny"

View File

@@ -10,16 +10,14 @@
[![Discord](https://img.shields.io/discord/900275882684477440?label=egui%20discord)](https://discord.gg/JFcEma9bJq)
<br/>
<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>
an SDK for visualizing streams of multimodal data.
</div>
---
<br/>
👉 [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).

View File

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

View File

@@ -11,7 +11,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui/tree/main/crates/eframe"
categories = ["gui", "game-development"]
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]
all-features = true
@@ -169,7 +169,10 @@ objc2-foundation = { workspace = true, default-features = false, features = [
objc2-app-kit = { workspace = true, default-features = false, features = [
"std",
"NSApplication",
"NSBitmapImageRep",
"NSGraphics",
"NSImage",
"NSImageRep",
"NSMenu",
"NSMenuItem",
"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
#[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.
///

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 _;
profiling::function_scope!();
use objc2::ClassType as _;
use objc2::AnyThread as _;
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;

View File

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

View File

@@ -34,7 +34,7 @@ use egui_winit::accesskit_winit;
use crate::{
App, AppCreator, CreationContext, NativeOptions, Result, Storage,
native::epi_integration::EpiIntegration,
native::{epi_integration::EpiIntegration, winit_integration::is_invisible_or_minimized},
};
use super::{
@@ -294,14 +294,15 @@ impl<'app> GlowWinitApp<'app> {
// Use latest raw_window_handle for eframe compatibility
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 cc = CreationContext {
egui_ctx: integration.egui_ctx.clone(),
integration_info: integration.frame.info().clone(),
storage: integration.frame.storage(),
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")]
wgpu_render_state: None,
raw_display_handle: window.display_handle().map(|h| h.as_raw()),
@@ -466,12 +467,21 @@ impl WinitApp for GlowWinitApp<'_> {
if let Some(viewport) = glutin
.focused_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() {
egui_winit.on_mouse_motion(delta);
if !window.has_focus()
&& !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()));
}
}
@@ -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 egui_ctx = glutin.egui_ctx.clone();
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);
let is_visible = viewport.info.visible().unwrap_or(true);
let Some(egui_winit) = viewport.egui_winit.as_mut() else {
return Ok(EventResult::Wait);
};
@@ -580,7 +592,7 @@ impl GlowWinitRunning<'_> {
.map(|(id, viewport)| (*id, viewport.info.clone()))
.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
@@ -596,7 +608,7 @@ impl GlowWinitRunning<'_> {
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.
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:
let mut glutin = self.glutin.borrow_mut();
@@ -631,9 +643,12 @@ impl GlowWinitRunning<'_> {
// The update function, which could call immediate viewports,
// so make sure we don't hold any locks here required by the immediate viewports rendeer.
let full_output =
self.integration
.update(self.app.as_mut(), viewport_ui_cb.as_deref(), raw_input);
let full_output = self.integration.update(
self.app.as_mut(),
viewport_ui_cb.as_deref(),
raw_input,
is_visible,
);
// ------------------------------------------------------------
@@ -676,85 +691,87 @@ impl GlowWinitRunning<'_> {
egui_winit.handle_platform_output(&window, platform_output);
let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point);
if is_visible {
let clipped_primitives = integration.egui_ctx.tessellate(shapes, pixels_per_point);
{
// We may need to switch contexts again, because of immediate viewports:
frame_timer.pause();
change_gl_context(current_gl_context, not_current_gl_context, gl_surface);
frame_timer.resume();
}
{
// We may need to switch contexts again, because of immediate viewports:
frame_timer.pause();
change_gl_context(current_gl_context, not_current_gl_context, gl_surface);
frame_timer.resume();
}
let screen_size_in_pixels: [u32; 2] = window.inner_size().into();
let screen_size_in_pixels: [u32; 2] = window.inner_size().into();
if !clear_before_update {
painter.clear(screen_size_in_pixels, clear_color);
}
if !clear_before_update {
painter.clear(screen_size_in_pixels, clear_color);
}
painter.paint_and_update_textures(
screen_size_in_pixels,
pixels_per_point,
&clipped_primitives,
&textures_delta,
);
painter.paint_and_update_textures(
screen_size_in_pixels,
pixels_per_point,
&clipped_primitives,
&textures_delta,
);
{
for action in viewport.actions_requested.drain(..) {
match action {
ActionRequested::Screenshot(user_data) => {
let screenshot = painter.read_screen_rgba(screen_size_in_pixels);
egui_winit
.egui_input_mut()
.events
.push(egui::Event::Screenshot {
viewport_id,
user_data,
image: screenshot.into(),
});
}
ActionRequested::Cut => {
egui_winit.egui_input_mut().events.push(egui::Event::Cut);
}
ActionRequested::Copy => {
egui_winit.egui_input_mut().events.push(egui::Event::Copy);
}
ActionRequested::Paste => {
if let Some(contents) = egui_winit.clipboard_text() {
let contents = contents.replace("\r\n", "\n");
if !contents.is_empty() {
egui_winit
.egui_input_mut()
.events
.push(egui::Event::Paste(contents));
{
for action in viewport.actions_requested.drain(..) {
match action {
ActionRequested::Screenshot(user_data) => {
let screenshot = painter.read_screen_rgba(screen_size_in_pixels);
egui_winit
.egui_input_mut()
.events
.push(egui::Event::Screenshot {
viewport_id,
user_data,
image: screenshot.into(),
});
}
ActionRequested::Cut => {
egui_winit.egui_input_mut().events.push(egui::Event::Cut);
}
ActionRequested::Copy => {
egui_winit.egui_input_mut().events.push(egui::Event::Copy);
}
ActionRequested::Paste => {
if let Some(contents) = egui_winit.clipboard_text() {
let contents = contents.replace("\r\n", "\n");
if !contents.is_empty() {
egui_winit
.egui_input_mut()
.events
.push(egui::Event::Paste(contents));
}
}
}
}
}
integration.post_rendering(&window);
}
integration.post_rendering(&window);
}
{
// vsync - don't count as frame-time:
frame_timer.pause();
profiling::scope!("swap_buffers");
let context = current_gl_context.as_ref().ok_or_else(|| {
egui_glow::PainterError::from(
"failed to get current context to swap buffers".to_owned(),
)
})?;
{
// vsync - don't count as frame-time:
frame_timer.pause();
profiling::scope!("swap_buffers");
let context = current_gl_context.as_ref().ok_or_else(|| {
egui_glow::PainterError::from(
"failed to get current context to swap buffers".to_owned(),
)
})?;
gl_surface.swap_buffers(context)?;
frame_timer.resume();
}
gl_surface.swap_buffers(context)?;
frame_timer.resume();
}
// give it time to settle:
#[cfg(feature = "__screenshot")]
if integration.egui_ctx.cumulative_pass_nr() == 2
&& let Ok(path) = std::env::var("EFRAME_SCREENSHOT_TO")
{
save_screenshot_and_exit(&path, &painter, screen_size_in_pixels);
// give it time to settle:
#[cfg(feature = "__screenshot")]
if integration.egui_ctx.cumulative_pass_nr() == 2
&& let Ok(path) = std::env::var("EFRAME_SCREENSHOT_TO")
{
save_screenshot_and_exit(&path, &painter, screen_size_in_pixels);
}
}
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));
if window.is_minimized() == Some(true) {
if is_invisible_or_minimized(&window) {
// On Mac, a minimized Window uses up all CPU:
// 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");
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 => {
if viewport_id == Some(ViewportId::ROOT) && self.integration.should_close() {
log::debug!(

View File

@@ -1,4 +1,4 @@
use std::time::Instant;
use std::time::{Duration, Instant};
use winit::{
application::ApplicationHandler,
@@ -11,9 +11,20 @@ use ahash::HashMap;
use super::winit_integration::{UserEvent, WinitApp};
use crate::{
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>> {
#[cfg(target_os = "android")]
@@ -177,23 +188,54 @@ impl<T: WinitApp> WinitAppWrapper<T> {
fn check_redraw_requests(&mut self, event_loop: &ActiveEventLoop) {
let now = Instant::now();
let mut invisible_window_ids = Vec::new();
self.windows_next_repaint_times
.retain(|window_id, repaint_time| {
if now < *repaint_time {
return true; // not yet ready
}
event_loop.set_control_flow(ControlFlow::Poll);
if let Some(window) = self.winit_app.window(*window_id) {
log::trace!("request_redraw for {window_id:?}");
window.request_redraw();
// 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:?}");
event_loop.set_control_flow(ControlFlow::Poll);
window.request_redraw();
}
} else {
log::trace!("No window found for {window_id:?}");
}
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();
if let Some(next_repaint_time) = 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) =
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))
} else {
Ok(EventResult::Wait)

View File

@@ -27,7 +27,10 @@ use winit_integration::UserEvent;
use crate::{
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};
@@ -184,9 +187,17 @@ impl<'app> WgpuWinitApp<'app> {
builder: ViewportBuilder,
) -> crate::Result<&mut WgpuWinitRunning<'app>> {
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(
egui_ctx.clone(),
self.native_options.wgpu_options.clone(),
wgpu_options,
self.native_options.viewport.transparent.unwrap_or(false),
egui_wgpu::RendererOptions {
msaa_samples: self.native_options.multisampling as _,
@@ -474,12 +485,21 @@ impl WinitApp for WgpuWinitApp<'_> {
if let Some(viewport) = shared
.focused_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() {
egui_winit.on_mouse_motion(delta);
if !window.has_focus()
&& !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()));
}
}
@@ -584,7 +604,7 @@ impl WgpuWinitRunning<'_> {
let mut frame_timer = crate::stopwatch::Stopwatch::new();
frame_timer.start();
let (viewport_ui_cb, raw_input) = {
let (viewport_ui_cb, raw_input, is_visible) = {
profiling::scope!("Prepare");
let mut shared_lock = shared.borrow_mut();
@@ -628,6 +648,8 @@ impl WgpuWinitRunning<'_> {
};
egui_winit::update_viewport_info(info, &integration.egui_ctx, window, false);
let is_visible = viewport.info.visible().unwrap_or(true);
{
profiling::scope!("set_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);
(viewport_ui_cb, raw_input)
(viewport_ui_cb, raw_input, is_visible)
};
// ------------------------------------------------------------
// Runs the update, which could call immediate viewports,
// 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,52 +723,58 @@ impl WgpuWinitRunning<'_> {
egui_winit.handle_platform_output(window, platform_output);
let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point);
let vsync_secs = if is_visible {
let clipped_primitives = egui_ctx.tessellate(shapes, pixels_per_point);
let mut screenshot_commands = vec![];
viewport.actions_requested.retain(|cmd| {
if let ActionRequested::Screenshot(info) = cmd {
screenshot_commands.push(info.clone());
false
} else {
true
}
});
let vsync_secs = painter.paint_and_update_textures(
viewport_id,
pixels_per_point,
app.clear_color(&egui_ctx.global_style().visuals),
&clipped_primitives,
&textures_delta,
screenshot_commands,
);
let mut screenshot_commands = vec![];
viewport.actions_requested.retain(|cmd| {
if let ActionRequested::Screenshot(info) = cmd {
screenshot_commands.push(info.clone());
false
} else {
true
}
});
let vsync_secs = painter.paint_and_update_textures(
viewport_id,
pixels_per_point,
app.clear_color(&egui_ctx.global_style().visuals),
&clipped_primitives,
&textures_delta,
screenshot_commands,
);
for action in viewport.actions_requested.drain(..) {
match action {
ActionRequested::Screenshot { .. } => {
// already handled above
}
ActionRequested::Cut => {
egui_winit.egui_input_mut().events.push(egui::Event::Cut);
}
ActionRequested::Copy => {
egui_winit.egui_input_mut().events.push(egui::Event::Copy);
}
ActionRequested::Paste => {
if let Some(contents) = egui_winit.clipboard_text() {
let contents = contents.replace("\r\n", "\n");
if !contents.is_empty() {
egui_winit
.egui_input_mut()
.events
.push(egui::Event::Paste(contents));
for action in viewport.actions_requested.drain(..) {
match action {
ActionRequested::Screenshot { .. } => {
// already handled above
}
ActionRequested::Cut => {
egui_winit.egui_input_mut().events.push(egui::Event::Cut);
}
ActionRequested::Copy => {
egui_winit.egui_input_mut().events.push(egui::Event::Copy);
}
ActionRequested::Paste => {
if let Some(contents) = egui_winit.clipboard_text() {
let contents = contents.replace("\r\n", "\n");
if !contents.is_empty() {
egui_winit
.egui_input_mut()
.events
.push(egui::Event::Paste(contents));
}
}
}
}
}
}
integration.post_rendering(window);
integration.post_rendering(window);
vsync_secs
} else {
0.0
};
let active_viewports_ids: ViewportIdSet = viewport_output.keys().copied().collect();
@@ -768,10 +801,12 @@ impl WgpuWinitRunning<'_> {
integration.maybe_autosave(app.as_mut(), window.map(|w| w.as_ref()));
if let Some(window) = window
&& window.is_minimized() == Some(true)
&& is_invisible_or_minimized(window)
{
// On Mac, a minimized Window uses up all CPU:
// 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");
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 => {
if viewport_id == Some(ViewportId::ROOT) && integration.should_close() {
log::debug!(

View File

@@ -9,6 +9,14 @@ use egui::ViewportId;
#[cfg(feature = "accesskit")]
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.
pub fn create_egui_context(storage: Option<&dyn crate::Storage>) -> egui::Context {
profiling::function_scope!();

View File

@@ -274,13 +274,21 @@ impl AppRunner {
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| {
self.app.logic(ui.ctx(), &mut self.frame);
#[expect(deprecated)]
self.app.update(ui.ctx(), &mut self.frame);
if is_visible {
#[expect(deprecated)]
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 {
platform_output,
@@ -311,8 +319,10 @@ impl AppRunner {
}
self.handle_platform_output(platform_output);
self.textures_delta.append(textures_delta);
self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point));
if is_visible {
self.textures_delta.append(textures_delta);
self.clipped_primitives = Some(self.egui_ctx.tessellate(shapes, pixels_per_point));
}
}
/// Paint the results of the last call to [`Self::logic`].

View File

@@ -31,11 +31,18 @@ impl WebInput {
time: Some(super::now_sec()),
..self.raw.take()
};
raw_input
let viewport = raw_input
.viewports
.entry(egui::ViewportId::ROOT)
.or_default()
.native_pixels_per_point = Some(super::native_pixels_per_point());
.or_default();
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
}

View File

@@ -38,6 +38,7 @@ mod web_painter_wgpu;
pub use backend::*;
use egui::Theme;
use js_sys::Object;
use wasm_bindgen::prelude::*;
use web_sys::{Document, MediaQueryList, Node};
@@ -370,5 +371,5 @@ pub fn percent_decode(s: &str) -> String {
/// Are we running inside the Safari browser?
pub fn is_safari_browser() -> bool {
web_sys::window().is_some_and(|window| window.has_own_property(&JsValue::from("safari")))
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_configuration: wgpu::SurfaceConfiguration,
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_texture_view: Option<wgpu::TextureView>,
screen_capture_state: Option<CaptureState>,
capture_tx: CaptureSender,
capture_rx: CaptureReceiver,
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 {
@@ -63,7 +82,17 @@ impl WebPainterWgpu {
) -> Result<Self, String> {
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
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
.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 render_state = RenderState::create(
&options.wgpu_options,
&wgpu_options,
&instance,
Some(&surface),
egui_wgpu::RendererOptions {
@@ -89,7 +118,7 @@ impl WebPainterWgpu {
let surface_configuration = wgpu::SurfaceConfiguration {
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],
..default_configuration
};
@@ -105,11 +134,12 @@ impl WebPainterWgpu {
surface_configuration,
depth_stencil_format,
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,
capture_tx,
capture_rx,
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() {
Ok(frame) => frame,
Err(err) => match (*self.on_surface_error)(err) {
SurfaceErrorAction::RecreateSurface => {
self.surface
.configure(&render_state.device, &self.surface_configuration);
return Ok(());
wgpu::CurrentSurfaceTexture::Success(frame) => frame,
wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
self.needs_reconfigure = true;
frame
}
other => {
match (*self.on_surface_status)(&other) {
SurfaceErrorAction::RecreateSurface => {
self.surface
.configure(&render_state.device, &self.surface_configuration);
}
SurfaceErrorAction::SkipFrame => {}
}
SurfaceErrorAction::SkipFrame => {
return Ok(());
}
},
return Ok(());
}
};
{
@@ -268,6 +308,7 @@ impl WebPainter for WebPainterWgpu {
label: Some("egui_render"),
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
// 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;
if capture && let Some(capture_state) = &mut self.screen_capture_state {
capture_buffer = Some(capture_state.copy_textures(
&render_state.device,
&output_frame,
&mut encoder,
));
}
let capture_buffer = if capture
&& let Some(capture_state) = &mut self.screen_capture_state
{
Some(capture_state.copy_textures(&render_state.device, &output_frame, &mut encoder))
} else {
None
};
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"
categories = ["gui", "game-development"]
keywords = ["wgpu", "egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "**/*.wgsl", "Cargo.toml"]
include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "**/*.wgsl", "Cargo.toml"]
[lints]
workspace = true

View File

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

View File

@@ -24,7 +24,10 @@ mod renderer;
mod setup;
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.
#[cfg(feature = "capture")]
@@ -185,12 +188,13 @@ impl RenderState {
wgpu::Backends::all()
};
instance.enumerate_adapters(backends)
instance.enumerate_adapters(backends).await
};
let (adapter, device, queue) = match config.wgpu_setup.clone() {
WgpuSetup::CreateNew(WgpuSetupCreateNew {
instance_descriptor: _,
display_handle: _,
power_preference,
native_adapter_selector: _native_adapter_selector,
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 {
/// Do nothing and skip the current frame.
SkipFrame,
@@ -299,8 +303,15 @@ pub struct WgpuConfiguration {
/// How to create the wgpu adapter & device
pub wgpu_setup: WgpuSetup,
/// Callback for surface errors.
pub on_surface_error: Arc<dyn Fn(wgpu::SurfaceError) -> SurfaceErrorAction + Send + Sync>,
/// Callback for surface status changes.
///
/// 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]
@@ -315,7 +326,7 @@ impl std::fmt::Debug for WgpuConfiguration {
present_mode,
desired_maximum_frame_latency,
wgpu_setup,
on_surface_error: _,
on_surface_status: _,
} = self;
f.debug_struct("WgpuConfiguration")
.field("present_mode", &present_mode)
@@ -333,15 +344,25 @@ impl Default for WgpuConfiguration {
Self {
present_mode: wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: None,
wgpu_setup: Default::default(),
on_surface_error: Arc::new(|err| {
if err == wgpu::SurfaceError::Outdated {
// This error occurs when the app is minimized on Windows.
// Silently return here to prevent spamming the console with:
// "The underlying surface has changed, and therefore the swap chain must be updated"
} else {
log::warn!("Dropped frame with error: {err}");
// No display handle available at this point — callers should replace this with
// `WgpuSetup::from_display_handle(...)` before creating the instance if one is available.
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.
// Silently return here to prevent spamming the console with:
// "The underlying surface has changed, and therefore the swap chain must be updated"
}
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
}),
}
@@ -395,6 +416,10 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
driver,
driver_info,
backend,
device_pci_bus_id,
subgroup_min_size,
subgroup_max_size,
transient_saves_memory,
} = &info;
// Example values:
@@ -426,6 +451,13 @@ pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String {
if *device != 0 {
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
}

View File

@@ -1,5 +1,3 @@
#![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps
use std::{borrow::Cow, num::NonZeroU64, ops::Range};
use ahash::HashMap;
@@ -352,16 +350,19 @@ impl Renderer {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("egui_pipeline_layout"),
bind_group_layouts: &[&uniform_bind_group_layout, &texture_bind_group_layout],
push_constant_ranges: &[],
bind_group_layouts: &[
Some(&uniform_bind_group_layout),
Some(&texture_bind_group_layout),
],
immediate_size: 0,
});
let depth_stencil = options
.depth_stencil_format
.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
});
@@ -426,7 +427,7 @@ impl Renderer {
})],
compilation_options: wgpu::PipelineCompilationOptions::default()
}),
multiview: None,
multiview_mask: None,
cache: None,
}
)
@@ -469,6 +470,9 @@ impl Renderer {
/// 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
/// 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(
&self,
render_pass: &mut wgpu::RenderPass<'static>,
@@ -513,8 +517,12 @@ impl Renderer {
// Skip rendering zero-sized clip areas.
if let Primitive::Mesh(_) = primitive {
// If this is a mesh, we need to advance the index and vertex buffer iterators:
index_buffer_slices.next().unwrap();
vertex_buffer_slices.next().unwrap();
index_buffer_slices
.next()
.expect("You must call .update_buffers() before .render()");
vertex_buffer_slices
.next()
.expect("You must call .update_buffers() before .render()");
}
continue;
}
@@ -524,8 +532,12 @@ impl Renderer {
match primitive {
Primitive::Mesh(mesh) => {
let index_buffer_slice = index_buffer_slices.next().unwrap();
let vertex_buffer_slice = vertex_buffer_slices.next().unwrap();
let index_buffer_slice = index_buffer_slices
.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) {
render_pass.set_bind_group(1, bind_group, &[]);
@@ -951,6 +963,7 @@ impl Renderer {
let index_buffer_staging = queue.write_buffer_with(
&self.index_buffer.buffer,
0,
#[expect(clippy::unwrap_used)] // Checked above
NonZeroU64::new(required_index_buffer_size).unwrap(),
);
@@ -968,7 +981,8 @@ impl Renderer {
Primitive::Mesh(mesh) => {
let size = mesh.indices.len() * std::mem::size_of::<u32>();
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));
self.index_buffer.slices.push(slice);
index_offset += size;
@@ -994,6 +1008,7 @@ impl Renderer {
let vertex_buffer_staging = queue.write_buffer_with(
&self.vertex_buffer.buffer,
0,
#[expect(clippy::unwrap_used)] // Checked above
NonZeroU64::new(required_vertex_buffer_size).unwrap(),
);
@@ -1011,7 +1026,8 @@ impl Renderer {
Primitive::Mesh(mesh) => {
let size = mesh.vertices.len() * std::mem::size_of::<Vertex>();
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));
self.vertex_buffer.slices.push(slice);
vertex_offset += size;

View File

@@ -1,5 +1,43 @@
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)]
pub enum WgpuSetup {
/// Construct a wgpu setup using some predefined settings & heuristics.
@@ -22,9 +60,19 @@ pub enum WgpuSetup {
Existing(WgpuSetupExisting),
}
impl Default for WgpuSetup {
fn default() -> Self {
Self::CreateNew(WgpuSetupCreateNew::default())
impl WgpuSetup {
/// Creates a new [`WgpuSetup::CreateNew`] with the given display handle.
///
/// 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:?}");
wgpu::util::new_instance_with_webgpu_detection(&create_new.instance_descriptor)
.await
let desc = &create_new.instance_descriptor;
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(),
}
@@ -98,18 +156,35 @@ pub type NativeAdapterSelectorMethod = Arc<
/// Configuration for creating a new wgpu setup.
///
/// Used for [`WgpuSetup::CreateNew`].
///
/// Prefer [`Self::from_display_handle`] when you have a display handle available.
/// Most platforms work without one, but some (e.g. Wayland with GLES, or WebGL)
/// require it, so providing one ensures maximum compatibility.
/// With winit, pass [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle).
///
/// Note: The display handle is stored in [`Self::display_handle`] rather than in
/// [`Self::instance_descriptor`] so the config can be cloned
/// ([`wgpu::InstanceDescriptor`] is not `Clone`). It is injected at instance creation time.
pub struct WgpuSetupCreateNew {
/// Instance descriptor for creating a wgpu instance.
/// Descriptor for the wgpu instance.
///
/// The most important field is [`wgpu::InstanceDescriptor::backends`], which
/// controls which backends are supported (wgpu will pick one of these).
/// If you only want to support WebGL (and not WebGPU),
/// you can set this to [`wgpu::Backends::GL`].
/// By default on web, WebGPU will be used if available.
/// WebGL will only be used as a fallback,
/// and only if you have enabled the `webgl` feature of crate `wgpu`.
/// 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,
/// 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.
pub power_preference: wgpu::PowerPreference,
@@ -128,32 +203,37 @@ pub struct WgpuSetupCreateNew {
Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>,
}
impl Clone for WgpuSetupCreateNew {
fn clone(&self) -> Self {
impl WgpuSetupCreateNew {
/// 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 {
instance_descriptor: self.instance_descriptor.clone(),
power_preference: self.power_preference,
native_adapter_selector: self.native_adapter_selector.clone(),
device_descriptor: Arc::clone(&self.device_descriptor),
display_handle: Some(Box::new(display_handle)),
..Self::without_display_handle()
}
}
}
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 {
fn default() -> Self {
/// Creates a new configuration without a display handle.
///
/// 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 {
instance_descriptor: wgpu::InstanceDescriptor {
// 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(),
backend_options: wgpu::BackendOptions::from_env_or_default(),
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
display: None,
},
display_handle: None,
power_preference: wgpu::PowerPreference::from_env()
.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.
///
/// Used for [`WgpuSetup::Existing`].

View File

@@ -17,6 +17,7 @@ struct SurfaceState {
width: u32,
height: u32,
resizing: bool,
needs_reconfigure: bool,
}
/// Everything you need to paint egui with [`wgpu`] on [`winit`].
@@ -234,6 +235,7 @@ impl Painter {
height: size.height,
alpha_mode,
resizing: false,
needs_reconfigure: false,
},
);
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"))]
{
// 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.
// This is how wgpu currently exposes this backend-specific flag.
// it gracefully fails.
unsafe {
if let Some(hal_surface) = state.surface.as_hal::<wgpu::hal::api::Metal>() {
let raw =
std::ptr::from_ref::<wgpu::hal::metal::Surface>(&*hal_surface).cast_mut();
(*raw).present_with_transaction = resizing;
hal_surface
.render_layer()
.lock()
.setPresentsWithTransaction(resizing);
Self::configure_surface(
state,
@@ -421,13 +422,41 @@ impl Painter {
) -> f32 {
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 mut vsync_sec = 0.0;
let Some(render_state) = self.render_state.as_mut() else {
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;
};
@@ -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 = {
profiling::scope!("get_current_texture");
// This is what vsync-waiting happens on my Mac.
@@ -474,16 +508,20 @@ impl Painter {
};
let output_frame = match output_frame {
Ok(frame) => frame,
Err(err) => match (*self.configuration.on_surface_error)(err) {
SurfaceErrorAction::RecreateSurface => {
Self::configure_surface(surface_state, render_state, &self.configuration);
return vsync_sec;
wgpu::CurrentSurfaceTexture::Success(frame) => frame,
wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
surface_state.needs_reconfigure = true;
frame
}
other => {
match (*self.configuration.on_surface_status)(&other) {
SurfaceErrorAction::RecreateSurface => {
Self::configure_surface(surface_state, render_state, &self.configuration);
}
SurfaceErrorAction::SkipFrame => {}
}
SurfaceErrorAction::SkipFrame => {
return vsync_sec;
}
},
return vsync_sec;
}
};
let mut capture_buffer = None;
@@ -554,6 +592,7 @@ impl Painter {
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// 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();
};
// 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.
// 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.

View File

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

View File

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

View File

@@ -9,6 +9,9 @@
#![expect(clippy::manual_range_contains)]
#[cfg(target_os = "windows")]
use std::collections::HashSet;
#[cfg(feature = "accesskit")]
pub use accesskit_winit;
pub use egui;
@@ -102,10 +105,16 @@ pub struct State {
has_sent_ime_enabled: bool,
#[cfg(feature = "accesskit")]
accesskit: Option<accesskit_winit::Adapter>,
pub accesskit: Option<accesskit_winit::Adapter>,
allow_ime: bool,
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 {
@@ -148,6 +157,8 @@ impl State {
allow_ime: false,
ime_rect_px: None,
#[cfg(target_os = "windows")]
pressed_processed_physical_keys: HashSet::new(),
};
slf.egui_input
@@ -364,25 +375,33 @@ impl State {
is_synthetic,
..
} => {
// Winit generates fake "synthetic" KeyboardInput events when the focus
// is changed to the window, or away from it. Synthetic key presses
// represent no real key presses and should be ignored.
// See https://github.com/rust-windowing/winit/issues/3543
if *is_synthetic && event.state == ElementState::Pressed {
// Winit generates fake "synthetic" KeyboardInput events when the focus
// is changed to the window, or away from it. Synthetic key presses
// represent no real key presses and should be ignored.
// See https://github.com/rust-windowing/winit/issues/3543
EventResponse {
repaint: true,
consumed: false,
}
} else {
self.on_keyboard_input(event);
let egui_wants_keyboard_input = self.egui_ctx.egui_wants_keyboard_input();
// When pressing the Tab key, egui focuses the first focusable element, hence Tab always consumes.
let consumed = self.egui_ctx.egui_wants_keyboard_input()
|| event.logical_key
== winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab);
EventResponse {
repaint: true,
consumed,
if let Some(response) =
self.try_on_ime_processed_keyboard_input(event, egui_wants_keyboard_input)
{
response
} else {
self.on_keyboard_input(event);
// When pressing the Tab key, egui focuses the first focusable element, hence Tab always consumes.
let consumed = egui_wants_keyboard_input
|| event.logical_key
== winit::keyboard::Key::Named(winit::keyboard::NamedKey::Tab);
EventResponse {
repaint: true,
consumed,
}
}
}
}
@@ -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
///
/// 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 |
/// | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
/// | a-macos15-apple_shuangpin | `Predict("", None)` -> `Commit("测试")` |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", None)` -> `Commit("测试")` -> `Predict("", Some(0, 0))` -> `Predict("", None)` (duplicate until `TextEdit` blurred) |
/// | c-windows11-ms_pinyin | `Predict("测试", Some(…))` -> `Predict("", None)` -> `Commit("测试")` -> `Disabled` |
/// | a-macos15-apple_shuangpin | `Preedit("", None)` -> `Commit("测试")` |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", None)` -> `Commit("测试")` -> `Preedit("", Some(0, 0))` -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
/// | 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 |
/// | a-macos15-apple_shuangpin | `Predict("", None)` |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Predict("", Some(0, 0))` -> `Predict("", None)` (duplicate until `TextEdit` blurred) |
/// | c-windows11-ms_pinyin | `Predict("", Some(0, 0))` -> `Predict("", None)` -> `Commit("")` -> `Disabled` |
/// | a-macos15-apple_shuangpin | `Preedit("", None)` |
/// | b-debian13_gnome48_wayland-fcitx5_shuangpin | `Preedit("", Some(0, 0))` -> `Preedit("", None)` (duplicate until `TextEdit` blurred) |
/// | 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 |
/// | ------------------------------------------- | ------------------------------------------------------------------------------------------------- |
/// | 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 |
fn on_ime(&mut self, ime: &winit::event::Ime) {
// // code for inspecting ime events emitted by winit:
@@ -610,15 +714,26 @@ impl State {
self.ime_event_disable();
}
winit::event::Ime::Preedit(_, None) => {
// we need to emit this on macOS, since winit doesn't emit
// `Predict("", Some(0, 0))` before this event on macOS when the
// user deletes the last character in the prediction with the
// backspace key. Without this, only `egui::ImeEvent::Disabled`
// is emitted here, leading to the last character being left in
// TextEdit in such situation.
self.egui_input
.events
.push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new())));
if cfg!(target_os = "macos") {
// On macOS, when the user presses backspace to delete the
// last character in an IME composition, `winit` only emits
// `winit::event::Ime::Preedit("", None)` without a
// preceding `winit::event::Ime::Preedit("", Some(0, 0))`.
//
// 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
.events
.push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new())));
}
self.ime_event_disable();
}
}
@@ -640,11 +755,27 @@ impl State {
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 {
x: delta.0 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`].
@@ -974,6 +1105,16 @@ impl State {
let allow_ime = ime.is_some();
if 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");
window.set_ime_allowed(allow_ime);
}

View File

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

View File

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

View File

@@ -1,10 +1,24 @@
use crate::{AtomKind, FontSelection, Id, SizedAtom, Ui};
use emath::{NumExt as _, Vec2};
use crate::{AtomKind, FontSelection, Id, IntoSizedArgs, IntoSizedResult, SizedAtom, Ui};
use emath::{Align2, NumExt as _, Vec2};
use epaint::text::TextWrapMode;
/// 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>`.
/// ```
/// # use egui::{Image, emath::Vec2};
@@ -14,6 +28,9 @@ use epaint::text::TextWrapMode;
/// ```
#[derive(Clone, Debug)]
pub struct Atom<'a> {
/// See [`crate::AtomExt::atom_id`]
pub id: Option<Id>,
/// See [`crate::AtomExt::atom_size`]
pub size: Option<Vec2>,
@@ -26,17 +43,22 @@ pub struct Atom<'a> {
/// See [`crate::AtomExt::atom_shrink`]
pub shrink: bool,
/// The atom type
/// See [`crate::AtomExt::atom_align`]
pub align: Align2,
/// The atom type / content
pub kind: AtomKind<'a>,
}
impl Default for Atom<'_> {
fn default() -> Self {
Atom {
id: None,
size: None,
max_size: Vec2::INFINITY,
grow: false,
shrink: false,
align: Align2::CENTER_CENTER,
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 {
Atom {
size: Some(size.into()),
kind: AtomKind::Custom(id),
kind: AtomKind::Empty,
id: Some(id),
..Default::default()
}
}
@@ -82,19 +120,32 @@ impl<'a> Atom<'a> {
wrap_mode = Some(TextWrapMode::Truncate);
}
let (intrinsic, kind) = self
.kind
.into_sized(ui, available_size, wrap_mode, fallback_font);
let id = self.id;
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
.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 {
id,
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,
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;
/// A trait for conveniently building [`Atom`]s.
///
/// The functions are prefixed with `atom_` to avoid conflicts with e.g. [`crate::RichText::size`].
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.
///
/// 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));
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
where
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> {
let mut atom = self.into();
atom.size = Some(size);
@@ -104,4 +121,10 @@ where
atom.max_size.y = max_height;
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 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.
#[derive(Clone, Default, Debug)]
#[derive(Default)]
pub enum AtomKind<'a> {
/// Empty, that can be used with [`crate::AtomExt::atom_grow`] to reserve space.
#[default]
@@ -38,37 +57,57 @@ pub enum AtomKind<'a> {
/// default font height, which is convenient for icons.
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
/// [`crate::Painter`] or [`Ui::place`] to add/draw some custom content.
/// The vec2 passed in is the available size to this atom. The returned vec2 should be the
/// preferred / intrinsic 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("⏵"));
/// }
/// # });
/// ```
Custom(Id),
/// Note: This api is experimental, expect breaking changes here.
/// When cloning, this will be cloned as [`AtomKind::Empty`].
Closure(AtomClosure<'a>),
}
impl Clone for AtomKind<'_> {
fn clone(&self) -> Self {
match self {
AtomKind::Empty => AtomKind::Empty,
AtomKind::Text(text) => AtomKind::Text(text.clone()),
AtomKind::Image(image) => AtomKind::Image(image.clone()),
AtomKind::Closure(_) => {
log::warn!("Cannot clone atom closures");
AtomKind::Empty
}
}
}
}
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> {
/// See [`Self::Text`]
pub fn text(text: impl Into<WidgetText>) -> Self {
AtomKind::Text(text.into())
}
/// See [`Self::Image`]
pub fn image(image: impl Into<Image<'a>>) -> Self {
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`].
///
/// 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(
self,
ui: &Ui,
available_size: Vec2,
wrap_mode: Option<TextWrapMode>,
fallback_font: FontSelection,
) -> (Vec2, SizedAtomKind<'a>) {
IntoSizedArgs {
available_size,
wrap_mode,
fallback_font,
}: IntoSizedArgs,
) -> IntoSizedResult<'a> {
match self {
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);
(galley.intrinsic_size(), SizedAtomKind::Text(galley))
IntoSizedResult {
intrinsic_size: galley.intrinsic_size(),
sized: SizedAtomKind::Text(galley),
}
}
AtomKind::Image(image) => {
let size = image.load_and_calc_size(ui, available_size);
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_font: Option<FontSelection>,
min_size: Vec2,
max_size: Vec2,
wrap_mode: Option<TextWrapMode>,
align2: Option<Align2>,
}
@@ -59,6 +60,7 @@ impl<'a> AtomLayout<'a> {
fallback_text_color: None,
fallback_font: None,
min_size: Vec2::ZERO,
max_size: Vec2::INFINITY,
wrap_mode: None,
align2: None,
}
@@ -113,6 +115,33 @@ impl<'a> AtomLayout<'a> {
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`].
#[inline]
pub fn id(mut self, id: Id) -> Self {
@@ -161,6 +190,7 @@ impl<'a> AtomLayout<'a> {
sense,
fallback_text_color,
min_size,
mut max_size,
wrap_mode,
align2,
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 none is found, mark the first text item as `shrink`.
if wrap_mode != TextWrapMode::Extend {
let any_shrink = atoms.iter().any(|a| a.shrink);
let any_shrink = atoms.any_shrink();
if !any_shrink {
let first_text = atoms
.iter_mut()
@@ -190,8 +220,16 @@ impl<'a> AtomLayout<'a> {
fallback_text_color.unwrap_or_else(|| ui.style().visuals.text_color());
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
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;
@@ -280,8 +318,9 @@ impl<'a> AtomLayout<'a> {
let (_, rect) = ui.allocate_space(frame_size);
let mut response = ui.interact(rect, id, sense);
response.intrinsic_size =
Some((Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size));
response.set_intrinsic_size(
(Vec2::new(intrinsic_width, intrinsic_height) + margin.sum()).at_least(min_size),
);
AllocatedAtomLayout {
sized_atoms: sized_items,
@@ -321,7 +360,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
pub fn iter_images(&self) -> impl Iterator<Item = &Image<'atom>> {
self.iter_kinds().filter_map(|kind| {
if let SizedAtomKind::Image(image, _) = kind {
if let SizedAtomKind::Image { image, size: _ } = kind {
Some(image)
} else {
None
@@ -331,7 +370,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
pub fn iter_images_mut(&mut self) -> impl Iterator<Item = &mut Image<'atom>> {
self.iter_kinds_mut().filter_map(|kind| {
if let SizedAtomKind::Image(image, _) = kind {
if let SizedAtomKind::Image { image, size: _ } = kind {
Some(image)
} else {
None
@@ -373,8 +412,11 @@ impl<'atom> AllocatedAtomLayout<'atom> {
F: FnMut(Image<'atom>) -> Image<'atom>,
{
self.map_kind(|kind| {
if let SizedAtomKind::Image(image, size) = kind {
SizedAtomKind::Image(f(image), size)
if let SizedAtomKind::Image { image, size } = kind {
SizedAtomKind::Image {
image: f(image),
size,
}
} else {
kind
}
@@ -422,25 +464,24 @@ impl<'atom> AllocatedAtomLayout<'atom> {
.with_min_x(cursor)
.with_max_x(cursor + size.x + growth);
cursor = frame.right() + gap;
let rect = sized.align.align_size_within_rect(size, frame);
let align = Align2::CENTER_CENTER;
let rect = align.align_size_within_rect(size, frame);
if let Some(id) = sized.id {
debug_assert!(
!response.custom_rects.iter().any(|(i, _)| *i == id),
"Duplicate custom id"
);
response.custom_rects.push((id, rect));
}
match sized.kind {
SizedAtomKind::Text(galley) => {
ui.painter().galley(rect.min, galley, fallback_text_color);
}
SizedAtomKind::Image(image, _) => {
SizedAtomKind::Image { image, size: _ } => {
image.paint_at(ui, rect);
}
SizedAtomKind::Custom(id) => {
debug_assert!(
!response.custom_rects.iter().any(|(i, _)| *i == id),
"Duplicate custom id"
);
response.custom_rects.push((id, rect));
}
SizedAtomKind::Empty => {}
SizedAtomKind::Empty { .. } => {}
}
}
@@ -450,7 +491,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
/// 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)]
pub struct AtomLayoutResponse {
pub response: Response,
@@ -470,7 +511,7 @@ impl AtomLayoutResponse {
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.
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<'_> {
fn ui(self, ui: &mut 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;
/// 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)]
pub struct Atoms<'a>(SmallVec<[Atom<'a>; ATOMS_SMALL_VEC_SIZE]>);
@@ -21,11 +30,26 @@ impl<'a> Atoms<'a> {
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).
pub fn push_left(&mut self, atom: impl Into<Atom<'a>>) {
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.
// 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.
@@ -54,6 +78,11 @@ impl<'a> Atoms<'a> {
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>> {
self.0.iter().map(|atom| &atom.kind)
}
@@ -172,6 +201,16 @@ where
}
/// 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> {
fn collect(self, atoms: &mut Atoms<'a>);

View File

@@ -4,6 +4,8 @@ use emath::Vec2;
/// A [`crate::Atom`] which has been sized.
#[derive(Clone, Debug)]
pub struct SizedAtom<'a> {
pub id: Option<crate::Id>,
pub(crate) grow: bool,
/// 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`.
pub intrinsic_size: Vec2,
/// How will the atom be aligned in its available space?
pub align: emath::Align2,
pub kind: SizedAtomKind<'a>,
}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
use crate::style::StyleModifier;
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 _,
};
use emath::{Align, RectAlign, Vec2, vec2};
@@ -458,6 +458,7 @@ impl SubMenu {
let is_any_open = open_item.is_some();
let mut is_open = open_item == Some(id);
let was_open = is_open;
let mut set_open = None;
// 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
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)
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 {
set_open = Some(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);
// 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 {
// Show a fake panel in this in-between animation state:

View File

@@ -962,6 +962,7 @@ fn do_resize_interaction(
WidgetRect {
layer_id,
id,
parent_id: layer_id.id,
rect,
interact_rect: rect,
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 {
id,
parent_id: _,
layer_id,
rect,
interact_rect,
@@ -1378,8 +1379,8 @@ impl Context {
interact_rect,
sense,
flags: Flags::empty(),
interact_pointer_pos: None,
intrinsic_size: None,
interact_pointer_pos_or_nan: Pos2::NAN,
intrinsic_size_or_nan: Vec2::NAN,
};
res.flags.set(Flags::ENABLED, enabled);
@@ -1470,14 +1471,11 @@ impl Context {
|| res.long_touched()
|| clicked
|| res.drag_stopped();
if is_interacted_with {
res.interact_pointer_pos = input.pointer.interact_pos();
if let (Some(to_global), Some(pos)) = (
memory.to_global.get(&res.layer_id),
&mut res.interact_pointer_pos,
) {
*pos = to_global.inverse() * *pos;
if is_interacted_with && let Some(mut pos) = input.pointer.interact_pos() {
if let Some(to_global) = memory.to_global.get(&res.layer_id) {
pos = to_global.inverse() * pos;
}
res.interact_pointer_pos_or_nan = pos;
}
if input.pointer.any_down() && !is_interacted_with {
@@ -2397,6 +2395,12 @@ impl Context {
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)]
self.debug_painting();
@@ -2616,6 +2620,7 @@ impl ContextImpl {
platform_output.accesskit_update = Some(accesskit::TreeUpdate {
nodes,
tree: Some(accesskit::Tree::new(root_id)),
tree_id: accesskit::TreeId::ROOT,
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);
if repaint_needed {
@@ -3262,7 +3280,7 @@ impl Context {
for (name, data) in &mut font_definitions.font_data {
ui.collapsing(name, |ui| {
let mut tweak = data.tweak;
let mut tweak = data.tweak.clone();
if tweak.ui(ui).changed() {
Arc::make_mut(data).tweak = tweak;
changed = true;
@@ -4236,6 +4254,112 @@ fn context_impl_send_sync() {
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)]
mod test {
use super::Context;

View File

@@ -253,9 +253,28 @@ pub struct ViewportInfo {
///
/// This should be the same as [`RawInput::focused`].
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 {
/// 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.
///
/// If this is the root viewport, the application will exit
@@ -282,6 +301,7 @@ impl ViewportInfo {
maximized: self.maximized,
fullscreen: self.fullscreen,
focused: self.focused,
occluded: self.occluded,
}
}
@@ -298,6 +318,7 @@ impl ViewportInfo {
maximized,
fullscreen,
focused,
occluded,
} = self;
crate::Grid::new("viewport_info").show(ui, |ui| {
@@ -345,6 +366,16 @@ impl ViewportInfo {
ui.label(opt_as_str(focused));
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 {
v.as_ref().map_or(String::new(), |r| {
format!("Pos: {:?}, size: {:?}", r.min, r.size())
@@ -410,6 +441,10 @@ pub enum Event {
Text(String),
/// 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 {
/// 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.

View File

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

View File

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

View File

@@ -661,6 +661,8 @@ impl InputState {
if self.pointer.wants_repaint()
|| self.wheel.unprocessed_wheel_delta.abs().max_elem() > 0.2
|| !self.events.is_empty()
|| !self.raw.hovered_files.is_empty()
|| !self.raw.dropped_files.is_empty()
{
// Immediate repaint
return Some(Duration::ZERO);
@@ -869,7 +871,8 @@ impl InputState {
let accesskit_id = id.accesskit_id();
self.events.iter().filter_map(move |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
{
return Some(request);
@@ -886,7 +889,8 @@ impl InputState {
let accesskit_id = id.accesskit_id();
self.events.retain(|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);
}

View File

@@ -623,12 +623,24 @@ impl Layout {
if (self.is_vertical() && self.horizontal_align() == Align::Center)
|| 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)
|| 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 {
@@ -791,14 +803,14 @@ impl Layout {
let new_top = region.cursor.bottom() + spacing.y;
region.cursor = Rect::from_min_max(
pos2(region.max_rect.left(), new_top),
pos2(INFINITY, new_top + region.cursor.height()),
pos2(INFINITY, new_top),
);
}
Direction::RightToLeft => {
let new_top = region.cursor.bottom() + spacing.y;
region.cursor = Rect::from_min_max(
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 => {}

View File

@@ -685,7 +685,7 @@ pub fn __run_test_ctx(mut run_ui: impl FnMut(&Context)) {
}
/// 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();
ctx.set_fonts(FontDefinitions::empty()); // prevent fonts from being loaded (save CPU time)
let _ = ctx.run_ui(Default::default(), |ui| {

View File

@@ -234,6 +234,16 @@ pub struct Options {
#[cfg_attr(feature = "serde", serde(skip))]
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.
pub tessellation_options: epaint::TessellationOptions,
@@ -304,6 +314,10 @@ impl Default for Options {
system_theme: None,
zoom_factor: 1.0,
zoom_with_keyboard: true,
quit_shortcuts: vec![crate::KeyboardShortcut::new(
crate::Modifiers::COMMAND,
crate::Key::Q,
)],
tessellation_options: Default::default(),
repaint_on_widget_change: false,
@@ -363,6 +377,7 @@ impl Options {
system_theme: _,
zoom_factor,
zoom_with_keyboard,
quit_shortcuts: _, // not shown in ui
tessellation_options,
repaint_on_widget_change,
max_passes,
@@ -564,11 +579,13 @@ impl Focus {
if let crate::Event::AccessKitActionRequest(accesskit::ActionRequest {
action: accesskit::Action::Focus,
target,
target_node,
target_tree,
data: None,
}) = 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.
/// `None` if the widget is not being interacted with.
#[doc(hidden)]
pub interact_pointer_pos: Option<Pos2>,
pub interact_pointer_pos_or_nan: Pos2,
/// 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
/// for improved layouting.
/// See for instance [`egui_flex`](https://github.com/lucasmerlin/hello_egui/tree/main/crates/egui_flex).
pub intrinsic_size: Option<Vec2>,
#[doc(hidden)]
pub intrinsic_size_or_nan: Vec2,
#[doc(hidden)]
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`.
#[doc(hidden)]
#[derive(Copy, Clone, Debug)]
@@ -141,6 +151,22 @@ bitflags::bitflags! {
}
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.
///
/// 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.
#[inline]
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?
@@ -732,6 +777,7 @@ impl Response {
WidgetRect {
layer_id: self.layer_id,
id: self.id,
parent_id: self.parent_id(),
rect: self.rect,
interact_rect: self.interact_rect,
sense: self.sense | sense,
@@ -1007,8 +1053,10 @@ impl Response {
interact_rect: self.interact_rect.union(other.interact_rect),
sense: self.sense.union(other.sense),
flags: self.flags | other.flags,
interact_pointer_pos: self.interact_pointer_pos.or(other.interact_pointer_pos),
intrinsic_size: None,
interact_pointer_pos_or_nan: self
.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).
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 crate::{
@@ -1319,6 +1323,10 @@ pub struct DebugOptions {
/// Show interesting widgets under the mouse cursor.
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`].
///
/// See [`emath::GuiRounding`] for more.
@@ -1345,6 +1353,7 @@ impl Default for DebugOptions {
show_resize: false,
show_interactive_widgets: false,
show_widget_hits: false,
warn_if_rect_changes_id: cfg!(debug_assertions),
show_unaligned: cfg!(debug_assertions),
show_focused_widget: false,
}
@@ -2526,6 +2535,7 @@ impl DebugOptions {
show_resize,
show_interactive_widgets,
show_widget_hits,
warn_if_rect_changes_id,
show_unaligned,
show_focused_widget,
} = self;
@@ -2557,6 +2567,11 @@ impl DebugOptions {
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(
show_unaligned,
"Show rectangles not aligned to integer point coordinates",
@@ -2876,7 +2891,7 @@ impl Widget for &mut crate::Frame {
impl Widget for &mut FontTweak {
fn ui(self, ui: &mut Ui) -> Response {
let original: FontTweak = *self;
let original: FontTweak = self.clone();
let mut response = Grid::new("font_tweak")
.num_columns(2)
@@ -2886,6 +2901,7 @@ impl Widget for &mut FontTweak {
y_offset_factor,
y_offset,
hinting_override,
coords,
} = self;
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(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() {
*self = Default::default();

View File

@@ -4,6 +4,26 @@ use crate::{Context, Galley, Id};
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.
pub fn update_accesskit_for_text_widget(
ctx: &Context,
@@ -20,14 +40,8 @@ pub fn update_accesskit_for_text_widget(
let anchor = galley.layout_from_cursor(cursor_range.secondary);
let focus = galley.layout_from_cursor(cursor_range.primary);
builder.set_text_selection(accesskit::TextSelection {
anchor: accesskit::TextPosition {
node: parent_id.with(anchor.row).accesskit_id(),
character_index: anchor.column,
},
focus: accesskit::TextPosition {
node: parent_id.with(focus.row).accesskit_id(),
character_index: focus.column,
},
anchor: text_run_position(parent_id, anchor.row, anchor.column),
focus: text_run_position(parent_id, focus.row, focus.column),
});
}
@@ -40,61 +54,144 @@ pub fn update_accesskit_for_text_widget(
return;
};
let mut prev_row_ended_with_newline = true;
for (row_index, row) in galley.rows.iter().enumerate() {
let row_id = parent_id.with(row_index);
let glyph_count = row.glyphs.len();
let mut value = String::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_widths = Vec::<f32>::with_capacity(glyph_count);
let mut word_starts = Vec::<usize>::new();
// For soft-wrapped continuation rows, treat the start as a word
// 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;
ctx.register_accesskit_parent(row_id, parent_id);
for glyph in &row.glyphs {
let is_word_char = is_word_char(glyph.chr);
if is_word_char && was_at_word_end {
word_starts.push(character_lengths.len());
}
was_at_word_end = !is_word_char;
let old_len = value.len();
value.push(glyph.chr);
character_lengths.push((value.len() - old_len) as _);
character_positions.push(glyph.pos.x - row.pos.x);
character_widths.push(glyph.advance_width);
}
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.
if row.ends_with_newline {
value.push('\n');
character_lengths.push(1);
character_positions.push(row.size.x);
character_widths.push(0.0);
}
let glyph_count = row.glyphs.len();
let mut value = String::new();
value.reserve(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_widths = Vec::<f32>::with_capacity(glyph_count);
let mut word_lengths = Vec::<u8>::new();
let mut was_at_word_end = false;
let mut last_word_start = 0usize;
let total_chars = character_lengths.len();
for glyph in &row.glyphs {
let is_word_char = is_word_char(glyph.chr);
if is_word_char && was_at_word_end {
word_lengths.push((character_lengths.len() - last_word_start) as _);
last_word_start = 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_character_lengths(character_lengths);
let pos_offset = character_positions.first().copied().unwrap_or(0.0);
for p in &mut character_positions {
*p -= pos_offset;
}
was_at_word_end = !is_word_char;
let old_len = value.len();
value.push(glyph.chr);
character_lengths.push((value.len() - old_len) as _);
character_positions.push(glyph.pos.x - row.pos.x);
character_widths.push(glyph.advance_width);
}
builder.set_character_positions(character_positions);
builder.set_character_widths(character_widths);
if row.ends_with_newline {
value.push('\n');
character_lengths.push(1);
character_positions.push(row.size.x);
character_widths.push(0.0);
}
word_lengths.push((character_lengths.len() - last_word_start) as _);
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;
builder.set_value(value);
builder.set_character_lengths(character_lengths);
builder.set_character_positions(character_positions);
builder.set_character_widths(character_widths);
builder.set_word_lengths(word_lengths);
});
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 {
action: accesskit::Action::SetTextSelection,
target,
target_node,
target_tree,
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 =
ccursor_from_accesskit_text_position(_widget_id, galley, &selection.focus);
let secondary =
@@ -224,18 +227,31 @@ fn ccursor_from_accesskit_text_position(
galley: &Galley,
position: &accesskit::TextPosition,
) -> Option<CCursor> {
use super::accesskit_text::MAX_CHARS_PER_TEXT_RUN;
let mut total_length = 0usize;
for (i, row) in galley.rows.iter().enumerate() {
let row_id = id.with(i);
if row_id.accesskit_id() == position.node {
return Some(CCursor {
index: total_length + position.character_index,
prefer_next_row: !(position.character_index == row.glyphs.len()
&& !row.ends_with_newline
&& (i + 1) < galley.rows.len()),
});
let row_chars = row.glyphs.len() + (row.ends_with_newline as usize);
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 {
index: total_length + column,
prefer_next_row: !(column == row.glyphs.len()
&& !row.ends_with_newline
&& (i + 1) < galley.rows.len()),
});
}
}
total_length += row.glyphs.len() + (row.ends_with_newline as usize);
total_length += row_chars;
}
None
}

View File

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

View File

@@ -173,6 +173,7 @@ impl Ui {
ui.ctx().create_widget(
WidgetRect {
id: ui.unique_id,
parent_id: ui.id,
layer_id: ui.layer_id(),
rect: start_rect,
interact_rect: start_rect,
@@ -339,6 +340,7 @@ impl Ui {
child_ui.ctx().create_widget(
WidgetRect {
id: child_ui.unique_id,
parent_id: self.id,
layer_id: child_ui.layer_id(),
rect: start_rect,
interact_rect: start_rect,
@@ -1043,6 +1045,7 @@ impl Ui {
self.ctx().create_widget(
WidgetRect {
id,
parent_id: self.id,
layer_id: self.layer_id(),
rect,
interact_rect: self.clip_rect().intersect(rect),
@@ -1112,6 +1115,7 @@ impl Ui {
let mut response = self.ctx().create_widget(
WidgetRect {
id: self.unique_id,
parent_id: self.id,
layer_id: self.layer_id(),
rect: 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 {
let (id, rect) = self.allocate_space(desired_size);
let mut response = self.interact(rect, id, sense);
response.intrinsic_size = Some(desired_size);
response.set_intrinsic_size(desired_size);
response
}

View File

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

View File

@@ -17,6 +17,12 @@ pub struct WidgetRect {
/// You can ensure globally unique ids using [`crate::Ui::push_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.
pub layer_id: LayerId,
@@ -46,6 +52,7 @@ impl WidgetRect {
pub fn transform(self, transform: emath::TSTransform) -> Self {
let Self {
id,
parent_id,
layer_id,
rect,
interact_rect,
@@ -54,6 +61,7 @@ impl WidgetRect {
} = self;
Self {
id,
parent_id,
layer_id,
rect: transform * rect,
interact_rect: transform * interact_rect,

View File

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

View File

@@ -240,6 +240,18 @@ impl<'a> Button<'a> {
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.
#[inline]
pub fn right_text(mut self, right_text: impl IntoAtoms<'a>) -> Self {
@@ -259,6 +271,13 @@ impl<'a> Button<'a> {
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.
pub fn atom_ui(self, ui: &mut Ui) -> AtomLayoutResponse {
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::{
Button, CursorIcon, Id, Key, MINUS_CHAR_STR, Modifiers, NumExt as _, Response, RichText, Sense,
TextEdit, TextWrapMode, Ui, Widget, WidgetInfo, emath, text,
Atom, AtomExt as _, AtomKind, Atoms, Button, CursorIcon, Id, IntoAtoms, Key, MINUS_CHAR_STR,
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> {
get_set_value: GetSetValue<'a>,
speed: f64,
prefix: String,
suffix: String,
atoms: Atoms<'a>,
range: RangeInclusive<f64>,
clamp_existing_to_range: bool,
min_decimals: usize,
@@ -50,6 +48,8 @@ pub struct DragValue<'a> {
}
impl<'a> DragValue<'a> {
const ATOM_ID: &'static str = "drag_item";
pub fn new<Num: emath::Numeric>(value: &'a mut Num) -> Self {
let slf = Self::from_get_set(move |v: Option<f64>| {
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 {
let atoms = Atoms::new(Atom::custom(Id::new(Self::ATOM_ID), Vec2::ZERO).atom_grow(true));
Self {
get_set_value: Box::new(get_set_value),
speed: 1.0,
prefix: Default::default(),
suffix: Default::default(),
atoms,
range: f64::NEG_INFINITY..=f64::INFINITY,
clamp_existing_to_range: true,
min_decimals: 0,
@@ -164,15 +165,15 @@ impl<'a> DragValue<'a> {
/// Show a prefix before the number, e.g. "x: "
#[inline]
pub fn prefix(mut self, prefix: impl ToString) -> Self {
self.prefix = prefix.to_string();
pub fn prefix(mut self, prefix: impl IntoAtoms<'a>) -> Self {
self.atoms.extend_left(prefix.into_atoms());
self
}
/// Add a suffix to the number, this can be e.g. a unit ("°" or " m")
#[inline]
pub fn suffix(mut self, suffix: impl ToString) -> Self {
self.suffix = suffix.to_string();
pub fn suffix(mut self, suffix: impl IntoAtoms<'a>) -> Self {
self.atoms.extend_right(suffix.into_atoms());
self
}
@@ -433,8 +434,7 @@ impl Widget for DragValue<'_> {
speed,
range,
clamp_existing_to_range,
prefix,
suffix,
mut atoms,
min_decimals,
max_decimals,
custom_formatter,
@@ -442,6 +442,23 @@ impl Widget for DragValue<'_> {
update_while_editing,
} = 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());
// The widget has the same ID whether it's in edit or button mode.
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 value_text = ui
.data_mut(|data| data.remove_temp::<String>(id))
@@ -586,13 +601,20 @@ impl Widget for DragValue<'_> {
ui.data_mut(|data| data.insert_temp(id, value_text));
response
} else {
let button = Button::new(
RichText::new(format!("{}{}{}", prefix, value_text.clone(), suffix))
.text_style(text_style),
)
.wrap_mode(TextWrapMode::Extend)
.sense(Sense::click_and_drag())
.min_size(ui.spacing().interact_size); // TODO(emilk): find some more generic solution to `min_size`
atoms.map_atoms(|atom| {
if atom.id == Some(atom_id) {
RichText::new(value_text.clone())
.text_style(text_style.clone())
.into()
} else {
atom
}
});
let button = Button::new(atoms)
.wrap_mode(TextWrapMode::Extend)
.sense(Sense::click_and_drag())
.gap(0.0)
.min_size(ui.spacing().interact_size); // TODO(emilk): find some more generic solution to `min_size`
let cursor_icon = if value <= *range.start() {
CursorIcon::ResizeEast
@@ -607,10 +629,8 @@ impl Widget for DragValue<'_> {
if ui.style().explanation_tooltips {
response = response.on_hover_text(format!(
"{}{}{}\nDrag to edit or click to enter a value.\nPress 'Shift' while dragging for better control.",
prefix,
"{}\nDrag to edit or click to enter a value.\nPress 'Shift' while dragging for better control.",
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
// when in edit mode.
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);
}
});

View File

@@ -220,7 +220,7 @@ impl Label {
.rect_without_leading_space()
.translate(pos.to_vec2());
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) {
let rect = placed_row.rect().translate(pos.to_vec2());
response |= ui.allocate_rect(rect, sense);
@@ -256,7 +256,7 @@ impl Label {
let galley = ui.fonts_mut(|fonts| fonts.layout_job(layout_job));
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 {
Align::LEFT => rect.left_top(),
Align::Center => rect.center_top(),

View File

@@ -1,15 +1,13 @@
use std::sync::Arc;
use emath::{Rect, TSTransform};
use epaint::{
StrokeKind,
text::{Galley, LayoutJob, cursor::CCursor},
};
use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
use crate::{
Align, Align2, Color32, Context, CursorIcon, Event, EventFilter, FontSelection, Id, ImeEvent,
Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, Shape, TextBuffer,
TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetWithState, epaint,
Align, Align2, Atom, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, CursorIcon,
Event, EventFilter, FontSelection, Frame, Id, ImeEvent, IntoAtoms, IntoSizedResult, Key,
KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, SizedAtomKind, TextBuffer,
TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
os::OperatingSystem,
output::OutputEvent,
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);`"]
pub struct TextEdit<'t> {
text: &'t mut dyn TextBuffer,
hint_text: WidgetText,
hint_text_font: Option<FontSelection>,
prefix: Atoms<'static>,
suffix: Atoms<'static>,
hint_text: Atoms<'static>,
id: Option<Id>,
id_salt: Option<Id>,
font_selection: FontSelection,
text_color: Option<Color32>,
layouter: Option<LayouterFn<'t>>,
password: bool,
frame: bool,
frame: Option<Frame>,
margin: Margin,
multiline: bool,
interactive: bool,
@@ -120,15 +119,16 @@ impl<'t> TextEdit<'t> {
pub fn multiline(text: &'t mut dyn TextBuffer) -> Self {
Self {
text,
prefix: Default::default(),
suffix: Default::default(),
hint_text: Default::default(),
hint_text_font: None,
id: None,
id_salt: None,
font_selection: Default::default(),
text_color: None,
layouter: None,
password: false,
frame: true,
frame: None,
margin: Margin::symmetric(4, 2),
multiline: true,
interactive: true,
@@ -202,8 +202,22 @@ impl<'t> TextEdit<'t> {
/// # });
/// ```
#[inline]
pub fn hint_text(mut self, hint_text: impl Into<WidgetText>) -> Self {
self.hint_text = hint_text.into();
pub fn hint_text(mut self, hint_text: impl IntoAtoms<'static>) -> Self {
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
}
@@ -215,13 +229,6 @@ impl<'t> TextEdit<'t> {
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.
#[inline]
pub fn password(mut self, password: bool) -> Self {
@@ -290,10 +297,10 @@ impl<'t> TextEdit<'t> {
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]
pub fn frame(mut self, frame: bool) -> Self {
self.frame = frame;
pub fn frame(mut self, frame: Frame) -> Self {
self.frame = Some(frame);
self
}
@@ -402,7 +409,7 @@ impl<'t> TextEdit<'t> {
impl Widget for TextEdit<'_> {
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 {
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 {
text,
hint_text,
hint_text_font,
prefix,
suffix,
mut hint_text,
id,
id_salt,
font_selection,
text_color,
layouter,
password,
frame: _,
frame,
margin,
multiline,
interactive,
@@ -492,7 +454,7 @@ impl TextEdit<'_> {
clip_text,
char_limit,
return_key,
background_color: _,
background_color,
} = self;
let text_color = text_color
@@ -501,18 +463,16 @@ impl TextEdit<'_> {
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
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 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.
let available_width = (ui.available_width() - margin.sum().x).at_least(MIN_WIDTH);
let desired_width = desired_width.unwrap_or_else(|| ui.spacing().text_edit_width);
let wrap_width = if ui.layout().horizontal_justify() {
available_width
} else {
desired_width.min(available_width)
};
let available_width = ui.available_width().at_least(MIN_WIDTH);
let desired_width = desired_width
.unwrap_or_else(|| ui.spacing().text_edit_width)
.at_least(min_size.x);
let allocate_width = desired_width.at_most(available_width);
let font_id_clone = font_id.clone();
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 mut galley = layouter(ui, text, wrap_width);
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 min_inner_height = (desired_height_rows.at_least(1) as f32) * row_height;
let id = id.unwrap_or_else(|| {
if let Some(id_salt) = id_salt {
ui.make_persistent_id(id_salt)
} 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
// dragging select text, or scroll the enclosing [`ScrollArea`] (if any)?
@@ -565,12 +516,218 @@ impl TextEdit<'_> {
} else {
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;
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
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)
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
&& response.hovered()
&& ui.input(|i| i.pointer.is_moving())
{
// 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);
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(
ui,
&response,
@@ -613,44 +770,15 @@ impl TextEdit<'_> {
ui.set_cursor_icon(CursorIcon::Text);
}
let mut cursor_range = None;
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();
}
cursor_range = Some(new_cursor_range);
if text_changed {
response.mark_changed();
}
let mut galley_pos = align
.align_size_within_rect(galley.size(), rect)
.intersect(rect) // limit pos to the response rect area
.align_size_within_rect(galley.size(), inner_rect)
.intersect(inner_rect) // limit pos to the response rect area
.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
if clip_text && align_offset.x == 0.0 {
@@ -660,18 +788,18 @@ impl TextEdit<'_> {
};
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 cursor_pos < *visible_range.start() {
offset_x = cursor_pos;
} else {
offset_x = cursor_pos - desired_inner_size.x;
offset_x = cursor_pos - inner_rect.width();
}
}
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);
state.text_offset = vec2(offset_x, align_offset.y);
@@ -688,32 +816,7 @@ impl TextEdit<'_> {
false
};
if ui.is_rect_visible(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);
}
if ui.is_rect_visible(inner_rect) {
let has_focus = ui.memory(|mem| mem.has_focus(id));
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);
}
// Allocate additional space if edits were made this frame that changed the size. This is important so that,
// if there's a ScrollArea, it can properly scroll to the cursor.
// Condition `!clip_text` is important to avoid breaking layout for `TextEdit::singleline` (PR #5640)
if !clip_text
&& 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);
painter.galley(
galley_pos - vec2(galley.rect.left(), 0.0),
Arc::clone(&galley),
text_color,
);
if has_focus && let Some(cursor_range) = state.cursor.range(&galley) {
let primary_cursor_rect = cursor_rect(&galley, &cursor_range.primary, row_height)
@@ -767,7 +836,7 @@ impl TextEdit<'_> {
if response.changed() || selection_changed {
// 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 {
@@ -796,9 +865,9 @@ impl TextEdit<'_> {
.layer_transform_to_global(ui.layer_id())
.unwrap_or_default();
ui.ctx().output_mut(|o| {
ui.output_mut(|o| {
o.ime = Some(crate::output::IMEOutput {
rect: to_global * rect,
rect: to_global * inner_rect,
cursor_rect: to_global * primary_cursor_rect,
});
});
@@ -846,24 +915,22 @@ impl TextEdit<'_> {
});
}
{
let role = if password {
accesskit::Role::PasswordInput
} else if multiline {
accesskit::Role::MultilineTextInput
} else {
accesskit::Role::TextInput
};
let role = if password {
accesskit::Role::PasswordInput
} else if multiline {
accesskit::Role::MultilineTextInput
} else {
accesskit::Role::TextInput
};
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
ui.ctx(),
id,
cursor_range,
role,
TSTransform::from_translation(galley_pos.to_vec2()),
&galley,
);
}
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
ui.ctx(),
id,
cursor_range,
role,
TSTransform::from_translation(galley_pos.to_vec2()),
&galley,
);
TextEditOutput {
response,
@@ -911,7 +978,7 @@ fn events(
event_filter: EventFilter,
return_key: Option<KeyboardShortcut>,
) -> (bool, CCursorRange) {
let os = ui.ctx().os();
let os = ui.os();
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 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(_)));
}
let events = ui.input(|i| i.filtered_events(&event_filter));
for event in &events {
let did_mutate_text = match event {
@@ -1066,26 +1127,36 @@ fn events(
} => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key),
Event::Ime(ime_event) => {
/// Empty prediction can be produced with [`ImeEvent::Preedit`]
/// or [`ImeEvent::Commit`] when user press backspace or escape
/// during IME, so this function should be called in both cases
/// to clear current text.
/// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")`
/// might be emitted from different integrations to signify that
/// the current IME composition should be cleared.
///
/// Example platforms where only `ImeEvent::Preedit("")` of
/// those two events is emitted when the last character in the
/// prediction is deleted:
/// - macOS 15.7.3.
/// - Debian13 with gnome48 and wayland.
/// Example integrations where only `ImeEvent::Preedit("")` of
/// those two events is emitted when the last character is
/// deleted with a backspace:
/// - `egui-winit` on macOS 15.7.3.
/// - `egui-winit` on Debian13 with gnome48 and wayland.
///
/// An example platform where only `ImeEvent::Commit("")` of
/// those two events is emitted when the last character in the
/// prediction is deleted:
/// - Safari 26.2 (on macOS 15.7.3).
fn clear_prediction(
/// An example integration where only `ImeEvent::Commit("")` of
/// those two events is emitted when the last character is
/// deleted with a backspace:
/// - `eframe`'s web integration on Safari 26.2 (on macOS
/// 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,
cursor_range: &CCursorRange,
preedit_range: &CCursorRange,
) -> CCursor {
text.delete_selected(cursor_range)
text.delete_selected(preedit_range)
}
match ime_event {
@@ -1094,33 +1165,33 @@ fn events(
state.ime_cursor_range = cursor_range;
None
}
ImeEvent::Preedit(text_mark) => {
if text_mark == "\n" || text_mark == "\r" {
ImeEvent::Preedit(preedit_text) => {
if preedit_text == "\n" || preedit_text == "\r" {
None
} else {
let mut ccursor = clear_prediction(text, &cursor_range);
let mut ccursor = clear_preedit_text(text, &cursor_range);
let start_cursor = ccursor;
if !text_mark.is_empty() {
text.insert_text_at(&mut ccursor, text_mark, char_limit);
if !preedit_text.is_empty() {
text.insert_text_at(&mut ccursor, preedit_text, char_limit);
}
state.ime_cursor_range = cursor_range;
Some(CCursorRange::two(start_cursor, ccursor))
}
}
ImeEvent::Commit(prediction) => {
if prediction == "\n" || prediction == "\r" {
ImeEvent::Commit(commit_text) => {
if commit_text == "\n" || commit_text == "\r" {
None
} else {
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
== 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))
@@ -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`.
fn check_for_mutating_key_press(
os: OperatingSystem,

View File

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

View File

@@ -25,10 +25,10 @@ crate-type = ["cdylib", "rlib"]
[features]
default = ["wgpu", "persistence"]
# image_viewer adds about 0.9 MB of WASM
web_app = ["http", "persistence"]
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"]
image_viewer = ["image/jpeg", "egui_extras/all_loaders", "rfd"]
persistence = ["eframe/persistence", "egui_extras/serde", "egui/persistence", "serde"]
@@ -42,15 +42,15 @@ wayland = ["eframe/wayland"]
x11 = ["eframe/x11"]
[dependencies]
chrono = { workspace = true, features = ["js-sys", "wasmbind"] }
eframe = { workspace = true, default-features = false, features = ["web_screen_reader"] }
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"] }
image = { workspace = true, default-features = false, features = [
# Ensure we can display the test images
"png",
] }
jiff = { workspace = true, features = ["std", "tz-system", "js"] }
log.workspace = true
profiling.workspace = true

View File

@@ -1,7 +1,7 @@
use std::mem;
use accesskit::{Action, ActionRequest, NodeId};
use accesskit_consumer::{FilterResult, Node, Tree, TreeChangeHandler};
use accesskit::{Action, ActionRequest};
use accesskit_consumer::{FilterResult, Node, NodeId, Tree, TreeChangeHandler};
use eframe::epaint::text::TextWrapMode;
use egui::{
@@ -25,7 +25,7 @@ use egui::{
pub struct AccessibilityInspectorPlugin {
pub open: bool,
tree: Option<accesskit_consumer::Tree>,
selected_node: Option<Id>,
selected_node: Option<NodeId>,
queued_action: Option<ActionRequest>,
}
@@ -113,13 +113,17 @@ impl AccessibilityInspectorPlugin {
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();
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 {
ui.debug_painter().debug_rect(
@@ -174,8 +178,10 @@ impl AccessibilityInspectorPlugin {
if node.supports_action(action, &|_node| FilterResult::Include)
&& ui.button(format!("{action:?}")).clicked()
{
let (target_node, target_tree) = node.locate();
let action_request = ActionRequest {
target: node.id(),
target_node,
target_tree,
action,
data: None,
};
@@ -188,8 +194,8 @@ impl AccessibilityInspectorPlugin {
}
}
fn node_ui(ui: &mut Ui, node: &Node<'_>, selected_node: &mut Option<Id>) {
if node.id() == Self::id().value().into()
fn node_ui(ui: &mut Ui, node: &Node<'_>, selected_node: &mut Option<NodeId>) {
if node.locate() == (Self::id().value().into(), accesskit::TreeId::ROOT)
|| node
.value()
.as_deref()
@@ -200,12 +206,12 @@ impl AccessibilityInspectorPlugin {
let label = node
.label()
.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);
// 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.id().0) };
let egui_node_id = unsafe { Id::from_high_entropy_bits(node.locate().0.0) };
ui.push_id(node.id(), |ui| {
let child_count = node.children().len();
@@ -228,7 +234,7 @@ impl AccessibilityInspectorPlugin {
collapsing.set_open(!collapsing.is_open());
}
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() {
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 {
label: Some("custom3d"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -62,7 +62,7 @@ impl Custom3d {
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
multiview_mask: None,
cache: None,
});

View File

@@ -219,6 +219,10 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
driver,
driver_info,
backend,
device_pci_bus_id,
subgroup_min_size,
subgroup_max_size,
transient_saves_memory,
} = &info;
// Example values:
@@ -261,6 +265,19 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
ui.label(format!("0x{device:02X}"));
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.
pub(crate) fn seconds_since_midnight() -> f64 {
use chrono::Timelike as _;
let time = chrono::Local::now().time();
time.num_seconds_from_midnight() as f64 + 1e-9 * (time.nanosecond() as f64)
jiff::Zoned::now()
.time()
.duration_since(jiff::civil::Time::midnight())
.as_secs_f64()
}
/// Trait that wraps different parts of the demo app.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ readme = "README.md"
repository = "https://github.com/emilk/egui/tree/main/crates/egui_demo_lib"
categories = ["gui", "graphics"]
keywords = ["glow", "egui", "gui", "gamedev"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml", "data/*"]
include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml", "data/*"]
[lints]
workspace = true
@@ -26,7 +26,7 @@ rustdoc-args = ["--generate-link-to-definition"]
[features]
default = []
chrono = ["egui_extras/datepicker", "dep:chrono"]
jiff = ["egui_extras/datepicker", "dep:jiff"]
## Allow serialization using [`serde`](https://docs.rs/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
#! ### Optional dependencies
chrono = { workspace = true, optional = true, features = ["js-sys", "wasmbind"] }
jiff = { workspace = true, optional = true, features = ["std", "js"] }
## Enable this when generating docs.
document-features = { 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) {
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!(
"egui is an immediate mode GUI library written in Rust. egui runs both on the web and natively on {}{}{}. \
On the web it is compiled to WebAssembly and rendered with WebGL.{}",
"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 or WebGPU.{}",
OS_APPLE, OS_LINUX, OS_WINDOWS,
if cfg!(target_arch = "wasm32") {
" Everything you see is rendered as textured triangles. There is no DOM, HTML, JS or CSS. Just Rust."
} else {""}
));
ui.label("egui is designed to be easy to use, portable, and fast.");
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);
ui.add_space(12.0);
@@ -52,12 +62,12 @@ impl crate::View for About {
ui.horizontal_wrapped(|ui| {
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.label(", a startup building an SDK for visualizing streams of multimodal data. ");
ui.label("For an example of a real-world egui app, see ");
ui.weak(", a startup building a data platform for robotics. ");
ui.weak("For an example of a professional egui app, run ");
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);
@@ -72,11 +82,9 @@ fn about_immediate_mode(ui: &mut egui::Ui) {
ui.style_mut().spacing.interact_size.y = 0.0; // hack to make `horizontal_wrapped` work better with text.
ui.horizontal_wrapped(|ui| {
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 ");
let _ = ui.small_button("button");
ui.label(" in egui:");
});
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("This is how you create a button in egui:");
});
ui.add_space(8.0);
crate::rust_view_ui(

View File

@@ -119,21 +119,30 @@ impl Default for DemoGroups {
}
impl DemoGroups {
pub fn about_egui_checkbox(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) {
let Self { about, .. } = self;
let mut is_open = open.contains(about.name());
ui.toggle_value(&mut is_open, about.name());
set_open(open, about.name(), is_open);
}
pub fn checkboxes(&mut self, ui: &mut Ui, open: &mut BTreeSet<String>) {
let Self {
about,
about: _,
demos,
tests,
} = self;
{
let mut is_open = open.contains(about.name());
ui.toggle_value(&mut is_open, about.name());
set_open(open, about.name(), is_open);
}
ui.separator();
ui.vertical_centered(|ui| {
ui.strong("Demos");
});
demos.checkboxes(ui, open);
ui.separator();
ui.vertical_centered(|ui| {
ui.strong("Tests");
});
tests.checkboxes(ui, open);
}
@@ -267,22 +276,20 @@ impl DemoWindows {
.default_size(160.0)
.min_size(160.0)
.show_inside(ui, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.heading("✒ egui demos");
ui.vertical_centered_justified(|ui| {
ui.add_space(4.0);
ui.add(
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();
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.add_space(4.0);
ui.separator();

View File

@@ -30,7 +30,7 @@ impl crate::View for SvgTest {
fn ui(&mut self, ui: &mut egui::Ui) {
let Self { color } = self;
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…
ui.add(

View File

@@ -297,6 +297,7 @@ fn rect_shape_ui(ui: &mut egui::Ui, shape: &mut RectShape) {
blur_width,
round_to_pixels,
brush: _,
angle: _,
} = shape;
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)"),
);
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
{
use egui::TextBuffer as _;

View File

@@ -19,11 +19,11 @@ pub struct WidgetGallery {
color: egui::Color32,
animate_progress_bar: bool,
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
#[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,
}
@@ -39,19 +39,19 @@ impl Default for WidgetGallery {
string: Default::default(),
color: egui::Color32::LIGHT_BLUE.linear_multiply(0.5),
animate_progress_bar: false,
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
date: None,
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
with_date_button: true,
}
}
}
impl WidgetGallery {
#[allow(clippy::allow_attributes, unused_mut)] // if not chrono
#[allow(clippy::allow_attributes, unused_mut)] // if not jiff
#[inline]
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;
}
@@ -140,9 +140,9 @@ impl WidgetGallery {
string,
color,
animate_progress_bar,
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
date,
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
with_date_button,
} = self;
@@ -229,7 +229,7 @@ impl WidgetGallery {
ui.end_row();
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.end_row();
@@ -237,17 +237,14 @@ impl WidgetGallery {
"Button with image",
"Button::image_and_text",
));
if ui
.add(egui::Button::image_and_text(egui_icon, "Click me!"))
.clicked()
{
if ui.button((egui_icon, "Click me!")).clicked() {
*boolean = !*boolean;
}
ui.end_row();
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
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(
"egui_extras",
"DatePickerButton",
@@ -305,7 +302,7 @@ fn doc_link_label_with_crate<'a>(
}
}
#[cfg(feature = "chrono")]
#[cfg(feature = "jiff")]
#[cfg(test)]
mod tests {
use super::*;
@@ -317,7 +314,7 @@ mod tests {
pub fn should_match_screenshot() {
let mut demo = WidgetGallery {
// 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()
};

View File

@@ -59,21 +59,27 @@ fn test_italics() {
#[test]
fn test_text_selection() {
let mut harness = Harness::builder().build_ui(|ui| {
let visuals = ui.visuals_mut();
visuals.selection.bg_fill = Color32::LIGHT_GREEN;
visuals.selection.stroke.color = Color32::DARK_BLUE;
let mut results = egui_kittest::SnapshotResults::new();
ui.label("Some varied ☺ text :)\nAnd it has a second line!");
});
harness.run();
harness.fit_contents();
for (test_idx, drag_start_x) in [0.2_f32, 0.9].into_iter().enumerate() {
let mut harness = Harness::builder().build_ui(|ui| {
let visuals = ui.visuals_mut();
visuals.selection.bg_fill = Color32::LIGHT_GREEN;
visuals.selection.stroke.color = Color32::RED;
// Drag to select text:
let label = harness.get_by_role(Role::Label);
harness.drag_at(label.rect().lerp_inside([0.2, 0.25]));
harness.drop_at(label.rect().lerp_inside([0.6, 0.75]));
harness.run();
ui.label("Some varied ☺ text :)\nAnd it has a second line!");
});
harness.run();
harness.fit_contents();
harness.snapshot("text_selection");
// Drag to select text:
let label = harness.get_by_role(Role::Label);
harness.drag_at(label.rect().lerp_inside([drag_start_x, 0.25]));
harness.drop_at(label.rect().lerp_inside([0.6, 0.75]));
harness.run();
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
oid sha256:52d2233594c4bad348f5479dcfad9576ee5fd7d49faedb6f5ba74b374cdaf3ad
size 26977
oid sha256:24f4a9745c60c0353ece5f8fc48200671dcb185f4f0b964bbe66bf4a2fe71d7a
size 27067

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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