From e3a021eea6a5802b136c256ec6bfb5319c9071cd Mon Sep 17 00:00:00 2001 From: TicClick Date: Wed, 19 Apr 2023 15:29:17 +0200 Subject: [PATCH] Allow for requesting the user's attention to the window (#2905) * add method for requesting attention to the main window * use another enum member for user attention type instead of nested `Option`s (also, document the enum members now that they don't mirror `winit`) * update the docstring Co-authored-by: Emil Ernerfeldt * add an example app for testing window attention requests * Apply suggestions from code review Co-authored-by: Emil Ernerfeldt * remove `chrono` dependency and improve the attention example's readability --------- Co-authored-by: Emil Ernerfeldt --- Cargo.lock | 7 ++ crates/eframe/src/epi.rs | 18 +++ crates/eframe/src/native/epi_integration.rs | 17 ++- crates/egui/src/data/output.rs | 17 +++ crates/egui/src/lib.rs | 2 +- examples/user_attention/Cargo.toml | 11 ++ examples/user_attention/README.mg | 7 ++ examples/user_attention/screenshot.png | Bin 0 -> 4564 bytes examples/user_attention/src/main.rs | 130 ++++++++++++++++++++ 9 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 examples/user_attention/Cargo.toml create mode 100644 examples/user_attention/README.mg create mode 100644 examples/user_attention/screenshot.png create mode 100644 examples/user_attention/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index dbef430a..f567218c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3813,6 +3813,13 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "user_attention" +version = "0.1.0" +dependencies = [ + "eframe", +] + [[package]] name = "usvg" version = "0.28.0" diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index ba06562d..33166362 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -802,6 +802,20 @@ impl Frame { self.output.focus = Some(true); } + /// If the window is unfocused, attract the user's attention (native only). + /// + /// Typically, this means that the window will flash on the taskbar, or bounce, until it is interacted with. + /// + /// When the window comes into focus, or if `None` is passed, the attention request will be automatically reset. + /// + /// See [winit's documentation][user_attention_details] for platform-specific effect details. + /// + /// [user_attention_details]: https://docs.rs/winit/latest/winit/window/enum.UserAttentionType.html + #[cfg(not(target_arch = "wasm32"))] + pub fn request_user_attention(&mut self, kind: egui::UserAttentionType) { + self.output.attention = Some(kind); + } + /// Maximize or unmaximize window. (native only) #[cfg(not(target_arch = "wasm32"))] pub fn set_maximized(&mut self, maximized: bool) { @@ -1126,6 +1140,10 @@ pub(crate) mod backend { #[cfg(not(target_arch = "wasm32"))] pub focus: Option, + /// Set to request a user's attention to the native window. + #[cfg(not(target_arch = "wasm32"))] + pub attention: Option, + #[cfg(not(target_arch = "wasm32"))] pub screenshot_requested: bool, } diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index 9b6d4f36..f7e79a9b 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -235,6 +235,7 @@ pub fn handle_app_output( minimized, maximized, focus, + attention, } = app_output; if let Some(decorated) = decorated { @@ -289,8 +290,17 @@ pub fn handle_app_output( window_state.maximized = maximized; } - if focus == Some(true) { - window.focus_window(); + if !window.has_focus() { + if focus == Some(true) { + window.focus_window(); + } else if let Some(attention) = attention { + use winit::window::UserAttentionType; + window.request_user_attention(match attention { + egui::UserAttentionType::Reset => None, + egui::UserAttentionType::Critical => Some(UserAttentionType::Critical), + egui::UserAttentionType::Informational => Some(UserAttentionType::Informational), + }); + } } } @@ -487,6 +497,9 @@ impl EpiIntegration { } self.frame.output.visible = app_output.visible; // this is handled by post_present self.frame.output.screenshot_requested = app_output.screenshot_requested; + if self.frame.output.attention.is_some() { + self.frame.output.attention = None; + } handle_app_output( window, self.egui_ctx.pixels_per_point(), diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index b0bfa9e6..06dd2bfe 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -185,6 +185,23 @@ impl OpenUrl { } } +/// Types of attention to request from a user when a native window is not in focus. +/// +/// See [winit's documentation][user_attention_type] for platform-specific meaning of the attention types. +/// +/// [user_attention_type]: https://docs.rs/winit/latest/winit/window/enum.UserAttentionType.html +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserAttentionType { + /// Request an elevated amount of animations and flair for the window and the task bar or dock icon. + Critical, + + /// Request a standard amount of attention-grabbing actions. + Informational, + + /// Reset the attention request and interrupt related animations and flashes. + Reset, +} + /// A mouse cursor icon. /// /// egui emits a [`CursorIcon`] in [`PlatformOutput`] each frame as a request to the integration. diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 6280bbab..f0364dac 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -357,7 +357,7 @@ pub use { context::Context, data::{ input::*, - output::{self, CursorIcon, FullOutput, PlatformOutput, WidgetInfo}, + output::{self, CursorIcon, FullOutput, PlatformOutput, UserAttentionType, WidgetInfo}, }, grid::Grid, id::{Id, IdMap}, diff --git a/examples/user_attention/Cargo.toml b/examples/user_attention/Cargo.toml new file mode 100644 index 00000000..56836bfa --- /dev/null +++ b/examples/user_attention/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "user_attention" +version = "0.1.0" +authors = ["TicClick "] +license = "MIT OR Apache-2.0" +edition = "2021" +rust-version = "1.65" +publish = false + +[dependencies] +eframe = { path = "../../crates/eframe" } diff --git a/examples/user_attention/README.mg b/examples/user_attention/README.mg new file mode 100644 index 00000000..4fdc1793 --- /dev/null +++ b/examples/user_attention/README.mg @@ -0,0 +1,7 @@ +An example of requesting a user's attention to the main window, and resetting the ongoing attention animations when necessary. Only works on native platforms. + +```sh +cargo run -p user_attention +``` + +![](screenshot.png) diff --git a/examples/user_attention/screenshot.png b/examples/user_attention/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..3387d313568e95b5876758c569ad6deaae3a7603 GIT binary patch literal 4564 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 String { + format!("{:?}", attention) +} + +struct Application { + attention: UserAttentionType, + request_at: Option, + + auto_reset: bool, + reset_at: Option, +} + +impl Application { + fn new(_cc: &CreationContext<'_>) -> Self { + Self { + attention: UserAttentionType::Informational, + request_at: None, + auto_reset: false, + reset_at: None, + } + } + + fn attention_reset_timeout() -> Duration { + Duration::from_secs(3) + } + + fn attention_request_timeout() -> Duration { + Duration::from_secs(2) + } + + fn repaint_max_timeout() -> Duration { + Duration::from_secs(1) + } +} + +impl eframe::App for Application { + fn update(&mut self, ctx: &Context, frame: &mut eframe::Frame) { + if let Some(request_at) = self.request_at { + if request_at < SystemTime::now() { + self.request_at = None; + frame.request_user_attention(self.attention); + if self.auto_reset { + self.auto_reset = false; + self.reset_at = Some(SystemTime::now() + Self::attention_reset_timeout()); + } + } + } + + if let Some(reset_at) = self.reset_at { + if reset_at < SystemTime::now() { + self.reset_at = None; + frame.request_user_attention(UserAttentionType::Reset); + } + } + + CentralPanel::default().show(ctx, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("Attention type:"); + eframe::egui::ComboBox::new("attention", "") + .selected_text(repr(self.attention)) + .show_ui(ui, |ui| { + for kind in [ + UserAttentionType::Informational, + UserAttentionType::Critical, + ] { + ui.selectable_value(&mut self.attention, kind, repr(kind)); + } + }) + }); + + let button_enabled = self.request_at.is_none() && self.reset_at.is_none(); + let button_text = if button_enabled { + format!( + "Request in {} seconds", + Self::attention_request_timeout().as_secs() + ) + } else { + match self.reset_at { + None => "Unfocus the window, fast!".to_owned(), + Some(t) => { + if let Ok(elapsed) = t.duration_since(SystemTime::now()) { + format!("Resetting attention in {} s...", elapsed.as_secs()) + } else { + "Resetting attention...".to_owned() + } + } + } + }; + + let resp = ui + .add_enabled(button_enabled, Button::new(button_text)) + .on_hover_text_at_pointer( + "After clicking, unfocus the application's window to see the effect", + ); + + ui.checkbox( + &mut self.auto_reset, + format!( + "Reset after {} seconds", + Self::attention_reset_timeout().as_secs() + ), + ); + + if resp.clicked() { + self.request_at = Some(SystemTime::now() + Self::attention_request_timeout()); + } + }); + }); + + ctx.request_repaint_after(Self::repaint_max_timeout()); + } +} + +fn main() -> eframe::Result<()> { + let native_options = NativeOptions { + initial_window_size: Some(eframe::egui::vec2(400., 200.)), + ..Default::default() + }; + eframe::run_native( + "User attention test", + native_options, + Box::new(|cc| Box::new(Application::new(cc))), + ) +}