Help: input in a focused floating window is timed for the hang report

The end of a frame counted the renderer as busy only when the main
window had the focus, so a stall drawing input in a focused floating
panel or document window wrote no hang report. A floating window that
has the focus and is not minimized now counts too. A frame that sends
that window commands to minimize or hide it, or to move or resize it,
ends idle, including commands its own pass sent, which are noted
before egui passes them on.
This commit is contained in:
dotCooCoo
2026-11-11 17:26:09 -08:00
parent d8be61ddb1
commit 7f5e4519a5
2 changed files with 114 additions and 11 deletions
+10 -2
View File
@@ -128,10 +128,18 @@ impl App {
BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len());
}
// Hide on macOS hides the window at once, before the window reports losing focus.
let shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true))
let main_shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true))
&& self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false));
// A floating panel or document window with the focus counts as well, with the commands it
// was sent after its pass, and those its pass sent (`hang_beacon::watch_input`).
let shown = ctx.input(|i| hang_beacon::shown_window(main_shown, &i.raw.viewports));
let handed_off = BEACON.take_handed_off();
let input = BEACON.take_input();
BEACON.enter(ctx.viewport(|vp| hang_beacon::after_frame(input, shown, &vp.commands)));
let stage = match shown {
Some(id) => ctx.viewport_for(id, |vp| hang_beacon::after_frame(input && !handed_off.contains(&id), true, &vp.commands)),
None => hang_beacon::after_frame(input, false, &[]),
};
BEACON.enter(stage);
}
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+104 -9
View File
@@ -159,6 +159,9 @@ pub struct Beacon {
documents: AtomicUsize,
/// A pass of some window had input since [`Beacon::take_input`] last ran.
input: AtomicBool,
/// The windows a pass handed to the system since [`Beacon::take_handed_off`] last ran. Only the
/// interface thread uses it.
handed_off: Mutex<Vec<egui::ViewportId>>,
commands: Mutex<VecDeque<String>>,
frames: Mutex<VecDeque<FrameTime>>,
}
@@ -177,6 +180,7 @@ impl Beacon {
activity: AtomicU8::new(Activity::None as u8),
documents: AtomicUsize::new(0),
input: AtomicBool::new(false),
handed_off: Mutex::new(Vec::new()),
commands: Mutex::new(VecDeque::new()),
frames: Mutex::new(VecDeque::new()),
}
@@ -234,6 +238,20 @@ impl Beacon {
self.input.swap(false, Ordering::Relaxed)
}
/// A pass of window `id` sent it commands that hand it to the system ([`hands_off`],
/// [`watch_input`]).
pub fn hand_off(&self, id: egui::ViewportId) {
let mut windows = self.handed_off.lock().unwrap_or_else(PoisonError::into_inner);
if !windows.contains(&id) {
windows.push(id);
}
}
/// The windows a pass handed to the system since the last call.
pub fn take_handed_off(&self) -> Vec<egui::ViewportId> {
std::mem::take(&mut *self.handed_off.lock().unwrap_or_else(PoisonError::into_inner))
}
/// A frame ended with `time`, with `documents` documents open.
pub fn frame(&self, time: FrameTime, documents: usize) {
self.documents.store(documents, Ordering::Relaxed);
@@ -285,7 +303,9 @@ pub fn had_input(raw: &egui::RawInput) -> bool {
/// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main
/// window's, after the host's input hook added its own, and those of the windows shown in line
/// (floating panels and documents), whose input does not pass through that hook.
/// (floating panels and documents), whose input does not pass through that hook. Mark as well every
/// pass of a window shown in line that sends its window commands that hand it to the system
/// ([`hands_off`]), before egui passes them on; the main window's are read at the frame's end.
pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) {
ctx.on_begin_pass(
"VectorCraft hang report input",
@@ -295,17 +315,39 @@ pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) {
}
}),
);
ctx.on_end_pass(
"VectorCraft hang report hand-off",
std::sync::Arc::new(move |ui| {
if ui.ctx().viewport_id() != egui::ViewportId::ROOT && ui.ctx().viewport(|vp| hands_off(&vp.commands)) {
beacon.hand_off(ui.ctx().viewport_id());
}
}),
);
}
/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and the main
/// window is `shown` (focused, not minimized or hidden), since input asks for the next frame at
/// once; otherwise [`Stage::Idle`]. A frame that sends the main window `commands` that minimize or
/// hide it, or hand it to the system to move or resize, also ends idle: the system may hold back
/// the next frame until the user is done.
pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage {
/// Whether `commands` minimize or hide their window, or hand it to the system to move or resize:
/// the system may hold back the next frame until the user is done.
pub fn hands_off(commands: &[egui::ViewportCommand]) -> bool {
use egui::ViewportCommand as C;
let handed_off = commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_)));
if input && shown && !handed_off { Stage::Drawing } else { Stage::Idle }
commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_)))
}
/// The window whose end of frame sets the stage ([`after_frame`]): the main window when it is
/// `main_shown`; otherwise a window shown in line (a floating panel or document) that `viewports`
/// (the main window's input) gives as focused and not minimized; otherwise none.
pub fn shown_window(main_shown: bool, viewports: &egui::ViewportIdMap<egui::ViewportInfo>) -> Option<egui::ViewportId> {
if main_shown {
return Some(egui::ViewportId::ROOT);
}
viewports.iter().find(|(id, v)| **id != egui::ViewportId::ROOT && v.focused == Some(true) && v.minimized != Some(true)).map(|(id, _)| *id)
}
/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and a window is
/// `shown` (focused, not minimized or hidden: [`shown_window`]), since input asks for the next
/// frame at once; otherwise [`Stage::Idle`]. A frame that sends that window `commands` that
/// minimize or hide it, or hand it to the system to move or resize ([`hands_off`]), also ends idle.
pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage {
if input && shown && !hands_off(commands) { Stage::Drawing } else { Stage::Idle }
}
/// Ends an [`Activity`] when dropped ([`Beacon::activity`]).
@@ -496,6 +538,59 @@ mod tests {
assert!(B.take_input(), "the main window's input");
}
#[test]
fn a_window_shown_in_line_handed_to_the_system_is_marked() {
static B: Beacon = Beacon::new();
egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| {
let id = viewport.ids.this;
let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect();
let input = egui::RawInput { viewport_id: id, viewports, ..Default::default() };
ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear();
});
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
watch_input(&B, &ctx);
let child = egui::ViewportId::from_hash_of("floating panel");
let frame = |command: Option<egui::ViewportCommand>, main: Option<egui::ViewportCommand>| {
ctx.run_ui(egui::RawInput::default(), |ui| {
ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |ui, _| {
if let Some(c) = command.clone() {
ui.ctx().send_viewport_cmd(c);
}
});
if let Some(c) = main.clone() {
ui.ctx().send_viewport_cmd(c);
}
})
.textures_delta
.clear();
};
frame(None, None);
assert!(B.take_handed_off().is_empty(), "nothing handed off");
frame(Some(egui::ViewportCommand::StartDrag), None);
assert_eq!(B.take_handed_off(), [child], "the floating window starts a move");
assert!(B.take_handed_off().is_empty(), "taken");
frame(Some(egui::ViewportCommand::Focus), None);
assert!(B.take_handed_off().is_empty(), "another command");
frame(None, Some(egui::ViewportCommand::StartDrag));
assert!(B.take_handed_off().is_empty(), "the main window's commands are read at the frame's end");
}
#[test]
fn a_focused_window_shown_in_line_is_the_shown_window() {
use egui::{ViewportId, ViewportIdMap, ViewportInfo};
let child = ViewportId::from_hash_of("document window");
let info = |focused: bool, minimized: bool| ViewportInfo { focused: Some(focused), minimized: Some(minimized), ..Default::default() };
let map = |root: ViewportInfo, window: ViewportInfo| -> ViewportIdMap<ViewportInfo> {
[(ViewportId::ROOT, root), (child, window)].into_iter().collect()
};
assert_eq!(shown_window(true, &map(info(true, false), info(false, false))), Some(ViewportId::ROOT));
assert_eq!(shown_window(false, &map(info(false, false), info(true, false))), Some(child), "the document window has the focus");
assert_eq!(shown_window(false, &map(info(false, false), info(true, true))), None, "minimized");
assert_eq!(shown_window(false, &map(info(false, false), info(false, false))), None, "no window has the focus");
assert_eq!(shown_window(false, &map(info(true, false), info(false, false))), None, "the main window is not shown");
}
#[test]
fn the_marked_clipboard_marks_its_calls() {
type Seen = std::sync::Arc<Mutex<Vec<Activity>>>;