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, ReorderAxis, Runtime, Scene, ScrollTo,
Scrollable, SheetTo, ShortcutKey, Size, Theme, 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,
}
}
#[cfg(any(android, test))]
fn wants_keyboard<M>(widget: &dyn Widget<M>) -> bool {
widget.text_value().is_some() && widget.focusable()
}
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 struct Clipboard(Option<arboard::Clipboard>);
#[cfg(desktop)]
impl Clipboard {
pub fn new() -> Self {
Self(arboard::Clipboard::new().ok())
}
pub fn get_text(&mut self) -> Option<String> {
self.0.as_mut().and_then(|c| c.get_text().ok())
}
pub fn set_text(&mut self, text: String) {
if let Some(c) = self.0.as_mut() {
let _ = c.set_text(text);
}
}
}
#[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 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 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 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, .. }));
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,
},
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>,
axis: Option<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,
runtime: &mut frus_widgets::Runtime,
preferred: &[frus_widgets::Locale],
) {
runtime.scrollbars = app.scrollbars();
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_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>,
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>,
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>,
occluded: bool,
elapsed: f32,
last_insets: WindowInsets,
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 {
Self::with_mailbox(app, Mailbox(None))
}
fn with_mailbox(app: A, proxy: Mailbox<A::Message>) -> Self {
#[cfg(feature = "net")]
frus_widgets::set_image_fetcher(fetch_image_bytes);
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::platform_default(),
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,
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,
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,
occluded: false,
elapsed: 0.0,
last_insets: WindowInsets::ZERO,
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(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) => {
if let Some(msg) = self.ui.as_ref().and_then(|ui| ui.msg_for(id)) {
self.dispatch(msg);
self.request_redraw();
}
}
A11yAction::Focus(id) => {
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) => {
if let Some(msg) = self.ui.as_ref().and_then(|ui| ui.msg_for(id)) {
self.dispatch(msg);
self.request_redraw();
}
}
A11yAction::Focus(id) => {
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)]
{
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);
}
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);
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:#}"),
}
});
return;
}
#[cfg(not(web))]
{
let size = window.inner_size();
let renderer = pollster::block_on(Renderer::new(
window.clone(),
size.width.max(1),
size.height.max(1),
));
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.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
});
}
}
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();
}
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::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
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 {
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.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 {
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))
.and_then(|widget| widget.cursor_at(0.0, 0.0, 1.0, 0))
.is_some();
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.copy_selection(focused);
self.apply_key(focused, Key::Backspace);
self.request_redraw();
}
ClipCommand::Paste => {
if let Some(pasted) =
self.clipboard.paste(focused, self.window.as_ref())
{
self.land_paste(pasted);
}
}
}
return;
}
if self.ctrl {
match &event.logical_key {
WinitKey::Character(c) if c.eq_ignore_ascii_case("a") => {
self.runtime.edits.insert(
focused,
Edit {
cursor: usize::MAX,
anchor: Some(0),
composing: None,
},
);
self.runtime.close_edit_run(focused);
self.request_redraw();
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();
}
}
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_or(self.app.scroll_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, edge, refused, extent, offset, cross);
self.runtime.glow_scroll_end(id);
}
self.request_redraw();
}
}
WindowEvent::RedrawRequested => {
#[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.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, &mut self.runtime, &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();
if frus_widgets::take_rebuild_request() {
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 tree = build_view(&self.app, &theme, &self.runtime);
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 scroll_physics = self.app.scroll_physics();
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 animating = scrolled
| sheeted
| self.runtime.advance(dt)
| self.runtime.advance_leaving(dt)
| self.runtime.advance_switchers(tree, dt)
| self.runtime.advance_values(tree, dt)
| self.runtime.advance_colors(tree, dt)
| self.runtime.advance_sizes(tree, dt)
| self.runtime.advance_radii(tree, dt)
| self.runtime.advance_paddings(tree, dt)
| self.runtime.advance_offsets(tree, dt)
| self.runtime.advance_pins(tree, dt)
| self.runtime.advance_fractions(tree, dt)
| self.runtime.advance_text_styles(tree, dt)
| self.runtime.advance_transforms(tree, dt)
| self
.runtime
.advance_scroll(&scroll_regions, scroll_physics, dt)
| self.runtime.advance_glow(dt)
| self.runtime.advance_refresh(&refresh_areas, dt)
| {
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
}
| {
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) {
let physics = area.physics_or(scroll_physics);
handed |=
self.runtime.fling_scroll(*area, physics, (0.0, velocity));
}
}
moving | handed
}
| self.runtime.advance_interactive(&interactive_bounds, dt)
| self.runtime.advance_ink(dt)
| reorder_animating
| autoscrolling
| app_animating;
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) {
eprintln!("{}", 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)
};
self.report_overflows(&ui);
self.runtime.states.end_frame();
if frus_widgets::rebuild_requested() {
self.request_redraw();
}
if let Some(renderer) = self.renderer.as_mut() {
match renderer.render(&scene) {
frus_gpu::RenderOutcome::Presented => {}
frus_gpu::RenderOutcome::NeedsReconfigure => {
renderer.reconfigure();
}
frus_gpu::RenderOutcome::Skipped => {}
}
}
let wants_animation = ui.wants_animation() || frus_widgets::images_in_flight() > 0;
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);
}
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();
}
}
_ => {}
}
}
}
impl<A: Application> App<A> {
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)
}
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) {
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.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;
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;
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::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();
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);
}
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();
match [press, reload, caret].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 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 icon = match requested.unwrap_or(UiCursor::Default) {
UiCursor::Default => CursorIcon::Default,
UiCursor::Pointer => CursorIcon::Pointer,
UiCursor::Text => CursorIcon::Text,
};
if let Some(window) = &self.window {
window.set_cursor(icon);
}
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.pending_word = None;
if let Some(drag) = self.grab_selection_handle() {
self.drag = Some(drag);
self.request_redraw();
return;
}
if self.runtime.selection_handles.take().is_some() {
self.request_redraw();
}
let on_back_edge = 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 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_or(self.app.scroll_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,
});
self.begin_gesture();
}
}
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, 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 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::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::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 (message, announce) = match (self.runtime.input.pressed, released) {
(Some(pressed), Some(released)) if pressed == released => {
let positional = self
.ui
.as_ref()
.and_then(|ui| ui.widget_rect(released))
.and_then(|rect| {
self.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), released))
.and_then(|widget| {
widget.positional_click(
self.cursor.x - rect.x,
self.cursor.y - rect.y,
rect.width,
rect.height,
)
})
});
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),
};
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(message) = message {
self.dispatch(message);
if let Some(announce) = announce {
self.set_announcement(announce);
}
}
self.request_redraw();
}
#[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 before = self.runtime.input.focused;
let after = resolve_focus(
before,
&present,
&mut self.focus_history,
&mut self.prev_focus,
);
if after != before {
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::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::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,
} => {
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(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_or(self.app.scroll_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.max_x > 0.0, area.max_y > 0.0));
} 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_or(self.app.scroll_physics()))
.unwrap_or_else(|| self.app.scroll_physics());
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, edge, refused, extent, offset, cross);
}
}
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 {
(Instant::now() - self.gesture_start).as_secs_f32()
}
fn begin_gesture(&mut self) {
self.gesture_velocity = VelocityTracker::platform_default();
self.gesture_start = Instant::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_or(self.app.scroll_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: &winit::event::KeyEvent) -> 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 dispatch_drag_edge(&mut self, id: WidgetId, rect: frus_widgets::Rect, edge: Edge) {
let fraction = drag_fraction(rect, self.cursor.x);
let message = self
.tree
.as_ref()
.and_then(|tree| find_widget(tree.as_ref(), id))
.and_then(|widget| 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 fraction = drag_fraction(rect, self.cursor.x);
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(fraction))
});
if let Some(message) = message {
self.dispatch(message);
}
}
fn system_back(&mut self, event_loop: &ActiveEventLoop) {
if let Some(message) = self.ui.as_ref().and_then(|ui| ui.top_dismiss()) {
self.dispatch(message);
self.request_redraw();
return;
}
if self.app.can_go_back() {
self.build_dirty = true;
self.app.back_gesture(0.0);
self.app.back_gesture_end(5.0);
self.request_redraw();
return;
}
event_loop.exit();
}
fn escape(&mut self) {
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 {
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);
#[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;
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) {
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));
if let Some(text) = text {
self.clipboard.set_text(text);
}
}
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();
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Edge {
Start,
End,
}
fn drag_fraction(rect: frus_widgets::Rect, x: f32) -> f32 {
if rect.width > 0.0 {
((x - rect.x) / rect.width).clamp(0.0, 1.0)
} else {
0.0
}
}
fn gesture_was_a_tap(ended: Option<&Drag>) -> bool {
matches!(
ended,
Some(
Drag::Scroll { moved: false, .. }
| Drag::Pan { 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| {
if down {
a.max_y > 0.0 || a.refresh.is_some()
} else {
a.max_x > 0.0
}
})
.or_else(|| chain.iter().find(|a| a.id == under))
.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 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"
);
}
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()
}
}
let mut runtime = frus_widgets::Runtime::default();
install_ambient(
&Speaks(None),
&mut runtime,
&[Locale::with_country("fr", "BE")],
);
assert_eq!(frus_widgets::locale::of(), Locale::new("fr"));
install_ambient(&Speaks(None), &mut runtime, &[]);
assert_eq!(frus_widgets::locale::of(), Locale::new("en"));
install_ambient(
&Speaks(Some(Locale::with_country("fr", "FR"))),
&mut runtime,
&[Locale::new("en")],
);
assert_eq!(frus_widgets::locale::of(), Locale::new("fr"));
install_ambient(&Deferred, &mut runtime, &[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))
}
}
let mut runtime = frus_widgets::Runtime::default();
install_ambient(&Speaks, &mut runtime, &[]);
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, &mut runtime, &[]);
assert_eq!(
frus_widgets::localizations::of().back_button_label(),
"Retour",
"saying nothing is not the same as saying English"
);
}
#[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,
};
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,
physics: None,
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 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,
physics: None,
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,
}
}
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"
);
}
}
#[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 {
Self {
shell: App::detached(app),
size: Size::new(width, height),
ghost: None,
}
}
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 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
}
fn pointer(&mut self, kind: PointerKind, at: Point) {
self.shell.pointer_event(PointerEvent {
kind,
position: at,
touch: true,
});
}
pub fn frame(&mut self, dt: f32) {
let (width, height) = (self.size.width, self.size.height);
let s = &mut self.shell;
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, &mut s.runtime, &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);
if frus_widgets::take_rebuild_request() {
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();
let physics = s.app.scroll_physics();
s.runtime.sync_pages(®ions);
s.runtime.sync_visible(®ions);
s.runtime.advance(dt);
s.runtime.advance_scroll(®ions, physics, dt);
let tree = s.tree.as_deref().expect("the view was built");
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);
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,
});
});
}
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));
}
}