#![allow(non_snake_case)]
use std::rc::Rc;
use crate::composable;
use crate::modifier::{Color, Modifier};
use crate::text::TextStyle;
use crate::widgets::box_widget::{Box, BoxSpec};
use crate::widgets::popup::Popup;
use crate::widgets::{Row, RowSpec, Text};
use crate::PointerInputScope;
use cranpose_foundation::PointerEventKind;
use cranpose_ui_graphics::{Point, Rect};
const MENU_BG: Color = Color(0.18, 0.18, 0.2, 0.96);
const MENU_FG: Color = Color(0.96, 0.96, 0.98, 1.0);
const MENU_HEIGHT: f32 = 40.0;
fn menu_text_style() -> TextStyle {
let mut style = TextStyle::default();
style.span_style.color = Some(MENU_FG);
style
}
fn menu_item(label: &str, action: Rc<dyn Fn()>) {
Text(
label.to_string(),
Modifier::empty()
.padding(10.0)
.then(menu_item_pointer_input(label, action)),
menu_text_style(),
);
}
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;
}
})
}
#[composable]
pub fn TextSelectionMenu(
anchor: 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 popup_anchor = Rect {
x: (anchor.x - 8.0).max(0.0),
y: (anchor.y - MENU_HEIGHT).max(0.0),
width: 0.0,
height: 0.0,
};
let on_copy: Rc<dyn Fn()> = Rc::new(on_copy);
let on_cut: Rc<dyn Fn()> = Rc::new(on_cut);
let on_paste: Rc<dyn Fn()> = Rc::new(on_paste);
let on_select_all: Rc<dyn Fn()> = Rc::new(on_select_all);
Popup(popup_anchor, Point { x: 0.0, y: 0.0 }, move || {
let on_copy = Rc::clone(&on_copy);
let on_cut = Rc::clone(&on_cut);
let on_paste = Rc::clone(&on_paste);
let on_select_all = Rc::clone(&on_select_all);
Box(
Modifier::empty().background(MENU_BG).rounded_corners(6.0),
BoxSpec::default(),
move || {
let on_copy = Rc::clone(&on_copy);
let on_cut = Rc::clone(&on_cut);
let on_paste = Rc::clone(&on_paste);
let on_select_all = Rc::clone(&on_select_all);
Row(Modifier::empty(), RowSpec::default(), move || {
menu_item("Copy", Rc::clone(&on_copy));
menu_item("Cut", Rc::clone(&on_cut));
if can_paste {
menu_item("Paste", Rc::clone(&on_paste));
}
menu_item("Select all", Rc::clone(&on_select_all));
});
},
);
});
}
#[allow(clippy::too_many_arguments)]
#[composable]
pub fn CaretActionMenu(
anchor: Point,
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 popup_anchor = Rect {
x: (anchor.x - 8.0).max(0.0),
y: (anchor.y - MENU_HEIGHT).max(0.0),
width: 0.0,
height: 0.0,
};
let on_paste: Rc<dyn Fn()> = Rc::new(on_paste);
let on_select_all: Rc<dyn Fn()> = Rc::new(on_select_all);
let on_undo: Rc<dyn Fn()> = Rc::new(on_undo);
let on_redo: Rc<dyn Fn()> = Rc::new(on_redo);
Popup(popup_anchor, Point { x: 0.0, y: 0.0 }, move || {
let on_paste = Rc::clone(&on_paste);
let on_select_all = Rc::clone(&on_select_all);
let on_undo = Rc::clone(&on_undo);
let on_redo = Rc::clone(&on_redo);
Box(
Modifier::empty().background(MENU_BG).rounded_corners(6.0),
BoxSpec::default(),
move || {
let on_paste = Rc::clone(&on_paste);
let on_select_all = Rc::clone(&on_select_all);
let on_undo = Rc::clone(&on_undo);
let on_redo = Rc::clone(&on_redo);
Row(Modifier::empty(), RowSpec::default(), move || {
if can_paste {
menu_item("Paste", Rc::clone(&on_paste));
}
menu_item("Select all", Rc::clone(&on_select_all));
if can_undo {
menu_item("Undo", Rc::clone(&on_undo));
}
if can_redo {
menu_item("Redo", Rc::clone(&on_redo));
}
});
},
);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
use cranpose_foundation::PointerEvent;
use cranpose_ui_graphics::Point;
use std::cell::Cell;
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");
}
}