use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::text::{
FontFamily, FontWeight, GenericSlot, LineHeight, TextContext, TextLayout, TextStyle,
};
use frust::authoring::{Action, Role};
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, Key, LayoutCtx,
NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust::{AnimationController, Curve, FrameTime};
use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size, Vec2};
use peniko::{Brush, Color};
use crate::motion::GlyphStagger;
use frust::IconData;
use frust::Timing;
use frust::{BackPolicy, NavigatorController, PopResult, PushOptions};
use frust::{TransitionDriver, TransitionSpec, make_driver};
use crate::press::presses;
const WINDOW_MARGIN: f64 = 8.0;
const PANEL_PAD: f64 = 6.0;
const ITEM_PAD_X: f64 = 11.0;
const ITEM_ROW_H: f64 = 34.0;
const SEP_TOTAL_H: f64 = 11.0;
const SEP_INSET_X: f64 = 4.0;
const ITEM_ICON_SIZE: f64 = 16.0;
const ITEM_ICON_GAP: f64 = 8.0;
const MIN_WIDTH: f64 = 170.0;
const MAX_WIDTH: f64 = 280.0;
const ITEM_FONT_SIZE: f32 = 11.5;
const ITEM_LINE_HEIGHT: f32 = 1.4;
const CONTAINER: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
const BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44);
const LABEL_INK: Color = Color::from_rgb8(0xf2, 0xea, 0xd9);
const DANGER_INK: Color = Color::from_rgb8(0xff, 0x6b, 0x6b);
const SEP_INK: Color = Color::from_rgb8(0x2a, 0x2f, 0x3a);
const HOVER: Color = Color::from_rgb8(0x22, 0x28, 0x35);
const DANGER_HOVER_ALPHA: f32 = 0.13;
const RADIUS: f64 = 10.0;
const ITEM_RADIUS: f64 = 6.0;
const BORDER_WIDTH: f64 = 1.0;
const PATH_TOLERANCE: f64 = 0.1;
const SHADOW_Y: f64 = 16.0;
const SHADOW_BLUR: f64 = 40.0;
const SHADOW_ALPHA: f32 = 0.45;
const SHADOW_BASE: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const ENTER_SCALE_START: f64 = 0.85;
const ENTER_SLIDE_START: f64 = -8.0;
const ITEM_SLIDE_START: f64 = -4.0;
const ENTER_DURATION: Duration = Duration::from_millis(200);
const ENTER_CURVE: Curve = Curve::Cubic(0.34, 1.4, 0.55, 1.0);
const EXIT_DURATION: Duration = Duration::from_millis(120);
const EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(100);
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_timings(theme: Option<&Theme>) -> (Timing, Timing) {
if theme.map(|t| t.motion.reduce_motion).unwrap_or(false) {
let t = Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
return (t, t);
}
match theme {
Some(t) => (
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.fast / 1000.0),
t.motion.easing.spatial,
),
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.instant / 1000.0),
t.motion.easing.exit,
),
),
None => (
Timing::Duration(ENTER_DURATION, ENTER_CURVE),
Timing::Duration(EXIT_DURATION, EXIT_CURVE),
),
}
}
fn resolve_container(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_highest,
None => CONTAINER,
}
}
fn resolve_border(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().outline,
None => BORDER,
}
}
fn resolve_separator(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().outline_variant,
None => SEP_INK,
}
}
fn resolve_label(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().on_surface,
None => LABEL_INK,
}
}
fn resolve_danger(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().error,
None => DANGER_INK,
}
}
fn resolve_hover(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_high,
None => HOVER,
}
}
fn resolve_danger_hover(theme: Option<&Theme>) -> Color {
with_alpha(resolve_danger(theme), DANGER_HOVER_ALPHA)
}
fn resolve_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(t) => t.shape.medium,
None => RADIUS,
}
}
fn resolve_item_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(t) => t.shape.small,
None => ITEM_RADIUS,
}
}
fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
match theme {
Some(t) => {
let s = t.elevation.level5.shadow(t.brightness);
(
s.y_offset,
s.blur_std_dev,
with_alpha(t.scheme().shadow, s.color_alpha),
)
}
None => (SHADOW_Y, SHADOW_BLUR, with_alpha(SHADOW_BASE, SHADOW_ALPHA)),
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
fn clamp_axis(pref: f64, size: f64, area: f64) -> f64 {
let max = (area - size - WINDOW_MARGIN).max(WINDOW_MARGIN);
pref.clamp(WINDOW_MARGIN, max)
}
fn ui_face() -> FontFamily {
FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace)
}
fn item_style(theme: Option<&Theme>, color: Color) -> TextStyle {
TextStyle {
family: theme.map_or_else(ui_face, |t| t.type_scale.label_large.family.clone()),
weight: FontWeight::REGULAR,
line_height: LineHeight::FontSizeRelative(ITEM_LINE_HEIGHT),
..TextStyle::new(ITEM_FONT_SIZE, color)
}
}
fn icon_reserve(item: &MenuItem) -> f64 {
if item.icon.is_some() {
ITEM_ICON_SIZE + ITEM_ICON_GAP
} else {
0.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuItemVariant {
Normal,
Danger,
}
#[derive(Clone, Debug)]
pub struct MenuItem {
pub label: String,
pub variant: MenuItemVariant,
pub icon: Option<IconData>,
}
impl PartialEq for MenuItem {
fn eq(&self, other: &Self) -> bool {
self.label == other.label
&& self.variant == other.variant
&& match (&self.icon, &other.icon) {
(Some(a), Some(b)) => a.same(b),
(None, None) => true,
_ => false,
}
}
}
impl Eq for MenuItem {}
impl MenuItem {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
variant: MenuItemVariant::Normal,
icon: None,
}
}
pub fn danger(label: impl Into<String>) -> Self {
Self {
label: label.into(),
variant: MenuItemVariant::Danger,
icon: None,
}
}
pub fn icon(mut self, icon: impl Into<IconData>) -> Self {
self.icon = Some(icon.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuEntry {
Item(MenuItem),
Separator,
}
impl MenuEntry {
pub fn icon(mut self, icon: impl Into<IconData>) -> Self {
if let MenuEntry::Item(item) = &mut self {
item.icon = Some(icon.into());
}
self
}
}
impl From<MenuItem> for MenuEntry {
fn from(item: MenuItem) -> Self {
MenuEntry::Item(item)
}
}
pub fn menu_item(label: impl Into<String>) -> MenuEntry {
MenuEntry::Item(MenuItem::new(label))
}
pub fn menu_item_danger(label: impl Into<String>) -> MenuEntry {
MenuEntry::Item(MenuItem::danger(label))
}
pub fn menu_separator() -> MenuEntry {
MenuEntry::Separator
}
type OnDismiss = Rc<dyn Fn(Option<usize>)>;
pub struct GlyphMenuView {
anchor: Rect,
entries: Vec<MenuEntry>,
on_dismiss: Option<OnDismiss>,
dismiss_signal: Option<Rc<Cell<u64>>>,
}
pub fn glyph_menu(anchor: Rect, entries: Vec<MenuEntry>) -> GlyphMenuView {
GlyphMenuView {
anchor,
entries,
on_dismiss: None,
dismiss_signal: None,
}
}
#[allow(non_snake_case)]
pub fn GlyphMenu(anchor: Rect, entries: Vec<MenuEntry>) -> GlyphMenuView {
glyph_menu(anchor, entries)
}
impl GlyphMenuView {
pub fn on_dismiss<F: Fn(Option<usize>) + 'static>(mut self, on_dismiss: F) -> Self {
self.on_dismiss = Some(Rc::new(on_dismiss));
self
}
}
pub fn show_glyph_menu<State, B, R>(
controller: &NavigatorController<State>,
anchor: Rect,
entries: B,
on_result: R,
) where
State: 'static,
B: Fn() -> Vec<MenuEntry> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let close_ctrl = controller.clone();
let signal = Rc::new(Cell::new(0u64));
let widget_signal = signal.clone();
let options = PushOptions::transparent()
.transition(TransitionSpec::NONE)
.back(BackPolicy::DismissAnimated)
.dismiss_signal(signal)
.on_result(on_result);
controller.push_with_options(
move || {
let ctrl = close_ctrl.clone();
let mut view = glyph_menu(anchor, entries()).on_dismiss(move |result| match result {
Some(i) => ctrl.pop_with_result(PopResult::of(i)),
None => ctrl.pop(),
});
view.dismiss_signal = Some(widget_signal.clone());
any::<State, _>(view)
},
options,
);
}
struct GlyphLabel {
content: String,
layout: Option<TextLayout>,
laid_out_style: Option<TextStyle>,
}
impl GlyphLabel {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
laid_out_style: None,
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if let Some(cached) = &self.layout
&& self.laid_out_style.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, None);
let size = laid.size();
self.layout = Some(laid);
self.laid_out_style = Some(style.clone());
size
}
fn paint(&self, origin: Point, scene: &mut dyn PaintScene) {
if let Some(layout) = &self.layout {
for run in layout.to_scene_runs(origin) {
scene.draw_glyph_run(run);
}
}
}
}
struct Row {
entry: MenuEntry,
label: Option<GlyphLabel>,
label_size: Size,
}
fn make_rows(entries: &[MenuEntry]) -> Vec<Row> {
entries
.iter()
.map(|e| Row {
entry: e.clone(),
label: match e {
MenuEntry::Item(item) => Some(GlyphLabel::new(item.label.clone())),
MenuEntry::Separator => None,
},
label_size: Size::ZERO,
})
.collect()
}
fn build_stagger(item_count: usize) -> AnimationController {
let total_ms = GlyphStagger::glyph().total_duration(item_count);
let mut c = AnimationController::new(Duration::from_millis(total_ms.round().max(0.0) as u64))
.with_curve(Curve::Linear);
c.forward();
c
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Enter,
Shown,
Exit,
Dismissed,
}
pub struct GlyphMenuWidget {
anchor: Rect,
rows: Vec<Row>,
on_dismiss: Option<OnDismiss>,
dismiss_signal: Option<Rc<Cell<u64>>>,
last_seen_dismiss: u64,
panel: Rect,
row_rects: Vec<Rect>,
phase: Phase,
driver: Option<TransitionDriver>,
stagger: AnimationController,
pending_result: Option<usize>,
pressed_row: Option<usize>,
scrim_captured: bool,
}
impl GlyphMenuWidget {
fn begin_exit(&mut self, result: Option<usize>) {
if matches!(self.phase, Phase::Enter | Phase::Shown) {
self.pending_result = result;
self.phase = Phase::Exit;
self.driver = None;
}
}
fn observe_dismiss_signal(&mut self) {
if let Some(signal) = &self.dismiss_signal {
let current = signal.get();
if current != self.last_seen_dismiss {
self.last_seen_dismiss = current;
self.begin_exit(None);
}
}
}
fn advance(&mut self, now: FrameTime, enter: Timing, exit: Timing) -> (f64, f64, bool) {
match self.phase {
Phase::Enter => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(enter).0)
.advance(now);
if adv.done {
self.phase = Phase::Shown;
self.driver = None;
}
let p = adv.value.clamp(0.0, 1.0);
let scale = ENTER_SCALE_START + (1.0 - ENTER_SCALE_START) * p;
(scale, p, !adv.done)
}
Phase::Shown => (1.0, 1.0, false),
Phase::Exit => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(exit).0)
.advance(now);
let q = adv.value.clamp(0.0, 1.0);
let scale = 1.0 - (1.0 - ENTER_SCALE_START) * q;
if adv.done {
self.phase = Phase::Dismissed;
self.driver = None;
if let Some(on_dismiss) = &self.on_dismiss {
on_dismiss(self.pending_result);
}
}
(scale, 1.0 - q, !adv.done)
}
Phase::Dismissed => (ENTER_SCALE_START, 0.0, false),
}
}
fn item_at(&self, pos: Point) -> Option<usize> {
self.row_rects.iter().enumerate().find_map(|(i, r)| {
if r.contains(pos) && matches!(self.rows[i].entry, MenuEntry::Item(_)) {
Some(i)
} else {
None
}
})
}
}
impl<State: 'static> View<State> for GlyphMenuView {
type Element = GlyphMenuWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> GlyphMenuWidget {
let rows = make_rows(&self.entries);
let item_count = rows
.iter()
.filter(|r| matches!(r.entry, MenuEntry::Item(_)))
.count();
GlyphMenuWidget {
anchor: self.anchor,
rows,
on_dismiss: self.on_dismiss.clone(),
dismiss_signal: self.dismiss_signal.clone(),
last_seen_dismiss: self.dismiss_signal.as_ref().map(|s| s.get()).unwrap_or(0),
panel: Rect::ZERO,
row_rects: Vec::new(),
phase: Phase::Enter,
driver: None,
stagger: build_stagger(item_count),
pending_result: None,
pressed_row: None,
scrim_captured: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut GlyphMenuWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
element.anchor = self.anchor;
if prev.anchor != self.anchor {
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.entries != self.entries {
element.rows = make_rows(&self.entries);
let item_count = element
.rows
.iter()
.filter(|r| matches!(r.entry, MenuEntry::Item(_)))
.count();
element.stagger = build_stagger(item_count);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
element.on_dismiss = self.on_dismiss.clone();
let signal_changed = self.dismiss_signal.as_ref().map(Rc::as_ptr)
!= element.dismiss_signal.as_ref().map(Rc::as_ptr);
if signal_changed {
element.last_seen_dismiss = self.dismiss_signal.as_ref().map(|s| s.get()).unwrap_or(0);
}
element.dismiss_signal = self.dismiss_signal.clone();
flags
}
fn teardown(&self, _element: &mut GlyphMenuWidget, _ctx: &mut BuildCtx<'_>) {}
}
impl Widget for GlyphMenuWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area_w = finite_or_zero(bc.max().width);
let area_h = finite_or_zero(bc.max().height);
let (label_style, danger_style) = {
let theme = Theme::from_layout_ctx(ctx);
(
item_style(theme, resolve_label(theme)),
item_style(theme, resolve_danger(theme)),
)
};
let mut content_w: f64 = 0.0;
for row in &mut self.rows {
if let (Some(label), MenuEntry::Item(item)) = (&mut row.label, &row.entry) {
let style = match item.variant {
MenuItemVariant::Normal => &label_style,
MenuItemVariant::Danger => &danger_style,
};
row.label_size = label.layout(ctx, style);
content_w = content_w.max(icon_reserve(item) + row.label_size.width);
}
}
let panel_max_w = area_w.min(MAX_WIDTH);
let lower = MIN_WIDTH.min(area_w);
let panel_w = (content_w + 2.0 * ITEM_PAD_X).clamp(lower, panel_max_w.max(lower));
let mut rows_h = 0.0;
for row in &self.rows {
rows_h += match row.entry {
MenuEntry::Item(_) => ITEM_ROW_H,
MenuEntry::Separator => SEP_TOTAL_H,
};
}
let panel_h = PANEL_PAD * 2.0 + rows_h;
let pref_x = self.anchor.x1 - panel_w;
let pref_y = self.anchor.y1;
let panel_x = clamp_axis(pref_x, panel_w, area_w);
let panel_y = clamp_axis(pref_y, panel_h, area_h);
self.panel = Rect::new(panel_x, panel_y, panel_x + panel_w, panel_y + panel_h);
self.row_rects.clear();
let mut ry = panel_y + PANEL_PAD;
for row in &self.rows {
let h = match row.entry {
MenuEntry::Item(_) => ITEM_ROW_H,
MenuEntry::Separator => SEP_TOTAL_H,
};
self.row_rects.push(match row.entry {
MenuEntry::Item(_) => Rect::new(
panel_x + PANEL_PAD,
ry,
panel_x + panel_w - PANEL_PAD,
ry + h,
),
MenuEntry::Separator => Rect::new(
panel_x + PANEL_PAD + SEP_INSET_X,
ry + (SEP_TOTAL_H - 1.0) / 2.0,
panel_x + panel_w - PANEL_PAD - SEP_INSET_X,
ry + (SEP_TOTAL_H - 1.0) / 2.0 + 1.0,
),
});
ry += h;
}
bc.constrain(Size::new(area_w, area_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.observe_dismiss_signal();
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let (enter, exit) = resolve_timings(theme);
let (scale, progress, panel_animating) = self.advance(ctx.frame_time(), enter, exit);
let stagger_animating = matches!(self.phase, Phase::Enter | Phase::Shown)
&& self.stagger.advance(ctx.frame_time());
let stagger_overall = self.stagger.value_clamped();
let item_count = self
.rows
.iter()
.filter(|r| matches!(r.entry, MenuEntry::Item(_)))
.count();
let stagger_spec = GlyphStagger::glyph();
let slide = ENTER_SLIDE_START * (1.0 - progress);
let panel_origin = Point::new(
ctx.origin().x + self.panel.x0,
ctx.origin().y + self.panel.y0 + slide,
);
let panel_size = self.panel.size();
let pivot = Point::new(panel_origin.x + panel_size.width, panel_origin.y);
let transform = Affine::translate(pivot.to_vec2())
* Affine::scale(scale)
* Affine::translate(-pivot.to_vec2());
scene.push_transform(transform);
let radius = resolve_radius(theme);
let (shadow_y, shadow_blur, shadow_color) = resolve_shadow(theme);
scene.draw_shadow(
Point::new(panel_origin.x, panel_origin.y + shadow_y),
panel_size,
radius,
shadow_blur,
shadow_color,
);
scene.fill_rounded_rect(panel_origin, panel_size, radius, resolve_container(theme));
let rr = RoundedRect::from_rect(Rect::from_origin_size(Point::ORIGIN, panel_size), radius);
let path = rr.to_path(PATH_TOLERANCE);
scene.stroke_path(
panel_origin,
&path,
BORDER_WIDTH,
&Brush::Solid(resolve_border(theme)),
);
let item_radius = resolve_item_radius(theme);
let (label_c, danger_c) = (resolve_label(theme), resolve_danger(theme));
let mut item_idx = 0usize;
for (i, row) in self.rows.iter().enumerate() {
let Some(r) = self.row_rects.get(i) else {
continue;
};
let r_origin = panel_origin - self.panel.origin().to_vec2()
+ r.origin().to_vec2()
+ Vec2::new(0.0, slide);
match &row.entry {
MenuEntry::Separator => {
scene.fill_rect(r_origin, r.size(), resolve_separator(theme));
}
MenuEntry::Item(item) => {
let alpha = stagger_spec.item_progress(
stagger_overall,
item_idx,
item_count,
reduce_motion,
) as f32;
item_idx += 1;
if alpha <= 0.0 {
continue;
}
let item_dy = ITEM_SLIDE_START * (1.0 - alpha as f64);
let row_origin = r_origin + Vec2::new(0.0, item_dy);
scene.push_layer(row_origin, r.size(), alpha);
if self.pressed_row == Some(i) {
let wash = match item.variant {
MenuItemVariant::Normal => resolve_hover(theme),
MenuItemVariant::Danger => resolve_danger_hover(theme),
};
scene.fill_rounded_rect(row_origin, r.size(), item_radius, wash);
}
if let Some(icon_data) = &item.icon {
let color = match item.variant {
MenuItemVariant::Normal => label_c,
MenuItemVariant::Danger => danger_c,
};
let (path, design) = icon_data.resolve();
let scale = if design > 0.0 {
ITEM_ICON_SIZE / design
} else {
1.0
};
let scaled = Affine::scale(scale) * path;
let icon_origin = Point::new(
row_origin.x + ITEM_PAD_X,
row_origin.y + (r.size().height - ITEM_ICON_SIZE) / 2.0,
);
scene.fill_path(icon_origin, &scaled, &Brush::Solid(color));
}
if let Some(label) = &row.label {
let label_origin = Point::new(
row_origin.x + ITEM_PAD_X + icon_reserve(item),
row_origin.y + (r.size().height - row.label_size.height) / 2.0,
);
label.paint(label_origin, scene);
}
scene.pop_layer();
}
}
}
scene.pop_transform();
if panel_animating || stagger_animating || self.phase == Phase::Dismissed {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Down) {
ctx.request_focus();
}
if let InputEvent::Key(key_event) = event {
if key_event.key == Key::Named(NamedKey::Escape) {
self.begin_exit(None);
return EventResult::Handled;
}
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Handled;
}
if let Some(i) = self.item_at(p.position) {
self.pressed_row = Some(i);
ctx.capture_pointer();
ctx.request_redraw();
return EventResult::Handled;
}
self.scrim_captured = true;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Move => {
if self.pressed_row.is_some() || self.scrim_captured {
EventResult::Handled
} else {
EventResult::Ignored
}
}
PointerPhase::Up => {
if let Some(i) = self.pressed_row.take() {
if self.item_at(p.position) == Some(i) {
self.begin_exit(Some(i));
}
ctx.request_redraw();
return EventResult::Handled;
}
if self.scrim_captured {
let outside = !self.panel.contains(p.position);
if outside {
self.begin_exit(None);
}
self.scrim_captured = false;
return EventResult::Handled;
}
EventResult::Ignored
}
PointerPhase::Cancel => {
self.pressed_row = None;
self.scrim_captured = false;
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let rows = &self.rows;
ctx.push_container(
Role::Menu,
|node| node.set_modal(),
|ctx| {
for row in rows.iter() {
if let MenuEntry::Item(item) = &row.entry {
ctx.push_node(Role::Button, |node| {
node.set_label(item.label.as_str());
node.add_action(Action::Click);
});
}
}
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::NavigatorView;
use frust::authoring::text::TextContext;
use frust::authoring::{
BuildCtx, KeyEvent, Modifiers, PointerButton, PointerEvent, any as core_any,
};
use frust_core::RenderRoot;
use frust_widgets::navigator;
use std::any::Any;
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn escape_event() -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
}
fn build(view: &GlyphMenuView) -> GlyphMenuWidget {
let mut counter = 0u64;
View::<()>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut GlyphMenuWidget, area: Size) {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(area));
}
fn dispatch(w: &mut GlyphMenuWidget, event: &InputEvent, area: Size) -> EventResult {
let mut dummy = ();
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
w.event(&mut ctx, event)
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
shadows: usize,
strokes: Vec<Color>,
path_fills: Vec<(Point, Rect, Color)>,
transforms: Vec<Affine>,
transform_pops: u32,
layers: Vec<(Point, Size, f32)>,
layer_pops: u32,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _b: f64, _c: Color) {
self.shadows += 1;
}
fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.strokes.push(*c);
}
}
fn fill_path(&mut self, origin: Point, path: &kurbo::BezPath, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.path_fills.push((origin, path.bounding_box(), *c));
}
}
fn push_transform(&mut self, t: Affine) {
self.transforms.push(t);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
fn push_layer(&mut self, o: Point, s: Size, alpha: f32) {
self.layers.push((o, s, alpha));
}
fn pop_layer(&mut self) {
self.layer_pops += 1;
}
}
fn three_items() -> Vec<MenuEntry> {
vec![
menu_item("Rename session"),
menu_item("Duplicate layout"),
menu_separator(),
menu_item_danger("Kill session"),
]
}
#[test]
fn anchor_near_right_edge_clamps_panel_inside_window_bounds() {
let area = Size::new(300.0, 600.0);
let anchor = Rect::new(280.0, 10.0, 300.0, 50.0);
let view = glyph_menu(anchor, three_items());
let mut w = build(&view);
layout(&mut w, area);
assert!(
w.panel.x0 >= WINDOW_MARGIN - 1e-9,
"panel left edge stays inside the margin: {}",
w.panel.x0
);
assert!(
w.panel.x1 <= area.width - WINDOW_MARGIN + 1e-9,
"panel right edge stays inside the margin: {}",
w.panel.x1
);
}
#[test]
fn anchor_well_inside_window_positions_below_trailing_with_no_clamp() {
let area = Size::new(800.0, 600.0);
let anchor = Rect::new(700.0, 40.0, 740.0, 80.0);
let view = glyph_menu(anchor, three_items());
let mut w = build(&view);
layout(&mut w, area);
assert!((w.panel.x1 - anchor.x1).abs() < 1e-6);
assert!((w.panel.y0 - anchor.y1).abs() < 1e-6);
}
#[test]
fn enter_staging_scales_panel_085_to_1() {
let view = glyph_menu(Rect::ZERO, three_items());
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
let (scale0, progress0, animating0) = w.advance(ft_ms(0.0), enter, exit);
assert!((scale0 - ENTER_SCALE_START).abs() < 1e-6);
assert!(progress0.abs() < 1e-6);
assert!(animating0);
assert_eq!(w.phase, Phase::Enter);
let (scale1, progress1, animating1) = w.advance(ft_ms(400.0), enter, exit);
assert!((scale1 - 1.0).abs() < 1e-6);
assert!((progress1 - 1.0).abs() < 1e-6);
assert!(!animating1);
assert_eq!(w.phase, Phase::Shown);
}
#[test]
fn exit_is_faster_than_enter() {
let view = glyph_menu(Rect::ZERO, three_items());
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(400.0), enter, exit); w.begin_exit(None);
assert_eq!(w.phase, Phase::Exit);
w.advance(ft_ms(400.0), enter, exit); let (_, _, animating_mid) = w.advance(ft_ms(400.0 + 119.0), enter, exit);
assert!(animating_mid, "still exiting just under 120ms");
let (scale_end, progress_end, animating_end) = w.advance(ft_ms(400.0 + 200.0), enter, exit);
assert!((scale_end - ENTER_SCALE_START).abs() < 1e-6);
assert!(progress_end.abs() < 1e-6);
assert!(!animating_end);
assert_eq!(w.phase, Phase::Dismissed);
}
#[test]
fn stagger_reveals_items_progressively() {
let spec = GlyphStagger::glyph();
let n = 3; let total = spec.total_duration(n);
assert!(spec.item_progress(0.0, 0, n, false) < 1e-9);
let mid = (90.0 / total).clamp(0.0, 1.0); assert!(spec.item_progress(mid, 0, n, false) > spec.item_progress(mid, 2, n, false));
assert!((spec.item_progress(1.0, 2, n, false) - 1.0).abs() < 1e-9);
}
#[test]
fn reduce_motion_collapses_stagger_to_one_synchronized_reveal() {
let spec = GlyphStagger::glyph();
let n = 3;
for i in 0..n {
assert_eq!(spec.item_progress(0.4, i, n, true), 0.4);
}
}
#[test]
fn paint_emits_one_panel_transform_and_a_layer_per_item() {
let view = glyph_menu(Rect::new(700.0, 40.0, 740.0, 80.0), three_items());
let mut w = build(&view);
let area = Size::new(800.0, 600.0);
layout(&mut w, area);
w.phase = Phase::Shown;
w.stagger = build_stagger(3);
w.stagger.advance(ft_ms(0.0));
w.stagger.advance(ft_ms(10_000.0));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.transforms.len(), 1, "one panel scale transform");
assert_eq!(rec.transform_pops, 1);
assert_eq!(rec.shadows, 1);
assert_eq!(rec.rrects[0].3, resolve_container(None), "panel fill");
assert_eq!(rec.layers.len(), 3);
assert_eq!(rec.layer_pops, 3);
for (_, _, alpha) in &rec.layers {
assert!((*alpha - 1.0).abs() < 1e-3, "fully settled stagger");
}
}
#[test]
fn stagger_completes_after_panel_enter_ends() {
let view = glyph_menu(Rect::new(700.0, 40.0, 740.0, 80.0), three_items());
let mut w = build(&view);
let area = Size::new(800.0, 600.0);
layout(&mut w, area);
let mut rec = Recorder::default();
w.paint(
&mut PaintCtx::for_test(Point::ZERO, area, ft_ms(0.0)),
&mut rec,
);
w.paint(
&mut PaintCtx::for_test(Point::ZERO, area, ft_ms(250.0)),
&mut rec,
);
assert_eq!(w.phase, Phase::Shown, "panel enter is over at 250ms");
let mut rec = Recorder::default();
w.paint(
&mut PaintCtx::for_test(Point::ZERO, area, ft_ms(1_000.0)),
&mut rec,
);
assert_eq!(rec.layers.len(), 3, "all three items paint");
for (_, _, alpha) in &rec.layers {
assert!((*alpha - 1.0).abs() < 1e-3, "stagger fully settled");
}
}
fn diamond_icon() -> IconData {
let mut p = kurbo::BezPath::new();
p.move_to((5.0, 0.0));
p.line_to((10.0, 5.0));
p.line_to((5.0, 10.0));
p.line_to((0.0, 5.0));
p.close_path();
IconData::from_path(p, 10.0)
}
#[test]
fn icon_item_paints_a_filled_path_leading_the_label() {
let entries = vec![
menu_item("Rename session").icon(diamond_icon()),
menu_item_danger("Kill session").icon(diamond_icon()),
];
let view = glyph_menu(Rect::new(700.0, 40.0, 740.0, 80.0), entries);
let mut w = build(&view);
let area = Size::new(800.0, 600.0);
layout(&mut w, area);
w.phase = Phase::Shown;
w.stagger = build_stagger(2);
w.stagger.advance(ft_ms(0.0));
w.stagger.advance(ft_ms(10_000.0));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.path_fills.len(), 2, "one fill_path per icon item");
let (_, normal_bbox, normal_color) = rec.path_fills[0];
assert!(
(normal_bbox.width() - ITEM_ICON_SIZE).abs() < 1e-6,
"icon scaled to ITEM_ICON_SIZE"
);
assert!((normal_bbox.height() - ITEM_ICON_SIZE).abs() < 1e-6);
assert_eq!(normal_color, resolve_label(None), "normal item icon tint");
let (_, _, danger_color) = rec.path_fills[1];
assert_eq!(danger_color, resolve_danger(None), "danger item icon tint");
}
#[test]
fn icon_on_separator_is_a_no_op() {
let entry = menu_separator().icon(diamond_icon());
assert_eq!(
entry,
MenuEntry::Separator,
"icon is dropped on a separator"
);
}
#[test]
fn structural_diff_treats_a_same_source_icon_as_unchanged() {
let a = MenuItem::new("Rename").icon(frust::icons::EDIT);
let b = MenuItem::new("Rename").icon(frust::icons::EDIT);
assert_eq!(a, b);
}
#[derive(Default)]
struct NavState {
results: Vec<Option<usize>>,
}
fn app_page(
controller: &NavigatorController<NavState>,
) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> {
let ctrl = controller.clone();
move |_: &mut NavState| navigator(&ctrl, || core_any::<NavState, _>(sized(400.0, 600.0)))
}
struct Sized {
size: Size,
}
struct SizedW {
size: Size,
}
impl View<NavState> for Sized {
type Element = SizedW;
fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
SizedW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for SizedW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
fn sized(w: f64, h: f64) -> Sized {
Sized {
size: Size::new(w, h),
}
}
#[test]
fn item_up_inside_animates_exit_then_pops_with_selected_index() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let anchor = Rect::new(360.0, 10.0, 392.0, 42.0);
show_glyph_menu(
&controller,
anchor,
three_items,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<usize>());
},
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
let target_row = {
let panel_x1 = anchor.x1;
let panel_y0 = anchor.y1;
Point::new(
panel_x1 - 20.0,
panel_y0 + PANEL_PAD + ITEM_ROW_H + ITEM_ROW_H / 2.0,
)
};
root.event(
&mut state,
&ev(PointerPhase::Down, target_row.x, target_row.y),
);
root.event(
&mut state,
&ev(PointerPhase::Up, target_row.x, target_row.y),
);
assert!(
state.results.is_empty(),
"the exit animation has not finished yet"
);
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(
&mut state,
&ev(PointerPhase::Move, target_row.x, target_row.y),
);
assert_eq!(state.results, vec![Some(1)], "index 1 was selected");
}
#[test]
fn scrim_tap_animates_then_pops_with_no_selection() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_menu(
&controller,
Rect::new(360.0, 10.0, 392.0, 42.0),
three_items,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<usize>());
},
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 300.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 300.0));
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 300.0));
assert_eq!(
state.results,
vec![None],
"scrim tap cancels with no selection"
);
}
#[test]
fn back_request_dismiss_animated_animates_then_pops() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_menu(
&controller,
Rect::new(360.0, 10.0, 392.0, 42.0),
three_items,
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<usize>());
},
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
controller.request_back();
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(400.0));
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"back request dismisses with no selection"
);
}
#[test]
fn escape_begins_exit_with_no_selection() {
let view = glyph_menu(Rect::new(360.0, 10.0, 392.0, 42.0), three_items());
let mut w = build(&view);
let area = Size::new(400.0, 600.0);
layout(&mut w, area);
dispatch(&mut w, &ev(PointerPhase::Down, 5.0, 5.0), area);
dispatch(&mut w, &ev(PointerPhase::Up, 5.0, 5.0), area);
dispatch(&mut w, &escape_event(), area);
assert_eq!(w.phase, Phase::Exit);
assert_eq!(w.pending_result, None);
}
#[test]
fn a_down_reclaims_focus_after_an_external_blur() {
use frust::authoring::ChildPod;
let view = glyph_menu(Rect::new(360.0, 10.0, 392.0, 42.0), three_items());
let area = Size::new(400.0, 600.0);
let w = build(&view);
let mut pod = ChildPod::new(Box::new(w));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut dummy = ();
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(pod.is_focused(), "the first Down claims focus");
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Up, 5.0, 5.0));
}
pod.set_focused(false);
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(
pod.is_focused(),
"a second Down must re-claim focus after an external blur — this is what \
keeps the root's focus/IME session alive while the menu is up"
);
}
#[test]
fn render_root_semantics_reports_modal_menu_and_button_items() {
fn logic(_s: &mut ()) -> GlyphMenuView {
glyph_menu(Rect::new(360.0, 10.0, 392.0, 42.0), three_items())
}
let mut root: RenderRoot<(), GlyphMenuView> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
assert!(
update
.nodes
.iter()
.any(|(_, n)| n.role() == Role::Menu && n.is_modal()),
"a modal Role::Menu node is contributed"
);
let button_count = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Button)
.count();
assert_eq!(button_count, 3, "one Button node per MenuEntry::Item");
}
#[cfg(feature = "bundled-fonts")]
fn menu_of_three(_: &mut ()) -> GlyphMenuView {
glyph_menu(Rect::new(360.0, 10.0, 392.0, 42.0), three_items())
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn labels_paint_in_their_role_face_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(menu_of_three, crate::baseline(), Size::new(400.0, 600.0));
assert_eq!(faces, [Face::PlexMono; 3]);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn labels_follow_a_live_theme_family_swap() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(menu_of_three, Size::new(400.0, 600.0));
assert_eq!(before, [Face::PlexMono; 3]);
assert_eq!(after, [Face::SpaceMono; 3]);
}
}