diff --git a/Cargo.lock b/Cargo.lock index b1ed1855914..d55bd13f6a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,6 +1310,8 @@ dependencies = [ name = "egui_demo_app" version = "0.32.0" dependencies = [ + "accesskit", + "accesskit_consumer", "bytemuck", "chrono", "eframe", diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 9594f03e93f..cc5bf2012c0 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1,23 +1,14 @@ #![warn(missing_docs)] // Let's keep `Context` well-documented. -use std::{borrow::Cow, cell::RefCell, panic::Location, sync::Arc, time::Duration}; - -use emath::{GuiRounding as _, OrderedFloat}; -use epaint::{ - ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect, StrokeKind, - TessellationOptions, TextureAtlas, TextureId, Vec2, - emath::{self, TSTransform}, - mutex::RwLock, - stats::PaintStats, - tessellator, - text::{FontInsert, FontPriority, Fonts}, - vec2, -}; - +use self::{hit_test::WidgetHits, interaction::InteractionSnapshot}; +#[cfg(feature = "accesskit")] +use crate::IdMap; +use crate::plugin::TypedPluginHandle; +use crate::text_selection::LabelSelectionState; use crate::{ Align2, CursorIcon, DeferredViewportUiCallback, FontDefinitions, Grid, Id, ImmediateViewport, ImmediateViewportRendererCallback, Key, KeyboardShortcut, Label, LayerId, Memory, - ModifierNames, Modifiers, NumExt as _, Order, Painter, RawInput, Response, RichText, + ModifierNames, Modifiers, NumExt as _, Order, Painter, Plugin, RawInput, Response, RichText, ScrollArea, Sense, Style, TextStyle, TextureHandle, TextureOptions, Ui, ViewportBuilder, ViewportCommand, ViewportId, ViewportIdMap, ViewportIdPair, ViewportIdSet, ViewportOutput, Widget as _, WidgetRect, WidgetText, @@ -33,15 +24,23 @@ use crate::{ os::OperatingSystem, output::FullOutput, pass_state::PassState, - resize, response, scroll_area, + plugin, resize, response, scroll_area, util::IdTypeMap, viewport::ViewportClass, }; - -#[cfg(feature = "accesskit")] -use crate::IdMap; - -use self::{hit_test::WidgetHits, interaction::InteractionSnapshot}; +use emath::{GuiRounding as _, OrderedFloat}; +use epaint::{ + ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect, StrokeKind, + TessellationOptions, TextureAtlas, TextureId, Vec2, + emath::{self, TSTransform}, + mutex::RwLock, + stats::PaintStats, + tessellator, + text::{FontInsert, FontPriority, Fonts}, + vec2, +}; +use std::any::TypeId; +use std::{borrow::Cow, cell::RefCell, panic::Location, sync::Arc, time::Duration}; /// Information given to the backend about when it is time to repaint the ui. /// @@ -93,46 +92,6 @@ impl Default for WrappedTextureManager { // ---------------------------------------------------------------------------- -/// Generic event callback. -pub type ContextCallback = Arc; - -#[derive(Clone)] -struct NamedContextCallback { - debug_name: &'static str, - callback: ContextCallback, -} - -/// Callbacks that users can register -#[derive(Clone, Default)] -struct Plugins { - pub on_begin_pass: Vec, - pub on_end_pass: Vec, -} - -impl Plugins { - fn call(ctx: &Context, _cb_name: &str, callbacks: &[NamedContextCallback]) { - profiling::scope!("plugins", _cb_name); - for NamedContextCallback { - debug_name: _name, - callback, - } in callbacks - { - profiling::scope!("plugin", _name); - (callback)(ctx); - } - } - - fn on_begin_pass(&self, ctx: &Context) { - Self::call(ctx, "on_begin_pass", &self.on_begin_pass); - } - - fn on_end_pass(&self, ctx: &Context) { - Self::call(ctx, "on_end_pass", &self.on_end_pass); - } -} - -// ---------------------------------------------------------------------------- - /// Repaint-logic impl ContextImpl { /// This is where we update the repaint logic. @@ -417,7 +376,7 @@ struct ContextImpl { memory: Memory, animation_manager: AnimationManager, - plugins: Plugins, + plugins: plugin::Plugins, /// All viewports share the same texture manager and texture namespace. /// @@ -774,10 +733,12 @@ impl Default for Context { }; let ctx = Self(Arc::new(RwLock::new(ctx_impl))); + ctx.add_plugin(crate::plugin::CallbackPlugin::default()); + // Register built-in plugins: - crate::debug_text::register(&ctx); - crate::text_selection::LabelSelectionState::register(&ctx); - crate::DragAndDrop::register(&ctx); + ctx.add_plugin(crate::debug_text::DebugTextPlugin::default()); + ctx.add_plugin(LabelSelectionState::default()); + ctx.add_plugin(crate::DragAndDrop::default()); ctx } @@ -907,13 +868,16 @@ impl Context { /// let full_output = ctx.end_pass(); /// // handle full_output /// ``` - pub fn begin_pass(&self, new_input: RawInput) { + pub fn begin_pass(&self, mut new_input: RawInput) { profiling::function_scope!(); + let plugins = self.read(|ctx| ctx.plugins.ordered_plugins()); + plugins.on_input(&mut new_input); + self.write(|ctx| ctx.begin_pass(new_input)); // Plugins run just after the pass starts: - self.read(|ctx| ctx.plugins.clone()).on_begin_pass(self); + plugins.on_begin_pass(self); } /// See [`Self::begin_pass`]. @@ -1872,26 +1836,73 @@ impl Context { impl Context { /// Call the given callback at the start of each pass of each viewport. /// - /// This can be used for egui _plugins_. - /// See [`crate::debug_text`] for an example. - pub fn on_begin_pass(&self, debug_name: &'static str, cb: ContextCallback) { - let named_cb = NamedContextCallback { - debug_name, - callback: cb, - }; - self.write(|ctx| ctx.plugins.on_begin_pass.push(named_cb)); + /// This is a convenience wrapper around [`Self::add_plugin`]. + pub fn on_begin_pass(&self, debug_name: &'static str, cb: plugin::ContextCallback) { + self.with_plugin(|p: &mut crate::plugin::CallbackPlugin| { + p.on_begin_plugins.push((debug_name, cb)); + }); } /// Call the given callback at the end of each pass of each viewport. /// - /// This can be used for egui _plugins_. - /// See [`crate::debug_text`] for an example. - pub fn on_end_pass(&self, debug_name: &'static str, cb: ContextCallback) { - let named_cb = NamedContextCallback { - debug_name, - callback: cb, - }; - self.write(|ctx| ctx.plugins.on_end_pass.push(named_cb)); + /// This is a convenience wrapper around [`Self::add_plugin`]. + pub fn on_end_pass(&self, debug_name: &'static str, cb: plugin::ContextCallback) { + self.with_plugin(|p: &mut crate::plugin::CallbackPlugin| { + p.on_end_plugins.push((debug_name, cb)); + }); + } + + /// Register a [`Plugin`] + /// + /// Plugins are called in the order they are added. + /// + /// A plugin of the same type can only be added once (further calls with the same type will be ignored). + /// This way it's convenient to add plugins in `eframe::run_simple_native`. + pub fn add_plugin(&self, plugin: impl Plugin + 'static) { + let handle = plugin::PluginHandle::new(plugin); + + let added = self.write(|ctx| ctx.plugins.add(handle.clone())); + + if added { + handle.lock().dyn_plugin_mut().setup(self); + } + } + + /// Call the provided closure with the plugin of type `T`, if it was registered. + /// + /// Returns `None` if the plugin was not registered. + pub fn with_plugin(&self, f: impl FnOnce(&mut T) -> R) -> Option { + let plugin = self.read(|ctx| ctx.plugins.get(TypeId::of::())); + plugin.map(|plugin| f(plugin.lock().typed_plugin_mut())) + } + + /// Get a handle to the plugin of type `T`. + /// + /// ## Panics + /// If the plugin of type `T` was not registered, this will panic. + pub fn plugin(&self) -> TypedPluginHandle { + if let Some(plugin) = self.plugin_opt() { + plugin + } else { + panic!("Plugin of type {:?} not found", std::any::type_name::()); + } + } + + /// Get a handle to the plugin of type `T`, if it was registered. + pub fn plugin_opt(&self) -> Option> { + let plugin = self.read(|ctx| ctx.plugins.get(TypeId::of::())); + plugin.map(TypedPluginHandle::new) + } + + /// Get a handle to the plugin of type `T`, or insert its default. + pub fn plugin_or_default(&self) -> TypedPluginHandle { + if let Some(plugin) = self.plugin_opt() { + plugin + } else { + let default_plugin = T::default(); + self.add_plugin(default_plugin); + self.plugin() + } } } @@ -2257,12 +2268,15 @@ impl Context { } // Plugins run just before the pass ends. - self.read(|ctx| ctx.plugins.clone()).on_end_pass(self); + let plugins = self.read(|ctx| ctx.plugins.ordered_plugins()); + plugins.on_end_pass(self); #[cfg(debug_assertions)] self.debug_painting(); - self.write(|ctx| ctx.end_pass()) + let mut output = self.write(|ctx| ctx.end_pass()); + plugins.on_output(&mut output); + output } /// Call at the end of each frame if you called [`Context::begin_pass`]. @@ -3193,7 +3207,7 @@ impl Context { .show(ui, |ui| { ui.label(format!( "{:#?}", - crate::text_selection::LabelSelectionState::load(ui.ctx()) + *ui.ctx().plugin::().lock() )); }); diff --git a/crates/egui/src/debug_text.rs b/crates/egui/src/debug_text.rs index 2cd1a2755a0..c5fe043fe4e 100644 --- a/crates/egui/src/debug_text.rs +++ b/crates/egui/src/debug_text.rs @@ -1,24 +1,14 @@ //! This is an example of how to create a plugin for egui. //! -//! A plugin usually consist of a struct that holds some state, -//! which is stored using [`Context::data_mut`]. -//! The plugin registers itself onto a specific [`Context`] -//! to get callbacks on certain events ([`Context::on_begin_pass`], [`Context::on_end_pass`]). +//! A plugin is a struct that implements the [`Plugin`] trait and holds some state. +//! The plugin is registered with the [`Context`] using [`Context::add_plugin`] +//! to get callbacks on certain events ([`Plugin::on_begin_pass`], [`Plugin::on_end_pass`]). use crate::{ - Align, Align2, Color32, Context, FontFamily, FontId, Id, Rect, Shape, Vec2, WidgetText, text, + Align, Align2, Color32, Context, FontFamily, FontId, Plugin, Rect, Shape, Vec2, WidgetText, + text, }; -/// Register this plugin on the given egui context, -/// so that it will be called every pass. -/// -/// This is a built-in plugin in egui, -/// meaning [`Context`] calls this from its `Default` implementation, -/// so this is marked as `pub(crate)`. -pub(crate) fn register(ctx: &Context) { - ctx.on_end_pass("debug_text", std::sync::Arc::new(State::end_pass)); -} - /// Print this text next to the cursor at the end of the pass. /// /// If you call this multiple times, the text will be appended. @@ -38,15 +28,12 @@ pub fn print(ctx: &Context, text: impl Into) { let location = std::panic::Location::caller(); let location = format!("{}:{}", location.file(), location.line()); - ctx.data_mut(|data| { - // We use `Id::NULL` as the id, since we only have one instance of this plugin. - // We use the `temp` version instead of `persisted` since we don't want to - // persist state on disk when the egui app is closed. - let state = data.get_temp_mut_or_default::(Id::NULL); - state.entries.push(Entry { - location, - text: text.into(), - }); + + let plugin = ctx.plugin::(); + let mut state = plugin.lock(); + state.entries.push(Entry { + location, + text: text.into(), }); } @@ -58,24 +45,26 @@ struct Entry { /// A plugin for easily showing debug-text on-screen. /// -/// This is a built-in plugin in egui. +/// This is a built-in plugin in egui, automatically registered during [`Context`] creation. #[derive(Clone, Default)] -struct State { +pub struct DebugTextPlugin { // This gets re-filled every pass. entries: Vec, } -impl State { - fn end_pass(ctx: &Context) { - let state = ctx.data_mut(|data| data.remove_temp::(Id::NULL)); - if let Some(state) = state { - state.paint(ctx); - } +impl Plugin for DebugTextPlugin { + fn debug_name(&self) -> &'static str { + "DebugTextPlugin" } - fn paint(self, ctx: &Context) { - let Self { entries } = self; + fn on_end_pass(&mut self, ctx: &Context) { + let entries = std::mem::take(&mut self.entries); + Self::paint_entries(ctx, entries); + } +} +impl DebugTextPlugin { + fn paint_entries(ctx: &Context, entries: Vec) { if entries.is_empty() { return; } diff --git a/crates/egui/src/drag_and_drop.rs b/crates/egui/src/drag_and_drop.rs index 6caecc24b00..21468ed46db 100644 --- a/crates/egui/src/drag_and_drop.rs +++ b/crates/egui/src/drag_and_drop.rs @@ -1,44 +1,46 @@ use std::{any::Any, sync::Arc}; -use crate::{Context, CursorIcon, Id}; +use crate::{Context, CursorIcon, Plugin}; -/// Tracking of drag-and-drop payload. +/// Plugin for tracking drag-and-drop payload. /// -/// This is a low-level API. +/// This plugin stores the current drag-and-drop payload internally and handles +/// automatic cleanup when the drag operation ends (via Escape key or mouse release). /// -/// For a higher-level API, see: +/// This is a low-level API. For a higher-level API, see: /// - [`crate::Ui::dnd_drag_source`] /// - [`crate::Ui::dnd_drop_zone`] /// - [`crate::Response::dnd_set_drag_payload`] /// - [`crate::Response::dnd_hover_payload`] /// - [`crate::Response::dnd_release_payload`] /// +/// This is a built-in plugin in egui, automatically registered during [`Context`] creation. +/// /// See [this example](https://github.com/emilk/egui/blob/main/crates/egui_demo_lib/src/demo/drag_and_drop.rs). #[doc(alias = "drag and drop")] #[derive(Clone, Default)] pub struct DragAndDrop { - /// If set, something is currently being dragged + /// The current drag-and-drop payload, if any. Automatically cleared when drag ends. payload: Option>, } -impl DragAndDrop { - pub(crate) fn register(ctx: &Context) { - ctx.on_begin_pass("drag_and_drop_begin_pass", Arc::new(Self::begin_pass)); - ctx.on_end_pass("drag_and_drop_end_pass", Arc::new(Self::end_pass)); +impl Plugin for DragAndDrop { + fn debug_name(&self) -> &'static str { + "DragAndDrop" } /// Interrupt drag-and-drop if the user presses the escape key. /// /// This needs to happen at frame start so we can properly capture the escape key. - fn begin_pass(ctx: &Context) { - let has_any_payload = Self::has_any_payload(ctx); + fn on_begin_pass(&mut self, ctx: &Context) { + let has_any_payload = self.payload.is_some(); if has_any_payload { let abort_dnd_due_to_escape_key = ctx.input_mut(|i| i.consume_key(crate::Modifiers::NONE, crate::Key::Escape)); if abort_dnd_due_to_escape_key { - Self::clear_payload(ctx); + self.payload = None; } } } @@ -48,14 +50,14 @@ impl DragAndDrop { /// This is a catch-all safety net in case user code doesn't capture the drag payload itself. /// This must happen at end-of-frame such that we don't shadow the mouse release event from user /// code. - fn end_pass(ctx: &Context) { - let has_any_payload = Self::has_any_payload(ctx); + fn on_end_pass(&mut self, ctx: &Context) { + let has_any_payload = self.payload.is_some(); if has_any_payload { let abort_dnd_due_to_mouse_release = ctx.input_mut(|i| i.pointer.any_released()); if abort_dnd_due_to_mouse_release { - Self::clear_payload(ctx); + self.payload = None; } else { // We set the cursor icon only if its default, as the user code might have // explicitly set it already. @@ -67,7 +69,9 @@ impl DragAndDrop { } } } +} +impl DragAndDrop { /// Set a drag-and-drop payload. /// /// This can be read by [`Self::payload`] until the pointer is released. @@ -75,18 +79,12 @@ impl DragAndDrop { where Payload: Any + Send + Sync, { - ctx.data_mut(|data| { - let state = data.get_temp_mut_or_default::(Id::NULL); - state.payload = Some(Arc::new(payload)); - }); + ctx.plugin::().lock().payload = Some(Arc::new(payload)); } /// Clears the payload, setting it to `None`. pub fn clear_payload(ctx: &Context) { - ctx.data_mut(|data| { - let state = data.get_temp_mut_or_default::(Id::NULL); - state.payload = None; - }); + ctx.plugin::().lock().payload = None; } /// Retrieve the payload, if any. @@ -99,11 +97,13 @@ impl DragAndDrop { where Payload: Any + Send + Sync, { - ctx.data(|data| { - let state = data.get_temp::(Id::NULL)?; - let payload = state.payload?; - payload.downcast().ok() - }) + ctx.plugin::() + .lock() + .payload + .as_ref()? + .clone() + .downcast() + .ok() } /// Retrieve and clear the payload, if any. @@ -116,11 +116,7 @@ impl DragAndDrop { where Payload: Any + Send + Sync, { - ctx.data_mut(|data| { - let state = data.get_temp_mut_or_default::(Id::NULL); - let payload = state.payload.take()?; - payload.downcast().ok() - }) + ctx.plugin::().lock().payload.take()?.downcast().ok() } /// Are we carrying a payload of the given type? @@ -139,9 +135,6 @@ impl DragAndDrop { /// Returns `true` both during a drag and on the frame the pointer is released /// (if there is a payload). pub fn has_any_payload(ctx: &Context) -> bool { - ctx.data(|data| { - let state = data.get_temp::(Id::NULL); - state.is_some_and(|state| state.payload.is_some()) - }) + ctx.plugin::().lock().payload.is_some() } } diff --git a/crates/egui/src/id.rs b/crates/egui/src/id.rs index 7bcef8dc220..cce99dd230b 100644 --- a/crates/egui/src/id.rs +++ b/crates/egui/src/id.rs @@ -83,6 +83,25 @@ impl Id { pub(crate) fn accesskit_id(&self) -> accesskit::NodeId { self.value().into() } + + /// Create a new [`Id`] from a high-entropy value. No hashing is done. + /// + /// This can be useful if you have an [`Id`] that was converted to some other type + /// (e.g. accesskit::NodeId) and you want to convert it back to an [`Id`]. + /// + /// # Safety + /// You need to ensure that the value is high-entropy since it might be used in + /// a [`IdSet`] or [`IdMap`], which rely on the assumption that [`Id`]s have good entropy. + /// + /// The method is not unsafe in terms of memory safety. + /// + /// # Panics + /// If the value is zero, this will panic. + #[doc(hidden)] + #[allow(unsafe_code)] + pub unsafe fn from_high_entropy_bits(value: u64) -> Self { + Self(NonZeroU64::new(value).expect("Id must be non-zero."), ) + } } impl std::fmt::Debug for Id { diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index c2908df1984..56adc01dc53 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -406,6 +406,7 @@ #![allow(clippy::manual_range_contains)] mod animation_manager; +mod atomics; pub mod cache; pub mod containers; mod context; @@ -429,6 +430,7 @@ pub mod os; mod painter; mod pass_state; pub(crate) mod placer; +mod plugin; pub mod response; mod sense; pub mod style; @@ -442,7 +444,6 @@ mod widget_rect; pub mod widget_text; pub mod widgets; -mod atomics; #[cfg(feature = "callstack")] #[cfg(debug_assertions)] mod callstack; @@ -501,6 +502,7 @@ pub use self::{ load::SizeHint, memory::{Memory, Options, Theme, ThemePreference}, painter::Painter, + plugin::Plugin, response::{InnerResponse, Response}, sense::Sense, style::{FontSelection, Spacing, Style, TextStyle, Visuals}, diff --git a/crates/egui/src/plugin.rs b/crates/egui/src/plugin.rs new file mode 100644 index 00000000000..32f9c6ef65a --- /dev/null +++ b/crates/egui/src/plugin.rs @@ -0,0 +1,237 @@ +use crate::{Context, FullOutput, RawInput}; +use ahash::HashMap; +use epaint::mutex::{Mutex, MutexGuard}; +use std::sync::Arc; + +/// A plugin to extend egui. +/// +/// Add plugins via [`Context::add_plugin`]. +/// +/// Plugins should not hold a reference to the [`Context`], since this would create a cycle +/// (which would prevent the [`Context`] from being dropped). +#[expect(unused_variables)] +pub trait Plugin: Send + Sync + 'static { + /// Plugin name. + /// + /// Used when profiling. + fn debug_name(&self) -> &'static str; + + /// Called once, when the plugin is registered. + /// + /// Useful to e.g. register image loaders. + fn setup(&mut self, ctx: &Context) {} + + /// Called at the start of each pass. + /// + /// Can be used to show ui, e.g. a [`crate::Window`] or [`crate::SidePanel`]. + fn on_begin_pass(&mut self, ctx: &Context) {} + + /// Called at the end of each pass. + /// + /// Can be used to show ui, e.g. a [`crate::Window`]. + fn on_end_pass(&mut self, ctx: &Context) {} + + /// Called just before the input is processed. + /// + /// Useful to inspect or modify the input. + /// Since this is called outside a pass, don't show ui here. + fn input_hook(&mut self, input: &mut RawInput) {} + + /// Called just before the output is passed to the backend. + /// + /// Useful to inspect or modify the output. + /// Since this is called outside a pass, don't show ui here. + fn output_hook(&mut self, output: &mut FullOutput) {} +} + +pub(crate) struct PluginHandle { + plugin: Box, + get_plugin_mut: fn(&mut Self) -> &mut dyn Plugin, +} + +pub struct TypedPluginHandle { + handle: Arc>, + _type: std::marker::PhantomData

, +} + +impl TypedPluginHandle

{ + pub(crate) fn new(handle: Arc>) -> Self { + Self { + handle, + _type: std::marker::PhantomData, + } + } + + pub fn lock(&self) -> TypedPluginGuard<'_, P> { + TypedPluginGuard { + guard: self.handle.lock(), + _type: std::marker::PhantomData, + } + } +} + +pub struct TypedPluginGuard<'a, P: Plugin> { + guard: MutexGuard<'a, PluginHandle>, + _type: std::marker::PhantomData

, +} + +impl TypedPluginGuard<'_, P> {} + +impl std::ops::Deref for TypedPluginGuard<'_, P> { + type Target = P; + + fn deref(&self) -> &Self::Target { + self.guard.typed_plugin() + } +} + +impl std::ops::DerefMut for TypedPluginGuard<'_, P> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.guard.typed_plugin_mut() + } +} + +impl PluginHandle { + pub fn new(plugin: P) -> Arc> { + Arc::new(Mutex::new(Self { + plugin: Box::new(plugin), + get_plugin_mut: |handle| { + let plugin: &mut P = handle.typed_plugin_mut(); + plugin as &mut dyn Plugin + }, + })) + } + + fn plugin_type_id(&self) -> std::any::TypeId { + (*self.plugin).type_id() + } + + pub fn dyn_plugin_mut(&mut self) -> &mut dyn Plugin { + (self.get_plugin_mut)(self) + } + + fn typed_plugin(&self) -> &P { + (*self.plugin) + .downcast_ref::

() + .expect("PluginHandle: plugin is not of the expected type") + } + + pub fn typed_plugin_mut(&mut self) -> &mut P { + (*self.plugin) + .downcast_mut::

() + .expect("PluginHandle: plugin is not of the expected type") + } +} + +/// User-registered plugins. +#[derive(Clone, Default)] +pub(crate) struct Plugins { + plugins: HashMap>>, + plugins_ordered: PluginsOrdered, +} + +#[derive(Clone, Default)] +pub(crate) struct PluginsOrdered(Vec>>); + +impl PluginsOrdered { + fn for_each_dyn(&self, mut f: F) + where + F: FnMut(&mut dyn Plugin), + { + for plugin in &self.0 { + let mut plugin = plugin.lock(); + profiling::scope!("plugin", plugin.dyn_plugin_mut().debug_name()); + f(plugin.dyn_plugin_mut()); + } + } + + pub fn on_begin_pass(&self, ctx: &Context) { + profiling::scope!("plugins", "on_begin_pass"); + self.for_each_dyn(|p| { + p.on_begin_pass(ctx); + }); + } + + pub fn on_end_pass(&self, ctx: &Context) { + profiling::scope!("plugins", "on_end_pass"); + self.for_each_dyn(|p| { + p.on_end_pass(ctx); + }); + } + + pub fn on_input(&self, input: &mut RawInput) { + profiling::scope!("plugins", "on_input"); + self.for_each_dyn(|plugin| { + plugin.input_hook(input); + }); + } + + pub fn on_output(&self, output: &mut FullOutput) { + profiling::scope!("plugins", "on_output"); + self.for_each_dyn(|plugin| { + plugin.output_hook(output); + }); + } +} + +impl Plugins { + pub fn ordered_plugins(&self) -> PluginsOrdered { + self.plugins_ordered.clone() + } + + /// Remember to call [`Plugin::setup`] on the plugin after adding it. + /// + /// Will not add the plugin if a plugin of the same type already exists. + /// Returns `false` if the plugin was not added, `true` if it was added. + pub fn add(&mut self, handle: Arc>) -> bool { + profiling::scope!("plugins", "add"); + + let type_id = handle.lock().plugin_type_id(); + + if self.plugins.contains_key(&type_id) { + return false; + } + + self.plugins.insert(type_id, handle.clone()); + self.plugins_ordered.0.push(handle); + + true + } + + pub fn get(&self, type_id: std::any::TypeId) -> Option>> { + self.plugins.get(&type_id).cloned() + } +} + +/// Generic event callback. +pub type ContextCallback = Arc; + +#[derive(Default)] +pub(crate) struct CallbackPlugin { + pub on_begin_plugins: Vec<(&'static str, ContextCallback)>, + pub on_end_plugins: Vec<(&'static str, ContextCallback)>, +} + +impl Plugin for CallbackPlugin { + fn debug_name(&self) -> &'static str { + "CallbackPlugins" + } + + fn on_begin_pass(&mut self, ctx: &Context) { + profiling::function_scope!(); + + for (_debug_name, cb) in &self.on_begin_plugins { + profiling::scope!(*_debug_name); + (cb)(ctx); + } + } + + fn on_end_pass(&mut self, ctx: &Context) { + profiling::function_scope!(); + + for (_debug_name, cb) in &self.on_end_plugins { + profiling::scope!(*_debug_name); + (cb)(ctx); + } + } +} diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index 8297bc42975..da248a0f5cb 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use emath::TSTransform; use crate::{ - Context, CursorIcon, Event, Galley, Id, LayerId, Pos2, Rect, Response, Ui, layers::ShapeIdx, - text::CCursor, text_selection::CCursorRange, + Context, CursorIcon, Event, Galley, Id, LayerId, Plugin, Pos2, Rect, Response, Ui, + layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, }; use super::{ @@ -123,65 +123,45 @@ impl Default for LabelSelectionState { } } -impl LabelSelectionState { - pub(crate) fn register(ctx: &Context) { - ctx.on_begin_pass("LabelSelectionState", std::sync::Arc::new(Self::begin_pass)); - ctx.on_end_pass("LabelSelectionState", std::sync::Arc::new(Self::end_pass)); - } - - pub fn load(ctx: &Context) -> Self { - let id = Id::new(ctx.viewport_id()); - ctx.data(|data| data.get_temp::(id)) - .unwrap_or_default() +impl Plugin for LabelSelectionState { + fn debug_name(&self) -> &'static str { + "LabelSelectionState" } - pub fn store(self, ctx: &Context) { - let id = Id::new(ctx.viewport_id()); - ctx.data_mut(|data| { - data.insert_temp(id, self); - }); - } - - fn begin_pass(ctx: &Context) { - let mut state = Self::load(ctx); - + fn on_begin_pass(&mut self, ctx: &Context) { if ctx.input(|i| i.pointer.any_pressed() && !i.modifiers.shift) { // Maybe a new selection is about to begin, but the old one is over: // state.selection = None; // TODO(emilk): this makes sense, but doesn't work as expected. } - state.selection_bbox_last_frame = state.selection_bbox_this_frame; - state.selection_bbox_this_frame = Rect::NOTHING; + self.selection_bbox_last_frame = self.selection_bbox_this_frame; + self.selection_bbox_this_frame = Rect::NOTHING; - state.any_hovered = false; - state.has_reached_primary = false; - state.has_reached_secondary = false; - state.text_to_copy.clear(); - state.last_copied_galley_rect = None; - state.painted_selections.clear(); - - state.store(ctx); + self.any_hovered = false; + self.has_reached_primary = false; + self.has_reached_secondary = false; + self.text_to_copy.clear(); + self.last_copied_galley_rect = None; + self.painted_selections.clear(); } - fn end_pass(ctx: &Context) { - let mut state = Self::load(ctx); - - if state.is_dragging { + fn on_end_pass(&mut self, ctx: &Context) { + if self.is_dragging { ctx.set_cursor_icon(CursorIcon::Text); } - if !state.has_reached_primary || !state.has_reached_secondary { + if !self.has_reached_primary || !self.has_reached_secondary { // We didn't see both cursors this frame, // maybe because they are outside the visible area (scrolling), // or one disappeared. In either case we will have horrible glitches, so let's just deselect. - let prev_selection = state.selection.take(); + let prev_selection = self.selection.take(); if let Some(selection) = prev_selection { // This was the first frame of glitch, so hide the // glitching by removing all painted selections: ctx.graphics_mut(|layers| { if let Some(list) = layers.get_mut(selection.layer_id) { - for (shape_idx, row_selections) in state.painted_selections.drain(..) { + for (shape_idx, row_selections) in self.painted_selections.drain(..) { list.mutate_shape(shape_idx, |shape| { if let epaint::Shape::Text(text_shape) = &mut shape.shape { let galley = Arc::make_mut(&mut text_shape.galley); @@ -211,25 +191,25 @@ impl LabelSelectionState { } let pressed_escape = ctx.input(|i| i.key_pressed(crate::Key::Escape)); - let clicked_something_else = ctx.input(|i| i.pointer.any_pressed()) && !state.any_hovered; + let clicked_something_else = ctx.input(|i| i.pointer.any_pressed()) && !self.any_hovered; let delected_everything = pressed_escape || clicked_something_else; if delected_everything { - state.selection = None; + self.selection = None; } if ctx.input(|i| i.pointer.any_released()) { - state.is_dragging = false; + self.is_dragging = false; } - let text_to_copy = std::mem::take(&mut state.text_to_copy); + let text_to_copy = std::mem::take(&mut self.text_to_copy); if !text_to_copy.is_empty() { ctx.copy_text(text_to_copy); } - - state.store(ctx); } +} +impl LabelSelectionState { pub fn has_selection(&self) -> bool { self.selection.is_some() } @@ -297,7 +277,8 @@ impl LabelSelectionState { fallback_color: epaint::Color32, underline: epaint::Stroke, ) { - let mut state = Self::load(ui.ctx()); + let plugin = ui.ctx().plugin::(); + let mut state = plugin.lock(); let new_vertex_indices = state.on_label(ui, response, galley_pos, &mut galley); let shape_idx = ui.painter().add( @@ -309,8 +290,6 @@ impl LabelSelectionState { .painted_selections .push((shape_idx, new_vertex_indices)); } - - state.store(ui.ctx()); } fn cursor_for( diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index 5868ed481ec..1193b289d10 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -39,6 +39,7 @@ persistence = [ puffin = ["dep:puffin", "dep:puffin_http", "profiling/profile-with-puffin"] serde = ["dep:serde", "egui_demo_lib/serde", "egui/serde"] syntect = ["egui_demo_lib/syntect"] +accessibility_inspector = ["dep:accesskit", "dep:accesskit_consumer", "eframe/accesskit"] glow = ["eframe/glow"] wgpu = ["eframe/wgpu", "bytemuck", "dep:wgpu"] @@ -64,6 +65,8 @@ log.workspace = true profiling.workspace = true # Optional dependencies: +accesskit = { version = "0.19.0", optional = true } +accesskit_consumer = { version = "0.28.0", optional = true } bytemuck = { workspace = true, optional = true } puffin = { workspace = true, optional = true } diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs new file mode 100644 index 00000000000..505ebdced30 --- /dev/null +++ b/crates/egui_demo_app/src/accessibility_inspector.rs @@ -0,0 +1,256 @@ +use accesskit::{Action, ActionRequest, NodeId}; +use accesskit_consumer::{Node, Tree, TreeChangeHandler}; +use eframe::epaint::text::TextWrapMode; +use egui::collapsing_header::CollapsingState; +use egui::{ + Button, Color32, Context, Event, Frame, FullOutput, Id, Key, KeyboardShortcut, Label, + Modifiers, RawInput, RichText, ScrollArea, SidePanel, TopBottomPanel, Ui, +}; +use std::mem; + +#[derive(Default, Debug)] +pub struct AccessibilityInspectorPlugin { + pub open: bool, + tree: Option, + selected_node: Option, + queued_action: Option, +} + +struct ChangeHandler; + +impl TreeChangeHandler for ChangeHandler { + fn node_added(&mut self, _node: &Node<'_>) {} + + fn node_updated(&mut self, _old_node: &Node<'_>, _new_node: &Node<'_>) {} + + fn focus_moved(&mut self, _old_node: Option<&Node<'_>>, _new_node: Option<&Node<'_>>) {} + + fn node_removed(&mut self, _node: &Node<'_>) {} +} + +impl egui::Plugin for AccessibilityInspectorPlugin { + fn debug_name(&self) -> &'static str { + "Accessibility Inspector" + } + + fn input_hook(&mut self, input: &mut RawInput) { + if let Some(queued_action) = self.queued_action.take() { + input + .events + .push(Event::AccessKitActionRequest(queued_action)); + } + } + + fn output_hook(&mut self, output: &mut FullOutput) { + if let Some(update) = output.platform_output.accesskit_update.clone() { + self.tree = match mem::take(&mut self.tree) { + None => { + // Create a new tree if it doesn't exist + Some(Tree::new(update, true)) + } + Some(mut tree) => { + // Update the tree with the latest accesskit data + tree.update_and_process_changes(update, &mut ChangeHandler); + + Some(tree) + } + } + } + } + + fn on_begin_pass(&mut self, ctx: &Context) { + if ctx.input_mut(|i| { + i.consume_shortcut(&KeyboardShortcut::new( + Modifiers::COMMAND | Modifiers::ALT, + Key::I, + )) + }) { + self.open = !self.open; + } + + if self.open { + ctx.enable_accesskit(); + + SidePanel::right(Self::id()).show(ctx, |ui| { + let response = ui.heading("🔎 AccessKit Inspector"); + ctx.with_accessibility_parent(response.id, || { + if let Some(selected_node) = &self.selected_node { + TopBottomPanel::bottom(Self::id().with("details_panel")) + .frame(Frame::new()) + .show_separator_line(false) + .show_inside(ui, |ui| { + ui.separator(); + + if let Some(tree) = &self.tree { + if let Some(node) = + tree.state().node_by_id(NodeId::from(selected_node.value())) + { + let node_response = ui.ctx().read_response(*selected_node); + + if let Some(widget_response) = node_response { + ui.ctx().debug_painter().debug_rect( + widget_response.rect, + ui.style_mut().visuals.selection.bg_fill, + "", + ); + } + + egui::Grid::new("node_details_grid").num_columns(2).show( + ui, + |ui| { + ui.label("Node ID:"); + ui.strong(format!("{selected_node:?}")); + ui.end_row(); + + ui.label("Role:"); + ui.strong(format!("{:?}", node.role())); + ui.end_row(); + + ui.label("Label:"); + ui.add( + Label::new( + RichText::new( + node.label().unwrap_or_default(), + ) + .strong(), + ) + .truncate(), + ); + ui.end_row(); + + ui.label("Value:"); + ui.add( + Label::new( + RichText::new( + node.value().unwrap_or_default(), + ) + .strong(), + ) + .truncate(), + ); + ui.end_row(); + + ui.label("Children:"); + ui.label( + RichText::new(format!( + "{}", + node.children().len() + )) + .strong(), + ); + ui.end_row(); + }, + ); + + ui.label("Actions:"); + ui.horizontal_wrapped(|ui| { + for action_n in 0..50 { + let action = Action::n(action_n); + let Some(action) = action else { + break; + }; + if node.supports_action(action) + && ui.button(format!("{action:?}")).clicked() + { + let action_request = ActionRequest { + target: node.id(), + action, + data: None, + }; + self.queued_action = Some(action_request); + } + } + }); + } else { + ui.label("Node not found"); + } + } else { + ui.label("No tree data available"); + } + }); + } + + ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate); + ScrollArea::vertical().show(ui, |ui| { + if let Some(tree) = &self.tree { + Self::node_ui(ui, &tree.state().root(), &mut self.selected_node); + } + }); + }); + }); + } + } +} + +impl AccessibilityInspectorPlugin { + fn id() -> Id { + Id::new("Accessibility Inspector") + } + + fn node_ui(ui: &mut Ui, node: &Node<'_>, selected_node: &mut Option) { + if node.id() == Self::id().value().into() + || node + .value() + .as_deref() + .is_some_and(|l| l.contains("AccessKit Inspector")) + { + return; + } + let label = node + .label() + .or(node.value()) + .unwrap_or(node.id().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)}; + + ui.push_id(node.id(), |ui| { + let child_count = node.children().len(); + let has_children = child_count > 0; + let default_open = child_count == 1 && node.role() != accesskit::Role::Label; + + let mut collapsing = CollapsingState::load_with_default_open( + ui.ctx(), + node_id.with("ak_collapse"), + default_open, + ); + + let header_response = ui.horizontal(|ui| { + let text = if collapsing.is_open() { "⏷" } else { "⏵" }; + + if ui + .add_visible(has_children, Button::new(text).frame_when_inactive(false)) + .clicked() + { + collapsing.set_open(!collapsing.is_open()); + }; + let label_response = ui.selectable_value( + selected_node, + Some(egui_node_id), + label.clone(), + ); + if label_response.hovered() { + let widget_response = ui.ctx().read_response(node_id); + + if let Some(widget_response) = widget_response { + ui.ctx() + .debug_painter() + .debug_rect(widget_response.rect, Color32::RED, ""); + } + } + }); + + if has_children { + collapsing.show_body_indented(&header_response.response, ui, |ui| { + node.children().for_each(|c| { + Self::node_ui(ui, &c, selected_node); + }); + }); + } + + collapsing.store(ui.ctx()); + }); + } +} diff --git a/crates/egui_demo_app/src/lib.rs b/crates/egui_demo_app/src/lib.rs index 9cfa26baadc..c2d523816f9 100644 --- a/crates/egui_demo_app/src/lib.rs +++ b/crates/egui_demo_app/src/lib.rs @@ -16,7 +16,8 @@ pub(crate) fn seconds_since_midnight() -> f64 { } // ---------------------------------------------------------------------------- - +#[cfg(feature = "accessibility_inspector")] +mod accessibility_inspector; #[cfg(target_arch = "wasm32")] mod web; diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 3775d93d211..3df454c03a3 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -187,6 +187,9 @@ impl WrapApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { // This gives us image support: egui_extras::install_image_loaders(&cc.egui_ctx); + #[cfg(feature = "accessibility_inspector")] + cc.egui_ctx + .add_plugin(crate::accessibility_inspector::AccessibilityInspectorPlugin::default()); #[allow(unused_mut, clippy::allow_attributes)] let mut slf = Self {