host: popover geometry (card placement, label, constants) is the host's
Pure placement of a trusted local card under a button of a site tab, kept inside the tab, flipped above when short, with the caret on the button; moved from the X app with its seven tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5d19396d89
commit
8a5403947d
2 changed files with 214 additions and 0 deletions
|
|
@ -65,6 +65,7 @@ pub mod layout;
|
|||
pub mod media;
|
||||
pub mod modes;
|
||||
pub mod plugins;
|
||||
pub mod popover;
|
||||
pub mod policy;
|
||||
pub mod profiles;
|
||||
pub mod safe;
|
||||
|
|
|
|||
213
crates/waybetter-host/src/popover.rs
Normal file
213
crates/waybetter-host/src/popover.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! Popover geometry: where a small trusted local webview (a card with a caret) goes when it is
|
||||
//! layered over a site tab right under a button of the site's page, so the site stays visible. The
|
||||
//! page reports only the button rectangle (in its own viewport); native code adds the tab
|
||||
//! webview's position and keeps the card inside that tab. Pure and headless-tested.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Labels of the popover webview are `popover-<n>` (a fresh label whenever it is recreated, so a
|
||||
/// closing webview never collides with the new one).
|
||||
pub const LABEL_PREFIX: &str = "popover-";
|
||||
|
||||
/// Card size in logical px. The card shrinks to the room left in the tab, never below the minimum.
|
||||
pub const CARD_WIDTH: f64 = 408.0;
|
||||
pub const CARD_MAX_HEIGHT: f64 = 472.0;
|
||||
pub const CARD_MIN_WIDTH: f64 = 260.0;
|
||||
pub const CARD_MIN_HEIGHT: f64 = 200.0;
|
||||
/// Prefer flipping above the button when less than this is left below it.
|
||||
pub const FLIP_BELOW: f64 = 300.0;
|
||||
/// Space between the button and the card (the caret lives here).
|
||||
pub const GAP: f64 = 8.0;
|
||||
/// Minimum distance from the tab's edges.
|
||||
pub const EDGE: f64 = 8.0;
|
||||
/// Transparent margin around the card for its shadow (not on the button side).
|
||||
pub const SHADOW: f64 = 22.0;
|
||||
/// The caret never gets closer than this to the card's corners.
|
||||
const CARET_INSET: f64 = 20.0;
|
||||
|
||||
pub fn valid_label(label: &str) -> bool {
|
||||
label
|
||||
.strip_prefix(LABEL_PREFIX)
|
||||
.is_some_and(|n| !n.is_empty() && n.len() <= 12 && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
|
||||
Self { x, y, width, height }
|
||||
}
|
||||
fn finite(&self) -> bool {
|
||||
[self.x, self.y, self.width, self.height].iter().all(|v| v.is_finite())
|
||||
}
|
||||
fn right(&self) -> f64 {
|
||||
self.x + self.width
|
||||
}
|
||||
fn bottom(&self) -> f64 {
|
||||
self.y + self.height
|
||||
}
|
||||
/// Same rectangle within half a px (layout reports round differently on each side).
|
||||
pub fn near(&self, other: &Rect) -> bool {
|
||||
(self.x - other.x).abs() < 0.5
|
||||
&& (self.y - other.y).abs() < 0.5
|
||||
&& (self.width - other.width).abs() < 0.5
|
||||
&& (self.height - other.height).abs() < 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the popover goes. `webview` is in window coordinates (logical px); `card` is relative to
|
||||
/// the webview; `caret_x` is relative to the card.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Placement {
|
||||
pub webview: Rect,
|
||||
pub card: Rect,
|
||||
pub above: bool,
|
||||
pub caret_x: f64,
|
||||
}
|
||||
|
||||
fn clamp(value: f64, min: f64, max: f64) -> f64 {
|
||||
value.max(min).min(max.max(min))
|
||||
}
|
||||
|
||||
/// Places the card under `anchor` (the button, in the tab's viewport), flipping above it when the
|
||||
/// room below is short, always inside `tab` (the tab webview, in window coordinates). `None` when
|
||||
/// the input is not finite or the tab is too small to hold a usable card.
|
||||
pub fn place(anchor: Rect, tab: Rect) -> Option<Placement> {
|
||||
if !anchor.finite() || !tab.finite() || tab.width <= 0.0 || tab.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The page's numbers are only a hint: keep the anchor inside its own viewport.
|
||||
let ax = tab.x + clamp(anchor.x, 0.0, tab.width);
|
||||
let ay = tab.y + clamp(anchor.y, 0.0, tab.height);
|
||||
let aw = clamp(anchor.width, 0.0, (tab.right() - ax).min(400.0));
|
||||
let ah = clamp(anchor.height, 0.0, (tab.bottom() - ay).min(200.0));
|
||||
|
||||
let width = CARD_WIDTH.min(tab.width - 2.0 * EDGE);
|
||||
if width < CARD_MIN_WIDTH {
|
||||
return None;
|
||||
}
|
||||
// Left edge a little left of the button so the caret sits near the card's corner.
|
||||
let left = clamp(ax + aw / 2.0 - 34.0, tab.x + EDGE, tab.right() - EDGE - width);
|
||||
|
||||
let below = tab.bottom() - EDGE - (ay + ah + GAP);
|
||||
let above_room = ay - GAP - (tab.y + EDGE);
|
||||
let above = below < FLIP_BELOW.min(CARD_MAX_HEIGHT) && above_room > below;
|
||||
let room = if above { above_room } else { below };
|
||||
if room < CARD_MIN_HEIGHT.min(tab.height - 2.0 * EDGE).max(120.0) {
|
||||
return None;
|
||||
}
|
||||
let height = CARD_MAX_HEIGHT.min(room);
|
||||
let top = if above { ay - GAP - height } else { ay + ah + GAP };
|
||||
let card = Rect::new(left, top, width, height);
|
||||
|
||||
// Shadow margin on every side except toward the button, clipped to the tab so the popover
|
||||
// never covers the shell's address bar or another pane.
|
||||
let (pad_top, pad_bottom) = if above { (SHADOW, GAP) } else { (GAP, SHADOW) };
|
||||
let wx = (card.x - SHADOW).max(tab.x);
|
||||
let wy = (card.y - pad_top).max(tab.y);
|
||||
let wr = (card.right() + SHADOW).min(tab.right());
|
||||
let wb = (card.bottom() + pad_bottom).min(tab.bottom());
|
||||
let webview = Rect::new(wx, wy, wr - wx, wb - wy);
|
||||
|
||||
let caret_x = clamp(ax + aw / 2.0 - card.x, CARET_INSET, width - CARET_INSET);
|
||||
Some(Placement {
|
||||
webview,
|
||||
card: Rect::new(card.x - wx, card.y - wy, width, height),
|
||||
above,
|
||||
caret_x,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TAB: Rect = Rect::new(100.0, 92.0, 800.0, 700.0);
|
||||
|
||||
#[test]
|
||||
fn labels() {
|
||||
assert!(valid_label("popover-1"));
|
||||
assert!(valid_label("popover-42"));
|
||||
assert!(!valid_label("popover-"));
|
||||
assert!(!valid_label("popover-x"));
|
||||
assert!(!valid_label("tab-1"));
|
||||
assert!(!valid_label("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_below_the_button_in_window_coordinates() {
|
||||
let p = place(Rect::new(200.0, 150.0, 34.0, 34.0), TAB).unwrap();
|
||||
assert!(!p.above);
|
||||
// Card: left = 100 + 200 + 17 - 34 = 283; top = 92 + 150 + 34 + 8 = 284.
|
||||
let card_x = p.webview.x + p.card.x;
|
||||
let card_y = p.webview.y + p.card.y;
|
||||
assert_eq!((card_x, card_y), (283.0, 284.0));
|
||||
assert_eq!(p.card.width, CARD_WIDTH);
|
||||
assert_eq!(p.card.height, CARD_MAX_HEIGHT);
|
||||
// Only a GAP-high strip above the card (no shadow margin over the button).
|
||||
assert_eq!(p.card.y, GAP);
|
||||
assert_eq!(p.card.x, SHADOW);
|
||||
// The caret points at the button's center.
|
||||
assert_eq!(card_x + p.caret_x, 100.0 + 200.0 + 17.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_above_near_the_bottom() {
|
||||
let p = place(Rect::new(200.0, 600.0, 34.0, 34.0), TAB).unwrap();
|
||||
assert!(p.above);
|
||||
let card_bottom = p.webview.y + p.card.y + p.card.height;
|
||||
assert_eq!(card_bottom, 92.0 + 600.0 - GAP);
|
||||
assert_eq!(p.webview.y + p.webview.height, card_bottom + GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_inside_the_tab() {
|
||||
for anchor in [
|
||||
Rect::new(790.0, 10.0, 34.0, 34.0),
|
||||
Rect::new(-50.0, -50.0, 34.0, 34.0),
|
||||
Rect::new(5_000.0, 5_000.0, 34.0, 34.0),
|
||||
Rect::new(400.0, 330.0, 34.0, 34.0),
|
||||
] {
|
||||
let p = place(anchor, TAB).unwrap();
|
||||
let w = p.webview;
|
||||
assert!(w.x >= TAB.x && w.y >= TAB.y && w.right() <= TAB.right() && w.bottom() <= TAB.bottom(), "{anchor:?} -> {w:?}");
|
||||
let (cx, cy) = (w.x + p.card.x, w.y + p.card.y);
|
||||
assert!(cx >= TAB.x + EDGE && cx + p.card.width <= TAB.right() - EDGE, "{anchor:?}");
|
||||
assert!(cy >= TAB.y + EDGE && cy + p.card.height <= TAB.bottom() - EDGE, "{anchor:?}");
|
||||
assert!(p.caret_x >= CARET_INSET && p.caret_x <= p.card.width - CARET_INSET);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_edge_keeps_the_caret_on_the_button() {
|
||||
let p = place(Rect::new(740.0, 100.0, 34.0, 34.0), TAB).unwrap();
|
||||
let card_x = p.webview.x + p.card.x;
|
||||
assert_eq!(card_x + p.card.width, TAB.right() - EDGE);
|
||||
assert_eq!(card_x + p.caret_x, 100.0 + 740.0 + 17.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_panes_shrink_the_card() {
|
||||
let tab = Rect::new(0.0, 44.0, 300.0, 700.0);
|
||||
let p = place(Rect::new(10.0, 100.0, 34.0, 34.0), tab).unwrap();
|
||||
assert_eq!(p.card.width, 300.0 - 2.0 * EDGE);
|
||||
let short = Rect::new(0.0, 44.0, 800.0, 400.0);
|
||||
let p = place(Rect::new(10.0, 60.0, 34.0, 34.0), short).unwrap();
|
||||
assert!(p.card.height < CARD_MAX_HEIGHT && p.card.height >= 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unusable_input() {
|
||||
assert!(place(Rect::new(f64::NAN, 0.0, 1.0, 1.0), TAB).is_none());
|
||||
assert!(place(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, f64::INFINITY, 10.0)).is_none());
|
||||
assert!(place(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 200.0, 700.0)).is_none());
|
||||
assert!(place(Rect::new(0.0, 60.0, 1.0, 1.0), Rect::new(0.0, 0.0, 800.0, 140.0)).is_none());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue