use frust::authoring::{
Action, Toggled,
text::{TextContext, TextLayout, TextOverflow, TextStyle},
};
use frust::authoring::{
Affine, AnyView, BezPath, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color,
CursorIcon, ErasedArgCallback, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
PaintScene, Point, PointerEvent, PointerPhase, Rect, Role, ScrollDelta, SemanticsCtx, Size,
TypedArgCallback, View, Widget, any, build_child, erase_callback_arg, rebuild_children,
route_event_single, teardown_child, visit_children,
};
use frust::input::WHEEL_LINE_PX;
use frust::{IconData, Theme};
use super::items::{MenuAction, MenuNode, MenuSelection, partition_surfaces};
use super::{
MENU_DIVIDER_THICKNESS, MENU_DIVIDER_V_PADDING, MENU_GROUP_LABEL_H_PADDING,
MENU_GROUP_LABEL_V_PADDING, MENU_ICON_GAP, MENU_ICON_SIZE, MENU_ITEM_GAP, MENU_MAX_HEIGHT,
MENU_MAX_WIDTH, MENU_MIN_WIDTH, MENU_ROW_H_PADDING, MENU_ROW_MIN_HEIGHT, MENU_SECTION_GAP,
MENU_SUBMENU_GAP, MENU_SUPPORTING_V_PADDING, MENU_SURFACE_H_PADDING, MENU_SURFACE_V_PADDING,
MenuColorStyle, MenuColors, SELECTED_CHECK_SCALE, container_radius, item_radius,
};
use crate::icons;
use crate::interaction::InteractionState;
use crate::overlay::{OverlayElevation, shadow};
use crate::press::presses;
const SHAPING_INK: Color = Color::BLACK;
struct Run {
content: String,
layout: Option<TextLayout>,
shaped_for: Option<(TextStyle, Option<f64>)>,
natural: Option<(TextStyle, f64)>,
}
impl Run {
fn new(content: impl Into<String>) -> Self {
Run {
content: content.into(),
layout: None,
shaped_for: None,
natural: None,
}
}
fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
if let Some((cached, width)) = &self.natural
&& cached == style
{
return *width;
}
let text_ctx = ctx.text_context::<TextContext>();
let width = text_ctx.layout(&self.content, style, None).size().width;
self.natural = Some((style.clone(), width));
width
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
let key = (style.clone(), Some(max_width));
if let Some(cached) = &self.layout
&& self.shaped_for.as_ref() == Some(&key)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout_bounded(
&self.content,
style,
Some(max_width.max(0.0) as f32),
Some(1),
TextOverflow::Ellipsis,
);
let size = laid.size();
self.layout = Some(laid);
self.shaped_for = Some(key);
size
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
struct Glyph {
path: BezPath,
design: f64,
extent: f64,
}
impl Glyph {
fn new(icon: frust::IconSource, extent: f64) -> Self {
let (path, design) = IconData::from(icon).resolve();
Glyph {
path,
design,
extent,
}
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let scale = if self.design > 0.0 {
self.extent / self.design
} else {
1.0
};
let scaled = Affine::scale(scale) * self.path.clone();
scene.fill_path(origin, &scaled, &Brush::Solid(color));
}
}
enum RowKind {
Entry,
Selectable(String),
Toggleable(bool),
Divider,
Submenu,
}
impl RowKind {
fn is_item(&self) -> bool {
!matches!(self, RowKind::Divider)
}
}
struct Row {
kind: RowKind,
index: usize,
surface: usize,
enabled: bool,
destructive: bool,
selected: bool,
label: Run,
supporting: Option<Run>,
shortcut: Option<Run>,
leading: Option<Glyph>,
trailing: Option<Glyph>,
sub: Option<usize>,
top: f64,
height: f64,
}
struct Surface {
label: Option<Run>,
top: f64,
height: f64,
}
struct Plan {
surfaces: Vec<PlannedSurface>,
rows: Vec<PlannedRow>,
}
struct PlannedSurface {
label: Option<String>,
}
struct PlannedRow {
node: MenuNode,
index: usize,
surface: usize,
submenu: Option<(usize, Vec<MenuNode>)>,
}
fn plan_tree(nodes: &[MenuNode], base: usize) -> Plan {
let mut surfaces = Vec::new();
let mut rows = Vec::new();
let mut index = base;
for group in partition_surfaces(nodes) {
let surface = surfaces.len();
surfaces.push(PlannedSurface {
label: group.section_label().map(str::to_string),
});
for node in group.children() {
let submenu = match node {
MenuNode::Submenu(item) => Some((index + 1, item.children().to_vec())),
_ => None,
};
index += node.span();
match node {
MenuNode::Group(inner) => {
let nested = plan_tree(inner.children(), index - node.span());
for mut row in nested.rows {
row.surface = surface;
rows.push(row);
}
}
_ => rows.push(PlannedRow {
node: node.clone(),
index: index - node.span(),
surface,
submenu,
}),
}
}
}
Plan { surfaces, rows }
}
pub struct MenuPanelView<State: 'static> {
nodes: Vec<MenuNode>,
style: MenuColorStyle,
selected: Option<String>,
open: bool,
base: usize,
on_select: TypedArgCallback<State, MenuSelection>,
}
pub fn menu_panel<State: 'static, F: Fn(&mut State, MenuSelection) + 'static>(
nodes: Vec<MenuNode>,
on_select: F,
) -> MenuPanelView<State> {
MenuPanelView {
nodes,
style: MenuColorStyle::default(),
selected: None,
open: true,
base: 0,
on_select: std::rc::Rc::new(on_select),
}
}
impl<State: 'static> MenuPanelView<State> {
pub fn color_style(mut self, style: MenuColorStyle) -> Self {
self.style = style;
self
}
pub fn selected(mut self, value: Option<String>) -> Self {
self.selected = value;
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn base(mut self, base: usize) -> Self {
self.base = base;
self
}
fn sub_views(&self, plan: &Plan) -> Vec<AnyView<State>> {
plan.rows
.iter()
.filter_map(|row| row.submenu.as_ref())
.map(|(base, children)| {
any(MenuPanelView {
nodes: children.clone(),
style: self.style,
selected: self.selected.clone(),
open: self.open,
base: *base,
on_select: self.on_select.clone(),
})
})
.collect()
}
fn build_rows(&self, plan: &Plan) -> Vec<Row> {
let mut pod = 0usize;
let mut rows = Vec::with_capacity(plan.rows.len());
for planned in &plan.rows {
let sub = planned.submenu.as_ref().map(|_| {
let index = pod;
pod += 1;
index
});
rows.push(self.build_row(planned, sub));
}
rows
}
fn build_row(&self, planned: &PlannedRow, sub: Option<usize>) -> Row {
let icon = |slot: &Option<super::MenuIcon>| slot.map(|i| Glyph::new(i.0, MENU_ICON_SIZE));
let (kind, label, supporting, shortcut, leading, trailing, enabled, destructive, selected) =
match &planned.node {
MenuNode::Entry(e) => (
RowKind::Entry,
Run::new(e.label.clone()),
e.supporting.clone().map(Run::new),
e.shortcut.clone().map(Run::new),
icon(&e.leading),
icon(&e.trailing),
e.enabled,
e.destructive,
false,
),
MenuNode::Selectable(e) => {
let selected =
e.selected || self.selected.as_deref().is_some_and(|v| v == e.value);
let leading = icon(&e.leading).or_else(|| {
selected.then(|| {
Glyph::new(icons::CHECK_ROUNDED, MENU_ICON_SIZE * SELECTED_CHECK_SCALE)
})
});
(
RowKind::Selectable(e.value.clone()),
Run::new(e.label.clone()),
e.supporting.clone().map(Run::new),
e.shortcut.clone().map(Run::new),
leading,
icon(&e.trailing),
e.enabled,
false,
selected,
)
}
MenuNode::Toggleable(e) => {
let leading = icon(&e.leading).or_else(|| {
let source = if e.checked {
icons::CHECK_BOX_ROUNDED
} else {
icons::CHECK_BOX_OUTLINE_BLANK_ROUNDED
};
Some(Glyph::new(source, MENU_ICON_SIZE))
});
(
RowKind::Toggleable(e.checked),
Run::new(e.label.clone()),
e.supporting.clone().map(Run::new),
e.shortcut.clone().map(Run::new),
leading,
icon(&e.trailing),
e.enabled,
false,
e.checked,
)
}
MenuNode::Submenu(e) => (
RowKind::Submenu,
Run::new(e.label.clone()),
None,
None,
icon(&e.leading),
Some(Glyph::new(icons::ARROW_RIGHT_ROUNDED, MENU_ICON_SIZE)),
e.enabled,
false,
false,
),
MenuNode::Divider | MenuNode::Group(_) => (
RowKind::Divider,
Run::new(String::new()),
None,
None,
None,
None,
false,
false,
false,
),
};
Row {
kind,
index: planned.index,
surface: planned.surface,
enabled,
destructive,
selected,
label,
supporting,
shortcut,
leading,
trailing,
sub,
top: 0.0,
height: 0.0,
}
}
fn build_surfaces(&self, plan: &Plan) -> Vec<Surface> {
plan.surfaces
.iter()
.map(|planned| Surface {
label: planned.label.clone().map(Run::new),
top: 0.0,
height: 0.0,
})
.collect()
}
}
pub struct MenuPanelWidget {
surfaces: Vec<Surface>,
rows: Vec<Row>,
pods: Vec<ChildPod>,
style: MenuColorStyle,
open: bool,
on_select: ErasedArgCallback<MenuSelection>,
hovered: Option<usize>,
pressed: Option<usize>,
press_inside: bool,
captured: bool,
open_sub: Option<usize>,
scroll: f64,
viewport: f64,
content_height: f64,
width: f64,
}
impl MenuPanelWidget {
pub fn width(&self) -> f64 {
self.width
}
pub fn open_submenu(&self) -> Option<usize> {
self.open_sub
}
pub fn scroll(&self) -> f64 {
self.scroll
}
#[cfg(test)]
fn band(&self, row: usize) -> (f64, f64) {
let row = &self.rows[row];
(row.top, row.height)
}
fn close_chain(&mut self) {
self.open_sub = None;
}
fn open_submenu_at(&mut self, row: usize) -> bool {
let opens = self
.rows
.get(row)
.is_some_and(|r| r.enabled && r.sub.is_some());
if opens {
self.open_sub = Some(row);
}
opens
}
fn open_sub_pod(&self) -> Option<usize> {
let row = self.open_sub?;
self.rows.get(row)?.sub
}
fn open_sub_rect(&self) -> Option<Rect> {
let pod = self.open_sub_pod()?;
let pod = self.pods.get(pod)?;
Some(Rect::from_origin_size(pod.origin(), pod.size()))
}
fn clamp_scroll(&mut self) {
let max = (self.content_height - self.viewport).max(0.0);
self.scroll = self.scroll.clamp(0.0, max);
}
fn apply_scroll(&mut self) {
for row in self.rows.iter() {
let Some(pod) = row.sub.and_then(|i| self.pods.get_mut(i)) else {
continue;
};
pod.set_origin(Point::new(
self.width + MENU_SUBMENU_GAP,
row.top - self.scroll - MENU_SURFACE_V_PADDING,
));
}
}
fn row_at(&self, pos: Point) -> Option<usize> {
if pos.x < 0.0 || pos.x >= self.width || pos.y < 0.0 || pos.y >= self.viewport {
return None;
}
let y = pos.y + self.scroll;
self.rows
.iter()
.position(|row| y >= row.top && y < row.top + row.height)
}
fn activatable(&self, i: usize) -> bool {
self.rows
.get(i)
.is_some_and(|row| row.enabled && row.kind.is_item())
}
fn activate(&mut self, ctx: &mut EventCtx, i: usize) {
let Some(row) = self.rows.get(i) else {
return;
};
let action = match &row.kind {
RowKind::Entry => MenuAction::Press,
RowKind::Selectable(value) => MenuAction::Select(value.clone()),
RowKind::Toggleable(checked) => MenuAction::Toggle(!checked),
RowKind::Submenu => {
self.open_submenu_at(i);
ctx.request_redraw();
return;
}
RowKind::Divider => return,
};
let selection = MenuSelection {
index: row.index,
label: row.label.content.clone(),
action,
};
(self.on_select)(ctx, selection);
}
fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
match p.phase {
PointerPhase::Move if !self.captured => {
if self.open_sub_rect().is_some_and(|r| r.contains(p.position)) {
return EventResult::Ignored;
}
let row = self.row_at(p.position);
if row.is_some() {
ctx.claim_hover();
}
let hovered = row.filter(|i| self.activatable(*i));
if hovered.is_some() {
ctx.set_cursor(CursorIcon::Pointer);
}
if self.hovered != hovered {
self.hovered = hovered;
match hovered {
Some(i) => {
if !self.open_submenu_at(i) {
self.open_sub = None;
}
}
None => self.open_sub = None,
}
ctx.request_redraw();
}
EventResult::Ignored
}
PointerPhase::Move => {
let inside = self.pressed.is_some() && self.row_at(p.position) == self.pressed;
if self.press_inside != inside {
self.press_inside = inside;
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
let Some(row) = self.row_at(p.position).filter(|i| self.activatable(*i)) else {
return EventResult::Ignored;
};
self.pressed = Some(row);
self.press_inside = true;
self.captured = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
let released = self.row_at(p.position);
let armed = self.pressed;
self.pressed = None;
self.press_inside = false;
self.captured = false;
if let Some(row) = armed.filter(|row| released == Some(*row)) {
self.activate(ctx, row);
}
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.pressed = None;
self.press_inside = false;
self.captured = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn handle_scroll(
&mut self,
ctx: &mut EventCtx,
position: Point,
delta: &ScrollDelta,
) -> EventResult {
if position.y < 0.0 || position.y >= self.viewport {
return EventResult::Ignored;
}
let dy = match delta {
ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
ScrollDelta::Pixels(_, y) => *y,
};
let before = self.scroll;
self.scroll += dy;
self.clamp_scroll();
if (self.scroll - before).abs() <= f64::EPSILON {
return EventResult::Ignored;
}
self.apply_scroll();
ctx.request_redraw();
EventResult::Handled
}
}
impl<State: 'static> View<State> for MenuPanelView<State> {
type Element = MenuPanelWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> MenuPanelWidget {
let plan = plan_tree(&self.nodes, self.base);
let pods = self
.sub_views(&plan)
.iter()
.map(|view| build_child(view, ctx))
.collect();
MenuPanelWidget {
surfaces: self.build_surfaces(&plan),
rows: self.build_rows(&plan),
pods,
style: self.style,
open: self.open,
on_select: erase_callback_arg(&self.on_select),
hovered: None,
pressed: None,
press_inside: false,
captured: false,
open_sub: None,
scroll: 0.0,
viewport: 0.0,
content_height: 0.0,
width: 0.0,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut MenuPanelWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let plan = plan_tree(&self.nodes, self.base);
let mut flags = rebuild_children(
&prev.sub_views(&plan_tree(&prev.nodes, prev.base)),
&self.sub_views(&plan),
&mut element.pods,
ctx,
|view: &AnyView<State>| view,
|_| None,
);
if prev.nodes != self.nodes
|| prev.selected != self.selected
|| prev.style != self.style
|| prev.base != self.base
{
element.surfaces = self.build_surfaces(&plan);
element.rows = self.build_rows(&plan);
element.style = self.style;
element.hovered = None;
element.pressed = None;
element.press_inside = false;
element.captured = false;
element.close_chain();
element.scroll = 0.0;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.open != self.open {
element.open = self.open;
if !self.open {
element.close_chain();
flags |= ChangeFlags::PAINT;
}
}
element.on_select = erase_callback_arg(&self.on_select);
flags
}
fn teardown(&self, element: &mut MenuPanelWidget, ctx: &mut BuildCtx<'_>) {
let plan = plan_tree(&self.nodes, self.base);
for (view, pod) in self.sub_views(&plan).iter().zip(element.pods.iter_mut()) {
teardown_child(view, pod, ctx);
}
}
}
struct Chrome {
colors: MenuColors,
shadow: Option<(f64, f64, Color)>,
container_radius: f64,
item_radius: f64,
}
struct Styles {
label: TextStyle,
supporting: TextStyle,
}
impl Styles {
fn resolve(theme: Option<&Theme>) -> Self {
let (mut label, mut supporting) = match theme {
Some(theme) => (
theme.type_scale.label_large.clone(),
theme.type_scale.label_medium.clone(),
),
None => {
let mut large = TextStyle::new(14.0, SHAPING_INK);
large.letter_spacing = 0.1;
let mut medium = TextStyle::new(12.0, SHAPING_INK);
medium.letter_spacing = 0.5;
(large, medium)
}
};
label.color = SHAPING_INK;
supporting.color = SHAPING_INK;
Styles { label, supporting }
}
}
impl Widget for MenuPanelWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
let mut natural: f64 = 0.0;
for i in 0..self.rows.len() {
natural = natural.max(self.row_natural_width(ctx, i, &styles));
}
for surface in self.surfaces.iter_mut() {
if let Some(label) = surface.label.as_mut() {
let width = label.natural_width(ctx, &styles.label);
natural = natural.max(width + 2.0 * MENU_GROUP_LABEL_H_PADDING);
}
}
let cap = if bc.max().width.is_finite() {
bc.max().width.min(MENU_MAX_WIDTH)
} else {
MENU_MAX_WIDTH
};
self.width = (natural + 2.0 * MENU_SURFACE_H_PADDING)
.max(MENU_MIN_WIDTH.min(cap))
.min(cap);
let mut y = 0.0;
for surface in 0..self.surfaces.len() {
if surface > 0 {
y += MENU_SECTION_GAP;
}
let top = y;
y += MENU_SURFACE_V_PADDING;
if let Some(label) = self.surfaces[surface].label.as_mut() {
let width = (self.width - 2.0 * MENU_GROUP_LABEL_H_PADDING).max(0.0);
let height = label.shape(ctx, &styles.label, width).height;
y += height + 2.0 * MENU_GROUP_LABEL_V_PADDING;
}
for i in 0..self.rows.len() {
if self.rows[i].surface != surface {
continue;
}
let height = self.row_layout(ctx, i, &styles);
self.rows[i].top = y;
self.rows[i].height = height;
y += height;
}
y += MENU_SURFACE_V_PADDING;
self.surfaces[surface].top = top;
self.surfaces[surface].height = y - top;
}
self.content_height = y;
let height_cap = if bc.max().height.is_finite() {
bc.max().height.min(MENU_MAX_HEIGHT)
} else {
MENU_MAX_HEIGHT
};
self.viewport = self.content_height.min(height_cap);
self.clamp_scroll();
let loose = BoxConstraints::loose(Size::new(MENU_MAX_WIDTH, MENU_MAX_HEIGHT));
for pod in self.pods.iter_mut() {
pod.layout_child(ctx, &loose);
}
self.apply_scroll();
bc.constrain(Size::new(self.width, self.viewport))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
if !ctx.is_hovered() {
self.hovered = None;
}
let theme = Theme::from_paint_ctx(ctx);
let chrome = Chrome {
colors: MenuColors::resolve(theme, self.style),
shadow: shadow(theme, OverlayElevation::Level2),
container_radius: container_radius(theme),
item_radius: item_radius(theme),
};
let origin = ctx.origin();
let clipped = self.content_height > self.viewport + f64::EPSILON;
if clipped {
scene.push_clip(origin, Size::new(self.width, self.viewport));
}
for surface in 0..self.surfaces.len() {
self.paint_surface(surface, origin, &chrome, scene);
}
if clipped {
scene.pop_clip();
}
if let Some(pod) = self.open_sub_pod()
&& let Some(pod) = self.pods.get_mut(pod)
{
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
for pod in self.pods.iter_mut() {
pod.event_child(ctx, event);
}
return EventResult::Ignored;
}
if let Some(pod) = self.open_sub_pod()
&& route_event_single(&mut self.pods[pod], ctx, event) == EventResult::Handled
{
return EventResult::Handled;
}
match event {
InputEvent::Scroll { position, delta } => self.handle_scroll(ctx, *position, delta),
InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
_ => EventResult::Ignored,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let rows = &self.rows;
let pods = &self.pods;
let open_sub = self.open_sub_pod();
ctx.push_container(
Role::Menu,
|_node| {},
|ctx| {
for row in rows.iter() {
let role = match row.kind {
RowKind::Divider => continue,
RowKind::Selectable(_) => Role::MenuItemRadio,
RowKind::Toggleable(_) => Role::MenuItemCheckBox,
RowKind::Entry | RowKind::Submenu => Role::MenuItem,
};
ctx.push_node(role, |node| {
node.set_label(row.label.content.as_str());
if row.enabled {
node.add_action(Action::Click);
}
if matches!(row.kind, RowKind::Selectable(_) | RowKind::Toggleable(_)) {
node.set_toggled(if row.selected {
Toggled::True
} else {
Toggled::False
});
}
});
}
if let Some(pod) = open_sub.and_then(|i| pods.get(i)) {
pod.semantics_child(ctx);
}
},
);
}
visit_children!(pods);
}
impl MenuPanelWidget {
fn row_natural_width(&mut self, ctx: &mut LayoutCtx, i: usize, styles: &Styles) -> f64 {
if !self.rows[i].kind.is_item() {
return 0.0;
}
let mut text = {
let row = &mut self.rows[i];
row.label.natural_width(ctx, &styles.label)
};
if let Some(supporting) = self.rows[i].supporting.as_mut() {
text = text.max(supporting.natural_width(ctx, &styles.supporting));
}
let mut width = 2.0 * MENU_ROW_H_PADDING + text;
if self.rows[i].leading.is_some() {
width += MENU_ICON_SIZE + MENU_ICON_GAP;
}
if let Some(shortcut) = self.rows[i].shortcut.as_mut() {
width += MENU_ICON_GAP + shortcut.natural_width(ctx, &styles.supporting);
}
if self.rows[i].trailing.is_some() {
width += MENU_ICON_GAP + MENU_ICON_SIZE;
}
width
}
fn row_text_width(&self, i: usize) -> f64 {
let row = &self.rows[i];
let mut width = self.width - 2.0 * MENU_SURFACE_H_PADDING - 2.0 * MENU_ROW_H_PADDING;
if row.leading.is_some() {
width -= MENU_ICON_SIZE + MENU_ICON_GAP;
}
if row.trailing.is_some() {
width -= MENU_ICON_GAP + MENU_ICON_SIZE;
}
if let Some(shortcut) = &row.shortcut {
width -= MENU_ICON_GAP + shortcut.size().width;
}
width.max(0.0)
}
fn row_layout(&mut self, ctx: &mut LayoutCtx, i: usize, styles: &Styles) -> f64 {
if !self.rows[i].kind.is_item() {
return 2.0 * MENU_DIVIDER_V_PADDING + MENU_DIVIDER_THICKNESS;
}
if let Some(shortcut) = self.rows[i].shortcut.as_mut() {
let natural = shortcut.natural_width(ctx, &styles.supporting);
shortcut.shape(ctx, &styles.supporting, natural);
}
let text_width = self.row_text_width(i);
let mut content = {
let label = &mut self.rows[i].label;
label.shape(ctx, &styles.label, text_width).height
};
let has_supporting = self.rows[i].supporting.is_some();
if let Some(supporting) = self.rows[i].supporting.as_mut() {
content += supporting.shape(ctx, &styles.supporting, text_width).height;
}
let padding = if has_supporting {
2.0 * MENU_SUPPORTING_V_PADDING
} else {
0.0
};
MENU_ITEM_GAP + (content + padding).max(MENU_ROW_MIN_HEIGHT)
}
fn paint_surface(&self, s: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
let top = origin.y + self.surfaces[s].top - self.scroll;
let box_origin = Point::new(origin.x, top);
let box_size = Size::new(self.width, self.surfaces[s].height);
if let Some((blur, y_offset, color)) = chrome.shadow {
scene.draw_shadow(
Point::new(box_origin.x, box_origin.y + y_offset),
box_size,
chrome.container_radius,
blur,
color,
);
}
scene.fill_rounded_rect(
box_origin,
box_size,
chrome.container_radius,
chrome.colors.container,
);
if let Some(label) = &self.surfaces[s].label {
label.paint(
Point::new(
box_origin.x + MENU_GROUP_LABEL_H_PADDING,
box_origin.y + MENU_SURFACE_V_PADDING + MENU_GROUP_LABEL_V_PADDING,
),
chrome.colors.supporting_content,
scene,
);
}
for i in 0..self.rows.len() {
if self.rows[i].surface == s {
self.paint_row(i, origin, chrome, scene);
}
}
}
fn paint_row(&self, i: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
let colors = &chrome.colors;
let row = &self.rows[i];
let top = origin.y + row.top - self.scroll;
if !row.kind.is_item() {
scene.fill_rect(
Point::new(
origin.x + MENU_SURFACE_H_PADDING + MENU_ROW_H_PADDING,
top + MENU_DIVIDER_V_PADDING,
),
Size::new(
(self.width - 2.0 * MENU_SURFACE_H_PADDING - 2.0 * MENU_ROW_H_PADDING).max(0.0),
MENU_DIVIDER_THICKNESS,
),
colors.divider,
);
return;
}
let item_origin = Point::new(origin.x + MENU_SURFACE_H_PADDING, top + MENU_ITEM_GAP / 2.0);
let item_size = Size::new(
(self.width - 2.0 * MENU_SURFACE_H_PADDING).max(0.0),
row.height - MENU_ITEM_GAP,
);
if row.selected {
scene.fill_rounded_rect(
item_origin,
item_size,
chrome.item_radius,
colors.selected_container,
);
}
let mut state = InteractionState::new();
state.hovered = self.hovered == Some(i) || self.open_sub == Some(i);
state.pressed = self.pressed == Some(i) && self.press_inside;
let opacity = if row.enabled {
state.resolve_opacity()
} else {
0.0
};
if opacity > 0.0 {
scene.fill_rounded_rect(
item_origin,
item_size,
chrome.item_radius,
crate::overlay::with_alpha(colors.state_layer, opacity),
);
}
let ink = colors.entry_foreground(row.enabled, row.destructive, row.selected);
let icon_ink = colors.icon_foreground(row.enabled, row.destructive, row.selected);
let mut x = item_origin.x + MENU_ROW_H_PADDING;
let middle = item_origin.y + item_size.height / 2.0;
if let Some(leading) = &row.leading {
leading.paint(
Point::new(x, middle - leading.extent / 2.0),
icon_ink,
scene,
);
x += MENU_ICON_SIZE + MENU_ICON_GAP;
}
let label_size = row.label.size();
let supporting_size = row.supporting.as_ref().map_or(Size::ZERO, Run::size);
let text_top = middle - (label_size.height + supporting_size.height) / 2.0;
row.label.paint(Point::new(x, text_top), ink, scene);
if let Some(supporting) = &row.supporting {
supporting.paint(
Point::new(x, text_top + label_size.height),
colors.supporting_foreground(row.enabled, row.selected),
scene,
);
}
let mut right = item_origin.x + item_size.width - MENU_ROW_H_PADDING;
if let Some(trailing) = &row.trailing {
right -= MENU_ICON_SIZE;
trailing.paint(
Point::new(right, middle - trailing.extent / 2.0),
icon_ink,
scene,
);
right -= MENU_ICON_GAP;
}
if let Some(shortcut) = &row.shortcut {
let size = shortcut.size();
shortcut.paint(
Point::new(right - size.width, middle - size.height / 2.0),
colors.supporting_foreground(row.enabled, row.selected),
scene,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::menu::items::{
menu_entry, menu_group, menu_selectable, menu_submenu, menu_toggleable,
};
use crate::menu::{MENU_CONTAINER_RADIUS, MENU_ITEM_RADIUS, MENU_SECTION_GAP};
use frust::authoring::scene::GlyphRun;
use frust::authoring::{BuildCtx, PointerButton, Rect as KRect, text::TextContext};
use frust::{Brightness, FrameTime};
use std::any::Any;
const AREA: Size = Size::new(600.0, 800.0);
#[derive(Default)]
struct AppState {
selections: Vec<MenuSelection>,
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
paths: Vec<Point>,
runs: usize,
shadows: usize,
clips: usize,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
self.rects.push((origin, size, color));
}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rrects.push((origin, size, radius, color));
}
fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
self.paths.push(origin);
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn draw_glyph_run(&mut self, _run: GlyphRun) {
self.runs += 1;
}
fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _std: f64, _c: Color) {
self.shadows += 1;
}
fn push_clip(&mut self, _origin: Point, _size: Size) {
self.clips += 1;
}
fn pop_clip(&mut self) {}
}
fn every_kind() -> Vec<MenuNode> {
vec![
menu_entry("Cut").shortcut("Ctrl X").into(),
MenuNode::Divider,
menu_group(vec![
menu_selectable("Grid", "grid").selected(true).into(),
menu_selectable("List", "list").into(),
])
.label("View")
.into(),
menu_toggleable("Word wrap", true).into(),
menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
]
}
fn view(nodes: Vec<MenuNode>) -> MenuPanelView<AppState> {
menu_panel(nodes, |state: &mut AppState, selection| {
state.selections.push(selection)
})
}
fn build(view: &MenuPanelView<AppState>) -> MenuPanelWidget {
let mut counter = 0u64;
View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut MenuPanelWidget, area: Size) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(area))
}
fn ready(nodes: Vec<MenuNode>) -> MenuPanelWidget {
let mut w = build(&view(nodes));
layout(&mut w, AREA);
w
}
fn rebuild(
w: &mut MenuPanelWidget,
prev: &MenuPanelView<AppState>,
next: &MenuPanelView<AppState>,
) {
let mut counter = 0u64;
View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
}
fn paint(w: &mut MenuPanelWidget) -> Recorder {
let theme = crate::baseline().with_brightness(Brightness::Light);
let mut rec = Recorder::default();
let size = Size::new(w.width, w.viewport);
let mut ctx =
PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
rec
}
fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn dispatch(w: &mut MenuPanelWidget, state: &mut AppState, event: &InputEvent) -> EventResult {
let any: &mut dyn Any = state;
let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.viewport));
w.event(&mut ctx, event)
}
fn row_center(w: &MenuPanelWidget, row: usize) -> Point {
let (top, height) = w.band(row);
Point::new(w.width / 2.0, top + height / 2.0 - w.scroll)
}
fn click(w: &mut MenuPanelWidget, state: &mut AppState, row: usize) {
let c = row_center(w, row);
dispatch(w, state, &pointer(PointerPhase::Down, c.x, c.y));
dispatch(w, state, &pointer(PointerPhase::Up, c.x, c.y));
}
fn click_submenu_row(w: &mut MenuPanelWidget, state: &mut AppState) {
let rect = w.open_sub_rect().expect("an open submenu is placed");
let p = Point::new(
rect.x0 + rect.width() / 2.0,
rect.y0 + MENU_SURFACE_V_PADDING + MENU_ROW_MIN_HEIGHT / 2.0,
);
dispatch(w, state, &pointer(PointerPhase::Down, p.x, p.y));
dispatch(w, state, &pointer(PointerPhase::Up, p.x, p.y));
}
#[test]
fn all_six_kinds_render_across_the_surfaces_they_partition_into() {
let mut w = ready(every_kind());
assert_eq!(w.rows.len(), 6);
assert_eq!(
w.surfaces.len(),
3,
"flat run, the labelled group, the tail"
);
assert!(w.surfaces[1].label.is_some(), "the group's section label");
let rec = paint(&mut w);
let containers = rec
.rrects
.iter()
.filter(|(_, _, radius, _)| *radius == MENU_CONTAINER_RADIUS)
.count();
assert_eq!(containers, 3);
assert_eq!(rec.shadows, 3, "every surface takes the level-2 rung");
assert_eq!(rec.rects.len(), 1);
assert_eq!(rec.rects[0].1.height, MENU_DIVIDER_THICKNESS);
assert_eq!(rec.paths.len(), 3);
assert!(rec.runs >= 7, "runs: {}", rec.runs);
}
#[test]
fn the_surfaces_stack_with_the_section_gap_between_them() {
let w = ready(every_kind());
for pair in w.surfaces.windows(2) {
assert_eq!(
pair[1].top - (pair[0].top + pair[0].height),
MENU_SECTION_GAP
);
}
assert_eq!(w.surfaces[0].top, 0.0);
}
#[test]
fn a_group_label_reserves_its_own_band_above_the_group_s_rows() {
let labelled = ready(vec![
menu_group(vec![menu_entry("a").into()])
.label("Section")
.into(),
]);
let bare = ready(vec![menu_group(vec![menu_entry("a").into()]).into()]);
assert!(
labelled.surfaces[0].height > bare.surfaces[0].height,
"{} vs {}",
labelled.surfaces[0].height,
bare.surfaces[0].height
);
assert!(labelled.rows[0].top > bare.rows[0].top);
}
#[test]
fn a_row_is_at_least_the_reference_s_entry_height() {
let w = ready(vec![menu_entry("Cut").into()]);
assert_eq!(w.rows[0].height, MENU_ITEM_GAP + MENU_ROW_MIN_HEIGHT);
let tall = ready(vec![
menu_entry("Cut").supporting("Removes the selection").into(),
]);
assert!(tall.rows[0].height >= w.rows[0].height);
}
#[test]
fn the_panel_width_clamps_into_the_reference_s_min_max_band() {
let narrow = ready(vec![menu_entry("a").into()]);
assert_eq!(
narrow.width(),
MENU_MIN_WIDTH,
"short labels take the floor"
);
let wide = ready(vec![
menu_entry("a label far longer than any menu should ever try to show inline")
.shortcut("Ctrl Shift X")
.into(),
]);
assert_eq!(wide.width(), MENU_MAX_WIDTH, "long ones take the ceiling");
}
#[test]
fn a_narrow_area_wins_over_the_reference_s_own_minimum() {
let mut w = build(&view(vec![menu_entry("a").into()]));
let size = layout(&mut w, Size::new(80.0, 800.0));
assert_eq!(size.width, 80.0, "the panel never overflows its area");
}
#[test]
fn a_tall_tree_caps_at_the_max_height_and_clips_while_scrolling() {
let nodes: Vec<MenuNode> = (0..20)
.map(|i| menu_entry(format!("row {i}")).into())
.collect();
let mut w = ready(nodes);
assert_eq!(w.viewport, MENU_MAX_HEIGHT);
assert!(w.content_height > MENU_MAX_HEIGHT);
assert_eq!(paint(&mut w).clips, 1, "a scrolling panel clips");
assert_eq!(
paint(&mut ready(vec![menu_entry("a").into()])).clips,
0,
"…and one that fits never clips its own shadow out"
);
let mut state = AppState::default();
let scrolled = dispatch(
&mut w,
&mut state,
&InputEvent::Scroll {
position: Point::new(10.0, 10.0),
delta: ScrollDelta::Pixels(0.0, 120.0),
},
);
assert_eq!(scrolled, EventResult::Handled);
assert_eq!(w.scroll(), 120.0);
dispatch(
&mut w,
&mut state,
&InputEvent::Scroll {
position: Point::new(10.0, 10.0),
delta: ScrollDelta::Pixels(0.0, 10_000.0),
},
);
assert_eq!(w.scroll(), w.content_height - w.viewport);
}
#[test]
fn an_entry_reports_a_press_at_its_depth_first_index() {
let mut w = ready(every_kind());
let mut state = AppState::default();
click(&mut w, &mut state, 0);
assert_eq!(state.selections.len(), 1);
assert_eq!(state.selections[0].index, 0);
assert_eq!(state.selections[0].label, "Cut");
assert_eq!(state.selections[0].action, MenuAction::Press);
}
#[test]
fn a_selectable_reports_its_value_and_never_flips_its_own_state() {
let nodes = vec![
menu_selectable("Grid", "grid").into(),
menu_selectable("List", "list").into(),
];
let mut w = build(&view(nodes.clone()).selected(Some("grid".into())));
layout(&mut w, AREA);
assert!(w.rows[0].selected, "the panel's value checks the row");
assert!(!w.rows[1].selected);
let mut state = AppState::default();
click(&mut w, &mut state, 1);
assert_eq!(state.selections[0].value(), Some("list"));
assert!(
w.rows[0].selected && !w.rows[1].selected,
"controlled: the panel does not move the check itself"
);
rebuild(
&mut w,
&view(nodes.clone()).selected(Some("grid".into())),
&view(nodes).selected(Some("list".into())),
);
layout(&mut w, AREA);
assert!(!w.rows[0].selected && w.rows[1].selected);
}
#[test]
fn a_toggleable_requests_the_opposite_of_its_current_state() {
let mut w = ready(vec![
menu_toggleable("Wrap", false).into(),
menu_toggleable("Minimap", true).into(),
]);
let mut state = AppState::default();
click(&mut w, &mut state, 0);
click(&mut w, &mut state, 1);
assert_eq!(state.selections[0].checked(), Some(true));
assert_eq!(state.selections[1].checked(), Some(false));
assert!(
!w.rows[0].selected && w.rows[1].selected,
"controlled: the boxes stay as the caller last drew them"
);
}
#[test]
fn a_disabled_row_and_a_divider_report_nothing() {
let mut w = ready(vec![
menu_entry("Paste").enabled(false).into(),
MenuNode::Divider,
]);
let mut state = AppState::default();
let c = row_center(&w, 0);
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
EventResult::Ignored
);
click(&mut w, &mut state, 1);
assert!(state.selections.is_empty());
}
#[test]
fn a_release_outside_the_armed_row_reports_nothing() {
let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
let mut state = AppState::default();
let first = row_center(&w, 0);
let second = row_center(&w, 1);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, first.x, first.y),
);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, second.x, second.y),
);
assert!(state.selections.is_empty(), "fires on up-inside only");
}
#[test]
fn only_a_primary_press_arms_a_row() {
let mut w = ready(vec![menu_entry("a").into()]);
let mut state = AppState::default();
let c = row_center(&w, 0);
let secondary = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: c,
button: PointerButton::Secondary,
});
assert_eq!(
dispatch(&mut w, &mut state, &secondary),
EventResult::Ignored
);
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y)),
EventResult::Ignored
);
assert!(state.selections.is_empty());
}
#[test]
fn a_cancel_clears_the_press_without_reporting() {
let mut w = ready(vec![menu_entry("a").into()]);
let mut state = AppState::default();
let c = row_center(&w, 0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y)),
EventResult::Handled
);
assert!(state.selections.is_empty());
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y)),
EventResult::Ignored,
"the gesture is over"
);
}
#[test]
fn a_press_on_the_panel_s_own_background_is_left_for_the_host() {
let mut w = ready(vec![menu_entry("a").into()]);
let mut state = AppState::default();
assert_eq!(
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 4.0, 1.0)),
EventResult::Ignored,
"the surface padding is not a row"
);
}
#[test]
fn a_tap_on_a_submenu_row_opens_its_panel_and_reports_nothing() {
let mut w = ready(vec![
menu_entry("Cut").into(),
menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
]);
let mut state = AppState::default();
assert_eq!(w.open_submenu(), None);
click(&mut w, &mut state, 1);
assert_eq!(w.open_submenu(), Some(1), "the reference's tap-open rule");
assert!(state.selections.is_empty(), "opening is not selecting");
let rect = w.open_sub_rect().expect("an open submenu is placed");
assert_eq!(rect.x0, w.width() + MENU_SUBMENU_GAP);
assert_eq!(rect.y0, w.band(1).0 - MENU_SURFACE_V_PADDING);
assert!(rect.width() > 0.0 && rect.height() > 0.0);
}
#[test]
fn hovering_a_submenu_row_opens_it_and_hovering_elsewhere_closes_it() {
let mut w = ready(vec![
menu_entry("Cut").into(),
menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
]);
let mut state = AppState::default();
let sub = row_center(&w, 1);
assert_eq!(
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, sub.x, sub.y)
),
EventResult::Ignored,
"watching a move is not consuming it"
);
assert_eq!(w.open_submenu(), Some(1));
let inside = w.open_sub_rect().expect("placed").center();
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, inside.x, inside.y),
);
assert_eq!(
w.open_submenu(),
Some(1),
"the pointer has to be able to get there"
);
let other = row_center(&w, 0);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, other.x, other.y),
);
assert_eq!(w.open_submenu(), None);
}
#[test]
fn a_submenu_row_reports_its_children_at_the_indices_that_follow_it() {
let mut w = ready(vec![
menu_entry("Cut").into(),
menu_submenu(
"Share",
vec![menu_entry("Copy link").into(), menu_entry("Email").into()],
)
.into(),
menu_entry("Paste").into(),
]);
let mut state = AppState::default();
click(&mut w, &mut state, 1);
click_submenu_row(&mut w, &mut state);
assert_eq!(state.selections.len(), 1, "the nested row reported");
assert_eq!(state.selections[0].index, 2);
assert_eq!(state.selections[0].label, "Copy link");
assert_eq!(
w.rows[2].index, 4,
"the tail keeps counting past the subtree"
);
}
#[test]
fn a_disabled_submenu_row_opens_nothing() {
let mut w = ready(vec![
menu_submenu("Share", vec![menu_entry("Copy link").into()])
.enabled(false)
.into(),
]);
let mut state = AppState::default();
let c = row_center(&w, 0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
click(&mut w, &mut state, 0);
assert_eq!(w.open_submenu(), None);
}
#[test]
fn closing_the_panel_closes_the_whole_chain() {
let nodes = vec![
menu_submenu(
"Share",
vec![menu_submenu("More", vec![menu_entry("Deep").into()]).into()],
)
.into(),
];
let mut w = ready(nodes.clone());
let mut state = AppState::default();
click(&mut w, &mut state, 0);
assert_eq!(w.open_submenu(), Some(0));
click_submenu_row(&mut w, &mut state);
assert_eq!(nested(&mut w, 0), Some(0), "the cascade is two deep");
rebuild(
&mut w,
&view(nodes.clone()).open(true),
&view(nodes).open(false),
);
assert_eq!(w.open_submenu(), None);
assert_eq!(
nested(&mut w, 0),
None,
"every depth closed, not just the first"
);
}
fn nested(w: &mut MenuPanelWidget, pod: usize) -> Option<usize> {
let boxed = w.pods[pod]
.widget_mut()
.downcast_mut::<Box<dyn Widget>>()
.expect("an erased pod");
(**boxed)
.downcast_mut::<MenuPanelWidget>()
.expect("the submenu panel")
.open_submenu()
}
#[test]
fn a_pressed_row_takes_a_state_layer_and_a_selected_one_its_own_fill() {
let mut w = ready(vec![menu_selectable("Grid", "grid").selected(true).into()]);
let colors = MenuColors::resolve(
Some(&crate::baseline().with_brightness(Brightness::Light)),
MenuColorStyle::Standard,
);
let washes = |rec: &Recorder| -> Vec<Color> {
rec.rrects
.iter()
.filter(|(_, _, radius, _)| *radius == MENU_ITEM_RADIUS)
.map(|(_, _, _, color)| *color)
.collect()
};
let rec = paint(&mut w);
assert_eq!(
washes(&rec),
vec![colors.selected_container],
"the selected fill, and no wash yet"
);
let mut state = AppState::default();
let c = row_center(&w, 0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
let rec = paint(&mut w);
let painted = washes(&rec);
assert_eq!(painted.len(), 2, "the fill plus the press wash over it");
assert_eq!(painted[0], colors.selected_container);
assert_eq!(
painted[1].components[3],
crate::interaction::PRESSED_OPACITY
);
}
#[test]
fn a_drag_off_the_armed_row_drops_its_wash_and_a_drag_back_re_arms_it() {
let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
let mut state = AppState::default();
let first = row_center(&w, 0);
let second = row_center(&w, 1);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Down, first.x, first.y),
);
assert!(w.press_inside);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, second.x, second.y),
);
assert!(!w.press_inside, "the wash follows the pointer out");
assert_eq!(w.pressed, Some(0), "…but the gesture is still armed");
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, first.x, first.y),
);
assert!(w.press_inside, "and back in");
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Up, first.x, first.y),
);
assert_eq!(state.selections.len(), 1);
}
#[test]
fn a_move_latches_the_hovered_row_for_the_next_paint() {
let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
let mut state = AppState::default();
let second = row_center(&w, 1);
dispatch(
&mut w,
&mut state,
&pointer(PointerPhase::Move, second.x, second.y),
);
assert_eq!(w.hovered, Some(1));
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 2.0, 1.0));
assert_eq!(w.hovered, None);
}
#[test]
fn a_disabled_row_never_washes() {
let mut w = ready(vec![menu_entry("Paste").enabled(false).into()]);
let mut state = AppState::default();
let c = row_center(&w, 0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
let rec = paint(&mut w);
assert!(
!rec.rrects
.iter()
.any(|(_, _, radius, _)| *radius == MENU_ITEM_RADIUS),
"no fill and no wash"
);
}
#[test]
fn the_radii_resolve_off_the_theme_s_shape_scale() {
let theme = crate::baseline();
assert_eq!(container_radius(Some(&theme)), theme.shape.large);
assert_eq!(item_radius(Some(&theme)), theme.shape.medium);
assert_eq!(container_radius(None), MENU_CONTAINER_RADIUS);
assert_eq!(item_radius(None), MENU_ITEM_RADIUS);
assert_eq!(theme.shape.large, MENU_CONTAINER_RADIUS);
assert_eq!(theme.shape.medium, MENU_ITEM_RADIUS);
}
#[test]
fn the_vibrant_style_paints_its_own_container() {
let mut standard = ready(vec![menu_entry("a").into()]);
let mut vibrant =
build(&view(vec![menu_entry("a").into()]).color_style(MenuColorStyle::Vibrant));
layout(&mut vibrant, AREA);
let a = paint(&mut standard).rrects[0].3;
let b = paint(&mut vibrant).rrects[0].3;
assert_ne!(a, b);
let theme = crate::baseline().with_brightness(Brightness::Light);
assert_eq!(a, theme.scheme().surface_container_low);
assert_eq!(b, theme.scheme().tertiary_container);
}
#[test]
fn an_unthemed_pass_paints_the_baseline_light_roles() {
let mut w = ready(vec![menu_entry("a").into()]);
let mut rec = Recorder::default();
let size = Size::new(w.width, w.viewport);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO);
w.paint(&mut ctx, &mut rec);
assert_eq!(
rec.rrects[0].3,
MenuColors::resolve(None, MenuColorStyle::Standard).container
);
}
#[test]
fn the_semantics_tree_is_a_menu_of_role_typed_rows() {
use frust_core::RenderRoot;
let mut root: RenderRoot<AppState, MenuPanelView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
let mut tcx = TextContext::new();
let mut logic = |_s: &mut AppState| {
view(vec![
menu_entry("Cut").into(),
MenuNode::Divider,
menu_selectable("Grid", "grid").selected(true).into(),
menu_toggleable("Wrap", false).into(),
])
};
root.rebuild(&mut logic, &mut state);
root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
let nodes = root.semantics().nodes;
let roles: Vec<_> = nodes.iter().map(|(_, n)| n.role()).collect();
assert!(roles.contains(&Role::Menu));
assert_eq!(
roles.iter().filter(|r| **r == Role::MenuItem).count(),
1,
"the divider contributes nothing"
);
assert!(roles.contains(&Role::MenuItemRadio));
assert!(roles.contains(&Role::MenuItemCheckBox));
let radio = nodes
.iter()
.find(|(_, n)| n.role() == Role::MenuItemRadio)
.expect("a radio row");
assert_eq!(radio.1.label(), Some("Grid"));
assert_eq!(radio.1.toggled(), Some(Toggled::True));
}
#[test]
fn a_rebuild_that_re_supplies_the_same_tree_keeps_its_shaped_runs() {
let nodes = every_kind();
let mut w = ready(nodes.clone());
let before = w.rows[0].label.size();
assert!(before.width > 0.0);
rebuild(&mut w, &view(nodes.clone()), &view(nodes));
assert_eq!(
w.rows[0].label.size(),
before,
"no re-shape, so the cached layout survives"
);
}
#[test]
fn a_rebuild_with_a_new_tree_replaces_the_rows_and_drops_the_chain() {
let nodes = vec![menu_submenu("Share", vec![menu_entry("Copy link").into()]).into()];
let mut w = ready(nodes.clone());
let mut state = AppState::default();
click(&mut w, &mut state, 0);
assert_eq!(w.open_submenu(), Some(0));
rebuild(
&mut w,
&view(nodes),
&view(vec![menu_entry("Only this").into()]),
);
layout(&mut w, AREA);
assert_eq!(w.rows.len(), 1);
assert_eq!(w.open_submenu(), None);
assert!(w.pods.is_empty(), "the submenu pod went with its row");
}
#[test]
fn a_broadcast_reaches_every_submenu_pod_and_is_never_consumed() {
let mut w = ready(vec![
menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
]);
let mut state = AppState::default();
let result = dispatch(&mut w, &mut state, &InputEvent::Housekeeping);
assert_eq!(result, EventResult::Ignored);
}
#[test]
fn a_key_event_is_left_for_the_host_to_dismiss_on() {
use frust::authoring::{Key, KeyEvent, Modifiers, NamedKey};
let mut w = ready(vec![menu_entry("a").into()]);
let mut state = AppState::default();
let escape = InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
});
assert_eq!(dispatch(&mut w, &mut state, &escape), EventResult::Ignored);
}
#[test]
fn a_paint_with_no_hover_link_clears_the_latched_row() {
let mut w = ready(vec![menu_entry("a").into()]);
let mut state = AppState::default();
let c = row_center(&w, 0);
dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
assert_eq!(w.hovered, Some(0));
paint(&mut w);
assert_eq!(w.hovered, None);
}
#[test]
fn every_painted_rect_stays_inside_the_panel_s_own_width() {
let mut w = ready(every_kind());
let rec = paint(&mut w);
let panel = KRect::from_origin_size(Point::ORIGIN, Size::new(w.width, w.viewport));
for (origin, size, _, _) in rec.rrects.iter() {
assert!(origin.x >= panel.x0 - 0.001, "{origin:?}");
assert!(origin.x + size.width <= panel.x1 + 0.001, "{origin:?}");
}
}
}