#![allow(non_snake_case)]
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::composable;
use crate::modifier::{Color, Modifier};
use crate::text::{measure_text, AnnotatedString, TextStyle, TextUnit};
use crate::widgets::box_widget::{Box, BoxSpec};
use crate::widgets::popup::{local_popup_viewport, Popup};
use crate::widgets::{Row, RowSpec, Text};
use crate::PointerInputScope;
use cranpose_animation::{Animatable, AnimationSpec, AnimationType, Easing};
use cranpose_core::{remember, with_current_composer, SideEffect};
use cranpose_foundation::PointerEventKind;
use cranpose_ui_graphics::{
liquid_menu_glass_effect, GraphicsLayer, LayerShape, Point, Rect, RoundedCornerShape, Size,
};
pub const MENU_HEIGHT: f32 = 44.0;
const MENU_SCREEN_MARGIN: f32 = 20.0;
const MENU_GAP_ABOVE_LINE: f32 = 15.0;
const ITEM_PADDING: f32 = 20.0;
const SEPARATOR_WIDTH: f32 = 1.0;
const SEPARATOR_HEIGHT: f32 = 17.0;
const SEPARATOR_COLOR: Color = Color(1.0, 1.0, 1.0, 0.07);
const MENU_FG: Color = Color(0.96, 0.96, 0.98, 1.0);
const MENU_FONT_SP: f32 = 15.0;
const DISC_COLOR: Color = Color(1.0, 1.0, 1.0, 0.19);
const DISC_PRESSED_COLOR: Color = Color(1.0, 1.0, 1.0, 0.9);
const MENU_BLUR_DP: f32 = 15.0;
const MENU_DISSOLVE_MS: u64 = 70;
const MENU_MATERIALIZE_MS: u64 = 140;
const MENU_RETURN_DELAY_MS: u64 = 250;
fn menu_text_style() -> TextStyle {
let mut style = TextStyle::default();
style.span_style.color = Some(MENU_FG);
style.span_style.font_size = TextUnit::Sp(MENU_FONT_SP);
style
}
#[derive(Clone)]
pub struct TextMenuItem {
pub label: String,
pub action: Rc<dyn Fn()>,
}
impl TextMenuItem {
pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
Self {
label: label.into(),
action: Rc::new(action),
}
}
}
impl PartialEq for TextMenuItem {
fn eq(&self, other: &Self) -> bool {
self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
}
}
impl std::fmt::Debug for TextMenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextMenuItem")
.field("label", &self.label)
.finish()
}
}
pub(crate) fn menu_item_pointer_input(label: &str, action: Rc<dyn Fn()>) -> Modifier {
let key = label.to_string();
Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
let action = Rc::clone(&action);
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
let mut pressed = false;
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => {
pressed = true;
event.consume();
}
PointerEventKind::Move => {
event.consume();
}
PointerEventKind::Up => {
if pressed {
action();
}
pressed = false;
event.consume();
}
PointerEventKind::Cancel => {
pressed = false;
event.consume();
}
_ => {}
}
}
})
.await;
}
})
}
fn disc_pointer_input(
pressed: Rc<Cell<bool>>,
on_tap: Rc<dyn Fn()>,
invalidate: Rc<dyn Fn()>,
) -> Modifier {
Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
let pressed = Rc::clone(&pressed);
let on_tap = Rc::clone(&on_tap);
let invalidate = Rc::clone(&invalidate);
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
let mut down = false;
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => {
down = true;
pressed.set(true);
invalidate();
event.consume();
}
PointerEventKind::Move => {
event.consume();
}
PointerEventKind::Up => {
if down {
on_tap();
}
down = false;
pressed.set(false);
invalidate();
event.consume();
}
PointerEventKind::Cancel => {
down = false;
pressed.set(false);
invalidate();
event.consume();
}
_ => {}
}
}
})
.await;
}
})
}
fn item_width(label: &str, style: &TextStyle) -> f32 {
measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
}
fn slide_item_at(
point: cranpose_ui_graphics::Point,
origin_x: f32,
origin_y: f32,
page_items: &[TextMenuItem],
style: &TextStyle,
) -> Option<usize> {
if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
return None;
}
let mut cursor = origin_x;
for (index, item) in page_items.iter().enumerate() {
if index > 0 {
cursor += SEPARATOR_WIDTH;
}
let width = item_width(&item.label, style);
if point.x >= cursor && point.x < cursor + width {
return Some(index);
}
cursor += width;
}
None
}
fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
let widths: Vec<f32> = items
.iter()
.map(|item| item_width(&item.label, style))
.collect();
let total: f32 =
widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
if total <= max_width || items.len() <= 1 {
return vec![(0..items.len()).collect()];
}
let disc = MENU_HEIGHT;
let mut pages: Vec<Vec<usize>> = Vec::new();
let mut page: Vec<usize> = Vec::new();
let mut used = disc;
for (index, width) in widths.iter().enumerate() {
let extra = width
+ if page.is_empty() {
0.0
} else {
SEPARATOR_WIDTH
};
if !page.is_empty() && used + extra > max_width {
pages.push(std::mem::take(&mut page));
used = disc;
}
used += width
+ if page.is_empty() {
0.0
} else {
SEPARATOR_WIDTH
};
page.push(index);
}
if !page.is_empty() {
pages.push(page);
}
pages
}
struct MenuMotion {
progress: RefCell<Animatable<f32>>,
was_visible: Cell<bool>,
page: Cell<usize>,
disc_pressed: Rc<Cell<bool>>,
slide_hover: Cell<Option<usize>>,
slide_live: Cell<bool>,
}
#[composable]
pub fn LiquidTextMenu(
center_x: f32,
line_top_y: f32,
visible: bool,
live_point: Option<cranpose_ui_graphics::Point>,
items: Vec<TextMenuItem>,
) {
let motion = remember(|| {
let runtime = with_current_composer(|composer| composer.runtime_handle());
Rc::new(MenuMotion {
progress: RefCell::new(Animatable::new(0.0, runtime)),
was_visible: Cell::new(false),
page: Cell::new(0),
disc_pressed: Rc::new(Cell::new(false)),
slide_hover: Cell::new(None),
slide_live: Cell::new(false),
})
})
.with(Rc::clone);
if visible != motion.was_visible.get() {
motion.was_visible.set(visible);
let mut progress = motion.progress.borrow_mut();
if visible {
progress.animateTo(
1.0,
AnimationType::Tween(
AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
.with_delay(MENU_RETURN_DELAY_MS),
),
);
} else {
progress.animateTo(
0.0,
AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
);
}
}
let progress_state = motion.progress.borrow().state();
let p = progress_state.value().clamp(0.0, 1.0);
if !visible && p <= 0.01 {
return;
}
let style = menu_text_style();
let viewport = local_popup_viewport().current().get();
let max_width = if viewport.width > 0.0 {
viewport.width - 2.0 * MENU_SCREEN_MARGIN
} else {
f32::INFINITY
};
let pages = paginate(&items, &style, max_width);
let page_index = motion.page.get().min(pages.len() - 1);
let page = &pages[page_index];
let has_disc = pages.len() > 1;
let mut width: f32 = page
.iter()
.map(|&i| item_width(&items[i].label, &style))
.sum();
width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
if has_disc {
width += MENU_HEIGHT;
}
let mut x = center_x - width * 0.5;
if viewport.width > 0.0 {
x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
}
x = x.max(MENU_SCREEN_MARGIN);
let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
let anchor = Rect {
x,
y,
width: 0.0,
height: 0.0,
};
let density = crate::current_density();
let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
let slide_hover = match live_point {
Some(point) => {
motion.slide_live.set(true);
let hover = slide_item_at(point, x, y, &page_items, &style);
motion.slide_hover.set(hover);
hover
}
None => {
let hover = motion.slide_hover.take();
if motion.slide_live.replace(false) {
if let Some(index) = hover {
if let Some(item) = page_items.get(index) {
let action = Rc::clone(&item.action);
SideEffect(move || action());
}
}
}
None
}
};
let disc_pressed = Rc::clone(&motion.disc_pressed);
let page_count = pages.len();
let motion_for_disc = Rc::clone(&motion);
Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
let page_items = page_items.clone();
let disc_pressed = Rc::clone(&disc_pressed);
let motion = Rc::clone(&motion_for_disc);
Box(
Modifier::empty()
.size(Size {
width,
height: MENU_HEIGHT,
})
.drop_shadow(
LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
move |scope| {
scope.radius = 16.0;
scope.spread = -2.0;
scope.offset.y = 0.0;
scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
scope.cutout = true;
},
)
.graphics_layer(move || GraphicsLayer {
alpha: p,
backdrop_effect: (p > 0.001).then(|| {
liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
}),
shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
clip: true,
..Default::default()
}),
BoxSpec::default(),
move || {
let page_items = page_items.clone();
let disc_pressed = Rc::clone(&disc_pressed);
let motion = Rc::clone(&motion);
Row(
Modifier::empty().size(Size {
width,
height: MENU_HEIGHT,
}),
RowSpec::default().vertical_alignment(
cranpose_ui_layout::VerticalAlignment::CenterVertically,
),
move || {
for (index, item) in page_items.iter().enumerate() {
if index > 0 {
Box(
Modifier::empty()
.size(Size {
width: SEPARATOR_WIDTH,
height: SEPARATOR_HEIGHT,
})
.background(SEPARATOR_COLOR),
BoxSpec::default(),
|| {},
);
}
let slide_hovered = slide_hover == Some(index);
Text(
item.label.clone(),
Modifier::empty()
.padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
.draw_behind(move |scope| {
if slide_hovered {
scope.draw_round_rect(
cranpose_ui_graphics::Brush::solid(Color(
1.0, 1.0, 1.0, 0.10,
)),
cranpose_ui_graphics::CornerRadii::uniform(10.0),
);
}
})
.then(menu_item_pointer_input(
&item.label,
Rc::clone(&item.action),
)),
menu_text_style(),
);
}
if page_count > 1 {
let pressed_now = disc_pressed.get();
let motion = Rc::clone(&motion);
let advance: Rc<dyn Fn()> = Rc::new(move || {
motion.page.set((motion.page.get() + 1) % page_count);
crate::request_render_invalidation();
});
let invalidate: Rc<dyn Fn()> =
Rc::new(crate::request_render_invalidation);
Box(
Modifier::empty()
.size(Size {
width: MENU_HEIGHT,
height: MENU_HEIGHT,
})
.background(if pressed_now {
DISC_PRESSED_COLOR
} else {
DISC_COLOR
})
.rounded_corners(MENU_HEIGHT * 0.5)
.then(disc_pointer_input(
Rc::clone(&disc_pressed),
advance,
invalidate,
)),
BoxSpec::default()
.content_alignment(cranpose_ui_layout::Alignment::CENTER),
|| {
Text(
"\u{203A}".to_string(),
Modifier::empty(),
menu_text_style(),
);
},
);
}
},
);
},
);
});
}
#[allow(clippy::too_many_arguments)]
#[composable]
pub fn TextSelectionMenu(
center_x: f32,
line_top_y: f32,
visible: bool,
live_point: Option<cranpose_ui_graphics::Point>,
can_paste: bool,
on_copy: impl Fn() + 'static,
on_cut: impl Fn() + 'static,
on_paste: impl Fn() + 'static,
on_select_all: impl Fn() + 'static,
) {
let mut items = vec![
TextMenuItem::new("Copy", on_copy),
TextMenuItem::new("Cut", on_cut),
];
if can_paste {
items.push(TextMenuItem::new("Paste", on_paste));
}
items.push(TextMenuItem::new("Select all", on_select_all));
LiquidTextMenu(center_x, line_top_y, visible, live_point, items);
}
#[allow(clippy::too_many_arguments)]
#[composable]
pub fn CaretActionMenu(
center_x: f32,
line_top_y: f32,
visible: bool,
can_paste: bool,
can_undo: bool,
can_redo: bool,
on_paste: impl Fn() + 'static,
on_select_all: impl Fn() + 'static,
on_undo: impl Fn() + 'static,
on_redo: impl Fn() + 'static,
) {
let mut items = Vec::new();
if can_paste {
items.push(TextMenuItem::new("Paste", on_paste));
}
items.push(TextMenuItem::new("Select all", on_select_all));
if can_undo {
items.push(TextMenuItem::new("Undo", on_undo));
}
if can_redo {
items.push(TextMenuItem::new("Redo", on_redo));
}
LiquidTextMenu(center_x, line_top_y, visible, None, items);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
use cranpose_foundation::PointerEvent;
use cranpose_ui_graphics::Point;
fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
let slices = collect_slices_from_modifier(modifier);
assert_eq!(
slices.pointer_inputs().len(),
1,
"menu button must install exactly one pointer-input gesture"
);
let handler = slices.pointer_inputs()[0].clone();
(handler, slices)
}
fn down(x: f32, y: f32) -> PointerEvent {
PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
}
fn up(x: f32, y: f32) -> PointerEvent {
PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
}
#[test]
fn menu_button_consumes_the_tap_and_runs_the_action() {
let _app_context = crate::render_state::app_context_test_scope();
let ran = Rc::new(Cell::new(false));
let action: Rc<dyn Fn()> = {
let ran = Rc::clone(&ran);
Rc::new(move || ran.set(true))
};
let modifier = menu_item_pointer_input("Copy", action);
let (handler, _slices) = button_handler(&modifier);
let press = down(5.0, 5.0);
handler(press.clone());
assert!(
press.is_consumed(),
"the press must be consumed so it never reaches the field and collapses the selection"
);
assert!(!ran.get(), "the action fires on release, not on press");
let release = up(6.0, 6.0);
handler(release.clone());
assert!(release.is_consumed(), "the release must be consumed too");
assert!(
ran.get(),
"releasing after a press on the button runs the action"
);
}
#[test]
fn menu_button_release_without_press_is_consumed_but_inert() {
let _app_context = crate::render_state::app_context_test_scope();
let ran = Rc::new(Cell::new(false));
let action: Rc<dyn Fn()> = {
let ran = Rc::clone(&ran);
Rc::new(move || ran.set(true))
};
let modifier = menu_item_pointer_input("Cut", action);
let (handler, _slices) = button_handler(&modifier);
let release = up(5.0, 5.0);
handler(release.clone());
assert!(
release.is_consumed(),
"a release on the menu is consumed so it never hits the field"
);
assert!(!ran.get(), "a release with no matching press must not act");
}
#[test]
fn menu_geometry_matches_the_reference() {
assert_eq!(MENU_HEIGHT, 44.0);
assert_eq!(ITEM_PADDING, 20.0);
assert_eq!(SEPARATOR_WIDTH, 1.0);
assert_eq!(SEPARATOR_HEIGHT, 17.0);
assert_eq!(MENU_GAP_ABOVE_LINE, 15.0);
assert_eq!(MENU_SCREEN_MARGIN, 20.0);
}
#[test]
fn pagination_reserves_the_disc_only_when_overflowing() {
let _app_context = crate::render_state::app_context_test_scope();
let style = menu_text_style();
let items: Vec<TextMenuItem> = ["Copy", "Cut", "Paste", "Select all"]
.iter()
.map(|label| TextMenuItem::new(*label, || {}))
.collect();
let one = paginate(&items, &style, f32::INFINITY);
assert_eq!(one.len(), 1, "everything fits on one page");
assert_eq!(one[0].len(), 4);
let total: f32 = items
.iter()
.map(|i| item_width(&i.label, &style))
.sum::<f32>()
+ 3.0 * SEPARATOR_WIDTH;
let narrow = paginate(&items, &style, total * 0.55);
assert!(narrow.len() > 1, "a narrow window must page");
assert!(narrow.iter().all(|page| !page.is_empty()));
let all: Vec<usize> = narrow.iter().flatten().copied().collect();
assert_eq!(all, vec![0, 1, 2, 3], "pages cover every item in order");
}
}