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:
CRTkafa 2026-10-01 14:26:25 +07:00
parent 5d19396d89
commit 8a5403947d
2 changed files with 214 additions and 0 deletions

View file

@ -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;

View 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());
}
}