diff --git a/CHANGELOG.md b/CHANGELOG.md index c530657dc..dc4785b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ NOTE: [`epaint`](crates/epaint/CHANGELOG.md), [`eframe`](crates/eframe/CHANGELOG * Added `Key::Minus` and `Key::Equals` ([#2239](https://github.com/emilk/egui/pull/2239)). * Added `egui::gui_zoom` module with helpers for scaling the whole GUI of an app ([#2239](https://github.com/emilk/egui/pull/2239)). * You can now put one interactive widget on top of another, and only one will get interaction at a time ([#2244](https://github.com/emilk/egui/pull/2244)). -* Add `ui.centered`. -* Added `Area::constrain` which constrains area to the screen bounds. ([#2270](https://github.com/emilk/egui/pull/2270)). +* Added `ui.centered`. +* Added `Area::constrain` and `Window::constrain` which constrains area to the screen bounds. ([#2270](https://github.com/emilk/egui/pull/2270)). +* Added `Area::pivot` and `Window::pivot` which controls what part of the window to position. ([#2303](https://github.com/emilk/egui/pull/2303)). ### Changed 🔧 * Panels always have a separator line, but no stroke on other sides. Their spacing has also changed slightly ([#2261](https://github.com/emilk/egui/pull/2261)). diff --git a/crates/eframe/src/native/epi_integration.rs b/crates/eframe/src/native/epi_integration.rs index fbf3030d4..f76956d0b 100644 --- a/crates/eframe/src/native/epi_integration.rs +++ b/crates/eframe/src/native/epi_integration.rs @@ -147,7 +147,7 @@ pub fn handle_app_output( fullscreen, drag_window, window_pos, - visible, + visible: _, // handled in post_present always_on_top, minimized, maximized, @@ -186,10 +186,6 @@ pub fn handle_app_output( let _ = window.drag_window(); } - if let Some(visible) = visible { - window.set_visible(visible); - } - if let Some(always_on_top) = always_on_top { window.set_always_on_top(always_on_top); } @@ -251,7 +247,10 @@ impl EpiIntegration { native_pixels_per_point: Some(native_pixels_per_point), window_info: read_window_info(window, egui_ctx.pixels_per_point()), }, - output: Default::default(), + output: epi::backend::AppOutput { + visible: Some(true), + ..Default::default() + }, storage, #[cfg(feature = "glow")] gl, @@ -336,6 +335,7 @@ impl EpiIntegration { if app_output.close { self.close = app.on_close_event(); } + self.frame.output.visible = app_output.visible; // this is handled by post_present handle_app_output(window, self.egui_ctx.pixels_per_point(), app_output); } @@ -352,6 +352,12 @@ impl EpiIntegration { app.post_rendering(window_size_px, &self.frame); } + pub fn post_present(&mut self, window: &winit::window::Window) { + if let Some(visible) = self.frame.output.visible.take() { + window.set_visible(visible); + } + } + pub fn handle_platform_output( &mut self, window: &winit::window::Window, diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index 5af243477..fb1e51250 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -22,7 +22,17 @@ pub use epi::NativeOptions; #[derive(Debug)] enum EventResult { Wait, - RepaintAsap, + /// Causes a synchronous repaint inside the event handler. This should only + /// be used in special situations if the window must be repainted while + /// handling a specific event. This occurs on Windows when handling resizes. + /// + /// `RepaintNow` creates a new frame synchronously, and should therefore + /// only be used for extremely urgent repaints. + RepaintNow, + /// Queues a repaint for once the event loop handles its next redraw. Exists + /// so that multiple input events can be handled in one frame. Does not + /// cause any delay like `RepaintNow`. + RepaintNext, RepaintAt(Instant), Exit, } @@ -103,7 +113,7 @@ fn run_and_return(event_loop: &mut EventLoop, mut winit_app winit::event::Event::UserEvent(RequestRepaintEvent) | winit::event::Event::NewEvents(winit::event::StartCause::ResumeTimeReached { .. - }) => EventResult::RepaintAsap, + }) => EventResult::RepaintNext, winit::event::Event::WindowEvent { window_id, .. } if winit_app.window().is_none() @@ -119,7 +129,12 @@ fn run_and_return(event_loop: &mut EventLoop, mut winit_app match event_result { EventResult::Wait => {} - EventResult::RepaintAsap => { + EventResult::RepaintNow => { + tracing::trace!("Repaint caused by winit::Event: {:?}", event); + next_repaint_time = Instant::now() + Duration::from_secs(1_000_000_000); + winit_app.paint(); + } + EventResult::RepaintNext => { tracing::trace!("Repaint caused by winit::Event: {:?}", event); next_repaint_time = Instant::now(); } @@ -188,14 +203,18 @@ fn run_and_exit( winit::event::Event::UserEvent(RequestRepaintEvent) | winit::event::Event::NewEvents(winit::event::StartCause::ResumeTimeReached { .. - }) => EventResult::RepaintAsap, + }) => EventResult::RepaintNext, event => winit_app.on_event(event_loop, &event), }; match event_result { EventResult::Wait => {} - EventResult::RepaintAsap => { + EventResult::RepaintNow => { + next_repaint_time = Instant::now() + Duration::from_secs(1_000_000_000); + winit_app.paint(); + } + EventResult::RepaintNext => { next_repaint_time = Instant::now(); } EventResult::RepaintAt(repaint_time) => { @@ -330,8 +349,9 @@ mod glow_integration { }; let window_settings = epi_integration::load_window_settings(storage); - let window_builder = - epi_integration::window_builder(native_options, &window_settings).with_title(title); + let window_builder = epi_integration::window_builder(native_options, &window_settings) + .with_title(title) + .with_visible(false); // Keep hidden until we've painted something. See https://github.com/emilk/egui/pull/2279 let gl_window = unsafe { glutin::ContextBuilder::new() @@ -493,10 +513,12 @@ mod glow_integration { gl_window.swap_buffers().unwrap(); } + integration.post_present(window); + let control_flow = if integration.should_close() { EventResult::Exit } else if repaint_after.is_zero() { - EventResult::RepaintAsap + EventResult::RepaintNext } else if let Some(repaint_after_instant) = std::time::Instant::now().checked_add(repaint_after) { @@ -538,7 +560,7 @@ mod glow_integration { if self.running.is_none() { self.init_run_state(event_loop); } - EventResult::RepaintAsap + EventResult::RepaintNow } winit::event::Event::Suspended => { #[cfg(target_os = "android")] @@ -560,11 +582,28 @@ mod glow_integration { winit::event::Event::WindowEvent { event, .. } => { if let Some(running) = &mut self.running { + // On Windows, if a window is resized by the user, it should repaint synchronously, inside the + // event handler. + // + // If this is not done, the compositor will assume that the window does not want to redraw, + // and continue ahead. + // + // In eframe's case, that causes the window to rapidly flicker, as it struggles to deliver + // new frames to the compositor in time. + // + // The flickering is technically glutin or glow's fault, but we should be responding properly + // to resizes anyway, as doing so avoids dropping frames. + // + // See: https://github.com/emilk/egui/issues/903 + let mut repaint_asap = false; + match &event { winit::event::WindowEvent::Focused(new_focused) => { self.is_focused = *new_focused; } winit::event::WindowEvent::Resized(physical_size) => { + repaint_asap = true; + // Resize with 0 width and height is used by winit to signal a minimize event on Windows. // See: https://github.com/rust-windowing/winit/issues/208 // This solves an issue where the app would panic when minimizing on Windows. @@ -576,6 +615,7 @@ mod glow_integration { new_inner_size, .. } => { + repaint_asap = true; running.gl_window.resize(**new_inner_size); } winit::event::WindowEvent::CloseRequested @@ -592,7 +632,11 @@ mod glow_integration { if running.integration.should_close() { EventResult::Exit } else if event_response.repaint { - EventResult::RepaintAsap + if repaint_asap { + EventResult::RepaintNow + } else { + EventResult::RepaintNext + } } else { EventResult::Wait } @@ -692,6 +736,7 @@ mod wgpu_integration { let window_settings = epi_integration::load_window_settings(storage); epi_integration::window_builder(native_options, &window_settings) .with_title(title) + .with_visible(false) // Keep hidden until we've painted something. See https://github.com/emilk/egui/pull/2279 .build(event_loop) .unwrap() } @@ -850,11 +895,12 @@ mod wgpu_integration { ); integration.post_rendering(app.as_mut(), window); + integration.post_present(window); let control_flow = if integration.should_close() { EventResult::Exit } else if repaint_after.is_zero() { - EventResult::RepaintAsap + EventResult::RepaintNext } else if let Some(repaint_after_instant) = std::time::Instant::now().checked_add(repaint_after) { @@ -913,7 +959,7 @@ mod wgpu_integration { ); self.init_run_state(event_loop, storage, window); } - EventResult::RepaintAsap + EventResult::RepaintNow } winit::event::Event::Suspended => { #[cfg(target_os = "android")] @@ -923,11 +969,28 @@ mod wgpu_integration { winit::event::Event::WindowEvent { event, .. } => { if let Some(running) = &mut self.running { + // On Windows, if a window is resized by the user, it should repaint synchronously, inside the + // event handler. + // + // If this is not done, the compositor will assume that the window does not want to redraw, + // and continue ahead. + // + // In eframe's case, that causes the window to rapidly flicker, as it struggles to deliver + // new frames to the compositor in time. + // + // The flickering is technically glutin or glow's fault, but we should be responding properly + // to resizes anyway, as doing so avoids dropping frames. + // + // See: https://github.com/emilk/egui/issues/903 + let mut repaint_asap = false; + match &event { winit::event::WindowEvent::Focused(new_focused) => { self.is_focused = *new_focused; } winit::event::WindowEvent::Resized(physical_size) => { + repaint_asap = true; + // Resize with 0 width and height is used by winit to signal a minimize event on Windows. // See: https://github.com/rust-windowing/winit/issues/208 // This solves an issue where the app would panic when minimizing on Windows. @@ -942,6 +1005,7 @@ mod wgpu_integration { new_inner_size, .. } => { + repaint_asap = true; running .painter .on_window_resized(new_inner_size.width, new_inner_size.height); @@ -959,7 +1023,11 @@ mod wgpu_integration { if running.integration.should_close() { EventResult::Exit } else if event_response.repaint { - EventResult::RepaintAsap + if repaint_asap { + EventResult::RepaintNow + } else { + EventResult::RepaintNext + } } else { EventResult::Wait } diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 7ce2bab52..072af44e4 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -49,6 +49,7 @@ pub struct Area { constrain: bool, order: Order, default_pos: Option, + pivot: Align2, anchor: Option<(Align2, Vec2)>, new_pos: Option, drag_bounds: Option, @@ -65,6 +66,7 @@ impl Area { order: Order::Middle, default_pos: None, new_pos: None, + pivot: Align2::LEFT_TOP, anchor: None, drag_bounds: None, } @@ -122,16 +124,28 @@ impl Area { self } + /// Positions the window and prevents it from being moved + pub fn fixed_pos(mut self, fixed_pos: impl Into) -> Self { + self.new_pos = Some(fixed_pos.into()); + self.movable = false; + self + } + /// Constrains this area to the screen bounds. pub fn constrain(mut self, constrain: bool) -> Self { self.constrain = constrain; self } - /// Positions the window and prevents it from being moved - pub fn fixed_pos(mut self, fixed_pos: impl Into) -> Self { - self.new_pos = Some(fixed_pos.into()); - self.movable = false; + /// Where the "root" of the area is. + /// + /// For instance, if you set this to [`Align2::RIGHT_TOP`] + /// then [`Self::fixed_pos`] will set the position of the right-top + /// corner of the area. + /// + /// Default: [`Align2::LEFT_TOP`]. + pub fn pivot(mut self, pivot: Align2) -> Self { + self.pivot = pivot; self } @@ -208,6 +222,7 @@ impl Area { enabled, default_pos, new_pos, + pivot, anchor, drag_bounds, constrain, @@ -229,9 +244,18 @@ impl Area { state.interactable = interactable; let mut temporarily_invisible = false; + if pivot != Align2::LEFT_TOP { + if is_new { + temporarily_invisible = true; // figure out the size first + } else { + state.pos.x -= pivot.x().to_factor() * state.size.x; + state.pos.y -= pivot.y().to_factor() * state.size.y; + } + } + if let Some((anchor, offset)) = anchor { if is_new { - temporarily_invisible = true; + temporarily_invisible = true; // figure out the size first } else { let screen = ctx.available_rect(); state.pos = anchor.align_size_within_rect(state.size, screen).min + offset; diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index 49b9f8a1f..9c33002f7 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -1,8 +1,16 @@ -use crate::{style::WidgetVisuals, *}; use epaint::Shape; +use crate::{style::WidgetVisuals, *}; + +/// Indicate wether or not a popup will be shown above or below the box. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AboveOrBelow { + Above, + Below, +} + /// A function that paints the [`ComboBox`] icon -pub type IconPainter = Box; +pub type IconPainter = Box; /// A drop-down selection menu with a descriptive label. /// @@ -89,6 +97,7 @@ impl ComboBox { /// rect: egui::Rect, /// visuals: &egui::style::WidgetVisuals, /// _is_open: bool, + /// _above_or_below: egui::AboveOrBelow, /// ) { /// let rect = egui::Rect::from_center_size( /// rect.center(), @@ -107,7 +116,10 @@ impl ComboBox { /// .show_ui(ui, |_ui| {}); /// # }); /// ``` - pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self { + pub fn icon( + mut self, + icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static, + ) -> Self { self.icon = Some(Box::new(icon_fn)); self } @@ -213,6 +225,23 @@ fn combo_box_dyn<'c, R>( let popup_id = button_id.with("popup"); let is_popup_open = ui.memory().is_popup_open(popup_id); + + let popup_height = ui + .ctx() + .memory() + .areas + .get(popup_id) + .map_or(100.0, |state| state.size.y); + + let above_or_below = + if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height + < ui.ctx().input().screen_rect().bottom() + { + AboveOrBelow::Below + } else { + AboveOrBelow::Above + }; + let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| { // We don't want to change width when user selects something new let full_minimum_width = ui.spacing().slider_width; @@ -243,9 +272,15 @@ fn combo_box_dyn<'c, R>( icon_rect.expand(visuals.expansion), visuals, is_popup_open, + above_or_below, ); } else { - paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals); + paint_default_icon( + ui.painter(), + icon_rect.expand(visuals.expansion), + visuals, + above_or_below, + ); } let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect); @@ -256,12 +291,18 @@ fn combo_box_dyn<'c, R>( if button_response.clicked() { ui.memory().toggle_popup(popup_id); } - let inner = crate::popup::popup_below_widget(ui, popup_id, &button_response, |ui| { - ScrollArea::vertical() - .max_height(ui.spacing().combo_height) - .show(ui, menu_contents) - .inner - }); + let inner = crate::popup::popup_above_or_below_widget( + ui, + popup_id, + &button_response, + above_or_below, + |ui| { + ScrollArea::vertical() + .max_height(ui.spacing().combo_height) + .show(ui, menu_contents) + .inner + }, + ); InnerResponse { inner, @@ -316,13 +357,31 @@ fn button_frame( response } -fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) { +fn paint_default_icon( + painter: &Painter, + rect: Rect, + visuals: &WidgetVisuals, + above_or_below: AboveOrBelow, +) { let rect = Rect::from_center_size( rect.center(), vec2(rect.width() * 0.7, rect.height() * 0.45), ); - painter.add(Shape::closed_line( - vec![rect.left_top(), rect.right_top(), rect.center_bottom()], - visuals.fg_stroke, - )); + + match above_or_below { + AboveOrBelow::Above => { + // Upward pointing triangle + painter.add(Shape::closed_line( + vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()], + visuals.fg_stroke, + )); + } + AboveOrBelow::Below => { + // Downward pointing triangle + painter.add(Shape::closed_line( + vec![rect.left_top(), rect.right_top(), rect.center_bottom()], + visuals.fg_stroke, + )); + } + } } diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 992b6cf95..3ae494791 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -294,7 +294,23 @@ pub fn was_tooltip_open_last_frame(ctx: &Context, tooltip_id: Id) -> bool { false } -/// Shows a popup below another widget. +/// Helper for [`popup_above_or_below_widget`]. +pub fn popup_below_widget( + ui: &Ui, + popup_id: Id, + widget_response: &Response, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + popup_above_or_below_widget( + ui, + popup_id, + widget_response, + AboveOrBelow::Below, + add_contents, + ) +} + +/// Shows a popup above or below another widget. /// /// Useful for drop-down menus (combo boxes) or suggestion menus under text fields. /// @@ -309,24 +325,32 @@ pub fn was_tooltip_open_last_frame(ctx: &Context, tooltip_id: Id) -> bool { /// if response.clicked() { /// ui.memory().toggle_popup(popup_id); /// } -/// egui::popup::popup_below_widget(ui, popup_id, &response, |ui| { +/// let below = egui::AboveOrBelow::Below; +/// egui::popup::popup_above_or_below_widget(ui, popup_id, &response, below, |ui| { /// ui.set_min_width(200.0); // if you want to control the size /// ui.label("Some more info, or things you can select:"); /// ui.label("…"); /// }); /// # }); /// ``` -pub fn popup_below_widget( +pub fn popup_above_or_below_widget( ui: &Ui, popup_id: Id, widget_response: &Response, + above_or_below: AboveOrBelow, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option { if ui.memory().is_popup_open(popup_id) { + let (pos, pivot) = match above_or_below { + AboveOrBelow::Above => (widget_response.rect.left_top(), Align2::LEFT_BOTTOM), + AboveOrBelow::Below => (widget_response.rect.left_bottom(), Align2::LEFT_TOP), + }; + let inner = Area::new(popup_id) .order(Order::Foreground) .constrain(true) - .fixed_pos(widget_response.rect.left_bottom()) + .fixed_pos(pos) + .pivot(pivot) .show(ui.ctx(), |ui| { // Note: we use a separate clip-rect for this area, so the popup can be outside the parent. // See https://github.com/emilk/egui/issues/825 diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index 6182b9f85..9d7032c53 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -122,6 +122,30 @@ impl<'open> Window<'open> { self } + /// Sets the window position and prevents it from being dragged around. + pub fn fixed_pos(mut self, pos: impl Into) -> Self { + self.area = self.area.fixed_pos(pos); + self + } + + /// Constrains this window to the screen bounds. + pub fn constrain(mut self, constrain: bool) -> Self { + self.area = self.area.constrain(constrain); + self + } + + /// Where the "root" of the window is. + /// + /// For instance, if you set this to [`Align2::RIGHT_TOP`] + /// then [`Self::fixed_pos`] will set the position of the right-top + /// corner of the window. + /// + /// Default: [`Align2::LEFT_TOP`]. + pub fn pivot(mut self, pivot: Align2) -> Self { + self.area = self.area.pivot(pivot); + self + } + /// Set anchor and distance. /// /// An anchor of `Align2::RIGHT_TOP` means "put the right-top corner of the window @@ -156,23 +180,17 @@ impl<'open> Window<'open> { self } - /// Set initial position and size of the window. - pub fn default_rect(self, rect: Rect) -> Self { - self.default_pos(rect.min).default_size(rect.size()) - } - - /// Sets the window position and prevents it from being dragged around. - pub fn fixed_pos(mut self, pos: impl Into) -> Self { - self.area = self.area.fixed_pos(pos); - self - } - /// Sets the window size and prevents it from being resized by dragging its edges. pub fn fixed_size(mut self, size: impl Into) -> Self { self.resize = self.resize.fixed_size(size); self } + /// Set initial position and size of the window. + pub fn default_rect(self, rect: Rect) -> Self { + self.default_pos(rect.min).default_size(rect.size()) + } + /// Sets the window pos and size and prevents it from being moved and resized by dragging its edges. pub fn fixed_rect(self, rect: Rect) -> Self { self.fixed_pos(rect.min).fixed_size(rect.size()) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index a4a924470..314557025 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -328,7 +328,7 @@ impl Context { sense: Sense, enabled: bool, ) -> Response { - let gap = 0.5; // Just to make sure we don't accidentally hover two things at once (a small eps should be sufficient). + let gap = 0.1; // Just to make sure we don't accidentally hover two things at once (a small eps should be sufficient). // Make it easier to click things: let interact_rect = rect.expand2( diff --git a/crates/egui/src/sense.rs b/crates/egui/src/sense.rs index 68efde52f..06c3a2b06 100644 --- a/crates/egui/src/sense.rs +++ b/crates/egui/src/sense.rs @@ -16,6 +16,7 @@ pub struct Sense { impl Sense { /// Senses no clicks or drags. Only senses mouse hover. + #[doc(alias = "none")] pub fn hover() -> Self { Self { click: false,