diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 10be6307a..4b3a4f722 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -583,7 +583,7 @@ impl Area { } } -fn round_area_position(ctx: &Context, pos: Pos2) -> Pos2 { +pub(crate) fn round_area_position(ctx: &Context, pos: Pos2) -> Pos2 { // We round a lot of rendering to pixels, so we round the whole // area positions to pixels too, so avoid widgets appearing to float // around independently of each other when the area is dragged. diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index d828ce0f8..cb66eb1f2 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -32,5 +32,5 @@ pub use { scroll_area::ScrollArea, sides::Sides, tooltip::*, - window::Window, + window::{Window, WindowDrag}, }; diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index e3546813e..cf6c58f88 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -9,6 +9,54 @@ use crate::*; use super::scroll_area::{DragScroll, ScrollBarVisibility, ScrollSource}; use super::{Area, Frame, Resize, ScrollArea, area, resize}; +/// Where the user can drag to move a [`Window`]. +/// +/// See [`Window::drag_area`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum WindowDrag { + /// Window cannot be moved by dragging. + /// + /// [`Window::movable(false)`](Window::movable) forces this regardless of + /// what was passed to [`Window::drag_area`]. + Off, + + /// The user can drag the window from anywhere on its surface. + /// + /// Good for touch screens, but can interfere with selecting / dragging + /// content inside the window when used with a mouse. + Anywhere, + + /// Only the title bar accepts the move-drag gesture. + /// + /// Windows without a title bar (see [`Window::title_bar`]) silently fall + /// back to [`Self::Anywhere`] — otherwise they'd be unmovable. + TitleBar, + + /// [`Self::Anywhere`] when a touch screen is detected (see + /// [`crate::InputState::has_touch_screen`]); [`Self::TitleBar`] otherwise. + /// The recommended default. + #[default] + OnTouch, +} + +impl WindowDrag { + /// Resolve [`Self::OnTouch`] to either [`Self::Anywhere`] or [`Self::TitleBar`] + /// based on whether a touch screen was detected. + fn resolve(self, ctx: &Context) -> Self { + match self { + Self::OnTouch => { + if ctx.input(|i| i.has_touch_screen()) { + Self::Anywhere + } else { + Self::TitleBar + } + } + other => other, + } + } +} + /// Builder for a floating window which can be dragged, closed, collapsed, resized and scrolled (off by default). /// /// You can customize: @@ -43,6 +91,7 @@ pub struct Window<'a> { with_title_bar: bool, fade_out: bool, auto_sized: bool, + drag_area: WindowDrag, } impl<'a> Window<'a> { @@ -67,6 +116,7 @@ impl<'a> Window<'a> { with_title_bar: true, fade_out: true, auto_sized: false, + drag_area: WindowDrag::default(), } } @@ -142,12 +192,29 @@ impl<'a> Window<'a> { } /// If `false` the window will be immovable. + /// + /// If `true`, you can move the window by dragging it. + /// Where you can drag to move the window is determined by [`Self::drag_area`]. #[inline] pub fn movable(mut self, movable: bool) -> Self { self.area = self.area.movable(movable); self } + /// Where the user can grab the window to move it. + /// + /// Defaults to [`WindowDrag::OnTouch`]: drag anywhere on touch screens, + /// title bar only otherwise. See [`WindowDrag`] for details. + /// + /// [`Self::movable(false)`](Self::movable) forces [`WindowDrag::Off`] + /// regardless of this setting. Windows without a title bar (see + /// [`Self::title_bar`]) fall back to [`WindowDrag::Anywhere`]. + #[inline] + pub fn drag_area(mut self, drag_area: WindowDrag) -> Self { + self.drag_area = drag_area; + self + } + /// `order(Order::Foreground)` for a Window that should always be on top #[inline] pub fn order(mut self, order: Order) -> Self { @@ -489,8 +556,64 @@ impl Window<'_> { with_title_bar, fade_out, auto_sized, + drag_area: drag_area_setting, } = self; + // `Window::movable(false)` (and `Area::movable(false)`) and + // `WindowDrag::Off` both mean "this window cannot be moved by + // dragging". Without a title bar, `TitleBar` mode would leave the + // window unmovable, so silently fall back to drag-anywhere instead. + let effective_drag = if !area.is_movable() || drag_area_setting == WindowDrag::Off { + WindowDrag::Off + } else if !with_title_bar { + WindowDrag::Anywhere + } else { + drag_area_setting.resolve(ctx) + }; + + // Make the area itself agree: keep its movable flag in sync with + // the resolved drag mode so resize behavior and `Area::begin`'s + // drag-from-anywhere handling don't disagree with the title-bar + // path. (Builder order shouldn't matter — `.drag_area(Off)` after + // `.movable(true)` and vice versa both end up here.) + let area = if effective_drag == WindowDrag::Off { + area.movable(false) + } else { + area + }; + + // Apply the previous frame's title-bar drag _before_ `Area::begin` + // loads the state. We can't apply it inside the content closure because + // `Area::end` writes the locally-captured `AreaState` back, overwriting + // any in-frame mutation. + // + // We deliberately leave `Area` with its normal `Sense::DRAG`: that way + // the area's widget still absorbs drag hit-tests over the body, so the + // resize-edge widgets aren't picked as the "closest drag" target when + // hovering anywhere in the window. The drag-from-anywhere move that + // `Area::begin` would then apply is undone right after `begin` for + // `WindowDrag::TitleBar`. + let title_drag_mode = effective_drag == WindowDrag::TitleBar; + let pivot_pos_before_begin = if title_drag_mode { + if let Some(resp) = ctx.read_response(area.id.with("__title_click")) + && resp.dragged() + { + let delta = ctx.input(|i| i.pointer.delta()); + if delta != Vec2::ZERO { + ctx.memory_mut(|mem| { + if let Some(state) = mem.areas_mut().get_mut(area.id) + && let Some(pivot_pos) = state.pivot_pos.as_mut() + { + *pivot_pos += delta; + } + }); + } + } + area::AreaState::load(ctx, area.id).and_then(|s| s.pivot_pos) + } else { + None + }; + let style = ctx.global_style(); let window_frame = frame.unwrap_or_else(|| Frame::window(&style)); @@ -525,6 +648,24 @@ impl Window<'_> { let on_top = Some(area_layer_id) == ctx.top_layer_id(); let mut area = area.begin(ctx); + // Title-bar-drag mode: throw away any drag-from-anywhere movement + // `Area::begin` may have applied. The title-bar pre-begin step above + // already accounted for the title drag. We then re-run the same + // constrain+round step `Area::begin` does so the title-bar drag + // can't escape `constrain_rect` or reintroduce sub-pixel jitter. + if let Some(pre_begin_pivot) = pivot_pos_before_begin { + let constrain = area.constrain(); + let constrain_rect = area.constrain_rect(); + let state = area.state_mut(); + state.pivot_pos = Some(pre_begin_pivot); + if constrain { + state.set_left_top_pos( + Context::constrain_window_rect_to_area(state.rect(), constrain_rect).min, + ); + } + state.set_left_top_pos(area::round_area_position(ctx, state.left_top_pos())); + } + area.with_widget_info(|| { WidgetInfo::labeled( WidgetType::Window, @@ -576,6 +717,8 @@ impl Window<'_> { on_top, open.as_deref_mut(), auto_sized, + effective_drag == WindowDrag::TitleBar, + area_id, ); } collapsing @@ -1142,6 +1285,8 @@ fn title_ui( active: bool, open: Option<&mut bool>, auto_sized: bool, + drag_to_move: bool, + area_id: Id, ) -> Response { let shape_idx = ui.painter().add(Shape::Noop); @@ -1247,16 +1392,21 @@ fn title_ui( } } - if collapsible - && child_ui - .interact( - title_click_rect, - child_ui.auto_id_with("window_title_click"), - Sense::click(), - ) - .double_clicked() - { - collapsing.toggle(&child_ui); + if collapsible || drag_to_move { + // Single widget covers double-click-to-toggle (when collapsible) and + // drag-to-move (in title-bar-drag mode). The move itself is applied in + // `Window::show_dyn` _before_ `Area::begin` next frame, since + // `Area::end` overwrites any in-frame mutation of `AreaState`. + let sense = if drag_to_move { + Sense::click_and_drag() + } else { + Sense::click() + }; + let response = child_ui.interact(title_click_rect, area_id.with("__title_click"), sense); + + if collapsible && response.double_clicked() { + collapsing.toggle(&child_ui); + } } { diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index de37138d5..b65dfdffa 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -1171,6 +1171,10 @@ impl Areas { self.areas.get(&id) } + pub(crate) fn get_mut(&mut self, id: Id) -> Option<&mut area::AreaState> { + self.areas.get_mut(&id) + } + /// All layers back-to-front, top is last. pub(crate) fn order(&self) -> &[LayerId] { &self.order diff --git a/crates/egui_demo_lib/src/demo/window_options.rs b/crates/egui_demo_lib/src/demo/window_options.rs index a04351714..d95faa546 100644 --- a/crates/egui_demo_lib/src/demo/window_options.rs +++ b/crates/egui_demo_lib/src/demo/window_options.rs @@ -1,4 +1,4 @@ -use egui::{UiKind, Vec2b}; +use egui::{UiKind, Vec2b, WindowDrag}; #[derive(Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -7,6 +7,7 @@ pub struct WindowOptions { title_bar: bool, closable: bool, collapsible: bool, + movable: bool, resizable: bool, constrain: bool, scroll2: Vec2b, @@ -15,6 +16,8 @@ pub struct WindowOptions { anchored: bool, anchor: egui::Align2, anchor_offset: egui::Vec2, + + drag_area: WindowDrag, } impl Default for WindowOptions { @@ -24,6 +27,7 @@ impl Default for WindowOptions { title_bar: true, closable: true, collapsible: true, + movable: true, resizable: true, constrain: true, scroll2: Vec2b::TRUE, @@ -31,6 +35,7 @@ impl Default for WindowOptions { anchored: false, anchor: egui::Align2::RIGHT_TOP, anchor_offset: egui::Vec2::ZERO, + drag_area: WindowDrag::default(), } } } @@ -46,6 +51,7 @@ impl crate::Demo for WindowOptions { title_bar, closable, collapsible, + movable, resizable, constrain, scroll2, @@ -53,6 +59,7 @@ impl crate::Demo for WindowOptions { anchored, anchor, anchor_offset, + drag_area, } = self.clone(); let enabled = ui.input(|i| i.time) - disabled_time > 2.0; @@ -66,7 +73,9 @@ impl crate::Demo for WindowOptions { .resizable(resizable) .constrain(constrain) .collapsible(collapsible) + .movable(movable) .title_bar(title_bar) + .drag_area(drag_area) .scroll(scroll2) .constrain_to(ui.available_rect_before_wrap()) .enabled(enabled); @@ -87,6 +96,7 @@ impl crate::View for WindowOptions { title_bar, closable, collapsible, + movable, resizable, constrain, scroll2, @@ -94,6 +104,7 @@ impl crate::View for WindowOptions { anchored, anchor, anchor_offset, + drag_area, } = self; ui.horizontal(|ui| { ui.label("title:"); @@ -106,6 +117,8 @@ impl crate::View for WindowOptions { ui.checkbox(title_bar, "title_bar"); ui.checkbox(closable, "closable"); ui.checkbox(collapsible, "collapsible"); + ui.checkbox(movable, "movable") + .on_hover_text("Can the window be moved by dragging?"); ui.checkbox(resizable, "resizable"); ui.checkbox(constrain, "constrain") .on_hover_text("Constrain window to the screen"); @@ -140,6 +153,19 @@ impl crate::View for WindowOptions { }); }); + ui.horizontal(|ui| { + ui.label("Drag to move:") + .on_hover_text("Where the user can grab the window to move it"); + ui.selectable_value(drag_area, WindowDrag::Off, "Off") + .on_hover_text("The window cannot be dragged to move it (same as movable = false)"); + ui.selectable_value(drag_area, WindowDrag::OnTouch, "OnTouch") + .on_hover_text("Anywhere on touch screens, title-bar only otherwise (default)"); + ui.selectable_value(drag_area, WindowDrag::TitleBar, "TitleBar") + .on_hover_text("Only the title bar moves the window"); + ui.selectable_value(drag_area, WindowDrag::Anywhere, "Anywhere") + .on_hover_text("Drag anywhere on the window to move it"); + }); + ui.separator(); let on_top = Some(ui.layer_id()) == ui.ctx().top_layer_id(); ui.label(format!("This window is on top: {on_top}.")); diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png index ecc93a7d4..83750aba9 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab0a8201038b2b066c5aff1fd1a35bc7aed95e74cf50c293eee4a76d66623822 -size 35171 +oid sha256:d27392f625445e82285c8eaeb532e11dcb02030f24c7a769356cb4c23d7c2067 +size 41516 diff --git a/tests/egui_tests/tests/test_window_drag.rs b/tests/egui_tests/tests/test_window_drag.rs new file mode 100644 index 000000000..979f992c2 --- /dev/null +++ b/tests/egui_tests/tests/test_window_drag.rs @@ -0,0 +1,195 @@ +//! Tests for [`Window::drag_area`] and [`Window::movable`]. +//! +//! Each test sets up a window with a particular drag configuration, drags +//! either inside or outside the title bar, and asserts on the area rect's +//! delta. `WindowDrag::OnTouch` is not exercised here since it just resolves +//! to `TitleBar` (no touch screen in headless tests). + +use egui::{Id, Pos2, Sense, Vec2, Window, WindowDrag}; +use egui_kittest::Harness; + +struct State { + drag_area: WindowDrag, + movable: bool, +} + +fn build(state: State) -> Harness<'static, State> { + let mut harness = Harness::builder() + .with_size(Vec2::new(500.0, 400.0)) + .with_max_steps(40) // Area requests a repaint every frame while pressed. + .build_ui_state( + move |ui, state: &mut State| { + Window::new("test_win") + .id(Id::new("test_win")) + .drag_area(state.drag_area) + .movable(state.movable) + .default_pos([100.0, 80.0]) + .default_size([180.0, 140.0]) + .show(ui.ctx(), |ui| { + // A passive widget fills the body; it has no drag sense + // of its own, so the Area / title-bar widget is what + // decides whether a drag moves the window. + ui.allocate_response(ui.available_size(), Sense::hover()); + }); + }, + state, + ); + // Let the window settle (auto-position / size, then idle). + harness.run_steps(4); + harness +} + +fn window_rect(harness: &Harness<'_, State>) -> egui::Rect { + egui::AreaState::load(&harness.ctx, Id::new("test_win")) + .expect("window area should be persisted after the first frame") + .rect() +} + +/// Drag the pointer from `from` to `to` over multiple frames; release at the end. +fn drag(harness: &mut Harness<'_, State>, from: Pos2, to: Pos2) { + harness.drag_at(from); + harness.run_steps(4); + harness.hover_at(to); + harness.run_steps(4); + harness.drop_at(to); + harness.run_steps(4); +} + +fn titlebar_pos(rect: egui::Rect) -> Pos2 { + // Just inside the title bar: + Pos2::new(rect.center().x, rect.top() + 8.0) +} + +fn body_pos(rect: egui::Rect) -> Pos2 { + // Well below the title bar: + Pos2::new(rect.center().x, rect.bottom() - 30.0) +} + +#[test] +fn title_bar_drag_on_titlebar_moves_window() { + let mut harness = build(State { + drag_area: WindowDrag::TitleBar, + movable: true, + }); + + let before = window_rect(&harness); + let from = titlebar_pos(before); + let to = from + Vec2::new(60.0, 40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + 20.0 < moved.x && 20.0 < moved.y, + "TitleBar + drag on titlebar should move the window (delta = {moved:?})" + ); +} + +#[test] +fn title_bar_drag_outside_titlebar_keeps_window_put() { + let mut harness = build(State { + drag_area: WindowDrag::TitleBar, + movable: true, + }); + + let before = window_rect(&harness); + let from = body_pos(before); + let to = from + Vec2::new(60.0, -40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "TitleBar + drag in the body should NOT move the window (delta = {moved:?})" + ); +} + +#[test] +fn anywhere_drag_in_body_moves_window() { + let mut harness = build(State { + drag_area: WindowDrag::Anywhere, + movable: true, + }); + + let before = window_rect(&harness); + let from = body_pos(before); + let to = from + Vec2::new(60.0, -40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + 20.0 < moved.x && moved.y < -20.0, + "Anywhere + drag anywhere should move the window (delta = {moved:?})" + ); +} + +#[test] +fn movable_false_keeps_window_put_even_on_titlebar() { + // Regression: a `movable(false)` window used to still move when the user + // dragged the title bar in `TitleBar` mode. + let mut harness = build(State { + drag_area: WindowDrag::TitleBar, + movable: false, + }); + + let before = window_rect(&harness); + let from = titlebar_pos(before); + let to = from + Vec2::new(60.0, 40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "TitleBar + movable(false) should NOT move the window (delta = {moved:?})" + ); +} + +#[test] +fn off_keeps_window_put_on_body_drag() { + // `WindowDrag::Off` should freeze the window regardless of `movable`. + let mut harness = build(State { + drag_area: WindowDrag::Off, + movable: true, + }); + + let before = window_rect(&harness); + let from = body_pos(before); + let to = from + Vec2::new(60.0, -40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "Off + drag in the body should NOT move the window (delta = {moved:?})" + ); +} + +#[test] +fn off_keeps_window_put_on_titlebar_drag() { + let mut harness = build(State { + drag_area: WindowDrag::Off, + movable: true, + }); + + let before = window_rect(&harness); + let from = titlebar_pos(before); + let to = from + Vec2::new(60.0, 40.0); + + drag(&mut harness, from, to); + + let after = window_rect(&harness); + let moved = after.min - before.min; + assert!( + moved.length() < 1.0, + "Off + drag on titlebar should NOT move the window (delta = {moved:?})" + ); +}