From e9a0c437e2d1b72433d82980f48a40445a0d19e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:12:29 +0800 Subject: [PATCH] =?UTF-8?q?M4:=20a=20generic=20popover=20=E2=80=94=20a=202?= =?UTF-8?q?40=20pt=20card=20with=20a=2040=20pt=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chrome detached panels share, in one place: a 240 pt card with a 40 pt header — the title 16 in from its left edge, a 24 pt X 8 in from its right — a hairline under the header and the theme's popup shadow, around one slot: let mut p = Popover::new("my_popover", tr("Title")); let closed = p.show(ctx, Open::Anchor(pos), Hold::default(), |ui| { /* the slot */ }); The measurements are the ones the colour picker's header already used, which this widget now draws; the card is as wide as the picker was. The shadow is the theme's (`visuals.popup_shadow`), so the dark theme gets its own. Closing is the widget's: the X, Escape, and a press outside it — except in the rects the caller holds out (`Hold { rects, escape }`: the button that opened it, a popover it opened in turn), so that button's own click can toggle it shut instead of the press closing it and the release opening it again (the bug the fill-rule panel has). Press-and-release tests pin all of it. It opens against an anchor (its right edge on the anchor, top on it, lifted when that would run off the screen's bottom) or at a point; the header drags it. Six headless tests: the header's geometry, the opening rule, the lift, the drag, the X, Escape, and the press-and-release of a press on the opener (open) and elsewhere (closed). --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/popover.rs | 186 ++++++++++++++++++++++++++++ crates/ui-egui/src/popover_tests.rs | 144 +++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 crates/ui-egui/src/popover.rs create mode 100644 crates/ui-egui/src/popover_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..fdb209e30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,7 @@ pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; +pub mod popover; pub mod present; pub mod proto_canvas; pub mod proto_editor; @@ -174,6 +175,8 @@ mod plugin_panel_tests; #[cfg(test)] mod plugins_browse_tests; #[cfg(test)] +mod popover_tests; +#[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_links_tests; diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs new file mode 100644 index 000000000..b6a56d768 --- /dev/null +++ b/crates/ui-egui/src/popover.rs @@ -0,0 +1,186 @@ +//! The chrome every detached panel shares: a 15rem-wide card with a 40-point header — the title 16 +//! in from its left edge, the X 8 in from its right — a hairline under the header and the +//! three-layer shadow of the design spec, around one slot. +//! +//! It opens against an anchor: its right edge on the anchor's x (the left edge of the right-hand +//! panel), its top on the anchor's y (the top of the button that opened it) — lifted, when that +//! would run its bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The +//! header drags it anywhere else, and a caller can put it down outright with [`Popover::at`]. + +use egui::epaint::RectShape; +use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Shadow, Ui, Vec2, pos2, vec2}; + +use crate::i18n::tr; +use crate::icons::Icon; +use crate::theme::{Tokens, semibold}; +use crate::widgets; + +/// 15rem: the width of every popover. +pub const WIDTH: f32 = 240.0; +/// The header's height: the title's line and the X sit in it. +pub const HEADER_H: f32 = 40.0; +/// The title starts this far from the panel's left edge, the X ends this far from its right one. +const TITLE_LEFT: f32 = 16.0; +const CLOSE_RIGHT: f32 = 8.0; +/// The X's button (the icon is inset in it). +const CLOSE: f32 = 24.0; +/// The slot's margin inside the panel. +const SLOT_PAD: f32 = 16.0; +/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others. +const SCREEN_GAP: f32 = 16.0; +const EDGE: f32 = 8.0; +/// The panel's corners. +const RADIUS: u8 = 13; + +/// The spec's three shadows — `0 2px 5px rgba(0,0,0,.15)`, `0 10px 16px rgba(0,0,0,.12)`, the +/// `0 0 .5px rgba(0,0,0,.12)` hairline — painted in reverse: the first of them ends up on top. +const SHADOW_NEAR: Shadow = Shadow { offset: [0, 2], blur: 5, spread: 0, color: Color32::from_black_alpha(38) }; +const SHADOW_FAR: Shadow = Shadow { offset: [0, 10], blur: 16, spread: 0, color: Color32::from_black_alpha(31) }; +const SHADOW_EDGE: Shadow = Shadow { offset: [0, 0], blur: 1, spread: 0, color: Color32::from_black_alpha(31) }; + +/// Where the popover shows. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Place { + /// Against the anchor, until the header drags it (the rule on opening). + Anchor, + /// Here: put down by the caller or by the drag. + At(Pos2), +} + +/// An open popover. +pub struct Popover { + id: Id, + /// The header's line. + pub title: String, + place: Place, + /// While the header is held down: where in the panel the pointer grabbed it. + grab: Option, + /// The panel, its header, its slot and the X, as drawn last frame (0-sized until it has been). + pub rect: Rect, + pub header: Rect, + pub slot: Rect, + pub close: Rect, + /// The panel's size as laid out last frame. + size: Vec2, +} + +impl Popover { + /// A popover titled `title`, opening against an anchor (see [`Self::show`]). + pub fn new(id: &'static str, title: impl Into) -> Self { + Self { + id: Id::new(id), + title: title.into(), + place: Place::Anchor, + grab: None, + rect: Rect::NOTHING, + header: Rect::NOTHING, + slot: Rect::NOTHING, + close: Rect::NOTHING, + size: vec2(WIDTH, 0.0), + } + } + + /// Its top-left at `pos`, instead of against the anchor. + pub fn at(mut self, pos: Pos2) -> Self { + self.place = Place::At(pos); + self + } + + /// Draws the panel and fills its slot with `body`; true when the X was clicked. + /// + /// Opened against `anchor` — its right edge on `anchor.x` (the right panel's left edge), its top + /// on `anchor.y` (the top of the button that opened it) — it is lifted when that would run its + /// bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The header + /// drags it; [`Self::at`] and the drag both overrule the anchor. + pub fn show(&mut self, ctx: &Context, anchor: Pos2, body: impl FnOnce(&mut Ui)) -> bool { + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + // The rule needs the panel's height, which only a layout gives: the first frame lays it + // out invisibly and the next one places it — a jump would show. + let first = self.size.y <= 0.0; + let pos = match self.place { + Place::At(p) => clamp(p, self.size, screen), + Place::Anchor => clamp(open_pos(anchor, self.size, screen), self.size, screen), + }; + let mut closed = false; + egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + if first { + ui.set_invisible(); + ctx.request_repaint(); + } + // The panel is painted into a place kept before the content: the shadows and the fill + // go under it, though its size is only known once the slot is laid out. + let bg = ui.painter().add(egui::Shape::Noop); + let spacing = ui.spacing().item_spacing; + ui.spacing_mut().item_spacing = Vec2::ZERO; + // 1. The header: the title, then the X at the right. + let (header, _) = ui.allocate_exact_size(vec2(WIDTH, HEADER_H), Sense::hover()); + let close = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE)); + let handle = Rect::from_min_max(header.min, pos2(close.min.x - 6.0, header.max.y)); + let drag = ui.interact(handle, self.id.with("drag"), Sense::drag()); + let font = semibold(11.5); + let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone()); + ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, t.text); + closed = widgets::icon_button_at(ui, self.id.with("close"), close, Icon::Close, tr("Close")); + // 2. The header drags the panel (grabbed where the press landed, so the first frame of + // a fast drag counts too); the X keeps its own clicks. + if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) { + self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos); + } + if drag.dragged() + && let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos()) + { + self.place = Place::At(clamp(at - grab, self.size, screen)); + } + if ctx.input(|i| i.pointer.any_released()) { + self.grab = None; + } + if drag.dragged() || drag.hovered() { + ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + // 3. The slot, under a hairline. + let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(SLOT_PAD as i8)).show(ui, |ui| { + ui.spacing_mut().item_spacing = spacing; + ui.set_width(WIDTH - 2.0 * SLOT_PAD); + body(ui); + ui.min_rect() + }); + let slot = frame.inner; + let panel = Rect::from_min_size(header.min, vec2(WIDTH, frame.response.rect.max.y - header.min.y)); + let shade = |s: Shadow| egui::Shape::from(s.as_shape(panel, RADIUS)); + // `inset 0 -1px #E6E6E6` under the header: the theme's border colour, a point thick. + let hairline = Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)); + let shapes = vec![ + shade(SHADOW_EDGE), + shade(SHADOW_FAR), + shade(SHADOW_NEAR), + RectShape::filled(panel, CornerRadius::same(RADIUS), t.panel).into(), + RectShape::filled(hairline, CornerRadius::ZERO, t.border).into(), + ]; + ui.painter().set(bg, egui::Shape::Vec(shapes)); + self.rect = panel; + self.header = header; + self.slot = slot; + self.close = close; + self.size = panel.size(); + }); + closed + } +} + +/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on +/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to +/// keep [`SCREEN_GAP`] under it. +fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 { + let bottom = screen.max.y - SCREEN_GAP; + let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y }; + pos2(anchor.x - size.x, y) +} + +/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't. +fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 { + let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE); + let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0)); + let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0)); + pos2(x, y) +} diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs new file mode 100644 index 000000000..80d067d27 --- /dev/null +++ b/crates/ui-egui/src/popover_tests.rs @@ -0,0 +1,144 @@ +//! The generic popover drawn on its own: 15rem wide, a 40-high header with the title 16 in from +//! the left and the X 8 in from the right, the slot under a hairline, the opening rule against an +//! anchor (right edge on it, top aligned, lifted when that would run off the screen) and the +//! header dragging the panel. + +use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; + +use crate::popover::{HEADER_H, Popover, WIDTH}; + +const SCREEN: egui::Vec2 = vec2(1440.0, 900.0); + +/// A context with the UI's fonts: the header's title is drawn in the semibold family. +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + ctx +} + +fn input(t: &mut f64, events: Vec) -> RawInput { + *t += 1.0 / 60.0; + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() } +} + +fn press(p: Pos2, pressed: bool) -> Vec { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +/// One frame of `p` against `anchor`, its slot holding a row: the shapes it painted and whether +/// the X was clicked. +fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2, events: Vec) -> (Vec, bool) { + let mut closed = false; + let mut out = ctx.run_ui(input(t, events), |ui| { + closed = p.show(ui.ctx(), anchor, |ui| { + ui.label("slot"); + }); + }); + out.textures_delta.clear(); + (crate::live_tests::flat(&out.shapes), closed) +} + +/// Two frames: the first lays the panel out to learn its height, the second places and paints it. +fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2) -> Vec { + draw(ctx, t, p, anchor, vec![]); + draw(ctx, t, p, anchor, vec![]).0 +} + +fn near(a: Pos2, b: Pos2) -> bool { + (a - b).length() < 0.05 +} + +#[test] +fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_header", "Fill rule"); + let shapes = settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); + // 15rem wide, its top-left where the rule puts it. + assert_eq!(p.rect.width(), WIDTH, "15rem wide"); + assert!(near(p.rect.min, pos2(400.0 - WIDTH, 100.0)), "right edge on the anchor, top on it: {:?}", p.rect); + assert_eq!(p.header.height(), HEADER_H, "the header is 40 high"); + // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line. + assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close); + assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line"); + // The title: one line, 16 in from the left, on the header's middle. + let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Fill rule").expect("the title shows"); + assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}"); + assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}"); + // The slot: 16 in from the panel's sides, under the header. + assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot); + assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot); +} + +#[test] +fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_anchor", "Popup"); + settle(&ctx, &mut t, &mut p, pos2(900.0, 200.0)); + assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect); + assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect); +} + +#[test] +fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_lift", "Popup"); + settle(&ctx, &mut t, &mut p, pos2(900.0, 860.0)); + // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's. + assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect); + assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect); +} + +#[test] +fn the_header_drags_it_and_the_slot_does_not() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_drag", "Popup"); + settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); + let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0)); + let before = p.rect.min; + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(from, true)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min); + // The slot does not drag it. + let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0)); + let held = p.rect.min; + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(from, true)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min); +} + +#[test] +fn the_x_closes_it() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_close", "Popup"); + settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); + let at = p.close.center(); + assert!(!draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, true)).1, "a press is not a click yet"); + assert!(draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, false)).1, "the X asks for the close"); +} + +#[test] +fn a_caller_can_put_it_where_it_likes() { + let ctx = context(); + let mut t = 0.0; + // Placed outright, the anchor no longer matters. + let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0)); + settle(&ctx, &mut t, &mut p, pos2(900.0, 100.0)); + assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect); + // Nor after a drag: it stays where it was dropped. + let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0)); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(from, true)); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(to, false)); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![]); + assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect); +}