use std::collections::HashMap;
use std::sync::Arc;
use web_time::Instant;
use frus_gpu::Renderer;
use frus_widgets::{
build_deferred, build_ui, collect_ids, find_by_key, find_path, find_widget,
nearest_reorder_slot, reflow_reorder_cards, reflow_reorder_columns, reorder_drop_after,
reorder_siblings, reorderable_owners, subtree_ids, Accessibility, Brightness, Color,
Cursor as UiCursor, Edit, EditKind, EditSnapshot, FocusDirection, Insets, Key, KeyResponse,
KeyStroke, MediaQuery, Point, Primitive, Rect, RegionPoint, RegionSelection, ReorderAxis,
Runtime, Scene, ScrollTo, Scrollable, SheetTo, ShortcutKey, Size, TextStop, Theme, ToolbarMark,
Ui, VelocityEstimate, VelocityTracker, Widget, WidgetId, WindowInsets,
};
use winit::application::ApplicationHandler;
use winit::event::{
ElementState, MouseButton, MouseScrollDelta, StartCause, TouchPhase, WindowEvent,
};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
use winit::keyboard::{Key as WinitKey, KeyCode, NamedKey, PhysicalKey};
use winit::window::{CursorIcon, Window, WindowId};
use crate::application::{Application, Lifecycle};
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct PlatformSettings {
pub text_scaler: f32,
pub brightness: Brightness,
pub accessibility: Accessibility,
pub locales: Vec<frus_widgets::Locale>,
}
impl Default for PlatformSettings {
fn default() -> Self {
Self {
text_scaler: 1.0,
brightness: Brightness::Light,
accessibility: Accessibility::NONE,
locales: Vec::new(),
}
}
}
use crate::gesture::{PointerEvent, PointerKind, PressRecognizer};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClipCommand {
Copy,
Cut,
Paste,
}
fn clipboard_command(logical: &WinitKey, physical: PhysicalKey, ctrl: bool) -> Option<ClipCommand> {
match logical {
WinitKey::Named(NamedKey::Copy) => return Some(ClipCommand::Copy),
WinitKey::Named(NamedKey::Cut) => return Some(ClipCommand::Cut),
WinitKey::Named(NamedKey::Paste) => return Some(ClipCommand::Paste),
WinitKey::Character(c) if ctrl => {
if c.eq_ignore_ascii_case("c") {
return Some(ClipCommand::Copy);
}
if c.eq_ignore_ascii_case("x") {
return Some(ClipCommand::Cut);
}
if c.eq_ignore_ascii_case("v") {
return Some(ClipCommand::Paste);
}
}
_ => {}
}
match physical {
PhysicalKey::Code(KeyCode::Copy) => Some(ClipCommand::Copy),
PhysicalKey::Code(KeyCode::Cut) => Some(ClipCommand::Cut),
PhysicalKey::Code(KeyCode::Paste) => Some(ClipCommand::Paste),
_ => None,
}
}
fn opens_selection_bar(logical: &WinitKey, shift: bool) -> bool {
match logical {
WinitKey::Named(NamedKey::ContextMenu) => true,
WinitKey::Named(NamedKey::F10) => shift,
_ => false,
}
}
#[cfg(any(android, test))]
fn wants_keyboard<M>(widget: &dyn Widget<M>) -> bool {
is_text_field(widget) && widget.focusable() && widget.takes_typing()
}
fn is_text_field<M>(widget: &dyn Widget<M>) -> bool {
widget.text_value().is_some()
}
const REGION_DOUBLE_CLICK_SLOP: f32 = 8.0;
const DOUBLE_TAP_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300);
const DOUBLE_TAP_SLOP: f32 = 100.0;
const MAX_PAN_VELOCITY: f32 = 8000.0;
struct PendingTap<M> {
id: WidgetId,
at: Instant,
position: Point,
single: Option<M>,
}
fn select_all_hides_bar(platform: frus_widgets::TargetPlatform) -> bool {
platform.is_desktop()
}
mod clip {
use frus_widgets::WidgetId;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pasted {
pub into: WidgetId,
pub text: String,
}
impl Pasted {
pub fn lands(&self, focused: Option<WidgetId>) -> bool {
focused == Some(self.into) && !self.text.is_empty()
}
}
#[cfg(not(web))]
impl Clipboard {
pub fn paste(
&mut self,
into: WidgetId,
_wake: Option<&std::sync::Arc<winit::window::Window>>,
) -> Option<Pasted> {
self.get_text().map(|text| Pasted { into, text })
}
pub fn take_answered(&mut self) -> Option<Pasted> {
None
}
}
#[cfg(desktop)]
pub enum Clipboard {
System(Option<arboard::Clipboard>),
#[cfg(any(test, feature = "testing"))]
Memory(Option<String>),
}
#[cfg(desktop)]
impl Clipboard {
pub fn new() -> Self {
Self::System(arboard::Clipboard::new().ok())
}
#[cfg(any(test, feature = "testing"))]
pub fn in_memory() -> Self {
Self::Memory(None)
}
pub fn get_text(&mut self) -> Option<String> {
match self {
Self::System(system) => system.as_mut().and_then(|c| c.get_text().ok()),
#[cfg(any(test, feature = "testing"))]
Self::Memory(text) => text.clone(),
}
}
pub fn has_text(&mut self) -> bool {
self.get_text().is_some_and(|text| !text.is_empty())
}
pub fn set_text(&mut self, text: String) {
match self {
Self::System(system) => {
if let Some(c) = system.as_mut() {
let _ = c.set_text(text);
}
}
#[cfg(any(test, feature = "testing"))]
Self::Memory(held) => *held = Some(text),
}
}
}
#[cfg(all(not(desktop), any(test, feature = "testing")))]
impl Clipboard {
pub fn in_memory() -> Self {
Self::new()
}
}
#[cfg(android)]
pub struct Clipboard;
#[cfg(android)]
impl Clipboard {
pub fn new() -> Self {
Self
}
pub fn get_text(&mut self) -> Option<String> {
crate::android_clipboard::get_text()
}
pub fn has_text(&mut self) -> bool {
self.get_text().is_some_and(|text| !text.is_empty())
}
pub fn set_text(&mut self, text: String) {
crate::android_clipboard::set_text(&text);
}
}
#[cfg(any(web, test))]
#[derive(Default)]
pub struct Answers(std::rc::Rc<std::cell::RefCell<std::collections::VecDeque<Pasted>>>);
#[cfg(any(web, test))]
impl Answers {
pub fn answer_for(
&self,
into: WidgetId,
wake: impl FnOnce() + 'static,
) -> impl FnOnce(String) + 'static {
let queue = std::rc::Rc::clone(&self.0);
move |text| {
queue.borrow_mut().push_back(Pasted { into, text });
wake();
}
}
pub fn take(&self) -> Option<Pasted> {
self.0.borrow_mut().pop_front()
}
}
#[cfg(web)]
pub struct Clipboard {
answered: Answers,
}
#[cfg(web)]
impl Clipboard {
pub fn new() -> Self {
Self {
answered: Answers::default(),
}
}
pub fn set_text(&mut self, text: String) {
crate::web_clipboard::set_text(&text);
}
pub fn has_text(&mut self) -> bool {
true
}
pub fn paste(
&mut self,
into: WidgetId,
wake: Option<&std::sync::Arc<winit::window::Window>>,
) -> Option<Pasted> {
let wake = wake.cloned();
crate::web_clipboard::get_text(self.answered.answer_for(into, move || {
if let Some(window) = wake {
window.request_redraw();
}
}));
None
}
pub fn take_answered(&mut self) -> Option<Pasted> {
self.answered.take()
}
}
#[cfg(not(any(desktop, android, web)))]
pub struct Clipboard;
#[cfg(not(any(desktop, android, web)))]
impl Clipboard {
pub fn new() -> Self {
Self
}
pub fn get_text(&mut self) -> Option<String> {
None
}
pub fn has_text(&mut self) -> bool {
false
}
pub fn set_text(&mut self, _text: String) {}
}
}
#[cfg(web)]
mod web_timer {
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast;
pub(crate) struct Interval {
id: i32,
_closure: Closure<dyn FnMut()>,
}
impl Interval {
pub(crate) fn new(ms: i32, f: impl FnMut() + 'static) -> Option<Self> {
let window = web_sys::window()?;
let closure = Closure::wrap(Box::new(f) as Box<dyn FnMut()>);
let id = window
.set_interval_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
ms.max(1),
)
.ok()?;
Some(Self {
id,
_closure: closure,
})
}
}
impl Drop for Interval {
fn drop(&mut self) {
if let Some(window) = web_sys::window() {
window.clear_interval_with_handle(self.id);
}
}
}
pub(crate) fn after(ms: i32, f: impl FnOnce() + 'static) {
let Some(window) = web_sys::window() else {
return;
};
let callback = wasm_bindgen::closure::Closure::once_into_js(f);
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.unchecked_ref(),
ms.max(0),
);
}
}
#[cfg(not(web))]
type SubHandle = async_executor::Task<()>;
#[cfg(web)]
type SubHandle = Option<web_timer::Interval>;
const SCROLL_SPEED: f32 = 40.0;
const TOUCH_SLOP: f32 = 18.0;
const PRECISE_SLOP: f32 = 1.0;
fn fling_velocity(estimate: VelocityEstimate, slop: f32) -> (f32, f32) {
let gate = |velocity: f32, travelled: f32| {
if travelled.abs() > slop {
velocity
} else {
0.0
}
};
(
gate(estimate.velocity.x, estimate.offset.0),
gate(estimate.velocity.y, estimate.offset.1),
)
}
const SCROLL_OVER: f32 = 48.0;
const PAN_FLING_MIN: f32 = 80.0;
const BACK_EDGE: f32 = 24.0;
mod drag_preview {
pub const SHADOW_OFFSET_Y: f32 = 4.0;
pub const SHADOW_BLUR: f32 = 12.0;
pub const SHADOW_ALPHA: f32 = 0.28;
pub const BORDER_ALPHA: f32 = 0.9;
pub const BORDER_WIDTH: f32 = 1.5;
pub const LIFT_Y: f32 = -2.0;
pub const INSERT_THICKNESS: f32 = 3.0;
}
fn frame_needs_build(dirty: bool, animating: bool, was_animating: bool, otherwise: bool) -> bool {
dirty || animating || was_animating || otherwise
}
fn hold_candidates<Msg: Clone>(
drag: Option<&Drag>,
ui: Option<&frus_widgets::Ui<Msg>>,
tree: Option<&dyn frus_widgets::Widget<Msg>>,
at: Point,
) -> (Option<Msg>, Option<frus_widgets::DragSource>) {
if matches!(drag, Some(Drag::Back { .. })) {
return (None, None);
}
let Some(ui) = ui else {
return (None, None);
};
let free = matches!(
drag,
None | Some(Drag::Scroll { moved: false, .. }) | Some(Drag::Gesture { moved: false, .. })
);
let long_press = if free { ui.long_press_at(at) } else { None };
let lift = ui.drag_source_at(at).filter(|source| {
tree.and_then(|tree| find_widget(tree, source.id))
.is_some_and(|widget| widget.drag_needs_long_press())
});
(long_press, lift)
}
fn drag_after_hold(
drag: Option<Drag>,
lift: Option<frus_widgets::DragSource>,
reorder: Option<(WidgetId, usize, Point)>,
cursor: Point,
) -> Option<Drag> {
if let Some(Drag::Back { start_x }) = drag {
return Some(Drag::Back { start_x });
}
if let Some(source) = lift {
Some(Drag::Item {
source,
start: cursor,
moved: true,
over: None,
})
} else if let Some((id, from, start)) = reorder {
Some(Drag::Reorder {
id,
from,
start,
moved: true,
carried: None,
})
} else {
None
}
}
enum Drag {
Scrollbar {
id: WidgetId,
vertical: bool,
grab: f32,
track_start: f32,
track_len: f32,
thumb_len: f32,
max: f32,
reverse: bool,
},
RegionSelect { area: WidgetId },
RegionHandle {
area: WidgetId,
handle: crate::selection::Handle,
grab: Point,
},
TextSelect {
id: WidgetId,
rect: frus_widgets::Rect,
},
SelectionHandle {
id: WidgetId,
rect: frus_widgets::Rect,
handle: crate::selection::Handle,
grab: Point,
},
Widget {
id: WidgetId,
rect: frus_widgets::Rect,
last_x: f32,
},
Reorder {
id: WidgetId,
from: usize,
start: Point,
moved: bool,
carried: Option<(WidgetId, Rect)>,
},
Pan {
id: WidgetId,
last: Point,
moved: bool,
viewport: frus_widgets::Rect,
},
Scroll {
id: WidgetId,
last: Point,
moved: bool,
carried: (f32, f32),
dismiss: Option<frus_widgets::Dismissable>,
pan: Option<(WidgetId, frus_widgets::PanAxis)>,
axis: Option<bool>,
},
Gesture {
id: WidgetId,
axis: frus_widgets::PanAxis,
start: Point,
last: Point,
moved: bool,
},
Dismiss {
item: frus_widgets::Dismissable,
last: Point,
moved: bool,
},
Item {
source: frus_widgets::DragSource,
start: Point,
moved: bool,
over: Option<WidgetId>,
},
Sheet {
id: WidgetId,
last: Point,
moved: bool,
available: f32,
},
Back { start_x: f32 },
}
fn install_ambient<A: Application>(app: &A, preferred: &[frus_widgets::Locale]) {
frus_widgets::ScrollConfiguration::set_root(app.scroll_behavior());
frus_widgets::locale::install(app.resolved_locale(preferred));
if let Some(table) = app.localizations() {
frus_widgets::localizations::install(table);
}
}
fn build_view<A: Application>(
app: &A,
theme: &Theme,
runtime: &frus_widgets::Runtime,
) -> Box<dyn Widget<A::Message>> {
debug_assert!(
frus_widgets::MediaQuery::of().is_described(),
"a view is being built with no surface described: install one first — milestone 416"
);
runtime.states.begin_build();
let tree = app.view(theme);
build_deferred(tree.as_ref(), theme, runtime);
tree
}
struct Mailbox<M: 'static>(Option<EventLoopProxy<M>>);
impl<M: 'static> Clone for Mailbox<M> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<M: 'static> Mailbox<M> {
fn send_event(&self, message: M) -> Result<(), ()> {
match &self.0 {
Some(proxy) => proxy.send_event(message).map_err(|_| ()),
None => Err(()),
}
}
}
pub struct App<A: Application> {
app: A,
proxy: Mailbox<A::Message>,
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
#[cfg(web)]
pending_renderer: std::rc::Rc<std::cell::RefCell<Option<Renderer>>>,
#[cfg(web)]
history: crate::history::History,
#[cfg(web)]
address: (crate::LocationStrategy, String),
#[cfg(desktop)]
a11y: Option<crate::a11y::A11y>,
#[cfg(web)]
a11y: Option<crate::a11y_web::A11y>,
ui: Option<Ui<A::Message>>,
tree: Option<Box<dyn Widget<A::Message>>>,
cursor: Point,
hover: crate::hover::Hover,
scale: f32,
last_size: Option<(f32, f32)>,
runtime: Runtime,
themes: crate::theming::ThemeFade,
reload: Option<crate::reload::ReloadWatcher>,
reported_overflows: std::cell::RefCell<std::collections::HashSet<u64>>,
inspector: bool,
inspector_dump: bool,
shift: bool,
ctrl: bool,
alt: bool,
meta: bool,
goal_x: Option<f32>,
clipboard: clip::Clipboard,
started: bool,
last_frame: Option<Instant>,
build_dirty: bool,
app_was_animating: bool,
drag: Option<Drag>,
pointer_touch: bool,
gesture_velocity: VelocityTracker,
gesture_clock: Option<(f32, f32)>,
gesture_start: Instant,
reorder_x: f32,
reorder_y: f32,
#[cfg(any(desktop, web))]
announce: String,
press: PressRecognizer,
long_press_msg: Option<A::Message>,
pending_word: Option<WidgetId>,
pending_region_hold: bool,
caret: crate::caret::CaretBlink,
#[cfg(android)]
autofill_reported: Vec<crate::autofill::AutofillField>,
#[cfg(android)]
autofill_focus: Option<WidgetId>,
#[cfg(android)]
autofill_group: Option<WidgetId>,
last_edit_at: Option<Instant>,
pending_lift: Option<frus_widgets::DragSource>,
pending_reorder: Option<(WidgetId, usize, Point)>,
last_click_time: Option<Instant>,
last_region_click: Option<(Instant, Point)>,
pending_tap: Option<PendingTap<A::Message>>,
task_arrivals: u64,
entered: Vec<(WidgetId, Rect)>,
pointer_held: bool,
captured: Vec<(WidgetId, Rect)>,
leaving_counter: u64,
running_subs: HashMap<u64, SubHandle>,
pending_focus: Vec<u64>,
pending_scroll: Vec<(u64, ScrollTo)>,
retry_scroll: Vec<(u64, ScrollTo)>,
pending_sheet: Vec<(u64, SheetTo)>,
retry_sheet: Vec<(u64, SheetTo)>,
focus_history: Vec<WidgetId>,
prev_focus: Option<WidgetId>,
autofocused: std::collections::HashSet<WidgetId>,
frame_stats: crate::frame_stats::FrameStats,
frame_costs: crate::frame_stats::FrameCosts,
occluded: bool,
elapsed: f32,
last_insets: WindowInsets,
#[cfg_attr(not(windows), allow(dead_code))]
title_bar_on: bool,
title_bar: Option<frus_widgets::TitleBar>,
#[cfg_attr(not(windows), allow(dead_code))]
window_icon: Option<&'static [u8]>,
reveal_after_keyboard: bool,
platform: PlatformSettings,
inset_baseline: Option<(Insets, (u32, u32))>,
lifecycle: Lifecycle,
#[cfg(android)]
android_app: Option<winit::platform::android::activity::AndroidApp>,
#[cfg(android)]
soft_input_shown: bool,
#[cfg(android)]
system_bars: Option<frus_widgets::SystemBars>,
}
impl<A: Application> App<A> {
pub fn new(app: A, proxy: EventLoopProxy<A::Message>) -> Self {
Self::with_mailbox(app, Mailbox(Some(proxy)))
}
#[cfg(any(test, feature = "testing"))]
fn detached(app: A) -> Self {
let mut shell = Self::with_mailbox(app, Mailbox(None));
shell.clipboard = clip::Clipboard::in_memory();
shell
}
fn with_mailbox(app: A, proxy: Mailbox<A::Message>) -> Self {
#[cfg(feature = "net")]
frus_widgets::set_image_fetcher(fetch_image_bytes);
#[cfg(not(web))]
frus_widgets::set_task_spawner(|task| crate::runtime::spawn(task).detach());
#[cfg(web)]
frus_widgets::set_task_spawner(|task| wasm_bindgen_futures::spawn_local(task));
Self {
app,
proxy,
window: None,
renderer: None,
#[cfg(web)]
pending_renderer: std::rc::Rc::new(std::cell::RefCell::new(None)),
#[cfg(web)]
history: crate::history::History::new(),
#[cfg(web)]
address: (crate::LocationStrategy::Hash, "/".to_string()),
#[cfg(desktop)]
a11y: None,
#[cfg(web)]
a11y: None,
ui: None,
tree: None,
cursor: Point::new(0.0, 0.0),
hover: crate::hover::Hover::default(),
scale: 1.0,
last_size: None,
runtime: Runtime::default(),
themes: crate::theming::ThemeFade::default(),
reload: crate::reload::ReloadWatcher::new(),
reported_overflows: std::cell::RefCell::new(std::collections::HashSet::new()),
inspector: false,
inspector_dump: false,
shift: false,
ctrl: false,
alt: false,
meta: false,
goal_x: None,
clipboard: clip::Clipboard::new(),
started: false,
last_frame: None,
build_dirty: true,
app_was_animating: false,
drag: None,
pointer_touch: false,
gesture_velocity: VelocityTracker::default(),
gesture_clock: None,
gesture_start: Instant::now(),
reorder_x: 0.0,
reorder_y: 0.0,
#[cfg(any(desktop, web))]
announce: String::new(),
press: PressRecognizer::new(),
long_press_msg: None,
pending_word: None,
pending_region_hold: false,
caret: crate::caret::CaretBlink::new(),
#[cfg(android)]
autofill_reported: Vec::new(),
#[cfg(android)]
autofill_focus: None,
#[cfg(android)]
autofill_group: None,
last_edit_at: None,
pending_lift: None,
pending_reorder: None,
last_click_time: None,
last_region_click: None,
pending_tap: None,
task_arrivals: 0,
entered: Vec::new(),
pointer_held: false,
captured: Vec::new(),
leaving_counter: 0,
running_subs: HashMap::new(),
pending_focus: Vec::new(),
pending_scroll: Vec::new(),
retry_scroll: Vec::new(),
pending_sheet: Vec::new(),
retry_sheet: Vec::new(),
focus_history: Vec::new(),
prev_focus: None,
autofocused: std::collections::HashSet::new(),
frame_stats: crate::frame_stats::FrameStats::new(),
frame_costs: crate::frame_stats::FrameCosts::default(),
occluded: false,
elapsed: 0.0,
last_insets: WindowInsets::ZERO,
title_bar_on: false,
title_bar: None,
window_icon: None,
reveal_after_keyboard: false,
platform: PlatformSettings::default(),
inset_baseline: None,
lifecycle: Lifecycle::Detached,
#[cfg(android)]
android_app: None,
#[cfg(android)]
soft_input_shown: false,
#[cfg(android)]
system_bars: None,
}
}
#[cfg(any(desktop, web))]
fn activate_for_assistive_tech(&mut self, id: WidgetId) {
if let Some(action) = self.ui.as_ref().and_then(|ui| ui.edit_action_for(id)) {
self.perform_edit_action(action);
return;
}
if let Some(msg) = self.ui.as_ref().and_then(|ui| ui.msg_for(id)) {
if self.ui.as_ref().is_some_and(|ui| ui.hit_is_on_toolbar(id)) {
self.runtime.selection_handles = None;
self.hide_selection_toolbar();
}
self.dispatch(msg);
self.request_redraw();
}
}
#[cfg(desktop)]
fn drain_a11y_actions(&mut self) {
use crate::a11y::A11yAction;
let actions = match self.a11y.as_ref() {
Some(a11y) => a11y.take_actions(),
None => return,
};
for action in actions {
match action {
A11yAction::Click(id) => self.activate_for_assistive_tech(id),
A11yAction::Focus(id) => {
if self.ui.as_ref().is_some_and(|ui| ui.hit_is_on_toolbar(id)) {
continue;
}
self.runtime.input.focused = Some(id);
self.runtime.focus_visible = true;
self.request_redraw();
}
}
}
}
#[cfg(web)]
fn drain_a11y_actions(&mut self) {
use crate::a11y_web::A11yAction;
let actions = match self.a11y.as_ref() {
Some(a11y) => a11y.take_actions(),
None => return,
};
for action in actions {
match action {
A11yAction::Click(id) => self.activate_for_assistive_tech(id),
A11yAction::Focus(id) => {
if self.ui.as_ref().is_some_and(|ui| ui.hit_is_on_toolbar(id)) {
continue;
}
self.runtime.input.focused = Some(id);
self.runtime.focus_visible = true;
self.request_redraw();
}
}
}
}
fn sync_system_bars(&mut self, theme: &Theme) {
#[cfg(android)]
{
let Some(ui) = self.ui.as_ref() else {
return;
};
let Some(window) = self.window.as_ref() else {
return;
};
let scale = self.total_scale();
let size = window.inner_size();
let (width, height) = (size.width as f32 / scale, size.height as f32 / scale);
let edges = self.last_insets.padding;
let bars = ui
.system_ui_style(
frus_widgets::Point::new(width / 2.0, edges.top + 0.5),
frus_widgets::Point::new(width / 2.0, height - edges.bottom - 0.5),
)
.resolve(theme);
if self.system_bars != Some(bars) && crate::android_system_bars::apply(bars) {
self.system_bars = Some(bars);
}
}
#[cfg(not(android))]
let _ = theme;
}
fn sync_soft_input(&mut self) {
#[cfg(android)]
{
let editing = self
.runtime
.input
.focused
.and_then(|id| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
})
.is_some_and(wants_keyboard);
if editing != self.soft_input_shown {
self.soft_input_shown = editing;
self.end_composition();
if crate::android_ime::installed() {
if editing {
if let Some(id) = self.runtime.input.focused {
self.push_ime_context(id);
}
crate::android_ime::start_input(self.focused_ime());
} else {
crate::android_ime::clear_editor_state();
crate::android_ime::stop_input();
}
} else if let Some(app) = &self.android_app {
if editing {
app.show_soft_input(true);
} else {
app.hide_soft_input(false);
}
}
}
}
}
#[cfg(android)]
fn focused_ime(&self) -> frus_widgets::Ime {
self.runtime
.input
.focused
.and_then(|id| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
})
.map(|widget| widget.ime())
.unwrap_or_default()
}
fn set_lifecycle(&mut self, state: Lifecycle) {
if self.lifecycle != state {
self.lifecycle = state;
self.app.on_lifecycle(state);
}
}
fn request_soft_input(&mut self) {
#[cfg(android)]
{
let typing = self
.runtime
.input
.focused
.and_then(|id| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
})
.is_some_and(wants_keyboard);
if !typing {
return;
}
self.soft_input_shown = true;
self.end_composition();
if crate::android_ime::installed() {
if let Some(id) = self.runtime.input.focused {
self.push_ime_context(id);
}
crate::android_ime::start_input(self.focused_ime());
} else if let Some(app) = &self.android_app {
app.show_soft_input(true);
}
}
}
#[cfg(android)]
fn drain_ime(&mut self) {
use crate::ime::Step;
let events = crate::android_ime::drain();
if events.is_empty() {
return;
}
let Some(focused) = self.runtime.input.focused else {
return;
};
for event in events {
let edit = self
.runtime
.edits
.get(&focused)
.copied()
.unwrap_or_default();
let text = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), focused))
.and_then(|widget| widget.text_value().map(str::to_owned))
.unwrap_or_default();
for step in crate::ime::plan(&event, &edit, &text) {
match step {
Step::Place { cursor, anchor } => {
let edit = self.runtime.edits.entry(focused).or_default();
edit.cursor = cursor;
edit.anchor = anchor;
}
Step::Key(key) => self.apply_key(focused, key),
Step::Compose(region) => {
if let Some(edit) = self.runtime.edits.get_mut(&focused) {
edit.composing = region;
}
}
Step::ComposeTo(start) => {
if let Some(edit) = self.runtime.edits.get_mut(&focused) {
let end = edit.cursor;
edit.composing = (end > start).then_some((start, end));
}
}
}
}
}
self.push_ime_context(focused);
self.request_redraw();
}
#[cfg(android)]
fn sync_autofill(&mut self) {
use crate::{android_autofill as platform, autofill};
if !crate::android_ime::installed() {
return;
}
let scale = self.total_scale();
let (Some(ui), Some(tree)) = (self.ui.as_ref(), self.tree.as_ref()) else {
return;
};
let fields_of = |id: WidgetId| {
autofill::structure(&ui.form_of(id), scale, |widget| {
find_widget(tree.as_ref(), widget).map(|w| {
(
w.autofill_hints(),
w.text_value().unwrap_or_default().to_owned(),
)
})
})
};
let focus = self.runtime.input.focused.filter(|&id| {
find_widget(tree.as_ref(), id).is_some_and(|w| !w.autofill_hints().is_empty())
});
if focus != self.autofill_focus {
if let Some(left) = self.autofill_focus {
platform::exit(autofill::virtual_id(left));
}
if let Some(entered) = focus {
let group = ui.form_group(entered);
let shown: &[autofill::AutofillField] =
if group.is_some() && group == self.autofill_group {
&self.autofill_reported
} else {
&[]
};
let fields = autofill::with_gone(fields_of(entered), shown);
platform::publish(&fields);
platform::enter(autofill::virtual_id(entered));
self.autofill_reported = fields;
self.autofill_group = group;
}
self.autofill_focus = focus;
} else if let Some(id) = focus {
let fields = autofill::with_gone(fields_of(id), &self.autofill_reported);
for (virtual_id, value) in autofill::changed(&self.autofill_reported, &fields) {
platform::value_changed(virtual_id, &value);
}
self.autofill_reported = fields;
}
if let Some(group) = self.autofill_group {
if ui.group_stops(group).is_empty() {
platform::commit();
self.autofill_group = None;
self.autofill_reported.clear();
}
}
}
#[cfg(android)]
fn drain_autofill(&mut self) {
let fills = crate::android_autofill::drain();
if fills.is_empty() {
return;
}
for (widget, value) in crate::autofill::route(&self.autofill_reported, fills) {
let message = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), widget))
.and_then(|w| w.replace_value(value.clone()));
if let Some(message) = message {
self.runtime.edits.insert(
widget,
Edit {
cursor: value.chars().count(),
anchor: None,
composing: None,
},
);
self.dispatch_edit(message);
}
}
if let Some(id) = self.runtime.input.focused {
self.push_ime_context(id);
}
self.request_redraw();
}
#[cfg(android)]
fn push_ime_context(&self, id: WidgetId) {
let value = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.text_value().map(|s| s.to_string()));
if let Some(text) = value {
let edit = self.runtime.edits.get(&id).copied().unwrap_or_default();
crate::android_ime::set_editor_state(&text, edit.cursor, edit.selection_range());
let len = text.chars().count();
let at = |i: usize| crate::ime::utf16_index(&text, i.min(len)) as i32;
let (start, end) = edit.selection_range().unwrap_or((edit.cursor, edit.cursor));
let (cand_start, cand_end) = edit
.composing
.map(|(s, e)| (at(s), at(e)))
.unwrap_or((-1, -1));
crate::android_ime::update_selection(at(start), at(end), cand_start, cand_end);
}
}
#[cfg(android)]
fn end_composition(&mut self) {
if let Some(id) = self.runtime.input.focused {
if let Some(edit) = self.runtime.edits.get_mut(&id) {
edit.composing = None;
}
}
}
#[cfg(android)]
pub(crate) fn set_android_app(
&mut self,
android_app: winit::platform::android::activity::AndroidApp,
) {
self.android_app = Some(android_app);
}
fn compute_insets(&self, phys_w: u32, phys_h: u32, scale: f32) -> Insets {
#[cfg(android)]
if let Some(app) = &self.android_app {
let r = app.content_rect();
if r.right > r.left && r.bottom > r.top {
let left = r.left.max(0) as f32;
let top = r.top.max(0) as f32;
let right = (phys_w as i32 - r.right).max(0) as f32;
let bottom = (phys_h as i32 - r.bottom).max(0) as f32;
return Insets::new(top / scale, right / scale, bottom / scale, left / scale);
}
}
let _ = (phys_w, phys_h, scale);
Insets::ZERO
}
fn refresh_platform_settings(&mut self) {
#[cfg(android)]
if let Some(app) = &self.android_app {
let read = crate::android_settings::read(app);
if read != self.platform {
self.platform = read;
self.build_dirty = true;
}
return;
}
let brightness = self
.window
.as_ref()
.and_then(|window| window.theme())
.map(|theme| match theme {
winit::window::Theme::Dark => frus_widgets::Brightness::Dark,
winit::window::Theme::Light => frus_widgets::Brightness::Light,
})
.unwrap_or_default();
if brightness != self.platform.brightness {
self.platform.brightness = brightness;
self.build_dirty = true;
}
if self.platform.locales.is_empty() {
let read = platform_locales();
if !read.is_empty() {
self.platform.locales = read;
self.build_dirty = true;
}
}
}
}
#[cfg(android)]
fn platform_locales() -> Vec<frus_widgets::Locale> {
Vec::new()
}
#[cfg(not(android))]
fn platform_locales() -> Vec<frus_widgets::Locale> {
#[cfg(not(web))]
{
sys_locale::get_locales()
.filter_map(|tag| frus_widgets::Locale::parse(&tag))
.collect()
}
#[cfg(web)]
{
let Some(navigator) = web_sys::window().map(|window| window.navigator()) else {
return Vec::new();
};
let listed: Vec<frus_widgets::Locale> = navigator
.languages()
.iter()
.filter_map(|value| value.as_string())
.filter_map(|tag| frus_widgets::Locale::parse(&tag))
.collect();
if listed.is_empty() {
return navigator
.language()
.and_then(|tag| frus_widgets::Locale::parse(&tag))
.into_iter()
.collect();
}
listed
}
}
impl<A: Application> ApplicationHandler<A::Message> for App<A> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
self.set_lifecycle(Lifecycle::Resumed);
self.refresh_platform_settings();
let mut attributes = Window::default_attributes()
.with_title(self.app.title())
.with_min_inner_size(winit::dpi::LogicalSize::new(360.0, 280.0));
#[cfg(desktop)]
{
attributes = attributes.with_visible(false);
}
#[cfg(desktop)]
{
let icon = crate::icon::window_icon(&self.app.icon());
#[cfg(windows)]
{
use winit::platform::windows::WindowAttributesExtWindows;
attributes = attributes.with_taskbar_icon(icon.clone());
}
attributes = attributes.with_window_icon(icon);
}
#[cfg(not(web))]
if let Some((w, h)) = self.app.window_size() {
attributes = attributes.with_inner_size(winit::dpi::LogicalSize::new(w, h));
}
#[cfg(web)]
{
use winit::platform::web::WindowAttributesExtWebSys;
attributes = attributes.with_append(true);
}
let window = match event_loop.create_window(attributes) {
Ok(window) => Arc::new(window),
Err(err) => {
log::error!("failed to create the window: {err}");
event_loop.exit();
return;
}
};
#[cfg(web)]
{
self.scale = window.scale_factor() as f32;
self.window = Some(window.clone());
self.a11y = crate::a11y_web::A11y::new(&window);
crate::web_keys::let_the_browser_keep_its_shortcuts();
crate::web_icon::apply(&self.app.icon());
self.build_dirty = true;
if !self.started {
self.started = true;
let command = self.app.init();
self.run_command(command);
self.sync_subscriptions();
self.open_launch_address(&window);
}
let slot = self.pending_renderer.clone();
let win = window.clone();
let size = window.inner_size();
wasm_bindgen_futures::spawn_local(async move {
match Renderer::new(win.clone(), size.width.max(1), size.height.max(1)).await {
Ok(r) => {
*slot.borrow_mut() = Some(r);
win.request_redraw();
}
Err(err) => log::error!("failed to initialise the renderer (Web): {err:#}"),
}
});
}
#[cfg(not(web))]
{
let size = window.inner_size();
let (width, height) = (size.width.max(1), size.height.max(1));
let see_through = if cfg!(windows) {
match pollster::block_on(Renderer::see_through(window.clone(), width, height)) {
Ok(renderer) => Some(renderer),
Err(err) => {
log::info!("title bar painted by frus: {err:#}");
None
}
}
} else {
None
};
let renderer = match see_through {
Some(renderer) => Ok(renderer),
None => pollster::block_on(Renderer::new(window.clone(), width, height)),
};
match renderer {
Ok(renderer) => {
self.scale = window.scale_factor() as f32;
#[cfg(desktop)]
{
self.a11y = Some(crate::a11y::A11y::new(event_loop, &window));
window.set_visible(true);
crate::instance::set_window(window.clone());
}
self.window = Some(window.clone());
#[cfg(android)]
crate::android_ime::set_window(Some(window.clone()));
self.renderer = Some(renderer);
self.build_dirty = true;
if !self.started {
self.started = true;
let command = self.app.init();
self.run_command(command);
self.sync_subscriptions();
#[cfg(any(android, desktop))]
self.open_launch_link();
}
window.request_redraw();
}
Err(err) => {
log::error!("failed to initialise the renderer: {err:#}");
event_loop.exit();
}
}
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
self.set_lifecycle(Lifecycle::Paused);
self.renderer = None;
self.window = None;
#[cfg(android)]
crate::android_ime::set_window(None);
self.last_frame = None;
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
self.set_lifecycle(Lifecycle::Detached);
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, message: A::Message) {
self.dispatch(message);
self.request_redraw();
}
fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) {
if matches!(cause, StartCause::ResumeTimeReached { .. }) && self.press.poll(Instant::now())
{
self.hold_deadline_reached();
}
if matches!(cause, StartCause::ResumeTimeReached { .. }) {
self.poll_pending_tap(Instant::now());
}
if matches!(cause, StartCause::ResumeTimeReached { .. })
&& self.lifecycle == Lifecycle::Resumed
&& self.caret.next_toggle(Instant::now()).is_some()
{
self.request_redraw();
}
#[cfg(android)]
self.drain_ime();
#[cfg(android)]
self.drain_autofill();
#[cfg(desktop)]
self.drain_a11y_actions();
#[cfg(web)]
self.drain_a11y_actions();
if let Some(watcher) = self.reload.as_mut() {
if watcher.binary_changed() {
watcher.handoff(self.app.save_state());
}
}
event_loop.set_control_flow(self.idle_control_flow());
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
#[cfg(desktop)]
if let (Some(a11y), Some(window)) = (self.a11y.as_mut(), self.window.as_ref()) {
a11y.process_event(window, &event);
}
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Focused(focused) => {
if !matches!(self.lifecycle, Lifecycle::Paused | Lifecycle::Detached) {
self.set_lifecycle(if focused {
Lifecycle::Resumed
} else {
Lifecycle::Inactive
});
}
#[cfg(windows)]
if self.title_bar_on {
self.request_redraw();
}
}
WindowEvent::Resized(size) => {
if let Some(renderer) = self.renderer.as_mut() {
renderer.resize(size.width, size.height);
}
if let Some(window) = &self.window {
window.request_redraw();
}
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.scale = scale_factor as f32;
if let Some(window) = &self.window {
let size = window.inner_size();
if let Some(renderer) = self.renderer.as_mut() {
renderer.resize(size.width, size.height);
}
}
self.request_redraw();
}
WindowEvent::Occluded(occluded) => {
self.occluded = occluded;
if !occluded {
self.request_redraw();
}
}
WindowEvent::CursorMoved { position, .. } => {
let scale = self.total_scale();
let position = Point::new(position.x as f32 / scale, position.y as f32 / scale);
self.pointer(
event_loop,
PointerEvent {
kind: PointerKind::Move,
position,
touch: false,
},
);
}
WindowEvent::CursorLeft { .. } => {
self.hover.left();
self.sync_hover();
self.sync_regions(false);
}
WindowEvent::Touch(touch) => {
let scale = self.total_scale();
let position = Point::new(
touch.location.x as f32 / scale,
touch.location.y as f32 / scale,
);
let kind = match touch.phase {
TouchPhase::Started => PointerKind::Down,
TouchPhase::Moved => PointerKind::Move,
TouchPhase::Ended => PointerKind::Up,
TouchPhase::Cancelled => PointerKind::Cancel,
};
self.pointer(
event_loop,
PointerEvent {
kind,
position,
touch: true,
},
);
}
WindowEvent::ModifiersChanged(modifiers) => {
let state = modifiers.state();
self.shift = state.shift_key();
self.ctrl = state.control_key();
self.alt = state.alt_key();
self.meta = state.super_key();
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} => self.pointer(
event_loop,
PointerEvent {
kind: PointerKind::Down,
position: self.cursor,
touch: false,
},
),
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => self.pointer(
event_loop,
PointerEvent {
kind: PointerKind::Up,
position: self.cursor,
touch: false,
},
),
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Right,
..
} => self.context_click(),
WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
self.key_down(&KeyDown::from(&event), Some(event_loop));
}
WindowEvent::MouseWheel { delta, .. } => {
let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x * SCROLL_SPEED, y * SCROLL_SPEED),
MouseScrollDelta::PixelDelta(pos) => {
let scale = self.total_scale();
(pos.x as f32 / scale, pos.y as f32 / scale)
}
};
if self.shift {
dx = dy;
dy = 0.0;
}
if let Some((id, viewport)) = self
.ui
.as_ref()
.and_then(|ui| ui.interactive_at(self.cursor))
{
let (min, max) = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|w| w.interactive())
.unwrap_or((0.5, 4.0));
let factor = (1.0 + dy * 0.1 / SCROLL_SPEED).clamp(0.2, 5.0);
self.runtime.interactive_velocity.remove(&id);
let view = self.runtime.interactive.entry(id).or_default();
*view = view
.zoom_at(factor, self.cursor, min, max)
.clamped(viewport);
self.request_redraw();
return;
}
let hit = {
let scroll = &self.runtime.scroll;
self.ui.as_ref().and_then(|ui| {
ui.scroll_chain(self.cursor).find(|area| {
area.accepts_user_offset(
scroll.get(&area.id).copied().unwrap_or((0.0, 0.0)),
)
})
})
};
if let Some(area) = hit {
let id = area.id;
let physics = area.physics;
let over = if physics.allows_overscroll() {
SCROLL_OVER
} else {
0.0
};
self.runtime.stop_scroll_fling(id);
let current = self.runtime.scroll.get(&id).copied().unwrap_or((0.0, 0.0));
let (ddx, ddy) = area.offset_delta((dx, dy));
let target = self.runtime.scroll_target.entry(id).or_insert(current);
let (wanted_x, wanted_y) = (target.0 + ddx, target.1 + ddy);
target.0 = wanted_x.clamp(-over, area.max_x + over);
target.1 = wanted_y.clamp(-over, area.max_y + over);
let refused = (wanted_x - target.0, wanted_y - target.1);
self.runtime.scroll_velocity.entry(id).or_insert((0.0, 0.0));
let cursor = self.cursor;
for (refused, vertical, extent) in [
(refused.0, false, area.viewport.width),
(refused.1, true, area.viewport.height),
] {
if refused.abs() < 1e-3 {
continue;
}
let edge = area.refused_edge(vertical, refused);
let (offset, cross) =
frus_widgets::glow_cross_axis(area.viewport, edge, cursor);
self.runtime.glow_pull(
id,
area.overscroll,
edge,
refused,
extent,
offset,
cross,
);
self.runtime.glow_scroll_end(id);
}
self.request_redraw();
}
}
WindowEvent::RedrawRequested => {
let measuring = self.frame_stats.enabled();
let frame_start = measuring.then(Instant::now);
self.frame_costs = crate::frame_stats::FrameCosts::default();
#[cfg(web)]
if self.renderer.is_none() {
match self.pending_renderer.borrow_mut().take() {
Some(mut renderer) => {
if let Some(window) = &self.window {
let size = window.inner_size();
renderer.resize(size.width.max(1), size.height.max(1));
}
self.renderer = Some(renderer);
}
None => return,
}
}
while let Some(pasted) = self.clipboard.take_answered() {
self.land_paste(pasted);
}
if self
.runtime
.selection_toolbar
.is_some_and(|mark| self.runtime.input.focused != Some(mark.id))
{
self.runtime.selection_toolbar = None;
}
if self.occluded {
return;
}
let size = self
.window
.as_ref()
.map(|w| w.inner_size())
.unwrap_or_default();
if size.width == 0 || size.height == 0 {
self.last_frame = None; return;
}
let scale = self.total_scale();
let (width, height) = (size.width as f32 / scale, size.height as f32 / scale);
if self.last_size != Some((width, height)) {
self.last_size = Some((width, height));
self.build_dirty = true;
self.app.on_resize(width, height);
}
let raw = self.compute_insets(size.width, size.height, scale);
let phys = (size.width, size.height);
let mut baseline = match self.inset_baseline {
Some((b, s)) if s == phys => b,
_ => {
self.inset_baseline = Some((raw, phys));
raw
}
};
if raw.bottom < baseline.bottom {
baseline = raw;
self.inset_baseline = Some((raw, phys));
}
let insets = WindowInsets::from_baseline(baseline, raw);
if self.last_insets != insets {
if insets.view_insets.bottom > self.last_insets.view_insets.bottom {
self.reveal_after_keyboard = true;
}
self.last_insets = insets;
self.build_dirty = true;
self.app.on_insets(insets);
}
let now = Instant::now();
let dt = self
.last_frame
.map(|prev| (now - prev).as_secs_f32().min(0.05))
.unwrap_or(0.0);
self.last_frame = Some(now);
self.elapsed += dt;
self.runtime.time = self.elapsed;
let caret_now = Instant::now();
let caret = if self.lifecycle == Lifecycle::Resumed {
self.caret_signature()
} else {
None
};
self.caret.observe(caret_now, caret);
self.runtime.caret_hidden = self.caret.hidden(caret_now);
let mut app_animating = self.app.tick(dt);
let settings = self
.platform
.accessibility
.with_overrides(self.app.accessibility());
install_ambient(&self.app, &self.platform.locales);
let theme_moved = self.themes.advance(
&self.app,
self.platform.brightness,
settings.high_contrast,
settings.disable_animations,
dt,
);
app_animating |= theme_moved | self.themes.animating();
let theme = self.themes.displayed(&self.app);
let _surface = self.media_query(width, height).install();
self.runtime.still = settings.disable_animations;
self.runtime.scrollbar_hovered = self
.ui
.as_ref()
.and_then(|ui| ui.scrollbar_near(self.cursor))
.map(|bar| bar.id);
self.runtime.scrollbar_dragged = match self.drag {
Some(Drag::Scrollbar { id, .. }) => Some(id),
_ => None,
};
let asked = self.app.effects();
self.run_command(asked);
#[cfg(web)]
self.sync_address();
#[cfg(desktop)]
self.open_links_from_other_instances();
let arrived = self.take_task_arrivals();
if frus_widgets::take_rebuild_request() || arrived {
self.build_dirty = true;
}
let need_build = frame_needs_build(
self.build_dirty,
app_animating,
std::mem::replace(&mut self.app_was_animating, app_animating),
self.tree.is_none() || self.runtime.switching(),
);
if need_build {
let build_start = measuring.then(Instant::now);
let tree = build_view(&self.app, &theme, &self.runtime);
if let Some(at) = build_start {
self.frame_costs.build = Some(at.elapsed().as_secs_f32() * 1000.0);
}
let ids = collect_ids(tree.as_ref());
let present: std::collections::HashSet<_> = ids.iter().copied().collect();
let leaving: std::collections::HashSet<u64> = self
.runtime
.mounted
.iter()
.filter(|id| !present.contains(id))
.map(|id| id.as_u64())
.collect();
if !leaving.is_empty() {
if let Some(ui) = &self.ui {
let captured: Vec<_> = ui
.scene()
.primitives()
.iter()
.filter(|p| leaving.contains(&p.owner()))
.cloned()
.collect();
if !captured.is_empty() {
self.runtime
.leaving
.insert(self.leaving_counter, (captured, 1.0));
self.leaving_counter = self.leaving_counter.wrapping_add(1);
}
}
}
for &id in &ids {
if self.runtime.mounted.insert(id) {
self.runtime.anims.entry(id).or_default().opacity = 0.0;
}
}
self.runtime.mounted.retain(|id| present.contains(id));
self.tree = Some(tree);
self.sync_subscriptions();
self.runtime.paint_cache.borrow_mut().bump_generation();
}
self.build_dirty = false;
if !self.pending_focus.is_empty() {
let keys = std::mem::take(&mut self.pending_focus);
let ids: Vec<WidgetId> = self
.tree
.as_deref()
.map(|tree| keys.iter().filter_map(|&k| find_by_key(tree, k)).collect())
.unwrap_or_default();
if let Some(&id) = ids.last() {
self.runtime.input.focused = Some(id);
self.runtime.focus_visible = true;
}
}
let autoscrolling = self.autoscroll_carried(dt);
let reorder_animating = self.advance_reorder_springs(dt);
let tree = self
.tree
.as_deref()
.expect("the view was built at least once");
let scroll_regions = self
.ui
.as_ref()
.map(|ui| ui.scroll_regions().to_vec())
.unwrap_or_default();
let refresh_areas = self
.ui
.as_ref()
.map(|ui| ui.refresh_areas().to_vec())
.unwrap_or_default();
let dismissables = self
.ui
.as_ref()
.map(|ui| ui.dismissables().to_vec())
.unwrap_or_default();
let mut dismissed: Vec<A::Message> = Vec::new();
let sheet_areas = self
.ui
.as_ref()
.map(|ui| ui.sheets().to_vec())
.unwrap_or_default();
let interactive_bounds = self
.ui
.as_ref()
.map(|ui| ui.interactive_bounds())
.unwrap_or_default();
self.runtime.sync_pages(&scroll_regions);
self.runtime.sync_visible(&scroll_regions);
let requests = std::mem::take(&mut self.pending_scroll);
let (retry, scrolled) =
apply_scroll_requests(&mut self.runtime, tree, requests, &scroll_regions);
self.retry_scroll = retry;
let requests = std::mem::take(&mut self.pending_sheet);
let (retry, sheeted) =
apply_sheet_requests(&mut self.runtime, tree, requests, &sheet_areas);
self.retry_sheet = retry;
let moving: [(&str, bool); 25] = [
("scrolled", scrolled),
("sheeted", sheeted),
("anims", self.runtime.advance(dt)),
("leaving", self.runtime.advance_leaving(dt)),
("switchers", self.runtime.advance_switchers(tree, dt)),
("values", self.runtime.advance_values(tree, dt)),
("colors", self.runtime.advance_colors(tree, dt)),
("sizes", self.runtime.advance_sizes(tree, dt)),
("radii", self.runtime.advance_radii(tree, dt)),
("paddings", self.runtime.advance_paddings(tree, dt)),
("offsets", self.runtime.advance_offsets(tree, dt)),
("pins", self.runtime.advance_pins(tree, dt)),
("fractions", self.runtime.advance_fractions(tree, dt)),
("text_styles", self.runtime.advance_text_styles(tree, dt)),
("transforms", self.runtime.advance_transforms(tree, dt)),
("scroll", self.runtime.advance_scroll(&scroll_regions, dt)),
("glow", self.runtime.advance_glow(dt)),
("refresh", self.runtime.advance_refresh(&refresh_areas, dt)),
("dismiss", {
let (moving, done) = self.runtime.advance_dismiss(&dismissables, dt);
dismissed.extend(done.into_iter().filter_map(|(id, direction)| {
find_widget(tree, id).and_then(|widget| widget.on_dismissed(direction))
}));
moving
}),
("sheets", {
let (moving, closed) = self.runtime.advance_sheets(&sheet_areas, dt);
dismissed.extend(closed.into_iter().filter_map(|id| {
find_widget(tree, id).and_then(|widget| widget.on_sheet_dismissed())
}));
let mut handed = false;
for (list, velocity) in self.runtime.take_sheet_handovers() {
if let Some(area) = scroll_regions.iter().find(|a| a.id == list) {
handed |=
self.runtime
.fling_scroll(*area, area.physics, (0.0, velocity));
}
}
moving | handed
}),
(
"interactive",
self.runtime.advance_interactive(&interactive_bounds, dt),
),
("ink", self.runtime.advance_ink(dt)),
("reorder", reorder_animating),
("autoscroll", autoscrolling),
("app", app_animating),
];
let animating = moving.iter().any(|(_, on)| *on);
self.frame_stats.note_moving(&moving);
let layout_start = measuring.then(Instant::now);
let (ui, scene) = if self.inspector {
let (ui, nodes) = frus_widgets::build_ui_inspected(
tree,
Size::new(width, height),
&self.runtime,
&theme,
);
if std::mem::take(&mut self.inspector_dump) {
log::info!(target: "frus::inspector", "
{}", frus_widgets::dump_tree(&nodes));
}
let mut scene = ui.scene().clone();
frus_widgets::paint_inspector_overlay(
&nodes,
Some(self.cursor),
Size::new(width, height),
&theme,
&mut scene,
);
(ui, scene.scaled(scale))
} else {
let ui = build_ui(tree, Size::new(width, height), &self.runtime, &theme);
let scene = if matches!(self.drag, Some(Drag::Reorder { moved: true, .. })) {
let mut scene = ui.scene().clone();
self.paint_reorder_preview(&ui, &theme, &mut scene);
scene.scaled(scale)
} else if matches!(self.drag, Some(Drag::Item { moved: true, .. })) {
let mut scene = ui.scene().clone();
self.paint_drag_ghost(&ui, &theme, &mut scene);
scene.scaled(scale)
} else {
ui.scene().scaled(scale)
};
(ui, scene)
};
if let Some(at) = layout_start {
self.frame_costs.layout_paint = at.elapsed().as_secs_f32() * 1000.0;
self.frame_costs.layers = count_layers(scene.primitives());
}
self.report_overflows(&ui);
self.runtime.states.end_frame();
if frus_widgets::rebuild_requested() {
self.request_redraw();
}
let scene = if self.renderer.as_ref().is_some_and(Renderer::sees_through) {
backdrop_under(scene, width * scale, height * scale, self.system_line())
} else {
scene
};
if let Some(renderer) = self.renderer.as_mut() {
let render_start = measuring.then(Instant::now);
match renderer.render(&scene) {
frus_gpu::RenderOutcome::Presented => {}
frus_gpu::RenderOutcome::NeedsReconfigure => {
renderer.reconfigure();
}
frus_gpu::RenderOutcome::Skipped => {}
}
if let Some(at) = render_start {
self.frame_costs.render = at.elapsed().as_secs_f32() * 1000.0;
let timings = renderer.last_timings();
self.frame_costs.acquire = timings.acquire;
self.frame_costs.draw = timings.draw;
self.frame_costs.gpu = timings.gpu;
}
}
let widget_asks = ui.wants_animation();
let loading =
frus_widgets::images_in_flight() > 0 || frus_widgets::tasks_in_flight() > 0;
let wants_animation = widget_asks || loading;
self.ui = Some(ui);
let revealed = std::mem::take(&mut self.reveal_after_keyboard) && {
let moves = match (self.runtime.input.focused, self.ui.as_ref()) {
(Some(id), Some(ui)) => ui.reveal(id, &self.runtime),
_ => Vec::new(),
};
let moved = !moves.is_empty();
for (area, offset) in moves {
self.runtime.scroll_ballistic.remove(&area);
self.runtime.scroll_velocity.remove(&area);
self.runtime.scroll_target.insert(area, offset);
}
moved
};
let rehovered = self.drag.is_none() && {
let hovered = self.ui.as_ref().and_then(|ui| self.hover.target(ui));
let changed = hovered != self.runtime.input.hovered;
self.runtime.input.hovered = hovered;
changed
};
if rehovered {
self.build_dirty = true;
}
let wants_animation = wants_animation || rehovered || revealed;
let paged = self
.ui
.as_ref()
.map(|ui| ui.scroll_regions().to_vec())
.unwrap_or_default();
self.runtime.keep_scroll_in_range(&paged);
let turned: Vec<A::Message> = self
.runtime
.page_changes(&paged)
.into_iter()
.filter_map(|(id, page)| {
find_widget(tree, id).and_then(|widget| widget.on_page_changed(page))
})
.collect();
let (_, moved_late) = apply_scroll_requests(
&mut self.runtime,
tree,
std::mem::take(&mut self.retry_scroll),
&paged,
);
let (_, sheeted_late) = {
let sheets = self
.ui
.as_ref()
.map(|ui| ui.sheets().to_vec())
.unwrap_or_default();
apply_sheet_requests(
&mut self.runtime,
tree,
std::mem::take(&mut self.retry_sheet),
&sheets,
)
};
let scrolled: Vec<A::Message> = {
let grain =
|id| find_widget(tree, id).map_or(0.0, |widget| widget.scroll_grain());
self.runtime
.scroll_changes(&paged, grain)
.into_iter()
.filter_map(|(id, position)| {
find_widget(tree, id).and_then(|widget| widget.on_scroll(position))
})
.collect()
};
for message in dismissed.into_iter().chain(turned).chain(scrolled) {
self.dispatch(message);
}
self.sync_regions(false);
if moved_late || sheeted_late {
self.request_redraw();
}
self.reconcile_focus();
#[cfg(desktop)]
if let Some(a11y) = self.a11y.as_mut() {
let focus = self.runtime.input.focused;
let title = self.app.title();
if let Some(ui) = self.ui.as_ref() {
a11y.update(ui.semantics(), focus, &title, &self.announce);
}
}
#[cfg(web)]
if let Some(a11y) = self.a11y.as_mut() {
let focus = self.runtime.input.focused;
let title = self.app.title();
if let Some(ui) = self.ui.as_ref() {
a11y.update(ui.semantics(), focus, &title, &self.announce);
}
}
self.sync_system_bars(&theme);
self.sync_soft_input();
#[cfg(android)]
self.sync_autofill();
if animating || wants_animation {
self.request_redraw();
}
if let Some(start) = frame_start {
use crate::frame_stats::Why;
let why = [
(app_animating, Why::APP),
(animating, Why::RUNTIME),
(widget_asks, Why::WIDGET),
(loading, Why::LOADING),
(rehovered || revealed, Why::POINTER),
];
self.frame_costs.why = why
.into_iter()
.filter(|(on, _)| *on)
.fold(Why::NONE, |all, (_, why)| all | why);
self.frame_stats.frame(start, self.frame_costs);
}
#[cfg(windows)]
self.sync_title_bar();
}
_ => {}
}
}
}
impl<A: Application> App<A> {
fn system_line(&self) -> f32 {
match self.title_bar {
Some(line) if self.title_bar_on && line.system_paints => {
line.height * self.total_scale()
}
_ => 0.0,
}
}
fn total_scale(&self) -> f32 {
(self.scale * self.app.density()).max(0.1)
}
fn media_query(&self, width: f32, height: f32) -> MediaQuery {
MediaQuery::new(Size::new(width, height))
.with_device_pixel_ratio(self.scale)
.with_density(self.app.density())
.with_text_scaler(self.platform.text_scaler)
.with_platform_brightness(self.platform.brightness)
.with_accessibility(
self.platform
.accessibility
.with_overrides(self.app.accessibility()),
)
.with_insets(self.last_insets)
.with_title_bar(self.title_bar)
}
#[cfg(windows)]
fn sync_title_bar(&mut self) {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Some(window) = self.window.clone() else {
return;
};
let hwnd = match window.window_handle().map(|h| h.as_raw()) {
Ok(RawWindowHandle::Win32(win)) => {
win.hwnd.get() as windows_sys::Win32::Foundation::HWND
}
_ => return,
};
let wants = self.ui.as_ref().is_some_and(|ui| ui.wants_title_bar());
if wants != self.title_bar_on {
unsafe {
if wants {
crate::title_bar::enable(hwnd);
let wake = window.clone();
crate::title_bar::on_change(move || wake.request_redraw());
} else {
crate::title_bar::disable(hwnd);
}
}
self.title_bar_on = wants;
}
let next = if self.title_bar_on {
let scale = self.total_scale();
let band = crate::title_bar::height() as f32 / scale;
if let Some(ui) = &self.ui {
let to_px = |r: frus_widgets::Rect| windows_sys::Win32::Foundation::RECT {
left: (r.x * scale).floor() as i32,
top: (r.y * scale).floor() as i32,
right: ((r.x + r.width) * scale).ceil() as i32,
bottom: ((r.y + r.height) * scale).ceil() as i32,
};
let interactive = ui
.hit_rects()
.filter(|r| r.y < band && r.width < self.logical_width())
.map(to_px)
.collect();
let icon = ui
.title_bar_regions()
.iter()
.find(|(_, role)| *role == frus_widgets::TitleBarRole::Icon)
.map(|(r, _)| to_px(*r));
crate::title_bar::publish(interactive, icon);
}
let buttons = unsafe { crate::title_bar::buttons(hwnd) }.map(|r| {
frus_widgets::Rect::new(
r.left as f32 / scale,
r.top as f32 / scale,
(r.right - r.left) as f32 / scale,
(r.bottom - r.top) as f32 / scale,
)
});
let button = |code: u32| {
use windows_sys::Win32::UI::WindowsAndMessaging::{
HTCLOSE, HTMAXBUTTON, HTMINBUTTON,
};
match code {
HTMINBUTTON => Some(frus_widgets::CaptionButton::Minimize),
HTMAXBUTTON => Some(frus_widgets::CaptionButton::Maximize),
HTCLOSE => Some(frus_widgets::CaptionButton::Close),
_ => None,
}
};
let (hovered, pressed) = crate::title_bar::buttons_state();
if self.window_icon.is_none() {
self.window_icon = self
.app
.icon()
.png()
.map(|png| &*Box::leak(png.to_vec().into_boxed_slice()));
}
let active = window.has_focus();
let dark = window.theme() == Some(winit::window::Theme::Dark);
let (background, foreground) = crate::title_bar::caption_colors(dark, active);
Some(frus_widgets::TitleBar {
height: buttons.map_or(band, |b| (b.y + b.height).clamp(1.0, band)),
leading: 0.0,
buttons,
icon: self.window_icon,
hovered: button(hovered),
pressed: button(pressed),
maximized: window.is_maximized(),
active,
background: Some(background),
foreground: Some(foreground),
system_paints: self.renderer.as_ref().is_some_and(Renderer::sees_through)
&& unsafe { crate::title_bar::system_backdrop(hwnd, dark) },
})
} else {
None
};
if next != self.title_bar {
log::debug!(
target: "frus::title_bar",
"the line: {:?}",
next.map(|line| (line.height, line.buttons, line.active, line.system_paints))
);
self.title_bar = next;
self.build_dirty = true;
self.request_redraw();
}
}
fn is_rtl(&self) -> bool {
self.themes.displayed(&self.app).direction.is_rtl()
}
fn logical_width(&self) -> f32 {
let scale = self.total_scale();
self.window
.as_ref()
.map(|w| w.inner_size().width as f32 / scale)
.unwrap_or(1.0)
.max(1.0)
}
fn request_redraw(&self) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn pointer(&mut self, event_loop: &ActiveEventLoop, event: PointerEvent) {
self.pointer_event(event);
event_loop.set_control_flow(self.idle_control_flow());
}
fn pointer_event(&mut self, event: PointerEvent) {
let kind = event.kind;
self.cursor = event.position;
self.route_listeners(kind, event.touch);
self.route_pointer_event(event);
self.pointer_held = match kind {
PointerKind::Down => true,
PointerKind::Up | PointerKind::Cancel => false,
PointerKind::Move => self.pointer_held,
};
self.sync_regions(kind == PointerKind::Move);
}
fn route_pointer_event(&mut self, event: PointerEvent) {
self.cursor = event.position;
self.hover.event(event.kind, event.position, event.touch);
let could_tap = gesture_was_a_tap(self.drag.as_ref());
match event.kind {
PointerKind::Down => {
self.sync_hover();
self.runtime.focus_visible = false;
self.pointer_down(event.touch);
let (long_press, lift) = hold_candidates(
self.drag.as_ref(),
self.ui.as_ref(),
self.tree.as_deref(),
self.cursor,
);
self.long_press_msg = long_press;
self.pending_lift = lift;
let interested = self.long_press_msg.is_some()
|| self.pending_lift.is_some()
|| self.pending_reorder.is_some()
|| self.pending_word.is_some()
|| self.pending_region_hold;
self.press.down(self.cursor, Instant::now(), interested);
}
PointerKind::Move => {
self.press.moved(self.cursor);
self.pointer_move();
if self.inspector {
self.request_redraw();
}
}
PointerKind::Up => {
let swallow = self.press.up();
self.pending_lift = None;
self.pending_reorder = None;
self.pending_word = None;
self.pending_region_hold = false;
if swallow
&& !matches!(
self.drag,
Some(Drag::Item { moved: true, .. } | Drag::Reorder { moved: true, .. })
)
{
self.drag = None;
self.runtime.input.pressed = None;
self.request_redraw();
} else {
self.pointer_up();
}
}
PointerKind::Cancel => {
self.press.cancel();
self.pending_lift = None;
self.pending_reorder = None;
self.pending_word = None;
self.pending_region_hold = false;
if let Some(Drag::Dismiss { item, .. }) = self.drag {
self.runtime.dismiss_release(
item.id,
0.0,
0.0,
item.spec.axis,
f32::INFINITY,
);
}
if let Some(Drag::Gesture { id, .. }) = self.drag {
self.runtime.release_value(id);
}
if let Some(Drag::Scroll { id, .. }) = self.drag {
self.runtime.release_scroll(id);
self.runtime.glow_scroll_end(id);
if let Some(host) = self.refresh_host_of(id) {
self.runtime.refresh_cancel(host);
}
}
let sheet = match self.drag {
Some(Drag::Sheet { id, available, .. }) => self
.ui
.as_ref()
.and_then(|ui| ui.sheet(id))
.map(|sheet| (sheet.id, sheet.spec.clone(), available)),
Some(Drag::Scroll { id, .. }) => self
.ui
.as_ref()
.and_then(|ui| ui.sheet_holding(id))
.map(|sheet| (sheet.id, sheet.spec.clone(), sheet.available)),
_ => None,
};
if let Some((id, spec, available)) = sheet {
self.runtime.sheet_release(id, &spec, available, 0.0, None);
}
self.drag = None;
self.runtime.input.pressed = None;
self.request_redraw();
}
}
if settle_press(
&mut self.runtime.input,
event.kind,
could_tap,
self.drag.as_ref(),
event.touch,
) {
self.request_redraw();
}
if matches!(event.kind, PointerKind::Up | PointerKind::Cancel) {
self.sync_hover();
}
}
fn hold_deadline_reached(&mut self) {
let lifting = self.pending_lift.is_some() || self.pending_reorder.is_some();
let word = self.pending_word.take();
let region = std::mem::take(&mut self.pending_region_hold)
&& !lifting
&& word.is_none()
&& self.long_press_msg.is_none();
if let Some(message) = self.long_press_msg.take() {
if !lifting && word.is_none() {
self.dispatch(message);
}
}
if let Some(id) = word {
self.select_held_word(id);
}
if region && self.select_region_word_held() {
if let Some(Drag::Scroll { id, .. }) = self.drag {
self.runtime.release_scroll(id);
}
}
let lift = self.pending_lift.take();
let reorder = self.pending_reorder.take();
if lift.is_some() || reorder.is_some() {
if let Some(Drag::Scroll { id, .. }) = self.drag {
self.runtime.release_scroll(id);
}
}
self.drag = drag_after_hold(self.drag.take(), lift, reorder, self.cursor);
if matches!(self.drag, Some(Drag::Reorder { .. })) {
self.reorder_x = self.cursor.x;
self.reorder_y = self.cursor.y;
}
self.request_redraw();
}
fn idle_control_flow(&self) -> ControlFlow {
let press = self.press.deadline();
let reload = self.reload.as_ref().map(|w| w.deadline());
let caret = (self.lifecycle == Lifecycle::Resumed)
.then(|| self.caret.next_toggle(Instant::now()))
.flatten();
let tap = self.pending_tap.as_ref().map(|p| p.at + DOUBLE_TAP_TIMEOUT);
match [press, reload, caret, tap].into_iter().flatten().min() {
Some(at) => ControlFlow::WaitUntil(at),
None => ControlFlow::Wait,
}
}
fn caret_signature(&self) -> Option<crate::caret::Signature> {
use std::hash::{Hash, Hasher};
let field = self.runtime.input.focused?;
let widget = find_widget(self.tree.as_ref()?.as_ref(), field)?;
let text = widget.text_value()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
Some(crate::caret::Signature {
field,
edit: self.runtime.edits.get(&field).copied().unwrap_or_default(),
value: hasher.finish(),
})
}
fn pointer_move(&mut self) {
if self.drag.is_some() {
self.handle_drag();
return;
}
let hovered = self.sync_hover();
self.update_cursor_icon(hovered);
}
fn sync_hover(&mut self) -> Option<WidgetId> {
let hovered = self.ui.as_ref().and_then(|ui| self.hover.target(ui));
if hovered != self.runtime.input.hovered {
self.runtime.input.hovered = hovered;
self.request_redraw();
}
hovered
}
fn take_task_arrivals(&mut self) -> bool {
let now = frus_widgets::task_arrivals();
std::mem::replace(&mut self.task_arrivals, now) != now
}
fn route_listeners(&mut self, kind: PointerKind, touch: bool) {
use frus_widgets::ListenerEventKind as Raw;
let at = self.cursor;
let heard: Vec<(WidgetId, Rect, Raw)> = match kind {
PointerKind::Down => {
self.captured = self
.ui
.as_ref()
.map(|ui| ui.pointer_listeners_at(at))
.unwrap_or_default();
self.captured
.iter()
.map(|(id, rect)| (*id, *rect, Raw::Down))
.collect()
}
PointerKind::Move if self.pointer_held => self
.captured
.iter()
.map(|(id, rect)| (*id, *rect, Raw::Move))
.collect(),
PointerKind::Move if !touch => self
.ui
.as_ref()
.map(|ui| ui.pointer_listeners_at(at))
.unwrap_or_default()
.into_iter()
.map(|(id, rect)| (id, rect, Raw::Hover))
.collect(),
PointerKind::Move => Vec::new(),
PointerKind::Up | PointerKind::Cancel => {
let raw = if kind == PointerKind::Up {
Raw::Up
} else {
Raw::Cancel
};
std::mem::take(&mut self.captured)
.into_iter()
.map(|(id, rect)| (id, rect, raw))
.collect()
}
};
let messages: Vec<A::Message> = heard
.into_iter()
.flat_map(|(id, rect, kind)| {
let event = frus_widgets::ListenerEvent {
kind,
local: Point::new(at.x - rect.x, at.y - rect.y),
touch,
};
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.map(|widget| widget.on_pointer_event(event))
.unwrap_or_default()
})
.collect();
for message in messages {
self.dispatch(message);
self.request_redraw();
}
}
fn sync_regions(&mut self, moved: bool) {
let point = self.hover.at();
let now: Vec<(WidgetId, Rect)> = match (point, self.ui.as_ref()) {
(Some(point), Some(ui)) => ui
.hover_regions_at(point)
.into_iter()
.map(|(id, rect, _)| (id, rect))
.collect(),
_ => Vec::new(),
};
let at = point.unwrap_or(self.cursor);
let local = |rect: Rect| Point::new(at.x - rect.x, at.y - rect.y);
let before = std::mem::replace(&mut self.entered, now.clone());
let mut events = Vec::new();
for (id, rect) in &before {
if !now.iter().any(|(n, _)| n == id) {
events.push((
*id,
frus_widgets::HoverEvent::Exit {
local: local(*rect),
},
));
}
}
for (id, rect) in now.iter().rev() {
if !before.iter().any(|(b, _)| b == id) {
events.push((
*id,
frus_widgets::HoverEvent::Enter {
local: local(*rect),
},
));
} else if moved && !self.pointer_held {
events.push((
*id,
frus_widgets::HoverEvent::Move {
local: local(*rect),
},
));
}
}
let messages: Vec<_> = events
.into_iter()
.filter_map(|(id, event)| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.on_hover_event(event))
})
.collect();
for message in messages {
self.dispatch(message);
self.request_redraw();
}
}
fn region_cursor(&self, requested: Option<UiCursor>) -> UiCursor {
requested
.or_else(|| {
let point = self.hover.at()?;
let (id, _) = self.ui.as_ref()?.focus_hit(point)?;
let widget = find_widget(self.tree.as_ref()?.as_ref(), id)?;
is_text_field(widget).then_some(UiCursor::Text)
})
.or_else(|| {
self.hover.at().and_then(|point| {
self.ui
.as_ref()?
.hover_regions_at(point)
.into_iter()
.find_map(|(_, _, region)| region.cursor)
})
})
.unwrap_or(UiCursor::Default)
}
fn update_cursor_icon(&mut self, hovered: Option<WidgetId>) {
let requested = hovered.and_then(|id| {
let rect = self.ui.as_ref()?.widget_rect(id)?;
let widget = find_widget(self.tree.as_ref()?.as_ref(), id)?;
widget.cursor_icon(
self.cursor.x - rect.x,
self.cursor.y - rect.y,
rect.width,
rect.height,
)
});
let shown = self.region_cursor(requested);
if let Some(window) = &self.window {
window.set_cursor_visible(shown != UiCursor::None);
window.set_cursor(cursor_icon(shown));
}
let hover_cursor = requested.map(|_| self.cursor);
if hover_cursor != self.runtime.input.hover_cursor {
self.runtime.input.hover_cursor = hover_cursor;
self.request_redraw();
}
}
fn pointer_down(&mut self, touch: bool) {
self.pointer_touch = touch;
self.poll_pending_tap(Instant::now());
if let Some(pending) = self.pending_tap.as_ref() {
let here = self.ui.as_ref().and_then(|ui| ui.hit(self.cursor));
let (dx, dy) = (
self.cursor.x - pending.position.x,
self.cursor.y - pending.position.y,
);
if here != Some(pending.id) || dx.hypot(dy) > DOUBLE_TAP_SLOP {
self.flush_pending_tap();
}
}
self.pending_word = None;
self.pending_region_hold = false;
if self
.ui
.as_ref()
.is_some_and(|ui| ui.toolbar_contains(self.cursor))
{
press_at(&mut self.runtime, self.ui.as_ref(), self.cursor);
self.request_redraw();
return;
}
if let Some(drag) = self.grab_region_handle() {
self.drag = Some(drag);
if let Some(selection) = self.runtime.region.as_mut() {
selection.bar = false;
}
self.request_redraw();
return;
}
if let Some(drag) = self.grab_selection_handle() {
self.drag = Some(drag);
self.runtime.selection_toolbar = None;
self.request_redraw();
return;
}
if self.runtime.selection_handles.take().is_some() {
self.request_redraw();
}
self.hide_selection_toolbar();
if self.runtime.region.take().is_some() {
self.request_redraw();
}
let above_the_pages = self.ui.as_ref().is_some_and(|ui| {
ui.over_overlay(self.cursor)
|| ui
.title_bar_row()
.is_some_and(|row| row.contains(self.cursor))
});
let on_back_edge = !above_the_pages
&& if self.is_rtl() {
self.cursor.x > self.logical_width() - BACK_EDGE
} else {
self.cursor.x < BACK_EDGE
};
if on_back_edge && self.app.can_go_back() {
self.drag = Some(Drag::Back {
start_x: self.cursor.x,
});
self.begin_gesture();
self.build_dirty = true;
self.app.back_gesture(0.0);
self.request_redraw();
return;
}
if let Some(bar) = self.ui.as_ref().and_then(|ui| ui.scrollbar_at(self.cursor)) {
let (along, thumb_start) = if bar.vertical {
(self.cursor.y, bar.thumb.y)
} else {
(self.cursor.x, bar.thumb.x)
};
self.drag = Some(Drag::Scrollbar {
id: bar.id,
vertical: bar.vertical,
grab: along - thumb_start,
track_start: bar.track_start,
track_len: bar.track_len,
thumb_len: bar.thumb_len,
max: bar.max,
reverse: bar.reverse,
});
self.request_redraw();
return;
}
if let Some((id, rect)) = self.ui.as_ref().and_then(|ui| ui.draggable_at(self.cursor)) {
self.drag = Some(Drag::Widget {
id,
rect,
last_x: self.cursor.x,
});
self.dispatch_drag_edge(id, rect, Edge::Start);
self.apply_widget_drag(id, rect, 0.0);
self.request_redraw();
return;
}
if let Some((id, from, hold)) = self.reorderable_at(self.cursor) {
if hold {
self.pending_reorder = Some((id, from, self.cursor));
} else {
self.drag = Some(Drag::Reorder {
id,
from,
start: self.cursor,
moved: false,
carried: None,
});
self.reorder_x = self.cursor.x; self.reorder_y = self.cursor.y; }
}
if press_at(&mut self.runtime, self.ui.as_ref(), self.cursor) {
self.request_redraw();
}
let previously_focused = self.runtime.input.focused;
let focus = self.ui.as_ref().and_then(|ui| ui.focus_hit(self.cursor));
self.runtime.input.focused = focus.map(|(id, _)| id);
if let Some((id, rect)) = focus {
let local_x = self.cursor.x - rect.x;
let local_y =
self.cursor.y - rect.y + self.runtime.scroll.get(&id).map(|s| s.1).unwrap_or(0.0);
let scroll_cursor = if previously_focused == Some(id) {
self.runtime.edits.get(&id).map(|e| e.cursor).unwrap_or(0)
} else {
0
};
let cursor = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.cursor_at(local_x, local_y, rect.width, scroll_cursor));
if let Some(cursor) = cursor {
self.goal_x = None;
self.runtime.edits.insert(
id,
Edit {
cursor,
anchor: None,
composing: None,
},
);
self.drag = Some(Drag::TextSelect { id, rect });
if touch {
self.pending_word = Some(id);
}
self.runtime.close_edit_run(id);
self.request_soft_input();
let now = Instant::now();
let double = self
.last_click_time
.map(|t| (now - t).as_secs_f32() < 0.4)
.unwrap_or(false);
self.last_click_time = Some(now);
if double {
if let Some((start, end)) = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.word_at(cursor))
{
self.runtime.edits.insert(
id,
Edit {
cursor: end,
anchor: Some(start),
composing: None,
},
);
self.drag = None;
}
}
}
}
if self.drag.is_none() && focus.is_none() {
if touch {
self.pending_region_hold = self.region_press_target().is_some();
} else {
self.begin_region_selection();
}
}
if touch && self.drag.is_none() {
let landed = {
let scroll = &self.runtime.scroll;
self.ui.as_ref().and_then(|ui| {
ui.scroll_chain(self.cursor).find(|area| {
area.accepts_user_offset(
scroll.get(&area.id).copied().unwrap_or((0.0, 0.0)),
)
})
})
};
if let Some(area) = landed {
let physics = area.physics;
let carried = self.runtime.catch_scroll_fling(area.id, physics);
self.runtime.hold_scroll(area.id);
if let Some(sheet) = self
.ui
.as_ref()
.and_then(|ui| ui.sheet_holding(area.id))
.map(|sheet| sheet.id)
{
self.runtime.sheet_hold(sheet);
}
self.drag = Some(Drag::Scroll {
id: area.id,
last: self.cursor,
moved: false,
carried,
dismiss: self
.ui
.as_ref()
.and_then(|ui| ui.dismissable_at(self.cursor)),
axis: None,
pan: self.pan_target(),
});
self.begin_gesture();
self.gesture_velocity = area.velocity_tracker();
}
}
if self.drag.is_none() {
if let Some((id, available)) = self
.ui
.as_ref()
.and_then(|ui| ui.sheet_at(self.cursor))
.map(|sheet| (sheet.id, sheet.available))
{
self.runtime.sheet_hold(id);
self.drag = Some(Drag::Sheet {
id,
last: self.cursor,
moved: false,
available,
});
self.begin_gesture();
}
}
if self.drag.is_none() {
if let Some(item) = self
.ui
.as_ref()
.and_then(|ui| ui.dismissable_at(self.cursor))
{
self.drag = Some(Drag::Dismiss {
item,
last: self.cursor,
moved: false,
});
self.begin_gesture();
}
}
if self.drag.is_none() {
if let Some(source) = self
.ui
.as_ref()
.and_then(|ui| ui.drag_source_at(self.cursor))
.filter(|source| {
!self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), source.id))
.is_some_and(|widget| widget.drag_needs_long_press())
})
{
self.drag = Some(Drag::Item {
source,
start: self.cursor,
moved: false,
over: None,
});
self.begin_gesture();
}
}
if self.drag.is_none() {
if let Some((id, axis)) = self.pan_target() {
self.drag = Some(Drag::Gesture {
id,
axis,
start: self.cursor,
last: self.cursor,
moved: false,
});
self.begin_gesture();
}
}
if self.drag.is_none() {
if let Some((id, viewport)) = self
.ui
.as_ref()
.and_then(|ui| ui.interactive_at(self.cursor))
{
self.runtime.interactive_velocity.remove(&id);
self.drag = Some(Drag::Pan {
id,
last: self.cursor,
moved: false,
viewport,
});
self.begin_gesture();
}
}
self.request_redraw();
}
fn positional_click_on(&self, id: WidgetId) -> Option<A::Message> {
let rect = self.ui.as_ref()?.widget_rect(id)?;
let widget = find_widget(self.tree.as_ref()?.as_ref(), id)?;
widget.positional_click(
self.cursor.x - rect.x,
self.cursor.y - rect.y,
rect.width,
rect.height,
)
}
fn pointer_up(&mut self) {
let ended = self.drag.take();
if let Some(Drag::Back { .. }) = ended {
let sign = if self.is_rtl() { -1.0 } else { 1.0 };
let velocity = sign * self.gesture_estimate().velocity.x / self.logical_width();
self.build_dirty = true;
self.app.back_gesture_end(velocity);
self.request_redraw();
return;
}
let was_tap = gesture_was_a_tap(ended.as_ref());
if let Some(Drag::Widget { id, rect, .. }) = ended {
self.dispatch_drag_edge(id, rect, Edge::End);
}
#[cfg(android)]
if let Some(Drag::SelectionHandle { id, .. }) = ended {
self.push_ime_context(id);
}
if let Some(Drag::SelectionHandle { id, .. }) = ended {
self.show_selection_toolbar(id);
}
if let Some(Drag::RegionHandle { .. }) = ended {
if let Some(selection) = self.runtime.region.as_mut() {
selection.bar = true;
}
}
if let Some(Drag::Reorder {
id,
from,
moved: true,
carried,
..
}) = &ended
{
let target = self.reorder_drop_target(*id, *from, *carried);
let tree = self.tree.as_ref();
let base = target
.and_then(|tid| tree.and_then(|t| find_widget(t.as_ref(), tid)))
.and_then(|widget| widget.reorder_index());
let to = match (base, target) {
(Some(base), Some(tid)) => {
let after = self
.ui
.as_ref()
.and_then(|ui| ui.widget_rect(tid))
.map(|rect| self.reorder_insert_after(tid, rect))
.unwrap_or(false);
Some(base + after as usize)
}
_ => None,
};
let self_drop = target == Some(*id);
let message = match to {
Some(to) if to != *from && !self_drop => tree
.and_then(|t| find_widget(t.as_ref(), *id))
.and_then(|widget| widget.on_reorder(to)),
_ => None,
};
if let Some(message) = message {
let to = to.unwrap_or(*from);
let widget = tree.and_then(|t| find_widget(t.as_ref(), *id));
let axis = widget
.map(|w| w.reorder_axis())
.unwrap_or(ReorderAxis::Horizontal);
let announcement = widget
.and_then(|w| w.reorder_announcement(to))
.unwrap_or_else(|| match axis {
ReorderAxis::Horizontal => format!("Column moved to position {}", to + 1),
ReorderAxis::Vertical => "Card moved".to_string(),
});
self.dispatch(message);
self.set_announcement(announcement);
}
}
if let Some(Drag::Dismiss { item, moved, .. }) = &ended {
if *moved {
let estimate = self.gesture_estimate();
let horizontal = item.spec.axis.is_horizontal();
let (along, across) = if horizontal {
(estimate.velocity.x, estimate.velocity.y)
} else {
(estimate.velocity.y, estimate.velocity.x)
};
self.runtime.dismiss_release(
item.id,
along,
across,
item.spec.axis,
item.spec.threshold,
);
self.request_redraw();
return;
}
}
if let Some(Drag::Sheet {
id,
moved,
available,
..
}) = &ended
{
let velocity = if *moved {
-self.fling_velocity(self.gesture_estimate()).1
} else {
0.0
};
let sheet = self.ui.as_ref().and_then(|ui| ui.sheet(*id)).map(|sheet| {
let list = match sheet.areas.as_slice() {
[only] => Some(*only),
_ => None,
};
(sheet.spec.clone(), list)
});
if let Some((spec, list)) = sheet {
self.runtime
.sheet_release(*id, &spec, *available, velocity, list);
}
if *moved {
self.request_redraw();
return;
}
}
if let Some(Drag::Item {
source,
moved: true,
over,
..
}) = &ended
{
self.runtime.drag_over = None;
let accepted = over.is_some();
let dropped = over.and_then(|id| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.on_drop(source.payload))
});
let ended_msg = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), source.id))
.and_then(|widget| widget.on_dropped(accepted));
for message in dropped.into_iter().chain(ended_msg) {
self.dispatch(message);
}
self.request_redraw();
return;
}
if let Some(Drag::Item { .. }) = &ended {
self.runtime.drag_over = None;
}
let mut sheet_took = false;
if let Some(Drag::Scroll { id, .. }) = &ended {
let sheet = self
.ui
.as_ref()
.and_then(|ui| ui.sheet_holding(*id))
.cloned();
if let Some(sheet) = sheet {
let velocity = match &ended {
Some(Drag::Scroll {
moved: true,
axis: Some(true),
carried,
..
}) => -self.fling_velocity(self.gesture_estimate()).1 + carried.1,
_ => 0.0,
};
let offset = self.runtime.scroll.get(id).map_or(0.0, |o| o.1);
let size = self.runtime.sheet_size(sheet.id, &sheet.spec);
sheet_took = velocity != 0.0
&& frus_widgets::sheet_takes_release(
velocity,
offset,
size,
&sheet.spec,
sheet.available,
);
self.runtime.sheet_release(
sheet.id,
&sheet.spec,
sheet.available,
if sheet_took { velocity } else { 0.0 },
Some(*id),
);
}
}
if let Some(Drag::Scroll { id, .. }) = &ended {
self.runtime.release_scroll(*id);
self.runtime.glow_scroll_end(*id);
self.release_refresh(*id);
}
if let Some(Drag::Scroll {
id,
moved: true,
carried,
axis,
..
}) = &ended
{
let estimate = self.gesture_estimate();
let velocity = self.fling_velocity(estimate);
let velocity = match self.ui.as_ref().and_then(|u| u.scroll_region(*id)) {
Some(area) => area.offset_delta(velocity),
None => (-velocity.0, -velocity.1),
};
let launch = (velocity.0 + carried.0, velocity.1 + carried.1);
if sheet_took {
self.request_redraw();
} else {
self.fling(
*id,
match axis {
Some(true) => (0.0, launch.1),
Some(false) => (launch.0, 0.0),
None => launch,
},
);
}
}
if let Some(Drag::Gesture {
id,
axis,
moved: true,
..
}) = &ended
{
let velocity = self.fling_velocity(self.gesture_estimate());
let velocity = mask_pan(*axis, Point::new(velocity.0, velocity.1));
let speed = velocity.x.hypot(velocity.y);
let velocity = if speed > MAX_PAN_VELOCITY {
let k = MAX_PAN_VELOCITY / speed;
Point::new(velocity.x * k, velocity.y * k)
} else {
velocity
};
self.send_pan(*id, frus_widgets::PanEvent::End { velocity });
}
if let Some(Drag::Pan {
id, moved: true, ..
}) = &ended
{
let estimate = self.gesture_estimate();
let velocity = self.fling_velocity(estimate);
if velocity.0.hypot(velocity.1) > PAN_FLING_MIN {
self.runtime.interactive_velocity.insert(*id, velocity);
}
}
if ended.is_some() && !was_tap {
self.request_redraw();
return;
}
let released = self.ui.as_ref().and_then(|ui| ui.hit(self.cursor));
let bar_action = match (self.runtime.input.pressed, released) {
(Some(pressed), Some(released)) if pressed == released => {
self.ui.as_ref().and_then(|ui| ui.edit_action_for(pressed))
}
_ => None,
};
let on_bar = self
.ui
.as_ref()
.is_some_and(|ui| ui.toolbar_contains(self.cursor));
let field_positional = if bar_action.is_some() {
None
} else {
self.ui
.as_ref()
.and_then(|ui| ui.focus_hit(self.cursor))
.map(|(id, _)| id)
.filter(|id| self.runtime.input.focused == Some(*id))
.and_then(|id| self.positional_click_on(id))
};
let (message, announce) = match (self.runtime.input.pressed, released) {
(Some(_), Some(_)) if bar_action.is_some() => (None, None),
_ if field_positional.is_some() => (field_positional, None),
(Some(pressed), Some(released)) if pressed == released => {
let positional = self.positional_click_on(released);
let message =
positional.or_else(|| self.ui.as_ref().and_then(|ui| ui.msg_for(pressed)));
let announce = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), pressed))
.and_then(|widget| widget.announce());
(message, announce)
}
_ => (None, None),
};
let message = match (self.runtime.input.pressed, released) {
(Some(pressed), Some(released)) if pressed == released && bar_action.is_none() => {
self.double_tap(pressed, message)
}
_ => message,
};
if let (Some(pressed), Some(released)) = (self.runtime.input.pressed, released) {
if pressed == released {
self.runtime.ink_confirm(pressed);
}
}
self.runtime.input.pressed = None;
if let Some(action) = bar_action {
self.perform_edit_action(action);
} else if on_bar && message.is_some() {
self.runtime.selection_handles = None;
self.hide_selection_toolbar();
}
if let Some(message) = message {
self.dispatch(message);
if let Some(announce) = announce {
self.set_announcement(announce);
}
}
self.request_redraw();
}
fn double_tap(&mut self, id: WidgetId, message: Option<A::Message>) -> Option<A::Message> {
let Some(double) = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.on_double_tap())
else {
return message;
};
let now = Instant::now();
let second = self.pending_tap.as_ref().is_some_and(|pending| {
let (dx, dy) = (
self.cursor.x - pending.position.x,
self.cursor.y - pending.position.y,
);
pending.id == id
&& now.duration_since(pending.at) <= DOUBLE_TAP_TIMEOUT
&& dx.hypot(dy) <= DOUBLE_TAP_SLOP
});
if second {
self.pending_tap = None;
return Some(double);
}
self.flush_pending_tap();
self.pending_tap = Some(PendingTap {
id,
at: now,
position: self.cursor,
single: message,
});
None
}
fn flush_pending_tap(&mut self) {
if let Some(message) = self.pending_tap.take().and_then(|pending| pending.single) {
self.dispatch(message);
self.request_redraw();
}
}
fn poll_pending_tap(&mut self, now: Instant) -> bool {
let due = self
.pending_tap
.as_ref()
.is_some_and(|pending| now.duration_since(pending.at) >= DOUBLE_TAP_TIMEOUT);
if due {
self.flush_pending_tap();
}
due
}
#[cfg(any(desktop, web))]
fn set_announcement(&mut self, message: String) {
self.announce = message;
}
#[cfg(not(any(desktop, web)))]
fn set_announcement(&mut self, _message: String) {}
fn reconcile_focus(&mut self) {
let present: std::collections::HashSet<WidgetId> = match self.ui.as_ref() {
Some(ui) => ui.focusable_ids().collect(),
None => return,
};
let autofocus: Vec<WidgetId> = self
.ui
.as_ref()
.map(|ui| ui.autofocus_ids().collect())
.unwrap_or_default();
let appeared = autofocus
.iter()
.find(|id| !self.autofocused.contains(id))
.copied();
self.autofocused = autofocus.into_iter().collect();
if let Some(id) = appeared {
self.runtime.input.focused = Some(id);
}
let before = self.runtime.input.focused;
let after = resolve_focus(
before,
&present,
&mut self.focus_history,
&mut self.prev_focus,
);
if after != before || appeared.is_some() {
self.runtime.input.focused = after;
self.request_redraw();
}
}
fn dispatch(&mut self, message: A::Message) {
self.build_dirty = true;
let command = self.app.update(message);
self.run_command(command);
let asked = self.app.effects();
self.run_command(asked);
#[cfg(web)]
self.sync_address();
self.sync_subscriptions();
}
#[cfg(any(android, desktop))]
fn open_launch_link(&mut self) {
#[cfg(android)]
let link = self
.android_app
.as_ref()
.and_then(crate::android_link::launch_link);
#[cfg(desktop)]
let link = crate::link::link_among(std::env::args());
if let Some(location) = link.as_deref().and_then(crate::link::location_of_link) {
let command = self.app.open_location(&location);
self.run_command(command);
}
}
#[cfg(desktop)]
fn open_links_from_other_instances(&mut self) {
for link in crate::instance::take_links() {
if let Some(location) = crate::link::location_of_link(&link) {
let command = self.app.open_location(&location);
self.run_command(command);
self.build_dirty = true;
self.request_redraw();
}
}
}
#[cfg(web)]
fn open_launch_address(&mut self, window: &Arc<Window>) {
let strategy = self.app.location_strategy();
let base = crate::address::base();
let (location, index) = crate::address::current(strategy, &base);
self.history.opened(location.clone(), index);
crate::address::listen(window.clone(), strategy, base.clone());
self.address = (strategy, base);
if let Some(location) = location {
let command = self.app.open_location(&location);
self.run_command(command);
let (strategy, base) = &self.address;
for (step, index) in self.history.seed(&self.app.location_stack()) {
crate::address::apply(step, *strategy, base, index);
}
}
}
#[cfg(web)]
fn sync_address(&mut self) {
if !self.started {
return;
}
for (location, state) in crate::address::take_popped() {
if self.history.popped(location.clone(), state) {
let command = self.app.open_location(&location);
self.run_command(command);
self.build_dirty = true;
self.request_redraw();
}
}
if let Some(location) = self.app.location() {
if let Some(step) = self
.history
.reflect(&location, self.app.location_replaces())
{
let (strategy, base) = &self.address;
crate::address::apply(step, *strategy, base, self.history.index());
}
}
}
fn run_command(&mut self, command: crate::command::Command<A::Message>) {
let parts = command.into_parts();
self.pending_focus.extend(parts.focus);
self.pending_scroll.extend(parts.scrolls);
self.pending_sheet.extend(parts.sheets);
for task in parts.tasks {
let proxy = self.proxy.clone();
#[cfg(not(web))]
std::thread::spawn(move || {
if let Some(message) = task() {
let _ = proxy.send_event(message);
}
});
#[cfg(web)]
wasm_bindgen_futures::spawn_local(async move {
if let Some(message) = task() {
let _ = proxy.send_event(message);
}
});
}
for future in parts.async_tasks {
let proxy = self.proxy.clone();
#[cfg(not(web))]
crate::runtime::spawn(async move {
if let Some(message) = future.await {
let _ = proxy.send_event(message);
}
})
.detach();
#[cfg(web)]
wasm_bindgen_futures::spawn_local(async move {
if let Some(message) = future.await {
let _ = proxy.send_event(message);
}
});
}
for (delay, message) in parts.timers {
let proxy = self.proxy.clone();
#[cfg(not(web))]
crate::runtime::spawn(async move {
crate::runtime::sleep(delay).await;
let _ = proxy.send_event(message);
})
.detach();
#[cfg(web)]
web_timer::after(delay.as_millis() as i32, move || {
let _ = proxy.send_event(message);
});
}
}
fn sync_subscriptions(&mut self) {
let entries = self.app.subscription().into_entries();
let declared: std::collections::HashSet<u64> = entries.iter().map(|e| e.id).collect();
self.running_subs.retain(|id, _| declared.contains(id));
for entry in entries {
if self.running_subs.contains_key(&entry.id) {
continue;
}
let handle = self.start_subscription(entry.kind);
self.running_subs.insert(entry.id, handle);
}
}
#[cfg(not(web))]
fn start_subscription(&self, kind: crate::subscription::Kind<A::Message>) -> SubHandle {
let proxy = self.proxy.clone();
match kind {
crate::subscription::Kind::Every { interval, make } => {
crate::runtime::spawn(async move {
loop {
crate::runtime::sleep(interval).await;
if proxy.send_event(make(Instant::now())).is_err() {
break;
}
}
})
}
}
}
#[cfg(web)]
fn start_subscription(&self, kind: crate::subscription::Kind<A::Message>) -> SubHandle {
let proxy = self.proxy.clone();
match kind {
crate::subscription::Kind::Every { interval, make } => {
let ms = interval.as_millis() as i32;
web_timer::Interval::new(ms, move || {
let _ = proxy.send_event(make(Instant::now()));
})
}
}
}
fn handle_drag(&mut self) {
let Some(mut drag) = self.drag.take() else {
return;
};
let slop = self.hit_slop();
match &mut drag {
Drag::Scrollbar {
id,
vertical,
grab,
track_start,
track_len,
thumb_len,
max,
reverse,
} => {
let along = if *vertical {
self.cursor.y
} else {
self.cursor.x
};
let travel = (*track_len - *thumb_len).max(1.0);
let thumb_start = (along - *grab).clamp(*track_start, *track_start + travel);
let fraction = (thumb_start - *track_start) / travel;
let fraction = if *reverse { 1.0 - fraction } else { fraction };
let offset = (fraction * *max).clamp(0.0, *max);
let entry = self.runtime.scroll.entry(*id).or_insert((0.0, 0.0));
if *vertical {
entry.1 = offset;
} else {
entry.0 = offset;
}
let synced = *entry;
self.runtime.scroll_target.insert(*id, synced);
self.runtime.scroll_velocity.remove(&*id);
}
Drag::RegionSelect { area } => {
let area = *area;
self.extend_region_selection(area);
}
Drag::RegionHandle { area, handle, grab } => {
let (area, handle, grab) = (*area, *handle, *grab);
self.drag_region_handle(area, handle, grab);
}
Drag::TextSelect { id, rect } => {
let local_x = self.cursor.x - rect.x;
let local_y = self.cursor.y - rect.y
+ self.runtime.scroll.get(id).map(|s| s.1).unwrap_or(0.0);
let scroll_cursor = self.runtime.edits.get(id).map(|e| e.cursor).unwrap_or(0);
let cursor = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), *id))
.and_then(|widget| {
widget.cursor_at(local_x, local_y, rect.width, scroll_cursor)
});
if let Some(cursor) = cursor {
let edit = self.runtime.edits.entry(*id).or_default();
if edit.anchor.is_none() {
edit.anchor = Some(edit.cursor);
}
edit.cursor = cursor;
}
}
Drag::SelectionHandle {
id,
rect,
handle,
grab,
} => {
let local_x = self.cursor.x - rect.x + grab.x;
let local_y = self.cursor.y - rect.y
+ grab.y
+ self.runtime.scroll.get(id).map(|s| s.1).unwrap_or(0.0);
let edit = self.runtime.edits.get(id).copied().unwrap_or_default();
let moved = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), *id))
.and_then(|widget| widget.cursor_at(local_x, local_y, rect.width, edit.cursor))
.and_then(|to| crate::selection::drag(edit, *handle, to));
if let Some(moved) = moved {
self.runtime.edits.insert(*id, moved);
}
}
Drag::Widget { id, rect, last_x } => {
let dx = self.cursor.x - *last_x;
*last_x = self.cursor.x;
self.apply_widget_drag(*id, *rect, dx);
}
Drag::Reorder { start, moved, .. } => {
let dx = self.cursor.x - start.x;
let dy = self.cursor.y - start.y;
if !*moved && (dx * dx + dy * dy) > slop * slop {
*moved = true;
}
if *moved {
self.request_redraw();
}
}
Drag::Gesture {
id,
axis,
start,
last,
moved,
} => {
let (id, axis) = (*id, *axis);
if !*moved {
let (tx, ty) = (self.cursor.x - start.x, self.cursor.y - start.y);
let travelled = match axis {
frus_widgets::PanAxis::Free => tx.hypot(ty),
frus_widgets::PanAxis::Horizontal => tx.abs(),
frus_widgets::PanAxis::Vertical => ty.abs(),
};
if travelled > slop {
*moved = true;
let local = self.pan_local(id, *start);
self.send_pan(id, frus_widgets::PanEvent::Start { local });
}
}
if *moved {
let delta = mask_pan(
axis,
Point::new(self.cursor.x - last.x, self.cursor.y - last.y),
);
*last = self.cursor;
self.track_gesture();
if delta.x != 0.0 || delta.y != 0.0 {
let local = self.pan_local(id, self.cursor);
self.send_pan(id, frus_widgets::PanEvent::Update { local, delta });
}
}
}
Drag::Pan {
id,
last,
moved,
viewport,
} => {
let dx = self.cursor.x - last.x;
let dy = self.cursor.y - last.y;
if !*moved && (dx * dx + dy * dy) > slop * slop {
*moved = true;
}
if *moved {
let view = self.runtime.interactive.entry(*id).or_default();
*view = view.pan(dx, dy).clamped(*viewport);
*last = self.cursor;
self.track_gesture();
}
}
Drag::Item {
source,
start,
moved,
over,
} => {
let dx = self.cursor.x - start.x;
let dy = self.cursor.y - start.y;
if !*moved && (dx * dx + dy * dy) > slop * slop {
*moved = true;
}
if !*moved {
return;
}
let payload = source.payload;
let candidate = self
.ui
.as_ref()
.and_then(|ui| ui.drop_zone_at(self.cursor))
.filter(|zone| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), zone.id))
.is_some_and(|widget| widget.accepts_drag(payload))
})
.map(|zone| zone.id);
if *over != candidate {
*over = candidate;
self.runtime.drag_over = candidate;
}
self.request_redraw();
}
Drag::Scroll {
id,
last,
moved,
carried,
dismiss,
axis,
pan,
} => {
let dx = self.cursor.x - last.x;
let dy = self.cursor.y - last.y;
if !*moved && (dx * dx + dy * dy) > slop * slop {
*moved = true;
if let Some((target, pan_axis)) = pan.take() {
let down = dy.abs() >= dx.abs();
let wins = match pan_axis {
frus_widgets::PanAxis::Horizontal => !down,
frus_widgets::PanAxis::Vertical => down,
frus_widgets::PanAxis::Free => {
let chain: Vec<_> = self
.ui
.as_ref()
.map(|ui| ui.scroll_chain(*last).collect())
.unwrap_or_default();
!chain.iter().any(|a| a.takes_user_scroll(down))
}
};
if wins {
self.runtime.release_scroll(*id);
let start = *last;
self.drag = Some(Drag::Gesture {
id: target,
axis: pan_axis,
start,
last: start,
moved: false,
});
self.handle_drag();
return;
}
}
if let Some(item) = dismiss.take() {
let along = if item.spec.axis.is_horizontal() {
dx.abs() > dy.abs()
} else {
dy.abs() > dx.abs()
};
if along {
self.runtime.release_scroll(*id);
self.drag = Some(Drag::Dismiss {
item,
last: *last,
moved: true,
});
self.handle_drag();
return;
}
}
let chain: Vec<_> = self
.ui
.as_ref()
.map(|ui| ui.scroll_chain(*last).collect())
.unwrap_or_default();
let down = dy.abs() >= dx.abs();
if let Some(area) = claim_area(&chain, *id, down) {
if area.id != *id {
self.runtime.release_scroll(*id);
let physics = area.physics;
*carried = self.runtime.catch_scroll_fling(area.id, physics);
self.runtime.hold_scroll(area.id);
*id = area.id;
}
*axis = Some(claim_axis(
dx,
dy,
area.takes_user_scroll(false),
area.takes_user_scroll(true),
));
} else {
*axis = Some(down);
}
}
let (dx, mut dy) = match *axis {
Some(true) => (0.0, dy),
Some(false) => (dx, 0.0),
None => (dx, dy),
};
if *moved && *axis == Some(true) {
let sheet = self
.ui
.as_ref()
.filter(|ui| ui.scroll_region(*id).is_some_and(|a| !a.reverse_y))
.and_then(|ui| ui.sheet_holding(*id))
.filter(|sheet| sheet.available > 0.0)
.cloned();
if let Some(sheet) = sheet {
let px = sheet.available;
let offset = self.runtime.scroll.get(id).map_or(0.0, |o| o.1);
let size = self.runtime.sheet_size(sheet.id, &sheet.spec) * px;
let (grown, listed) = frus_widgets::split_sheet_drag(
-dy,
offset,
size,
sheet.spec.floor() * px,
sheet.spec.max * px,
);
if grown != 0.0 {
self.runtime
.sheet_drag(sheet.id, &sheet.spec, grown / px, px);
}
dy = -listed;
}
}
if *moved {
let area = self.ui.as_ref().and_then(|u| u.scroll_region(*id));
let physics = area.map(|a| a.physics).unwrap_or_else(|| {
self.app
.scroll_behavior()
.scroll_physics(&self.themes.displayed(&self.app))
});
let cur = self.runtime.scroll.get(id).copied().unwrap_or((0.0, 0.0));
let axis = |metrics: frus_widgets::ScrollMetrics, delta: f32| {
let applied = physics.apply_user_offset(metrics, delta);
let proposed = metrics.pixels + applied;
let refused = physics.apply_boundary_conditions(metrics, proposed);
(proposed - refused, refused)
};
let (nx, ny, refused) = match area {
Some(area) => {
let (ddx, ddy) = area.offset_delta((dx, dy));
let (nx, rx) = axis(area.metrics_x(cur.0), ddx);
let (ny, ry) = axis(area.metrics_y(cur.1), ddy);
(nx, ny, Some((area, rx, ry)))
}
None => (cur.0 - dx, cur.1 - dy, None),
};
if let Some((area, rx, ry)) = refused {
if let Some(host) = area.refresh {
self.feed_refresh(host, physics, area.viewport.height, cur.1, ny, ry);
}
let cursor = self.cursor;
for (refused, vertical, extent) in [
(rx, false, area.viewport.width),
(ry, true, area.viewport.height),
] {
if refused.abs() < 1e-3 {
continue;
}
let edge = area.refused_edge(vertical, refused);
if area.refresh.is_some() && edge == area.start_edge(vertical) {
continue;
}
let (offset, cross) =
frus_widgets::glow_cross_axis(area.viewport, edge, cursor);
self.runtime.glow_pull(
area.id,
area.overscroll,
edge,
refused,
extent,
offset,
cross,
);
}
if rx.abs() < 1e-3 && ry.abs() < 1e-3 && (nx, ny) != cur {
self.runtime.stretch_release(area.id);
}
}
self.runtime.scroll.insert(*id, (nx, ny));
self.runtime.scroll_target.insert(*id, (nx, ny));
self.runtime.scroll_velocity.remove(id);
*last = self.cursor;
self.track_gesture();
}
}
Drag::Dismiss { item, last, moved } => {
let dx = self.cursor.x - last.x;
let dy = self.cursor.y - last.y;
if !*moved && (dx * dx + dy * dy) > slop * slop {
*moved = true;
}
if *moved {
let delta = if item.spec.axis.is_horizontal() {
dx
} else {
dy
};
self.runtime
.dismiss_drag(item.id, delta, item.extent(), item.spec.axis);
*last = self.cursor;
self.track_gesture();
}
}
Drag::Sheet {
id,
last,
moved,
available,
} => {
let dx = self.cursor.x - last.x;
let dy = self.cursor.y - last.y;
if !*moved && (dx * dx + dy * dy) > slop * slop {
*moved = true;
}
if *moved && *available > 0.0 {
let spec = self
.ui
.as_ref()
.and_then(|ui| ui.sheet(*id))
.map(|sheet| sheet.spec.clone());
if let Some(spec) = spec {
self.runtime
.sheet_drag(*id, &spec, -dy / *available, *available);
}
*last = self.cursor;
self.track_gesture();
}
}
Drag::Back { start_x } => {
let width = self.logical_width();
let sign = if self.is_rtl() { -1.0 } else { 1.0 };
let progress = (sign * (self.cursor.x - *start_x) / width).clamp(0.0, 1.0);
self.track_gesture();
self.build_dirty = true;
self.app.back_gesture(progress);
}
}
self.drag = Some(drag);
self.request_redraw();
}
fn refresh_host_of(&self, scrollable: WidgetId) -> Option<WidgetId> {
self.ui
.as_ref()
.and_then(|ui| ui.scroll_region(scrollable))
.and_then(|area| area.refresh)
}
fn release_refresh(&mut self, scrollable: WidgetId) {
let Some(host) = self.refresh_host_of(scrollable) else {
return;
};
if !self.runtime.refresh_release(host) {
return;
}
let message = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), host))
.and_then(|widget| widget.on_refresh());
if let Some(message) = message {
self.dispatch(message);
}
}
fn feed_refresh(
&mut self,
host: WidgetId,
physics: frus_widgets::ScrollPhysics,
extent: f32,
before: f32,
after: f32,
refused_y: f32,
) {
if after > 0.5 {
self.runtime.refresh_cancel(host);
return;
}
let pulled = if physics.allows_overscroll() {
(-after).max(0.0) - (-before).max(0.0)
} else {
-refused_y
};
if pulled.abs() > 1e-3 {
self.runtime.refresh_pull(host, pulled, extent);
}
}
fn hit_slop(&self) -> f32 {
if self.pointer_touch {
TOUCH_SLOP
} else {
PRECISE_SLOP
}
}
fn gesture_now(&self) -> f32 {
match self.gesture_clock {
Some((now, start)) => now - start,
None => (Instant::now() - self.gesture_start).as_secs_f32(),
}
}
fn begin_gesture(&mut self) {
self.gesture_velocity = self
.app
.scroll_behavior()
.velocity_tracker(&self.themes.displayed(&self.app));
self.gesture_start = Instant::now();
if let Some((now, start)) = self.gesture_clock.as_mut() {
*start = *now;
}
self.track_gesture();
}
fn track_gesture(&mut self) {
let now = self.gesture_now();
self.gesture_velocity.add_position(now, self.cursor);
}
fn gesture_estimate(&self) -> VelocityEstimate {
self.gesture_velocity
.estimate(self.gesture_now())
.unwrap_or(VelocityEstimate::STILL)
}
fn fling_velocity(&self, estimate: VelocityEstimate) -> (f32, f32) {
fling_velocity(estimate, self.hit_slop())
}
fn fling(&mut self, id: WidgetId, velocity: (f32, f32)) {
let Some(area) = self.ui.as_ref().and_then(|ui| ui.scroll_region(id)) else {
return;
};
let physics = area.physics;
if self.runtime.fling_scroll(area, physics, velocity) {
self.request_redraw();
}
}
fn reorderable_at(&self, point: Point) -> Option<(WidgetId, usize, bool)> {
let id = self.ui.as_ref()?.reorderable_at(point)?;
let widget = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))?;
if !widget.reorder_draggable() {
return None;
}
let from = widget.reorder_index()?;
Some((id, from, widget.drag_needs_long_press()))
}
fn reorder_source(&self, id: WidgetId, from: usize) -> Option<(WidgetId, Rect)> {
let ui = self.ui.as_ref()?;
let tree = self.tree.as_ref()?;
let own = ui.widget_rect(id)?;
let grabbed = find_widget(tree.as_ref(), id)?;
if grabbed.reorder_droppable() {
return Some((id, own));
}
let middle = Point::new(own.x + own.width * 0.5, own.y + own.height * 0.5);
let slot = ui.reorderables_at(middle).find(|other| {
find_widget(tree.as_ref(), *other)
.is_some_and(|w| w.reorder_droppable() && w.reorder_index() == Some(from))
})?;
Some((slot, ui.widget_rect(slot).unwrap_or(own)))
}
fn reorder_carried(&self) -> Option<(WidgetId, Rect)> {
match &self.drag {
Some(Drag::Reorder {
carried: Some(carried),
..
}) => Some(*carried),
Some(Drag::Reorder { id, from, .. }) => self.reorder_source(*id, *from),
_ => None,
}
}
fn keep_carried_box(&mut self) {
let source = match &self.drag {
Some(Drag::Reorder {
id,
from,
moved: true,
carried: None,
..
}) => self.reorder_source(*id, *from),
_ => return,
};
if let Some(Drag::Reorder { carried, .. }) = self.drag.as_mut() {
*carried = source;
}
}
fn reorder_target_at(&self, point: Point) -> Option<WidgetId> {
let ui = self.ui.as_ref()?;
let tree = self.tree.as_ref()?;
ui.reorderables_at(point)
.find(|id| find_widget(tree.as_ref(), *id).is_some_and(|w| w.reorder_droppable()))
}
fn reorder_drop_target(
&self,
id: WidgetId,
from: usize,
carried: Option<(WidgetId, Rect)>,
) -> Option<WidgetId> {
if let Some(target) = self.reorder_target_at(self.cursor) {
return Some(target);
}
let tree = self.tree.as_ref()?;
let Some(ReorderMotion::Slots(axis)) = find_widget(tree.as_ref(), id).map(reorder_motion)
else {
return None;
};
let row = match carried {
Some((row, _)) => row,
None => self.reorder_source(id, from)?.0,
};
let slots = reorder_siblings(self.ui.as_ref()?, tree.as_ref(), row);
let boxes: Vec<Rect> = slots.iter().map(|(_, rect)| *rect).collect();
nearest_reorder_slot(self.cursor, &boxes, axis).map(|index| slots[index].0)
}
fn carried_rect(&self) -> Option<Rect> {
match &self.drag {
Some(Drag::Reorder {
start, moved: true, ..
}) => {
let (_, src) = self.reorder_carried()?;
let (gx, gy) = ghost_offset(
self.dragged_reorder_motion()
.unwrap_or(ReorderMotion::Columns),
(self.cursor.x - start.x, self.cursor.y - start.y),
);
Some(src.translate(gx, gy))
}
Some(Drag::Item {
source,
start,
moved: true,
..
}) => Some(
source
.rect
.translate(self.cursor.x - start.x, self.cursor.y - start.y),
),
_ => None,
}
}
fn autoscroll_carried(&mut self, dt: f32) -> bool {
self.keep_carried_box();
let Some(carried) = self.carried_rect() else {
return false;
};
let area = {
let scroll = &self.runtime.scroll;
self.ui.as_ref().and_then(|ui| {
ui.scroll_chain(self.cursor).find(|area| {
area.accepts_user_offset(scroll.get(&area.id).copied().unwrap_or((0.0, 0.0)))
})
})
};
let Some(area) = area else {
return false;
};
let offset = self
.runtime
.scroll
.get(&area.id)
.copied()
.unwrap_or((0.0, 0.0));
let room = |offset: f32, max: f32, reverse: bool| {
if reverse {
(max - offset, offset)
} else {
(offset, max - offset)
}
};
let view = area.viewport;
let dx = edge_autoscroll(
(carried.x, carried.x + carried.width),
(view.x, view.x + view.width),
room(offset.0, area.max_x, area.reverse_x),
dt,
)
.unwrap_or(0.0);
let dy = edge_autoscroll(
(carried.y, carried.y + carried.height),
(view.y, view.y + view.height),
room(offset.1, area.max_y, area.reverse_y),
dt,
)
.unwrap_or(0.0);
if dx == 0.0 && dy == 0.0 {
return false;
}
let delta = area.offset_delta((dx, dy));
let next = (
(offset.0 + delta.0).clamp(0.0, area.max_x),
(offset.1 + delta.1).clamp(0.0, area.max_y),
);
if next == offset {
return false;
}
self.runtime.scroll.insert(area.id, next);
self.runtime.scroll_target.insert(area.id, next);
self.runtime.scroll_velocity.remove(&area.id);
if let Some(drag) = self.drag.as_mut() {
follow_content(
drag,
area.offset_delta((next.0 - offset.0, next.1 - offset.1)),
);
}
true
}
fn dragged_reorder_motion(&self) -> Option<ReorderMotion> {
let Some(Drag::Reorder { id, .. }) = self.drag else {
return None;
};
self.tree
.as_ref()
.and_then(|t| find_widget(t.as_ref(), id))
.map(reorder_motion)
}
fn advance_reorder_springs(&mut self, dt: f32) -> bool {
let reorder_motion = matches!(self.drag, Some(Drag::Reorder { moved: true, .. }))
.then(|| self.dragged_reorder_motion())
.flatten();
match reorder_motion {
Some(ReorderMotion::Columns) => {
self.reorder_x = spring_toward(self.reorder_x, self.cursor.x, dt, 0.07);
(self.cursor.x - self.reorder_x).abs() > 0.5
}
Some(ReorderMotion::Slots(ReorderAxis::Vertical)) => {
match self.reorder_drop_line(drag_preview::INSERT_THICKNESS) {
Some(target) => {
self.reorder_y = spring_toward(self.reorder_y, target.y, dt, 0.07);
(target.y - self.reorder_y).abs() > 0.5
}
None => false,
}
}
Some(ReorderMotion::Slots(ReorderAxis::Horizontal)) => {
match self.reorder_drop_line(drag_preview::INSERT_THICKNESS) {
Some(target) => {
self.reorder_x = spring_toward(self.reorder_x, target.x, dt, 0.07);
(target.x - self.reorder_x).abs() > 0.5
}
None => false,
}
}
None => false,
}
}
fn report_overflows(&self, ui: &Ui<A::Message>) {
for o in ui.overflows() {
let mut key = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
(
o.rect.width.to_bits(),
o.rect.height.to_bits(),
o.side as u8,
o.amount.round() as i32,
)
.hash(&mut key);
if !self.reported_overflows.borrow_mut().insert(key.finish()) {
continue;
}
let side = match o.side {
frus_widgets::Side::Left => "left",
frus_widgets::Side::Right => "right",
frus_widgets::Side::Top => "top",
frus_widgets::Side::Bottom => "bottom",
};
log::warn!(
"a box {:.0}x{:.0} is overflowed by {:.0} px on the {side}: its children do not \
fit inside it, and what runs past the edge is drawn outside its parent — \
invisible where something clips it, and untappable where it leaves the window. \
Give the child that should give way an `Expanded`, or put the content in a \
`SingleChildScrollView`.",
o.rect.width,
o.rect.height,
o.amount,
);
}
}
fn keystroke(&self, event: &KeyDown) -> Option<KeyStroke> {
let key = match &event.logical_key {
WinitKey::Named(NamedKey::Enter) => ShortcutKey::Enter,
WinitKey::Named(NamedKey::Escape) => ShortcutKey::Escape,
WinitKey::Named(NamedKey::Tab) => ShortcutKey::Tab,
WinitKey::Named(NamedKey::Space) => ShortcutKey::Space,
WinitKey::Named(NamedKey::Backspace) => ShortcutKey::Backspace,
WinitKey::Named(NamedKey::Delete) => ShortcutKey::Delete,
WinitKey::Named(NamedKey::ArrowUp) => ShortcutKey::Up,
WinitKey::Named(NamedKey::ArrowDown) => ShortcutKey::Down,
WinitKey::Named(NamedKey::ArrowLeft) => ShortcutKey::Left,
WinitKey::Named(NamedKey::ArrowRight) => ShortcutKey::Right,
WinitKey::Named(NamedKey::Home) => ShortcutKey::Home,
WinitKey::Named(NamedKey::End) => ShortcutKey::End,
WinitKey::Named(NamedKey::PageUp) => ShortcutKey::PageUp,
WinitKey::Named(NamedKey::PageDown) => ShortcutKey::PageDown,
WinitKey::Named(NamedKey::F1) => ShortcutKey::F(1),
WinitKey::Named(NamedKey::F2) => ShortcutKey::F(2),
WinitKey::Named(NamedKey::F3) => ShortcutKey::F(3),
WinitKey::Named(NamedKey::F4) => ShortcutKey::F(4),
WinitKey::Named(NamedKey::F5) => ShortcutKey::F(5),
WinitKey::Named(NamedKey::F6) => ShortcutKey::F(6),
WinitKey::Named(NamedKey::F7) => ShortcutKey::F(7),
WinitKey::Named(NamedKey::F8) => ShortcutKey::F(8),
WinitKey::Named(NamedKey::F9) => ShortcutKey::F(9),
WinitKey::Named(NamedKey::F10) => ShortcutKey::F(10),
WinitKey::Named(NamedKey::F11) => ShortcutKey::F(11),
WinitKey::Character(text) => ShortcutKey::Char(text.chars().next()?),
_ => return None,
};
Some(KeyStroke {
key,
ctrl: self.ctrl,
shift: self.shift,
alt: self.alt,
meta: self.meta,
})
}
fn paint_reorder_preview(&self, ui: &Ui<A::Message>, theme: &Theme, scene: &mut Scene) {
let Some(Drag::Reorder {
start, moved: true, ..
}) = self.drag
else {
return;
};
let Some((id, src)) = self.reorder_carried() else {
return;
};
let motion = self
.tree
.as_ref()
.and_then(|t| find_widget(t.as_ref(), id))
.map(reorder_motion)
.unwrap_or(ReorderMotion::Columns);
let dx = self.cursor.x - start.x;
let dy = self.cursor.y - start.y;
let (gx, gy) = ghost_offset(motion, (dx, dy));
let owners: std::collections::HashSet<u64> = self
.tree
.as_ref()
.and_then(|t| find_widget(t.as_ref(), id))
.map(|w| subtree_ids(w, id).iter().map(|i| i.as_u64()).collect())
.unwrap_or_else(|| std::iter::once(id.as_u64()).collect());
match motion {
ReorderMotion::Columns => {
let reflowed =
reflow_reorder_columns(scene.primitives(), src, self.reorder_x, id.as_u64());
scene.clear();
for primitive in reflowed {
scene.push_primitive(primitive);
}
}
ReorderMotion::Slots(axis) => {
let line =
self.reorder_drop_line(drag_preview::INSERT_THICKNESS)
.map(|r| match axis {
ReorderAxis::Vertical => Rect {
y: self.reorder_y,
..r
},
ReorderAxis::Horizontal => Rect {
x: self.reorder_x,
..r
},
});
let movable = self
.tree
.as_ref()
.map(|tree| reorderable_owners(ui, tree.as_ref()))
.unwrap_or_default();
let reflowed =
reflow_reorder_cards(scene.primitives(), src, line, &owners, &movable, axis);
scene.clear();
for primitive in reflowed {
scene.push_primitive(primitive);
}
if let Some(line) = line {
let thickness = match axis {
ReorderAxis::Vertical => line.height,
ReorderAxis::Horizontal => line.width,
};
scene.set_clip(Rect::UNBOUNDED);
scene.draw_rect(
line,
theme.primary,
theme.radius.min(thickness * 0.5),
0.0,
Color::TRANSPARENT,
);
}
}
}
let ghost: Vec<Primitive> = ui
.scene()
.primitives()
.iter()
.filter(|p| owners.contains(&p.owner()))
.map(|p| p.translated(gx, gy).with_clip(Rect::UNBOUNDED))
.collect();
draw_ghost_card(scene, theme, src.translate(gx, gy), &ghost);
}
fn paint_drag_ghost(&self, ui: &Ui<A::Message>, theme: &Theme, scene: &mut Scene) {
let Some(Drag::Item {
source,
start,
moved: true,
..
}) = &self.drag
else {
return;
};
let widget = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), source.id));
let owners: std::collections::HashSet<u64> = widget
.map(|w| {
subtree_ids(w, source.id)
.iter()
.map(|i| i.as_u64())
.collect()
})
.unwrap_or_else(|| std::iter::once(source.id.as_u64()).collect());
let opacity = widget.map(|w| w.drag_ghost_opacity()).unwrap_or(1.0);
let dx = self.cursor.x - start.x;
let dy = self.cursor.y - start.y;
let original: Vec<Primitive> = scene.primitives().to_vec();
scene.clear();
for primitive in &original {
if owners.contains(&primitive.owner()) {
scene.push_faded(primitive, opacity);
} else {
scene.push_primitive(primitive.clone());
}
}
let ghost: Vec<Primitive> = ui
.scene()
.primitives()
.iter()
.filter(|p| owners.contains(&p.owner()))
.map(|p| p.translated(dx, dy).with_clip(Rect::UNBOUNDED))
.collect();
draw_ghost_card(scene, theme, source.rect.translate(dx, dy), &ghost);
}
fn reorder_drop_line(&self, thickness: f32) -> Option<Rect> {
let Some(Drag::Reorder {
id, from, carried, ..
}) = self.drag
else {
return None;
};
let target = self.reorder_drop_target(id, from, carried)?;
let rect = self.ui.as_ref()?.widget_rect(target)?;
let axis = match self
.tree
.as_ref()
.and_then(|t| find_widget(t.as_ref(), target))
.map(reorder_motion)
{
Some(ReorderMotion::Slots(axis)) => axis,
_ => ReorderAxis::Vertical,
};
Some(drop_insertion_line(
rect,
thickness,
self.reorder_insert_after(target, rect),
axis,
self.is_rtl(),
))
}
fn reorder_insert_after(&self, target: WidgetId, rect: Rect) -> bool {
match self
.tree
.as_ref()
.and_then(|t| find_widget(t.as_ref(), target))
.map(reorder_motion)
{
Some(ReorderMotion::Slots(axis)) => {
reorder_drop_after(self.cursor, rect, axis, self.is_rtl())
}
_ => false,
}
}
fn pan_target(&self) -> Option<(WidgetId, frus_widgets::PanAxis)> {
let (id, _) = self.ui.as_ref()?.pan_at(self.cursor)?;
let axis = find_widget(self.tree.as_ref()?.as_ref(), id)?.pan_axis()?;
Some((id, axis))
}
fn pan_local(&self, id: WidgetId, at: Point) -> Point {
let origin = self
.ui
.as_ref()
.and_then(|ui| ui.widget_rect(id))
.map_or(Point::new(0.0, 0.0), |rect| Point::new(rect.x, rect.y));
Point::new(at.x - origin.x, at.y - origin.y)
}
fn send_pan(&mut self, id: WidgetId, event: frus_widgets::PanEvent) {
let rtl = self.is_rtl();
let value = self.runtime.value(id);
let (message, moved, released) = match self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
{
Some(widget) => {
let moved = widget.pan_value(event, value, rtl);
let released = match event {
frus_widgets::PanEvent::End { .. } if self.runtime.value_placed(id) => {
Some(widget.on_value_release(value))
}
_ => None,
};
(widget.on_pan(event), moved, released)
}
None => (None, None, None),
};
if let Some(v) = moved {
self.runtime.hold_value(id, v);
}
if let Some(release) = released {
self.runtime.release_value(id);
if let Some(message) = release {
self.dispatch(message);
}
}
if let Some(message) = message {
self.dispatch(message);
}
self.request_redraw();
}
fn dispatch_drag_edge(&mut self, id: WidgetId, rect: frus_widgets::Rect, edge: Edge) {
let (x, rtl) = (self.cursor.x, self.is_rtl());
let message = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| {
let fraction = widget.drag_track().fraction(rect, x, rtl);
match edge {
Edge::Start => widget.on_drag_start(fraction),
Edge::End => widget.on_drag_end(fraction),
}
});
if let Some(message) = message {
self.dispatch(message);
}
}
fn apply_widget_drag(&mut self, id: WidgetId, rect: frus_widgets::Rect, dx: f32) {
let (x, rtl) = (self.cursor.x, self.is_rtl());
let message = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| {
widget
.on_drag_delta(dx)
.or_else(|| widget.on_drag(widget.drag_track().fraction(rect, x, rtl)))
});
if let Some(message) = message {
self.dispatch(message);
}
}
fn key_down(&mut self, event: &KeyDown, event_loop: Option<&ActiveEventLoop>) {
if !self.runtime.focus_visible {
self.runtime.focus_visible = true;
self.request_redraw();
}
if matches!(
event.logical_key,
WinitKey::Named(NamedKey::BrowserBack) | WinitKey::Named(NamedKey::GoBack)
) {
if !event.repeat {
if let Some(event_loop) = event_loop {
self.system_back(event_loop);
}
}
return;
}
if matches!(event.logical_key, WinitKey::Named(NamedKey::F12)) {
if cfg!(debug_assertions) && !event.repeat {
self.inspector = !self.inspector;
self.inspector_dump = self.inspector;
self.request_redraw();
}
return;
}
if let Some(stroke) = self.keystroke(event) {
let typing = !stroke.is_command() && self.runtime.input.focused.is_some();
if !typing {
let msgs = self
.ui
.as_ref()
.map(|ui| ui.keystroke(stroke, self.runtime.input.focused))
.unwrap_or_default();
if !msgs.is_empty() {
for msg in msgs {
self.dispatch(msg);
}
self.request_redraw();
return;
}
}
}
if matches!(event.logical_key, WinitKey::Named(NamedKey::Tab)) {
let forward = !self.shift;
let next = self
.ui
.as_ref()
.and_then(|ui| ui.focus_next(self.runtime.input.focused, forward));
if next.is_some() {
self.runtime.input.focused = next;
self.runtime.region = None;
self.reveal_focus();
self.request_redraw();
}
return;
}
if matches!(event.logical_key, WinitKey::Named(NamedKey::Escape)) {
if !event.repeat {
self.escape();
}
return;
}
let Some(focused) = self.runtime.input.focused else {
self.region_key(event);
return;
};
let arrow = match event.logical_key {
WinitKey::Named(NamedKey::ArrowUp) => Some(FocusDirection::Up),
WinitKey::Named(NamedKey::ArrowDown) => Some(FocusDirection::Down),
WinitKey::Named(NamedKey::ArrowLeft) => Some(FocusDirection::Left),
WinitKey::Named(NamedKey::ArrowRight) => Some(FocusDirection::Right),
_ => None,
};
if matches!(
event.logical_key,
WinitKey::Named(NamedKey::PageUp) | WinitKey::Named(NamedKey::PageDown)
) {
let down = matches!(event.logical_key, WinitKey::Named(NamedKey::PageDown));
if self.move_caret_vertical(focused, down, true) {
return;
}
}
if let Some(direction) = arrow {
if matches!(direction, FocusDirection::Up | FocusDirection::Down) {
let down = matches!(direction, FocusDirection::Down);
if self.move_caret_vertical(focused, down, false) {
return;
}
}
if matches!(direction, FocusDirection::Left | FocusDirection::Right) {
let key = if matches!(direction, FocusDirection::Left) {
Key::Left {
shift: self.shift,
word: self.ctrl,
}
} else {
Key::Right {
shift: self.shift,
word: self.ctrl,
}
};
let widget = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), focused));
let reorder_from = widget.and_then(|w| w.reorder_index());
let handled = widget.map(|w| w.on_key(&key));
if let Some(KeyResponse::Handled(message)) = handled {
if let Some(message) = message {
self.dispatch(message);
}
if let Some(from) = reorder_from {
let to = if matches!(direction, FocusDirection::Left) {
from.wrapping_sub(1)
} else {
from + 1
};
self.set_announcement(format!("Column moved to position {}", to + 1));
}
self.request_redraw();
return;
}
}
let is_text = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), focused))
.is_some_and(is_text_field);
let navigates =
!is_text || matches!(direction, FocusDirection::Up | FocusDirection::Down);
if navigates {
if let Some(next) = self
.ui
.as_ref()
.and_then(|ui| ui.focus_directional(focused, direction))
{
self.runtime.input.focused = Some(next);
self.reveal_focus();
self.request_redraw();
}
return;
}
}
if matches!(
event.logical_key,
WinitKey::Named(NamedKey::Home) | WinitKey::Named(NamedKey::End)
) {
let key = if matches!(event.logical_key, WinitKey::Named(NamedKey::Home)) {
Key::Home {
shift: self.shift,
doc: self.ctrl,
}
} else {
Key::End {
shift: self.shift,
doc: self.ctrl,
}
};
let handled = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), focused))
.map(|widget| widget.on_key(&key));
if let Some(KeyResponse::Handled(message)) = handled {
if let Some(message) = message {
self.dispatch(message);
}
self.request_redraw();
return;
}
}
if matches!(
event.logical_key,
WinitKey::Named(NamedKey::Enter) | WinitKey::Named(NamedKey::Space)
) {
let widget = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), focused))
.filter(|widget| widget.focusable());
let message = widget.and_then(|widget| widget.on_click());
if let Some(message) = message {
if !event.repeat {
let announce = widget.and_then(|widget| widget.announce());
self.dispatch(message);
if let Some(announce) = announce {
self.set_announcement(announce);
}
self.request_redraw();
}
return;
}
}
if let Some(command) = clipboard_command(&event.logical_key, event.physical_key, self.ctrl)
{
match command {
ClipCommand::Copy => {
self.copy_selection(focused);
}
ClipCommand::Cut => self.cut_selection(focused),
ClipCommand::Paste => {
if let Some(pasted) = self.clipboard.paste(focused, self.window.as_ref()) {
self.land_paste(pasted);
}
}
}
return;
}
if opens_selection_bar(&event.logical_key, self.shift)
&& self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), focused))
.is_some_and(|widget| widget.text_value().is_some())
{
self.show_selection_toolbar(focused);
return;
}
if self.ctrl {
match &event.logical_key {
WinitKey::Character(c) if c.eq_ignore_ascii_case("a") => {
self.select_all(focused);
return;
}
WinitKey::Character(c) if c.eq_ignore_ascii_case("z") && !self.shift => {
self.step_history(focused, false);
self.request_redraw();
return;
}
WinitKey::Character(c)
if c.eq_ignore_ascii_case("y")
|| (c.eq_ignore_ascii_case("z") && self.shift) =>
{
self.step_history(focused, true);
self.request_redraw();
return;
}
_ => {}
}
}
#[cfg(android)]
if crate::android_ime::installed() {
return;
}
let shift = self.shift;
let key = match &event.logical_key {
WinitKey::Named(NamedKey::Backspace) => Some(Key::Backspace),
WinitKey::Named(NamedKey::Delete) => Some(Key::Delete),
WinitKey::Named(NamedKey::Enter) if !event.repeat => Some(Key::Enter),
WinitKey::Named(NamedKey::Enter) => None,
WinitKey::Named(NamedKey::ArrowLeft) => Some(Key::Left {
shift,
word: self.ctrl,
}),
WinitKey::Named(NamedKey::ArrowRight) => Some(Key::Right {
shift,
word: self.ctrl,
}),
WinitKey::Named(NamedKey::Home) => Some(Key::Home {
shift,
doc: self.ctrl,
}),
WinitKey::Named(NamedKey::End) => Some(Key::End {
shift,
doc: self.ctrl,
}),
WinitKey::Named(NamedKey::Space) => Some(Key::Text(" ".to_string())),
WinitKey::Character(c) if c == "\n" || c == "\r" => {
if event.repeat {
None
} else {
Some(Key::Enter)
}
}
_ => event.text.as_ref().map(|text| Key::Text(text.to_string())),
};
if let Some(key) = key {
self.apply_key(focused, key);
self.request_redraw();
}
}
fn system_back(&mut self, event_loop: &ActiveEventLoop) {
if !self.system_back_step() {
event_loop.exit();
}
}
fn system_back_step(&mut self) -> bool {
if self.close_selection_toolbar() {
return true;
}
if self
.runtime
.region
.as_ref()
.is_some_and(|selection| selection.bar)
{
self.runtime.region = None;
self.request_redraw();
return true;
}
if let Some(message) = self.ui.as_ref().and_then(|ui| ui.top_dismiss()) {
self.dispatch(message);
self.request_redraw();
return true;
}
if self.app.can_go_back() {
self.build_dirty = true;
self.app.go_back();
self.request_redraw();
return true;
}
false
}
fn escape(&mut self) {
if self.close_selection_toolbar() {
return;
}
if self.runtime.region.take().is_some() {
self.request_redraw();
return;
}
let outcome: Option<Option<A::Message>> = self.runtime.input.focused.and_then(|focused| {
let tree = self.tree.as_ref()?;
let path = find_path(tree.as_ref(), focused);
for widget in path.iter().rev() {
match widget.on_key(&Key::Escape) {
KeyResponse::Handled(message) => return Some(message),
KeyResponse::Skip => return Some(None),
KeyResponse::Ignored => {}
}
}
None
});
match outcome {
Some(message) => {
if let Some(message) = message {
self.dispatch(message);
self.request_redraw();
}
}
None => {
if let Some(message) = self.ui.as_ref().and_then(|ui| ui.top_dismiss()) {
self.dispatch(message);
self.request_redraw();
}
}
}
}
fn move_caret_vertical(&mut self, id: WidgetId, down: bool, page: bool) -> bool {
let width = self
.ui
.as_ref()
.and_then(|ui| ui.widget_rect(id))
.map(|r| r.width)
.unwrap_or(0.0);
let cursor = self.runtime.edits.get(&id).map(|e| e.cursor).unwrap_or(0);
let moved = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.caret_vertical(width, cursor, down, page, self.goal_x));
let Some((new_cursor, goal)) = moved else {
return false;
};
let shift = self.shift;
let edit = self.runtime.edits.entry(id).or_default();
if shift {
if edit.anchor.is_none() {
edit.anchor = Some(edit.cursor);
}
} else {
edit.anchor = None;
}
edit.cursor = new_cursor;
self.goal_x = Some(goal);
self.reveal_caret(id, new_cursor);
self.request_redraw();
true
}
fn select_held_word(&mut self, id: WidgetId) {
let cursor = self.runtime.edits.get(&id).map(|e| e.cursor).unwrap_or(0);
let word = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.word_at(cursor))
.filter(|(start, end)| start < end);
let Some((start, end)) = word else {
self.show_selection_toolbar(id);
return;
};
self.runtime.edits.insert(
id,
Edit {
cursor: end,
anchor: Some(start),
composing: None,
},
);
self.runtime.selection_handles = Some(id);
self.runtime.close_edit_run(id);
self.show_selection_toolbar(id);
#[cfg(android)]
self.push_ime_context(id);
}
fn grab_selection_handle(&self) -> Option<Drag> {
let id = self.runtime.selection_handles?;
if self.runtime.input.focused != Some(id) {
return None;
}
let rect = self.ui.as_ref()?.widget_rect(id)?;
let edit = self.runtime.edits.get(&id).copied()?;
let scroll_y = self.runtime.scroll.get(&id).map(|s| s.1).unwrap_or(0.0);
let handles = find_widget(self.tree.as_ref()?.as_ref(), id)?
.selection_handles(rect.width, &edit, scroll_y)?;
let local = Point::new(self.cursor.x - rect.x, self.cursor.y - rect.y);
let handle = crate::selection::grab(&handles, local)?;
let at = handles[handle.index()].line_center;
Some(Drag::SelectionHandle {
id,
rect,
handle,
grab: Point::new(at.x - local.x, at.y - local.y),
})
}
fn apply_key(&mut self, id: WidgetId, key: Key) {
self.goal_x = None;
self.runtime.selection_handles = None;
self.runtime.selection_toolbar = None;
let was = self.runtime.edits.get(&id).copied().unwrap_or_default();
let mut edit = was;
let widget = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id));
let before = widget
.and_then(|widget| widget.text_value())
.map(str::to_owned);
let announce = widget.and_then(|widget| widget.announce());
let message = widget.and_then(|widget| widget.on_edit(&mut edit, &key));
self.runtime.edits.insert(id, edit);
self.reveal_caret(id, edit.cursor);
if let Some(message) = message {
self.dispatch_edit(message);
if let Some(announce) = announce {
self.set_announcement(announce);
}
}
let after = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.text_value())
.map(str::to_owned);
match (before, after) {
(Some(before), Some(after)) if before != after => {
let kind = EditKind::of(&key, was.selection_range().is_some(), self.composing());
let since = self.since_last_edit();
self.runtime
.record_edit(id, EditSnapshot::new(before, was), kind, since);
}
(Some(_), _) => self.runtime.close_edit_run(id),
_ => {}
}
}
fn dispatch_edit(&mut self, message: A::Message) {
self.dispatch(message);
if let Some((width, height)) = self.last_size {
let theme = self.themes.displayed(&self.app);
let tree = self
.media_query(width, height)
.scope(|| build_view(&self.app, &theme, &self.runtime));
self.tree = Some(tree);
}
}
fn since_last_edit(&mut self) -> f32 {
let now = Instant::now();
let since = self
.last_edit_at
.map(|then| now.duration_since(then).as_secs_f32())
.unwrap_or(f32::INFINITY);
self.last_edit_at = Some(now);
since
}
fn composing(&self) -> bool {
self.runtime
.input
.focused
.and_then(|id| self.runtime.edits.get(&id))
.is_some_and(|edit| edit.composing.is_some())
}
fn step_history(&mut self, id: WidgetId, forward: bool) -> bool {
let value = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.text_value())
.map(str::to_owned);
let Some(value) = value else {
return false;
};
let edit = self.runtime.edits.get(&id).copied().unwrap_or_default();
let current = EditSnapshot::new(value, edit);
let target = if forward {
self.runtime.redo_edit(id, current)
} else {
self.runtime.undo_edit(id, current)
};
let Some(target) = target else {
return false;
};
let message = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.replace_value(target.value.clone()));
self.runtime.edits.insert(id, target.edit);
self.reveal_caret(id, target.edit.cursor);
if let Some(message) = message {
self.dispatch_edit(message);
}
self.last_edit_at = None;
true
}
fn reveal_focus(&mut self) {
let Some(id) = self.runtime.input.focused else {
return;
};
let Some(moves) = self.ui.as_ref().map(|ui| ui.reveal(id, &self.runtime)) else {
return;
};
for (area, offset) in moves {
self.runtime.scroll_ballistic.remove(&area);
self.runtime.scroll_velocity.remove(&area);
self.runtime.scroll_target.insert(area, offset);
}
}
fn reveal_caret(&mut self, id: WidgetId, cursor: usize) {
let Some(vp) = self.ui.as_ref().and_then(|ui| ui.scrollable_viewport(id)) else {
return;
};
let metrics = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.text_metrics(vp.width, cursor));
let Some((content_h, visible_h, caret_top, caret_h)) = metrics else {
return;
};
let max_y = (content_h - visible_h).max(0.0);
let cur = self.runtime.scroll.get(&id).map(|s| s.1).unwrap_or(0.0);
let lo = (caret_top + caret_h - visible_h).max(0.0);
let hi = caret_top;
let target = cur.clamp(lo.min(hi), lo.max(hi)).clamp(0.0, max_y);
self.runtime.scroll.insert(id, (0.0, target));
self.runtime.scroll_target.insert(id, (0.0, target));
self.runtime.scroll_velocity.remove(&id);
}
fn copy_selection(&mut self, id: WidgetId) -> bool {
let edit = self.runtime.edits.get(&id).copied().unwrap_or_default();
let text = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| widget.selected_text(&edit));
match text {
Some(text) => {
self.clipboard.set_text(text);
true
}
None => false,
}
}
fn region_stops(&self, area: WidgetId) -> Vec<(WidgetId, usize)> {
let (Some(ui), Some(tree)) = (self.ui.as_ref(), self.tree.as_ref()) else {
return Vec::new();
};
ui.text_stops_in(area)
.filter_map(|stop| {
let text = find_widget(tree.as_ref(), stop.id)?.selection_text()?;
Some((stop.id, text.chars().count()))
})
.collect()
}
fn region_selection(
&self,
area: WidgetId,
anchor: RegionPoint,
extent: RegionPoint,
) -> RegionSelection {
RegionSelection::new(area, anchor, extent, &self.region_stops(area))
}
fn region_index(&self, stop: &TextStop, at: Point) -> Option<usize> {
find_widget(self.tree.as_ref()?.as_ref(), stop.id)?.selection_hit(
at.x - stop.rect.x,
at.y - stop.rect.y,
stop.rect.width,
)
}
fn begin_region_selection(&mut self) {
let Some(stop) = self.region_press_target() else {
return;
};
let Some(index) = self.region_index(&stop, self.cursor) else {
return;
};
let now = Instant::now();
let here = self.cursor;
let double = self.last_region_click.is_some_and(|(then, there)| {
(now - then).as_secs_f32() < 0.4
&& (here.x - there.x).hypot(here.y - there.y) <= REGION_DOUBLE_CLICK_SLOP
});
self.last_region_click = Some((now, here));
let word = double
.then(|| {
let tree = self.tree.as_ref()?;
let text = find_widget(tree.as_ref(), stop.id)?.selection_text()?;
Some(frus_widgets::word_bounds(text, index))
})
.flatten();
let (anchor, extent) = match word {
Some((start, end)) => (
RegionPoint {
text: stop.id,
index: start,
},
RegionPoint {
text: stop.id,
index: end,
},
),
None => {
let point = RegionPoint {
text: stop.id,
index,
};
(point, point)
}
};
self.runtime.region = Some(self.region_selection(stop.area, anchor, extent));
self.drag = word
.is_none()
.then_some(Drag::RegionSelect { area: stop.area });
self.request_redraw();
}
fn region_press_target(&self) -> Option<TextStop> {
let ui = self.ui.as_ref()?;
let claimed = self
.runtime
.input
.pressed
.is_some_and(|pressed| ui.msg_for(pressed).is_some());
if claimed {
return None;
}
ui.text_stop_at(self.cursor)
}
fn select_region_word_held(&mut self) -> bool {
let Some(stop) = self.ui.as_ref().and_then(|ui| ui.text_stop_at(self.cursor)) else {
return false;
};
let Some(index) = self.region_index(&stop, self.cursor) else {
return false;
};
let word = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), stop.id))
.and_then(|widget| widget.selection_text())
.map(|text| frus_widgets::word_bounds(text, index))
.filter(|(start, end)| start < end);
let Some((start, end)) = word else {
return false;
};
let anchor = RegionPoint {
text: stop.id,
index: start,
};
let extent = RegionPoint {
text: stop.id,
index: end,
};
self.runtime.region = Some(
self.region_selection(stop.area, anchor, extent)
.with_bar(true),
);
self.request_redraw();
true
}
fn region_handles(&self) -> Option<(WidgetId, [frus_widgets::SelectionHandle; 2])> {
let selection = self.runtime.region.as_ref().filter(|s| s.handles)?;
let (ui, tree) = (self.ui.as_ref()?, self.tree.as_ref()?);
let at = |point: RegionPoint, start: bool| {
let stop = ui
.text_stops_in(selection.area)
.find(|s| s.id == point.text)?;
let handle = find_widget(tree.as_ref(), point.text)?.selection_grip(
stop.rect.width,
point.index,
start,
)?;
let (dx, dy) = (stop.rect.x, stop.rect.y);
Some(frus_widgets::SelectionHandle {
rect: handle.rect.translate(dx, dy),
line_center: Point::new(handle.line_center.x + dx, handle.line_center.y + dy),
})
};
Some((
selection.area,
[at(selection.start?, true)?, at(selection.end?, false)?],
))
}
fn grab_region_handle(&self) -> Option<Drag> {
let (area, handles) = self.region_handles()?;
let handle = crate::selection::grab(&handles, self.cursor)?;
let at = handles[handle.index()].line_center;
Some(Drag::RegionHandle {
area,
handle,
grab: Point::new(at.x - self.cursor.x, at.y - self.cursor.y),
})
}
fn drag_region_handle(
&mut self,
area: WidgetId,
handle: crate::selection::Handle,
grab: Point,
) {
let Some(selection) = self.runtime.region.as_ref() else {
return;
};
let (Some(start), Some(end)) = (selection.start, selection.end) else {
return;
};
let target = Point::new(self.cursor.x + grab.x, self.cursor.y + grab.y);
let Some(stop) = self
.ui
.as_ref()
.and_then(|ui| ui.nearest_text_stop(area, target))
else {
return;
};
let Some(index) = self.region_index(&stop, target) else {
return;
};
let moved = RegionPoint {
text: stop.id,
index,
};
let stops = self.region_stops(area);
let order = |point: RegionPoint| {
stops
.iter()
.position(|(id, _)| *id == point.text)
.map(|at| (at, point.index))
};
let (Some(to), Some(other)) = (
order(moved),
order(match handle {
crate::selection::Handle::Start => end,
crate::selection::Handle::End => start,
}),
) else {
return;
};
let (fixed, crosses) = match handle {
crate::selection::Handle::Start => (end, to >= other),
crate::selection::Handle::End => (start, to <= other),
};
if crosses {
return;
}
let next = RegionSelection::new(area, fixed, moved, &stops).with_bar(true);
let next = RegionSelection { bar: false, ..next };
if self.runtime.region.as_ref() != Some(&next) {
self.runtime.region = Some(next);
self.request_redraw();
}
}
fn extend_region_selection(&mut self, area: WidgetId) {
let Some(anchor) = self.runtime.region.as_ref().map(|region| region.anchor) else {
return;
};
let Some(stop) = self
.ui
.as_ref()
.and_then(|ui| ui.nearest_text_stop(area, self.cursor))
else {
return;
};
let Some(index) = self.region_index(&stop, self.cursor) else {
return;
};
let extent = RegionPoint {
text: stop.id,
index,
};
let selection = self.region_selection(area, anchor, extent);
if self.runtime.region.as_ref() != Some(&selection) {
self.runtime.region = Some(selection);
self.request_redraw();
}
}
fn region_text(&self) -> Option<String> {
let region = self.runtime.region.as_ref()?;
let (ui, tree) = (self.ui.as_ref()?, self.tree.as_ref()?);
let texts: Vec<(WidgetId, &str)> = ui
.text_stops_in(region.area)
.filter_map(|stop| {
let text = find_widget(tree.as_ref(), stop.id)?.selection_text()?;
Some((stop.id, text))
})
.collect();
let copied = frus_widgets::region_copy(&texts, ®ion.ranges);
(!copied.is_empty()).then_some(copied)
}
fn copy_region(&mut self) -> bool {
match self.region_text() {
Some(copied) => {
self.clipboard.set_text(copied);
true
}
None => false,
}
}
fn select_all_region(&mut self) {
let Some(area) = self.runtime.region.as_ref().map(|region| region.area) else {
return;
};
let stops = self.region_stops(area);
let (Some(first), Some(last)) = (stops.first(), stops.last()) else {
return;
};
let anchor = RegionPoint {
text: first.0,
index: 0,
};
let extent = RegionPoint {
text: last.0,
index: last.1,
};
let bar = self
.runtime
.region
.as_ref()
.is_some_and(|selection| selection.bar);
self.runtime.region = Some(self.region_selection(area, anchor, extent).with_bar(bar));
self.request_redraw();
}
fn region_key(&mut self, event: &KeyDown) -> bool {
if self.runtime.region.is_none() {
return false;
}
if let Some(ClipCommand::Copy) =
clipboard_command(&event.logical_key, event.physical_key, self.ctrl)
{
self.copy_region();
return true;
}
let select_all = self.ctrl
&& matches!(&event.logical_key, WinitKey::Character(c) if c.eq_ignore_ascii_case("a"));
if select_all {
self.select_all_region();
return true;
}
false
}
fn cut_selection(&mut self, id: WidgetId) {
if self.copy_selection(id) {
self.apply_key(id, Key::Backspace);
self.request_redraw();
}
}
fn select_all(&mut self, id: WidgetId) {
self.runtime.edits.insert(
id,
Edit {
cursor: usize::MAX,
anchor: Some(0),
composing: None,
},
);
self.runtime.close_edit_run(id);
self.request_redraw();
}
fn show_selection_toolbar(&mut self, id: WidgetId) {
self.show_selection_toolbar_at(id, None);
}
fn show_selection_toolbar_at(&mut self, id: WidgetId, at: Option<Point>) {
let can_paste = self.clipboard.has_text();
self.runtime.selection_toolbar = Some(ToolbarMark { id, can_paste, at });
self.request_redraw();
}
fn close_selection_toolbar(&mut self) -> bool {
if self.runtime.selection_toolbar.is_none() {
return false;
}
self.runtime.selection_handles = None;
self.hide_selection_toolbar();
true
}
fn hide_selection_toolbar(&mut self) {
if self.runtime.selection_toolbar.take().is_some() {
self.request_redraw();
}
}
fn perform_edit_action(&mut self, action: frus_widgets::EditAction) {
use frus_widgets::EditAction;
if self
.runtime
.region
.as_ref()
.is_some_and(|selection| selection.bar)
{
match action {
EditAction::Copy => {
self.copy_region();
self.runtime.region = None;
}
EditAction::SelectAll => self.select_all_region(),
EditAction::Cut | EditAction::Paste => {}
}
self.request_redraw();
return;
}
let Some(id) = self.runtime.selection_toolbar.map(|mark| mark.id) else {
return;
};
match action {
EditAction::Copy => {
if self.copy_selection(id) {
if let Some(edit) = self.runtime.edits.get_mut(&id) {
*edit = collapsed_to_end(*edit);
}
self.runtime.close_edit_run(id);
}
self.runtime.selection_handles = None;
self.hide_selection_toolbar();
}
EditAction::Cut => {
self.cut_selection(id);
self.hide_selection_toolbar();
}
EditAction::Paste => {
self.runtime.selection_handles = None;
self.hide_selection_toolbar();
if let Some(pasted) = self.clipboard.paste(id, self.window.as_ref()) {
self.land_paste(pasted);
}
}
EditAction::SelectAll => {
self.select_all(id);
if self.runtime.selection_handles.is_some() {
self.runtime.selection_handles = Some(id);
}
if select_all_hides_bar(frus_widgets::default_target_platform()) {
self.runtime.selection_handles = None;
self.hide_selection_toolbar();
} else {
let at = self.runtime.selection_toolbar.and_then(|mark| mark.at);
self.show_selection_toolbar_at(id, at);
}
}
}
#[cfg(android)]
self.push_ime_context(id);
}
fn context_click(&mut self) {
let secondary = self
.ui
.as_ref()
.and_then(|ui| ui.hit(self.cursor))
.and_then(|id| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
})
.and_then(|widget| widget.on_secondary_tap());
if let Some(message) = secondary {
self.hide_selection_toolbar();
self.dispatch(message);
self.request_redraw();
return;
}
let hit = self.ui.as_ref().and_then(|ui| ui.focus_hit(self.cursor));
let Some((id, rect)) = hit else {
self.hide_selection_toolbar();
return;
};
let was_focused = self.runtime.input.focused == Some(id);
let scroll_y = self.runtime.scroll.get(&id).map(|s| s.1).unwrap_or(0.0);
let scroll_cursor = if was_focused {
self.runtime.edits.get(&id).map(|e| e.cursor).unwrap_or(0)
} else {
0
};
let cursor = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| {
widget.cursor_at(
self.cursor.x - rect.x,
self.cursor.y - rect.y + scroll_y,
rect.width,
scroll_cursor,
)
});
let Some(cursor) = cursor else {
self.hide_selection_toolbar();
return;
};
let inside = was_focused
&& self
.runtime
.edits
.get(&id)
.and_then(|edit| edit.selection_range())
.is_some_and(|(start, end)| cursor >= start && cursor <= end);
self.runtime.input.focused = Some(id);
self.runtime.selection_handles = None;
if !inside {
self.goal_x = None;
self.runtime.edits.insert(
id,
Edit {
cursor,
anchor: None,
composing: None,
},
);
self.runtime.close_edit_run(id);
}
self.show_selection_toolbar_at(id, Some(self.cursor));
}
fn land_paste(&mut self, pasted: clip::Pasted) {
if pasted.lands(self.runtime.input.focused) {
self.apply_key(pasted.into, Key::Text(pasted.text));
self.request_redraw();
}
}
}
fn collapsed_to_end(edit: Edit) -> Edit {
let cursor = edit.selection_range().map_or(edit.cursor, |(_, end)| end);
Edit {
cursor,
anchor: None,
composing: None,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Edge {
Start,
End,
}
fn cursor_icon(cursor: UiCursor) -> CursorIcon {
match cursor {
UiCursor::Default | UiCursor::None => CursorIcon::Default,
UiCursor::Pointer => CursorIcon::Pointer,
UiCursor::Text => CursorIcon::Text,
UiCursor::Forbidden => CursorIcon::NotAllowed,
UiCursor::Wait => CursorIcon::Wait,
UiCursor::Progress => CursorIcon::Progress,
UiCursor::Help => CursorIcon::Help,
UiCursor::Precise => CursorIcon::Crosshair,
UiCursor::Move => CursorIcon::Move,
UiCursor::Grab => CursorIcon::Grab,
UiCursor::Grabbing => CursorIcon::Grabbing,
UiCursor::ResizeColumn => CursorIcon::ColResize,
UiCursor::ResizeRow => CursorIcon::RowResize,
UiCursor::ZoomIn => CursorIcon::ZoomIn,
UiCursor::ZoomOut => CursorIcon::ZoomOut,
UiCursor::ContextMenu => CursorIcon::ContextMenu,
}
}
fn mask_pan(axis: frus_widgets::PanAxis, value: Point) -> Point {
match axis {
frus_widgets::PanAxis::Free => value,
frus_widgets::PanAxis::Horizontal => Point::new(value.x, 0.0),
frus_widgets::PanAxis::Vertical => Point::new(0.0, value.y),
}
}
fn gesture_was_a_tap(ended: Option<&Drag>) -> bool {
matches!(
ended,
Some(
Drag::Scroll { moved: false, .. }
| Drag::Pan { moved: false, .. }
| Drag::Gesture { moved: false, .. }
| Drag::Reorder { moved: false, .. }
| Drag::Item { moved: false, .. }
| Drag::Dismiss { moved: false, .. }
| Drag::Sheet { moved: false, .. }
)
)
}
fn press_at<Msg: Clone>(runtime: &mut Runtime, ui: Option<&Ui<Msg>>, at: Point) -> bool {
runtime.input.pressed = ui.and_then(|ui| ui.hit(at));
let Some(pressed) = runtime.input.pressed else {
return false;
};
let Some(rect) = ui.and_then(|ui| ui.ink_box(pressed)) else {
return false;
};
runtime.ink_press(
pressed,
Point::new(at.x - rect.x, at.y - rect.y),
Size::new(rect.width, rect.height),
);
true
}
fn settle_press(
input: &mut frus_widgets::InputState,
kind: PointerKind,
could_tap: bool,
drag: Option<&Drag>,
touch: bool,
) -> bool {
let taken = match kind {
PointerKind::Down => false,
PointerKind::Move => could_tap && !gesture_was_a_tap(drag),
PointerKind::Up | PointerKind::Cancel => true,
};
if !taken {
return false;
}
let mut changed = input.pressed.take().is_some();
if touch {
changed |= input.hovered.take().is_some();
}
changed
}
const AUTOSCROLL_VELOCITY: f32 = 50.0;
const AUTOSCROLL_MAX_OVERHANG: f32 = 20.0;
fn apply_scroll_requests<Msg>(
runtime: &mut Runtime,
tree: &dyn Widget<Msg>,
requests: Vec<(u64, ScrollTo)>,
regions: &[Scrollable],
) -> (Vec<(u64, ScrollTo)>, bool) {
let mut unplaced = Vec::new();
let mut moved = false;
for (key, to) in requests {
let area = find_by_key(tree, key).and_then(|id| regions.iter().find(|a| a.id == id));
match area {
Some(area) => moved |= runtime.scroll_to(area, to),
None => unplaced.push((key, to)),
}
}
(unplaced, moved)
}
fn apply_sheet_requests<Msg>(
runtime: &mut Runtime,
tree: &dyn Widget<Msg>,
requests: Vec<(u64, SheetTo)>,
sheets: &[frus_widgets::SheetArea],
) -> (Vec<(u64, SheetTo)>, bool) {
let mut unplaced = Vec::new();
let mut moved = false;
for (key, to) in requests {
let sheet = frus_widgets::find_sheet_by_key(tree, key)
.and_then(|id| sheets.iter().find(|sheet| sheet.id == id));
match sheet {
Some(sheet) => moved |= runtime.sheet_to(sheet.id, &sheet.spec, &to),
None => unplaced.push((key, to)),
}
}
(unplaced, moved)
}
fn follow_content(drag: &mut Drag, shift: (f32, f32)) {
match drag {
Drag::Reorder { start, carried, .. } => {
start.x += shift.0;
start.y += shift.1;
if let Some((_, rect)) = carried {
*rect = rect.translate(shift.0, shift.1);
}
}
Drag::Item { source, start, .. } => {
start.x += shift.0;
start.y += shift.1;
source.rect = source.rect.translate(shift.0, shift.1);
}
_ => {}
}
}
fn edge_autoscroll(item: (f32, f32), view: (f32, f32), room: (f32, f32), dt: f32) -> Option<f32> {
let before = view.0 - item.0; let after = item.1 - view.1; let overhang = if before > 0.0 && room.0 > 0.0 {
before.min(AUTOSCROLL_MAX_OVERHANG)
} else if after > 0.0 && room.1 > 0.0 {
-after.min(AUTOSCROLL_MAX_OVERHANG)
} else {
return None;
};
let travel = overhang * AUTOSCROLL_VELOCITY * dt;
(travel != 0.0).then_some(travel)
}
fn spring_toward(current: f32, target: f32, dt: f32, tau: f32) -> f32 {
let k = 1.0 - (-dt / tau.max(1e-4)).exp();
current + (target - current) * k
}
const FOCUS_HISTORY_MAX: usize = 8;
fn resolve_focus(
current: Option<WidgetId>,
present: &std::collections::HashSet<WidgetId>,
history: &mut Vec<WidgetId>,
prev: &mut Option<WidgetId>,
) -> Option<WidgetId> {
let mut cur = current;
if let Some(c) = cur {
if !present.contains(&c) {
cur = None;
while let Some(cand) = history.pop() {
if present.contains(&cand) {
cur = Some(cand);
break;
}
}
}
}
if *prev != cur {
if let Some(old) = *prev {
if present.contains(&old) && Some(old) != cur {
history.push(old);
if history.len() > FOCUS_HISTORY_MAX {
history.remove(0);
}
}
}
*prev = cur;
}
cur
}
fn draw_ghost_card(scene: &mut Scene, theme: &Theme, card: Rect, ghost: &[Primitive]) {
use drag_preview::{BORDER_ALPHA, BORDER_WIDTH, SHADOW_ALPHA, SHADOW_BLUR, SHADOW_OFFSET_Y};
scene.set_clip(Rect::UNBOUNDED);
let shadow = theme.scheme.shadow.with_alpha(SHADOW_ALPHA);
scene.shadow(
card.translate(0.0, SHADOW_OFFSET_Y),
shadow,
theme.radius,
SHADOW_BLUR,
);
let border = theme.primary.fade(BORDER_ALPHA);
if ghost.is_empty() {
scene.draw_rect(card, theme.surface, theme.radius, BORDER_WIDTH, border);
} else {
scene.draw_rect(card, theme.surface, theme.radius, 0.0, Color::TRANSPARENT);
for primitive in ghost {
scene.push_primitive(primitive.clone());
}
scene.draw_rect(card, Color::TRANSPARENT, theme.radius, BORDER_WIDTH, border);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReorderMotion {
Columns,
Slots(ReorderAxis),
}
impl ReorderMotion {
fn of(axis: ReorderAxis, inserts: bool) -> Self {
match axis {
ReorderAxis::Vertical => Self::Slots(ReorderAxis::Vertical),
ReorderAxis::Horizontal if inserts => Self::Slots(ReorderAxis::Horizontal),
ReorderAxis::Horizontal => Self::Columns,
}
}
}
fn reorder_motion<Msg>(widget: &dyn Widget<Msg>) -> ReorderMotion {
ReorderMotion::of(widget.reorder_axis(), widget.reorder_inserts())
}
fn ghost_offset(motion: ReorderMotion, travel: (f32, f32)) -> (f32, f32) {
match motion {
ReorderMotion::Columns => (travel.0, drag_preview::LIFT_Y),
ReorderMotion::Slots(ReorderAxis::Vertical) => travel,
ReorderMotion::Slots(ReorderAxis::Horizontal) => (travel.0, 0.0),
}
}
fn drop_insertion_line(
target: Rect,
thickness: f32,
after: bool,
axis: ReorderAxis,
rtl: bool,
) -> Rect {
match axis {
ReorderAxis::Vertical => {
let edge = if after {
target.y + target.height
} else {
target.y
};
Rect::new(target.x, edge - thickness * 0.5, target.width, thickness)
}
ReorderAxis::Horizontal => {
let edge = if after != rtl {
target.x + target.width
} else {
target.x
};
Rect::new(edge - thickness * 0.5, target.y, thickness, target.height)
}
}
}
fn claim_axis(dx: f32, dy: f32, can_x: bool, can_y: bool) -> bool {
match (can_x, can_y) {
(true, false) => false,
(false, true) => true,
_ => dy.abs() >= dx.abs(),
}
}
fn claim_area(
chain: &[frus_widgets::Scrollable],
under: WidgetId,
down: bool,
) -> Option<frus_widgets::Scrollable> {
chain
.iter()
.find(|a| a.takes_user_scroll(down))
.or_else(|| {
chain
.iter()
.find(|a| a.id == under && a.physics.user() != frus_widgets::UserScroll::Never)
})
.copied()
}
#[cfg(feature = "net")]
fn fetch_image_bytes(
url: &str,
deliver: Box<dyn FnOnce(Result<Vec<u8>, String>) + Send + 'static>,
) {
const DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
let url = url.to_string();
let run = async move {
let result = crate::net::Request::get(url)
.timeout(DEADLINE)
.send_bytes()
.await;
deliver(result.map_err(|e| e.to_string()));
};
#[cfg(not(web))]
crate::runtime::spawn(run).detach();
#[cfg(web)]
wasm_bindgen_futures::spawn_local(run);
}
#[cfg(test)]
mod tests {
use super::{
build_view, claim_area, claim_axis, draw_ghost_card, drop_insertion_line, fling_velocity,
gesture_was_a_tap, ghost_offset, install_ambient, reorder_motion, resolve_focus,
spring_toward, Drag, Point, Rect, ReorderAxis, ReorderMotion, Scene, Theme,
VelocityEstimate, PRECISE_SLOP, TOUCH_SLOP,
};
use super::{clipboard_command, ClipCommand, KeyCode, PhysicalKey, WinitKey};
use super::{collect_ids, find_widget, MediaQuery};
use frus_widgets::Locale;
#[test]
fn select_all_hides_the_bar_on_the_desktops() {
use frus_widgets::TargetPlatform as P;
for p in [P::MacOs, P::Linux, P::Windows] {
assert!(super::select_all_hides_bar(p), "{p:?}");
}
for p in [P::Android, P::Ios, P::Fuchsia] {
assert!(!super::select_all_hides_bar(p), "{p:?}");
}
}
#[test]
fn the_context_menu_key_and_shift_f10_open_the_bar() {
use super::opens_selection_bar;
assert!(opens_selection_bar(
&WinitKey::Named(winit::keyboard::NamedKey::ContextMenu),
false
));
assert!(opens_selection_bar(
&WinitKey::Named(winit::keyboard::NamedKey::F10),
true
));
assert!(!opens_selection_bar(
&WinitKey::Named(winit::keyboard::NamedKey::F10),
false
));
assert!(!opens_selection_bar(
&WinitKey::Named(winit::keyboard::NamedKey::Enter),
true
));
assert!(!opens_selection_bar(&character("m"), true));
}
#[test]
fn a_copy_collapses_the_selection_to_its_end() {
use frus_widgets::Edit;
let forwards = Edit {
cursor: 11,
anchor: Some(6),
composing: None,
};
let backwards = Edit {
cursor: 6,
anchor: Some(11),
composing: None,
};
let expect = Edit {
cursor: 11,
anchor: None,
composing: None,
};
assert_eq!(super::collapsed_to_end(forwards), expect);
assert_eq!(super::collapsed_to_end(backwards), expect);
let caret = Edit {
cursor: 3,
anchor: None,
composing: None,
};
assert_eq!(super::collapsed_to_end(caret), caret);
let empty = Edit {
cursor: 3,
anchor: Some(3),
composing: Some((1, 2)),
};
assert_eq!(super::collapsed_to_end(empty).anchor, None);
assert_eq!(super::collapsed_to_end(empty).cursor, 3);
}
#[test]
fn a_field_with_a_clear_button_still_wants_the_keyboard() {
use frus_widgets::{Container, Icons, TextField};
let clearable = TextField::<()>::new("F")
.suffix_icon(Icons::CLOSE)
.on_suffix(());
assert!(
super::wants_keyboard(&clearable),
"the field with its clear button"
);
assert!(
!super::wants_keyboard(&TextField::<()>::new("F").enabled(false)),
"a disabled field takes no typing"
);
assert!(
!super::wants_keyboard(&Container::<()>::new()),
"and what is not a field wants no keyboard"
);
}
#[test]
fn a_field_with_a_clear_button_is_a_text_field_and_keeps_its_arrows() {
use frus_widgets::{Container, Icons, TextField};
let clearable = TextField::<()>::new("hello")
.suffix_icon(Icons::CLOSE)
.on_suffix(());
assert!(
frus_widgets::Widget::<()>::cursor_at(&clearable, 0.0, 0.0, 1.0, 0).is_none(),
"the trap: the probe finds no caret at the corner a clear button covers"
);
assert!(
super::is_text_field(&clearable),
"and it is a text field all the same"
);
assert!(
super::is_text_field(&TextField::<()>::new("")),
"empty, too"
);
assert!(
!super::is_text_field(&Container::<()>::new()),
"what is not a field leaves its arrows to focus navigation"
);
}
#[test]
fn a_text_that_can_only_be_read_wants_no_keyboard() {
use frus_widgets::{Text, TextField};
let read_only = TextField::<()>::new("REF-4417").read_only();
assert!(super::is_text_field(&read_only), "it has a caret");
assert!(
!super::wants_keyboard(&read_only),
"and no use for a keyboard"
);
let selectable = Text::new("ORDER-4417").selectable();
assert!(
super::is_text_field::<()>(&selectable),
"the arrows move its caret"
);
assert!(!super::wants_keyboard::<()>(&selectable));
assert!(
super::wants_keyboard(&TextField::<()>::new("typed")),
"and a field that can be typed into still does"
);
assert!(
!super::is_text_field::<()>(&Text::new("ORDER-4417")),
"an ordinary text leaves its arrows to focus navigation"
);
}
fn character(c: &str) -> WinitKey {
WinitKey::Character(c.into())
}
#[test]
fn ctrl_c_x_v_are_the_clipboard_and_the_letters_alone_are_not() {
let any = PhysicalKey::Code(KeyCode::KeyQ);
for (letter, command) in [
("c", ClipCommand::Copy),
("X", ClipCommand::Cut),
("v", ClipCommand::Paste),
] {
assert_eq!(
clipboard_command(&character(letter), any, true),
Some(command)
);
assert_eq!(
clipboard_command(&character(letter), any, false),
None,
"{letter}"
);
}
assert_eq!(clipboard_command(&character("a"), any, true), None);
}
#[test]
fn a_keyboards_own_clipboard_keys_work_by_their_physical_code() {
let unidentified = WinitKey::Unidentified(winit::keyboard::NativeKey::Unidentified);
for (code, command) in [
(KeyCode::Copy, ClipCommand::Copy),
(KeyCode::Cut, ClipCommand::Cut),
(KeyCode::Paste, ClipCommand::Paste),
] {
assert_eq!(
clipboard_command(&unidentified, PhysicalKey::Code(code), false),
Some(command),
"{code:?}"
);
}
let named = WinitKey::Named(super::NamedKey::Paste);
let any = PhysicalKey::Code(KeyCode::KeyQ);
assert_eq!(
clipboard_command(&named, any, false),
Some(ClipCommand::Paste)
);
}
#[test]
fn a_paste_lands_in_the_field_that_asked_only_while_it_has_the_focus() {
use super::clip::Pasted;
let field = WidgetId::from_u64(1);
let other = WidgetId::from_u64(2);
let pasted = Pasted {
into: field,
text: "copied".into(),
};
assert!(pasted.lands(Some(field)));
assert!(
!pasted.lands(Some(other)),
"the focus moved to another field"
);
assert!(!pasted.lands(None), "the focus left every field");
let nothing = Pasted {
into: field,
text: String::new(),
};
assert!(!nothing.lands(Some(field)), "an empty clipboard");
}
#[test]
fn a_paste_answered_later_waits_for_the_frame_that_takes_it() {
use super::clip::{Answers, Pasted};
use std::cell::Cell;
use std::rc::Rc;
let first = WidgetId::from_u64(1);
let second = WidgetId::from_u64(2);
let answers = Answers::default();
let wakes = Rc::new(Cell::new(0));
let wake = || {
let wakes = Rc::clone(&wakes);
move || wakes.set(wakes.get() + 1)
};
let to_first = answers.answer_for(first, wake());
let to_second = answers.answer_for(second, wake());
assert_eq!(answers.take(), None, "nothing is answered yet");
assert_eq!(wakes.get(), 0, "asking is not answering");
to_second("later".into());
assert_eq!(wakes.get(), 1);
to_first("sooner".into());
assert_eq!(wakes.get(), 2);
assert_eq!(
answers.take(),
Some(Pasted {
into: second,
text: "later".into()
})
);
assert_eq!(
answers.take(),
Some(Pasted {
into: first,
text: "sooner".into()
})
);
assert_eq!(answers.take(), None, "each answer is taken once");
}
struct Deferred;
impl crate::Application for Deferred {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn frus_widgets::Widget<()>> {
Box::new(frus_widgets::ThemeBuilder::new(|_: &Theme| {
frus_widgets::Flex::column()
.width(300.0)
.height(80.0)
.child(
frus_widgets::TextField::new("hi")
.width(200.0)
.on_input(|_| ()),
)
}))
}
}
fn surfaced<R>(f: impl FnOnce() -> R) -> R {
MediaQuery::new(frus_widgets::Size::new(300.0, 80.0)).scope(f)
}
#[test]
fn a_shell_installs_the_reader_s_language() {
struct Speaks(Option<Locale>);
impl crate::Application for Speaks {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn frus_widgets::Widget<()>> {
Box::new(frus_widgets::Flex::<()>::column())
}
fn supported_locales(&self) -> Vec<Locale> {
vec![
Locale::new("en"),
Locale::new("fr"),
Locale::with_country("fr", "CA"),
]
}
fn locale(&self) -> Option<Locale> {
self.0.clone()
}
}
install_ambient(&Speaks(None), &[Locale::with_country("fr", "BE")]);
assert_eq!(frus_widgets::locale::of(), Locale::new("fr"));
install_ambient(&Speaks(None), &[]);
assert_eq!(frus_widgets::locale::of(), Locale::new("en"));
install_ambient(
&Speaks(Some(Locale::with_country("fr", "FR"))),
&[Locale::new("en")],
);
assert_eq!(frus_widgets::locale::of(), Locale::new("fr"));
install_ambient(&Deferred, &[Locale::new("fr")]);
assert_eq!(
frus_widgets::locale::of(),
Locale::new("en"),
"it supports only English, so French resolves to it"
);
}
#[test]
fn a_shell_installs_the_application_s_words() {
struct Fr;
impl frus_widgets::localizations::Localizations for Fr {
fn back_button_label(&self) -> &str {
"Retour"
}
fn first_day_of_week_index(&self) -> usize {
1
}
}
struct Speaks;
impl crate::Application for Speaks {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn frus_widgets::Widget<()>> {
Box::new(frus_widgets::Flex::<()>::column())
}
fn localizations(&self) -> Option<std::rc::Rc<dyn frus_widgets::Localizations>> {
Some(std::rc::Rc::new(Fr))
}
}
install_ambient(&Speaks, &[]);
assert_eq!(
frus_widgets::localizations::of().back_button_label(),
"Retour",
"the shell read the application's table and installed it"
);
assert_eq!(
frus_widgets::localizations::of().first_day_of_week_index(),
1
);
install_ambient(&Deferred, &[]);
assert_eq!(
frus_widgets::localizations::of().back_button_label(),
"Retour",
"saying nothing is not the same as saying English"
);
}
#[test]
fn the_scroll_behaviour_at_the_root_is_the_applications() {
struct Quiet;
impl crate::Application for Quiet {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn frus_widgets::Widget<()>> {
Box::new(frus_widgets::Flex::<()>::column())
}
fn scroll_behavior(&self) -> frus_widgets::ScrollBehavior {
frus_widgets::ScrollBehavior::material().with_overscroll(false)
}
}
struct Silent;
impl crate::Application for Silent {
type Message = ();
fn update(&mut self, _message: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn frus_widgets::Widget<()>> {
Box::new(frus_widgets::Flex::<()>::column())
}
}
install_ambient(&Quiet, &[]);
assert_eq!(
frus_widgets::ScrollConfiguration::of(),
frus_widgets::ScrollBehavior::material().with_overscroll(false)
);
install_ambient(&Silent, &[]);
assert_eq!(
frus_widgets::ScrollConfiguration::of(),
frus_widgets::ScrollBehavior::material()
);
}
#[test]
fn a_view_is_built_ready_to_be_read() {
let theme = Theme::default();
let prepared =
surfaced(|| build_view(&Deferred, &theme, &frus_widgets::Runtime::default()));
let ids = collect_ids(prepared.as_ref());
assert!(
ids.len() > 1,
"an application inside a deferred subtree has identities to mount: {ids:?}"
);
for id in &ids {
assert!(
find_widget(prepared.as_ref(), *id).is_some(),
"{id:?} was counted but cannot be found"
);
}
}
#[test]
#[should_panic(expected = "before it was built")]
fn an_unprepared_view_is_the_state_that_broke() {
let theme = Theme::default();
let raw = surfaced(|| crate::Application::view(&Deferred, &theme));
let _ = collect_ids(raw.as_ref());
}
#[test]
fn a_swipe_that_never_started_is_still_a_tap() {
let item = frus_widgets::Dismissable {
id: WidgetId::from_u64(1),
rect: Rect::new(0.0, 0.0, 300.0, 62.0),
spec: frus_widgets::DismissSpec::default(),
};
let dismiss = |moved| Drag::Dismiss {
item,
last: Point::new(0.0, 0.0),
moved,
};
assert!(
gesture_was_a_tap(Some(&dismiss(false))),
"a swipe under the threshold leaves the click alone"
);
assert!(
!gesture_was_a_tap(Some(&dismiss(true))),
"a swipe that ran has already been answered and must not also click"
);
let scroll = Drag::Scroll {
id: WidgetId::from_u64(2),
last: Point::new(0.0, 0.0),
moved: false,
carried: (0.0, 0.0),
dismiss: None,
axis: None,
pan: None,
};
assert!(gesture_was_a_tap(Some(&scroll)));
let sheet = |moved| Drag::Sheet {
id: WidgetId::from_u64(4),
last: Point::new(0.0, 0.0),
moved,
available: 800.0,
};
assert!(gesture_was_a_tap(Some(&sheet(false))));
assert!(!gesture_was_a_tap(Some(&sheet(true))));
let select = Drag::TextSelect {
id: WidgetId::from_u64(3),
rect: Rect::new(0.0, 0.0, 10.0, 10.0),
};
assert!(!gesture_was_a_tap(Some(&select)));
assert!(!gesture_was_a_tap(None), "no gesture, no verdict to give");
}
#[test]
fn a_scroll_gesture_is_claimed_by_one_axis() {
assert!(claim_axis(6.0, 40.0, true, true), "down the page");
assert!(!claim_axis(40.0, 6.0, true, true), "across it");
assert!(claim_axis(10.0, 10.0, true, true));
assert!(claim_axis(40.0, 6.0, false, true), "a list only goes down");
assert!(
!claim_axis(6.0, 40.0, true, false),
"a strip only goes across"
);
}
fn area(id: u64, max_x: f32, max_y: f32) -> frus_widgets::Scrollable {
frus_widgets::Scrollable {
id: WidgetId::from_u64(id),
viewport: Rect::new(0.0, 0.0, 100.0, 100.0),
max_x,
max_y,
axis: frus_widgets::Axis::Vertical,
physics: frus_widgets::ScrollPhysics::CLAMPING,
overscroll: Some(frus_widgets::OverscrollIndicator::Glow),
fling: frus_widgets::VelocityStrategy::Regression,
refresh: None,
page: None,
reverse_x: false,
reverse_y: false,
host: None,
keep_visible: None,
}
}
#[test]
fn the_gesture_goes_to_the_area_that_can_take_it() {
let strip = area(1, 400.0, 0.0);
let page = area(2, 0.0, 900.0);
let chain = [strip, page]; assert_eq!(
claim_area(&chain, strip.id, true).map(|a| a.id),
Some(page.id),
"down the page, over the strip"
);
assert_eq!(
claim_area(&chain, strip.id, false).map(|a| a.id),
Some(strip.id),
"across the strip"
);
assert_eq!(
claim_area(&[strip], strip.id, true).map(|a| a.id),
Some(strip.id)
);
let inner = area(3, 0.0, 200.0);
assert_eq!(
claim_area(&[inner, strip, page], inner.id, true).map(|a| a.id),
Some(inner.id)
);
}
#[test]
fn the_physics_have_the_last_word_on_a_finger() {
let mut inner = area(1, 0.0, 300.0);
inner.physics = frus_widgets::ScrollPhysics::NEVER;
assert!(!inner.accepts_user_offset((0.0, 0.0)));
assert!(!inner.takes_user_scroll(true));
let outer = area(2, 0.0, 600.0);
let chain = [inner, outer];
assert_eq!(
claim_area(&chain, WidgetId::from_u64(1), true).map(|a| a.id),
Some(WidgetId::from_u64(2)),
"the page around it scrolls"
);
assert_eq!(
claim_area(&[inner], WidgetId::from_u64(1), true).map(|a| a.id),
None,
"and alone it keeps nothing"
);
let mut short = area(3, 0.0, 0.0);
short.physics = frus_widgets::ScrollPhysics::ALWAYS;
assert!(short.accepts_user_offset((0.0, 0.0)));
assert!(short.takes_user_scroll(true), "along its axis");
assert!(!short.takes_user_scroll(false), "and not across it");
assert_eq!(
claim_area(&[short, outer], WidgetId::from_u64(3), true).map(|a| a.id),
Some(WidgetId::from_u64(3)),
"a short list that always scrolls keeps the drag from the page"
);
let mut strip = short;
strip.axis = frus_widgets::Axis::Horizontal;
assert_eq!(
claim_area(&[strip, outer], WidgetId::from_u64(3), true).map(|a| a.id),
Some(WidgetId::from_u64(2)),
"a strip that always scrolls across leaves a drag down to the page"
);
let mut both = short;
both.axis = frus_widgets::Axis::Both;
assert!(both.takes_user_scroll(false) && both.takes_user_scroll(true));
}
#[test]
fn an_area_whose_content_fits_takes_no_finger() {
let fits = area(1, 0.0, 0.0);
assert!(!fits.accepts_user_offset((0.0, 0.0)));
assert!(fits.accepts_user_offset((0.0, 12.0)));
let mut pullable = fits;
pullable.refresh = Some(WidgetId::from_u64(9));
assert!(pullable.accepts_user_offset((0.0, 0.0)));
assert!(area(1, 0.0, 1.0).accepts_user_offset((0.0, 0.0)));
assert!(area(1, 1.0, 0.0).accepts_user_offset((0.0, 0.0)));
}
use frus_widgets::WidgetId;
use std::collections::HashSet;
fn released(velocity: (f32, f32), offset: (f32, f32)) -> VelocityEstimate {
VelocityEstimate {
velocity: frus_widgets::Velocity::new(velocity.0, velocity.1),
confidence: 1.0,
duration: 0.05,
offset,
}
}
#[test]
fn a_fast_twitch_that_went_nowhere_is_not_a_fling() {
let (x, y) = fling_velocity(released((0.0, 2000.0), (0.0, 3.0)), TOUCH_SLOP);
assert_eq!((x, y), (0.0, 0.0));
let (_, y) = fling_velocity(released((0.0, 2000.0), (0.0, 60.0)), TOUCH_SLOP);
assert_eq!(y, 2000.0);
}
#[test]
fn the_fling_gate_is_per_axis() {
let (x, y) = fling_velocity(released((300.0, 1500.0), (5.0, 120.0)), TOUCH_SLOP);
assert_eq!(x, 0.0, "the wobble is not a horizontal fling");
assert_eq!(y, 1500.0, "the swipe still flings vertically");
}
#[test]
fn a_precise_pointer_needs_almost_no_travel() {
let estimate = released((0.0, 900.0), (0.0, 3.0));
assert_eq!(fling_velocity(estimate, TOUCH_SLOP).1, 0.0);
assert_eq!(fling_velocity(estimate, PRECISE_SLOP).1, 900.0);
}
#[test]
fn insertion_line_sits_on_the_target_top_edge() {
let line = drop_insertion_line(
Rect::new(20.0, 100.0, 200.0, 44.0),
4.0,
false,
ReorderAxis::Vertical,
false,
);
assert_eq!(line.x, 20.0, "aligned to the target's left");
assert_eq!(line.width, 200.0, "the target's full width");
assert_eq!(line.y, 98.0, "centred on the top edge (100 - 4/2)");
assert_eq!(line.height, 4.0);
}
#[test]
fn insertion_line_sits_on_the_target_bottom_edge_when_inserting_after() {
let line = drop_insertion_line(
Rect::new(20.0, 100.0, 200.0, 44.0),
4.0,
true,
ReorderAxis::Vertical,
false,
);
assert_eq!(line.x, 20.0, "still aligned to the left");
assert_eq!(line.width, 200.0, "the target's full width");
assert_eq!(line.y, 142.0, "centred on the bottom edge (144 - 4/2)");
assert_eq!(line.height, 4.0);
let rtl = drop_insertion_line(
Rect::new(20.0, 100.0, 200.0, 44.0),
4.0,
true,
ReorderAxis::Vertical,
true,
);
assert_eq!(rtl, line, "right to left changes nothing down a list");
}
#[test]
fn a_horizontal_insertion_line_stands_on_the_edge_the_row_goes_to() {
let chip = Rect::new(100.0, 20.0, 80.0, 48.0);
let line = |after, rtl| drop_insertion_line(chip, 4.0, after, ReorderAxis::Horizontal, rtl);
let before = line(false, false);
assert_eq!(
(before.x, before.y, before.width, before.height),
(98.0, 20.0, 4.0, 48.0),
"before a row: its left edge, the full height, 4 wide"
);
assert_eq!(
line(true, false).x,
178.0,
"after it: its right edge (180 - 4/2)"
);
assert_eq!(
line(true, true).x,
98.0,
"after, right to left: the left edge"
);
assert_eq!(
line(false, true).x,
178.0,
"before, right to left: the right edge"
);
}
#[test]
fn a_horizontal_reorderable_inserts_only_when_it_says_so() {
assert_eq!(
ReorderMotion::of(ReorderAxis::Horizontal, false),
ReorderMotion::Columns,
"a table's column"
);
assert_eq!(
ReorderMotion::of(ReorderAxis::Horizontal, true),
ReorderMotion::Slots(ReorderAxis::Horizontal),
"a horizontal list's row"
);
for inserts in [false, true] {
assert_eq!(
ReorderMotion::of(ReorderAxis::Vertical, inserts),
ReorderMotion::Slots(ReorderAxis::Vertical)
);
}
assert_eq!(
reorder_motion::<()>(&frus_widgets::Container::new()),
ReorderMotion::Columns
);
for (axis, expected) in [
(
ReorderAxis::Vertical,
ReorderMotion::Slots(ReorderAxis::Vertical),
),
(
ReorderAxis::Horizontal,
ReorderMotion::Slots(ReorderAxis::Horizontal),
),
] {
let list = frus_widgets::ReorderableList::<()>::new(|_, _| ())
.axis(axis)
.row(frus_widgets::Container::new().width(60.0));
let row = frus_widgets::Widget::children(&list)[0].as_ref();
assert_eq!(reorder_motion(row), expected, "{axis:?}");
}
}
#[test]
fn a_horizontal_rows_ghost_follows_the_finger_along_x_only() {
let travel = (120.0, 35.0);
assert_eq!(
ghost_offset(ReorderMotion::Columns, travel),
(120.0, super::drag_preview::LIFT_Y)
);
assert_eq!(
ghost_offset(ReorderMotion::Slots(ReorderAxis::Vertical), travel),
(120.0, 35.0)
);
assert_eq!(
ghost_offset(ReorderMotion::Slots(ReorderAxis::Horizontal), travel),
(120.0, 0.0)
);
}
#[test]
fn focus_returns_to_trigger_when_overlay_closes() {
let anchor = WidgetId::from_u64(1);
let item = WidgetId::from_u64(2);
let (mut history, mut prev) = (Vec::new(), None);
let f = resolve_focus(
Some(anchor),
&HashSet::from([anchor]),
&mut history,
&mut prev,
);
assert_eq!(f, Some(anchor));
assert!(history.is_empty());
let present = HashSet::from([anchor, item]);
let f = resolve_focus(Some(item), &present, &mut history, &mut prev);
assert_eq!(f, Some(item));
assert_eq!(history, vec![anchor]);
let f = resolve_focus(
Some(item),
&HashSet::from([anchor]),
&mut history,
&mut prev,
);
assert_eq!(f, Some(anchor), "focus returns to the trigger");
assert!(history.is_empty());
}
#[test]
fn focus_falls_to_none_when_no_trigger_remains() {
let a = WidgetId::from_u64(1);
let (mut history, mut prev) = (Vec::new(), Some(a));
let f = resolve_focus(Some(a), &HashSet::new(), &mut history, &mut prev);
assert_eq!(f, None);
assert_eq!(prev, None);
}
#[test]
fn spring_approaches_target_monotonically_and_settles() {
let mut x = 0.0;
let mut prev = -1.0;
for _ in 0..30 {
x = spring_toward(x, 100.0, 0.016, 0.07);
assert!(x > prev && x <= 100.0, "monotonic and bounded: {x}");
prev = x;
}
assert!(x > 99.0, "all but reached after ~0.5 s: {x}");
}
#[test]
fn ghost_card_shape() {
let theme = Theme::default();
let card = Rect::new(140.0, 0.0, 80.0, 34.0);
let mut scene = Scene::new();
draw_ghost_card(&mut scene, &theme, card, &[]);
assert_eq!(scene.primitives().len(), 2, "shadow plus solid card");
}
}
#[cfg(test)]
mod sheet_request_tests {
use super::{apply_sheet_requests, Runtime, Widget};
use crate::command::Command;
use frus_widgets::{
build_ui, keyed, Container, DraggableScrollableSheet, SheetTo, Size, Theme,
};
fn view() -> Box<dyn Widget<()>> {
Box::new(
Container::<()>::new()
.width(400.0)
.height(800.0)
.child(keyed(
"places",
DraggableScrollableSheet::<()>::new(Container::new()),
)),
)
}
#[test]
fn a_request_reaches_the_sheet_the_view_named() {
let tree = view();
let mut runtime = Runtime::default();
let sheets = build_ui(
tree.as_ref(),
Size::new(400.0, 800.0),
&runtime,
&Theme::default(),
)
.sheets()
.to_vec();
let sheet = sheets.first().expect("the page has its sheet").clone();
let requests = Command::<()>::sheet("places", SheetTo::size(0.9))
.into_parts()
.sheets;
let (unplaced, moved) =
apply_sheet_requests(&mut runtime, tree.as_ref(), requests, &sheets);
assert!(unplaced.is_empty());
assert!(moved);
assert_eq!(runtime.sheet_size(sheet.id, &sheet.spec), 0.9);
let elsewhere = Command::<()>::sheet("elsewhere", SheetTo::size(0.3))
.into_parts()
.sheets;
let (unplaced, moved) =
apply_sheet_requests(&mut runtime, tree.as_ref(), elsewhere, &sheets);
assert_eq!(unplaced.len(), 1);
assert!(!moved);
let not_yet = Command::<()>::sheet("places", SheetTo::size(0.3))
.into_parts()
.sheets;
let (unplaced, _) = apply_sheet_requests(&mut runtime, tree.as_ref(), not_yet, &[]);
assert_eq!(unplaced.len(), 1, "kept for the frame that has the sheet");
assert_eq!(
runtime.sheet_size(sheet.id, &sheet.spec),
0.9,
"nothing moved"
);
}
}
#[cfg(test)]
mod scroll_request_tests {
use super::{apply_scroll_requests, Rect, Runtime, Scrollable, Widget, WidgetId};
use crate::command::Command;
use frus_widgets::{keyed, Container, ScrollTo, SingleChildScrollView};
fn view() -> Box<dyn Widget<()>> {
Box::new(Container::<()>::new().child(keyed(
"log",
SingleChildScrollView::<()>::new().height(200.0),
)))
}
fn region(id: WidgetId) -> Scrollable {
Scrollable {
id,
viewport: Rect {
x: 0.0,
y: 0.0,
width: 300.0,
height: 200.0,
},
max_x: 0.0,
max_y: 1000.0,
axis: frus_widgets::Axis::Vertical,
physics: frus_widgets::ScrollPhysics::CLAMPING,
overscroll: Some(frus_widgets::OverscrollIndicator::Glow),
fling: frus_widgets::VelocityStrategy::Regression,
refresh: None,
page: None,
reverse_x: false,
reverse_y: false,
keep_visible: None,
host: None,
}
}
#[test]
fn a_request_reaches_the_region_the_view_named() {
let tree = view();
let requests = Command::<()>::scroll("log", ScrollTo::end().instant())
.into_parts()
.scrolls;
let id = frus_widgets::find_by_key(tree.as_ref(), requests[0].0)
.expect("the view named it, so the tree knows it");
let mut runtime = Runtime::default();
let (unplaced, moved) =
apply_scroll_requests(&mut runtime, tree.as_ref(), requests, &[region(id)]);
assert!(unplaced.is_empty());
assert!(moved);
assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 1000.0)));
}
#[test]
fn a_region_this_registry_has_not_got_is_handed_back() {
let tree = view();
let requests = Command::<()>::scroll("log", ScrollTo::y(120.0))
.into_parts()
.scrolls;
let mut runtime = Runtime::default();
let (unplaced, moved) = apply_scroll_requests(&mut runtime, tree.as_ref(), requests, &[]);
assert_eq!(unplaced.len(), 1, "kept for the second attempt");
assert!(!moved);
assert!(runtime.scroll_target.is_empty());
}
#[test]
fn a_name_the_view_does_not_use_moves_nothing() {
let tree = view();
let requests = Command::<()>::scroll("ledger", ScrollTo::start())
.into_parts()
.scrolls;
let mut runtime = Runtime::default();
let id = frus_widgets::find_by_key(
tree.as_ref(),
Command::<()>::scroll("log", ScrollTo::start())
.into_parts()
.scrolls[0]
.0,
)
.expect("the real one is there");
let (unplaced, moved) =
apply_scroll_requests(&mut runtime, tree.as_ref(), requests, &[region(id)]);
assert_eq!(unplaced.len(), 1);
assert!(!moved);
assert!(runtime.scroll.is_empty() && runtime.scroll_target.is_empty());
}
#[test]
fn a_batch_places_every_request_it_carries() {
let tree = view();
let requests = Command::<()>::batch([
Command::scroll("log", ScrollTo::y(400.0).instant()),
Command::scroll("log", ScrollTo::y(600.0).instant()),
])
.into_parts()
.scrolls;
assert_eq!(requests.len(), 2);
let id = frus_widgets::find_by_key(tree.as_ref(), requests[0].0).expect("named");
let mut runtime = Runtime::default();
let (unplaced, moved) =
apply_scroll_requests(&mut runtime, tree.as_ref(), requests, &[region(id)]);
assert!(unplaced.is_empty() && moved);
assert_eq!(
runtime.scroll.get(&id),
Some(&(0.0, 600.0)),
"the last request is the current statement"
);
}
}
#[cfg(test)]
mod autoscroll_tests {
use super::{
edge_autoscroll, follow_content, Drag, Point, Rect, WidgetId, AUTOSCROLL_MAX_OVERHANG,
AUTOSCROLL_VELOCITY,
};
const VIEW: (f32, f32) = (100.0, 500.0);
const ROOM: (f32, f32) = (1000.0, 1000.0);
const DT: f32 = 1.0 / 60.0;
#[test]
fn a_row_inside_the_viewport_scrolls_nothing() {
assert_eq!(edge_autoscroll((200.0, 260.0), VIEW, ROOM, DT), None);
assert_eq!(edge_autoscroll((440.0, 500.0), VIEW, ROOM, DT), None);
}
#[test]
fn the_speed_follows_how_far_out_the_row_is() {
let near = edge_autoscroll((460.0, 501.0), VIEW, ROOM, DT).expect("one pixel out");
let far = edge_autoscroll((460.0, 510.0), VIEW, ROOM, DT).expect("ten pixels out");
assert!(
near < 0.0,
"the content moves up to reveal what is below: {near}"
);
assert!(
(far / near - 10.0).abs() < 0.01,
"ten times as far, ten times as fast: {near} vs {far}"
);
assert!(
(near + 1.0 * AUTOSCROLL_VELOCITY * DT).abs() < 1e-4,
"one pixel out, at the stated velocity: {near}"
);
}
#[test]
fn the_overhang_is_capped() {
let far = edge_autoscroll((460.0, 520.0), VIEW, ROOM, DT).expect("twenty out");
let absurd = edge_autoscroll((460.0, 5000.0), VIEW, ROOM, DT).expect("off the window");
assert!((far - absurd).abs() < 1e-4, "{far} vs {absurd}");
assert!((absurd + AUTOSCROLL_MAX_OVERHANG * AUTOSCROLL_VELOCITY * DT).abs() < 1e-4);
}
#[test]
fn above_the_top_the_content_comes_down() {
let up = edge_autoscroll((90.0, 150.0), VIEW, ROOM, DT).expect("ten above");
assert!(up > 0.0, "{up}");
}
#[test]
fn a_carried_item_follows_its_content() {
let shift = (0.0, -95.0);
let pressed = Point::new(900.0, 1330.0);
let row_then = Rect::new(105.0, 1250.0, 700.0, 160.0);
let row_now = row_then.translate(0.0, -95.0);
let finger = Point::new(900.0, 2030.0);
let mut reorder = Drag::Reorder {
id: WidgetId::from_u64(1),
from: 0,
start: pressed,
moved: true,
carried: Some((WidgetId::from_u64(1), row_then)),
};
follow_content(&mut reorder, shift);
let Drag::Reorder { start, carried, .. } = reorder else {
unreachable!("still a reorder");
};
let (_, carried) = carried.expect("the box it was carried from");
assert_eq!(carried, row_now, "the carried box went with the content");
let ghost = carried.translate(finger.x - start.x, finger.y - start.y);
assert_eq!(
ghost,
row_then.translate(finger.x - pressed.x, finger.y - pressed.y),
"the ghost is where it would be had nothing scrolled"
);
let mut item = Drag::Item {
source: frus_widgets::DragSource {
id: WidgetId::from_u64(1),
rect: row_then,
payload: 7,
},
start: pressed,
moved: true,
over: None,
};
follow_content(&mut item, shift);
let Drag::Item { source, start, .. } = item else {
unreachable!("still an item");
};
assert_eq!(
source.rect, row_now,
"the box taken at the press went with the content"
);
assert_eq!(start, Point::new(900.0, 1235.0), "and so did the press");
}
#[test]
fn an_exhausted_edge_stays_still() {
assert_eq!(
edge_autoscroll((460.0, 520.0), VIEW, (1000.0, 0.0), DT),
None
);
assert_eq!(
edge_autoscroll((90.0, 150.0), VIEW, (0.0, 1000.0), DT),
None
);
}
#[test]
fn a_row_taller_than_the_viewport_follows_its_leading_edge() {
let both = edge_autoscroll((50.0, 900.0), VIEW, ROOM, DT).expect("over both edges");
assert!(both > 0.0, "the top edge wins: {both}");
}
}
#[cfg(test)]
mod back_gesture_tests {
use super::{
drag_after_hold, frame_needs_build, hold_candidates, Drag, Point, Theme, WidgetId,
};
use frus_widgets::{build_ui, column, Container, DragSource, Draggable, Navigator};
use frus_widgets::{Rect, Runtime, Size, Ui, Widget};
const W: f32 = 392.0;
const H: f32 = 850.0;
const ROW_TOP: f32 = 480.0;
const ROW_HEIGHT: f32 = 66.0;
const ON_THE_ROW: Point = Point::new(3.0, 509.0);
const ABOVE_THE_ROW: Point = Point::new(3.0, 200.0);
const HELD: u8 = 1;
fn home() -> impl Widget<u8> {
column![
Container::<u8>::new().width(W).height(ROW_TOP),
Draggable::new(
Container::<u8>::new()
.width(W)
.height(ROW_HEIGHT)
.on_long_press(HELD)
)
.payload(7)
.long_press(),
]
}
fn after_a_push() -> Navigator<u8> {
Navigator::new("data", Container::<u8>::new().width(W).height(H))
.size(W, H)
.from("home", home(), 0.999, true)
}
fn frame(root: &dyn Widget<u8>) -> Ui<u8> {
build_ui(
root,
Size::new(W, H),
&Runtime::default(),
&Theme::default(),
)
}
fn back(at: Point) -> Drag {
Drag::Back { start_x: at.x }
}
fn still_scroll() -> Drag {
Drag::Scroll {
id: WidgetId::from_u64(9),
last: ON_THE_ROW,
moved: false,
carried: (0.0, 0.0),
dismiss: None,
axis: None,
pan: None,
}
}
fn row_source() -> DragSource {
DragSource {
id: WidgetId::from_u64(3),
rect: Rect::new(-117.0, ROW_TOP, W, ROW_HEIGHT),
payload: 7,
}
}
#[test]
fn the_frame_an_animation_settles_in_is_built_again() {
assert!(
frame_needs_build(false, false, true, false),
"the frame the application's animation stops in builds the view again"
);
assert!(
frame_needs_build(false, true, true, false),
"as does every frame while it moves"
);
assert!(
!frame_needs_build(false, false, false, false),
"and a still frame after a still one repaints the tree it has"
);
assert!(frame_needs_build(true, false, false, false), "unless asked");
assert!(
frame_needs_build(false, false, false, true),
"or the frame has its own reason"
);
}
#[test]
fn a_back_gesture_claims_nothing_under_its_finger() {
let root = after_a_push();
let ui = frame(&root);
let (held, lift) = hold_candidates(None, Some(&ui), Some(&root), ON_THE_ROW);
assert!(
lift.is_some() && held == Some(HELD),
"the frame the phone had: a press there, with no gesture, finds the row's hold"
);
let gesture = back(ON_THE_ROW);
let (held, lift) = hold_candidates(Some(&gesture), Some(&ui), Some(&root), ON_THE_ROW);
assert!(
lift.is_none(),
"a back gesture's press arms no lift of the row under it"
);
assert_eq!(held, None, "nor its long press");
}
#[test]
fn a_hold_never_takes_the_pointer_from_a_back_gesture() {
let reorder = Some((WidgetId::from_u64(4), 2, ON_THE_ROW));
for (lift, reorder) in [
(Some(row_source()), None),
(None, reorder),
(Some(row_source()), reorder),
(None, None),
] {
let after = drag_after_hold(Some(back(ON_THE_ROW)), lift, reorder, ON_THE_ROW);
assert!(
matches!(after, Some(Drag::Back { start_x }) if start_x == ON_THE_ROW.x),
"the back gesture is still the drag (lift {}, reorder {})",
lift.is_some(),
reorder.is_some()
);
}
}
#[test]
fn a_back_gesture_where_nothing_is_under_it_still_owns_the_pointer() {
let root = after_a_push();
let ui = frame(&root);
let gesture = back(ABOVE_THE_ROW);
let (held, lift) = hold_candidates(Some(&gesture), Some(&ui), Some(&root), ABOVE_THE_ROW);
assert!(held.is_none() && lift.is_none());
assert!(matches!(
drag_after_hold(Some(gesture), None, None, ABOVE_THE_ROW),
Some(Drag::Back { .. })
));
}
#[test]
fn a_hold_on_a_row_without_a_back_gesture_still_lifts_it() {
let root = home();
let ui = frame(&root);
let at = Point::new(40.0, ROW_TOP + 10.0);
let scroll = still_scroll();
let (held, lift) = hold_candidates(Some(&scroll), Some(&ui), Some(&root), at);
assert_eq!(
held,
Some(HELD),
"the long press is armed under a still scroll"
);
assert!(lift.is_some(), "and so is the lift");
let (held, lift) = hold_candidates(None, Some(&ui), Some(&root), at);
assert!(held.is_some() && lift.is_some(), "and with no drag at all");
let cursor = Point::new(41.0, ROW_TOP + 11.0);
let lifted = drag_after_hold(Some(still_scroll()), Some(row_source()), None, cursor);
assert!(
matches!(lifted, Some(Drag::Item { moved: true, start, .. }) if start == cursor),
"the hold lifts the item where the finger is"
);
let reorder = Some((WidgetId::from_u64(4), 2, at));
let reordering = drag_after_hold(Some(still_scroll()), None, reorder, cursor);
assert!(
matches!(reordering, Some(Drag::Reorder { moved: true, from: 2, start, .. }) if start == at),
"or picks the row up for a reorder, from where it was pressed"
);
assert!(
drag_after_hold(Some(still_scroll()), None, None, cursor).is_none(),
"a hold with nothing to lift ends the still scroll: the long press had it"
);
}
}
fn count_layers(primitives: &[frus_widgets::Primitive]) -> u32 {
primitives
.iter()
.map(|p| match p {
frus_widgets::Primitive::Layer { primitives, .. } => 1 + count_layers(primitives),
_ => 0,
})
.sum()
}
pub(crate) struct KeyDown {
logical_key: WinitKey,
physical_key: PhysicalKey,
text: Option<winit::keyboard::SmolStr>,
repeat: bool,
}
impl From<&winit::event::KeyEvent> for KeyDown {
fn from(event: &winit::event::KeyEvent) -> Self {
Self {
logical_key: event.logical_key.clone(),
physical_key: event.physical_key,
text: event.text.clone(),
repeat: event.repeat,
}
}
}
fn backdrop_under(mut scene: Scene, width: f32, height: f32, line: f32) -> Scene {
let mut framed = Scene::new();
framed.fill_rect(
frus_widgets::Rect::new(0.0, line, width, (height - line).max(0.0)),
frus_gpu::backdrop(),
);
for primitive in scene.split_off(0) {
framed.push_primitive(primitive);
}
framed
}
#[cfg(test)]
mod backdrop_tests {
use super::*;
#[test]
fn the_background_leaves_the_system_s_line_clear() {
let mut scene = Scene::new();
scene.fill_rect(
frus_widgets::Rect::new(10.0, 5.0, 20.0, 20.0),
frus_widgets::Color::WHITE,
);
let framed = backdrop_under(scene.clone(), 800.0, 600.0, 48.0);
let prims = framed.primitives();
assert_eq!(prims.len(), 2);
match &prims[0] {
frus_widgets::Primitive::Rect { rect, color, .. } => {
assert_eq!(*rect, frus_widgets::Rect::new(0.0, 48.0, 800.0, 552.0));
assert_eq!(*color, frus_gpu::backdrop());
assert_eq!(
color.a, 1.0,
"opaque: nothing behind the window shows there"
);
}
other => panic!("the background first: {other:?}"),
}
assert_eq!(prims[1], scene.primitives()[0], "then the scene");
let whole = backdrop_under(Scene::new(), 800.0, 600.0, 0.0);
assert!(
matches!(&whole.primitives()[0],
frus_widgets::Primitive::Rect { rect, .. } if *rect == frus_widgets::Rect::new(0.0, 0.0, 800.0, 600.0)),
"no line: the whole window"
);
}
}
#[cfg(any(test, feature = "testing"))]
#[doc(hidden)]
pub mod testing {
use super::*;
use crate::gesture::{PointerEvent, PointerKind, LONG_PRESS_DELAY};
pub type FrameParts<'a, Msg> = (&'a Ui<Msg>, &'a dyn Widget<Msg>);
pub struct Driver<A: Application> {
shell: App<A>,
size: Size,
ghost: Option<Rect>,
}
impl<A: Application> Driver<A> {
pub fn new(app: A, width: f32, height: f32) -> Self {
let mut shell = App::detached(app);
shell.gesture_clock = Some((0.0, 0.0));
Self {
shell,
size: Size::new(width, height),
ghost: None,
}
}
pub fn system_back(&mut self) -> bool {
self.shell.system_back_step()
}
pub fn fling_strategy(&self) -> frus_widgets::VelocityStrategy {
self.shell.gesture_velocity.strategy()
}
pub fn carried(&self) -> Option<Rect> {
self.shell.carried_rect()
}
pub fn ghost(&self) -> Option<Rect> {
self.ghost
}
pub fn offsets(&self) -> Vec<(Rect, (f32, f32))> {
let s = &self.shell;
s.ui.as_ref()
.map(|ui| {
ui.scroll_regions()
.iter()
.map(|a| {
(
a.viewport,
s.runtime.scroll.get(&a.id).copied().unwrap_or((0.0, 0.0)),
)
})
.collect()
})
.unwrap_or_default()
}
pub fn frame_parts(&self) -> Option<FrameParts<'_, A::Message>> {
let s = &self.shell;
Some((s.ui.as_ref()?, s.tree.as_deref()?))
}
pub fn texts(&self) -> Vec<(String, Rect)> {
self.shell
.ui
.as_ref()
.map(|ui| {
ui.scene()
.primitives()
.iter()
.filter_map(|p| match p {
frus_widgets::Primitive::Text { text, .. } => {
Some((text.clone(), p.bounds()))
}
_ => None,
})
.collect()
})
.unwrap_or_default()
}
pub fn tap_text(&mut self, label: &str) -> bool {
let Some((_, rect)) = self.texts().into_iter().find(|(text, _)| text == label) else {
return false;
};
let at = Point::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
self.press(at);
self.release(at);
self.frame(1.0 / 60.0);
true
}
pub fn app(&self) -> &A {
&self.shell.app
}
pub fn focused(&self) -> Option<WidgetId> {
self.shell.runtime.input.focused
}
pub fn shell_modifiers(&self) -> (bool, bool, bool, bool) {
(
self.shell.ctrl,
self.shell.shift,
self.shell.alt,
self.shell.meta,
)
}
pub fn hit(&self, at: Point) -> Option<WidgetId> {
self.shell.ui.as_ref().and_then(|ui| ui.hit(at))
}
pub fn update(&mut self, message: A::Message) {
self.shell.dispatch(message);
}
pub fn run(&mut self, seconds: f32) {
let n = (seconds * 60.0).round() as usize;
for _ in 0..n.max(1) {
self.frame(1.0 / 60.0);
}
}
pub fn press(&mut self, at: Point) {
self.pointer(PointerKind::Down, at);
}
pub fn move_to(&mut self, at: Point) {
self.pointer(PointerKind::Move, at);
}
pub fn release(&mut self, at: Point) {
self.pointer(PointerKind::Up, at);
}
pub fn hold_deadline(&mut self) -> bool {
let fired = self.shell.press.poll(Instant::now() + LONG_PRESS_DELAY);
if fired {
self.shell.hold_deadline_reached();
}
fired
}
pub fn tap_deadline(&mut self) -> bool {
self.shell
.poll_pending_tap(Instant::now() + DOUBLE_TAP_TIMEOUT)
}
pub fn cursor_shown(&self) -> frus_widgets::Cursor {
let s = &self.shell;
let requested = s.runtime.input.hovered.and_then(|id| {
let rect = s.ui.as_ref()?.widget_rect(id)?;
let widget = find_widget(s.tree.as_ref()?.as_ref(), id)?;
widget.cursor_icon(
s.cursor.x - rect.x,
s.cursor.y - rect.y,
rect.width,
rect.height,
)
});
s.region_cursor(requested)
}
pub fn next_wake(&self) -> Option<Instant> {
match self.shell.idle_control_flow() {
ControlFlow::WaitUntil(at) => Some(at),
_ => None,
}
}
pub fn secondary_click(&mut self, at: Point) {
self.pointer_of(PointerKind::Move, at, false);
self.shell.context_click();
}
pub fn press_mouse(&mut self, at: Point) {
self.pointer_of(PointerKind::Down, at, false);
}
pub fn move_mouse(&mut self, at: Point) {
self.pointer_of(PointerKind::Move, at, false);
}
pub fn release_mouse(&mut self, at: Point) {
self.pointer_of(PointerKind::Up, at, false);
}
pub fn area_selection(&self) -> Option<String> {
self.shell.region_text()
}
pub fn area_select_all(&mut self) {
self.shell.select_all_region();
}
pub fn escape(&mut self) {
self.shell.escape();
}
pub fn key(&mut self, stroke: frus_widgets::KeyStroke) {
use frus_widgets::ShortcutKey as K;
let named = |key| (WinitKey::Named(key), None);
let (logical_key, text) = match stroke.key {
K::Char(c) => {
let typed: winit::keyboard::SmolStr = c.to_string().into();
(WinitKey::Character(typed.clone()), Some(typed))
}
K::Enter => named(NamedKey::Enter),
K::Escape => named(NamedKey::Escape),
K::Tab => named(NamedKey::Tab),
K::Space => (WinitKey::Named(NamedKey::Space), Some(" ".into())),
K::Backspace => named(NamedKey::Backspace),
K::Delete => named(NamedKey::Delete),
K::Up => named(NamedKey::ArrowUp),
K::Down => named(NamedKey::ArrowDown),
K::Left => named(NamedKey::ArrowLeft),
K::Right => named(NamedKey::ArrowRight),
K::Home => named(NamedKey::Home),
K::End => named(NamedKey::End),
K::PageUp => named(NamedKey::PageUp),
K::PageDown => named(NamedKey::PageDown),
K::F(n) => named(match n {
1 => NamedKey::F1,
2 => NamedKey::F2,
3 => NamedKey::F3,
4 => NamedKey::F4,
5 => NamedKey::F5,
6 => NamedKey::F6,
7 => NamedKey::F7,
8 => NamedKey::F8,
9 => NamedKey::F9,
10 => NamedKey::F10,
11 => NamedKey::F11,
_ => NamedKey::F12,
}),
};
const LETTERS: [KeyCode; 26] = [
KeyCode::KeyA,
KeyCode::KeyB,
KeyCode::KeyC,
KeyCode::KeyD,
KeyCode::KeyE,
KeyCode::KeyF,
KeyCode::KeyG,
KeyCode::KeyH,
KeyCode::KeyI,
KeyCode::KeyJ,
KeyCode::KeyK,
KeyCode::KeyL,
KeyCode::KeyM,
KeyCode::KeyN,
KeyCode::KeyO,
KeyCode::KeyP,
KeyCode::KeyQ,
KeyCode::KeyR,
KeyCode::KeyS,
KeyCode::KeyT,
KeyCode::KeyU,
KeyCode::KeyV,
KeyCode::KeyW,
KeyCode::KeyX,
KeyCode::KeyY,
KeyCode::KeyZ,
];
let physical_key = match stroke.key {
K::Char(c) if c.is_ascii_alphabetic() => {
PhysicalKey::Code(LETTERS[(c.to_ascii_lowercase() as u8 - b'a') as usize])
}
_ => PhysicalKey::Unidentified(winit::keyboard::NativeKeyCode::Unidentified),
};
let held = (
self.shell.ctrl,
self.shell.shift,
self.shell.alt,
self.shell.meta,
);
(
self.shell.ctrl,
self.shell.shift,
self.shell.alt,
self.shell.meta,
) = (stroke.ctrl, stroke.shift, stroke.alt, stroke.meta);
self.shell.key_down(
&KeyDown {
logical_key,
physical_key,
text,
repeat: false,
},
None,
);
(
self.shell.ctrl,
self.shell.shift,
self.shell.alt,
self.shell.meta,
) = held;
}
pub fn area_handles(&self) -> Option<[frus_widgets::SelectionHandle; 2]> {
self.shell.region_handles().map(|(_, handles)| handles)
}
fn pointer(&mut self, kind: PointerKind, at: Point) {
self.pointer_of(kind, at, true);
}
fn pointer_of(&mut self, kind: PointerKind, at: Point, touch: bool) {
self.shell.pointer_event(PointerEvent {
kind,
position: at,
touch,
});
}
pub fn frame(&mut self, dt: f32) {
let (width, height) = (self.size.width, self.size.height);
let s = &mut self.shell;
if let Some((now, _)) = s.gesture_clock.as_mut() {
*now += dt;
}
if s.last_size != Some((width, height)) {
s.last_size = Some((width, height));
s.build_dirty = true;
s.app.on_resize(width, height);
}
let mut app_animating = s.app.tick(dt);
let settings = s
.platform
.accessibility
.with_overrides(s.app.accessibility());
let theme_moved = s.themes.advance(
&s.app,
s.platform.brightness,
settings.high_contrast,
settings.disable_animations,
dt,
);
app_animating |= theme_moved | s.themes.animating();
let theme = s.themes.displayed(&s.app);
install_ambient(&s.app, &s.platform.locales);
let _surface = s.media_query(width, height).install();
s.runtime.still = settings.disable_animations;
let was = std::mem::replace(&mut s.app_was_animating, app_animating);
let asked = s.app.effects();
s.run_command(asked);
let arrived = s.take_task_arrivals();
if frus_widgets::take_rebuild_request() || arrived {
s.build_dirty = true;
}
if frame_needs_build(
s.build_dirty,
app_animating,
was,
s.tree.is_none() || s.runtime.switching(),
) {
s.tree = Some(build_view(&s.app, &theme, &s.runtime));
}
s.build_dirty = false;
s.autoscroll_carried(dt);
s.advance_reorder_springs(dt);
let regions =
s.ui.as_ref()
.map(|ui| ui.scroll_regions().to_vec())
.unwrap_or_default();
s.runtime.sync_pages(®ions);
s.runtime.sync_visible(®ions);
s.runtime.advance(dt);
s.runtime.advance_scroll(®ions, dt);
let tree = s.tree.as_deref().expect("the view was built");
s.runtime.advance_values(tree, dt);
let ui = build_ui(tree, Size::new(width, height), &s.runtime, &theme);
s.runtime.states.end_frame();
let mut scene = ui.scene().clone();
let ghost = if matches!(s.drag, Some(Drag::Reorder { moved: true, .. })) {
s.paint_reorder_preview(&ui, &theme, &mut scene);
scene.primitives().last().map(|p| p.bounds())
} else {
None
};
let paged = ui.scroll_regions().to_vec();
let scrolled: Vec<A::Message> = {
let grain = |id| find_widget(tree, id).map_or(0.0, |w| w.scroll_grain());
s.runtime
.scroll_changes(&paged, grain)
.into_iter()
.filter_map(|(id, position)| {
find_widget(tree, id).and_then(|w| w.on_scroll(position))
})
.collect()
};
s.ui = Some(ui);
s.sync_regions(false);
s.reconcile_focus();
for message in scrolled {
s.dispatch(message);
}
self.ghost = ghost;
}
}
}
#[cfg(test)]
mod carried_row_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{
column, text, Axis, Button, CellFn, Container, Flex, Kanban, Point, Rect, ReorderAxis,
ReorderGrab, ReorderableList, SingleChildScrollView, Theme, Widget,
};
const W: f32 = 392.7;
const H: f32 = 850.9;
const STRIP_Y: f32 = 88.0;
const STRIP_CONTENT: f32 = 24.0 + 10.0 * 96.0 + 9.0 * 8.0 + 24.0;
#[derive(Clone, Debug, PartialEq)]
enum Board {
Label(usize, usize),
Card(usize, usize, usize, usize),
Delete(usize, usize),
Add(usize),
Row(usize, usize),
}
struct BoardApp {
labels: Vec<usize>,
cards: Vec<Vec<String>>,
log: Vec<Board>,
}
impl Default for BoardApp {
fn default() -> Self {
let cols: [&[&str]; 3] = [
&["Design API", "Write spec", "Triage bugs"],
&["Build widget"],
&["Kickoff", "Research"],
];
Self {
labels: (0..10).collect(),
cards: cols
.iter()
.map(|c| c.iter().map(|s| s.to_string()).collect())
.collect(),
log: Vec::new(),
}
}
}
impl Application for BoardApp {
type Message = Board;
fn update(&mut self, message: Board) -> Command<Board> {
self.log.push(message.clone());
match message {
Board::Label(from, to) => {
let label = self.labels.remove(from);
self.labels.insert(to, label);
}
Board::Card(from_col, from_pos, to_col, to_pos) => {
let card = self.cards[from_col].remove(from_pos);
let to_pos = to_pos.min(self.cards[to_col].len());
self.cards[to_col].insert(to_pos, card);
}
_ => {}
}
Command::none()
}
fn view(&self, theme: &Theme) -> Box<dyn Widget<Board>> {
let mut strip = ReorderableList::new(Board::Label)
.axis(ReorderAxis::Horizontal)
.grab(ReorderGrab::LongPress)
.gap(8.0);
for &label in &self.labels {
strip = strip.keyed_row(
label as u64,
Container::new()
.width(96.0)
.padding(12.0)
.color(theme.surface)
.child(text(format!("label {label}")).size(14.0)),
);
}
let strip = SingleChildScrollView::new()
.axis(Axis::Horizontal)
.width(W)
.child(
Container::new()
.padding_each(12.0, 24.0, 0.0, 24.0)
.child(strip),
);
let mut board = Kanban::new(Board::Card)
.on_add(Board::Add)
.scrollable_columns();
for (col, title) in ["To do", "Doing", "Done"].iter().enumerate() {
let cards: Vec<CellFn<Board>> = self.cards[col]
.iter()
.enumerate()
.map(|(pos, label)| {
let label = label.clone();
Box::new(move || {
Box::new(
Flex::row()
.child(text(label.clone()).size(14.0))
.child(Flex::row().flex(1.0))
.child(Button::new("x").on_press(Board::Delete(col, pos))),
) as Box<dyn Widget<Board>>
}) as CellFn<Board>
})
.collect();
board = board.column_widgets(*title, cards);
}
let board = SingleChildScrollView::new()
.axis(Axis::Horizontal)
.width(W)
.flex(1.0)
.child(Container::new().padding(24.0).child(board));
Box::new(
Container::new().width(W).height(H).child(
column![
Container::<Board>::new().width(W).height(56.0),
strip,
board
]
.flex(1.0),
),
)
}
}
#[derive(Default)]
struct ListApp {
log: Vec<Board>,
}
impl Application for ListApp {
type Message = Board;
fn update(&mut self, message: Board) -> Command<Board> {
self.log.push(message);
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Board>> {
let mut list = ReorderableList::new(Board::Row)
.grab(ReorderGrab::LongPress)
.width(W);
for row in 0..20u64 {
list = list.keyed_row(
row,
Container::new()
.height(60.0)
.child(text(format!("row {row}")).size(14.0)),
);
}
Box::new(
SingleChildScrollView::new()
.axis(Axis::Vertical)
.width(W)
.height(H)
.child(list),
)
}
}
fn driver<A: Application>(app: A) -> Driver<A> {
let mut driver = Driver::new(app, W, H);
driver.run(0.2);
driver
}
fn hold<A: Application>(driver: &mut Driver<A>, at: Point) {
driver.press(at);
driver.run(0.5);
assert!(
driver.hold_deadline(),
"the hold lifts what is under {at:?}"
);
driver.run(0.05);
}
fn carry<A: Application>(driver: &mut Driver<A>, points: impl IntoIterator<Item = Point>) {
for at in points {
driver.move_to(at);
driver.run(0.1);
}
}
fn held_under_the_finger<A: Application>(
driver: &mut Driver<A>,
seconds: f32,
finger: Point,
offset: (f32, f32),
size: (f32, f32),
region: usize,
) -> (f32, f32) {
let mut last = driver.offsets()[region].1;
for frame in 0..(seconds * 60.0) as usize {
driver.run(1.0 / 60.0);
let now = driver.offsets()[region].1;
let carried: Rect = driver.carried().unwrap_or_else(|| {
panic!("frame {frame}: nothing is carried any more, the list at {now:?}")
});
assert!(
(carried.x - finger.x - offset.0).abs() < 0.5
&& (carried.y - finger.y - offset.1).abs() < 0.5
&& (carried.width - size.0).abs() < 0.5
&& (carried.height - size.1).abs() < 0.5,
"frame {frame}: carried at {carried:?}, the finger at {finger:?}, the list at {now:?}"
);
assert!(
now.0 >= last.0 - 0.01 && now.1 >= last.1 - 0.01,
"frame {frame}: the list went back, {last:?} then {now:?}"
);
let ghost = driver
.ghost()
.unwrap_or_else(|| panic!("frame {frame}: no ghost is drawn"));
assert!(
(ghost.x - carried.x).abs() < 1.0
&& (ghost.y - carried.y).abs() < 1.0
&& (ghost.width - carried.width).abs() < 2.0,
"frame {frame}: the ghost is drawn at {ghost:?}, what is carried is at {carried:?}"
);
last = now;
}
last
}
#[test]
fn a_label_carried_past_the_strips_edge_stays_under_the_finger() {
let mut driver = driver(BoardApp::default());
hold(&mut driver, Point::new(155.0, STRIP_Y));
let lifted = driver.carried().expect("the label is carried");
assert_eq!(
lifted,
Rect::new(128.0, 68.0, 96.0, 41.0),
"Feature, lifted"
);
let finger = Point::new(370.0, STRIP_Y);
carry(
&mut driver,
[200.0, 260.0, 320.0, finger.x].map(|x| Point::new(x, STRIP_Y)),
);
let strip = held_under_the_finger(
&mut driver,
1.5,
finger,
(lifted.x - 155.0, lifted.y - STRIP_Y),
(96.0, 41.0),
0,
);
assert!(
(strip.0 - (STRIP_CONTENT - W)).abs() < 1.0,
"the strip scrolled on to its end: {strip:?}"
);
driver.release(finger);
driver.run(0.2);
assert_eq!(
driver.app().log,
[Board::Label(1, 9)],
"past the last label, it goes last; no card moves"
);
}
#[test]
fn a_hold_on_a_label_then_a_carry_along_x_reorders_the_labels_and_moves_no_card() {
let mut driver = driver(BoardApp::default());
hold(&mut driver, Point::new(155.0, STRIP_Y));
carry(
&mut driver,
[167.0, 189.0, 218.0, 247.0, 276.0, 306.0].map(|x| Point::new(x, STRIP_Y)),
);
driver.run(0.4);
assert!(driver.carried().is_some(), "still carried");
driver.release(Point::new(306.0, STRIP_Y));
driver.run(0.2);
assert_eq!(driver.app().log, [Board::Label(1, 2)]);
assert_eq!(driver.app().labels[..4], [0, 2, 1, 3]);
assert_eq!(
driver.app().cards,
BoardApp::default().cards,
"no card moved"
);
assert_eq!(driver.offsets()[0].1, (0.0, 0.0), "nothing scrolled");
assert_eq!(driver.offsets()[1].1, (0.0, 0.0), "the board neither");
}
#[test]
fn a_card_carried_across_the_board_moves_the_card() {
let mut driver = driver(BoardApp::default());
driver.press(Point::new(100.0, 190.0));
carry(
&mut driver,
[130.0, 180.0, 240.0, 300.0].map(|x| Point::new(x, 185.0)),
);
driver.release(Point::new(300.0, 185.0));
driver.run(0.2);
assert_eq!(driver.app().log, [Board::Card(0, 0, 1, 0)]);
assert_eq!(driver.app().labels, (0..10).collect::<Vec<_>>());
}
#[test]
fn a_card_carried_to_the_boards_edge_stays_under_the_finger() {
let mut driver = driver(BoardApp::default());
let press = Point::new(100.0, 190.0);
driver.press(press);
let finger = Point::new(385.0, 190.0);
carry(
&mut driver,
[130.0, 200.0, 280.0, 340.0, finger.x].map(|x| Point::new(x, 190.0)),
);
let card = driver.carried().expect("the card is carried");
let board = held_under_the_finger(
&mut driver,
1.0,
finger,
(card.x - finger.x, card.y - finger.y),
(card.width, card.height),
1,
);
assert!(
board.0 > 250.0,
"the board scrolled past the column the card came from: {board:?}"
);
}
#[test]
fn a_row_carried_past_the_bottom_of_a_long_list_stays_under_the_finger() {
let mut driver = driver(ListApp::default());
hold(&mut driver, Point::new(100.0, 30.0));
let lifted = driver.carried().expect("the row is carried");
let finger = Point::new(100.0, 840.0);
carry(
&mut driver,
[200.0, 400.0, 600.0, 800.0, finger.y].map(|y| Point::new(100.0, y)),
);
let list = held_under_the_finger(
&mut driver,
1.5,
finger,
(lifted.x - 100.0, lifted.y - 30.0),
(lifted.width, lifted.height),
0,
);
assert!(
list.1 > 200.0,
"the list scrolled past the row's own place: {list:?}"
);
driver.release(finger);
driver.run(0.2);
assert!(
matches!(driver.app().log[..], [Board::Row(0, to)] if to > 15),
"row 0 lands near the end: {:?}",
driver.app().log
);
}
}
#[cfg(test)]
mod press_tests {
use super::{gesture_was_a_tap, press_at, settle_press, Drag, Point, Runtime, Theme};
use crate::gesture::PointerKind;
use crate::hover::Hover;
use frus_widgets::{
build_ui, Axis, Button, Container, Flex, Primitive, SegmentedButton, SingleChildScrollView,
Size, Ui, Widget,
};
const SCREEN: Size = Size::new(300.0, 400.0);
const DT: f32 = 1.0 / 60.0;
fn filters() -> Box<dyn Widget<usize>> {
let mut segments = SegmentedButton::new(0, |i| i);
for label in ["All", "Active", "Done", "Archived", "Starred", "Shared"] {
segments = segments.segment(label);
}
Box::new(
Flex::column().width(300.0).height(400.0).child(
SingleChildScrollView::new()
.axis(Axis::Horizontal)
.width(300.0)
.height(80.0)
.child(segments),
),
)
}
fn page() -> Box<dyn Widget<usize>> {
Box::new(
Flex::column().width(300.0).height(400.0).child(
SingleChildScrollView::new()
.width(300.0)
.height(400.0)
.child(
Flex::column()
.child(Button::new("Go").on_press(7usize))
.child(Container::new().width(300.0).height(1200.0)),
),
),
)
}
fn frame(view: &dyn Widget<usize>, runtime: &Runtime) -> Ui<usize> {
build_ui(view, SCREEN, runtime, &Theme::default())
}
fn idle(runtime: &mut Runtime, seconds: f32) {
for _ in 0..(seconds / DT) as usize {
runtime.advance(DT);
runtime.advance_ink(DT);
}
}
fn painted(view: &dyn Widget<usize>, runtime: &Runtime) -> Vec<Primitive> {
frame(view, runtime).scene().primitives().to_vec()
}
fn untouched(view: &dyn Widget<usize>, runtime: &Runtime) -> Vec<Primitive> {
let mut quiet = Runtime::default();
quiet.scroll.clone_from(&runtime.scroll);
quiet.scroll_target.clone_from(&runtime.scroll_target);
idle(&mut quiet, 0.5);
painted(view, &quiet)
}
struct Pointer<'a> {
view: &'a dyn Widget<usize>,
runtime: Runtime,
hover: Hover,
drag: Option<Drag>,
touch: bool,
at: Point,
}
impl<'a> Pointer<'a> {
fn new(view: &'a dyn Widget<usize>, touch: bool) -> Self {
Self {
view,
runtime: Runtime::default(),
hover: Hover::default(),
drag: None,
touch,
at: Point::new(0.0, 0.0),
}
}
fn event(&mut self, kind: PointerKind, at: Point, route: impl FnOnce(&mut Self)) {
self.at = at;
self.hover.event(kind, at, self.touch);
let could_tap = gesture_was_a_tap(self.drag.as_ref());
if kind == PointerKind::Down {
let ui = frame(self.view, &self.runtime);
self.runtime.input.hovered = self.hover.target(&ui);
press_at(&mut self.runtime, Some(&ui), at);
}
route(self);
settle_press(
&mut self.runtime.input,
kind,
could_tap,
self.drag.as_ref(),
self.touch,
);
if matches!(kind, PointerKind::Up | PointerKind::Cancel) {
let ui = frame(self.view, &self.runtime);
self.runtime.input.hovered = self.hover.target(&ui);
}
}
fn land(&mut self, at: Point) {
self.event(PointerKind::Down, at, |p| {
if !p.touch {
return;
}
let ui = frame(p.view, &p.runtime);
let area = ui
.scroll_chain(at)
.find(|area| area.accepts_user_offset((0.0, 0.0)))
.expect("the region under the finger has somewhere to go");
p.drag = Some(Drag::Scroll {
id: area.id,
last: at,
moved: false,
carried: (0.0, 0.0),
dismiss: None,
axis: None,
pan: None,
});
});
}
fn slide(&mut self, by: (f32, f32)) {
let to = Point::new(self.at.x + by.0, self.at.y + by.1);
self.event(PointerKind::Move, to, |p| {
let Some(Drag::Scroll {
id,
moved,
axis,
last,
..
}) = p.drag.as_mut()
else {
return;
};
let (dx, dy) = (to.x - last.x, to.y - last.y);
if dx.hypot(dy) <= super::TOUCH_SLOP && !*moved {
return;
}
*moved = true;
*axis = Some(dy.abs() >= dx.abs());
*last = to;
let offset = p.runtime.scroll.get(id).copied().unwrap_or((0.0, 0.0));
let moved_to = (offset.0 - by.0, offset.1 - by.1);
p.runtime.scroll.insert(*id, moved_to);
p.runtime.scroll_target.insert(*id, moved_to);
});
}
fn lift(&mut self) -> Option<Drag> {
let mut ended = None;
self.event(PointerKind::Up, self.at, |p| ended = p.drag.take());
ended
}
}
#[test]
fn a_segment_a_swipe_started_on_is_not_left_pressed() {
let view = filters();
let mut finger = Pointer::new(view.as_ref(), true);
let done = Point::new(200.0, 16.0);
finger.land(done);
let segment = finger
.runtime
.input
.pressed
.expect("the segment is pressed");
assert!(
finger.runtime.ink.contains_key(&segment),
"and it splashed: a segment takes ink"
);
assert!(
frame(view.as_ref(), &finger.runtime)
.scroll_chain(done)
.any(|area| area.max_x > 0.0),
"the strip is wider than the screen, as the phone's was"
);
for _ in 0..19 {
finger.slide((-10.0, 0.0));
}
let ended = finger.lift();
assert!(
!gesture_was_a_tap(ended.as_ref()),
"a gesture that scrolled clicks nothing"
);
idle(&mut finger.runtime, 2.0);
let (shown, quiet) = (
painted(view.as_ref(), &finger.runtime),
untouched(view.as_ref(), &finger.runtime),
);
assert_eq!(
shown.len(),
quiet.len(),
"two seconds on, the screen paints what nobody touched, and no layer more"
);
assert_eq!(shown, quiet, "nothing is highlighted");
assert!(
finger.runtime.ink.is_empty(),
"no ink waits for a finger that has gone"
);
assert_eq!(finger.runtime.input.pressed, None, "the press went with it");
assert_eq!(
finger.runtime.input.hovered, None,
"and a lifted finger hovers nothing"
);
}
#[test]
fn the_press_ends_when_the_scroll_takes_it() {
let view = filters();
let mut finger = Pointer::new(view.as_ref(), true);
finger.land(Point::new(200.0, 16.0));
finger.slide((-4.0, 0.0));
assert!(
finger.runtime.input.pressed.is_some(),
"under the slop it may still be a tap"
);
finger.slide((-40.0, 0.0));
assert_eq!(
finger.runtime.input.pressed, None,
"past it, the scroll has it"
);
assert_eq!(
finger.runtime.input.hovered, None,
"and the finger's hover with it"
);
idle(&mut finger.runtime, 0.5);
assert_eq!(
painted(view.as_ref(), &finger.runtime),
untouched(view.as_ref(), &finger.runtime),
"the finger is still down, and nothing under it is highlighted"
);
}
#[test]
fn a_button_a_page_scrolled_from_is_not_left_pressed() {
let view = page();
let mut finger = Pointer::new(view.as_ref(), true);
let go = Point::new(20.0, 15.0);
finger.land(go);
let button = finger.runtime.input.pressed.expect("the button is pressed");
assert_eq!(
frame(view.as_ref(), &finger.runtime).msg_for(button),
Some(7),
"the press is on the button"
);
for _ in 0..10 {
finger.slide((0.0, -30.0));
}
assert!(!gesture_was_a_tap(finger.lift().as_ref()));
idle(&mut finger.runtime, 2.0);
assert_eq!(finger.runtime.input.pressed, None);
assert!(finger.runtime.ink.is_empty());
assert_eq!(
painted(view.as_ref(), &finger.runtime),
untouched(view.as_ref(), &finger.runtime),
);
}
#[test]
fn a_tap_still_presses_and_clicks() {
let view = filters();
let mut finger = Pointer::new(view.as_ref(), true);
let segment = Point::new(200.0, 16.0);
finger.land(segment);
finger.slide((3.0, 2.0));
let pressed = finger.runtime.input.pressed;
assert!(pressed.is_some(), "a wobble under the slop takes nothing");
assert_eq!(finger.runtime.input.hovered, pressed, "and the press shows");
let ui = frame(view.as_ref(), &finger.runtime);
assert_eq!(
ui.hit(segment),
pressed,
"released on the widget it pressed"
);
assert!(
pressed.and_then(|id| ui.msg_for(id)).is_some(),
"and the segment it pressed has a choice to make"
);
let ended = finger.lift();
assert!(
gesture_was_a_tap(ended.as_ref()),
"a scroll that never moved"
);
assert_eq!(
finger.runtime.input.pressed, None,
"let go of once released"
);
}
#[test]
fn a_mouse_keeps_its_press_while_it_moves_and_its_hover_after() {
let view = filters();
let mut mouse = Pointer::new(view.as_ref(), false);
let done = Point::new(200.0, 16.0);
mouse.land(done);
assert!(mouse.drag.is_none(), "a mouse does not drag a scroll");
let pressed = mouse.runtime.input.pressed.expect("pressed");
mouse.slide((-60.0, 0.0));
assert_eq!(mouse.runtime.input.pressed, Some(pressed), "still held");
mouse.lift();
assert_eq!(mouse.runtime.input.pressed, None);
assert!(
mouse.runtime.input.hovered.is_some(),
"a released mouse is still over the strip"
);
}
#[test]
fn a_gesture_takes_a_mouse_s_press_and_leaves_its_hover() {
let id = frus_widgets::WidgetId::from_u64(3);
let sheet = |moved| Drag::Sheet {
id: frus_widgets::WidgetId::from_u64(9),
last: Point::new(0.0, 0.0),
moved,
available: 800.0,
};
for touch in [false, true] {
let mut input = frus_widgets::InputState {
pressed: Some(id),
hovered: Some(id),
..Default::default()
};
assert!(!settle_press(
&mut input,
PointerKind::Move,
true,
Some(&sheet(false)),
touch
));
assert_eq!(input.pressed, Some(id), "not yet past the slop");
assert!(settle_press(
&mut input,
PointerKind::Move,
true,
Some(&sheet(true)),
touch
));
assert_eq!(input.pressed, None, "touch {touch}");
assert_eq!(input.hovered, (!touch).then_some(id), "touch {touch}");
}
let mut input = frus_widgets::InputState {
pressed: Some(id),
..Default::default()
};
let select = Drag::TextSelect {
id,
rect: super::Rect::new(0.0, 0.0, 10.0, 10.0),
};
assert!(!settle_press(
&mut input,
PointerKind::Move,
false,
Some(&select),
true
));
assert_eq!(input.pressed, Some(id));
}
}
#[cfg(test)]
mod selection_area_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{Button, Container, Flex, Point, SelectionArea, Text, Theme, Widget};
const W: f32 = 400.0;
const H: f32 = 300.0;
#[derive(Default)]
struct Doc {
bar: Bar,
shared: std::rc::Rc<std::cell::Cell<u32>>,
platform: Option<frus_widgets::TargetPlatform>,
}
#[derive(Default, Clone, Copy)]
enum Bar {
#[default]
Default,
Share,
Empty,
}
impl Application for Doc {
type Message = ();
fn update(&mut self, _message: ()) -> Command<()> {
self.shared.set(self.shared.get() + 1);
Command::none()
}
fn theme(&self) -> Theme {
Theme::default().with_platform(
self.platform
.unwrap_or(frus_widgets::TargetPlatform::Android),
)
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
let area = SelectionArea::around(
Flex::column()
.child(Text::new("first line of words").no_wrap())
.child(Text::new("second line").no_wrap())
.child(Button::new("Press").on_press(()))
.child(Text::new("third line ends here").no_wrap()),
);
let area = match self.bar {
Bar::Default => area,
Bar::Share => area.selection_toolbar(|_, items| {
let mut items: Vec<_> = items
.into_iter()
.filter(|item| item.label() == "Copy")
.collect();
items.push(frus_widgets::ToolbarItem::custom("Share", ()));
items
}),
Bar::Empty => area.selection_toolbar(|_, _| Vec::new()),
};
Box::new(
Container::new().width(W).height(H).child(
Flex::column()
.child(Text::new("Outside the area").no_wrap())
.child(area),
),
)
}
}
struct Tappable;
impl Application for Tappable {
type Message = ();
fn update(&mut self, _message: ()) -> Command<()> {
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
Box::new(
Container::new()
.width(W)
.height(H)
.child(SelectionArea::around(
Flex::column()
.child(
Container::new()
.on_click(())
.child(Text::new("tap this row").no_wrap()),
)
.child(Text::new("plain words here").no_wrap()),
)),
)
}
}
#[test]
fn a_row_that_takes_a_tap_keeps_its_press() {
let mut d = Driver::new(Tappable, W, H);
d.run(0.2);
let find = |d: &Driver<Tappable>, label: &str| {
let (_, rect) = d
.texts()
.into_iter()
.find(|(text, _)| text == label)
.expect("on screen");
Point::new(rect.x + 20.0, rect.y + 8.0)
};
let (tap, plain) = (find(&d, "tap this row"), find(&d, "plain words here"));
let end = Point::new(plain.x + 60.0, plain.y);
d.press_mouse(tap);
d.run(0.05);
d.move_mouse(end);
d.run(0.05);
d.release_mouse(end);
d.run(0.05);
assert_eq!(d.area_selection(), None, "the row's press stayed the row's");
d.press_mouse(plain);
d.run(0.05);
d.move_mouse(end);
d.run(0.05);
d.release_mouse(end);
d.run(0.05);
assert!(d.area_selection().is_some(), "plain words still select");
}
fn driver() -> Driver<Doc> {
driver_with(Bar::Default)
}
fn driver_with(bar: Bar) -> Driver<Doc> {
driver_on(bar, None)
}
fn driver_on(bar: Bar, platform: Option<frus_widgets::TargetPlatform>) -> Driver<Doc> {
let doc = Doc {
bar,
platform,
..Doc::default()
};
let mut driver = Driver::new(doc, W, H);
driver.run(0.2);
driver
}
fn on(driver: &Driver<Doc>, label: &str, across: f32) -> Point {
let (_, rect) = driver
.texts()
.into_iter()
.find(|(text, _)| text == label)
.unwrap_or_else(|| panic!("{label:?} is on screen"));
Point::new(rect.x + across, rect.y + 8.0)
}
fn drag(driver: &mut Driver<Doc>, from: Point, to: Point) {
driver.press_mouse(from);
driver.run(0.05);
driver.move_mouse(to);
driver.run(0.05);
driver.release_mouse(to);
driver.run(0.05);
}
#[test]
fn a_drag_selects_across_the_texts_of_an_area() {
let mut d = driver();
let (from, to) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
drag(&mut d, from, to);
let copied = d.area_selection().expect("something is selected");
let lines: Vec<&str> = copied.lines().collect();
assert_eq!(lines.len(), 3, "one line per text: {copied:?}");
assert!(
!lines[0].is_empty() && "first line of words".ends_with(lines[0]),
"the tail of the first: {:?}",
lines[0]
);
assert!(lines[0].len() < "first line of words".len());
assert_eq!(lines[1], "second line", "the whole of the middle one");
assert!(
!lines[2].is_empty() && "third line ends here".starts_with(lines[2]),
"the head of the last: {:?}",
lines[2]
);
assert!(lines[2].len() < "third line ends here".len());
assert!(!copied.contains("Press"), "a button's label is not text");
}
#[test]
fn dragging_upward_selects_the_same_words() {
let (down, up) = {
let mut d = driver();
let (a, b) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
drag(&mut d, a, b);
let down = d.area_selection();
drag(&mut d, b, a);
(down, d.area_selection())
};
assert!(down.is_some());
assert_eq!(down, up);
}
#[test]
fn a_drag_past_the_words_ends_at_the_last_of_them() {
let mut d = driver();
let from = on(&d, "second line", 5.0);
drag(&mut d, from, Point::new(W - 2.0, H - 2.0));
let copied = d.area_selection().expect("a selection");
assert!(copied.ends_with("third line ends here"), "{copied:?}");
}
#[test]
fn words_outside_the_area_are_left_alone() {
let mut d = driver();
let outside = on(&d, "Outside the area", 30.0);
let end = on(&d, "third line ends here", 40.0);
drag(&mut d, outside, end);
assert_eq!(d.area_selection(), None, "the press began outside");
let inside = on(&d, "first line of words", 30.0);
drag(&mut d, inside, Point::new(20.0, 2.0));
let copied = d.area_selection().expect("a selection");
assert!(!copied.contains("Outside"), "{copied:?}");
}
#[test]
fn a_button_keeps_its_press() {
let mut d = driver();
let button = on(&d, "Press", 10.0);
let end = on(&d, "third line ends here", 40.0);
drag(&mut d, button, end);
assert_eq!(d.area_selection(), None);
}
#[test]
fn a_click_puts_the_selection_away() {
let mut d = driver();
let (from, to) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
drag(&mut d, from, to);
assert!(d.area_selection().is_some());
let elsewhere = on(&d, "second line", 30.0);
drag(&mut d, elsewhere, elsewhere);
assert_eq!(d.area_selection(), None, "a click in the area");
drag(&mut d, from, to);
assert!(d.area_selection().is_some());
let outside = on(&d, "Outside the area", 30.0);
drag(&mut d, outside, outside);
assert_eq!(d.area_selection(), None, "a click outside it");
}
#[test]
fn a_double_click_selects_the_word() {
let mut d = driver();
let at = on(&d, "second line", 12.0);
d.press_mouse(at);
d.release_mouse(at);
d.run(0.05);
d.press_mouse(at);
d.release_mouse(at);
d.run(0.05);
assert_eq!(d.area_selection().as_deref(), Some("second"));
}
#[test]
fn select_all_takes_the_whole_area() {
let mut d = driver();
let at = on(&d, "second line", 12.0);
let end = on(&d, "second line", 40.0);
drag(&mut d, at, end);
assert!(d.area_selection().is_some());
d.area_select_all();
assert_eq!(
d.area_selection().as_deref(),
Some("first line of words\nsecond line\nthird line ends here")
);
}
#[test]
fn a_finger_does_not_select() {
let mut d = driver();
let (from, to) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
d.press(from);
d.run(0.05);
d.move_to(to);
d.run(0.05);
d.release(to);
d.run(0.05);
assert_eq!(d.area_selection(), None);
}
fn hold(d: &mut Driver<Doc>, at: Point) -> bool {
d.press(at);
d.run(0.1);
let fired = d.hold_deadline();
d.run(0.05);
d.release(at);
d.run(0.05);
fired
}
fn bar_button(d: &Driver<Doc>, label: &str) -> Option<Point> {
let (ui, _) = d.frame_parts()?;
d.texts()
.into_iter()
.filter(|(text, _)| text == label)
.map(|(_, rect)| Point::new(rect.x + 4.0, rect.y + 6.0))
.find(|at| ui.toolbar_contains(*at))
}
#[test]
fn a_hold_selects_the_word_and_opens_the_bar() {
let mut d = driver();
let at = on(&d, "second line", 12.0);
assert!(hold(&mut d, at), "the hold fired");
assert_eq!(d.area_selection().as_deref(), Some("second"));
assert!(bar_button(&d, "Copy").is_some(), "Copy is on the bar");
assert!(bar_button(&d, "Select all").is_some(), "and Select all");
}
#[test]
fn the_desktop_menus_rows_act_on_the_area() {
let mut d = driver_on(Bar::Default, Some(frus_widgets::TargetPlatform::Linux));
let at = on(&d, "second line", 12.0);
hold(&mut d, at);
let select_all = bar_button(&d, "Select all").expect("Select all");
d.press(select_all);
d.release(select_all);
d.run(0.05);
assert_eq!(
d.area_selection().as_deref(),
Some("first line of words\nsecond line\nthird line ends here")
);
}
#[test]
fn the_bars_buttons_act_on_the_area() {
let mut d = driver();
let at = on(&d, "second line", 12.0);
hold(&mut d, at);
let select_all = bar_button(&d, "Select all").expect("Select all");
d.press(select_all);
d.release(select_all);
d.run(0.05);
assert_eq!(
d.area_selection().as_deref(),
Some("first line of words\nsecond line\nthird line ends here")
);
assert!(
bar_button(&d, "Select all").is_none(),
"nothing left to select"
);
let copy = bar_button(&d, "Copy").expect("Copy stays");
d.press(copy);
d.release(copy);
d.run(0.05);
assert_eq!(d.area_selection(), None, "copied, and put away");
assert!(bar_button(&d, "Copy").is_none(), "the bar with it");
}
#[test]
fn a_hold_on_a_button_selects_nothing() {
let mut d = driver();
let button = on(&d, "Press", 10.0);
hold(&mut d, button);
assert_eq!(d.area_selection(), None);
}
#[test]
fn escape_and_a_tap_put_the_bar_away() {
let mut d = driver();
let at = on(&d, "second line", 12.0);
hold(&mut d, at);
assert!(d.area_selection().is_some());
d.escape();
d.run(0.05);
assert_eq!(d.area_selection(), None);
assert!(bar_button(&d, "Copy").is_none());
hold(&mut d, at);
assert!(d.area_selection().is_some());
let elsewhere = on(&d, "Outside the area", 20.0);
d.press(elsewhere);
d.release(elsewhere);
d.run(0.05);
assert_eq!(d.area_selection(), None);
assert!(bar_button(&d, "Copy").is_none());
}
#[test]
fn a_mouse_selection_has_no_bar() {
let mut d = driver();
let (from, to) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
drag(&mut d, from, to);
assert!(d.area_selection().is_some());
assert!(bar_button(&d, "Copy").is_none());
}
#[test]
fn the_application_says_what_the_bar_holds() {
let mut d = driver_with(Bar::Share);
let at = on(&d, "second line", 12.0);
assert!(hold(&mut d, at));
assert_eq!(d.area_selection().as_deref(), Some("second"));
assert!(bar_button(&d, "Copy").is_some(), "Copy was kept");
assert!(
bar_button(&d, "Select all").is_none(),
"Select all was taken off"
);
let share = bar_button(&d, "Share").expect("the application's own item");
let shared = d.app().shared.clone();
d.press(share);
d.release(share);
d.run(0.05);
assert_eq!(shared.get(), 1, "its message was sent");
}
#[test]
fn an_empty_list_shows_no_bar() {
let mut d = driver_with(Bar::Empty);
let at = on(&d, "second line", 12.0);
assert!(hold(&mut d, at));
assert_eq!(d.area_selection().as_deref(), Some("second"));
assert!(bar_button(&d, "Copy").is_none());
assert!(d
.frame_parts()
.is_some_and(|(ui, _)| !ui.toolbar_contains(at)));
}
fn middle(rect: frus_widgets::Rect) -> Point {
Point::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0)
}
fn carry_handle(d: &mut Driver<Doc>, from: Point, to: Point) {
d.press(from);
d.run(0.05);
for step in 1..=4 {
let f = step as f32 / 4.0;
d.move_to(Point::new(
from.x + (to.x - from.x) * f,
from.y + (to.y - from.y) * f,
));
d.run(0.05);
}
d.release(to);
d.run(0.05);
}
#[test]
fn a_touch_selection_has_handles_and_a_mouse_one_does_not() {
let mut d = driver();
let paths = |d: &Driver<Doc>| {
let (ui, _) = d.frame_parts().expect("a frame");
ui.scene()
.primitives()
.iter()
.filter(|p| matches!(p, frus_widgets::Primitive::Path { .. }))
.count()
};
let before = paths(&d);
let at = on(&d, "second line", 12.0);
hold(&mut d, at);
assert_eq!(paths(&d), before + 2, "the two handles are painted");
let [start, end] = d.area_handles().expect("two handles");
assert!(start.line_center.x < end.line_center.x, "{start:?} {end:?}");
assert!(start.rect.y > at.y - 8.0, "they hang below the line");
let (from, to) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
drag(&mut d, from, to);
assert!(d.area_selection().is_some());
assert!(
d.area_handles().is_none(),
"a mouse selection has no handles"
);
d.area_select_all();
assert!(
d.area_handles().is_none(),
"nor does it grow any when the keyboard selects all"
);
}
#[test]
fn the_end_handle_widens_the_selection_across_texts() {
let mut d = driver();
let at = on(&d, "second line", 12.0);
hold(&mut d, at);
let [_, end] = d.area_handles().expect("handles");
let third = on(&d, "third line ends here", 60.0);
let from = middle(end.rect);
let to = Point::new(third.x, from.y + (third.y - at.y));
d.press(from);
d.run(0.05);
d.move_to(Point::new(from.x, (from.y + to.y) / 2.0));
d.run(0.05);
d.move_to(to);
d.run(0.05);
assert!(
bar_button(&d, "Copy").is_none(),
"no bar while a handle moves"
);
d.release(to);
d.run(0.05);
let copied = d.area_selection().expect("a selection");
let lines: Vec<&str> = copied.lines().collect();
assert_eq!(lines.len(), 2, "{copied:?}");
assert_eq!(lines[0], "second line", "the rest of the word's line");
assert!("third line ends here".starts_with(lines[1]), "{copied:?}");
assert!(!lines[1].is_empty());
assert!(bar_button(&d, "Copy").is_some(), "the bar is back");
}
#[test]
fn the_start_handle_widens_it_upward() {
let mut d = driver();
let at = on(&d, "second line", 80.0);
hold(&mut d, at);
assert_eq!(d.area_selection().as_deref(), Some("line"));
let [start, _] = d.area_handles().expect("handles");
let first = on(&d, "first line of words", 30.0);
let from = middle(start.rect);
let to = Point::new(first.x, from.y + (first.y - at.y));
carry_handle(&mut d, from, to);
let copied = d.area_selection().expect("a selection");
let lines: Vec<&str> = copied.lines().collect();
assert_eq!(lines.len(), 2, "{copied:?}");
assert!("first line of words".ends_with(lines[0]), "{copied:?}");
assert_eq!(lines[1], "second line");
}
#[test]
fn a_handle_does_not_cross_the_other() {
let mut d = driver();
let at = on(&d, "second line", 80.0);
hold(&mut d, at);
let before = d.area_selection();
assert!(before.is_some());
let [_, end] = d.area_handles().expect("handles");
let first = on(&d, "first line of words", 5.0);
let from = middle(end.rect);
let to = Point::new(first.x, from.y + (first.y - at.y));
d.press(from);
d.run(0.05);
d.move_to(to);
d.run(0.05);
d.release(to);
d.run(0.05);
assert_eq!(d.area_selection(), before);
}
#[test]
fn the_selection_is_highlighted_in_each_text() {
let mut d = driver();
let (from, to) = (
on(&d, "first line of words", 60.0),
on(&d, "third line ends here", 40.0),
);
let bare = rects(&d);
drag(&mut d, from, to);
let boxes: Vec<frus_widgets::Rect> = rects(&d)
.into_iter()
.filter(|rect| !bare.contains(rect))
.collect();
assert_eq!(boxes.len(), 3, "one per text covered: {boxes:?}");
let outside = on(&d, "Outside the area", 0.0);
assert!(
boxes.iter().all(|b| b.y > outside.y + 10.0),
"none over the line outside the area: {boxes:?}"
);
}
fn rects(d: &Driver<Doc>) -> Vec<frus_widgets::Rect> {
let (ui, _) = d.frame_parts().expect("a frame");
ui.scene()
.primitives()
.iter()
.filter_map(|p| match p {
frus_widgets::Primitive::Rect { rect, .. } => Some(*rect),
_ => None,
})
.collect()
}
}
#[cfg(test)]
mod gesture_detector_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{Container, Flex, GestureDetector, Point, Text, Theme, Widget};
use std::cell::RefCell;
use std::rc::Rc;
const W: f32 = 400.0;
const H: f32 = 400.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Tap,
Double,
Hold,
Menu,
Plain,
OnlyDouble,
}
#[derive(Default)]
struct Page {
heard: Rc<RefCell<Vec<Msg>>>,
}
impl Application for Page {
type Message = Msg;
fn update(&mut self, message: Msg) -> Command<Msg> {
self.heard.borrow_mut().push(message);
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Msg>> {
let block = |label: &str| {
Container::new()
.width(300.0)
.height(60.0)
.child(Text::new(label).no_wrap())
};
Box::new(
Container::new().width(W).height(H).child(
Flex::column()
.gap(40.0)
.child(
GestureDetector::new(block("everything"))
.on_tap(Msg::Tap)
.on_double_tap(Msg::Double)
.on_long_press(Msg::Hold)
.on_secondary_tap(Msg::Menu),
)
.child(GestureDetector::new(block("tap only")).on_tap(Msg::Plain))
.child(
GestureDetector::new(block("double only"))
.on_double_tap(Msg::OnlyDouble),
),
),
)
}
}
fn driver() -> (Driver<Page>, Rc<RefCell<Vec<Msg>>>) {
let page = Page::default();
let heard = page.heard.clone();
let mut driver = Driver::new(page, W, H);
driver.run(0.2);
(driver, heard)
}
fn on(d: &Driver<Page>, label: &str) -> Point {
let (_, rect) = d
.texts()
.into_iter()
.find(|(text, _)| text == label)
.unwrap_or_else(|| panic!("{label:?} is on screen"));
Point::new(rect.x + 20.0, rect.y + 8.0)
}
fn tap(d: &mut Driver<Page>, at: Point) {
d.press(at);
d.run(0.02);
d.release(at);
d.run(0.02);
}
#[test]
fn a_tap_waits_while_a_double_tap_is_possible() {
let (mut d, heard) = driver();
let at = on(&d, "everything");
tap(&mut d, at);
assert!(heard.borrow().is_empty(), "not yet: {:?}", heard.borrow());
assert!(d.tap_deadline(), "a tap was waiting");
assert_eq!(*heard.borrow(), vec![Msg::Tap]);
}
#[test]
fn the_loop_wakes_for_a_waiting_tap() {
let (mut d, _) = driver();
let before = std::time::Instant::now();
let at = on(&d, "everything");
tap(&mut d, at);
let wake = d.next_wake().expect("a wake for the tap");
let wait = wake.duration_since(before);
assert!(
wait <= std::time::Duration::from_millis(400),
"about 300 ms: {wait:?}"
);
d.tap_deadline();
assert_eq!(d.next_wake(), None);
}
#[test]
fn two_quick_taps_are_a_double_tap() {
let (mut d, heard) = driver();
let at = on(&d, "everything");
tap(&mut d, at);
tap(&mut d, Point::new(at.x + 6.0, at.y + 2.0));
assert_eq!(*heard.borrow(), vec![Msg::Double]);
assert!(!d.tap_deadline(), "nothing is left waiting");
assert_eq!(*heard.borrow(), vec![Msg::Double]);
}
#[test]
fn a_tap_alone_answers_at_once() {
let (mut d, heard) = driver();
let at = on(&d, "tap only");
tap(&mut d, at);
assert_eq!(*heard.borrow(), vec![Msg::Plain]);
tap(&mut d, at);
assert_eq!(
*heard.borrow(),
vec![Msg::Plain, Msg::Plain],
"two taps are two taps"
);
}
#[test]
fn a_press_elsewhere_sends_the_waiting_tap() {
let (mut d, heard) = driver();
let (first, other) = (on(&d, "everything"), on(&d, "tap only"));
tap(&mut d, first);
tap(&mut d, other);
assert_eq!(*heard.borrow(), vec![Msg::Tap, Msg::Plain]);
}
#[test]
fn a_secondary_click_sends_its_message() {
let (mut d, heard) = driver();
let at = on(&d, "everything");
d.secondary_click(at);
d.run(0.02);
assert_eq!(*heard.borrow(), vec![Msg::Menu]);
let plain = on(&d, "tap only");
d.secondary_click(plain);
assert_eq!(
*heard.borrow(),
vec![Msg::Menu],
"a detector without one hears nothing"
);
}
#[test]
fn a_long_press_is_not_also_a_tap() {
let (mut d, heard) = driver();
let at = on(&d, "everything");
d.press(at);
d.run(0.1);
assert!(d.hold_deadline(), "the hold fired");
d.release(at);
d.run(0.02);
d.tap_deadline();
assert_eq!(*heard.borrow(), vec![Msg::Hold]);
}
#[test]
fn a_double_tap_alone_is_heard() {
let (mut d, heard) = driver();
let at = on(&d, "double only");
tap(&mut d, at);
d.tap_deadline();
assert!(heard.borrow().is_empty(), "{:?}", heard.borrow());
tap(&mut d, at);
tap(&mut d, at);
assert_eq!(*heard.borrow(), vec![Msg::OnlyDouble]);
}
}
#[cfg(test)]
mod gesture_pan_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{
Container, Flex, GestureDetector, PanAxis, Point, SingleChildScrollView, Text, Theme,
Widget,
};
use std::cell::RefCell;
use std::rc::Rc;
const W: f32 = 400.0;
const H: f32 = 400.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Start(Point),
Update(Point, Point),
End(Point),
Tap,
Hold,
}
struct Page {
axis: PanAxis,
scrolled: bool,
heard: Rc<RefCell<Vec<Msg>>>,
}
impl Application for Page {
type Message = Msg;
fn update(&mut self, message: Msg) -> Command<Msg> {
self.heard.borrow_mut().push(message);
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Msg>> {
let detector = GestureDetector::new(
Container::new()
.width(300.0)
.height(150.0)
.child(Text::new("drag me").no_wrap()),
)
.on_tap(Msg::Tap)
.on_long_press(Msg::Hold)
.on_pan_start(Msg::Start)
.on_pan_update(Msg::Update)
.on_pan_end(Msg::End)
.pan_axis(self.axis);
let content = Flex::column()
.child(Container::new().width(300.0).height(30.0))
.child(detector)
.child(Container::new().width(300.0).height(1200.0));
if self.scrolled {
Box::new(
SingleChildScrollView::new()
.width(W)
.height(H)
.child(content),
)
} else {
Box::new(Container::new().width(W).height(H).child(content))
}
}
}
fn driver(axis: PanAxis, scrolled: bool) -> (Driver<Page>, Rc<RefCell<Vec<Msg>>>) {
let heard = Rc::new(RefCell::new(Vec::new()));
let page = Page {
axis,
scrolled,
heard: heard.clone(),
};
let mut driver = Driver::new(page, W, H);
driver.run(0.2);
(driver, heard)
}
fn origin(d: &Driver<Page>) -> Point {
let (_, rect) = d
.texts()
.into_iter()
.find(|(text, _)| text == "drag me")
.expect("on screen");
Point::new(rect.x, rect.y)
}
fn swipe(d: &mut Driver<Page>, from: Point, dx: f32, dy: f32) {
d.press(from);
d.run(0.02);
for step in 1..=8 {
let f = step as f32 / 8.0;
d.move_to(Point::new(from.x + dx * f, from.y + dy * f));
d.run(0.02);
}
d.release(Point::new(from.x + dx, from.y + dy));
d.run(0.02);
}
fn deltas(heard: &[Msg]) -> Point {
heard.iter().fold(Point::new(0.0, 0.0), |sum, m| match m {
Msg::Update(_, delta) => Point::new(sum.x + delta.x, sum.y + delta.y),
_ => sum,
})
}
fn scroll_y(d: &Driver<Page>) -> f32 {
d.offsets().first().map_or(0.0, |(_, offset)| offset.1)
}
#[test]
fn a_drag_is_a_start_updates_and_an_end() {
let (mut d, heard) = driver(PanAxis::Free, false);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 40.0);
swipe(&mut d, at, 120.0, 60.0);
let heard = heard.borrow();
assert!(matches!(heard.first(), Some(Msg::Start(_))), "{heard:?}");
if let Some(Msg::Start(local)) = heard.first() {
let corner = origin(&d);
assert!(corner.y >= 30.0, "{corner:?}");
assert_eq!(*local, Point::new(at.x - corner.x, at.y - corner.y));
}
assert!(matches!(heard.last(), Some(Msg::End(_))), "{heard:?}");
if let Some(Msg::End(velocity)) = heard.last() {
assert!(velocity.x.hypot(velocity.y) <= 8000.0 + 0.5, "{velocity:?}");
}
let moved = deltas(&heard);
assert!(moved.x > 60.0 && moved.y > 20.0, "{moved:?}");
assert!(!heard.contains(&Msg::Tap), "a drag is not a tap");
let last = heard.iter().rev().find_map(|m| match m {
Msg::Update(local, _) => Some(*local),
_ => None,
});
let corner = origin(&d);
assert_eq!(
last,
Some(Point::new(at.x + 120.0 - corner.x, at.y + 60.0 - corner.y))
);
}
#[test]
fn a_press_that_does_not_move_is_a_tap() {
let (mut d, heard) = driver(PanAxis::Free, false);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 40.0);
swipe(&mut d, at, 3.0, 2.0);
assert_eq!(*heard.borrow(), vec![Msg::Tap]);
}
#[test]
fn a_hold_on_a_dragging_detector_is_a_long_press() {
let (mut d, heard) = driver(PanAxis::Free, false);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 40.0);
d.press(at);
d.run(0.1);
assert!(d.hold_deadline(), "the hold fired");
d.release(at);
d.run(0.02);
assert_eq!(*heard.borrow(), vec![Msg::Hold]);
}
#[test]
fn a_mouse_drags_it() {
let (mut d, heard) = driver(PanAxis::Free, false);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 40.0);
d.press_mouse(at);
d.run(0.02);
d.move_mouse(Point::new(at.x + 30.0, at.y));
d.run(0.02);
d.release_mouse(Point::new(at.x + 30.0, at.y));
d.run(0.02);
let heard = heard.borrow();
assert!(matches!(heard.first(), Some(Msg::Start(_))), "{heard:?}");
assert!(matches!(heard.last(), Some(Msg::End(_))), "{heard:?}");
}
#[test]
fn a_horizontal_drag_shares_a_vertical_scroll() {
let (mut d, heard) = driver(PanAxis::Horizontal, true);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 40.0);
swipe(&mut d, at, 120.0, 10.0);
assert!(
matches!(heard.borrow().first(), Some(Msg::Start(_))),
"{:?}",
heard.borrow()
);
assert_eq!(deltas(&heard.borrow()).y, 0.0, "only across");
assert_eq!(scroll_y(&d), 0.0, "the page did not move");
heard.borrow_mut().clear();
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 120.0);
swipe(&mut d, at, 5.0, -120.0);
assert!(heard.borrow().is_empty(), "{:?}", heard.borrow());
assert!(scroll_y(&d) > 30.0, "the page scrolled: {}", scroll_y(&d));
}
#[test]
fn a_free_drag_leaves_the_scroll_its_own_direction() {
let (mut d, heard) = driver(PanAxis::Free, true);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 40.0);
swipe(&mut d, at, 150.0, 8.0);
assert!(
matches!(heard.borrow().first(), Some(Msg::Start(_))),
"{:?}",
heard.borrow()
);
assert_eq!(scroll_y(&d), 0.0);
heard.borrow_mut().clear();
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 120.0);
swipe(&mut d, at, 4.0, -120.0);
assert!(heard.borrow().is_empty(), "{:?}", heard.borrow());
assert!(scroll_y(&d) > 30.0, "the page scrolled: {}", scroll_y(&d));
}
#[test]
fn a_stall_mid_drag_does_not_change_the_scroll() {
let (mut d, heard) = driver(PanAxis::Free, true);
let from = Point::new(origin(&d).x + 40.0, origin(&d).y + 120.0);
d.press(from);
d.run(0.02);
for step in 1..=8 {
let f = step as f32 / 8.0;
d.move_to(Point::new(from.x + 4.0 * f, from.y - 120.0 * f));
if step == 5 {
std::thread::sleep(std::time::Duration::from_millis(20));
}
d.run(0.02);
}
d.release(Point::new(from.x + 4.0, from.y - 120.0));
d.run(0.02);
assert!(heard.borrow().is_empty(), "{:?}", heard.borrow());
assert!(scroll_y(&d) > 30.0, "the page scrolled: {}", scroll_y(&d));
}
#[test]
fn a_vertical_drag_takes_it_from_the_scroll() {
let (mut d, heard) = driver(PanAxis::Vertical, true);
let at = Point::new(origin(&d).x + 40.0, origin(&d).y + 120.0);
swipe(&mut d, at, 6.0, -100.0);
let heard = heard.borrow();
assert!(matches!(heard.first(), Some(Msg::Start(_))), "{heard:?}");
let moved = deltas(&heard);
assert_eq!(moved.x, 0.0, "only down");
assert!(moved.y < -40.0, "{moved:?}");
assert_eq!(scroll_y(&d), 0.0, "the page did not move");
}
}
#[cfg(test)]
mod mouse_region_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{
Container, Cursor, Flex, MouseRegion, Point, Positioned, Stack, TextField, Theme, Widget,
};
use std::cell::RefCell;
use std::rc::Rc;
const W: f32 = 400.0;
const H: f32 = 600.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Enter(&'static str, Point),
Move(&'static str),
Exit(&'static str),
Show,
}
struct Page {
front_opaque: bool,
shown: bool,
heard: Rc<RefCell<Vec<Msg>>>,
}
fn region(name: &'static str, child: impl Widget<Msg> + 'static) -> MouseRegion<Msg> {
MouseRegion::new(child)
.on_enter(move |at| Msg::Enter(name, at))
.on_hover(move |_| Msg::Move(name))
.on_exit(move |_| Msg::Exit(name))
}
impl Application for Page {
type Message = Msg;
fn update(&mut self, message: Msg) -> Command<Msg> {
if message == Msg::Show {
self.shown = true;
} else {
self.heard.borrow_mut().push(message);
}
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Msg>> {
let nested = region(
"outer",
Container::new().width(300.0).height(200.0).child(
region("inner", Container::new().width(100.0).height(50.0))
.cursor(Cursor::Pointer),
),
)
.cursor(Cursor::Grab);
let stack = Stack::new()
.width(300.0)
.height(200.0)
.layer(region("back", Container::new().width(300.0).height(200.0)))
.layer(
Positioned::new(
region("front", Container::new().width(100.0).height(100.0))
.opaque(self.front_opaque),
)
.left(50.0)
.top(50.0),
);
let field = region("field", TextField::new("").width(300.0)).cursor(Cursor::Pointer);
let appearing: Box<dyn Widget<Msg>> = if self.shown {
Box::new(region("late", Container::new().width(300.0).height(100.0)))
} else {
Box::new(Container::new().width(300.0).height(100.0))
};
Box::new(
Container::new().width(W).height(H).child(
Flex::column()
.child(nested)
.child(stack)
.child(Container::new().height(100.0).child(field))
.child(appearing),
),
)
}
}
fn driver(front_opaque: bool) -> (Driver<Page>, Rc<RefCell<Vec<Msg>>>) {
let heard = Rc::new(RefCell::new(Vec::new()));
let page = Page {
front_opaque,
shown: false,
heard: heard.clone(),
};
let mut driver = Driver::new(page, W, H);
driver.run(0.2);
(driver, heard)
}
fn take(heard: &Rc<RefCell<Vec<Msg>>>) -> Vec<Msg> {
std::mem::take(&mut *heard.borrow_mut())
}
fn mouse(d: &mut Driver<Page>, x: f32, y: f32) {
d.move_mouse(Point::new(x, y));
d.run(0.02);
}
#[test]
fn nested_regions_are_entered_and_left_together() {
let (mut d, heard) = driver(true);
mouse(&mut d, 20.0, 10.0);
assert_eq!(
take(&heard),
vec![
Msg::Enter("outer", Point::new(20.0, 10.0)),
Msg::Enter("inner", Point::new(20.0, 10.0)),
]
);
mouse(&mut d, 30.0, 12.0);
let moved = take(&heard);
assert!(moved.contains(&Msg::Move("inner")) && moved.contains(&Msg::Move("outer")));
mouse(&mut d, 200.0, 150.0);
assert_eq!(take(&heard), vec![Msg::Exit("inner"), Msg::Move("outer")]);
mouse(&mut d, 350.0, 150.0);
assert_eq!(take(&heard), vec![Msg::Exit("outer")]);
}
#[test]
fn an_opaque_region_hides_the_one_behind() {
let (mut d, heard) = driver(true);
mouse(&mut d, 80.0, 280.0);
let entered: Vec<_> = take(&heard);
assert!(
entered.iter().any(|m| matches!(m, Msg::Enter("front", _))),
"{entered:?}"
);
assert!(
!entered.iter().any(|m| matches!(m, Msg::Enter("back", _))),
"{entered:?}"
);
mouse(&mut d, 250.0, 280.0);
let after = take(&heard);
assert!(after.contains(&Msg::Exit("front")), "{after:?}");
assert!(
after.iter().any(|m| matches!(m, Msg::Enter("back", _))),
"{after:?}"
);
let (mut d, heard) = driver(false);
mouse(&mut d, 80.0, 280.0);
let entered = take(&heard);
assert!(
entered.iter().any(|m| matches!(m, Msg::Enter("front", _))),
"{entered:?}"
);
assert!(
entered.iter().any(|m| matches!(m, Msg::Enter("back", _))),
"{entered:?}"
);
}
#[test]
fn no_moves_while_a_button_is_held() {
let (mut d, heard) = driver(true);
mouse(&mut d, 350.0, 150.0);
take(&heard);
d.press_mouse(Point::new(350.0, 150.0));
d.run(0.02);
d.move_mouse(Point::new(40.0, 20.0));
d.run(0.02);
d.move_mouse(Point::new(45.0, 22.0));
d.run(0.02);
let heard_now = take(&heard);
assert!(
heard_now
.iter()
.any(|m| matches!(m, Msg::Enter("inner", _))),
"{heard_now:?}"
);
assert!(
!heard_now.iter().any(|m| matches!(m, Msg::Move(_))),
"{heard_now:?}"
);
d.release_mouse(Point::new(45.0, 22.0));
d.run(0.02);
}
#[test]
fn a_finger_enters_while_it_touches() {
let (mut d, heard) = driver(true);
d.press(Point::new(200.0, 150.0));
d.run(0.02);
assert_eq!(
take(&heard),
vec![Msg::Enter("outer", Point::new(200.0, 150.0))]
);
d.release(Point::new(200.0, 150.0));
d.run(0.02);
assert_eq!(take(&heard), vec![Msg::Exit("outer")]);
}
#[test]
fn a_region_appearing_under_the_pointer_is_entered() {
let (mut d, heard) = driver(true);
mouse(&mut d, 150.0, 550.0);
assert!(take(&heard).is_empty());
d.update(Msg::Show);
d.run(0.05);
let heard_now = take(&heard);
assert!(
heard_now.iter().any(|m| matches!(m, Msg::Enter("late", _))),
"{heard_now:?}"
);
}
#[test]
fn the_innermost_region_sets_the_cursor() {
let (mut d, _) = driver(true);
mouse(&mut d, 20.0, 10.0);
assert_eq!(d.cursor_shown(), Cursor::Pointer, "the inner region's");
mouse(&mut d, 200.0, 150.0);
assert_eq!(d.cursor_shown(), Cursor::Grab, "the outer region's");
mouse(&mut d, 350.0, 150.0);
assert_eq!(d.cursor_shown(), Cursor::Default, "no region");
mouse(&mut d, 100.0, 420.0);
assert_eq!(d.cursor_shown(), Cursor::Text, "the field's own");
}
}
#[cfg(test)]
mod listener_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{
Button, Container, Flex, Listener, ListenerEvent, ListenerEventKind as Raw, Point,
SingleChildScrollView, Theme, Widget,
};
use std::cell::RefCell;
use std::rc::Rc;
const W: f32 = 400.0;
const H: f32 = 400.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Heard(&'static str, Raw, Point),
Pressed,
}
struct Page {
heard: Rc<RefCell<Vec<Msg>>>,
}
fn listen(name: &'static str) -> impl Fn(ListenerEvent) -> Option<Msg> {
move |event| Some(Msg::Heard(name, event.kind, event.local))
}
impl Application for Page {
type Message = Msg;
fn update(&mut self, message: Msg) -> Command<Msg> {
self.heard.borrow_mut().push(message);
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Msg>> {
let inner = Listener::new(
Container::new()
.width(100.0)
.height(100.0)
.child(Button::new("Press").on_press(Msg::Pressed)),
listen("inner"),
);
let outer = Listener::new(
Container::new().width(300.0).height(300.0).child(inner),
listen("outer"),
);
Box::new(
SingleChildScrollView::new().width(W).height(H).child(
Flex::column()
.child(Container::new().width(300.0).height(40.0))
.child(outer)
.child(Container::new().width(300.0).height(1000.0)),
),
)
}
}
fn driver() -> (Driver<Page>, Rc<RefCell<Vec<Msg>>>) {
let heard = Rc::new(RefCell::new(Vec::new()));
let mut d = Driver::new(
Page {
heard: heard.clone(),
},
W,
H,
);
d.run(0.2);
(d, heard)
}
fn take(heard: &Rc<RefCell<Vec<Msg>>>) -> Vec<Msg> {
std::mem::take(&mut *heard.borrow_mut())
}
fn of(heard: &[Msg], name: &str) -> Vec<(Raw, Point)> {
heard
.iter()
.filter_map(|m| match m {
Msg::Heard(n, kind, at) if *n == name => Some((*kind, *at)),
_ => None,
})
.collect()
}
fn scroll_y(d: &Driver<Page>) -> f32 {
d.offsets().first().map_or(0.0, |(_, offset)| offset.1)
}
#[test]
fn a_press_is_heard_innermost_first() {
let (mut d, heard) = driver();
d.press(Point::new(250.0, 90.0));
d.run(0.02);
let h = take(&heard);
assert_eq!(of(&h, "outer"), vec![(Raw::Down, Point::new(250.0, 50.0))]);
assert!(of(&h, "inner").is_empty(), "outside the inner one: {h:?}");
d.release(Point::new(250.0, 90.0));
d.run(0.02);
take(&heard);
d.press(Point::new(60.0, 80.0));
d.run(0.02);
let h = take(&heard);
let order: Vec<_> = h
.iter()
.filter_map(|m| match m {
Msg::Heard(n, Raw::Down, _) => Some(*n),
_ => None,
})
.collect();
assert_eq!(order, vec!["inner", "outer"], "{h:?}");
assert_eq!(of(&h, "inner"), vec![(Raw::Down, Point::new(60.0, 40.0))]);
d.release(Point::new(60.0, 80.0));
d.run(0.02);
}
#[test]
fn the_pointer_is_followed_out_and_the_page_still_scrolls() {
let (mut d, heard) = driver();
let from = Point::new(250.0, 300.0);
d.press(from);
d.run(0.02);
for step in 1..=8 {
d.move_to(Point::new(250.0, 300.0 - 35.0 * step as f32));
d.run(0.02);
}
d.release(Point::new(250.0, 20.0));
d.run(0.02);
let h = take(&heard);
let outer = of(&h, "outer");
assert_eq!(outer.first().map(|e| e.0), Some(Raw::Down), "{outer:?}");
assert_eq!(outer.last().map(|e| e.0), Some(Raw::Up), "{outer:?}");
assert!(
outer.iter().filter(|e| e.0 == Raw::Move).count() >= 7,
"{outer:?}"
);
assert!(scroll_y(&d) > 50.0, "the page scrolled: {}", scroll_y(&d));
}
#[test]
fn a_pointer_that_leaves_is_still_heard() {
let (mut d, heard) = driver();
d.press_mouse(Point::new(60.0, 80.0));
d.run(0.02);
d.move_mouse(Point::new(390.0, 390.0));
d.run(0.02);
d.release_mouse(Point::new(390.0, 390.0));
d.run(0.02);
let h = take(&heard);
let inner = of(&h, "inner");
assert_eq!(
inner,
vec![
(Raw::Down, Point::new(60.0, 40.0)),
(Raw::Move, Point::new(390.0, 350.0)),
(Raw::Up, Point::new(390.0, 350.0)),
],
"{h:?}"
);
assert_eq!(of(&h, "outer").len(), 3, "{h:?}");
}
#[test]
fn a_button_inside_keeps_its_tap() {
let (mut d, heard) = driver();
let at = Point::new(30.0, 55.0);
d.press(at);
d.release(at);
d.run(0.05);
let h = take(&heard);
assert!(h.contains(&Msg::Pressed), "{h:?}");
let inner = of(&h, "inner");
assert_eq!(
inner.iter().map(|e| e.0).collect::<Vec<_>>(),
vec![Raw::Down, Raw::Up]
);
}
#[test]
fn a_mouse_hovering_is_heard() {
let (mut d, heard) = driver();
d.move_mouse(Point::new(250.0, 90.0));
d.run(0.02);
let h = take(&heard);
assert_eq!(of(&h, "outer"), vec![(Raw::Hover, Point::new(250.0, 50.0))]);
assert!(of(&h, "inner").is_empty());
d.move_mouse(Point::new(390.0, 390.0));
d.run(0.02);
assert!(take(&heard).is_empty(), "outside both");
}
}
#[cfg(test)]
mod menu_keyboard_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{
KeyStroke, MenuBar, MenuItem, MenuPath, Point, ShortcutKey, SubmenuButton, Theme, Widget,
};
const W: f32 = 600.0;
const H: f32 = 400.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Menu(MenuPath),
Picked(&'static str),
}
#[derive(Default)]
struct Editor {
menu: MenuPath,
picked: Option<&'static str>,
}
impl Application for Editor {
type Message = Msg;
fn update(&mut self, message: Msg) -> Command<Msg> {
match message {
Msg::Menu(path) => self.menu = path,
Msg::Picked(what) => {
self.picked = Some(what);
self.menu = MenuPath::closed();
}
}
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Msg>> {
Box::new(
MenuBar::new(&self.menu, Msg::Menu).menu(
SubmenuButton::new("File")
.item(MenuItem::new("New", Msg::Picked("new")))
.submenu(
SubmenuButton::new("Open recent")
.item(MenuItem::new("notes.txt", Msg::Picked("notes")))
.item(MenuItem::new("todo.md", Msg::Picked("todo"))),
)
.item(MenuItem::new("Quit", Msg::Picked("quit"))),
),
)
}
}
fn row(d: &Driver<Editor>, label: &str) -> Option<frus_widgets::WidgetId> {
let (_, rect) = d.texts().into_iter().find(|(text, _)| text == label)?;
d.hit(Point::new(rect.x + 4.0, rect.y + 4.0))
}
#[test]
fn opening_a_menu_focuses_its_first_row_and_escape_goes_back_one_level() {
let mut d = Driver::new(Editor::default(), W, H);
d.run(0.1);
assert_eq!(d.focused(), None);
d.update(Msg::Menu(MenuPath::from_indices(vec![0])));
d.run(0.1);
let new = row(&d, "New").expect("the menu is open");
assert_eq!(d.focused(), Some(new), "the first row has the focus");
d.update(Msg::Menu(MenuPath::from_indices(vec![0, 1])));
d.run(0.1);
let notes = row(&d, "notes.txt").expect("the submenu is open");
assert_eq!(d.focused(), Some(notes), "the submenu's first row has it");
d.escape();
d.run(0.1);
assert_eq!(
d.app().menu,
MenuPath::from_indices(vec![0]),
"one level closed"
);
assert!(row(&d, "notes.txt").is_none());
assert_eq!(d.focused(), Some(new), "and the focus is back in the menu");
d.escape();
d.run(0.1);
assert!(!d.app().menu.is_open(), "then the menu");
}
#[test]
fn the_menu_is_worked_from_the_keyboard() {
let key = |d: &mut Driver<Editor>, k: ShortcutKey| {
d.key(KeyStroke::new(k));
d.run(0.1);
};
let mut d = Driver::new(Editor::default(), W, H);
d.run(0.1);
d.update(Msg::Menu(MenuPath::from_indices(vec![0])));
d.run(0.1);
let (new, recent) = (row(&d, "New").unwrap(), row(&d, "Open recent").unwrap());
assert_eq!(d.focused(), Some(new));
key(&mut d, ShortcutKey::Down);
assert_eq!(d.focused(), Some(recent), "down to the next row");
key(&mut d, ShortcutKey::Right);
assert_eq!(
d.app().menu,
MenuPath::from_indices(vec![0, 1]),
"right opens it"
);
assert_eq!(
d.focused(),
row(&d, "notes.txt"),
"and its first row has the focus"
);
key(&mut d, ShortcutKey::Down);
assert_eq!(d.focused(), row(&d, "todo.md"));
key(&mut d, ShortcutKey::Left);
assert_eq!(
d.app().menu,
MenuPath::from_indices(vec![0]),
"left closes it"
);
assert_eq!(
d.focused(),
Some(recent),
"and the focus is back on its row"
);
key(&mut d, ShortcutKey::Up);
assert_eq!(d.focused(), Some(new));
key(&mut d, ShortcutKey::Enter);
assert_eq!(d.app().picked, Some("new"), "Enter picks the row");
assert!(!d.app().menu.is_open(), "and the menus close");
}
#[test]
fn a_stroke_holds_its_modifiers_for_the_key_alone() {
let mut d = Driver::new(Editor::default(), W, H);
d.run(0.1);
d.key(KeyStroke::new(ShortcutKey::Char('a')).ctrl());
assert!(!d.shell_modifiers().0, "ctrl let go after the stroke");
}
#[test]
fn closing_a_menu_hands_the_focus_back_to_its_word() {
let key = |d: &mut Driver<Editor>, k: ShortcutKey| {
d.key(KeyStroke::new(k));
d.run(0.1);
};
let mut d = Driver::new(Editor::default(), W, H);
d.run(0.1);
key(&mut d, ShortcutKey::Tab);
let file = d.focused().expect("Tab reached the bar");
key(&mut d, ShortcutKey::Enter);
assert!(d.app().menu.is_open(), "Enter opened the menu");
assert_eq!(d.focused(), row(&d, "New"), "and the focus went into it");
key(&mut d, ShortcutKey::Escape);
assert!(!d.app().menu.is_open(), "Escape closed it");
assert_eq!(d.focused(), Some(file), "and the focus is back on its word");
}
#[test]
fn a_submenu_closing_hands_the_focus_back_to_any_row() {
let key = |d: &mut Driver<Editor>, k: ShortcutKey| {
d.key(KeyStroke::new(k));
d.run(0.1);
};
let mut d = Driver::new(Editor::default(), W, H);
d.run(0.1);
d.update(Msg::Menu(MenuPath::from_indices(vec![0])));
d.run(0.1);
key(&mut d, ShortcutKey::Down);
key(&mut d, ShortcutKey::Down);
let quit = row(&d, "Quit");
assert_eq!(d.focused(), quit, "down twice reaches Quit");
d.update(Msg::Menu(MenuPath::from_indices(vec![0, 1])));
d.run(0.1);
assert_eq!(d.focused(), row(&d, "notes.txt"));
key(&mut d, ShortcutKey::Escape);
assert_eq!(d.app().menu, MenuPath::from_indices(vec![0]));
assert_eq!(d.focused(), quit, "the focus is back on Quit");
}
}
#[cfg(test)]
mod right_click_bar_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{Point, TextField, Theme, Widget};
struct Form;
impl Application for Form {
type Message = ();
fn update(&mut self, _message: ()) -> Command<()> {
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
Box::new(TextField::new("some words in a field"))
}
}
#[test]
fn a_right_click_on_a_field_opens_its_bar() {
let mut d = Driver::new(Form, 500.0, 300.0);
d.run(0.1);
let shown = |d: &Driver<Form>, label: &str| d.texts().iter().any(|(t, _)| t == label);
assert!(!shown(&d, "Select all"), "no bar before the click");
let (_, words) = d
.texts()
.into_iter()
.find(|(t, _)| t.contains("some words"))
.expect("the field's words");
d.secondary_click(Point::new(words.x + 20.0, words.y + words.height * 0.5));
d.run(0.1);
assert!(shown(&d, "Select all"), "the bar is open: {:?}", d.texts());
assert!(!shown(&d, "Copy") && !shown(&d, "Cut"));
assert!(
!shown(&d, "Paste"),
"an empty clipboard offers nothing to paste"
);
}
struct DesktopForm;
impl Application for DesktopForm {
type Message = ();
fn update(&mut self, _message: ()) -> Command<()> {
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
Box::new(TextField::new("some words in a field"))
}
fn theme(&self) -> Theme {
Theme::default().with_platform(frus_widgets::TargetPlatform::Linux)
}
}
#[test]
fn a_right_click_opens_the_menu_at_the_pointer() {
let mut d = Driver::new(DesktopForm, 500.0, 300.0);
d.run(0.1);
let (_, words) = d
.texts()
.into_iter()
.find(|(t, _)| t.contains("some words"))
.expect("the field's words");
let click = Point::new(words.x + 20.0, words.y + words.height * 0.5);
d.secondary_click(click);
d.run(0.1);
let (_, row) = d
.texts()
.into_iter()
.find(|(t, _)| t == "Select all")
.expect("the menu is open");
assert_eq!(row.x, click.x + 20.0, "the menu starts at the pointer");
assert!(
row.y >= click.y && row.y < click.y + 36.0,
"its first row is on the pointer's line: {row:?} {click:?}"
);
}
#[test]
fn a_desktop_s_select_all_puts_the_menu_away() {
assert!(frus_widgets::default_target_platform().is_desktop());
let mut d = Driver::new(DesktopForm, 500.0, 300.0);
d.run(0.1);
let shown = |d: &Driver<DesktopForm>, label: &str| {
d.texts()
.into_iter()
.find(|(t, _)| t == label)
.map(|(_, r)| r)
};
let words = shown(&d, "some words in a field").expect("the field's words");
let click = Point::new(words.x + 20.0, words.y + words.height * 0.5);
d.secondary_click(click);
d.run(0.1);
let row = shown(&d, "Select all").expect("the menu is open");
let row = Point::new(row.x + 10.0, row.y + row.height * 0.5);
d.press(row);
d.release(row);
d.run(0.1);
assert!(shown(&d, "Select all").is_none(), "the menu is gone");
assert!(shown(&d, "Copy").is_none(), "all of it: {:?}", d.texts());
d.secondary_click(click);
d.run(0.1);
assert!(shown(&d, "Copy").is_some(), "the text is still selected");
}
}
#[cfg(test)]
mod scroll_behavior_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{
Container, Point, ScrollBehavior, ScrollConfiguration, SingleChildScrollView,
TargetPlatform, Theme, VelocityStrategy, Widget,
};
struct Page;
impl Application for Page {
type Message = ();
fn update(&mut self, _message: ()) -> Command<()> {
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<()>> {
Box::new(ScrollConfiguration::new(
ScrollBehavior::material().with_platform(TargetPlatform::Ios),
SingleChildScrollView::new()
.width(300.0)
.height(400.0)
.child(Container::new().width(300.0).height(2000.0)),
))
}
fn theme(&self) -> Theme {
Theme::default().with_platform(TargetPlatform::Android)
}
}
#[test]
fn a_drag_on_an_area_reads_its_fling_the_area_s_way() {
let mut d = Driver::new(Page, 300.0, 400.0);
d.run(0.2);
d.press(Point::new(150.0, 200.0));
assert_eq!(
d.fling_strategy(),
VelocityStrategy::RecentAverage(frus_widgets::BOUNCING_FLING_WEIGHTS)
);
d.release(Point::new(150.0, 200.0));
}
}
#[cfg(test)]
mod switch_drag_tests {
use super::testing::Driver;
use crate::{Application, Command};
use frus_widgets::{Container, Point, Rect, Switch, Theme, Widget};
const W: f32 = 300.0;
const H: f32 = 200.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Set(bool),
}
#[derive(Default)]
struct Page {
on: bool,
heard: Vec<Msg>,
}
impl Application for Page {
type Message = Msg;
fn update(&mut self, message: Msg) -> Command<Msg> {
let Msg::Set(on) = message;
self.on = on;
self.heard.push(message);
Command::none()
}
fn view(&self, _theme: &Theme) -> Box<dyn Widget<Msg>> {
Box::new(
Container::new()
.width(W)
.height(H)
.child(Switch::new(self.on).on_toggle(Msg::Set)),
)
}
}
fn driver() -> Driver<Page> {
let mut d = Driver::new(Page::default(), W, H);
d.run(0.2);
d
}
fn track_and_thumb(d: &Driver<Page>) -> (Rect, Rect) {
let (ui, _) = d.frame_parts().expect("a frame");
let rects: Vec<Rect> = ui
.scene()
.primitives()
.iter()
.filter_map(|p| match p {
frus_widgets::Primitive::Rect { rect, .. } => Some(*rect),
_ => None,
})
.collect();
let track = *rects
.iter()
.find(|r| r.width == 52.0 && r.height == 32.0)
.expect("the track");
(track, *rects.last().expect("the thumb"))
}
fn along(d: &Driver<Page>) -> f32 {
let (track, thumb) = track_and_thumb(d);
(thumb.x + thumb.width * 0.5 - track.x - 16.0) / 20.0
}
fn drag(d: &mut Driver<Page>, dx: f32, lift: bool) {
let (track, _) = track_and_thumb(d);
let from = Point::new(track.x + 16.0, track.y + 16.0);
d.press(from);
d.run(0.02);
for step in 1..=8 {
d.move_to(Point::new(from.x + dx * step as f32 / 8.0, from.y));
d.run(0.02);
}
if lift {
d.release(Point::new(from.x + dx, from.y));
d.run(0.02);
}
}
#[test]
fn the_thumb_follows_the_finger() {
let mut d = driver();
assert!(along(&d).abs() < 1e-3, "off at rest");
drag(&mut d, 40.0, false);
let now = along(&d);
assert!(now > 0.3 && now <= 1.0, "moved with the finger: {now}");
assert!(d.app().heard.is_empty(), "nothing said while held");
d.run(0.5);
assert!((along(&d) - now).abs() < 1e-3, "held where it was put");
}
#[test]
fn let_go_past_half_way_it_flips() {
let mut d = driver();
drag(&mut d, 60.0, true);
assert_eq!(d.app().heard, vec![Msg::Set(true)]);
d.run(0.5);
assert!((along(&d) - 1.0).abs() < 1e-3, "settled on");
}
#[test]
fn let_go_short_of_half_way_it_goes_back() {
let mut d = driver();
drag(&mut d, 30.0, false);
let (track, _) = track_and_thumb(&d);
let back = Point::new(track.x + 16.0 + 15.0, track.y + 16.0);
d.move_to(back);
d.run(0.02);
assert!(along(&d) > 0.0, "it had moved");
assert!(along(&d) < 0.5, "but is short of half way: {}", along(&d));
d.release(back);
d.run(0.5);
assert!(d.app().heard.is_empty(), "nothing said");
assert!(along(&d).abs() < 1e-3, "back off: {}", along(&d));
}
#[test]
fn a_tap_still_flips_it() {
let mut d = driver();
let (track, _) = track_and_thumb(&d);
let at = Point::new(track.x + 16.0, track.y + 16.0);
d.press(at);
d.release(at);
d.run(0.5);
assert_eq!(d.app().heard, vec![Msg::Set(true)]);
assert!((along(&d) - 1.0).abs() < 1e-3);
}
}