use std::rc::Rc;
use frus_core::{
BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextStyle,
};
use frus_layout::{Align, Dimension, FlexDirection, Style};
use crate::disabled::disabled_content;
use crate::divider::Divider;
use crate::flex::Flex;
use crate::icons::{IconData, Icons};
use crate::interaction::Status;
use crate::portal::Placement;
use crate::theme::Theme;
use crate::transparent::Shared;
use crate::widget::Widget;
const WIDTH: f32 = 220.0;
const ROW_H: f32 = crate::theme::MIN_TAP_TARGET;
const PAD_X: f32 = 12.0;
const PAD_Y: f32 = 8.0;
const ELEVATION: f32 = 3.0;
const DROPDOWN_ELEVATION: f32 = 8.0;
const LEAD: f32 = 18.0;
const LEAD_GAP: f32 = 12.0;
const SHORTCUT_GAP: f32 = 24.0;
fn panel_background(own: Option<Color>, theme: &Theme, kind: PanelKind) -> Color {
own.or(menu_theme(theme, kind).background)
.unwrap_or(theme.scheme.surface_container)
}
pub(crate) fn menu_theme(theme: &Theme, kind: PanelKind) -> crate::MenuTheme {
match kind {
PanelKind::Bar => {
let desktop = if theme.platform.is_desktop() {
crate::MenuTheme::desktop(theme)
} else {
crate::MenuTheme::default()
};
theme
.widgets
.menu_bar
.menus
.or(desktop)
.or(theme.widgets.menu)
}
_ => theme.widgets.menu,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PanelKind {
Menu,
Dropdown,
Bar,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct PanelStyle {
pub(crate) background: Option<Color>,
pub(crate) shape: Option<ShapeBorder>,
pub(crate) elevation: Option<f32>,
pub(crate) shadow_color: Option<Color>,
pub(crate) padding: Option<Insets>,
pub(crate) border: Option<frus_core::BorderSide>,
}
impl PanelStyle {
pub(crate) fn background(&self, kind: PanelKind, theme: &Theme) -> Color {
match kind {
PanelKind::Menu | PanelKind::Bar => panel_background(self.background, theme, kind),
PanelKind::Dropdown => self
.background
.or(theme.widgets.dropdown.menu_background)
.unwrap_or(theme.scheme.surface_container),
}
}
fn shape(&self, kind: PanelKind, theme: &Theme) -> ShapeBorder {
let (shape, radius) = match kind {
PanelKind::Menu | PanelKind::Bar => {
let m = menu_theme(theme, kind);
(m.shape, m.radius)
}
PanelKind::Dropdown => (
theme.widgets.dropdown.menu_shape,
theme.widgets.dropdown.menu_radius,
),
};
crate::resolve_shape(
self.shape,
shape,
radius.map(BorderRadius::uniform),
ShapeBorder::rounded(theme.radius),
)
}
fn elevation(&self, kind: PanelKind, theme: &Theme) -> f32 {
match kind {
PanelKind::Menu | PanelKind::Bar => self
.elevation
.or(menu_theme(theme, kind).elevation)
.unwrap_or(ELEVATION),
PanelKind::Dropdown => self
.elevation
.or(theme.widgets.dropdown.menu_elevation)
.unwrap_or(DROPDOWN_ELEVATION),
}
}
fn shadow_color(&self, kind: PanelKind, theme: &Theme) -> Color {
let themed = match kind {
PanelKind::Menu | PanelKind::Bar => menu_theme(theme, kind).shadow_color,
PanelKind::Dropdown => theme.widgets.dropdown.menu_shadow_color,
};
self.shadow_color.or(themed).unwrap_or(theme.scheme.shadow)
}
fn padding(&self, kind: PanelKind, theme: Option<&Theme>) -> Insets {
let themed = theme.and_then(|t| match kind {
PanelKind::Menu | PanelKind::Bar => menu_theme(t, kind).padding,
PanelKind::Dropdown => t.widgets.dropdown.menu_padding,
});
self.padding
.or(themed)
.unwrap_or(Insets::new(PAD_Y, 0.0, PAD_Y, 0.0))
}
}
fn label_style(
over: Option<TextStyle>,
theme: Option<&Theme>,
kind: PanelKind,
) -> ResolvedTextStyle {
over.or(theme.and_then(|t| menu_theme(t, kind).text_style))
.unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
.resolved()
}
#[derive(Clone)]
pub(crate) struct RowKeys<Msg> {
pub(crate) left: Option<Msg>,
pub(crate) right: Option<Msg>,
pub(crate) escape: Option<Msg>,
pub(crate) autofocus: bool,
}
impl<Msg> Default for RowKeys<Msg> {
fn default() -> Self {
Self {
left: None,
right: None,
escape: None,
autofocus: false,
}
}
}
#[derive(Clone, Copy)]
enum Lead {
Check(bool),
Icon(IconData),
}
#[derive(Clone)]
struct Measure {
label: Option<String>,
shortcut: Option<String>,
submenu: bool,
}
struct Item<Msg> {
label: Option<String>,
children: Vec<Box<dyn Widget<Msg>>>,
lead: Option<Lead>,
lead_column: bool,
shortcut: Option<String>,
submenu: bool,
expanded: bool,
keys: RowKeys<Msg>,
enabled: bool,
text_style: Option<TextStyle>,
background: Option<Color>,
padding: Option<Insets>,
height: Option<f32>,
kind: PanelKind,
message: Msg,
}
impl<Msg> Item<Msg> {
fn padding(&self, theme: Option<&Theme>) -> Insets {
self.padding
.or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
.unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X))
}
fn lead_room(&self) -> f32 {
if self.lead_column {
LEAD + LEAD_GAP
} else {
0.0
}
}
fn shortcut_room(&self, theme: Option<&Theme>) -> f32 {
let keys = self.shortcut.as_deref().map_or(0.0, |keys| {
SHORTCUT_GAP
+ frus_text::measure_resolved(keys, &label_style(self.text_style, theme, self.kind))
.width
});
keys + if self.submenu {
SHORTCUT_GAP + LEAD
} else {
0.0
}
}
fn sizing(&self, theme: Option<&Theme>) -> Style {
let height = self
.height
.or(theme.and_then(|t| menu_theme(t, self.kind).item_height))
.unwrap_or(ROW_H);
let line =
frus_text::line_box(height, &label_style(self.text_style, theme, self.kind), 0.0);
if self.children.is_empty() {
return Style {
width: Dimension::Auto,
height: Dimension::Length(line),
..Default::default()
};
}
let pad = self.padding(theme);
Style {
width: Dimension::Auto,
height: Dimension::Auto,
min_height: Dimension::Length(line),
flex_direction: FlexDirection::Row,
align: Align::Center,
padding: Insets::new(
pad.top,
pad.right + self.shortcut_room(theme),
pad.bottom,
pad.left + self.lead_room(),
),
..Default::default()
}
}
}
impl<Msg: Clone> Widget<Msg> for Item<Msg> {
fn style(&self) -> Style {
self.sizing(None)
}
fn style_themed(&self, theme: &Theme) -> Style {
self.sizing(Some(theme))
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let base = panel_background(self.background, theme, self.kind);
if self.enabled {
let mut status = status;
if self.expanded {
status.hover_progress = 1.0;
}
let tinted = theme.state_layer(base, theme.on_surface, &status);
if tinted != base {
scene.fill_rect(bounds, tinted.fade(o));
}
}
let ink = if self.enabled {
theme.on_surface
} else {
disabled_content(theme)
};
let style = label_style(self.text_style, Some(theme), self.kind);
let pad = self.padding(Some(theme));
let mark = match self.lead {
Some(Lead::Check(on)) => on.then_some(Icons::CHECK),
Some(Lead::Icon(icon)) => Some(icon),
None => None,
};
if let Some(icon) = mark {
let y = bounds.y + (bounds.height - LEAD) * 0.5;
let path = icon.placed(LEAD, bounds.x + pad.left, y, theme.direction);
scene.fill_path(&path, ink.fade(o));
}
let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
if let Some(label) = &self.label {
scene.text(
Point::new(bounds.x + pad.left + self.lead_room(), ty),
label.clone(),
&style,
ink.fade(o),
);
}
let chevron_room = if self.submenu {
let x = bounds.x + bounds.width - pad.right - LEAD;
let y = bounds.y + (bounds.height - LEAD) * 0.5;
let path = Icons::CHEVRON_RIGHT.placed(LEAD, x, y, theme.direction);
scene.fill_path(&path, ink.fade(o));
SHORTCUT_GAP + LEAD
} else {
0.0
};
if let Some(keys) = &self.shortcut {
let width = frus_text::measure_resolved(keys, &style).width + chevron_room;
let tint = if self.enabled {
theme.muted
} else {
disabled_content(theme)
};
scene.text(
Point::new(bounds.x + bounds.width - pad.right - width, ty),
keys.clone(),
&style,
tint.fade(o),
);
}
}
fn on_click(&self) -> Option<Msg> {
if !self.enabled {
return None;
}
Some(self.message.clone())
}
fn focusable(&self) -> bool {
self.enabled
}
fn autofocus(&self) -> bool {
self.enabled && self.keys.autofocus
}
fn on_key(&self, key: &crate::interaction::Key) -> crate::interaction::KeyResponse<Msg> {
use crate::interaction::{Key, KeyResponse};
if !self.enabled {
return KeyResponse::Ignored;
}
let sent = match key {
Key::Left { .. } => self.keys.left.clone(),
Key::Right { .. } => self.keys.right.clone(),
Key::Escape => self.keys.escape.clone(),
_ => None,
};
match sent {
Some(message) => KeyResponse::Handled(Some(message)),
None => KeyResponse::Ignored,
}
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let (role, toggled) = match self.lead {
Some(Lead::Check(on)) => (frus_core::Role::CheckBox, Some(on)),
_ => (frus_core::Role::Button, None),
};
let mut spoken = self.label.clone().unwrap_or_default();
if let Some(keys) = &self.shortcut {
if !spoken.is_empty() {
spoken.push_str(", ");
}
spoken.push_str(keys);
}
let mut semantics = frus_core::SemanticsProperties::new(role).maybe_toggled(toggled);
if !spoken.is_empty() {
semantics = semantics.label(spoken);
}
Some(if self.enabled {
semantics.clickable()
} else {
semantics.disabled(true)
})
}
}
struct Rule {
kind: PanelKind,
height: Option<f32>,
}
impl Rule {
fn divider(&self, theme: &Theme) -> Divider {
match self.height.or(menu_theme(theme, self.kind).divider_height) {
Some(h) => Divider::new().height(h),
None => Divider::new(),
}
}
}
impl<Msg> Widget<Msg> for Rule {
fn style(&self) -> Style {
Widget::<Msg>::style(&Divider::new())
}
fn style_themed(&self, theme: &Theme) -> Style {
Widget::<Msg>::style_themed(&self.divider(theme), theme)
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
Widget::<Msg>::paint(&self.divider(theme), bounds, status, theme, scene);
}
fn on_click(&self) -> Option<Msg> {
None
}
fn debug_name(&self) -> &'static str {
"MenuRule"
}
}
pub(crate) struct Panel<Msg> {
children: Vec<Box<dyn Widget<Msg>>>,
kind: PanelKind,
look: PanelStyle,
measure: Option<RowMeasure>,
}
struct RowMeasure {
rows: Vec<Measure>,
lead_column: bool,
text_style: Option<TextStyle>,
item_padding: Option<Insets>,
}
impl<Msg> Panel<Msg> {
pub(crate) fn new(
kind: PanelKind,
look: PanelStyle,
children: Vec<Box<dyn Widget<Msg>>>,
) -> Self {
Self {
children,
kind,
look,
measure: None,
}
}
fn shape_of(&self, theme: &Theme) -> ShapeBorder {
self.look.shape(self.kind, theme)
}
fn sizing(&self, theme: Option<&Theme>) -> Style {
let padding = self.look.padding(self.kind, theme);
Style {
width: self.row_width(theme).map_or(Dimension::Auto, |rows| {
Dimension::Length(rows + padding.left + padding.right)
}),
flex_direction: FlexDirection::Column,
padding,
..Default::default()
}
}
fn row_width(&self, theme: Option<&Theme>) -> Option<f32> {
let measure = self.measure.as_ref()?;
let style = label_style(measure.text_style, theme, self.kind);
let pad = measure
.item_padding
.or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
.unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X));
let lead = if measure.lead_column {
LEAD + LEAD_GAP
} else {
0.0
};
let widest = measure.rows.iter().fold(0.0f32, |wide, row| {
let label = row
.label
.as_deref()
.map_or(0.0, |t| frus_text::measure_resolved(t, &style).width);
let keys = row.shortcut.as_deref().map_or(0.0, |t| {
SHORTCUT_GAP + frus_text::measure_resolved(t, &style).width
});
let chevron = if row.submenu {
SHORTCUT_GAP + LEAD
} else {
0.0
};
wide.max(pad.left + lead + label + keys + chevron + pad.right)
});
Some(widest.max(WIDTH).ceil())
}
}
impl<Msg: Clone> Widget<Msg> for Panel<Msg> {
fn style(&self) -> Style {
self.sizing(None)
}
fn style_themed(&self, theme: &Theme) -> Style {
self.sizing(Some(theme))
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let shape = self.shape_of(theme);
let radius = shape
.as_rounded(bounds)
.map(|(_, r)| r)
.unwrap_or(BorderRadius::ZERO);
let depth = self.look.elevation(self.kind, theme);
let shadow = self.look.shadow_color(self.kind, theme);
if depth > 0.0 && shadow.a > 0.0 {
frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
}
let border = match self.kind {
PanelKind::Menu | PanelKind::Bar => {
self.look.border.or(menu_theme(theme, self.kind).border)
}
PanelKind::Dropdown => None,
};
let shape = match border {
Some(side) => {
shape.with_side(frus_core::BorderSide::new(side.color.fade(o), side.width))
}
None => shape,
};
scene.draw_shape(
bounds,
shape,
self.look.background(self.kind, theme).fade(o),
);
}
fn on_click(&self) -> Option<Msg> {
None
}
fn opaque(&self) -> bool {
true
}
}
pub struct MenuItem<Msg = crate::callback::Callback> {
label: Option<String>,
child: Option<Rc<dyn Widget<Msg>>>,
lead: Option<Lead>,
shortcut: Option<String>,
enabled: bool,
message: Option<Msg>,
pub(crate) submenu: bool,
pub(crate) expanded: bool,
pub(crate) keys: RowKeys<Msg>,
}
impl<Msg> MenuItem<Msg> {
fn action(message: Msg) -> Self {
Self {
label: None,
child: None,
lead: None,
shortcut: None,
enabled: true,
message: Some(message),
submenu: false,
expanded: false,
keys: RowKeys::default(),
}
}
pub fn new(label: impl Into<String>, message: Msg) -> Self {
Self {
label: Some(label.into()),
..Self::action(message)
}
}
pub fn widget(child: impl Widget<Msg> + 'static, message: Msg) -> Self {
Self {
child: Some(Rc::new(child)),
..Self::action(message)
}
}
pub fn icon(icon: IconData, label: impl Into<String>, message: Msg) -> Self {
Self {
lead: Some(Lead::Icon(icon)),
..Self::new(label, message)
}
}
pub fn checked(label: impl Into<String>, checked: bool, message: Msg) -> Self {
Self {
lead: Some(Lead::Check(checked)),
..Self::new(label, message)
}
}
pub fn divider() -> Self {
Self {
label: None,
child: None,
lead: None,
shortcut: None,
enabled: false,
message: None,
submenu: false,
expanded: false,
keys: RowKeys::default(),
}
}
#[must_use]
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub(crate) fn is_action(&self) -> bool {
self.message.is_some() && self.enabled
}
pub(crate) fn clone_row(&self) -> Self
where
Msg: Clone,
{
Self {
label: self.label.clone(),
child: self.child.clone(),
lead: self.lead,
shortcut: self.shortcut.clone(),
enabled: self.enabled,
message: self.message.clone(),
submenu: self.submenu,
expanded: self.expanded,
keys: self.keys.clone(),
}
}
#[must_use]
pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
self.shortcut = Some(keys.into());
self
}
}
pub struct PopupMenuButton<Msg = crate::callback::Callback> {
open: bool,
enabled: bool,
children: Vec<Box<dyn Widget<Msg>>>,
items: Vec<MenuItem<Msg>>,
text_style: Option<TextStyle>,
look: PanelStyle,
item_padding: Option<Insets>,
item_height: Option<f32>,
dismiss: Option<Msg>,
on_dismiss: Option<Msg>,
}
impl<Msg: Clone + 'static> PopupMenuButton<Msg> {
pub fn new(anchor: impl Widget<Msg> + 'static, open: bool, on_dismiss: Msg) -> Self {
Self {
open,
enabled: true,
children: vec![Box::new(anchor)],
items: Vec::new(),
text_style: None,
look: PanelStyle::default(),
item_padding: None,
item_height: None,
dismiss: Some(on_dismiss.clone()),
on_dismiss: if open { Some(on_dismiss) } else { None },
}
}
#[must_use]
pub fn text_style(mut self, style: TextStyle) -> Self {
self.text_style = Some(style);
self.rebuild();
self
}
#[must_use]
pub fn background(mut self, color: Color) -> Self {
self.look.background = Some(color);
self.rebuild();
self
}
#[must_use]
pub fn shape(mut self, shape: ShapeBorder) -> Self {
self.look.shape = Some(shape);
self.rebuild();
self
}
#[must_use]
pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
self.shape(ShapeBorder::rounded(radius.into()))
}
#[must_use]
pub fn elevation(mut self, elevation: f32) -> Self {
self.look.elevation = Some(elevation);
self.rebuild();
self
}
#[must_use]
pub fn shadow_color(mut self, color: Color) -> Self {
self.look.shadow_color = Some(color);
self.rebuild();
self
}
#[must_use]
pub fn menu_padding(mut self, padding: Insets) -> Self {
self.look.padding = Some(padding);
self.rebuild();
self
}
#[must_use]
pub fn item_padding(mut self, padding: Insets) -> Self {
self.item_padding = Some(padding);
self.rebuild();
self
}
#[must_use]
pub fn item_height(mut self, height: f32) -> Self {
self.item_height = Some(height);
self.rebuild();
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self.on_dismiss = if self.open && enabled {
self.dismiss.clone()
} else {
None
};
self.rebuild();
self
}
pub fn item(self, label: impl Into<String>, message: Msg) -> Self {
self.entry(MenuItem::new(label, message))
}
pub fn item_widget(self, child: impl Widget<Msg> + 'static, message: Msg) -> Self {
self.entry(MenuItem::widget(child, message))
}
pub fn icon_item(self, icon: IconData, label: impl Into<String>, message: Msg) -> Self {
self.entry(MenuItem::icon(icon, label, message))
}
pub fn checked_item(self, label: impl Into<String>, checked: bool, message: Msg) -> Self {
self.entry(MenuItem::checked(label, checked, message))
}
pub fn divider(self) -> Self {
self.entry(MenuItem::divider())
}
pub fn entry(mut self, item: MenuItem<Msg>) -> Self {
if self.open {
self.items.push(item);
self.rebuild();
}
self
}
fn rebuild(&mut self) {
if !self.enabled {
self.children.truncate(1);
return;
}
let panel = menu_panel(
&self.items,
&RowLook {
look: self.look,
text_style: self.text_style,
item_padding: self.item_padding,
item_height: self.item_height,
divider_height: None,
enabled: self.enabled,
kind: PanelKind::Menu,
},
&|_, row| row,
);
if self.children.len() > 1 {
self.children[1] = panel;
} else {
self.children.push(panel);
}
}
}
impl<Msg: Clone> Widget<Msg> for PopupMenuButton<Msg> {
fn style(&self) -> Style {
Style {
flex_direction: FlexDirection::Column,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
self.children
.get(1)
.map(|panel| (panel.as_ref(), Placement::Below))
}
fn overlay_dismiss(&self) -> Option<Msg> {
self.on_dismiss.clone()
}
fn overlay_traps_focus(&self) -> bool {
self.open
}
}
#[derive(Clone, Copy)]
pub(crate) struct RowLook {
pub(crate) look: PanelStyle,
pub(crate) text_style: Option<TextStyle>,
pub(crate) item_padding: Option<Insets>,
pub(crate) item_height: Option<f32>,
pub(crate) divider_height: Option<f32>,
pub(crate) enabled: bool,
pub(crate) kind: PanelKind,
}
pub(crate) fn menu_panel<Msg: Clone + 'static>(
items: &[MenuItem<Msg>],
row: &RowLook,
decorate: &dyn Fn(usize, Box<dyn Widget<Msg>>) -> Box<dyn Widget<Msg>>,
) -> Box<dyn Widget<Msg>> {
let lead_column = items.iter().any(|item| item.lead.is_some());
let mut list = Flex::column();
let mut rows = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
rows.push(Measure {
label: item.label.clone(),
shortcut: item.shortcut.clone(),
submenu: item.submenu,
});
let Some(message) = &item.message else {
list = list.child(Rule {
kind: row.kind,
height: row.divider_height,
});
continue;
};
let built: Box<dyn Widget<Msg>> = Box::new(Item {
label: item.label.clone(),
children: item
.child
.clone()
.map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
.into_iter()
.collect(),
lead: item.lead,
lead_column,
shortcut: item.shortcut.clone(),
submenu: item.submenu,
expanded: item.expanded,
keys: item.keys.clone(),
enabled: row.enabled && item.enabled,
text_style: row.text_style,
background: row.look.background,
padding: row.item_padding,
height: row.item_height,
kind: row.kind,
message: message.clone(),
});
list = list.child(decorate(index, built));
}
Box::new(Panel {
measure: Some(RowMeasure {
rows,
lead_column,
text_style: row.text_style,
item_padding: row.item_padding,
}),
..Panel::new(row.kind, row.look, vec![Box::new(list)])
})
}
#[cfg(test)]
pub(crate) mod probe {
use frus_core::{BorderRadius, Color, Primitive, Rect, Scene};
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Painted {
pub(crate) rect: Rect,
pub(crate) color: Color,
pub(crate) radius: BorderRadius,
pub(crate) border_width: f32,
pub(crate) blur: f32,
pub(crate) clip: Rect,
}
pub(crate) fn height_of(scene: &Scene) -> f32 {
crate::shadowprobe::height(scene.primitives())
}
pub(crate) fn colour_of(scene: &Scene) -> Option<Color> {
crate::shadowprobe::colour(scene.primitives())
}
fn painted(scene: &Scene) -> Vec<Painted> {
fn walk(primitives: &[Primitive], out: &mut Vec<Painted>) {
for p in primitives {
match p {
Primitive::Rect {
rect,
color,
radius,
border_width,
blur,
clip,
..
} => out.push(Painted {
rect: *rect,
color: *color,
radius: *radius,
border_width: *border_width,
blur: *blur,
clip: *clip,
}),
Primitive::Layer { primitives, .. } => walk(primitives, out),
_ => {}
}
}
}
let mut out = Vec::new();
walk(scene.primitives(), &mut out);
out
}
pub(crate) fn crisp(scene: &Scene) -> Vec<Painted> {
painted(scene)
.into_iter()
.filter(|r| r.blur == 0.0)
.collect()
}
pub(crate) fn blurred(scene: &Scene) -> Vec<Painted> {
painted(scene)
.into_iter()
.filter(|r| r.blur > 0.0)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::probe::{blurred, colour_of, height_of};
use super::*;
use crate::{build_ui, Container, Point as P, Runtime, Size};
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Close,
A,
B,
}
fn anchor() -> Container<Msg> {
Container::<Msg>::new().width(40.0).height(30.0)
}
fn rects(scene: &frus_core::Scene) -> Vec<(Rect, frus_core::Color, BorderRadius, f32)> {
fn walk(
primitives: &[frus_core::Primitive],
out: &mut Vec<(Rect, frus_core::Color, BorderRadius, f32)>,
) {
for p in primitives {
match p {
frus_core::Primitive::Rect {
rect,
color,
radius,
border_width,
..
} => out.push((*rect, *color, *radius, *border_width)),
frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
_ => {}
}
}
}
let mut out = Vec::new();
walk(scene.primitives(), &mut out);
out
}
fn open_menu() -> PopupMenuButton<Msg> {
PopupMenuButton::new(anchor(), true, Msg::Close)
.item("A", Msg::A)
.item("B", Msg::B)
}
fn frame(menu: &PopupMenuButton<Msg>, theme: &Theme) -> crate::Ui<Msg> {
build_ui(menu, Size::new(400.0, 400.0), &Runtime::default(), theme)
}
#[test]
fn a_menu_is_one_panel_and_not_a_stack_of_buttons() {
let theme = Theme::default();
let ui = frame(&open_menu(), &theme);
let painted = rects(ui.scene());
let panels: Vec<_> = painted
.iter()
.filter(|(rect, color, ..)| {
rect.width >= WIDTH && *color == theme.scheme.surface_container
})
.collect();
assert_eq!(
panels.len(),
1,
"one surface, not one per row: {painted:#?}"
);
assert!(
panels[0].2 != BorderRadius::ZERO,
"and it is rounded: {:?}",
panels[0].2
);
assert!(
painted.iter().all(|(.., border)| *border == 0.0),
"nothing in an open menu draws an outline: {painted:#?}"
);
}
#[test]
fn a_row_is_at_least_a_tap_target() {
let theme = Theme::default();
let ui = frame(&open_menu(), &theme);
let panel = ui
.scene()
.primitives()
.iter()
.find_map(|p| find_panel(p, &theme))
.expect("a panel");
assert!(
panel.height >= 2.0 * crate::theme::MIN_TAP_TARGET + 2.0 * PAD_Y,
"two rows of a tap target each, plus the panel's own room: {panel:?}"
);
}
fn find_panel(p: &frus_core::Primitive, theme: &Theme) -> Option<Rect> {
match p {
frus_core::Primitive::Rect { rect, color, .. }
if rect.width >= WIDTH && *color == theme.scheme.surface_container =>
{
Some(*rect)
}
frus_core::Primitive::Layer { primitives, .. } => {
primitives.iter().find_map(|p| find_panel(p, theme))
}
_ => None,
}
}
#[test]
fn a_menu_answers_to_its_theme_and_to_its_caller() {
let mut theme = Theme::default();
theme.widgets.menu.background = Some(frus_core::Color::rgb(0.2, 0.4, 0.6));
theme.widgets.menu.radius = Some(3.0);
theme.widgets.menu.item_height = Some(60.0);
theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
let ui = frame(&open_menu(), &theme);
let panel = rects(ui.scene())
.into_iter()
.find(|(rect, color, ..)| {
rect.width >= WIDTH && *color == frus_core::Color::rgb(0.2, 0.4, 0.6)
})
.expect("the theme's surface");
assert_eq!(panel.2, BorderRadius::uniform(3.0), "the theme's corner");
assert_eq!(
panel.0.height, 160.0,
"two rows of 60 and 20 above and below"
);
let told = open_menu()
.background(frus_core::Color::rgb(0.9, 0.1, 0.1))
.radius(9.0)
.item_height(50.0)
.menu_padding(Insets::ZERO);
let ui = frame(&told, &theme);
let panel = rects(ui.scene())
.into_iter()
.find(|(rect, color, ..)| {
rect.width >= WIDTH && *color == frus_core::Color::rgb(0.9, 0.1, 0.1)
})
.expect("the caller's surface");
assert_eq!(panel.2, BorderRadius::uniform(9.0));
assert_eq!(panel.0.height, 100.0);
}
#[test]
fn the_builders_can_be_written_in_any_order() {
let theme = Theme::default();
let colour = frus_core::Color::rgb(0.9, 0.1, 0.1);
let first = PopupMenuButton::new(anchor(), true, Msg::Close)
.background(colour)
.item("A", Msg::A)
.item("B", Msg::B);
let last = open_menu().background(colour);
assert_eq!(
rects(frame(&first, &theme).scene()),
rects(frame(&last, &theme).scene())
);
}
fn texts(scene: &frus_core::Scene) -> Vec<(Point, String)> {
fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Point, String)>) {
for p in primitives {
match p {
frus_core::Primitive::Text { position, text, .. } => {
out.push((*position, text.clone()))
}
frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
_ => {}
}
}
}
let mut out = Vec::new();
walk(scene.primitives(), &mut out);
out
}
fn text_at(ui: &crate::Ui<Msg>, words: &str) -> Point {
texts(ui.scene())
.into_iter()
.find(|(_, text)| text == words)
.unwrap_or_else(|| panic!("{words:?} is drawn"))
.0
}
fn press(ui: &crate::Ui<Msg>, x: f32, y: f32) -> Option<Msg> {
ui.hit(P::new(x, y)).and_then(|id| ui.msg_for(id))
}
fn row_middle(n: f32) -> f32 {
30.0 + PAD_Y + ROW_H * (n + 0.5)
}
#[test]
fn the_marks_line_up_down_one_column() {
let theme = Theme::default();
let plain = frame(&open_menu(), &theme);
let marked = frame(
&PopupMenuButton::new(anchor(), true, Msg::Close)
.checked_item("A", true, Msg::A)
.item("B", Msg::B),
&theme,
);
let unmarked_row = text_at(&marked, "B").x;
assert_eq!(
text_at(&marked, "A").x,
unmarked_row,
"the ticked row and the row with nothing start in the same place"
);
assert_eq!(
unmarked_row - text_at(&plain, "B").x,
LEAD + LEAD_GAP,
"and that place is the leading column further in than in a menu with no marks"
);
}
#[test]
fn a_tick_that_is_off_draws_nothing_and_moves_nothing() {
let theme = Theme::default();
let menu = |on: bool| {
PopupMenuButton::new(anchor(), true, Msg::Close)
.checked_item("A", on, Msg::A)
.item("B", Msg::B)
};
let (off, on) = (frame(&menu(false), &theme), frame(&menu(true), &theme));
assert_eq!(
paths(off.scene()),
0,
"nothing is drawn for a tick that is off"
);
assert_eq!(paths(on.scene()), 1, "and one tick when it is on");
assert_eq!(
text_at(&off, "A").x,
text_at(&on, "A").x,
"the label does not move when the tick appears"
);
}
fn paths(scene: &frus_core::Scene) -> usize {
fn walk(primitives: &[frus_core::Primitive]) -> usize {
primitives
.iter()
.map(|p| match p {
frus_core::Primitive::Path { .. } => 1,
frus_core::Primitive::Layer { primitives, .. } => walk(primitives),
_ => 0,
})
.sum()
}
walk(scene.primitives())
}
#[test]
fn a_row_can_be_a_widget_the_caller_drew() {
let theme = Theme::default();
let mark = frus_core::Color::rgb(0.9, 0.1, 0.1);
let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
.item_widget(
Container::<Msg>::new().width(60.0).height(80.0).color(mark),
Msg::A,
)
.item("B", Msg::B);
let ui = frame(&menu, &theme);
let drawn = rects(ui.scene())
.into_iter()
.find(|(_, color, ..)| *color == mark)
.expect("the caller's own widget is drawn");
assert_eq!(
drawn.0.x,
text_at(&ui, "B").x,
"it starts where a label would have started"
);
assert_eq!(
drawn.0.height, 80.0,
"and it is given the height it asked for"
);
let panel = ui
.scene()
.primitives()
.iter()
.find_map(|p| find_panel(p, &theme))
.expect("a panel");
assert_eq!(panel.height, 80.0 + ROW_H + 2.0 * PAD_Y);
}
#[test]
fn a_rule_is_not_a_row() {
let theme = Theme::default();
let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
.item("A", Msg::A)
.divider()
.item("B", Msg::B);
let ui = frame(&menu, &theme);
let panel = ui
.scene()
.primitives()
.iter()
.find_map(|p| find_panel(p, &theme))
.expect("a panel");
assert_eq!(
panel.height,
2.0 * ROW_H + crate::divider::DIVIDER_SPACE + 2.0 * PAD_Y,
"two rows, a rule of sixteen, and the panel's own room"
);
assert_eq!(
press(&ui, 110.0, row_middle(0.0)),
Some(Msg::A),
"the row above it still answers"
);
}
#[test]
fn one_row_can_be_unavailable_while_the_rest_is_not() {
let theme = Theme::default();
let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
.item("A", Msg::A)
.entry(MenuItem::new("B", Msg::B).enabled(false));
let ui = frame(&menu, &theme);
assert_eq!(press(&ui, 110.0, row_middle(0.0)), Some(Msg::A));
assert_eq!(
press(&ui, 110.0, row_middle(1.0)),
None,
"the row that said it was unavailable answers nothing"
);
let greyed = texts(ui.scene()).iter().any(|(_, text)| text == "B");
assert!(greyed, "and is still drawn rather than left out");
}
#[test]
fn the_keys_are_drawn_on_the_right_and_announced() {
let theme = Theme::default();
let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
.entry(MenuItem::new("Paste", Msg::A).shortcut("Ctrl+V"));
let ui = frame(&menu, &theme);
let keys = text_at(&ui, "Ctrl+V");
assert!(
keys.x > text_at(&ui, "Paste").x,
"the keys are past the label, not before it"
);
let row = Item {
label: Some("Paste".into()),
children: Vec::new(),
lead: None,
lead_column: false,
shortcut: Some("Ctrl+V".into()),
submenu: false,
expanded: false,
keys: RowKeys::default(),
enabled: true,
text_style: None,
background: None,
padding: None,
height: None,
kind: PanelKind::Menu,
message: Msg::A,
};
assert_eq!(
Widget::<Msg>::semantics(&row)
.expect("announced")
.label
.as_deref(),
Some("Paste, Ctrl+V")
);
}
#[test]
fn a_row_that_is_on_or_off_is_announced_as_one() {
let row = |on: bool| Item {
label: Some("Word wrap".into()),
children: Vec::new(),
lead: Some(Lead::Check(on)),
lead_column: true,
shortcut: None,
submenu: false,
expanded: false,
keys: RowKeys::default(),
enabled: true,
text_style: None,
background: None,
padding: None,
height: None,
kind: PanelKind::Menu,
message: Msg::A,
};
let on = Widget::<Msg>::semantics(&row(true)).expect("announced");
assert_eq!(on.role, frus_core::Role::CheckBox);
assert_eq!(on.toggled, frus_core::Toggled::True);
assert!(on.clickable, "and it is still an action");
assert_eq!(
Widget::<Msg>::semantics(&row(false))
.expect("announced")
.toggled,
frus_core::Toggled::False,
"off is a state, not the absence of one"
);
}
#[test]
fn the_menu_grows_to_its_widest_row_and_never_shrinks() {
let theme = Theme::default();
let width = |menu: &PopupMenuButton<Msg>| {
frame(menu, &theme)
.scene()
.primitives()
.iter()
.find_map(|p| find_panel(p, &theme))
.expect("a panel")
.width
};
assert_eq!(
width(&open_menu()),
WIDTH,
"short labels keep the old width"
);
let long = PopupMenuButton::new(anchor(), true, Msg::Close)
.item("Duplicate this record and everything under it", Msg::A);
assert!(
width(&long) > WIDTH,
"a label that does not fit widens the menu instead of running off it"
);
}
#[test]
fn a_press_on_the_menus_own_surface_does_not_close_it() {
let theme = Theme::default();
let ui = frame(&open_menu(), &theme);
assert_eq!(
press(&ui, 110.0, 30.0 + PAD_Y * 0.5),
None,
"the panel's own room above the first row swallows the press"
);
assert_eq!(
press(&ui, 390.0, 290.0),
Some(Msg::Close),
"and the page beyond it still closes the menu"
);
}
#[test]
fn the_shadow_is_cast_in_a_colour_that_can_be_told() {
let theme = Theme::default();
let cast = |menu: &PopupMenuButton<Msg>, theme: &Theme| blurred(frame(menu, theme).scene());
let colour =
|menu: &PopupMenuButton<Msg>, theme: &Theme| colour_of(frame(menu, theme).scene());
let shadows = cast(&open_menu(), &theme);
assert_eq!(shadows.len(), 3, "{shadows:#?}");
assert_eq!(
height_of(frame(&open_menu(), &theme).scene()),
3.0,
"three high"
);
assert_eq!(colour(&open_menu(), &theme), Some(theme.scheme.shadow));
let shade = frus_core::Color::rgba(0.0, 0.2, 0.6, 0.5);
assert_eq!(
colour(&open_menu().shadow_color(shade), &theme),
Some(shade)
);
let mut themed = Theme::default();
themed.widgets.menu.shadow_color = Some(shade);
assert_eq!(colour(&open_menu(), &themed), Some(shade), "the theme's");
let own = frus_core::Color::rgba(0.6, 0.0, 0.0, 0.25);
assert_eq!(
colour(&open_menu().shadow_color(own), &themed),
Some(own),
"the caller's over the theme's"
);
assert!(cast(
&open_menu().shadow_color(frus_core::Color::TRANSPARENT),
&theme
)
.is_empty());
themed.widgets.menu.shadow_color = Some(frus_core::Color::TRANSPARENT);
assert!(cast(&open_menu(), &themed).is_empty());
}
#[test]
fn closed_has_no_overlay() {
let menu = PopupMenuButton::new(anchor(), false, Msg::Close).item("A", Msg::A);
assert!(Widget::<Msg>::overlay(&menu).is_none());
assert_eq!(Widget::<Msg>::children(&menu).len(), 1);
}
#[test]
fn open_floats_items_and_dismisses_outside() {
let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
.item("A", Msg::A)
.item("B", Msg::B);
assert!(Widget::<Msg>::overlay(&menu).is_some());
assert_eq!(Widget::<Msg>::overlay_dismiss(&menu), Some(Msg::Close));
let ui = build_ui(
&menu,
Size::new(400.0, 300.0),
&Runtime::default(),
&Theme::default(),
);
let corner = ui.hit(P::new(390.0, 290.0)).expect("hit de fermeture");
assert_eq!(ui.msg_for(corner), Some(Msg::Close));
}
}