Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
//! # What is accessibility?
|
||||
//! Accessibility (or a11y) is the umbrella term used to describe features that enable people with
|
||||
//! disabilities to use certain software. In our case: we focus on blind users and their day-to-day
|
||||
//! life with screen readers.
|
||||
//!
|
||||
//! ## How does a11y work in Warp?
|
||||
//! Because Warp uses its own rust UI framework (warpui), we don’t benefit from the built-in
|
||||
//! VoiceOver integration and objc NSAccessibility APIs. This is both good and bad for our app and
|
||||
//! the UI framework.
|
||||
//!
|
||||
//! Good parts:
|
||||
//! - We actually had to think on how to add support to the UI framework to make it easier for future
|
||||
//! app developers to not overlook a11y;
|
||||
//! - We don’t rely on complicated defaults and the cumbersome experience of analyzing all the UI
|
||||
//! elements in the app, instead we can provide a more ergonomic experience to blind users.
|
||||
//!
|
||||
//! Bad parts:
|
||||
//! - It takes time to implement the full support (for example, we still lack the ability to focus a
|
||||
//! certain UI element, like a button, and “click it” or otherwise act on it with just the keyboard);
|
||||
//! - We need to think about a11y (yeah, I mentioned it in good parts, but given that a11y is usually
|
||||
//! thirdwheeling next to AwesomeFeatures™ and BugFixes® it’s easy to ship features that are not accessible).
|
||||
//!
|
||||
//! WarpUI framework right now provides 3 ways of announcing what’s happening in the app:
|
||||
//! - Accessibility Contents for the currently focused View;
|
||||
//! - Accessibility Contents for the currently performed Action;
|
||||
//! - On-demand emitting Accessibility Contents.
|
||||
//!
|
||||
//! ## Testing for a11y
|
||||
//! We don’t have (and I don’t know if such a thing even exists) a way to automatically test a11y
|
||||
//! features. To test it then, we just need to run the app and run VoiceOver.
|
||||
//!
|
||||
//! To run it - go to your System Preferences -> Accessibility -> VoiceOver, and then click
|
||||
//! “Enable VoiceOver”. Note that it may be loud and distracting. It’s sometimes easier to turn off
|
||||
//! the sound, and check the content of the tiny rectangle that will show on your screen together
|
||||
//! with VoiceOver.
|
||||
//!
|
||||
//! ### What to look for?
|
||||
//! - Whenever a new view opens, the user will get the information about what’s happening;
|
||||
//! - The feature is keyboard accessible (a good practice would be to have it in the command palette);
|
||||
//! - Any meaningful changes to the state of feature are announced (both triggered by a user’s
|
||||
//! Action or a background Event);
|
||||
//! - The user can quit the feature and get back to the command input with keyboard (a good
|
||||
//! practice would be to keep it consistent among all the features, and quit via Escape key);
|
||||
//! - User docs mention whether the feature is accessible (on the feature’s page) and what’s the
|
||||
//! keybinding to access it;
|
||||
//! - If there’s a video/GIF in the user docs, make sure that its content is also reflected in text.
|
||||
|
||||
use crate::Action;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Main structure describing the content VoiceOver (or other screen reading software) will receive.
|
||||
pub struct AccessibilityContent {
|
||||
/// The main information related to the view/action/event. Keep it short and as informative
|
||||
/// as possible. It’s semi-equivalent to
|
||||
/// [AccessibilityLabel](https://developer.apple.com/documentation/appkit/nsaccessibility/1534976-accessibilitylabel).
|
||||
/// For example, a `value` for the command editor in our case is “Command Input”.
|
||||
pub value: String,
|
||||
/// Optional string that provides more context and information about available actions.
|
||||
/// For example, help for the “Command Input” informs about “cmd-up” action.
|
||||
pub help: Option<String>,
|
||||
/// (currently unused) The rectangle that describes where the given element is on the screen.
|
||||
/// System’s APIs then draw a frame around that element, making it super clear what object
|
||||
/// the description is referring to.
|
||||
/// Frame support is a work-in-progress in Warp and right now this field is omitted and not set.
|
||||
pub frame: Option<RectF>,
|
||||
/// The role a given element has. Note that we use our own, WarpUI-defined roles (vs those that
|
||||
/// come from the NSAccessibility framework). The role describes the action/element/event role (
|
||||
/// for example, when the “Command Input” is focused, it announces with a `TextareaRole`.
|
||||
/// This is another helper field that lets the user understand what they can potentially do,
|
||||
/// or what object is in focus.
|
||||
pub role: WarpA11yRole,
|
||||
}
|
||||
|
||||
/// Verbosity level of a11y announcements. By default, all announcements include both the value
|
||||
/// and help (if provided). It can be changed per-app basis, in AppContext.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(
|
||||
feature = "schema_gen",
|
||||
schemars(
|
||||
description = "Verbosity level for screen reader announcements.",
|
||||
rename_all = "snake_case"
|
||||
)
|
||||
)]
|
||||
#[cfg_attr(feature = "settings_value", derive(settings_value::SettingsValue))]
|
||||
pub enum AccessibilityVerbosity {
|
||||
/// Default verbosity level, includes help string.
|
||||
#[default]
|
||||
#[serde(rename = "VERBOSE")]
|
||||
#[cfg_attr(feature = "schema_gen", schemars(rename = "verbose"))]
|
||||
Verbose,
|
||||
/// Concise level, only announces `value` from AccessibilityContent.
|
||||
#[serde(rename = "CONCISE")]
|
||||
#[cfg_attr(feature = "schema_gen", schemars(rename = "concise"))]
|
||||
Concise,
|
||||
}
|
||||
|
||||
/// For single character strings, we want to announce extra information (such as
|
||||
/// capitalization). For longer/shorter strings - we just return the string itself.
|
||||
// Note: This should be localized or passed directly to voice over in a "working" format.
|
||||
// For some reason, VO currently ignores out punctuation and capital letters so we need to do it
|
||||
// manually. The Appkit Obj-C APIs don't provide support to enforce punctuation or change of pitch
|
||||
// for capital letters, so for now we're just implementing the missing pieces by hand, yay!
|
||||
fn string_announcement(s: String) -> String {
|
||||
if s.len() != 1 {
|
||||
return s;
|
||||
}
|
||||
|
||||
let c = s.chars().next().expect("String has exactly 1 character");
|
||||
|
||||
if c.is_uppercase() {
|
||||
return format!("capital {s}");
|
||||
}
|
||||
|
||||
if c.is_ascii_punctuation() {
|
||||
return match c {
|
||||
'.' => "period".to_string(),
|
||||
'!' => "exclamation mark".to_string(),
|
||||
'~' => "tilde".to_string(),
|
||||
'`' => "accent".to_string(),
|
||||
'^' => "caret".to_string(),
|
||||
'(' => "left parenthesis".to_string(),
|
||||
')' => "right parenthesis".to_string(),
|
||||
'-' => "hyphen".to_string(),
|
||||
'_' => "underscore".to_string(),
|
||||
'?' => "question mark".to_string(),
|
||||
':' => "colon".to_string(),
|
||||
';' => "semicolon".to_string(),
|
||||
'"' => "double quotation mark".to_string(),
|
||||
'\'' => "single quotation mark".to_string(),
|
||||
'\\' => "backslash".to_string(),
|
||||
'/' => "slash".to_string(),
|
||||
',' => "comma".to_string(),
|
||||
'[' => "left bracket".to_string(),
|
||||
']' => "right bracket".to_string(),
|
||||
'{' => "left brace".to_string(),
|
||||
'}' => "right brace".to_string(),
|
||||
'|' => "vertical line".to_string(),
|
||||
|
||||
// everything else seems to have proper interpretation in voiceover
|
||||
_ => s,
|
||||
};
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
impl AccessibilityContent {
|
||||
// TODO add frame support
|
||||
pub fn new_without_help<T>(value: T, role: WarpA11yRole) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
Self::new_internal::<T, String>(value, None, role)
|
||||
}
|
||||
|
||||
pub fn new<V, H>(value: V, help: H, role: WarpA11yRole) -> Self
|
||||
where
|
||||
V: Into<String>,
|
||||
H: Into<String>,
|
||||
{
|
||||
Self::new_internal(value, Some(help), role)
|
||||
}
|
||||
|
||||
fn new_internal<V, H>(value: V, help: Option<H>, role: WarpA11yRole) -> Self
|
||||
where
|
||||
V: Into<String>,
|
||||
H: Into<String>,
|
||||
{
|
||||
let value: String = value.into();
|
||||
// Note that for values that are all whitespace, we still want to read them out, hence
|
||||
// swapping certain whitespace characters with their "readings".
|
||||
let value = if value.chars().all(char::is_whitespace) {
|
||||
value
|
||||
.replace(' ', " space ") // Note: order here is important, space should go first.
|
||||
.replace('\t', " tab ")
|
||||
.replace('\n', " newline ")
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
string_announcement(value)
|
||||
};
|
||||
AccessibilityContent {
|
||||
value,
|
||||
help: help.map(|s| s.into()),
|
||||
role,
|
||||
frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_frame(mut self, frame: Option<RectF>) -> Self {
|
||||
self.frame = frame;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_verbosity(mut self, verbosity: AccessibilityVerbosity) -> Self {
|
||||
if matches!(verbosity, AccessibilityVerbosity::Concise) {
|
||||
self.help = None;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
pub enum WarpA11yRole {
|
||||
ButtonRole,
|
||||
CheckboxRole,
|
||||
HelpRole,
|
||||
ImageRole,
|
||||
LinkRole,
|
||||
ListRole,
|
||||
MenuItemRole,
|
||||
MenuRole,
|
||||
PopoverRole,
|
||||
ScrollareaRole,
|
||||
TextRole,
|
||||
TextareaRole,
|
||||
TextfieldRole,
|
||||
#[default]
|
||||
WindowRole,
|
||||
UserAction,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WarpA11yRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
use WarpA11yRole::*;
|
||||
let word = match self {
|
||||
ButtonRole => "Button",
|
||||
CheckboxRole => "Checkbox",
|
||||
HelpRole => "Help",
|
||||
ImageRole => "Image",
|
||||
LinkRole => "Link",
|
||||
ListRole => "List",
|
||||
MenuItemRole => "MenuItem",
|
||||
MenuRole => "Menu",
|
||||
PopoverRole => "Popover",
|
||||
ScrollareaRole => "Scrollarea",
|
||||
TextRole => "Text",
|
||||
TextareaRole => "Textarea",
|
||||
TextfieldRole => "Textfield",
|
||||
WindowRole => "Window",
|
||||
UserAction => "Action",
|
||||
};
|
||||
write!(f, "{word}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum ActionAccessibilityContent {
|
||||
#[default]
|
||||
Empty,
|
||||
Custom(AccessibilityContent),
|
||||
CustomFn(fn(&dyn Action) -> AccessibilityContent),
|
||||
}
|
||||
|
||||
impl ActionAccessibilityContent {
|
||||
pub fn from_debug() -> Self {
|
||||
Self::CustomFn(|action| {
|
||||
AccessibilityContent::new_without_help(format!("{action:?}."), WarpA11yRole::UserAction)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<AccessibilityContent>> for ActionAccessibilityContent {
|
||||
fn from(opt: Option<AccessibilityContent>) -> ActionAccessibilityContent {
|
||||
match opt {
|
||||
None => ActionAccessibilityContent::Empty,
|
||||
Some(content) => ActionAccessibilityContent::Custom(content),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// A StandardAction is one that corresponds to an action that
|
||||
/// must be dispatched and handled natively by NSApp (e.g. terminate:)
|
||||
/// Use CustomActions for handling Warp specific actions.
|
||||
///
|
||||
/// Set a 'repr' here as we store these values as tags in menu items.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, ToPrimitive, Hash)]
|
||||
#[repr(isize)]
|
||||
pub enum StandardAction {
|
||||
Close,
|
||||
Hide,
|
||||
HideOtherApps,
|
||||
ShowAllApps,
|
||||
Quit,
|
||||
Zoom,
|
||||
Minimize,
|
||||
BringAllToFront,
|
||||
ToggleFullScreen,
|
||||
Paste,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::time::get_current_time;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde_json::json;
|
||||
|
||||
// `DailyAppFocusDuration` records the cumulative duration for focus events that end on each day.
|
||||
struct DailyAppFocusDuration {
|
||||
duration: Duration,
|
||||
last_synced_time: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl DailyAppFocusDuration {
|
||||
// If calendar date has advanced since the last sync, record the
|
||||
// Daily App Focus event with the current duration.
|
||||
#[allow(deprecated)]
|
||||
fn try_record(&mut self, user_id: Option<String>, anonymous_id: String) {
|
||||
if get_current_time().date_naive() > self.last_synced_time.date_naive() {
|
||||
let daily_app_focus_duration_seconds =
|
||||
json!(self.duration.num_milliseconds() as f64 / 1000.);
|
||||
crate::telemetry::record_event(
|
||||
user_id,
|
||||
anonymous_id,
|
||||
"Daily App Focus Duration (seconds)".into(),
|
||||
Some(daily_app_focus_duration_seconds),
|
||||
false, /* contains_ugc */
|
||||
self.last_synced_time.date().and_hms(0, 0, 0),
|
||||
);
|
||||
self.reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.duration = Duration::seconds(0);
|
||||
self.last_synced_time = get_current_time();
|
||||
}
|
||||
|
||||
fn add_duration(&mut self, duration: Duration, user_id: Option<String>, anonymous_id: String) {
|
||||
self.try_record(user_id, anonymous_id);
|
||||
if let Some(new_duration) = self.duration.checked_add(&duration) {
|
||||
self.duration = new_duration;
|
||||
} else {
|
||||
log::info!("Unable to increase the running total daily app focus duration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppFocusInfo {
|
||||
last_time_app_focused: DateTime<Utc>,
|
||||
daily_app_focus_duration: DailyAppFocusDuration,
|
||||
}
|
||||
|
||||
impl AppFocusInfo {
|
||||
pub fn new() -> Self {
|
||||
let now = get_current_time();
|
||||
Self {
|
||||
last_time_app_focused: now,
|
||||
daily_app_focus_duration: DailyAppFocusDuration {
|
||||
duration: Duration::seconds(0),
|
||||
last_synced_time: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_app_focus(&mut self, user_id: Option<String>, anonymous_id: String) {
|
||||
self.last_time_app_focused = get_current_time();
|
||||
self.try_record_daily_app_focus_duration(user_id, anonymous_id);
|
||||
}
|
||||
|
||||
pub fn try_record_daily_app_focus_duration(
|
||||
&mut self,
|
||||
user_id: Option<String>,
|
||||
anonymous_id: String,
|
||||
) {
|
||||
self.daily_app_focus_duration
|
||||
.try_record(user_id, anonymous_id);
|
||||
}
|
||||
|
||||
pub fn record_app_blur(&mut self, user_id: Option<String>, anonymous_id: String) {
|
||||
let app_focus_duration =
|
||||
get_current_time().signed_duration_since(self.last_time_app_focused);
|
||||
self.daily_app_focus_duration
|
||||
.add_duration(app_focus_duration, user_id, anonymous_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app_focus_telemetry_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::app_focus_telemetry::AppFocusInfo;
|
||||
use crate::time::test_offset_time;
|
||||
use chrono::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_daily_app_focus_duration_increase() {
|
||||
let mut app_focus_info = AppFocusInfo::new();
|
||||
let user_id = Some("user123".to_string());
|
||||
let anonymous_id = "anon-user-xyz".to_string();
|
||||
|
||||
// When app blurs, the daily focus duration increases if date is the same
|
||||
let focus_duration_0 = app_focus_info.daily_app_focus_duration.duration;
|
||||
let last_synced_date_0 = app_focus_info
|
||||
.daily_app_focus_duration
|
||||
.last_synced_time
|
||||
.date_naive();
|
||||
app_focus_info.record_app_focus(user_id.clone(), anonymous_id.clone());
|
||||
test_offset_time(10);
|
||||
app_focus_info.record_app_blur(user_id.clone(), anonymous_id.clone());
|
||||
let focus_duration_1 = app_focus_info.daily_app_focus_duration.duration;
|
||||
let last_synced_date_1 = app_focus_info
|
||||
.daily_app_focus_duration
|
||||
.last_synced_time
|
||||
.date_naive();
|
||||
assert_eq!(focus_duration_1 - focus_duration_0, Duration::seconds(10));
|
||||
assert_eq!(last_synced_date_1, last_synced_date_0);
|
||||
|
||||
// If date is the next day, the running total would be counted for the new day
|
||||
app_focus_info.record_app_focus(user_id.clone(), anonymous_id.clone());
|
||||
let one_day_seconds = 24 * 60 * 60;
|
||||
test_offset_time(one_day_seconds);
|
||||
app_focus_info.record_app_blur(user_id, anonymous_id);
|
||||
let focus_duration_2 = app_focus_info.daily_app_focus_duration.duration;
|
||||
let last_synced_date_2 = app_focus_info
|
||||
.daily_app_focus_duration
|
||||
.last_synced_time
|
||||
.date_naive();
|
||||
assert_eq!(focus_duration_2, Duration::seconds(one_day_seconds));
|
||||
assert_eq!(last_synced_date_2 - last_synced_date_1, Duration::days(1));
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{Error, Result};
|
||||
use async_channel::{self, Receiver, Sender};
|
||||
use bytes::Bytes;
|
||||
use derivative::Derivative;
|
||||
use futures::FutureExt as _;
|
||||
use futures::{future::BoxFuture, Future};
|
||||
use std::any::{Any, TypeId};
|
||||
use std::pin::Pin;
|
||||
use std::{cell::RefCell, collections::HashMap, hash::Hash, rc::Rc, sync::Arc};
|
||||
|
||||
use crate::image_cache::ImageCache;
|
||||
use crate::{r#async::executor, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::AssetProvider;
|
||||
|
||||
pub trait FetchAsset: crate::r#async::Spawnable + Future<Output = Result<Bytes>> {}
|
||||
impl<T: crate::r#async::Spawnable + Future<Output = Result<Bytes>> + ?Sized> FetchAsset for T {}
|
||||
|
||||
/// Marker trait for async asset ID namespaces.
|
||||
///
|
||||
/// Each distinct kind of async asset source defines its own zero-sized marker
|
||||
/// type that implements this trait. The marker's [`TypeId`] is stored inside
|
||||
/// [`AsyncAssetId`] so that IDs from different sources can never collide, even
|
||||
/// if they happen to share the same key string.
|
||||
pub trait AsyncAssetType: 'static {}
|
||||
|
||||
/// A namespaced identifier for an [`AssetSource::Async`] entry.
|
||||
///
|
||||
/// The namespace is stored as a [`TypeId`] derived from a marker type that
|
||||
/// implements [`AsyncAssetType`]. This guarantees that two different async
|
||||
/// sources cannot accidentally produce colliding cache keys.
|
||||
#[derive(Clone, Hash, PartialEq, Eq)]
|
||||
pub struct AsyncAssetId {
|
||||
namespace: TypeId,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl AsyncAssetId {
|
||||
/// Creates a new ID in the namespace defined by `N`.
|
||||
pub fn new<N: AsyncAssetType>(key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
namespace: TypeId::of::<N>(),
|
||||
key: key.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the key portion of this ID.
|
||||
pub fn key(&self) -> &str {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AsyncAssetId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// TypeId's Debug output is opaque, so just print the key.
|
||||
f.debug_struct("AsyncAssetId")
|
||||
.field("key", &self.key)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A "URI" for some data file. In other words, the location of an asset.
|
||||
#[derive(Derivative)]
|
||||
#[derivative(Clone, Hash, PartialEq, Eq, Debug)]
|
||||
pub enum AssetSource {
|
||||
/// Loaded from an arbitrary asynchronous source (e.g. a URL fetch).
|
||||
Async {
|
||||
/// A namespaced identifier used as the cache key.
|
||||
id: AsyncAssetId,
|
||||
/// A factory that produces the future to fetch the asset bytes.
|
||||
/// Called at most once per unique `id` — only when the asset is
|
||||
/// not already loaded or loading.
|
||||
#[derivative(Hash = "ignore", PartialEq = "ignore", Debug = "ignore")]
|
||||
fetch: Arc<dyn Fn() -> Pin<Box<dyn FetchAsset>> + Send + Sync>,
|
||||
},
|
||||
/// Included in the app bundle.
|
||||
Bundled {
|
||||
// Assets that are statically included in the bundle can be statically
|
||||
// referenced, hence using a `&'static str` here and not a `String`.
|
||||
path: &'static str,
|
||||
},
|
||||
/// Accessible in the user's local filesystem at the provided path.
|
||||
LocalFile { path: String },
|
||||
/// Image loaded directly with bytes
|
||||
Raw { id: String },
|
||||
}
|
||||
|
||||
/// The public representation of an asset's current state (i.e., in-memory availability).
|
||||
pub enum AssetState<T> {
|
||||
Loading { handle: AssetHandle },
|
||||
Loaded { data: Rc<T> },
|
||||
Evicted,
|
||||
FailedToLoad(Rc<Error>),
|
||||
}
|
||||
|
||||
/// An external type so views can refer to the asset they requested.
|
||||
/// Transforms into a future that resolves once the asset is finished loading, allowing
|
||||
/// work to be scheduled at the time of load completion.
|
||||
#[derive(Clone, Hash, PartialEq, Eq, Debug)]
|
||||
pub struct AssetHandle {
|
||||
source: AssetSource,
|
||||
asset_type: TypeId,
|
||||
}
|
||||
|
||||
impl AssetHandle {
|
||||
/// Creates a future that resolves whenever the asset is finished loading.
|
||||
pub fn when_loaded(&self, asset_cache: &AssetCache) -> Option<BoxFuture<'static, ()>> {
|
||||
asset_cache.create_future_for_loading_asset(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// An internal representation of an asset's state, as it's tracked and updated by the
|
||||
/// AssetCache. An implementation.
|
||||
enum AssetStateInternal {
|
||||
Loading {
|
||||
channel: (Sender<()>, Receiver<()>),
|
||||
},
|
||||
Loaded {
|
||||
data: Rc<dyn Any>,
|
||||
timestamp: u64,
|
||||
size_in_bytes: usize,
|
||||
},
|
||||
Evicted,
|
||||
Error(Rc<Error>),
|
||||
}
|
||||
|
||||
impl AssetStateInternal {
|
||||
fn loading() -> Self {
|
||||
// Whenever we add an asset in a loading state, we create a channel that
|
||||
// can be alerted once the asset load completes (i.e., becomes available
|
||||
// or encounters an error). The channel must support the ability to clone one
|
||||
// side of the channel.
|
||||
let channel = async_channel::bounded(1);
|
||||
AssetStateInternal::Loading { channel }
|
||||
}
|
||||
|
||||
fn to_external_type<T: Asset>(&self, source: AssetSource) -> AssetState<T> {
|
||||
match self {
|
||||
AssetStateInternal::Loading { .. } => AssetState::Loading {
|
||||
handle: AssetHandle {
|
||||
source,
|
||||
asset_type: TypeId::of::<T>(),
|
||||
},
|
||||
},
|
||||
AssetStateInternal::Loaded { data, .. } => AssetState::Loaded {
|
||||
data: data
|
||||
.clone()
|
||||
.downcast::<T>()
|
||||
.expect("should not fail to downcast"),
|
||||
},
|
||||
AssetStateInternal::Evicted => AssetState::Evicted,
|
||||
AssetStateInternal::Error(err) => AssetState::FailedToLoad(err.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A general-purpose data cache for managing assets. Generalized to any file type.
|
||||
/// Internally handles networking and persistence caching.
|
||||
pub struct AssetCache {
|
||||
// Note: interior mutability allows us to update the state of an asset
|
||||
// without requiring a mutable reference to the AssetCache.
|
||||
inner: Rc<RefCell<HashMap<AssetHandle, AssetStateInternal>>>,
|
||||
|
||||
bundled_asset_provider: Box<dyn AssetProvider>,
|
||||
foreground_executor: Rc<executor::Foreground>,
|
||||
background_executor: Arc<executor::Background>,
|
||||
}
|
||||
|
||||
pub trait Asset: Any {
|
||||
fn try_from_bytes(data: &[u8]) -> anyhow::Result<Self>
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn size_in_bytes(&self) -> usize;
|
||||
}
|
||||
|
||||
impl Asset for String {
|
||||
fn try_from_bytes(data: &[u8]) -> anyhow::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
std::str::from_utf8(data)
|
||||
.map(|s| s.to_string())
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
fn size_in_bytes(&self) -> usize {
|
||||
self.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetCache {
|
||||
const MAX_RAW_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
|
||||
|
||||
pub fn new(
|
||||
bundled_asset_provider: Box<dyn AssetProvider>,
|
||||
foreground_executor: Rc<executor::Foreground>,
|
||||
background_executor: Arc<executor::Background>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Rc::new(RefCell::new(HashMap::new())),
|
||||
bundled_asset_provider,
|
||||
foreground_executor,
|
||||
background_executor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks the current total size of raw assets in memory.
|
||||
pub fn get_total_raw_asset_size(&self) -> usize {
|
||||
self.inner
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter_map(|(handle, state)| {
|
||||
if let AssetStateInternal::Loaded { size_in_bytes, .. } = state {
|
||||
if matches!(handle.source, AssetSource::Raw { .. }) {
|
||||
return Some(*size_in_bytes);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Removes the least recently added raw assets until the total size is within the limit.
|
||||
fn evict_raw_assets_if_needed(&self, ctx: &ModelContext<Self>) -> Vec<u32> {
|
||||
let mut total_size = self.get_total_raw_asset_size();
|
||||
let mut assets = self.inner.borrow_mut();
|
||||
|
||||
if total_size <= Self::MAX_RAW_ASSET_SIZE {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Collect all raw assets with their timestamps
|
||||
let mut raw_assets: Vec<_> = assets
|
||||
.iter()
|
||||
.filter_map(|(handle, state)| {
|
||||
if matches!(handle.source, AssetSource::Raw { .. }) {
|
||||
if let AssetStateInternal::Loaded {
|
||||
timestamp,
|
||||
size_in_bytes,
|
||||
..
|
||||
} = state
|
||||
{
|
||||
return Some((handle.clone(), *timestamp, *size_in_bytes));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
raw_assets.sort_by_key(|&(_, timestamp, _)| timestamp);
|
||||
|
||||
let mut evicted_image_ids = vec![];
|
||||
|
||||
// Evict until within the limit
|
||||
for (handle, _, size_in_bytes) in raw_assets {
|
||||
if total_size <= Self::MAX_RAW_ASSET_SIZE {
|
||||
break;
|
||||
}
|
||||
if let AssetSource::Raw { id } = &handle.source {
|
||||
if assets.remove(&handle).is_some() {
|
||||
assets.insert(handle.clone(), AssetStateInternal::Evicted);
|
||||
ImageCache::as_ref(ctx).evict_image(&handle.source);
|
||||
total_size -= size_in_bytes;
|
||||
|
||||
if let Ok(id) = id.parse::<u32>() {
|
||||
evicted_image_ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evicted_image_ids
|
||||
}
|
||||
|
||||
/// The main API of the asset cache. Given the location of an asset, returns an indicator of the
|
||||
/// in-memory availability of the asset. If the asset is not already loaded or loading, a background
|
||||
/// task is spawned to perform the retrieval.
|
||||
///
|
||||
/// Note: this is an idempotent operation. It can be called as many times as needed on a given
|
||||
/// asset and won't duplicate work.
|
||||
pub fn load_asset<T: Asset>(&self, source: AssetSource) -> AssetState<T> {
|
||||
let mut assets = self.inner.borrow_mut();
|
||||
|
||||
// If we've already seen this asset source, we can simply return the current state of it. Otherwise,
|
||||
// begin the load.
|
||||
let key = AssetHandle {
|
||||
source: source.clone(),
|
||||
asset_type: TypeId::of::<T>(),
|
||||
};
|
||||
if !assets.contains_key(&key) {
|
||||
match source.clone() {
|
||||
AssetSource::Async { fetch, .. } => {
|
||||
assets.insert(key.clone(), AssetStateInternal::loading());
|
||||
let future = (fetch)();
|
||||
self.load_asynchronously::<T>(source.clone(), future);
|
||||
}
|
||||
AssetSource::Bundled { path } => {
|
||||
let asset_state = match self
|
||||
.bundled_asset_provider
|
||||
.get(path)
|
||||
.and_then(|bytes| T::try_from_bytes(&bytes))
|
||||
{
|
||||
Ok(asset) => {
|
||||
let timestamp = instant::now() as u64;
|
||||
let size_in_bytes = asset.size_in_bytes();
|
||||
|
||||
AssetStateInternal::Loaded {
|
||||
data: Rc::new(asset) as Rc<dyn Any>,
|
||||
timestamp,
|
||||
size_in_bytes,
|
||||
}
|
||||
}
|
||||
Err(err) => AssetStateInternal::Error(Rc::new(err)),
|
||||
};
|
||||
assets.insert(key.clone(), asset_state);
|
||||
}
|
||||
AssetSource::LocalFile { path } => {
|
||||
assets.insert(key.clone(), AssetStateInternal::loading());
|
||||
self.load_asynchronously::<T>(
|
||||
source.clone(),
|
||||
Box::pin(async move {
|
||||
let buffer = async_fs::read(path).await?;
|
||||
Ok(buffer.into())
|
||||
}),
|
||||
);
|
||||
}
|
||||
AssetSource::Raw { id } => {
|
||||
assets.insert(
|
||||
key.clone(),
|
||||
AssetStateInternal::Error(Rc::new(anyhow!(
|
||||
"Raw image with ID {:?} did not exist",
|
||||
id
|
||||
))),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
assets[&key].to_external_type(source)
|
||||
}
|
||||
|
||||
pub fn insert_raw_asset_bytes<T: Asset>(
|
||||
&self,
|
||||
id: String,
|
||||
bytes: &[u8],
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut assets = self.inner.borrow_mut();
|
||||
let source = AssetSource::Raw { id: id.clone() };
|
||||
let key = AssetHandle {
|
||||
source: source.clone(),
|
||||
asset_type: TypeId::of::<T>(),
|
||||
};
|
||||
match T::try_from_bytes(bytes) {
|
||||
Ok(asset) => {
|
||||
let timestamp = instant::now() as u64;
|
||||
let size_in_bytes = asset.size_in_bytes();
|
||||
|
||||
assets.insert(
|
||||
key.clone(),
|
||||
AssetStateInternal::Loaded {
|
||||
data: Rc::new(asset) as Rc<dyn Any>,
|
||||
timestamp,
|
||||
size_in_bytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Raw asset conversion failed (ID: {id}): {err:#}");
|
||||
assets.insert(key.clone(), AssetStateInternal::Error(Rc::new(err)));
|
||||
}
|
||||
};
|
||||
|
||||
ImageCache::as_ref(ctx).evict_image(&source);
|
||||
|
||||
drop(assets);
|
||||
let image_ids = self.evict_raw_assets_if_needed(ctx);
|
||||
|
||||
if !image_ids.is_empty() {
|
||||
ctx.emit(AssetCacheEvent::ImagesEvicted { image_ids });
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a future that resolves when an asset is loaded into moemory.
|
||||
fn create_future_for_loading_asset(
|
||||
&self,
|
||||
asset_handle: &AssetHandle,
|
||||
) -> Option<BoxFuture<'static, ()>> {
|
||||
let assets = self.inner.borrow_mut();
|
||||
|
||||
assets.get(asset_handle).map(|asset_state| {
|
||||
match asset_state {
|
||||
AssetStateInternal::Loading { channel } => {
|
||||
// Internally, the future works by cloning a new receiver on the channel that's assigned
|
||||
// to this asset. Inside the future, we simply wait on the receiving end of the channel.
|
||||
// Note that the channel is held by the AssetStateInternal::Loading variant, so when the asset
|
||||
// is promoted to the Loaded or FailedToLoad variants, the channel is dropped. This returns a
|
||||
// RecvError to any receivers, serving as our notification that the asset is no longer loading.
|
||||
let rx = channel.1.clone();
|
||||
async move {
|
||||
let _ = rx.recv().await;
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
// If the asset isn't currently loading, it is either already loaded or it's in an error state. Either
|
||||
// way, we should return a future that resolves immediately since there's no more pending updates
|
||||
// for this asset.
|
||||
_ => futures::future::ready(()).boxed(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper method to spawn the futures that perform an asset load and place the results into the asset cache.
|
||||
fn load_asynchronously<T: Asset>(
|
||||
&self,
|
||||
asset_source: AssetSource,
|
||||
future: Pin<Box<dyn FetchAsset>>,
|
||||
) {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
// Spawn the work on the background executor.
|
||||
self.background_executor
|
||||
.spawn(async move {
|
||||
let result = future.await;
|
||||
// When the fetch finished, send the results to the future running on the foreground executor.
|
||||
if tx.send(result).is_err() {
|
||||
log::error!("Error sending background task result to main thread");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Spawn a receiver on the foreground executor.
|
||||
let assets = Rc::downgrade(&self.inner);
|
||||
self.foreground_executor
|
||||
.spawn_boxed(Box::pin(async move {
|
||||
let result = match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
let msg = "sender unexpectedly dropped before receiver";
|
||||
log::error!("{msg}");
|
||||
Err(anyhow!(msg))
|
||||
}
|
||||
};
|
||||
|
||||
let Some(assets) = assets.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut assets = assets.borrow_mut();
|
||||
|
||||
// Populate the asset cache with the result.
|
||||
let handle = AssetHandle {
|
||||
source: asset_source.clone(),
|
||||
asset_type: TypeId::of::<T>(),
|
||||
};
|
||||
match result {
|
||||
Ok(bytes) => match T::try_from_bytes(&bytes) {
|
||||
Ok(asset) => {
|
||||
log::debug!("Asset fetch succeeded: {asset_source:?}");
|
||||
|
||||
let timestamp = instant::now() as u64;
|
||||
let size_in_bytes = asset.size_in_bytes();
|
||||
|
||||
assets.insert(
|
||||
handle,
|
||||
AssetStateInternal::Loaded {
|
||||
data: Rc::new(asset) as Rc<dyn Any>,
|
||||
timestamp,
|
||||
size_in_bytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Asset conversion failed ({asset_source:?}): {err:#}");
|
||||
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
log::warn!("Asset fetch failed ({asset_source:?}): {err:#}");
|
||||
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
|
||||
}
|
||||
}
|
||||
}))
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AssetCacheEvent {
|
||||
ImagesEvicted { image_ids: Vec<u32> },
|
||||
}
|
||||
|
||||
impl Entity for AssetCache {
|
||||
type Event = AssetCacheEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for AssetCache {}
|
||||
@@ -0,0 +1,16 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::borrow::Cow;
|
||||
pub mod asset_cache;
|
||||
|
||||
impl AssetProvider for () {
|
||||
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>> {
|
||||
Err(anyhow!(
|
||||
"get called on empty asset provider with \"{}\"",
|
||||
path
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AssetProvider: 'static {
|
||||
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::{
|
||||
future::Future,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures::{pin_mut, FutureExt as _};
|
||||
use futures_util::stream::AbortHandle;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_family = "wasm")] {
|
||||
mod wasm;
|
||||
use wasm as imp;
|
||||
} else {
|
||||
mod native;
|
||||
use native as imp;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export a variety of symbols from the internal implementation modules.
|
||||
pub use imp::{block_on, BoxFuture, Spawnable, SpawnableOutput, Stream, Timer, TransportStream};
|
||||
|
||||
pub use futures_util::future::LocalBoxFuture;
|
||||
|
||||
pub mod executor {
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("constructed off of the main thread")]
|
||||
NotOnMainThread,
|
||||
}
|
||||
|
||||
pub use super::imp::executor::*;
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct FutureId(usize);
|
||||
|
||||
static NEXT_FUTURE_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
impl FutureId {
|
||||
/// \return the next view ID. Note the first return is 0.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub(super) fn new() -> FutureId {
|
||||
let raw = NEXT_FUTURE_ID.fetch_add(1, Ordering::Relaxed);
|
||||
FutureId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a future that was spawned on an executor via `ctx#spawn`.
|
||||
/// The handle can be used to abort the future using `#abort`. In tests, the
|
||||
/// future ID can be used to await the spawned future.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpawnedFutureHandle {
|
||||
abort_handle: AbortHandle,
|
||||
future_id: FutureId,
|
||||
}
|
||||
|
||||
impl SpawnedFutureHandle {
|
||||
/// Abort the spawned future associated with this handle.
|
||||
pub fn abort(&self) {
|
||||
self.abort_handle.abort()
|
||||
}
|
||||
|
||||
pub fn abort_handle(&self) -> AbortHandle {
|
||||
self.abort_handle.clone()
|
||||
}
|
||||
|
||||
/// The `FutureID` associated with this `SpawnedFuture`. In tests, this can be used to
|
||||
/// await the spawned future.
|
||||
pub fn future_id(&self) -> FutureId {
|
||||
self.future_id
|
||||
}
|
||||
|
||||
pub fn new(abort_handle: AbortHandle, future_id: FutureId) -> Self {
|
||||
Self {
|
||||
abort_handle,
|
||||
future_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpawnedLocalStream {
|
||||
#[allow(dead_code)]
|
||||
future: LocalBoxFuture<'static, ()>,
|
||||
}
|
||||
|
||||
impl SpawnedLocalStream {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_future(self) -> LocalBoxFuture<'static, ()> {
|
||||
self.future
|
||||
}
|
||||
|
||||
pub(crate) fn new(future: LocalBoxFuture<'static, ()>) -> Self {
|
||||
Self { future }
|
||||
}
|
||||
}
|
||||
|
||||
/// This trait impl allows us to use `Background` as an executor in some executor-agnostic libraries,
|
||||
impl futures_util::task::Spawn for executor::Background {
|
||||
fn spawn_obj(
|
||||
&self,
|
||||
future: futures::task::FutureObj<'static, ()>,
|
||||
) -> Result<(), futures::task::SpawnError> {
|
||||
self.spawn(future).detach();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status(&self) -> Result<(), futures::task::SpawnError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TimeoutError;
|
||||
|
||||
pub trait FutureExt: Future {
|
||||
/// Converts a future into one that will time out with an error after a
|
||||
/// given duration.
|
||||
///
|
||||
/// Note that this timeout can only occur while the future is at an await
|
||||
/// point, so futures wrapped in this way must periodically yield back to
|
||||
/// the executor.
|
||||
fn with_timeout(
|
||||
self,
|
||||
timeout: Duration,
|
||||
) -> impl Future<Output = Result<<Self as Future>::Output, TimeoutError>>;
|
||||
}
|
||||
|
||||
impl<F: Future> FutureExt for F {
|
||||
async fn with_timeout(
|
||||
self,
|
||||
timeout: Duration,
|
||||
) -> Result<<Self as Future>::Output, TimeoutError> {
|
||||
let fut = self.fuse();
|
||||
pin_mut!(fut);
|
||||
|
||||
let mut timeout = Timer::after(timeout).fuse();
|
||||
|
||||
futures::select! {
|
||||
value = fut => Ok(value),
|
||||
_ = timeout => Err(TimeoutError),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::{
|
||||
marker::PhantomData,
|
||||
pin::Pin,
|
||||
rc::Rc,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use async_executor::LocalExecutor;
|
||||
use futures::{
|
||||
future::{BoxFuture, LocalBoxFuture},
|
||||
Future, FutureExt,
|
||||
};
|
||||
use futures_util::future::{AbortHandle, Abortable};
|
||||
|
||||
use crate::{platform, r#async::executor::Error};
|
||||
|
||||
pub type ForegroundTask = async_task::Task<()>;
|
||||
|
||||
pub struct BackgroundTask {
|
||||
inner: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
pub fn abort(&self) {
|
||||
if let Some(inner) = &self.inner {
|
||||
inner.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detach(self) {
|
||||
// Nothing to do here; dropping the join handle will cause the task
|
||||
// to be detached.
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for BackgroundTask {
|
||||
type Output = Result<(), tokio::task::JoinError>;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
match &mut self.inner {
|
||||
Some(inner) => inner.poll_unpin(cx),
|
||||
None => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Foreground {
|
||||
Platform {
|
||||
not_send_or_sync: PhantomData<Rc<()>>, // Make sure the type is `!Send` and `!Sync`.
|
||||
delegate: Arc<dyn platform::DispatchDelegate>,
|
||||
},
|
||||
Test {
|
||||
executor: LocalExecutor<'static>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Foreground {
|
||||
pub fn platform(delegate: Arc<dyn platform::DispatchDelegate>) -> Result<Self, Error> {
|
||||
if delegate.is_main_thread() {
|
||||
Ok(Self::Platform {
|
||||
not_send_or_sync: PhantomData,
|
||||
delegate,
|
||||
})
|
||||
} else {
|
||||
Err(Error::NotOnMainThread)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test() -> Self {
|
||||
Self::Test {
|
||||
executor: LocalExecutor::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on the main thread.
|
||||
///
|
||||
/// If you have a boxed future, use `spawn_boxed` instead.
|
||||
pub fn spawn(&self, future: impl Future<Output = ()> + 'static) -> ForegroundTask {
|
||||
self.spawn_boxed(future.boxed_local())
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on the main thread.
|
||||
///
|
||||
/// This takes in a boxed future in order to avoid monomorphizing the
|
||||
/// underlying task implementation. `spawn_boxed` generates significantly
|
||||
/// less code than a generic implementation, with no noticeable performance
|
||||
/// impact.
|
||||
pub fn spawn_boxed(&self, future: LocalBoxFuture<'static, ()>) -> ForegroundTask {
|
||||
match self {
|
||||
Foreground::Platform {
|
||||
not_send_or_sync: _,
|
||||
delegate: platform,
|
||||
} => {
|
||||
let platform = platform.clone();
|
||||
let schedule = move |task: async_task::Runnable| platform.run_on_main_thread(task);
|
||||
let (runnable, handle) = async_task::spawn_local(future, schedule);
|
||||
runnable.schedule();
|
||||
handle
|
||||
}
|
||||
Foreground::Test { executor } => executor.spawn(future),
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules an abortable asynchronous task to run on the main thread.
|
||||
///
|
||||
/// This is the same as `spawn()` except the task may be aborted using the returned
|
||||
/// [`AbortHandle`].
|
||||
pub fn spawn_abortable(
|
||||
&self,
|
||||
future: impl Future<Output = ()> + 'static,
|
||||
) -> (ForegroundTask, AbortHandle) {
|
||||
let (handle, registration) = AbortHandle::new_pair();
|
||||
let task = self.spawn(Abortable::new(future, registration).map(|_| ()));
|
||||
(task, handle)
|
||||
}
|
||||
|
||||
pub async fn run<T>(&'_ self, future: impl Future<Output = T>) -> T {
|
||||
match self {
|
||||
Foreground::Platform {
|
||||
not_send_or_sync: _,
|
||||
delegate: _,
|
||||
} => unimplemented!("only the test executor can be run"),
|
||||
Foreground::Test { executor } => executor.run(future).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Background {
|
||||
runtime: Option<tokio::runtime::Runtime>,
|
||||
}
|
||||
|
||||
impl Drop for Background {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.runtime.take() {
|
||||
// Cancel all running tasks immediately instead of blocking until they complete.
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Background {
|
||||
fn default() -> Self {
|
||||
let num_threads = if cfg!(any(test, feature = "integration_tests")) {
|
||||
// For tests, limit each test to a single background thread.
|
||||
// When running unit tests via [`App::test()`] on machines with
|
||||
// many logical cores, the time it takes to spawning the background
|
||||
// threads can far exceed the time it takes to actually run the
|
||||
// test.
|
||||
1
|
||||
} else {
|
||||
// In production, create a thread for each logical CPU core,
|
||||
// maximizing our possible parallelism.
|
||||
num_cpus::get()
|
||||
};
|
||||
|
||||
Self::new(num_threads, |i| format!("background-executor-{i}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Background {
|
||||
pub fn new(
|
||||
num_threads: usize,
|
||||
name_fn: impl Fn(usize) -> String + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(num_threads)
|
||||
.thread_name_fn(move || {
|
||||
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
|
||||
name_fn(id)
|
||||
})
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("should not fail to create tokio runtime for background executor");
|
||||
|
||||
Self {
|
||||
runtime: Some(runtime),
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on a background thread.
|
||||
///
|
||||
/// If you have a boxed future, use `spawn_boxed` instead.
|
||||
pub fn spawn(&self, future: impl Send + Future<Output = ()> + 'static) -> BackgroundTask {
|
||||
self.spawn_boxed(future.boxed())
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on a background thread.
|
||||
///
|
||||
/// This takes in a boxed future in order to avoid monomorphizing the
|
||||
/// underlying task implementation. `spawn_boxed` generates significantly
|
||||
/// less code than a generic implementation, with no noticeable performance
|
||||
/// impact.
|
||||
pub fn spawn_boxed(&self, future: BoxFuture<'static, ()>) -> BackgroundTask {
|
||||
let inner = match &self.runtime {
|
||||
Some(runtime) => Some(runtime.spawn(future)),
|
||||
None => {
|
||||
log::error!("tried to spawn a background task after the executor was shut down");
|
||||
None
|
||||
}
|
||||
};
|
||||
BackgroundTask { inner }
|
||||
}
|
||||
|
||||
/// Schedules an abortable asynchronous task to run on a background thread.
|
||||
///
|
||||
/// This is the same as `spawn()` except the task may be aborted using the returned
|
||||
/// [`AbortHandle`].
|
||||
pub fn spawn_abortable(
|
||||
&self,
|
||||
future: impl Send + Future<Output = ()> + 'static,
|
||||
) -> (BackgroundTask, AbortHandle) {
|
||||
let (handle, registration) = AbortHandle::new_pair();
|
||||
let task = self.spawn(Abortable::new(future, registration).map(|_| ()));
|
||||
(task, handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
pub mod executor;
|
||||
|
||||
pub use async_io::{block_on, Timer};
|
||||
use futures::Future;
|
||||
|
||||
pub use futures_util::future::BoxFuture;
|
||||
|
||||
trait_set::trait_set! {
|
||||
/// A trait representing a task which can be run in the background.
|
||||
pub trait Spawnable = 'static + Future + Send;
|
||||
/// A trait representing a stream which can be polled in the background.
|
||||
pub trait Stream = 'static + futures::Stream + Send;
|
||||
/// A trait representing a value which can be returned from a background
|
||||
/// task.
|
||||
pub trait SpawnableOutput = Send;
|
||||
/// Bounds for async I/O streams passed to cross-platform networking code.
|
||||
/// On native, streams must be `Send` for the multi-threaded tokio runtime.
|
||||
pub trait TransportStream = Unpin + Send + 'static;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::{future::LocalBoxFuture, Future, FutureExt};
|
||||
use futures_util::future::{AbortHandle, Abortable};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::{platform, r#async::executor::Error};
|
||||
|
||||
/// A handle to a task that will run on the main thread.
|
||||
pub struct ForegroundTask;
|
||||
|
||||
impl ForegroundTask {
|
||||
/// Detaches the task to let it keep running in the background.
|
||||
pub fn detach(self) {}
|
||||
}
|
||||
|
||||
/// A handle to a task that will run in the background.
|
||||
///
|
||||
/// In practice, for wasm, this task will be executed on the singular main
|
||||
/// thread that all JavaScript code is run on.
|
||||
pub struct BackgroundTask;
|
||||
|
||||
impl BackgroundTask {
|
||||
/// Detaches the task to let it keep running in the background.
|
||||
pub fn detach(self) {}
|
||||
}
|
||||
|
||||
/// An executor that can be used to run tasks on the main thread.
|
||||
pub struct Foreground;
|
||||
|
||||
impl Foreground {
|
||||
pub fn platform(_delegate: Arc<dyn platform::DispatchDelegate>) -> Result<Self, Error> {
|
||||
Ok(Foreground)
|
||||
}
|
||||
|
||||
pub fn test() -> Self {
|
||||
Foreground
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on the main thread.
|
||||
///
|
||||
/// If you have a boxed future, use `spawn_boxed` instead.
|
||||
pub fn spawn(&self, future: impl Future<Output = ()> + 'static) -> ForegroundTask {
|
||||
self.spawn_boxed(future.boxed_local())
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on the main thread.
|
||||
///
|
||||
/// This takes in a boxed future in order to avoid monomorphizing the
|
||||
/// underlying task implementation. `spawn_boxed` generates significantly
|
||||
/// less code than a generic implementation, with no noticeable performance
|
||||
/// impact.
|
||||
pub fn spawn_boxed(&self, future: LocalBoxFuture<'static, ()>) -> ForegroundTask {
|
||||
spawn_local(future);
|
||||
ForegroundTask
|
||||
}
|
||||
|
||||
/// Schedules an abortable asynchronous task to run on the main thread.
|
||||
///
|
||||
/// This is the same as `spawn()` except the task may be aborted using the returned
|
||||
/// [`AbortHandle`].
|
||||
pub fn spawn_abortable(
|
||||
&self,
|
||||
future: impl Future<Output = ()> + 'static,
|
||||
) -> (ForegroundTask, AbortHandle) {
|
||||
let (handle, registration) = AbortHandle::new_pair();
|
||||
let task = self.spawn(Abortable::new(future, registration).map(|_| ()));
|
||||
(task, handle)
|
||||
}
|
||||
|
||||
pub async fn run<T: 'static>(&'_ self, future: impl Future<Output = T> + 'static) -> T {
|
||||
future.await
|
||||
}
|
||||
}
|
||||
|
||||
/// An executor that can be used to run background tasks.
|
||||
///
|
||||
/// In practice, for wasm, these tasks will be executed on the singular main
|
||||
/// thread that all JavaScript code is run on.
|
||||
pub struct Background;
|
||||
|
||||
impl Default for Background {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Background {
|
||||
pub fn new() -> Self {
|
||||
Background
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on a background thread.
|
||||
///
|
||||
/// If you have a boxed future, use `spawn_boxed` instead.
|
||||
pub fn spawn(&self, future: impl Future<Output = ()> + 'static) -> BackgroundTask {
|
||||
self.spawn_boxed(future.boxed_local())
|
||||
}
|
||||
|
||||
/// Schedule an asynchronous task to run on a background thread.
|
||||
///
|
||||
/// This takes in a boxed future in order to avoid monomorphizing the
|
||||
/// underlying task implementation. `spawn_boxed` generates significantly
|
||||
/// less code than a generic implementation, with no noticeable performance
|
||||
/// impact.
|
||||
pub fn spawn_boxed(&self, future: LocalBoxFuture<'static, ()>) -> BackgroundTask {
|
||||
spawn_local(future);
|
||||
BackgroundTask
|
||||
}
|
||||
|
||||
/// Schedules an abortable asynchronous task to run on a background thread.
|
||||
///
|
||||
/// This is the same as `spawn()` except the task may be aborted using the returned
|
||||
/// [`AbortHandle`].
|
||||
pub fn spawn_abortable(
|
||||
&self,
|
||||
future: impl Future<Output = ()> + 'static,
|
||||
) -> (BackgroundTask, AbortHandle) {
|
||||
let (handle, registration) = AbortHandle::new_pair();
|
||||
let task = self.spawn(Abortable::new(future, registration).map(|_| ()));
|
||||
(task, handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
pub mod executor;
|
||||
|
||||
use std::{
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use futures::Future;
|
||||
pub use futures_lite::future::block_on;
|
||||
use futures_lite::FutureExt;
|
||||
use gloo::timers::future::TimeoutFuture;
|
||||
use instant::Instant;
|
||||
|
||||
// There is no such thing as a background thread in wasm, so all futures are local.
|
||||
pub use futures_util::future::LocalBoxFuture as BoxFuture;
|
||||
|
||||
// Define a trait that is implemented by all types to allow us to not
|
||||
// place any restrictions on SpawnableOutput.
|
||||
trait Unrestricted {}
|
||||
impl<T> Unrestricted for T {}
|
||||
|
||||
// In wasm, there's no such thing as a background thread, so all
|
||||
// futures are local. The implementation of wasm_bindgen_futures::JsFuture
|
||||
// doesn't implement Send, so we want to relax that constraint when
|
||||
// running in wasm.
|
||||
trait_set::trait_set! {
|
||||
/// A trait representing a task which can be run in the background.
|
||||
pub trait Spawnable = 'static + Future;
|
||||
/// A trait representing a stream which can be polled in the background.
|
||||
pub trait Stream = 'static + futures::Stream;
|
||||
/// A trait representing a value which can be returned from a background
|
||||
/// task.
|
||||
///
|
||||
/// We need to supply _some_ trait bound here, so we use Unrestricted, which
|
||||
/// doesn't apply any additional constraints on the output type.
|
||||
pub trait SpawnableOutput = Unrestricted;
|
||||
/// Bounds for async I/O streams passed to cross-platform networking code.
|
||||
/// On WASM, `Send` is not required since everything runs on the main thread.
|
||||
pub trait TransportStream = Unpin + 'static;
|
||||
}
|
||||
|
||||
/// A future that emits timed events.
|
||||
///
|
||||
/// This must conform to the same API as [`async_io::Timer`].
|
||||
pub struct Timer {
|
||||
/// The actual future that will resolve at some future time,
|
||||
/// producing the [`Instant`] at which it is configured to
|
||||
/// be ready.
|
||||
inner: Pin<Box<dyn Future<Output = Instant>>>,
|
||||
/// Whether or not a [`Stream`] representation of this timer
|
||||
/// is exhausted (and so should produce [`None`]).
|
||||
stream_exhausted: bool,
|
||||
}
|
||||
|
||||
impl Timer {
|
||||
pub fn after(duration: std::time::Duration) -> Self {
|
||||
Self::new(duration, Instant::now() + duration)
|
||||
}
|
||||
|
||||
pub fn at(instant: Instant) -> Self {
|
||||
let duration = instant - instant::Instant::now();
|
||||
Self::new(duration, instant)
|
||||
}
|
||||
|
||||
pub fn never() -> Self {
|
||||
Self {
|
||||
inner: futures_lite::future::pending().boxed(),
|
||||
stream_exhausted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn new(duration: std::time::Duration, instant: Instant) -> Self {
|
||||
let future = async move {
|
||||
// We're never scheduling a timeout for more than 50 days, so this cast to u32 is fine.
|
||||
TimeoutFuture::new(duration.as_millis() as u32).await;
|
||||
instant
|
||||
};
|
||||
Self {
|
||||
inner: Box::pin(future),
|
||||
stream_exhausted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for Timer {
|
||||
type Output = Instant;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
self.inner.poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl futures::Stream for Timer {
|
||||
type Item = Instant;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
if self.stream_exhausted {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
self.inner.poll(cx).map(|val| {
|
||||
self.stream_exhausted = true;
|
||||
Some(val)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use parking_lot::Mutex;
|
||||
|
||||
/// A Clipboard that can read and write strings. Each platform must implement this trait to support
|
||||
/// writing to a clipboard.
|
||||
pub trait Clipboard: 'static {
|
||||
fn write(&mut self, contents: ClipboardContent);
|
||||
|
||||
fn read(&mut self) -> ClipboardContent;
|
||||
|
||||
/// Writes to the primary clipboard, used to support the Primary Selection Protocol (middle-click paste).
|
||||
///
|
||||
/// NOTE: For platforms that don't support the primary clipboard, it writes to the default clipboard instead.
|
||||
fn write_to_primary_clipboard(&mut self, contents: ClipboardContent) {
|
||||
self.write(contents)
|
||||
}
|
||||
|
||||
/// Reads the primary clipboard, used to support the Primary Selection Protocol (middle-click paste).
|
||||
/// This reads from the default clipboard on platforms other than Linux.
|
||||
fn read_from_primary_clipboard(&mut self) -> ClipboardContent {
|
||||
self.read()
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
fn save(&mut self, content: ClipboardContent);
|
||||
}
|
||||
|
||||
// Clipboard could contain content with multiple data types at the same type.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClipboardContent {
|
||||
// Clipboard contains plain string.
|
||||
pub plain_text: String,
|
||||
// Clipboard contains a list of file paths.
|
||||
// Parsed direct from OS clipboard on Mac/Windows, from plain_text on Linux.
|
||||
// On Mac/Linux, plain_text may also be populated.
|
||||
pub paths: Option<Vec<String>>,
|
||||
// Clipboard contains HTML content.
|
||||
pub html: Option<String>,
|
||||
// Clipboard contains image data (can be multiple images).
|
||||
pub images: Option<Vec<ImageData>>,
|
||||
}
|
||||
|
||||
/// Represents image data from the clipboard.
|
||||
///
|
||||
/// Contains the raw image bytes and associated MIME type information.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageData {
|
||||
/// Raw image data as bytes.
|
||||
pub data: Vec<u8>,
|
||||
/// MIME type of the image (e.g., "image/png", "image/jpeg").
|
||||
pub mime_type: String,
|
||||
/// Original filename if available (e.g., "photo.jpg").
|
||||
pub filename: Option<String>,
|
||||
}
|
||||
|
||||
impl ClipboardContent {
|
||||
pub fn plain_text(text: String) -> Self {
|
||||
Self {
|
||||
plain_text: text,
|
||||
paths: Default::default(),
|
||||
html: Default::default(),
|
||||
images: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let Self {
|
||||
plain_text,
|
||||
paths,
|
||||
html,
|
||||
images,
|
||||
} = self;
|
||||
plain_text.is_empty() && paths.is_none() && html.is_none() && images.is_none()
|
||||
}
|
||||
|
||||
pub fn has_image_data(&self) -> bool {
|
||||
self.images
|
||||
.as_ref()
|
||||
.map(|images| !images.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn num_paths(&self) -> usize {
|
||||
self.paths.as_ref().map(|paths| paths.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Check if clipboard contains file paths that are not images
|
||||
pub fn has_non_image_filepaths(&self) -> bool {
|
||||
self.paths
|
||||
.as_ref()
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.any(|path| !crate::clipboard_utils::has_image_extension(path))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_insert_text_on_paste(content: &ClipboardContent) -> bool {
|
||||
// Insert any text content present when:
|
||||
// 1. No images at all (neither data nor paths)
|
||||
// 2. Has non-image files (mixed content)
|
||||
// 3. Has image data but no file paths (direct image paste)
|
||||
if !content.has_image_data() && content.num_paths() == 0 {
|
||||
return true; // No images at all
|
||||
}
|
||||
if content.has_non_image_filepaths() {
|
||||
return true; // Mixed content - user likely wants text paths
|
||||
}
|
||||
// Direct image paste - user would still want any text content present (unless paths)
|
||||
content.has_image_data() && content.num_paths() == 0
|
||||
}
|
||||
|
||||
/// Stores clipboard content in the heap of this process. Therefore, it is scoped to this process.
|
||||
/// This is not a proper implementation for a "real" platform. It's useful in tests or as a
|
||||
/// temporary substitute.
|
||||
pub struct InMemoryClipboard {
|
||||
clipboard_content: Mutex<ClipboardContent>,
|
||||
}
|
||||
|
||||
impl Default for InMemoryClipboard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clipboard_content: Mutex::new(ClipboardContent::plain_text(String::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard for InMemoryClipboard {
|
||||
fn write(&mut self, contents: ClipboardContent) {
|
||||
*self.clipboard_content.lock() = contents;
|
||||
}
|
||||
|
||||
fn read(&mut self) -> ClipboardContent {
|
||||
self.clipboard_content.lock().clone()
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
fn save(&mut self, _content: ClipboardContent) {}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
#[allow(unused_imports)]
|
||||
use crate::clipboard::{Clipboard, ClipboardContent};
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
use {arboard, image::ImageEncoder};
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
/// Supported image file extensions for clipboard operations.
|
||||
pub const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp"];
|
||||
|
||||
/// Preferred image MIME types for clipboard operations (in order of preference)
|
||||
pub const CLIPBOARD_IMAGE_MIME_TYPES: &[&str] = &[
|
||||
"image/png", // Preferred: lossless, good compression
|
||||
"image/jpeg", // Good fallback: widely supported
|
||||
"image/jpg", // JPEG variant
|
||||
"image/gif", // Animated images
|
||||
"image/webp", // Modern format but less compatible
|
||||
];
|
||||
|
||||
/// Minimum bytes needed for image format detection.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
const MIN_IMAGE_HEADER_SIZE: usize = 8;
|
||||
|
||||
/// Check if a string has an image file extension.
|
||||
pub fn has_image_extension(s: &str) -> bool {
|
||||
IMAGE_EXTENSIONS
|
||||
.iter()
|
||||
.any(|ext| s.to_lowercase().ends_with(ext))
|
||||
}
|
||||
|
||||
/// Extract filename from a file path, handling file:// URLs and path separators.
|
||||
fn extract_filename_from_path(path: &str) -> String {
|
||||
path.strip_prefix("file://")
|
||||
.unwrap_or(path)
|
||||
.split(['/', '\\'])
|
||||
.next_back()
|
||||
.unwrap_or(path)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Extract filename from clipboard content (HTML or text).
|
||||
/// Tries HTML first, then falls back to text content.
|
||||
pub fn extract_filename_from_clipboard_content(
|
||||
html_content: &Option<String>,
|
||||
text_content: &str,
|
||||
) -> Option<String> {
|
||||
html_content
|
||||
.as_ref()
|
||||
.and_then(|html| extract_filename_from_html(html))
|
||||
.or_else(|| extract_filename_from_text(text_content))
|
||||
}
|
||||
|
||||
/// Extract filename from text content (file paths, URLs, etc.).
|
||||
pub fn extract_filename_from_text(text: &str) -> Option<String> {
|
||||
// Early return for empty input
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// First, check if the entire text is a file path with an image extension
|
||||
let trimmed = text.trim();
|
||||
if trimmed.contains('.') && has_image_extension(trimmed) {
|
||||
return Some(extract_filename_from_path(trimmed));
|
||||
}
|
||||
|
||||
// Look for file paths in the text
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.contains('.') && has_image_extension(line) {
|
||||
return Some(extract_filename_from_path(line));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract filename from HTML content.
|
||||
pub fn extract_filename_from_html(html: &str) -> Option<String> {
|
||||
// Early return for empty HTML
|
||||
if html.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// First try to extract from HTML structure, then fall back to text extraction
|
||||
if let Some(filename) = extract_filename_from_html_tags(html) {
|
||||
return Some(filename);
|
||||
}
|
||||
|
||||
// Fall back to treating HTML as plain text for file paths
|
||||
extract_filename_from_text(html)
|
||||
}
|
||||
|
||||
/// Extract filename from HTML tags and attributes.
|
||||
fn extract_filename_from_html_tags(html: &str) -> Option<String> {
|
||||
// Helper function to extract quoted attribute value
|
||||
let extract_quoted_value = |html: &str, attr_pattern: &str| -> Option<String> {
|
||||
html.find(attr_pattern)
|
||||
.and_then(|start| {
|
||||
let content_start = start + attr_pattern.len();
|
||||
html[content_start..].split('"').next()
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
// 1. Check src attribute in img tag (most common case)
|
||||
if let Some(src_content) = extract_quoted_value(html, "src=\"") {
|
||||
let filename = extract_filename_from_path(&src_content);
|
||||
if filename.contains('.') && has_image_extension(&filename) {
|
||||
return Some(filename);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check title attribute
|
||||
if let Some(title_content) = extract_quoted_value(html, "title=\"") {
|
||||
if title_content.contains('.') && has_image_extension(&title_content) {
|
||||
return Some(title_content);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check alt attribute
|
||||
if let Some(alt_content) = extract_quoted_value(html, "alt=\"") {
|
||||
if alt_content.contains('.') && has_image_extension(&alt_content) {
|
||||
return Some(alt_content);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Look for any filename-like strings with image extensions in the entire HTML
|
||||
const TRIM_CHARS: &[char] = &['"', '\'', '<', '>', '(', ')', ',', ';'];
|
||||
|
||||
for word in html.split_whitespace() {
|
||||
if word.contains('.') {
|
||||
let clean_word = word.trim_matches(TRIM_CHARS);
|
||||
if has_image_extension(clean_word) {
|
||||
let filename = extract_filename_from_path(clean_word);
|
||||
return Some(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Best-effort conversion of HTML clipboard contents to plain text.
|
||||
///
|
||||
/// This is intentionally lightweight (no external HTML parser dependency). It strips tags,
|
||||
/// decodes a small set of common entities, and collapses whitespace.
|
||||
pub fn strip_html_to_plain_text(html: &str) -> String {
|
||||
if html.trim().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Fast path: if there are no obvious tag/entity markers, treat as plain text.
|
||||
if !html.contains('<') && !html.contains('&') {
|
||||
return html.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
}
|
||||
|
||||
fn decode_entity(entity: &str) -> Option<char> {
|
||||
match entity {
|
||||
"nbsp" => Some(' '),
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"#39" => Some('\''),
|
||||
_ if entity.starts_with("#x") || entity.starts_with("#X") => {
|
||||
u32::from_str_radix(&entity[2..], 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
}
|
||||
_ if entity.starts_with('#') => {
|
||||
entity[1..].parse::<u32>().ok().and_then(char::from_u32)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
let mut in_entity = false;
|
||||
let mut entity_buf = String::new();
|
||||
let mut last_was_space = false;
|
||||
|
||||
for ch in html.chars() {
|
||||
if in_tag {
|
||||
if ch == '>' {
|
||||
in_tag = false;
|
||||
// Treat tags as word boundaries.
|
||||
if !last_was_space {
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_entity {
|
||||
if ch == ';' {
|
||||
let decoded = decode_entity(entity_buf.as_str());
|
||||
if let Some(decoded) = decoded {
|
||||
if decoded.is_whitespace() {
|
||||
if !last_was_space {
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
} else {
|
||||
out.push(decoded);
|
||||
last_was_space = false;
|
||||
}
|
||||
} else {
|
||||
// Unknown entity; keep it as-is (best effort).
|
||||
if !last_was_space {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push('&');
|
||||
out.push_str(entity_buf.as_str());
|
||||
out.push(';');
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
entity_buf.clear();
|
||||
in_entity = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Guard against extremely long/unterminated entities.
|
||||
if entity_buf.len() >= 24 {
|
||||
in_entity = false;
|
||||
entity_buf.clear();
|
||||
if !last_was_space {
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
entity_buf.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
// Ensure words on either side of tags don't get glued together.
|
||||
if !last_was_space && !out.is_empty() {
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
}
|
||||
'&' => {
|
||||
in_entity = true;
|
||||
entity_buf.clear();
|
||||
}
|
||||
ch if ch.is_whitespace() => {
|
||||
if !last_was_space {
|
||||
out.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
out.push(ch);
|
||||
last_was_space = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Process clipboard image data, preserving original format or converting to PNG.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn process_clipboard_image(
|
||||
arboard_image: &arboard::ImageData,
|
||||
filename: Option<String>,
|
||||
) -> Option<crate::clipboard::ImageData> {
|
||||
let result =
|
||||
try_preserve_original_format(&arboard_image.bytes, filename.clone()).or_else(|| {
|
||||
convert_raw_bitmap_to_png(
|
||||
arboard_image.width,
|
||||
arboard_image.height,
|
||||
arboard_image.bytes.to_vec(),
|
||||
filename,
|
||||
)
|
||||
});
|
||||
|
||||
if result.is_none() {
|
||||
log::warn!(
|
||||
"Failed to process clipboard image: format preservation and PNG conversion both failed"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Read image data from clipboard, checking for images before expensive filename extraction.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn read_images_from_clipboard(
|
||||
clipboard: &mut arboard::Clipboard,
|
||||
html_content: &Option<String>,
|
||||
text_content: &str,
|
||||
) -> Option<Vec<crate::clipboard::ImageData>> {
|
||||
// First, quickly check if there are any images in the clipboard
|
||||
// This is a fast operation that avoids filename extraction overhead
|
||||
match clipboard.get().image() {
|
||||
Ok(arboard_image) => {
|
||||
// Images found! Now extract filename from clipboard content
|
||||
let filename = extract_filename_from_clipboard_content(html_content, text_content);
|
||||
|
||||
// Process the image with the extracted filename
|
||||
match process_clipboard_image(&arboard_image, filename) {
|
||||
Some(image_data) => Some(vec![image_data]),
|
||||
None => {
|
||||
log::warn!("Failed to process clipboard image: format detection and conversion both failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(arboard::Error::ContentNotAvailable) => None,
|
||||
Err(err) => {
|
||||
log::warn!("Unable to read image from clipboard: {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to preserve original image format using infer crate for detection.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn try_preserve_original_format(
|
||||
bytes: &[u8],
|
||||
filename: Option<String>,
|
||||
) -> Option<crate::clipboard::ImageData> {
|
||||
if bytes.len() < MIN_IMAGE_HEADER_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Use infer crate to detect the image format
|
||||
if let Some(kind) = infer::get(bytes) {
|
||||
// Check if it's a supported image format
|
||||
match kind.mime_type() {
|
||||
"image/png" | "image/jpeg" | "image/gif" | "image/webp" => {
|
||||
return Some(crate::clipboard::ImageData {
|
||||
data: bytes.to_vec(),
|
||||
mime_type: kind.mime_type().to_string(),
|
||||
filename,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Converts RGBA bitmap data to PNG format, returns None on invalid dimensions/encoding.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub fn convert_raw_bitmap_to_png(
|
||||
width: usize,
|
||||
height: usize,
|
||||
bytes: Vec<u8>,
|
||||
filename: Option<String>,
|
||||
) -> Option<crate::clipboard::ImageData> {
|
||||
// Validate dimensions before processing
|
||||
let width_u32 = match width.try_into() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("Invalid width for PNG conversion: {width} - {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let height_u32 = match height.try_into() {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
log::warn!("Invalid height for PNG conversion: {height} - {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Create RGBA image buffer from raw data
|
||||
// Note: arboard should already provide data in RGBA format
|
||||
let img_buffer =
|
||||
image::ImageBuffer::<image::Rgba<u8>, Vec<u8>>::from_raw(width_u32, height_u32, bytes)?;
|
||||
|
||||
// Encode as PNG with optimized settings for speed
|
||||
let mut png_data = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut png_data);
|
||||
|
||||
// Use fast compression settings to reduce encoding time
|
||||
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
||||
&mut cursor,
|
||||
image::codecs::png::CompressionType::Fast,
|
||||
image::codecs::png::FilterType::NoFilter,
|
||||
);
|
||||
|
||||
let encode_result = encoder.write_image(
|
||||
&img_buffer,
|
||||
width_u32,
|
||||
height_u32,
|
||||
image::ColorType::Rgba8.into(),
|
||||
);
|
||||
|
||||
match encode_result {
|
||||
Ok(_) => Some(crate::clipboard::ImageData {
|
||||
data: png_data,
|
||||
mime_type: "image/png".to_string(),
|
||||
filename,
|
||||
}),
|
||||
Err(err) => {
|
||||
log::warn!("PNG encoding failed: {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_image_filepaths_from_paths(paths: &[String]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
.filter(|path| has_image_extension(path))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create escaped file paths text string for insertion into terminal.
|
||||
pub fn escaped_paths_str(
|
||||
paths: &[String],
|
||||
shell_family: Option<warp_util::path::ShellFamily>,
|
||||
) -> String {
|
||||
// Handle regular file paths as text
|
||||
#[allow(unused_mut)]
|
||||
let mut input = paths
|
||||
.iter()
|
||||
.map(|path| match shell_family {
|
||||
Some(shell_family) => shell_family.escape(path.as_ref()),
|
||||
None => std::borrow::Cow::Borrowed(path.as_ref()),
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Append a space in case of back-to-back drag-drops.
|
||||
input.push(' ');
|
||||
|
||||
input
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clipboard_utils_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,311 @@
|
||||
use super::*;
|
||||
use crate::clipboard::{ClipboardContent, ImageData};
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS (shared across tests)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn create_rgba_data(w: usize, h: usize) -> Vec<u8> {
|
||||
// Simple test pattern: red gradient
|
||||
(0..h)
|
||||
.flat_map(|y| {
|
||||
(0..w).flat_map(move |x| [((x * 255) / w) as u8, ((y * 255) / h) as u8, 128, 255])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn create_simple_png() -> Vec<u8> {
|
||||
// PNG header for 1x1 red pixel
|
||||
vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] // PNG signature
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn create_simple_jpeg() -> Vec<u8> {
|
||||
// JPEG header
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn create_simple_gif() -> Vec<u8> {
|
||||
// GIF header
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(b"GIF87a");
|
||||
data.extend_from_slice(&[1, 0, 1, 0, 0, 0, 0]); // minimal 1x1 GIF
|
||||
data
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn create_simple_webp() -> Vec<u8> {
|
||||
// WebP header
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(b"RIFF");
|
||||
data.extend_from_slice(&[12, 0, 0, 0]); // file size
|
||||
data.extend_from_slice(b"WEBP");
|
||||
data.extend_from_slice(b"VP8 ");
|
||||
data
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn assert_valid_png(result: Option<ImageData>) {
|
||||
let image_data = result.expect("Should process image successfully");
|
||||
assert_eq!(image_data.mime_type, "image/png");
|
||||
assert_eq!(&image_data.data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]); // PNG header
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FILENAME EXTRACTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_extract_filename_from_html() {
|
||||
// Test extraction from src attribute with file:// URL (common on macOS)
|
||||
let html1 = r##"<img src="file:///Users/test/Pictures/screenshot.png" alt="Screenshot">"##;
|
||||
let filename = extract_filename_from_html(html1);
|
||||
assert_eq!(filename, Some("screenshot.png".to_string()));
|
||||
|
||||
// Test extraction from src attribute with http URL
|
||||
let html2 = r##"<img src="https://example.com/images/photo.jpg" alt="Photo">"##;
|
||||
let filename = extract_filename_from_html(html2);
|
||||
assert_eq!(filename, Some("photo.jpg".to_string()));
|
||||
|
||||
// Test extraction from title attribute
|
||||
let html3 = r##"<img title="document.gif" src="data:image/gif;base64,R0lGOD...">"##;
|
||||
let filename = extract_filename_from_html(html3);
|
||||
assert_eq!(filename, Some("document.gif".to_string()));
|
||||
|
||||
// Test extraction from alt attribute
|
||||
let html4 = r##"<img alt="image.webp" src="data:image/webp;base64,UklGR...">"##;
|
||||
let filename = extract_filename_from_html(html4);
|
||||
assert_eq!(filename, Some("image.webp".to_string()));
|
||||
|
||||
// Test extraction from free text
|
||||
let html5 = r##"<div>Here is my image: myfile.jpeg that I copied</div>"##;
|
||||
let filename = extract_filename_from_html(html5);
|
||||
assert_eq!(filename, Some("myfile.jpeg".to_string()));
|
||||
|
||||
// Test no filename found
|
||||
let html6 = r##"<div>Just some text with no image references</div>"##;
|
||||
let filename = extract_filename_from_html(html6);
|
||||
assert_eq!(filename, None);
|
||||
|
||||
// Test non-image extension ignored
|
||||
let html7 = r##"<div>document.pdf and archive.zip should be ignored</div>"##;
|
||||
let filename = extract_filename_from_html(html7);
|
||||
assert_eq!(filename, None);
|
||||
|
||||
// Test complex path extraction with Windows-style paths
|
||||
let html8 =
|
||||
r##"<img src="file://C:\Users\John%20Doe\Desktop\My%20Images\vacation-photo.png">"##;
|
||||
let filename = extract_filename_from_html(html8);
|
||||
assert_eq!(filename, Some("vacation-photo.png".to_string()));
|
||||
|
||||
// Test case-insensitive extension matching
|
||||
let html9 = r##"<img src="test.PNG" alt="Test">"##;
|
||||
let filename = extract_filename_from_html(html9);
|
||||
assert_eq!(filename, Some("test.PNG".to_string()));
|
||||
|
||||
// Test extraction with various punctuation
|
||||
let html10 = r##"<div>Look at "my-image.jpg", (another.gif), or <file.webp>!</div>"##;
|
||||
let filename = extract_filename_from_html(html10);
|
||||
// Should find the first one
|
||||
assert_eq!(filename, Some("my-image.jpg".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_filename_from_text() {
|
||||
// Test full file path
|
||||
let file_path = "/Users/test/Documents/screenshot.png";
|
||||
let result = extract_filename_from_text(file_path);
|
||||
assert_eq!(result, Some("screenshot.png".to_string()));
|
||||
|
||||
// Test Windows path
|
||||
let windows_path = "C:\\Users\\test\\Documents\\image.jpg";
|
||||
let result = extract_filename_from_text(windows_path);
|
||||
assert_eq!(result, Some("image.jpg".to_string()));
|
||||
|
||||
// Test file:// URL
|
||||
let file_url = "file:///Users/test/screenshot.gif";
|
||||
let result = extract_filename_from_text(file_url);
|
||||
assert_eq!(result, Some("screenshot.gif".to_string()));
|
||||
|
||||
// Test multiline with file path
|
||||
let multiline = "Some text\n/path/to/image.webp\nMore text";
|
||||
let result = extract_filename_from_text(multiline);
|
||||
assert_eq!(result, Some("image.webp".to_string()));
|
||||
|
||||
// Test non-image file (should return None)
|
||||
let text_file = "/Users/test/document.txt";
|
||||
let result = extract_filename_from_text(text_file);
|
||||
assert_eq!(result, None);
|
||||
|
||||
// Test no file path
|
||||
let plain_text = "Just some plain text";
|
||||
let result = extract_filename_from_text(plain_text);
|
||||
assert_eq!(result, None);
|
||||
|
||||
// Test just filename
|
||||
let just_filename = "my-screenshot.png";
|
||||
let result = extract_filename_from_text(just_filename);
|
||||
assert_eq!(result, Some("my-screenshot.png".to_string()));
|
||||
|
||||
// Test empty string
|
||||
let empty = "";
|
||||
let result = extract_filename_from_text(empty);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_filename_from_clipboard_content() {
|
||||
// Test HTML takes precedence over text
|
||||
let html_content = Some(r##"<img src="test.png" alt="Test">"##.to_string());
|
||||
let text_content = "other-file.jpg";
|
||||
let result = extract_filename_from_clipboard_content(&html_content, text_content);
|
||||
assert_eq!(result, Some("test.png".to_string()));
|
||||
|
||||
// Test fallback to text when HTML has no filename
|
||||
let html_content = Some("<div>No images here</div>".to_string());
|
||||
let text_content = "/path/to/image.gif";
|
||||
let result = extract_filename_from_clipboard_content(&html_content, text_content);
|
||||
assert_eq!(result, Some("image.gif".to_string()));
|
||||
|
||||
// Test fallback to text when no HTML
|
||||
let html_content = None;
|
||||
let text_content = "screenshot.webp";
|
||||
let result = extract_filename_from_clipboard_content(&html_content, text_content);
|
||||
assert_eq!(result, Some("screenshot.webp".to_string()));
|
||||
|
||||
// Test no filename found
|
||||
let html_content = Some("<div>Just text</div>".to_string());
|
||||
let text_content = "No images here either";
|
||||
let result = extract_filename_from_clipboard_content(&html_content, text_content);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMAGE PROCESSING TESTS (Linux/Windows platforms only)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
mod image_processing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rgba_bitmap_processing() {
|
||||
let arboard_image = arboard::ImageData {
|
||||
width: 8,
|
||||
height: 6,
|
||||
bytes: create_rgba_data(8, 6).into(),
|
||||
};
|
||||
assert_valid_png(process_clipboard_image(&arboard_image, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_data_rejection() {
|
||||
let arboard_image = arboard::ImageData {
|
||||
width: 10,
|
||||
height: 10,
|
||||
bytes: vec![1, 2, 3, 4, 5].into(),
|
||||
};
|
||||
assert!(process_clipboard_image(&arboard_image, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_various_dimensions() {
|
||||
for (w, h) in [(100, 100), (782, 297), (1, 1)] {
|
||||
let arboard_image = arboard::ImageData {
|
||||
width: w,
|
||||
height: h,
|
||||
bytes: create_rgba_data(w, h).into(),
|
||||
};
|
||||
let result = process_clipboard_image(&arboard_image, None)
|
||||
.unwrap_or_else(|| panic!("Failed to process {w}x{h} image"));
|
||||
let loaded = image::load_from_memory(&result.data)
|
||||
.unwrap_or_else(|e| panic!("Failed to load processed {w}x{h} image: {e}"));
|
||||
assert_eq!((loaded.width(), loaded.height()), (w as u32, h as u32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_preservation_and_detection() {
|
||||
let test_cases = vec![
|
||||
(create_simple_png(), "image/png", "test.png"),
|
||||
(create_simple_jpeg(), "image/jpeg", "test.jpg"),
|
||||
(create_simple_gif(), "image/gif", "test.gif"),
|
||||
(create_simple_webp(), "image/webp", "test.webp"),
|
||||
];
|
||||
|
||||
for (data, expected_mime, filename) in test_cases {
|
||||
let result = try_preserve_original_format(&data, Some(filename.to_string()));
|
||||
if let Some(image_data) = result {
|
||||
assert_eq!(image_data.mime_type, expected_mime);
|
||||
assert_eq!(image_data.filename, Some(filename.to_string()));
|
||||
// Format preservation should keep original data
|
||||
assert_eq!(image_data.data, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_format_fallback() {
|
||||
// Create some random data that doesn't match any supported format
|
||||
let unsupported_data = vec![0x50, 0x4B, 0x03, 0x04]; // ZIP signature
|
||||
let arboard_image = arboard::ImageData {
|
||||
width: 4,
|
||||
height: 4,
|
||||
bytes: unsupported_data.into(),
|
||||
};
|
||||
|
||||
// Should return None since ZIP is not a supported image format
|
||||
let result = process_clipboard_image(&arboard_image, None);
|
||||
assert!(result.is_none(), "Should reject unsupported format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_raw_bitmap_to_png() {
|
||||
// Test valid conversion
|
||||
let width = 2;
|
||||
let height = 2;
|
||||
let rgba_data = vec![
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
|
||||
];
|
||||
|
||||
let result =
|
||||
convert_raw_bitmap_to_png(width, height, rgba_data, Some("test.png".to_string()));
|
||||
if let Some(image_data) = result {
|
||||
assert_eq!(image_data.mime_type, "image/png");
|
||||
assert_eq!(image_data.filename, Some("test.png".to_string()));
|
||||
assert!(!image_data.data.is_empty());
|
||||
}
|
||||
|
||||
// Test invalid dimensions
|
||||
let result = convert_raw_bitmap_to_png(usize::MAX, 1, vec![255, 0, 0, 255], None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLIPBOARD CONTENT STRUCTURE TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_content_with_images() {
|
||||
let image_data = ImageData {
|
||||
data: vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
|
||||
mime_type: "image/png".to_string(),
|
||||
filename: Some("test.png".to_string()),
|
||||
};
|
||||
|
||||
let content = ClipboardContent {
|
||||
plain_text: "Test text".to_string(),
|
||||
html: Some(r##"<img src="test.png">"##.to_string()),
|
||||
images: Some(vec![image_data.clone()]),
|
||||
paths: None,
|
||||
};
|
||||
|
||||
assert!(!content.is_empty());
|
||||
assert!(content.images.is_some());
|
||||
assert_eq!(content.images.as_ref().unwrap().len(), 1);
|
||||
assert_eq!(content.images.as_ref().unwrap()[0].mime_type, "image/png");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
fmt::Debug,
|
||||
};
|
||||
|
||||
/// Trait representing a Typed action.
|
||||
///
|
||||
/// We require that an action implement a number of parent traits:
|
||||
///
|
||||
/// - `Any` to support downcasting to the absolute type
|
||||
/// - `Debug` so that we can show log messages about the action as it is dispatched
|
||||
pub trait Action: Any + Debug + Send + Sync {
|
||||
/// Convert this `Action` into a `dyn Any` reference, necessary for passing to the handler
|
||||
/// function, as trait upcasting isn't yet stable, so we can't treat a value of `&dyn Action`
|
||||
/// as a value of `&dyn Any` directly.
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
fn type_name(&self) -> &'static str {
|
||||
std::any::type_name::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
/// Blanket impl for `Action`, allowing any type that implements the parent traits to
|
||||
/// automatically be treated as an `Action`, without the app needing to do anything special
|
||||
impl<T> Action for T
|
||||
where
|
||||
T: Any + Debug + Send + Sync,
|
||||
{
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Hash)]
|
||||
pub(super) struct ActionType(TypeId);
|
||||
|
||||
impl ActionType {
|
||||
pub fn of<T: ?Sized + 'static>() -> Self {
|
||||
ActionType(TypeId::of::<T>())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&dyn Action> for ActionType {
|
||||
fn from(action: &dyn Action) -> Self {
|
||||
ActionType(action.type_id())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
elements::Empty, platform::WindowStyle, App, AppContext, Element, Entity, ModelHandle,
|
||||
TypedActionView, View,
|
||||
};
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Model {
|
||||
first: Tracked<usize>,
|
||||
second: Tracked<bool>,
|
||||
third: Tracked<isize>,
|
||||
}
|
||||
|
||||
impl Entity for Model {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
struct TestView {
|
||||
model: ModelHandle<Model>,
|
||||
field: Tracked<usize>,
|
||||
other_field: Tracked<bool>,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TestView {
|
||||
fn new(model: ModelHandle<Model>, counter: Arc<AtomicUsize>) -> Self {
|
||||
TestView {
|
||||
model,
|
||||
field: Tracked::new(0),
|
||||
other_field: Tracked::new(false),
|
||||
counter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
// While rendering, we explicitly read / depend on the fields `first` and `second` of
|
||||
// model, as well as `field` of the View itself.
|
||||
|
||||
// We explicitly do _not_ depend on `Model::third` nor `other_field` on the View.
|
||||
let model = self.model.as_ref(app);
|
||||
let _first = *model.first;
|
||||
let _second = *model.second;
|
||||
let _field = *self.field;
|
||||
|
||||
// Increment the render counter so that we can track how often a View is rendered
|
||||
self.counter.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_view_dependency_rerenders() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Model::default());
|
||||
|
||||
let render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestView::new(model_handle.clone(), render_counter.clone())
|
||||
});
|
||||
|
||||
// Force the window to be rendered the first time
|
||||
view_handle.update(&mut app, |_, _| {});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update an internal View dependency and confirm that it causes a rerender of the View
|
||||
view_handle.update(&mut app, |view, _| {
|
||||
*view.field += 1;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_view_non_dependency_no_rerender() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Model::default());
|
||||
|
||||
let render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestView::new(model_handle.clone(), render_counter.clone())
|
||||
});
|
||||
|
||||
// Force the window to be rendered the first time
|
||||
view_handle.update(&mut app, |_, _| {});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update an internal View field that is not a dependency and confirm that it does not
|
||||
// cause a rerender of the View
|
||||
view_handle.update(&mut app, |view, _| {
|
||||
*view.other_field = true;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_model_dependency_rerenders() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Model::default());
|
||||
|
||||
let render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestView::new(model_handle.clone(), render_counter.clone())
|
||||
});
|
||||
|
||||
// Force the window to be rendered the first time
|
||||
view_handle.update(&mut app, |_, _| {});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update a Model dependency and confirm that it causes a rerender of the View
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.first += 1;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 2);
|
||||
|
||||
// Update another Model dependency and confirm that it causes a rerender
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.second = true;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_model_non_dependency_no_rerender() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Model::default());
|
||||
|
||||
let render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestView::new(model_handle.clone(), render_counter.clone())
|
||||
});
|
||||
|
||||
// Force the window to be rendered the first time
|
||||
view_handle.update(&mut app, |_, _| {});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update a Model field that is not a dependency and confirm that it does not
|
||||
// cause a rerender of the View
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.third -= 1000;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_updates_multiple_sources() {
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Model::default());
|
||||
|
||||
let render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let (_, view_handle) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestView::new(model_handle.clone(), render_counter.clone())
|
||||
});
|
||||
|
||||
// Force the window to be rendered the first time
|
||||
view_handle.update(&mut app, |_, _| {});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update several Model dependencies and confirm it causes a single rerender
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.first += 1;
|
||||
*model.second = true;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 2);
|
||||
|
||||
// Update a View dependency and confirm it causes another rerender
|
||||
view_handle.update(&mut app, |view, _| {
|
||||
*view.field += 100;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 3);
|
||||
|
||||
// Update a field that is not a dependency and confirm that it doesn't cause a rerender
|
||||
view_handle.update(&mut app, |view, _| {
|
||||
*view.other_field = true;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 3);
|
||||
|
||||
// Update a non-dependency on the Model and confirm that there is no rerender
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.third -= 100;
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 3);
|
||||
|
||||
// Update the view _and_ the model in the same call and confirm that it causes a single
|
||||
// rerender
|
||||
view_handle.update(&mut app, |view, ctx| {
|
||||
*view.field += 20;
|
||||
view.model.update(ctx, |model, _| {
|
||||
*model.first += 23;
|
||||
});
|
||||
});
|
||||
assert_eq!(render_counter.load(Ordering::Relaxed), 4);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_updates_multiple_views() {
|
||||
struct OtherView {
|
||||
model: ModelHandle<Model>,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl OtherView {
|
||||
fn new(model: ModelHandle<Model>, counter: Arc<AtomicUsize>) -> Self {
|
||||
OtherView { model, counter }
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OtherView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for OtherView {
|
||||
fn ui_name() -> &'static str {
|
||||
"OtherView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let model = self.model.as_ref(app);
|
||||
// This view depends on `second` and `third` in the model, so updates to those should
|
||||
// cause it to rerender (overlapping `second` with the main test view)
|
||||
let _second = *model.second;
|
||||
let _third = *model.third;
|
||||
|
||||
self.counter.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let model_handle = app.add_model(|_| Model::default());
|
||||
|
||||
let first_render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let (window_id, first_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestView::new(model_handle.clone(), first_render_counter.clone())
|
||||
});
|
||||
|
||||
let second_render_counter = Arc::new(AtomicUsize::new(0));
|
||||
let _second_view = app.add_view(window_id, |_| {
|
||||
OtherView::new(model_handle.clone(), second_render_counter.clone())
|
||||
});
|
||||
|
||||
// Force the window to be rendered the first time
|
||||
first_view.update(&mut app, |_, _| {});
|
||||
assert_eq!(first_render_counter.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(second_render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update a field on the model that _only_ the first view depends on and confirm that is
|
||||
// the only view rerendered
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.first += 100;
|
||||
});
|
||||
assert_eq!(first_render_counter.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(second_render_counter.load(Ordering::Relaxed), 1);
|
||||
|
||||
// Update a field on the model that _both_ views depend on and confirm both are rerendered
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.second = true;
|
||||
});
|
||||
assert_eq!(first_render_counter.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(second_render_counter.load(Ordering::Relaxed), 2);
|
||||
|
||||
// Update a field on the model that _only_ the second view depends on and confirm that is
|
||||
// the only view rerendered
|
||||
model_handle.update(&mut app, |model, _| {
|
||||
*model.third -= 55;
|
||||
});
|
||||
assert_eq!(first_render_counter.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(second_render_counter.load(Ordering::Relaxed), 3);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Automatic change tracking (autotracking) system to reduce the need to call `ctx.notify()`
|
||||
//!
|
||||
//! This module provides a wrapper type `Tracked`, which automatically tracks changes to the
|
||||
//! underlying data and invalidates any Views that depend on that data (the equivalent of calling
|
||||
//! `ctx.notify()` directly).
|
||||
//!
|
||||
//! ## Use
|
||||
//!
|
||||
//! The `Tracked` type is intended to be straightforward to use: Any data that is wrapped in a
|
||||
//! `Tracked` will be hooked into the Autotracking system and will not need any calls to `notify`
|
||||
//! from any views that depend on the data for rendering. Creating a `Tracked` can be done in two
|
||||
//! ways:
|
||||
//!
|
||||
//! 1. Directly using the `Tracked::new` constructor, e.g. `Tracked::new(true)`
|
||||
//! 2. Via `Into`, e.g. `true.into()`
|
||||
//!
|
||||
//! `Tracked` implements `Deref` and `DerefMut` for the underlying type, so in most cases you
|
||||
//! should be able to use it directly wherever the underlying data would be expected. It may
|
||||
//! require an explicit dereference (e.g. `*my_value`) in some cases, but other than that the
|
||||
//! intent is for it to be mostly transparent.
|
||||
//!
|
||||
//! See the `autotracking` example in the `examples/` directory for more.
|
||||
//!
|
||||
//! ## Limitations
|
||||
//!
|
||||
//! `Tracked` is currently single-threaded, meaning that all of the values you want to have
|
||||
//! automatically tracked must be on the main thread of the app. This should apply to anything
|
||||
//! stored in a Model or View, as those are owned by the main thread already. To ensure that
|
||||
//! autotracked data is not incorrectly shared between threads, `Tracked` explicitly does not
|
||||
//! implement `Send` or `Sync`.
|
||||
//!
|
||||
//! Additionally, `Tracked` currently detects any mutable _access_ to the data as an update. It
|
||||
//! explicitly does not do any diff of the data to determine if there was an actual change made.
|
||||
//! This means it could generate false positive updates if you update it to the same values or
|
||||
//! otherwise take mutable access without modifying the data.
|
||||
//!
|
||||
//! Similar to the above, since `Tracked` relies on _mutable_ access, it will not detect changes
|
||||
//! in a type that uses interior mutability to make changes through a shared reference (`&self`
|
||||
//! instead of `&mut self`).
|
||||
//!
|
||||
//! ## Granularity
|
||||
//!
|
||||
//! Since `Tracked` can wrap any type, it is up to the user to determine how granular you want the
|
||||
//! tracking to be. It can wrap each individual value to give you very granular updates when only
|
||||
//! specific dependencies are changed, or it can wrap an entire model and give coarse updates
|
||||
//! whenever the model is modified.
|
||||
//!
|
||||
//! ## Details
|
||||
//!
|
||||
//! Internally, the Autotracking system works by tracking access in two ways:
|
||||
//!
|
||||
//! 1. While a View is rendering, any _reads_ of `Tracked` data are cached as dependencies for that
|
||||
//! view.
|
||||
//! 2. When any `Tracked` data is _updated_, all Views that depended on that data are marked for
|
||||
//! invalidation.
|
||||
//!
|
||||
//! ### `Tracked`
|
||||
//!
|
||||
//! To track reads and updates, each instance of a `Tracked` includes a unique identifier used by
|
||||
//! the autotracking system. The `Deref` and `DerefMut` implementations for `Tracked` send that
|
||||
//! identifier to the Autotracking system indicating a read or update, respectively. The fact that
|
||||
//! the tracking is tied to `Deref` and `DerefMut` is the reason behind the limitation listed above
|
||||
//! that it could generate false-positive results.
|
||||
//!
|
||||
//! ### Reads
|
||||
//!
|
||||
//! In order to track reads and cache dependencies, whenever the UI Framework begins rendering a
|
||||
//! View (i.e. calling `View::render` on it), it first notifies the Autotracking system that a
|
||||
//! render is starting. The autotracking system clears the cache for that View and holds onto the
|
||||
//! `WindowId` and `ViewId` for the duration of the render. During that time, any reads of
|
||||
//! `Tracked` data result in the autotracking cache being updated to list that tracked data as a
|
||||
//! dependency of the rendering view.
|
||||
//!
|
||||
//! When the call to `View::render` is complete, the UI Framework notifies the Autotracking system
|
||||
//! that it's over and it stops associating reads with a View dependency.
|
||||
//!
|
||||
//! ### Updates
|
||||
//!
|
||||
//! Whenever a `Tracked` data is updated, the Autotracking system adds all views that depend on
|
||||
//! that data to a set of invalidations. Then, every time the UI Framework collects the manual
|
||||
//! invalidations (e.g. those created by calls to `ctx.notify()`), it also drains the stored
|
||||
//! invalidations from the autotracking system. From that point forward, they are treated exactly
|
||||
//! the same as if you had called `ctx.notify()` for the relevant views.
|
||||
//!
|
||||
//! ### Removing Views
|
||||
//!
|
||||
//! When the UI Framework removes a view or window, it notifies the Autotracking system of that
|
||||
//! removal and any Views that no longer exist are removed from the dependency cache. This ensures
|
||||
//! that we aren't wasting resources trying to invalidate views that no longer exist.
|
||||
//!
|
||||
//! ### Cache
|
||||
//!
|
||||
//! All of the Autotracking cached data is stored in a thread-local static on the main thread. This
|
||||
//! removes the need for synchronization (e.g. `Mutex` or `RwLock`), as the data will only ever be
|
||||
//! accessed by a single thread. This also allows the `Tracked` instances to notify about any reads
|
||||
//! or updates without having to maintain a reference to the `AppContext` or similar app
|
||||
//! state. However, this is also the source of the limitation that all data using `Tracked` must
|
||||
//! be on the main thread and the lack of support for multithreaded change tracking.
|
||||
|
||||
mod tracked;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "autotracking_test.rs"]
|
||||
mod tests;
|
||||
|
||||
use itertools::Itertools as _;
|
||||
pub use tracked::Tracked;
|
||||
|
||||
use super::{EntityId, WindowId};
|
||||
use std::cell::UnsafeCell;
|
||||
use std::collections::{hash_map::Entry, HashMap, HashSet};
|
||||
use std::mem;
|
||||
use tracked::TrackedId;
|
||||
|
||||
/// Internal state cache used for autotracking changes
|
||||
///
|
||||
/// Rendering dependencies are stored in two maps, one from `TrackedId` -> Set of `View`s that
|
||||
/// depend on that value; and the other from `View` -> Set of `TrackedId`s that View depends on.
|
||||
/// This double-map allows us to insert and retrieve dependencies in O(1) time while also limiting
|
||||
/// the time it takes to remove a view from the cache.
|
||||
///
|
||||
/// When we start rendering a view, we first clear that view's dependencies from the existing
|
||||
/// cache, then we track that view in `rendering_view`. Subsequently, when a `Tracked` value is
|
||||
/// read, we update the maps to reflect that dependency.
|
||||
///
|
||||
/// When a `Tracked` value is updated, we refer back to the cached set of Views that depend on that
|
||||
/// value and add them all to the `invalidations` list, so that those views will be considered
|
||||
/// invalidated on the next render.
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
rendering_view: Option<View>,
|
||||
view_dependencies: HashMap<View, HashSet<TrackedId>>,
|
||||
value_dependencies: HashMap<TrackedId, HashSet<View>>,
|
||||
invalidations: HashSet<View>,
|
||||
}
|
||||
|
||||
/// Helper struct to encapsulate a View with its associated Window, necessary for properly tracking
|
||||
/// invalidations by window.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
struct View {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CACHE: UnsafeCell<Cache> = UnsafeCell::new(Cache::default())
|
||||
}
|
||||
|
||||
/// Helper method for dereferencing the cache value and providing it to the caller via a callback.
|
||||
fn with_cache<F, R>(callback: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Cache) -> R,
|
||||
{
|
||||
CACHE.with(|cache_cell| {
|
||||
// Safety: The cache is thread-local and only ever accessed by functions in this module.
|
||||
// Therefore, there is only ever one reference active at a time.
|
||||
let cache = unsafe { &mut *cache_cell.get() };
|
||||
|
||||
callback(cache)
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a View using the provided callback while tracking any reads of `Tracked` values.
|
||||
///
|
||||
/// While the render is performed, any reads of `Tracked` values will be stored as dependencies for
|
||||
/// the provided View.
|
||||
///
|
||||
/// ## Invariants
|
||||
///
|
||||
/// This function requires that only one view is rendered at a time, so the callback cannot result
|
||||
/// in a recursive call to `render`
|
||||
pub(super) fn render_view<F, R>(window_id: WindowId, view_id: EntityId, render_callback: F) -> R
|
||||
where
|
||||
F: FnOnce() -> R,
|
||||
{
|
||||
with_cache(|cache| {
|
||||
debug_assert!(cache.rendering_view.is_none());
|
||||
|
||||
let view = View { window_id, view_id };
|
||||
// Clear the dependency cache for this view as it is being rendered again
|
||||
remove_view_internal(view, cache);
|
||||
cache.rendering_view = Some(view);
|
||||
|
||||
let return_value = render_callback();
|
||||
|
||||
cache.rendering_view = None;
|
||||
|
||||
return_value
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the list of windows that have invalidations caused by the
|
||||
/// Autotracking system.
|
||||
pub(super) fn windows_with_invalidations() -> Vec<WindowId> {
|
||||
with_cache(|cache| {
|
||||
cache
|
||||
.invalidations
|
||||
.iter()
|
||||
.map(|view| view.window_id)
|
||||
.unique()
|
||||
.collect_vec()
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrieves any invalidations for the given window caused by the Autotracking
|
||||
/// system.
|
||||
///
|
||||
/// Note: This will clear the cache of invalidations for this window.
|
||||
pub(super) fn take_invalidations_for_window(window_id: WindowId) -> HashSet<EntityId> {
|
||||
with_cache(|cache| {
|
||||
let (matching, remainder) = mem::take(&mut cache.invalidations)
|
||||
.into_iter()
|
||||
.partition(|view| view.window_id == window_id);
|
||||
cache.invalidations = remainder;
|
||||
matching.into_iter().map(|view| view.view_id).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Notify the Autotracking system that a Window is being closed
|
||||
///
|
||||
/// This will remove all Views associated with that Window from the dependencies cache to make
|
||||
/// sure that we don't invalidate views from closed windows.
|
||||
///
|
||||
/// ## Invariants
|
||||
///
|
||||
/// This should not be called during the rendering of a View
|
||||
pub(super) fn close_window(window_id: WindowId) {
|
||||
with_cache(|cache| {
|
||||
debug_assert!(cache.rendering_view.is_none());
|
||||
|
||||
let removed_views = cache
|
||||
.view_dependencies
|
||||
.keys()
|
||||
.filter(|view| view.window_id == window_id)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for removed_view in removed_views {
|
||||
remove_view_internal(removed_view, cache);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a view from the dependency cache and any existing invalidations
|
||||
///
|
||||
/// ## Invariants
|
||||
///
|
||||
/// Should not be called during the rendering of a View
|
||||
pub(super) fn remove_view(window_id: WindowId, view_id: EntityId) {
|
||||
with_cache(|cache| {
|
||||
debug_assert!(cache.rendering_view.is_none());
|
||||
|
||||
remove_view_internal(View { window_id, view_id }, cache);
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_view_internal(view: View, cache: &mut Cache) {
|
||||
for tracked_id in cache.view_dependencies.remove(&view).into_iter().flatten() {
|
||||
if let Entry::Occupied(mut entry) = cache.value_dependencies.entry(tracked_id) {
|
||||
entry.get_mut().remove(&view);
|
||||
|
||||
if entry.get().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the Autotracking system that a given `Tracked` value was read
|
||||
fn track_read(field: TrackedId) {
|
||||
with_cache(|cache| {
|
||||
if let Some(view) = cache.rendering_view {
|
||||
cache
|
||||
.view_dependencies
|
||||
.entry(view)
|
||||
.or_default()
|
||||
.insert(field);
|
||||
cache
|
||||
.value_dependencies
|
||||
.entry(field)
|
||||
.or_default()
|
||||
.insert(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Notify the Autotracking system that a given `Tracked` value was updated
|
||||
fn track_update(field: TrackedId) {
|
||||
with_cache(|cache| {
|
||||
cache
|
||||
.invalidations
|
||||
.extend(cache.value_dependencies.get(&field).into_iter().flatten());
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::{track_read, track_update};
|
||||
use std::marker::PhantomData;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// An autotracked value type
|
||||
///
|
||||
/// This implements `Deref` and `DerefMut` for the underlying type `T`, so in most cases can be
|
||||
/// used as the underlying type without any code changes.
|
||||
///
|
||||
/// When the underlying data is read or updated, the Autotracking system will be notified so that
|
||||
/// it can manage Views' dependencies on `Tracked` values and automatically create invalidations
|
||||
/// (effectively calls to `ctx.notify()`) for the appropriate Views.
|
||||
///
|
||||
/// Note: Since the autotracking system only works on the main thread, `Tracked` does not implement
|
||||
/// `Send` or `Sync` and so cannot be shared between threads.
|
||||
#[derive(Debug)]
|
||||
pub struct Tracked<T> {
|
||||
id: TrackedId,
|
||||
inner: T,
|
||||
_no_send: PhantomData<*const u8>,
|
||||
}
|
||||
|
||||
impl<T> Tracked<T> {
|
||||
pub fn new(value: T) -> Self {
|
||||
Tracked {
|
||||
id: TrackedId::next(),
|
||||
inner: value,
|
||||
_no_send: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for Tracked<T> {
|
||||
fn from(value: T) -> Self {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for Tracked<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &T {
|
||||
track_read(self.id);
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for Tracked<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
track_update(self.id);
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for Tracked<T>
|
||||
where
|
||||
T: Default,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Tracked {
|
||||
id: TrackedId::next(),
|
||||
inner: T::default(),
|
||||
_no_send: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Autoincrementing identifier used to track a given `Tracked` value
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub(super) struct TrackedId(usize);
|
||||
|
||||
impl TrackedId {
|
||||
/// Generate the next unique `TrackedId` value
|
||||
fn next() -> Self {
|
||||
static TRACKED_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let next = TRACKED_ID.fetch_add(1, Ordering::Relaxed);
|
||||
TrackedId(next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use core::fmt;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ModelHandle;
|
||||
|
||||
/// A unique identifier for a View or a Model.
|
||||
///
|
||||
/// View and Model identifiers are not separately namespaced because we want to
|
||||
/// use them interchangeably in several places, e.g. in observations.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct EntityId(usize);
|
||||
|
||||
impl EntityId {
|
||||
/// Constructs a new globally-unique entity ID.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> EntityId {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
EntityId(raw)
|
||||
}
|
||||
|
||||
pub fn from_usize(value: usize) -> EntityId {
|
||||
EntityId(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EntityId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// An interface for a structure that can produce events.
|
||||
///
|
||||
/// TODO(vorporeal): This can probably be eliminated entirely, with View and
|
||||
/// Model exposing the associated type Event independently.
|
||||
pub trait Entity: 'static {
|
||||
type Event;
|
||||
}
|
||||
|
||||
/// An interface for a structure holding global state for the application.
|
||||
pub trait SingletonEntity: Entity + Sized {
|
||||
/// Returns the handle to the single model of this type stored within the
|
||||
/// provided application state.
|
||||
fn handle<T: GetSingletonModelHandle>(ctx: &T) -> ModelHandle<Self> {
|
||||
ctx.get_singleton_model_handle()
|
||||
}
|
||||
|
||||
fn as_ref(ctx: &crate::AppContext) -> &Self {
|
||||
ctx.get_singleton_model_as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for retrieving a handle to a singleton model by type.
|
||||
pub trait GetSingletonModelHandle {
|
||||
/// Returns the handle to the single model of this type stored within the
|
||||
/// provided application state.
|
||||
fn get_singleton_model_handle<T: SingletonEntity>(&self) -> ModelHandle<T>;
|
||||
}
|
||||
|
||||
pub trait AddSingletonModel {
|
||||
fn add_singleton_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
|
||||
where
|
||||
T: SingletonEntity,
|
||||
F: FnOnce(&mut super::ModelContext<T>) -> T;
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
mod action;
|
||||
mod app;
|
||||
mod autotracking;
|
||||
mod entity;
|
||||
mod model;
|
||||
mod view;
|
||||
mod window;
|
||||
|
||||
pub use action::*;
|
||||
pub use app::*;
|
||||
pub use autotracking::Tracked;
|
||||
pub use entity::*;
|
||||
pub use model::*;
|
||||
pub use view::*;
|
||||
pub use window::*;
|
||||
|
||||
use crate::platform::{self, FullscreenState, WindowBounds, WindowStyle};
|
||||
use crate::{keymap, Element};
|
||||
use anyhow::Error;
|
||||
|
||||
use crate::rendering::OnGPUDeviceSelected;
|
||||
use derivative::Derivative;
|
||||
use futures_util::future::BoxFuture;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::{self, Debug},
|
||||
hash::Hash,
|
||||
mem,
|
||||
sync::{atomic::AtomicUsize, atomic::Ordering},
|
||||
};
|
||||
|
||||
/// A unique identifier for a display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DisplayId(usize);
|
||||
|
||||
impl From<usize> for DisplayId {
|
||||
fn from(value: usize) -> Self {
|
||||
DisplayId(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of a valid display. Note that this only denotes the index of a display
|
||||
/// in the list of active displays and is not a unique identifier.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(
|
||||
feature = "schema_gen",
|
||||
schemars(
|
||||
description = "Which display to use when multiple monitors are connected.",
|
||||
rename_all = "snake_case"
|
||||
)
|
||||
)]
|
||||
#[cfg_attr(feature = "settings_value", derive(settings_value::SettingsValue))]
|
||||
pub enum DisplayIdx {
|
||||
/// The primary display of the user.
|
||||
#[cfg_attr(
|
||||
feature = "schema_gen",
|
||||
schemars(description = "The primary (main) display.")
|
||||
)]
|
||||
Primary,
|
||||
/// An external display at a given index.
|
||||
#[cfg_attr(
|
||||
feature = "schema_gen",
|
||||
schemars(description = "An external display, identified by index.")
|
||||
)]
|
||||
External(usize),
|
||||
}
|
||||
|
||||
impl DisplayIdx {
|
||||
// If the current DisplayIdx is still valid given the number of displays user has.
|
||||
pub fn is_valid_given_display_count(&self, display_count: usize) -> bool {
|
||||
match self {
|
||||
DisplayIdx::Primary => display_count >= 1,
|
||||
// Assumption here is we will always have one primary display -- any
|
||||
// external display count should be on top of it.
|
||||
DisplayIdx::External(idx) => display_count > *idx + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DisplayIdx {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DisplayIdx::Primary => write!(f, "Main Screen"),
|
||||
// The naming convention here is for the first external display External(0),
|
||||
// we should name it "Screen 2" and incrementally for the following displays.
|
||||
DisplayIdx::External(idx) => write!(f, "Screen {}", idx + 2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information to display the IME editor near the active cursor.
|
||||
#[derive(Debug)]
|
||||
pub struct CursorInfo {
|
||||
/// Position of the active cursor.
|
||||
pub position: RectF,
|
||||
/// The font size tells us how far below the active cursor position we place the IME.
|
||||
pub font_size: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApplicationBundleInfo<'a> {
|
||||
pub path: &'a Path,
|
||||
// Executable path could be None if the application does not have an executable.
|
||||
pub executable: Option<&'a Path>,
|
||||
}
|
||||
|
||||
// An TimerId is a globally unique id for a timer associated with a callback
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct TaskId(usize);
|
||||
|
||||
impl TaskId {
|
||||
/// \return the next view ID. Note the first return is 0.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> TaskId {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
TaskId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
pub type OptionalPlatformWindow = Option<Rc<dyn platform::Window>>;
|
||||
|
||||
type ActionCallback =
|
||||
dyn FnMut(&mut dyn AnyView, &dyn Any, &mut AppContext, WindowId, EntityId) -> bool;
|
||||
|
||||
type TypedActionCallback =
|
||||
dyn FnMut(&mut dyn AnyView, &dyn Any, &mut AppContext, WindowId, EntityId);
|
||||
|
||||
type GlobalActionCallback =
|
||||
dyn FnMut(&dyn Any, &'static std::panic::Location<'static>, &mut AppContext);
|
||||
|
||||
type InvalidationCallback = dyn FnMut(WindowId, &mut AppContext);
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||
struct ViewType(TypeId);
|
||||
|
||||
impl ViewType {
|
||||
fn of<T: ?Sized + 'static>() -> Self {
|
||||
ViewType(TypeId::of::<T>())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper struct for defining actions bound to a global shortcut/hotkey.
|
||||
struct GlobalShortcut {
|
||||
action: &'static str,
|
||||
args: Box<dyn Any>,
|
||||
}
|
||||
|
||||
#[derive(Default, Derivative)]
|
||||
#[derivative(Debug)]
|
||||
pub struct AddWindowOptions {
|
||||
pub background_blur_radius_pixels: Option<u8>,
|
||||
pub background_blur_texture: bool,
|
||||
pub window_style: WindowStyle,
|
||||
pub window_bounds: WindowBounds,
|
||||
pub title: Option<String>,
|
||||
pub fullscreen_state: FullscreenState,
|
||||
|
||||
/// If true, new windows created immediately after this window is closed
|
||||
/// will have the same position and size as this window.
|
||||
pub anchor_new_windows_from_closed_position: NextNewWindowsHasThisWindowsBoundsUponClose,
|
||||
/// The callback to be called when the GPU driver this window will render to is selected.
|
||||
#[derivative(Debug = "ignore")]
|
||||
pub on_gpu_driver_selected: Option<Box<OnGPUDeviceSelected>>,
|
||||
/// This is a name to distinguish different windows among one application. It is a no-op on all
|
||||
/// platforms except X11 Linux. See docs on the "WM_CLASS" property:
|
||||
/// https://www.x.org/docs/ICCCM/icccm.pdf
|
||||
pub window_instance: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub enum NextNewWindowsHasThisWindowsBoundsUponClose {
|
||||
/// Create the next new window with the position and size of this window if it's been closed.
|
||||
#[default]
|
||||
Yes,
|
||||
|
||||
/// Ignore the bounds of this window when creating the next new one.
|
||||
No,
|
||||
}
|
||||
|
||||
pub(crate) type SpawnedFuture = BoxFuture<'static, ()>;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WindowInvalidation {
|
||||
pub updated: HashSet<EntityId>,
|
||||
pub removed: HashSet<EntityId>,
|
||||
/// Stores whether an element in the window needs to be repainted. Currently an
|
||||
/// invalidation will repaint the entire element tree for that window, so we
|
||||
/// only store a boolean. In the future we can extend this to store entity ids
|
||||
/// for specific views that need to be redrawn once we have that capability.
|
||||
pub redraw_requested: bool,
|
||||
}
|
||||
|
||||
pub enum Effect {
|
||||
Event {
|
||||
entity_id: EntityId,
|
||||
payload: Box<dyn Any>,
|
||||
},
|
||||
ModelNotification {
|
||||
model_id: EntityId,
|
||||
},
|
||||
ViewNotification {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
},
|
||||
Focus {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
},
|
||||
TypedAction {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
action: Box<dyn Action>,
|
||||
},
|
||||
GlobalAction {
|
||||
name: &'static str,
|
||||
location: &'static std::panic::Location<'static>,
|
||||
arg: Box<dyn Any>,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait AnyView {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
fn ui_name(&self) -> &'static str;
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element>;
|
||||
fn on_focus(
|
||||
&mut self,
|
||||
focus_ctx: &FocusContext,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
);
|
||||
fn on_blur(
|
||||
&mut self,
|
||||
blur_ctx: &BlurContext,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
);
|
||||
fn keymap_context(&self, app: &AppContext) -> keymap::Context;
|
||||
fn active_cursor_position(
|
||||
&self,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) -> Option<CursorInfo>;
|
||||
fn on_window_closed(&mut self, app: &mut AppContext, window_id: WindowId, view_id: EntityId);
|
||||
fn on_window_transferred(
|
||||
&mut self,
|
||||
source_window_id: WindowId,
|
||||
target_window_id: WindowId,
|
||||
app: &mut AppContext,
|
||||
view_id: EntityId,
|
||||
);
|
||||
fn self_or_child_interacted_with(
|
||||
&self,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
);
|
||||
fn accessibility_data(
|
||||
&self,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) -> Option<AccessibilityData>;
|
||||
}
|
||||
|
||||
impl<T> AnyView for T
|
||||
where
|
||||
T: View,
|
||||
{
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn ui_name(&self) -> &'static str {
|
||||
T::ui_name()
|
||||
}
|
||||
|
||||
fn render<'a>(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
View::render(self, app)
|
||||
}
|
||||
|
||||
fn active_cursor_position(
|
||||
&self,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) -> Option<CursorInfo> {
|
||||
let ctx = ViewContext::new(app, window_id, view_id);
|
||||
View::active_cursor_position(self, &ctx)
|
||||
}
|
||||
|
||||
fn on_focus(
|
||||
&mut self,
|
||||
focus_ctx: &FocusContext,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) {
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
View::on_focus(self, focus_ctx, &mut ctx);
|
||||
// Send notification to a11y tools that the view gained focus
|
||||
if focus_ctx.is_self_focused() {
|
||||
if let Some(accessibility_contents) = View::accessibility_contents(self, app) {
|
||||
app.platform_delegate.set_accessibility_contents(
|
||||
accessibility_contents.with_verbosity(app.a11y_verbosity),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(
|
||||
&mut self,
|
||||
blur_ctx: &BlurContext,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) {
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
View::on_blur(self, blur_ctx, &mut ctx);
|
||||
}
|
||||
|
||||
fn on_window_closed(&mut self, app: &mut AppContext, window_id: WindowId, view_id: EntityId) {
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
View::on_window_closed(self, &mut ctx);
|
||||
}
|
||||
|
||||
fn on_window_transferred(
|
||||
&mut self,
|
||||
source_window_id: WindowId,
|
||||
target_window_id: WindowId,
|
||||
app: &mut AppContext,
|
||||
view_id: EntityId,
|
||||
) {
|
||||
let mut ctx = ViewContext::new(app, target_window_id, view_id);
|
||||
View::on_window_transferred(self, source_window_id, target_window_id, &mut ctx);
|
||||
}
|
||||
|
||||
fn keymap_context(&self, app: &AppContext) -> keymap::Context {
|
||||
View::keymap_context(self, app)
|
||||
}
|
||||
|
||||
fn self_or_child_interacted_with(
|
||||
&self,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) {
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
View::self_or_child_interacted_with(self, &mut ctx)
|
||||
}
|
||||
|
||||
fn accessibility_data(
|
||||
&self,
|
||||
app: &mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) -> Option<AccessibilityData> {
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
View::accessibility_data(self, &mut ctx)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Handle<T> {
|
||||
fn id(&self) -> EntityId;
|
||||
fn location(&self) -> EntityLocation;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub enum EntityLocation {
|
||||
Model(EntityId),
|
||||
View(WindowId, EntityId),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RefCounts {
|
||||
entity_counts: HashMap<EntityId, usize>,
|
||||
dropped: DroppedItems,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DroppedItems {
|
||||
models: HashSet<EntityId>,
|
||||
views: HashSet<(WindowId, EntityId)>,
|
||||
}
|
||||
|
||||
impl RefCounts {
|
||||
fn inc_entity(&mut self, entity_id: EntityId) {
|
||||
*self.entity_counts.entry(entity_id).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
fn dec_model(&mut self, model_id: EntityId) {
|
||||
if let Some(count) = self.entity_counts.get_mut(&model_id) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
self.entity_counts.remove(&model_id);
|
||||
|
||||
self.dropped.models.insert(model_id);
|
||||
}
|
||||
} else {
|
||||
panic!("Expected ref count to be positive")
|
||||
}
|
||||
}
|
||||
|
||||
fn dec_view(&mut self, window_id: WindowId, view_id: EntityId) {
|
||||
if let Some(count) = self.entity_counts.get_mut(&view_id) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
self.entity_counts.remove(&view_id);
|
||||
self.dropped.views.insert((window_id, view_id));
|
||||
}
|
||||
} else {
|
||||
panic!("Expected ref count to be positive")
|
||||
}
|
||||
}
|
||||
|
||||
fn take_dropped(&mut self) -> DroppedItems {
|
||||
mem::take(&mut self.dropped)
|
||||
}
|
||||
}
|
||||
|
||||
impl DroppedItems {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.models.is_empty() && self.views.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
type SubscriptionFromModelCallback = dyn FnMut(&mut dyn Any, &dyn Any, &mut AppContext, EntityId);
|
||||
type SubscriptionFromViewCallback =
|
||||
dyn FnMut(&mut dyn Any, &dyn Any, &mut AppContext, WindowId, EntityId);
|
||||
type SubscriptionFromAppCallback = dyn FnMut(&dyn Any, &mut AppContext, EntityId);
|
||||
|
||||
/// Key that uniquely identifies a subscription for deferred unsubscribe tracking.
|
||||
#[derive(Hash, Eq, PartialEq, Clone, Copy)]
|
||||
pub(super) enum SubscriptionKey {
|
||||
Model(EntityId),
|
||||
View(WindowId, EntityId),
|
||||
}
|
||||
|
||||
/// Tracks pending unsubscribes during event emission.
|
||||
/// When `emit_event` is processing callbacks, unsubscribes are deferred to avoid
|
||||
/// O(N²) tombstone scanning. This struct collects the unsubscribes, which are then
|
||||
/// processed in a single pass at the end of event emission.
|
||||
pub(super) struct PendingUnsubscribes {
|
||||
/// The entity we're currently emitting events for.
|
||||
pub entity_id: EntityId,
|
||||
/// Keys of subscriptions that should be removed after all callbacks complete.
|
||||
pub keys: HashSet<SubscriptionKey>,
|
||||
}
|
||||
|
||||
/// Sources from where an [`Entity`] (e.g. View or Model) can be subscribed to for events.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum Subscription {
|
||||
/// The [`Entity`] is subscribed to from a [`Model`].
|
||||
FromModel {
|
||||
model_id: EntityId,
|
||||
callback: Box<SubscriptionFromModelCallback>,
|
||||
},
|
||||
/// The [`Entity`] is subscribed to from a [`View`].
|
||||
FromView {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
callback: Box<SubscriptionFromViewCallback>,
|
||||
},
|
||||
/// The [`Entity`] is subscribed to from the [`App`].
|
||||
FromApp {
|
||||
callback: Box<SubscriptionFromAppCallback>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Returns a key that uniquely identifies this subscription for deferred unsubscribe tracking.
|
||||
/// Returns `None` for `FromApp` subscriptions since they cannot be unsubscribed.
|
||||
fn subscription_key(&self) -> Option<SubscriptionKey> {
|
||||
match self {
|
||||
Subscription::FromModel { model_id, .. } => Some(SubscriptionKey::Model(*model_id)),
|
||||
Subscription::FromView {
|
||||
window_id, view_id, ..
|
||||
} => Some(SubscriptionKey::View(*window_id, *view_id)),
|
||||
Subscription::FromApp { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ObservationFromModelCallback = dyn FnMut(&mut dyn Any, EntityId, &mut AppContext, EntityId);
|
||||
type ObservationFromViewCallback =
|
||||
dyn FnMut(&mut dyn Any, EntityId, &mut AppContext, WindowId, EntityId);
|
||||
type ObservationFromAppCallback = dyn FnMut(EntityId, &mut AppContext);
|
||||
|
||||
/// Sources from where an [`Entity`] can be observed for invalidations.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum Observation {
|
||||
/// The [`Entity`] is observed from another [`Model`].
|
||||
FromModel {
|
||||
model_id: EntityId,
|
||||
callback: Box<ObservationFromModelCallback>,
|
||||
},
|
||||
/// The [`Entity`] is observed from a [`View`].
|
||||
FromView {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
callback: Box<ObservationFromViewCallback>,
|
||||
},
|
||||
/// The [`Entity`] is observed from the [`App`].
|
||||
FromApp {
|
||||
callback: Box<ObservationFromAppCallback>,
|
||||
},
|
||||
}
|
||||
|
||||
type ModelFromFutureCallback = dyn FnOnce(&mut dyn Any, Box<dyn Any>, &mut AppContext, EntityId);
|
||||
|
||||
type ModelFromStreamItemCallback = dyn FnMut(&mut dyn Any, Box<dyn Any>, &mut AppContext, EntityId);
|
||||
type ModelFromStreamDoneCallback = dyn FnOnce(&mut dyn Any, &mut AppContext, EntityId);
|
||||
|
||||
type ViewFromFutureCallback =
|
||||
dyn FnOnce(&mut dyn AnyView, Box<dyn Any>, &mut AppContext, WindowId, EntityId);
|
||||
|
||||
type ViewFromStreamItemCallback =
|
||||
dyn FnMut(&mut dyn AnyView, Box<dyn Any>, &mut AppContext, WindowId, EntityId);
|
||||
|
||||
type ViewFromStreamDoneCallback = dyn FnOnce(&mut dyn AnyView, &mut AppContext, WindowId, EntityId);
|
||||
|
||||
enum TaskCallback {
|
||||
ModelFromFuture {
|
||||
model_id: EntityId,
|
||||
callback: Box<ModelFromFutureCallback>,
|
||||
},
|
||||
ModelFromStream {
|
||||
model_id: EntityId,
|
||||
on_item: Box<ModelFromStreamItemCallback>,
|
||||
on_done: Box<ModelFromStreamDoneCallback>,
|
||||
},
|
||||
ViewFromFuture {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
callback: Box<ViewFromFutureCallback>,
|
||||
},
|
||||
ViewFromStream {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
on_item: Box<ViewFromStreamItemCallback>,
|
||||
on_done: Box<ViewFromStreamDoneCallback>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Given a duration and a max jitter percentage, returns a duration representing the
|
||||
/// Duration + random value (0, jitter_percentage * Duration)
|
||||
pub fn duration_with_jitter(duration: Duration, max_jitter_percentage: f32) -> Duration {
|
||||
let max_jitter = duration.mul_f32(max_jitter_percentage);
|
||||
let jitter = max_jitter.mul_f32(rand::random());
|
||||
duration + jitter
|
||||
}
|
||||
|
||||
/// Configurable retrying option for spawn_with_retry_on_error.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RetryOption {
|
||||
strategy: RetryStrategy,
|
||||
/// Interval until the next retry.
|
||||
interval: Duration,
|
||||
/// The remaining number of retries left.
|
||||
remaining_retry_count: usize,
|
||||
/// The maximum jitter percentage to be added to the interval. If this is None, there's no jitter.
|
||||
max_jitter_percentage: Option<f32>,
|
||||
}
|
||||
|
||||
impl RetryOption {
|
||||
pub const fn linear(interval: Duration, remaining_retry_count: usize) -> Self {
|
||||
Self {
|
||||
strategy: RetryStrategy::LinearBackoff,
|
||||
interval,
|
||||
remaining_retry_count,
|
||||
max_jitter_percentage: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn exponential(
|
||||
interval: Duration,
|
||||
factor: f32,
|
||||
remaining_retry_count: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
strategy: RetryStrategy::ExponentialBackoff(factor),
|
||||
interval,
|
||||
remaining_retry_count,
|
||||
max_jitter_percentage: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_jitter(mut self, max_jitter_percentage: f32) -> Self {
|
||||
self.max_jitter_percentage = Some(max_jitter_percentage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Advance the retry option after receiving one failure callback.
|
||||
pub fn advance(&mut self) {
|
||||
self.remaining_retry_count = self.remaining_retry_count.saturating_sub(1);
|
||||
|
||||
if let RetryStrategy::ExponentialBackoff(factor) = self.strategy {
|
||||
self.interval = self.interval.mul_f32(factor);
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of remaining retries, not including previous attempts.
|
||||
pub fn remaining_retries(&self) -> usize {
|
||||
self.remaining_retry_count
|
||||
}
|
||||
|
||||
/// Computes the duration until the next retry.
|
||||
pub fn duration(&self) -> Duration {
|
||||
match self.max_jitter_percentage {
|
||||
Some(max_jitter_percentage) => {
|
||||
duration_with_jitter(self.interval, max_jitter_percentage)
|
||||
}
|
||||
None => self.interval,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum RetryStrategy {
|
||||
/// Constant interval between each backoff.
|
||||
LinearBackoff,
|
||||
/// Exponential backoff with the set multiplication factor.
|
||||
ExponentialBackoff(f32),
|
||||
}
|
||||
|
||||
/// State of the resolved future in `spawn_with_retry_on_error`.
|
||||
#[derive(Debug)]
|
||||
pub enum RequestState<T> {
|
||||
/// Request succeeded with return value T.
|
||||
RequestSucceeded(T),
|
||||
/// Request failed but there are pending retries.
|
||||
RequestFailedRetryPending(Error),
|
||||
/// Request failed.
|
||||
RequestFailed(Error),
|
||||
}
|
||||
|
||||
impl<T> RequestState<T> {
|
||||
pub fn has_pending_retries(&self) -> bool {
|
||||
matches!(self, RequestState::RequestFailedRetryPending(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,656 @@
|
||||
use std::{any::Any, future::Future, marker::PhantomData, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
r#async::{SpawnableOutput, Timer},
|
||||
windowing::WindowManager,
|
||||
ReadModel, ReadView, UpdateView, View, ViewAsRef, ViewContext, ViewHandle, WeakModelHandle,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use futures::{
|
||||
stream::{AbortHandle, Abortable},
|
||||
FutureExt,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
accessibility::AccessibilityContent,
|
||||
core::{Observation, Subscription, SubscriptionKey, TaskCallback},
|
||||
r#async::{executor, SpawnedFutureHandle, SpawnedLocalStream},
|
||||
AppContext, Effect, Entity, EntityId, GetSingletonModelHandle, ModelAsRef, ModelHandle,
|
||||
RequestState, RetryOption, UpdateModel,
|
||||
};
|
||||
|
||||
/// Error returned when a model has been dropped, and so references to it are invalid.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
#[error("Model has been dropped")]
|
||||
pub struct ModelDropped;
|
||||
|
||||
/// Structure that combines model identifiers and a handle to the application
|
||||
/// context/application state.
|
||||
pub struct ModelContext<'a, T: ?Sized> {
|
||||
app: &'a mut AppContext,
|
||||
model_id: EntityId,
|
||||
model_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<'a, T: Entity> ModelContext<'a, T> {
|
||||
pub(in crate::core) fn new(app: &'a mut AppContext, model_id: EntityId) -> Self {
|
||||
Self {
|
||||
app,
|
||||
model_id,
|
||||
model_type: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> WeakModelHandle<T> {
|
||||
WeakModelHandle::new(self.model_id)
|
||||
}
|
||||
|
||||
pub fn background_executor(&self) -> Arc<executor::Background> {
|
||||
self.app.background_executor().clone()
|
||||
}
|
||||
|
||||
pub fn model_id(&self) -> EntityId {
|
||||
self.model_id
|
||||
}
|
||||
|
||||
pub fn windows(&self) -> &WindowManager {
|
||||
self.app.windows()
|
||||
}
|
||||
|
||||
pub fn add_model<S, F>(&mut self, build_model: F) -> ModelHandle<S>
|
||||
where
|
||||
S: Entity,
|
||||
F: FnOnce(&mut ModelContext<S>) -> S,
|
||||
{
|
||||
self.app.add_model(build_model)
|
||||
}
|
||||
|
||||
pub fn subscribe_to_model<S: Entity, F>(&mut self, handle: &ModelHandle<S>, mut callback: F)
|
||||
where
|
||||
S::Event: 'static,
|
||||
F: 'static + FnMut(&mut T, &S::Event, &mut ModelContext<T>),
|
||||
{
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(handle.id())
|
||||
.or_default()
|
||||
.push(Subscription::FromModel {
|
||||
model_id: self.model_id,
|
||||
callback: Box::new(move |model, payload, app, model_id| {
|
||||
let model = model.downcast_mut().expect("downcast is type safe");
|
||||
let payload: &<S as Entity>::Event =
|
||||
payload.downcast_ref().expect("downcast is type safe");
|
||||
let mut ctx = ModelContext::new(app, model_id);
|
||||
callback(model, payload, &mut ctx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn unsubscribe_from_model<E>(&mut self, handle: &ModelHandle<E>)
|
||||
where
|
||||
E: Entity,
|
||||
E::Event: 'static,
|
||||
{
|
||||
let target_entity = handle.id();
|
||||
|
||||
// If we're currently emitting events for this entity, defer the unsubscribe.
|
||||
if let Some(ref mut pending) = self.app.pending_unsubscribes {
|
||||
if pending.entity_id == target_entity {
|
||||
pending.keys.insert(SubscriptionKey::Model(self.model_id));
|
||||
|
||||
// Remove subscriptions created earlier in this emission so subscribe-then-unsubscribe ordering is preserved.
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.app.subscriptions.entry(target_entity)
|
||||
{
|
||||
entry.get_mut().retain(|subscription| match subscription {
|
||||
Subscription::FromView { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromModel { model_id, .. } => *model_id != self.model_id,
|
||||
});
|
||||
|
||||
if entry.get().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise process immediately.
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(target_entity)
|
||||
.or_default()
|
||||
.retain(|subscription| match subscription {
|
||||
Subscription::FromView { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromModel { model_id, .. } => *model_id != self.model_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscribe_to_view<V, F>(&mut self, handle: &ViewHandle<V>, mut callback: F)
|
||||
where
|
||||
V: View,
|
||||
V::Event: 'static,
|
||||
F: 'static + FnMut(&mut T, &V::Event, &mut ModelContext<T>),
|
||||
{
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(handle.id())
|
||||
.or_default()
|
||||
.push(Subscription::FromModel {
|
||||
model_id: self.model_id,
|
||||
callback: Box::new(move |model, payload, app, model_id| {
|
||||
let model = model.downcast_mut().expect("downcast is type safe");
|
||||
let payload = payload.downcast_ref().expect("downcast is type safe");
|
||||
let mut ctx = ModelContext::new(app, model_id);
|
||||
callback(model, payload, &mut ctx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn unsubscribe_from_view<V>(&mut self, handle: &ViewHandle<V>)
|
||||
where
|
||||
V: View,
|
||||
V::Event: 'static,
|
||||
{
|
||||
let target_entity = handle.id();
|
||||
|
||||
// If we're currently emitting events for this entity, defer the unsubscribe.
|
||||
if let Some(ref mut pending) = self.app.pending_unsubscribes {
|
||||
if pending.entity_id == target_entity {
|
||||
pending.keys.insert(SubscriptionKey::Model(self.model_id));
|
||||
|
||||
// Remove subscriptions created earlier in this emission so subscribe-then-unsubscribe ordering is preserved.
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.app.subscriptions.entry(target_entity)
|
||||
{
|
||||
entry.get_mut().retain(|subscription| match subscription {
|
||||
Subscription::FromView { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromModel { model_id, .. } => *model_id != self.model_id,
|
||||
});
|
||||
|
||||
if entry.get().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise process immediately.
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(target_entity)
|
||||
.or_default()
|
||||
.retain(|subscription| match subscription {
|
||||
Subscription::FromView { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromModel { model_id, .. } => *model_id != self.model_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, payload: T::Event) {
|
||||
self.app.pending_effects.push_back(Effect::Event {
|
||||
entity_id: self.model_id,
|
||||
payload: Box::new(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/// Global actions are being phased out. Prefer dispatching typed actions instead of global actions.
|
||||
/// Dispatch a global action to be handled by the registered handler
|
||||
///
|
||||
/// Note: The dispatch of the global action will be registered as an effect and flushed after
|
||||
/// the current model update is complete. This will ensure that the model has been re-inserted
|
||||
/// into the `AppContext`, so it will be accessible to the global action, if necessary
|
||||
#[track_caller]
|
||||
pub fn dispatch_global_action<A: Any>(&mut self, name: &'static str, arg: A) {
|
||||
let location = std::panic::Location::caller();
|
||||
self.app.pending_effects.push_back(Effect::GlobalAction {
|
||||
name,
|
||||
location,
|
||||
arg: Box::new(arg),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn observe<S, F>(&mut self, handle: &ModelHandle<S>, mut callback: F)
|
||||
where
|
||||
S: Entity,
|
||||
F: 'static + FnMut(&mut T, ModelHandle<S>, &mut ModelContext<T>),
|
||||
{
|
||||
self.app
|
||||
.observations
|
||||
.entry(handle.id())
|
||||
.or_default()
|
||||
.push(Observation::FromModel {
|
||||
model_id: self.model_id,
|
||||
callback: Box::new(move |model, observed_id, app, model_id| {
|
||||
let model = model.downcast_mut().expect("downcast is type safe");
|
||||
let observed = ModelHandle::new(observed_id, &app.ref_counts);
|
||||
let mut ctx = ModelContext::new(app, model_id);
|
||||
callback(model, observed, &mut ctx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn notify(&mut self) {
|
||||
// If the last effect is a model notification for this model,
|
||||
// don't add another one.
|
||||
if let Some(Effect::ModelNotification { model_id }) = self.app.pending_effects.back() {
|
||||
if *model_id == self.model_id {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.app
|
||||
.pending_effects
|
||||
.push_back(Effect::ModelNotification {
|
||||
model_id: self.model_id,
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit AccessibilityContent
|
||||
/// This method lets propagate any content to the screen reader on demand (doesn't need to be
|
||||
/// tied with actions or specific events).
|
||||
pub fn emit_a11y_content(&mut self, content: AccessibilityContent) {
|
||||
self.app
|
||||
.platform_delegate
|
||||
.set_accessibility_contents(content);
|
||||
}
|
||||
|
||||
// Only public in crate::core so it can be used by ui/src/core/mod_test.rs.
|
||||
pub(in crate::core) fn spawn_local<S, F, U>(
|
||||
&mut self,
|
||||
future: S,
|
||||
callback: F,
|
||||
) -> impl Future<Output = ()>
|
||||
where
|
||||
S: 'static + Future,
|
||||
F: 'static + FnOnce(&mut T, S::Output, &mut ModelContext<T>) -> U,
|
||||
U: 'static,
|
||||
{
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let task_id = self.app.spawn_local(future);
|
||||
|
||||
self.app.task_callbacks.insert(
|
||||
task_id,
|
||||
TaskCallback::ModelFromFuture {
|
||||
model_id: self.model_id,
|
||||
callback: Box::new(move |model, output, app, model_id| {
|
||||
let model = model.downcast_mut().unwrap();
|
||||
let output = *output.downcast().unwrap();
|
||||
let result = callback(model, output, &mut ModelContext::new(app, model_id));
|
||||
let _ = tx.send(result);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
async move {
|
||||
if rx.await.is_err() {
|
||||
log::error!("sender unexpectedly dropped before receiver");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a future that returns a Result type to run on the background thread.
|
||||
/// If the future resolves to success (Ok), call the set callback with RequestState::RequestSucceeded.
|
||||
/// If the future fails and we still have remaining retry counts, call the set callback
|
||||
/// with RequestState::RequestFailedRetryPending and retry based on the RetryOption.
|
||||
/// Otherwise, call the set callback with RequestState::RequestFailed.
|
||||
pub fn spawn_with_retry_on_error<P, S, F, M>(
|
||||
&mut self,
|
||||
future_closure: P,
|
||||
retry_option: RetryOption,
|
||||
callback: F,
|
||||
) -> SpawnedFutureHandle
|
||||
where
|
||||
P: 'static + FnMut() -> S,
|
||||
S: crate::r#async::Spawnable + Future<Output = Result<M>>,
|
||||
<S as Future>::Output: crate::r#async::SpawnableOutput,
|
||||
F: 'static + FnMut(&mut T, RequestState<M>, &mut ModelContext<T>),
|
||||
{
|
||||
self.spawn_with_retry_on_error_when(future_closure, retry_option, |_| true, callback)
|
||||
}
|
||||
|
||||
/// Like [`Self::spawn_with_retry_on_error`], but additionally consults `should_retry` on
|
||||
/// each failure. The chain stops immediately (calling the callback with
|
||||
/// [`RequestState::RequestFailed`]) when `should_retry` returns false, even if retries
|
||||
/// remain on the [`RetryOption`]. Use this for errors that are known to be permanent so
|
||||
/// they don't issue redundant requests — e.g. classify a 403/404 with
|
||||
/// `is_transient_http_error` and skip retries.
|
||||
pub fn spawn_with_retry_on_error_when<P, S, R, F, M>(
|
||||
&mut self,
|
||||
mut future_closure: P,
|
||||
mut retry_option: RetryOption,
|
||||
mut should_retry: R,
|
||||
mut callback: F,
|
||||
) -> SpawnedFutureHandle
|
||||
where
|
||||
P: 'static + FnMut() -> S,
|
||||
S: crate::r#async::Spawnable + Future<Output = Result<M>>,
|
||||
<S as Future>::Output: crate::r#async::SpawnableOutput,
|
||||
R: 'static + FnMut(&anyhow::Error) -> bool,
|
||||
F: 'static + FnMut(&mut T, RequestState<M>, &mut ModelContext<T>),
|
||||
{
|
||||
let future = future_closure();
|
||||
|
||||
self.spawn(future, move |me, res, ctx| match res {
|
||||
Ok(success) => {
|
||||
callback(me, RequestState::RequestSucceeded(success), ctx);
|
||||
}
|
||||
Err(e) => {
|
||||
if retry_option.remaining_retry_count == 0 || !should_retry(&e) {
|
||||
callback(me, RequestState::RequestFailed(e), ctx);
|
||||
} else {
|
||||
callback(me, RequestState::RequestFailedRetryPending(e), ctx);
|
||||
|
||||
let _ = ctx.spawn(
|
||||
async move { Timer::after(retry_option.duration()).await },
|
||||
move |_, _, ctx| {
|
||||
retry_option.advance();
|
||||
ctx.spawn_with_retry_on_error_when(
|
||||
future_closure,
|
||||
retry_option,
|
||||
should_retry,
|
||||
callback,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Schedules a future to run on a background thread, invoking a callback on
|
||||
/// the _main_ thread upon completion.
|
||||
///
|
||||
/// This function is useful in situations where a long-running process needs
|
||||
/// to occur (e.g.: a network request), after which the model needs to be
|
||||
/// updated based on the result.
|
||||
///
|
||||
/// The callback receives the output of the future, if any, in addition to
|
||||
/// mutable references to the spawning view and its context, allowing for
|
||||
/// dirtying of the model (via [`Self::notify`]) if appropriate.
|
||||
///
|
||||
/// The future can be aborted by calling `abort` on the returned `SpawnedFutureHandle`. Note the
|
||||
/// future will only be aborted the _next_ time the future is polled.
|
||||
///
|
||||
/// See [`Self::spawn_abortable`] for an alternative version of this function that accepts an
|
||||
/// `on_abort` function that is called when the future is aborted.
|
||||
pub fn spawn<S, F, U>(&mut self, future: S, callback: F) -> SpawnedFutureHandle
|
||||
where
|
||||
S: crate::r#async::Spawnable,
|
||||
<S as Future>::Output: crate::r#async::SpawnableOutput,
|
||||
F: 'static + FnOnce(&mut T, S::Output, &mut ModelContext<T>) -> U,
|
||||
U: 'static,
|
||||
{
|
||||
self.spawn_abortable::<S, _, _>(
|
||||
future,
|
||||
|view, output, ctx| {
|
||||
callback(view, output, ctx);
|
||||
},
|
||||
|_, _| {},
|
||||
)
|
||||
}
|
||||
|
||||
/// Schedules a future to run on a background thread, invoking the `on_resolve`
|
||||
/// callback on the _main_ thread upon completion. If the future is aborted, the
|
||||
/// `on_abort` function is called.
|
||||
///
|
||||
/// This function is useful in situations where a long-running process needs
|
||||
/// to occur (e.g.: a network request), after which the model needs to be
|
||||
/// updated based on the result.
|
||||
///
|
||||
/// The `on_resolve` callback receives the output of the future, if any, in addition to
|
||||
/// mutable references to the spawning model and its context, allowing for
|
||||
/// dirtying of the view (via [`Self::notify`]) if appropriate.
|
||||
///
|
||||
/// The future can be aborted by calling `abort` on the returned `SpawnedFutureHandle`. Note, a
|
||||
/// future is not immediately killed on `abort`--it will only be aborted once the future's
|
||||
/// `poll` method returns.
|
||||
///
|
||||
/// See [`Self::spawn`] for an alternative version of this function that doesn't
|
||||
/// require a callback if/when the future is aborted.
|
||||
pub fn spawn_abortable<S, F, A>(
|
||||
&mut self,
|
||||
future: S,
|
||||
on_resolve: F,
|
||||
on_abort: A,
|
||||
) -> SpawnedFutureHandle
|
||||
where
|
||||
S: crate::r#async::Spawnable,
|
||||
<S as Future>::Output: crate::r#async::SpawnableOutput,
|
||||
F: 'static + FnOnce(&mut T, S::Output, &mut ModelContext<T>),
|
||||
A: 'static + FnOnce(&mut T, &mut ModelContext<T>),
|
||||
{
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let (abort_handle, abort_registration) = AbortHandle::new_pair();
|
||||
self.app
|
||||
.background_executor()
|
||||
.spawn_boxed(Box::pin(async move {
|
||||
let abortable = Abortable::new(future, abort_registration);
|
||||
let result = abortable.await;
|
||||
if tx.send(result).is_err() {
|
||||
log::error!("Error sending background task result to main thread",);
|
||||
}
|
||||
}))
|
||||
.detach();
|
||||
|
||||
let future = self.spawn_local(rx, |model, rx_result, ctx| {
|
||||
let output = match rx_result {
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
log::error!("sender unexpectedly dropped before receiver");
|
||||
on_abort(model, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Call the appropriate callback based on the output of resolving the future. If the
|
||||
// future returned `Ok`, the future was not aborted so we can call `on_resolve`. If
|
||||
// the future returned `Err`--the future was aborted.
|
||||
match output {
|
||||
Ok(output) => on_resolve(model, output, ctx),
|
||||
Err(_) => on_abort(model, ctx),
|
||||
}
|
||||
});
|
||||
|
||||
let future_id = self.app.register_spawned_future(future.boxed());
|
||||
SpawnedFutureHandle::new(abort_handle, future_id)
|
||||
}
|
||||
|
||||
/// Creates a handle which background tasks can use to spawn work for this model. Spawned tasks
|
||||
/// are executed on the main thread in the context of the model, and results are sent back to
|
||||
/// the background task.
|
||||
///
|
||||
/// Note that the spawner *does not* keep a strong reference to the model. If the model is
|
||||
/// dropped, any pending or future tasks will be discarded.
|
||||
pub fn spawner(&mut self) -> ModelSpawner<T> {
|
||||
let (task_tx, task_rx) = async_channel::unbounded();
|
||||
let (completion_tx, _completion_rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let task_id = self.app.spawn_stream_local(task_rx, completion_tx);
|
||||
self.app.task_callbacks.insert(
|
||||
task_id,
|
||||
TaskCallback::ModelFromStream {
|
||||
model_id: self.model_id,
|
||||
on_item: Box::new(move |model, task, app, model_id| {
|
||||
let model = model.downcast_mut().expect("unexpected model type");
|
||||
let task: ModelTask<T> = *task
|
||||
.downcast()
|
||||
.expect("task from spawner should be ModelTask<T>");
|
||||
let mut ctx = ModelContext::new(app, model_id);
|
||||
task(model, &mut ctx);
|
||||
}),
|
||||
on_done: Box::new(move |_model, _app, _model_id| {}),
|
||||
},
|
||||
);
|
||||
|
||||
ModelSpawner {
|
||||
task_sender: task_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_stream_local<S, F, G>(
|
||||
&mut self,
|
||||
stream: S,
|
||||
mut on_item: F,
|
||||
on_done: G,
|
||||
) -> SpawnedLocalStream
|
||||
where
|
||||
S: 'static + crate::r#async::Stream,
|
||||
S::Item: SpawnableOutput,
|
||||
F: 'static + FnMut(&mut T, S::Item, &mut ModelContext<T>),
|
||||
G: 'static + FnOnce(&mut T, &mut ModelContext<T>),
|
||||
{
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let task_id = self.app.spawn_stream_local(stream, tx);
|
||||
self.app.task_callbacks.insert(
|
||||
task_id,
|
||||
TaskCallback::ModelFromStream {
|
||||
model_id: self.model_id,
|
||||
on_item: Box::new(move |model, output, app, model_id| {
|
||||
let model = model.downcast_mut().unwrap();
|
||||
let output = *output.downcast().unwrap();
|
||||
let mut ctx = ModelContext::new(app, model_id);
|
||||
on_item(model, output, &mut ctx);
|
||||
}),
|
||||
on_done: Box::new(move |model, app, model_id| {
|
||||
let model = model.downcast_mut().unwrap();
|
||||
let mut ctx = ModelContext::new(app, model_id);
|
||||
on_done(model, &mut ctx);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
SpawnedLocalStream::new(
|
||||
async move {
|
||||
if rx.await.is_err() {
|
||||
log::error!("sender unexpectedly dropped before receiver");
|
||||
}
|
||||
}
|
||||
.boxed_local(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for ModelContext<'_, T> {
|
||||
type Target = AppContext;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.app
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::DerefMut for ModelContext<'_, T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.app
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> ViewAsRef for ModelContext<'_, M> {
|
||||
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T {
|
||||
self.app.view(handle)
|
||||
}
|
||||
|
||||
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T> {
|
||||
self.app.try_view(handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> ReadView for ModelContext<'_, M> {
|
||||
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
|
||||
where
|
||||
T: View,
|
||||
F: FnOnce(&T, &AppContext) -> S,
|
||||
{
|
||||
self.app.read_view(handle, read)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> UpdateView for ModelContext<'_, M> {
|
||||
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
|
||||
where
|
||||
T: View,
|
||||
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
|
||||
{
|
||||
self.app.update_view(handle, update)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> ModelAsRef for ModelContext<'_, M> {
|
||||
fn model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T {
|
||||
self.app.model(handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> ReadModel for ModelContext<'_, M> {
|
||||
fn read_model<T, F, S>(&self, handle: &ModelHandle<T>, read: F) -> S
|
||||
where
|
||||
T: Entity,
|
||||
F: FnOnce(&T, &AppContext) -> S,
|
||||
{
|
||||
self.app.read_model(handle, read)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> UpdateModel for ModelContext<'_, M> {
|
||||
fn update_model<T, F, S>(&mut self, handle: &ModelHandle<T>, update: F) -> S
|
||||
where
|
||||
T: Entity,
|
||||
F: FnOnce(&mut T, &mut ModelContext<T>) -> S,
|
||||
{
|
||||
self.app.update_model(handle, update)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> GetSingletonModelHandle for ModelContext<'_, M> {
|
||||
fn get_singleton_model_handle<T: crate::SingletonEntity>(&self) -> ModelHandle<T> {
|
||||
self.app.get_singleton_model_handle()
|
||||
}
|
||||
}
|
||||
|
||||
/// A task which must run in the context of a model of type `M`.
|
||||
type ModelTask<M> = Box<dyn FnOnce(&mut M, &mut ModelContext<M>) + Send + 'static>;
|
||||
|
||||
/// A handle for spawning model tasks from background threads.
|
||||
pub struct ModelSpawner<M> {
|
||||
task_sender: async_channel::Sender<ModelTask<M>>,
|
||||
}
|
||||
|
||||
impl<M> Clone for ModelSpawner<M> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
task_sender: self.task_sender.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> ModelSpawner<M> {
|
||||
/// Spawn a task that will execute on the main thread, in the context of a model.
|
||||
pub async fn spawn<R: Send + 'static>(
|
||||
&self,
|
||||
work: impl FnOnce(&mut M, &mut ModelContext<M>) -> R + Send + 'static,
|
||||
) -> Result<R, ModelDropped> {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
self.task_sender
|
||||
.send(Box::new(move |me, ctx| {
|
||||
let result = work(me, ctx);
|
||||
// If the background task has dropped the receiver, then we don't need to send
|
||||
// the result, and there's no one to inform regardless.
|
||||
let _ = tx.send(result);
|
||||
}))
|
||||
.await
|
||||
.map_err(|_| ModelDropped)?;
|
||||
|
||||
rx.await.map_err(|_| ModelDropped)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "context_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::ModelDropped;
|
||||
use crate::{App, Entity};
|
||||
|
||||
#[test]
|
||||
fn test_model_spawner() {
|
||||
#[derive(Default)]
|
||||
struct Model {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Entity for Model {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let handle = app.add_model(|_| Model::default());
|
||||
|
||||
let task = handle.update(&mut app, |_model, ctx| {
|
||||
let spawner = ctx.spawner();
|
||||
|
||||
// Background::spawn requires a 'static future, so this shows that we can move the
|
||||
// ModelSpawner without borrowing anything from the model or ModelContext.
|
||||
ctx.background_executor().spawn(async move {
|
||||
let result = spawner
|
||||
.spawn(move |me, _ctx| {
|
||||
me.count += 1;
|
||||
me.count
|
||||
})
|
||||
.await
|
||||
.expect("Spawn failed");
|
||||
assert_eq!(result, 1);
|
||||
|
||||
let result = spawner
|
||||
.spawn(move |me, _ctx| {
|
||||
me.count += 1;
|
||||
me.count
|
||||
})
|
||||
.await
|
||||
.expect("Spawn failed");
|
||||
assert_eq!(result, 2);
|
||||
})
|
||||
});
|
||||
|
||||
task.await.expect("should not fail to join with task");
|
||||
|
||||
handle.read(&app, |model, _| {
|
||||
assert_eq!(model.count, 2);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_spawner_dropped_model() {
|
||||
#[derive(Default)]
|
||||
struct Model {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Entity for Model {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let handle = app.add_model(|_| Model::default());
|
||||
|
||||
let spawner = handle.update(&mut app, |_model, ctx| ctx.spawner());
|
||||
|
||||
// Explicitly drop the model handle and allow the app to flush effects, removing the task subscriber.
|
||||
app.update(|_| drop(handle));
|
||||
|
||||
let result = spawner
|
||||
.spawn(|me, _ctx| {
|
||||
me.count += 1;
|
||||
me.count
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(ModelDropped));
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use std::{
|
||||
any::{type_name, TypeId},
|
||||
fmt::{self, Debug},
|
||||
hash::{Hash, Hasher},
|
||||
marker::PhantomData,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::{core::RefCounts, AppContext, Entity, EntityId, EntityLocation, Handle, ModelContext};
|
||||
|
||||
/// A strong reference to a particular [`Entity`] instance within the application.
|
||||
///
|
||||
/// Handles structures are used in place of references (e.g.: `&Entity`) to avoid
|
||||
/// the complexity of reference lifetimes and appeasing the borrow checker. A
|
||||
/// handle can be combined with a reference to the application state (e.g.:
|
||||
/// [`AppContext`]) to get access to the actual [`Entity`] instance behind the
|
||||
/// handle.
|
||||
pub struct ModelHandle<T> {
|
||||
model_id: EntityId,
|
||||
model_type: PhantomData<T>,
|
||||
ref_counts: Weak<Mutex<RefCounts>>,
|
||||
}
|
||||
|
||||
impl<T: Entity> ModelHandle<T> {
|
||||
pub(in crate::core) fn new(model_id: EntityId, ref_counts: &Arc<Mutex<RefCounts>>) -> Self {
|
||||
ref_counts.lock().inc_entity(model_id);
|
||||
Self {
|
||||
model_id,
|
||||
model_type: PhantomData,
|
||||
ref_counts: Arc::downgrade(ref_counts),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn downgrade(&self) -> WeakModelHandle<T> {
|
||||
WeakModelHandle::new(self.model_id)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> EntityId {
|
||||
self.model_id
|
||||
}
|
||||
|
||||
pub fn as_ref<'a, A: ModelAsRef>(&self, app: &'a A) -> &'a T {
|
||||
app.model(self)
|
||||
}
|
||||
|
||||
pub fn read<A, F, S>(&self, app: &A, read: F) -> S
|
||||
where
|
||||
A: ReadModel,
|
||||
F: FnOnce(&T, &AppContext) -> S,
|
||||
{
|
||||
app.read_model(self, read)
|
||||
}
|
||||
|
||||
pub fn update<A, F, S>(&self, app: &mut A, update: F) -> S
|
||||
where
|
||||
A: UpdateModel,
|
||||
F: FnOnce(&mut T, &mut ModelContext<T>) -> S,
|
||||
{
|
||||
app.update_model(self, update)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for ModelHandle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().inc_entity(self.model_id);
|
||||
}
|
||||
|
||||
Self {
|
||||
model_id: self.model_id,
|
||||
model_type: PhantomData,
|
||||
ref_counts: self.ref_counts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialEq for ModelHandle<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.model_id == other.model_id
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Eq for ModelHandle<T> {}
|
||||
|
||||
impl<T> Hash for ModelHandle<T> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.model_id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::borrow::Borrow<EntityId> for ModelHandle<T> {
|
||||
fn borrow(&self) -> &EntityId {
|
||||
&self.model_id
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for ModelHandle<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple(&format!("ModelHandle<{}>", type_name::<T>()))
|
||||
.field(&self.model_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl<T> Send for ModelHandle<T> {}
|
||||
unsafe impl<T> Sync for ModelHandle<T> {}
|
||||
|
||||
impl<T> Drop for ModelHandle<T> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().dec_model(self.model_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Handle<T> for ModelHandle<T> {
|
||||
fn id(&self) -> EntityId {
|
||||
self.model_id
|
||||
}
|
||||
|
||||
fn location(&self) -> EntityLocation {
|
||||
EntityLocation::Model(self.model_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased strong reference to a particular [`Entity`] instance within the
|
||||
/// application.
|
||||
///
|
||||
/// `AnyModelHandle` is used within the core UI framework in places where we need
|
||||
/// to hold a strong reference to a `Entity`, but don't want to add a generic type
|
||||
/// parameter to the containing structure. See `singleton_models` in
|
||||
/// [`AppContext`](crate::core::AppContext) for an example.
|
||||
pub struct AnyModelHandle {
|
||||
model_id: EntityId,
|
||||
model_type: TypeId,
|
||||
ref_counts: Weak<Mutex<RefCounts>>,
|
||||
}
|
||||
|
||||
impl AnyModelHandle {
|
||||
pub fn id(&self) -> EntityId {
|
||||
self.model_id
|
||||
}
|
||||
|
||||
pub fn is<T: 'static>(&self) -> bool {
|
||||
TypeId::of::<T>() == self.model_type
|
||||
}
|
||||
|
||||
pub fn downcast<T: Entity>(self) -> Option<ModelHandle<T>> {
|
||||
if self.is::<T>() {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
return Some(ModelHandle::new(self.model_id, &ref_counts));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn downcast_ref<'a, T: Entity>(&'a self, ctx: &'a AppContext) -> Option<&'a T> {
|
||||
if self.is::<T>() {
|
||||
return ctx.models.get(&self.model_id)?.as_any().downcast_ref();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for AnyModelHandle {
|
||||
fn clone(&self) -> Self {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().inc_entity(self.model_id);
|
||||
}
|
||||
|
||||
Self {
|
||||
model_id: self.model_id,
|
||||
model_type: self.model_type,
|
||||
ref_counts: self.ref_counts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Entity> From<ModelHandle<T>> for AnyModelHandle {
|
||||
fn from(handle: ModelHandle<T>) -> Self {
|
||||
if let Some(ref_counts) = handle.ref_counts.upgrade() {
|
||||
ref_counts.lock().inc_entity(handle.model_id);
|
||||
}
|
||||
|
||||
Self {
|
||||
model_id: handle.model_id,
|
||||
model_type: TypeId::of::<T>(),
|
||||
ref_counts: handle.ref_counts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AnyModelHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().dec_model(self.model_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A weak reference to a particular [`Entity`] instance within the application.
|
||||
///
|
||||
/// `WeakModelHandle` is useful when a view wants to hold onto its own handle -
|
||||
/// holding a strong reference via [`ModelHandle`] would create a reference
|
||||
/// cycle that prevents the application from ever dropping the model.
|
||||
pub struct WeakModelHandle<T> {
|
||||
model_id: EntityId,
|
||||
model_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: Entity> WeakModelHandle<T> {
|
||||
pub(super) fn new(model_id: EntityId) -> Self {
|
||||
Self {
|
||||
model_id,
|
||||
model_type: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upgrade(&self, app: &AppContext) -> Option<ModelHandle<T>> {
|
||||
if app.models.contains_key(&self.model_id) {
|
||||
Some(ModelHandle::new(self.model_id, &app.ref_counts))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for WeakModelHandle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
model_id: self.model_id,
|
||||
model_type: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ModelAsRef {
|
||||
fn model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T;
|
||||
}
|
||||
|
||||
pub trait ReadModel: ModelAsRef {
|
||||
fn read_model<T, F, S>(&self, handle: &ModelHandle<T>, read: F) -> S
|
||||
where
|
||||
T: Entity,
|
||||
F: FnOnce(&T, &AppContext) -> S;
|
||||
}
|
||||
|
||||
pub trait UpdateModel: ReadModel {
|
||||
fn update_model<T, F, S>(&mut self, handle: &ModelHandle<T>, update: F) -> S
|
||||
where
|
||||
T: Entity,
|
||||
F: FnOnce(&mut T, &mut ModelContext<T>) -> S;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
pub mod context;
|
||||
pub mod handle;
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
use crate::Entity;
|
||||
|
||||
pub use self::{context::*, handle::*};
|
||||
|
||||
pub trait AnyModel {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
impl<T> AnyModel for T
|
||||
where
|
||||
T: Entity,
|
||||
{
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transfer_view_to_window_updates_window_mapping() {
|
||||
#[derive(Default)]
|
||||
struct TestView {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
|
||||
let view_to_transfer = app.add_view(window_1_id, |_| TestView { value: 42 });
|
||||
let view_id = view_to_transfer.id();
|
||||
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
view_to_transfer.window_id(ctx),
|
||||
window_1_id,
|
||||
"view should initially be in window 1"
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
ctx.windows[&window_1_id].views.contains_key(&view_id),
|
||||
"view should be in window 1's views map"
|
||||
);
|
||||
assert!(
|
||||
!ctx.windows[&window_2_id].views.contains_key(&view_id),
|
||||
"view should not be in window 2's views map yet"
|
||||
);
|
||||
});
|
||||
|
||||
let success =
|
||||
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
|
||||
assert!(success, "transfer should succeed");
|
||||
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
view_to_transfer.window_id(ctx),
|
||||
window_2_id,
|
||||
"view should now be in window 2"
|
||||
);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!ctx.windows[&window_1_id].views.contains_key(&view_id),
|
||||
"view should no longer be in window 1's views map"
|
||||
);
|
||||
assert!(
|
||||
ctx.windows[&window_2_id].views.contains_key(&view_id),
|
||||
"view should now be in window 2's views map"
|
||||
);
|
||||
});
|
||||
|
||||
view_to_transfer.read(&app, |view, _| {
|
||||
assert_eq!(
|
||||
view.value, 42,
|
||||
"view data should be preserved after transfer"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_view_subscriptions_continue_working() {
|
||||
#[derive(Default)]
|
||||
struct EmitterView;
|
||||
|
||||
impl Entity for EmitterView {
|
||||
type Event = usize;
|
||||
}
|
||||
|
||||
impl View for EmitterView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"EmitterView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for EmitterView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SubscriberView {
|
||||
received_events: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Entity for SubscriberView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for SubscriberView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"SubscriberView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SubscriberView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| EmitterView);
|
||||
let (window_2_id, _) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| SubscriberView::default());
|
||||
|
||||
let emitter = app.add_view(window_1_id, |_| EmitterView);
|
||||
let subscriber = app.add_view(window_2_id, |_| SubscriberView::default());
|
||||
|
||||
subscriber.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_view(&emitter, |view, _, event, _| {
|
||||
view.received_events.push(*event);
|
||||
});
|
||||
});
|
||||
|
||||
emitter.update(&mut app, |_, ctx| ctx.emit(1));
|
||||
subscriber.read(&app, |view, _| {
|
||||
assert_eq!(
|
||||
view.received_events,
|
||||
vec![1],
|
||||
"should receive event before transfer"
|
||||
);
|
||||
});
|
||||
|
||||
let emitter_id = emitter.id();
|
||||
let success =
|
||||
app.update(|ctx| ctx.transfer_view_to_window(emitter_id, window_1_id, window_2_id));
|
||||
assert!(success, "transfer should succeed");
|
||||
|
||||
emitter.update(&mut app, |_, ctx| ctx.emit(2));
|
||||
subscriber.read(&app, |view, _| {
|
||||
assert_eq!(
|
||||
view.received_events,
|
||||
vec![1, 2],
|
||||
"should receive event after transfer"
|
||||
);
|
||||
});
|
||||
|
||||
emitter.update(&mut app, |_, ctx| ctx.emit(3));
|
||||
subscriber.read(&app, |view, _| {
|
||||
assert_eq!(
|
||||
view.received_events,
|
||||
vec![1, 2, 3],
|
||||
"should continue receiving events"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_view_app_subscriptions_continue_working() {
|
||||
#[derive(Default)]
|
||||
struct TestView;
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = usize;
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
|
||||
let emitter = app.add_view(window_1_id, |_| TestView);
|
||||
let received_events: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let received_events_clone = received_events.clone();
|
||||
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_view(&emitter, move |_view, event, _ctx| {
|
||||
received_events_clone.borrow_mut().push(*event);
|
||||
});
|
||||
});
|
||||
|
||||
emitter.update(&mut app, |_, ctx| ctx.emit(1));
|
||||
assert_eq!(
|
||||
*received_events.borrow(),
|
||||
vec![1],
|
||||
"should receive event before transfer"
|
||||
);
|
||||
|
||||
let emitter_id = emitter.id();
|
||||
let success =
|
||||
app.update(|ctx| ctx.transfer_view_to_window(emitter_id, window_1_id, window_2_id));
|
||||
assert!(success, "transfer should succeed");
|
||||
|
||||
emitter.update(&mut app, |_, ctx| ctx.emit(2));
|
||||
assert_eq!(
|
||||
*received_events.borrow(),
|
||||
vec![1, 2],
|
||||
"should receive event after transfer"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_view_observations_continue_working() {
|
||||
#[derive(Default)]
|
||||
struct ObserverView {
|
||||
observed_counts: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Entity for ObserverView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for ObserverView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"ObserverView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ObserverView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ObservedModel {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Entity for ObservedModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| ObserverView::default());
|
||||
let (window_2_id, _) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| ObserverView::default());
|
||||
|
||||
let model = app.add_model(|_| ObservedModel { count: 0 });
|
||||
let observer = app.add_view(window_1_id, |_| ObserverView::default());
|
||||
|
||||
observer.update(&mut app, |_, ctx| {
|
||||
ctx.observe(&model, |view, observed, ctx| {
|
||||
view.observed_counts.push(observed.as_ref(ctx).count);
|
||||
});
|
||||
});
|
||||
|
||||
model.update(&mut app, |m, ctx| {
|
||||
m.count = 1;
|
||||
ctx.notify();
|
||||
});
|
||||
observer.read(&app, |view, _| {
|
||||
assert_eq!(
|
||||
view.observed_counts,
|
||||
vec![1],
|
||||
"should observe before transfer"
|
||||
);
|
||||
});
|
||||
|
||||
let observer_id = observer.id();
|
||||
let success =
|
||||
app.update(|ctx| ctx.transfer_view_to_window(observer_id, window_1_id, window_2_id));
|
||||
assert!(success, "transfer should succeed");
|
||||
|
||||
model.update(&mut app, |m, ctx| {
|
||||
m.count = 2;
|
||||
ctx.notify();
|
||||
});
|
||||
observer.read(&app, |view, _| {
|
||||
assert_eq!(
|
||||
view.observed_counts,
|
||||
vec![1, 2],
|
||||
"should observe after transfer"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_on_window_transferred_callback_fires() {
|
||||
struct TransferTrackingView {
|
||||
transfer_events: Vec<(WindowId, WindowId)>,
|
||||
}
|
||||
|
||||
impl Entity for TransferTrackingView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TransferTrackingView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TransferTrackingView"
|
||||
}
|
||||
|
||||
fn on_window_transferred(
|
||||
&mut self,
|
||||
source_window_id: WindowId,
|
||||
target_window_id: WindowId,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.transfer_events
|
||||
.push((source_window_id, target_window_id));
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TransferTrackingView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| TransferTrackingView {
|
||||
transfer_events: Vec::new(),
|
||||
});
|
||||
let (window_2_id, _) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| TransferTrackingView {
|
||||
transfer_events: Vec::new(),
|
||||
});
|
||||
|
||||
let view = app.add_view(window_1_id, |_| TransferTrackingView {
|
||||
transfer_events: Vec::new(),
|
||||
});
|
||||
|
||||
view.read(&app, |v, _| {
|
||||
assert!(v.transfer_events.is_empty(), "no transfers yet");
|
||||
});
|
||||
|
||||
let view_id = view.id();
|
||||
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
|
||||
|
||||
view.read(&app, |v, _| {
|
||||
assert_eq!(
|
||||
v.transfer_events,
|
||||
vec![(window_1_id, window_2_id)],
|
||||
"callback should fire with correct window IDs"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weak_view_handle_upgrade_after_transfer() {
|
||||
#[derive(Default)]
|
||||
struct TestView {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
|
||||
let view = app.add_view(window_1_id, |_| TestView { value: 42 });
|
||||
let weak = view.downgrade();
|
||||
|
||||
app.read(|ctx| {
|
||||
let upgraded = weak.upgrade(ctx);
|
||||
assert!(
|
||||
upgraded.is_some(),
|
||||
"weak handle should upgrade before transfer"
|
||||
);
|
||||
assert_eq!(
|
||||
upgraded.as_ref().map(|v| v.window_id(ctx)),
|
||||
Some(window_1_id)
|
||||
);
|
||||
});
|
||||
|
||||
let view_id = view.id();
|
||||
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
|
||||
|
||||
app.read(|ctx| {
|
||||
let upgraded = weak.upgrade(ctx);
|
||||
assert!(
|
||||
upgraded.is_some(),
|
||||
"weak handle should upgrade after transfer"
|
||||
);
|
||||
assert_eq!(
|
||||
upgraded.as_ref().map(|v| v.window_id(ctx)),
|
||||
Some(window_2_id),
|
||||
"upgraded handle should point to new window"
|
||||
);
|
||||
});
|
||||
|
||||
view.read(&app, |v, _| {
|
||||
assert_eq!(v.value, 42, "view data preserved");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_nonexistent_view_returns_false() {
|
||||
#[derive(Default)]
|
||||
struct TestView;
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
|
||||
let fake_view_id = EntityId::new();
|
||||
let success =
|
||||
app.update(|ctx| ctx.transfer_view_to_window(fake_view_id, window_1_id, window_2_id));
|
||||
assert!(!success, "transfer of nonexistent view should return false");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_to_same_window_is_noop() {
|
||||
#[derive(Default)]
|
||||
struct TestView {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = usize;
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
|
||||
let view = app.add_view(window_id, |_| TestView { value: 42 });
|
||||
let view_id = view.id();
|
||||
|
||||
let success = app.update(|ctx| ctx.transfer_view_to_window(view_id, window_id, window_id));
|
||||
assert!(success, "transfer to same window should return true");
|
||||
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
view.window_id(ctx),
|
||||
window_id,
|
||||
"view should still be in same window"
|
||||
);
|
||||
assert!(
|
||||
ctx.windows[&window_id].views.contains_key(&view_id),
|
||||
"view should still be in window's views map"
|
||||
);
|
||||
});
|
||||
|
||||
view.update(&mut app, |_, ctx| ctx.emit(1));
|
||||
view.read(&app, |v, _| {
|
||||
assert_eq!(v.value, 42, "view should still work normally");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_view_drop_and_reference_counting() {
|
||||
#[derive(Default)]
|
||||
struct TestView {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
|
||||
let view = app.add_view(window_1_id, |_| TestView { value: 42 });
|
||||
let view_id = view.id();
|
||||
let view_clone = view.clone();
|
||||
|
||||
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
|
||||
|
||||
drop(view_clone);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
ctx.windows[&window_2_id].views.contains_key(&view_id),
|
||||
"view should still exist after dropping one handle"
|
||||
);
|
||||
assert!(
|
||||
ctx.view_to_window.contains_key(&view_id),
|
||||
"view_to_window mapping should still exist"
|
||||
);
|
||||
});
|
||||
|
||||
view.read(&app, |v, _| {
|
||||
assert_eq!(v.value, 42, "view data should be intact");
|
||||
});
|
||||
|
||||
drop(view);
|
||||
|
||||
// Trigger cleanup via app.update which calls flush_effects -> remove_dropped_items
|
||||
app.update(|_| {});
|
||||
|
||||
// Verify the view was removed from the correct window (window_2, not window_1)
|
||||
// and that the view_to_window mapping was cleaned up
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!ctx.windows[&window_1_id].views.contains_key(&view_id),
|
||||
"view should not be in original window"
|
||||
);
|
||||
assert!(
|
||||
!ctx.windows[&window_2_id].views.contains_key(&view_id),
|
||||
"view should be removed from target window after drop"
|
||||
);
|
||||
assert!(
|
||||
!ctx.view_to_window.contains_key(&view_id),
|
||||
"view_to_window mapping should be cleaned up"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_structural_children_follows_parent() {
|
||||
#[derive(Default)]
|
||||
struct TestView {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, root_1) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
|
||||
let parent = root_1.update(&mut app, |_, ctx| {
|
||||
ctx.add_typed_action_view(|_| TestView { value: 1 })
|
||||
});
|
||||
|
||||
let structural_child = parent.update(&mut app, |_, ctx| {
|
||||
ctx.add_typed_action_view(|_| TestView { value: 2 })
|
||||
});
|
||||
|
||||
let parent_id = parent.id();
|
||||
let child_id = structural_child.id();
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
ctx.windows[&window_1_id].views.contains_key(&child_id),
|
||||
"child should initially be in window 1"
|
||||
);
|
||||
});
|
||||
|
||||
let transferred =
|
||||
app.update(|ctx| ctx.transfer_view_tree_to_window(parent_id, window_1_id, window_2_id));
|
||||
|
||||
assert!(
|
||||
transferred.contains(&parent_id),
|
||||
"parent should be in transferred list"
|
||||
);
|
||||
assert!(
|
||||
transferred.contains(&child_id),
|
||||
"structural child should be in transferred list"
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
ctx.windows[&window_2_id].views.contains_key(&parent_id),
|
||||
"parent should be in window 2"
|
||||
);
|
||||
assert!(
|
||||
ctx.windows[&window_2_id].views.contains_key(&child_id),
|
||||
"structural child should be in window 2"
|
||||
);
|
||||
assert!(
|
||||
!ctx.windows[&window_1_id].views.contains_key(&parent_id),
|
||||
"parent should no longer be in window 1"
|
||||
);
|
||||
assert!(
|
||||
!ctx.windows[&window_1_id].views.contains_key(&child_id),
|
||||
"structural child should no longer be in window 1"
|
||||
);
|
||||
});
|
||||
|
||||
structural_child.read(&app, |v, _| {
|
||||
assert_eq!(v.value, 2, "structural child data should be preserved");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_structural_grandchildren_follows_transitively() {
|
||||
#[derive(Default)]
|
||||
struct TestView {
|
||||
value: usize,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, root_1) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
|
||||
|
||||
let parent = root_1.update(&mut app, |_, ctx| {
|
||||
ctx.add_typed_action_view(|_| TestView { value: 1 })
|
||||
});
|
||||
|
||||
let child = parent.update(&mut app, |_, ctx| {
|
||||
ctx.add_typed_action_view(|_| TestView { value: 2 })
|
||||
});
|
||||
|
||||
let grandchild = child.update(&mut app, |_, ctx| {
|
||||
ctx.add_typed_action_view(|_| TestView { value: 3 })
|
||||
});
|
||||
|
||||
let parent_id = parent.id();
|
||||
let child_id = child.id();
|
||||
let grandchild_id = grandchild.id();
|
||||
|
||||
let transferred =
|
||||
app.update(|ctx| ctx.transfer_view_tree_to_window(parent_id, window_1_id, window_2_id));
|
||||
|
||||
assert!(
|
||||
transferred.contains(&parent_id),
|
||||
"parent should be transferred"
|
||||
);
|
||||
assert!(
|
||||
transferred.contains(&child_id),
|
||||
"child should be transferred"
|
||||
);
|
||||
assert!(
|
||||
transferred.contains(&grandchild_id),
|
||||
"grandchild should be transferred transitively"
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(ctx.windows[&window_2_id].views.contains_key(&grandchild_id));
|
||||
assert!(!ctx.windows[&window_1_id].views.contains_key(&grandchild_id));
|
||||
});
|
||||
|
||||
grandchild.read(&app, |v, _| {
|
||||
assert_eq!(v.value, 3, "grandchild data should be preserved");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_structural_children_does_not_move_unrelated_views() {
|
||||
#[derive(Default)]
|
||||
struct TestView;
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"TestView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (window_1_id, root_1) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
|
||||
|
||||
let parent = root_1.update(&mut app, |_, ctx| ctx.add_typed_action_view(|_| TestView));
|
||||
|
||||
let structural_child =
|
||||
parent.update(&mut app, |_, ctx| ctx.add_typed_action_view(|_| TestView));
|
||||
|
||||
let unrelated = app.add_view(window_1_id, |_| TestView);
|
||||
|
||||
let parent_id = parent.id();
|
||||
let child_id = structural_child.id();
|
||||
let unrelated_id = unrelated.id();
|
||||
|
||||
let transferred =
|
||||
app.update(|ctx| ctx.transfer_view_tree_to_window(parent_id, window_1_id, window_2_id));
|
||||
|
||||
assert!(
|
||||
transferred.contains(&parent_id),
|
||||
"parent should be transferred"
|
||||
);
|
||||
assert!(
|
||||
transferred.contains(&child_id),
|
||||
"structural child should be transferred"
|
||||
);
|
||||
assert!(
|
||||
!transferred.contains(&unrelated_id),
|
||||
"unrelated view should NOT be transferred"
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
ctx.windows[&window_1_id].views.contains_key(&unrelated_id),
|
||||
"unrelated view should remain in window 1"
|
||||
);
|
||||
assert!(
|
||||
!ctx.windows[&window_2_id].views.contains_key(&unrelated_id),
|
||||
"unrelated view should NOT be in window 2"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
use std::{any::Any, marker::PhantomData, rc::Rc, sync::Arc};
|
||||
|
||||
use futures::future::{AbortHandle, Abortable};
|
||||
use futures::{Future, FutureExt};
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::modals::{AlertDialogWithCallbacks, ModalButton, ViewModalCallback};
|
||||
use crate::platform::{
|
||||
file_picker::{FilePickerConfiguration, FilePickerError},
|
||||
Cursor, SaveFilePickerConfiguration, TerminationMode,
|
||||
};
|
||||
use crate::r#async::SpawnableOutput;
|
||||
use crate::windowing::WindowManager;
|
||||
use crate::{
|
||||
accessibility::AccessibilityContent,
|
||||
core::{Observation, Subscription, SubscriptionKey, TaskCallback},
|
||||
fonts::Cache as FontCache,
|
||||
notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification},
|
||||
r#async::{
|
||||
executor::{Background, Foreground},
|
||||
SpawnedFutureHandle, SpawnedLocalStream,
|
||||
},
|
||||
Action, AppContext, Effect, Entity, EntityId, ModelAsRef, ModelContext, ModelHandle,
|
||||
UpdateModel, WindowId,
|
||||
};
|
||||
use crate::{GetSingletonModelHandle, ReadModel};
|
||||
|
||||
use super::{
|
||||
handle::{AnyViewHandle, ReadView, UpdateView, ViewAsRef, ViewHandle, WeakViewHandle},
|
||||
TypedActionView, View,
|
||||
};
|
||||
|
||||
/// Structure that combines view identifiers and a handle to the application
|
||||
/// context/application state.
|
||||
pub struct ViewContext<'a, T: ?Sized> {
|
||||
app: &'a mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
view_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<'a, T: View> ViewContext<'a, T> {
|
||||
pub(in crate::core) fn new(
|
||||
app: &'a mut AppContext,
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
) -> Self {
|
||||
Self {
|
||||
app,
|
||||
window_id,
|
||||
view_id,
|
||||
view_type: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a callback that will be invoked immediately after the next frame is drawn.
|
||||
/// Note that the callback is only invoked once and is discarded after it is called.
|
||||
pub fn on_next_frame_drawn<F: 'static + Fn()>(&mut self, callback: F) {
|
||||
self.app.on_next_frame_drawn(self.window_id, callback);
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> WeakViewHandle<T> {
|
||||
WeakViewHandle::new(self.view_id)
|
||||
}
|
||||
|
||||
pub fn window_id(&self) -> WindowId {
|
||||
self.window_id
|
||||
}
|
||||
|
||||
pub fn view_id(&self) -> EntityId {
|
||||
self.view_id
|
||||
}
|
||||
|
||||
pub fn font_cache(&self) -> &FontCache {
|
||||
self.app.font_cache()
|
||||
}
|
||||
|
||||
pub fn windows(&self) -> &WindowManager {
|
||||
self.app.windows()
|
||||
}
|
||||
|
||||
pub fn disable_key_bindings_dispatching(&mut self) {
|
||||
let window_id = self.window_id;
|
||||
log::info!("disabling actions for window id {window_id}");
|
||||
self.disable_key_bindings(window_id)
|
||||
}
|
||||
|
||||
pub fn enable_key_bindings_dispatching(&mut self) {
|
||||
let window_id = self.window_id;
|
||||
log::info!("enabling actions for window id {window_id}");
|
||||
self.enable_key_bindings(window_id)
|
||||
}
|
||||
|
||||
// Function to check if the parent view is focused.
|
||||
pub fn is_self_focused(&self) -> bool {
|
||||
self.app.check_view_focused(self.window_id, &self.view_id)
|
||||
}
|
||||
|
||||
pub fn is_self_or_child_focused(&self) -> bool {
|
||||
self.app
|
||||
.check_view_or_child_focused(self.window_id, &self.view_id)
|
||||
}
|
||||
|
||||
pub fn element_position_by_id<S>(&self, id: S) -> Option<RectF>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let presenter = self.app.presenter(self.window_id);
|
||||
|
||||
if let Some(presenter) = presenter {
|
||||
let borrowed_presenter = presenter.borrow();
|
||||
borrowed_presenter.position_cache().get_position(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus<S: View>(&mut self, handle: &ViewHandle<S>) {
|
||||
let handle: AnyViewHandle = handle.into();
|
||||
self.app.pending_effects.push_back(Effect::Focus {
|
||||
window_id: handle.window_id(self.app),
|
||||
view_id: handle.id(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn focus_self(&mut self) {
|
||||
self.app.pending_effects.push_back(Effect::Focus {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_model<S, F>(&mut self, build_model: F) -> ModelHandle<S>
|
||||
where
|
||||
S: Entity,
|
||||
F: FnOnce(&mut ModelContext<S>) -> S,
|
||||
{
|
||||
self.app.add_model(build_model)
|
||||
}
|
||||
|
||||
pub fn add_view<S, F>(&mut self, build_view: F) -> ViewHandle<S>
|
||||
where
|
||||
S: View,
|
||||
F: FnOnce(&mut ViewContext<S>) -> S,
|
||||
{
|
||||
self.app.add_view(self.window_id, build_view)
|
||||
}
|
||||
|
||||
pub fn add_typed_action_view<V, F>(&mut self, build_view: F) -> ViewHandle<V>
|
||||
where
|
||||
V: TypedActionView + View,
|
||||
F: FnOnce(&mut ViewContext<V>) -> V,
|
||||
{
|
||||
// Add a new view, and set the parent view as the current context's view.
|
||||
self.app
|
||||
.add_typed_action_view_with_parent(self.window_id, build_view, self.view_id)
|
||||
}
|
||||
|
||||
pub fn add_option_view<S, F>(&mut self, build_view: F) -> Option<ViewHandle<S>>
|
||||
where
|
||||
S: View,
|
||||
F: FnOnce(&mut ViewContext<S>) -> Option<S>,
|
||||
{
|
||||
self.app.add_option_view(self.window_id, build_view)
|
||||
}
|
||||
|
||||
pub fn subscribe_to_model<E, F>(&mut self, handle: &ModelHandle<E>, mut callback: F)
|
||||
where
|
||||
E: Entity,
|
||||
E::Event: 'static,
|
||||
F: 'static + FnMut(&mut T, ModelHandle<E>, &E::Event, &mut ViewContext<T>),
|
||||
{
|
||||
let emitter_handle = handle.downgrade();
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(handle.id())
|
||||
.or_default()
|
||||
.push(Subscription::FromView {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
callback: Box::new(move |view, payload, app, window_id, view_id| {
|
||||
if let Some(emitter_handle) = emitter_handle.upgrade(app) {
|
||||
let model = view.downcast_mut().expect("downcast is type safe");
|
||||
let payload = payload.downcast_ref().expect("downcast is type safe");
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
callback(model, emitter_handle, payload, &mut ctx);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn subscribe_to_view<V, F>(&mut self, handle: &ViewHandle<V>, mut callback: F)
|
||||
where
|
||||
V: View,
|
||||
V::Event: 'static,
|
||||
F: 'static + FnMut(&mut T, ViewHandle<V>, &V::Event, &mut ViewContext<T>),
|
||||
{
|
||||
let emitter_handle = handle.downgrade();
|
||||
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(handle.id())
|
||||
.or_default()
|
||||
.push(Subscription::FromView {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
callback: Box::new(move |view, payload, app, window_id, view_id| {
|
||||
if let Some(emitter_handle) = emitter_handle.upgrade(app) {
|
||||
let model = view.downcast_mut().expect("downcast is type safe");
|
||||
let payload = payload.downcast_ref().expect("downcast is type safe");
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
callback(model, emitter_handle, payload, &mut ctx);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn unsubscribe_to_view<V>(&mut self, handle: &ViewHandle<V>)
|
||||
where
|
||||
V: View,
|
||||
V::Event: 'static,
|
||||
{
|
||||
let target_entity = handle.id();
|
||||
|
||||
// If we're currently emitting events for this entity, defer the unsubscribe.
|
||||
if let Some(ref mut pending) = self.app.pending_unsubscribes {
|
||||
if pending.entity_id == target_entity {
|
||||
pending
|
||||
.keys
|
||||
.insert(SubscriptionKey::View(self.window_id, self.view_id));
|
||||
|
||||
// Remove subscriptions created earlier in this emission so subscribe-then-unsubscribe ordering is preserved.
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.app.subscriptions.entry(target_entity)
|
||||
{
|
||||
entry.get_mut().retain(|subscription| match subscription {
|
||||
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromView {
|
||||
window_id, view_id, ..
|
||||
} => *window_id != self.window_id || *view_id != self.view_id,
|
||||
});
|
||||
|
||||
if entry.get().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise process immediately.
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(target_entity)
|
||||
.or_default()
|
||||
.retain(|subscription| match subscription {
|
||||
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromView {
|
||||
window_id, view_id, ..
|
||||
} => *window_id != self.window_id || *view_id != self.view_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn unsubscribe_to_model<E>(&mut self, handle: &ModelHandle<E>)
|
||||
where
|
||||
E: Entity,
|
||||
E::Event: 'static,
|
||||
{
|
||||
let target_entity = handle.id();
|
||||
|
||||
// If we're currently emitting events for this entity, defer the unsubscribe.
|
||||
if let Some(ref mut pending) = self.app.pending_unsubscribes {
|
||||
if pending.entity_id == target_entity {
|
||||
pending
|
||||
.keys
|
||||
.insert(SubscriptionKey::View(self.window_id, self.view_id));
|
||||
|
||||
// Remove subscriptions created earlier in this emission so subscribe-then-unsubscribe ordering is preserved.
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.app.subscriptions.entry(target_entity)
|
||||
{
|
||||
entry.get_mut().retain(|subscription| match subscription {
|
||||
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromView {
|
||||
window_id, view_id, ..
|
||||
} => *window_id != self.window_id || *view_id != self.view_id,
|
||||
});
|
||||
|
||||
if entry.get().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise process immediately.
|
||||
self.app
|
||||
.subscriptions
|
||||
.entry(target_entity)
|
||||
.or_default()
|
||||
.retain(|subscription| match subscription {
|
||||
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
|
||||
Subscription::FromView {
|
||||
window_id, view_id, ..
|
||||
} => *window_id != self.window_id || *view_id != self.view_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prompt the user to pick file path(s) in the OS native file picker.
|
||||
pub fn open_file_picker(
|
||||
&mut self,
|
||||
callback: impl FnOnce(Result<Vec<String>, FilePickerError>, &mut ViewContext<T>)
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
config: FilePickerConfiguration,
|
||||
) {
|
||||
let window_id = self.window_id;
|
||||
let view_id = self.view_id;
|
||||
self.app.open_file_picker(
|
||||
move |result, app| {
|
||||
let mut view_context = ViewContext::new(app, window_id, view_id);
|
||||
callback(result, &mut view_context)
|
||||
},
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
/// Prompt the user to save a file with the OS native save file dialog.
|
||||
///
|
||||
/// The callback receives the chosen path (or `None` if cancelled), a mutable
|
||||
/// reference to the owning view, and its `ViewContext`.
|
||||
pub fn open_save_file_picker(
|
||||
&mut self,
|
||||
callback: impl FnOnce(Option<String>, &mut T, &mut ViewContext<T>) + Send + Sync + 'static,
|
||||
config: SaveFilePickerConfiguration,
|
||||
) {
|
||||
let view_id = self.view_id;
|
||||
self.app.open_save_file_picker(
|
||||
move |path, app| {
|
||||
let weak = WeakViewHandle::<T>::new(view_id);
|
||||
if let Some(handle) = weak.upgrade(app) {
|
||||
app.update_view(&handle, |view, ctx| {
|
||||
callback(path, view, ctx);
|
||||
});
|
||||
}
|
||||
},
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
/// Emits the provided event on this `View`.
|
||||
///
|
||||
/// Unlike DOM events, these events don't bubble or otherwise automatically
|
||||
/// propagate themselves up the view hierarchy. In order for another view
|
||||
/// to receive any events emitted by this view, the receiver will need to
|
||||
/// explicitly subscribe to this view's events by calling
|
||||
/// [`Self::subscribe_to_view()`][^note].
|
||||
///
|
||||
/// [^note]: This subscription is on a per-instance basis, not on a per-type
|
||||
/// basis.
|
||||
pub fn emit(&mut self, payload: T::Event) {
|
||||
self.app.pending_effects.push_back(Effect::Event {
|
||||
entity_id: self.view_id,
|
||||
payload: Box::new(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/// When all else fails, `emit_a11y_content` comes to the rescue! In our UI framework, some stuff is just an Event, and not an Action. Sometimes a different View emits the event, while another one handles it… So instead of solving this, we simply use `emit_a11y_content(&mut self, content: AccessibilityContent)` on demand, meaning, whenever something meaningful happens and it’s not related to Action or View focusing, we should emit a11y content. A good example for this is announcing that a new update is available.
|
||||
///
|
||||
/// ### When and how to use it?
|
||||
/// Whenever we feel like it’s important to make an announcement about events in the app. Note that this requires a ViewContext.
|
||||
pub fn emit_a11y_content(&mut self, content: AccessibilityContent) {
|
||||
let verbosity = self.a11y_verbosity;
|
||||
self.platform_delegate
|
||||
.set_accessibility_contents(content.with_verbosity(verbosity));
|
||||
}
|
||||
|
||||
/// Delegates to the OS to request the user attention for the given window. For mac this bounces the
|
||||
/// icon in the dock. If the window is already focused this is a noop.
|
||||
pub fn request_user_attention(&mut self) {
|
||||
let window_id = self.window_id;
|
||||
self.app.request_user_attention(window_id);
|
||||
}
|
||||
|
||||
/// Global actions are being phased out. Prefer dispatching typed actions instead of global actions.
|
||||
/// Dispatch a global action to be handled by the registered handler
|
||||
///
|
||||
/// Note: The dispatch of the global action will be registered as an effect and flushed after
|
||||
/// the current view update is complete. This will ensure that the view has been re-inserted
|
||||
/// into the `AppContext`, so it will be accessible to the global action, if necessary
|
||||
#[track_caller]
|
||||
pub fn dispatch_global_action<A: Any>(&mut self, name: &'static str, arg: A) {
|
||||
let location = std::panic::Location::caller();
|
||||
self.app.pending_effects.push_back(Effect::GlobalAction {
|
||||
name,
|
||||
location,
|
||||
arg: Box::new(arg),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn dispatch_typed_action(&mut self, action: &dyn Action) {
|
||||
let window_id = self.window_id;
|
||||
let view_id = self.view_id;
|
||||
self.dispatch_typed_action_for_view(window_id, view_id, action);
|
||||
}
|
||||
|
||||
/// Defers dispatching a typed action until effects are flushed.
|
||||
///
|
||||
/// This is useful to avoid re-entrant view updates (e.g. triggering UI updates
|
||||
/// while a view in the responder chain is still mid-update).
|
||||
pub fn dispatch_typed_action_deferred<A: Action + 'static>(&mut self, action: A) {
|
||||
self.app.pending_effects.push_back(Effect::TypedAction {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
action: Box::new(action),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn observe<S, F>(&mut self, handle: &ModelHandle<S>, mut callback: F)
|
||||
where
|
||||
S: Entity,
|
||||
F: 'static + FnMut(&mut T, ModelHandle<S>, &mut ViewContext<T>),
|
||||
{
|
||||
self.app
|
||||
.observations
|
||||
.entry(handle.id())
|
||||
.or_default()
|
||||
.push(Observation::FromView {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
callback: Box::new(move |view, observed_id, app, window_id, view_id| {
|
||||
let view = view.downcast_mut().expect("downcast is type safe");
|
||||
let observed = ModelHandle::new(observed_id, &app.ref_counts);
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
callback(view, observed, &mut ctx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// Notifies the framework that this view is dirty and needs to be
|
||||
/// re-rendered.
|
||||
///
|
||||
/// "Dirtiness" only applies to this specific instance, and not the entire
|
||||
/// view hierarchy rooted at this view. Each dirty child view also needs to
|
||||
/// have `ctx.notify()` called on the child's `ViewContext` in order for the
|
||||
/// child to be re-rendered.
|
||||
pub fn notify(&mut self) {
|
||||
self.app
|
||||
.pending_effects
|
||||
.push_back(Effect::ViewNotification {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
});
|
||||
}
|
||||
|
||||
/// Requests permissions to send desktop notifications. The `on_completion callback` can be invoked to
|
||||
/// propagate the outcome of the request (accepted/denied/other) back to the app.
|
||||
///
|
||||
/// ## Platform-Specific
|
||||
/// * Linux: Always calls the `on_completion_callback` with a value of [`RequestPermissionsOutcome::Accepted`].
|
||||
pub fn request_desktop_notification_permissions<F>(&mut self, on_completion_callback: F)
|
||||
where
|
||||
F: 'static + Send + Sync + FnOnce(&mut T, RequestPermissionsOutcome, &mut ViewContext<T>),
|
||||
{
|
||||
let view_id = self.view_id;
|
||||
let window_id = self.window_id;
|
||||
self.app.request_desktop_notification_permissions(
|
||||
view_id,
|
||||
window_id,
|
||||
on_completion_callback,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends a desktop notification. The `on_error_callback` can be invoked to
|
||||
/// propagate an error to the view that initiated the notification send.
|
||||
pub fn send_desktop_notification<F>(&mut self, content: UserNotification, on_error_callback: F)
|
||||
where
|
||||
F: 'static + Send + Sync + FnOnce(&mut T, NotificationSendError, &mut ViewContext<T>),
|
||||
{
|
||||
let view_id = self.view_id;
|
||||
let window_id = self.window_id;
|
||||
self.app
|
||||
.send_desktop_notification(content, view_id, window_id, on_error_callback);
|
||||
}
|
||||
|
||||
/// Schedules a future to run on the main thread, invoking a callback on the
|
||||
/// main thread upon completion.
|
||||
///
|
||||
/// The callback receives the output of the future, if any, in addition to
|
||||
/// mutable references to the spawning view and its context, allowing for
|
||||
/// dirtying of the view (via [`Self::notify()`]) if appropriate.
|
||||
///
|
||||
/// This is private to [`ViewContext`] because we shouldn't ever need to
|
||||
/// poll a future on the main thread. Currently, the only use is by
|
||||
/// [`Self::spawn()`] in order to pass the results of the background task to
|
||||
/// a callback executed on the main thread.
|
||||
///
|
||||
/// TODO(vorporeal): Determine how best to eliminate this function and move
|
||||
/// the relevant logic into `spawn()`.
|
||||
fn spawn_local<S, F, U>(&mut self, future: S, callback: F) -> impl Future<Output = ()>
|
||||
where
|
||||
S: 'static + Future,
|
||||
F: 'static + FnOnce(&mut T, S::Output, &mut ViewContext<T>) -> U,
|
||||
U: 'static,
|
||||
{
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let task_id = self.app.spawn_local(future);
|
||||
|
||||
self.app.task_callbacks.insert(
|
||||
task_id,
|
||||
TaskCallback::ViewFromFuture {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
callback: Box::new(move |view, output, app, window_id, view_id| {
|
||||
let view = view.as_any_mut().downcast_mut().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
|
||||
let output = *output.downcast().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
|
||||
let result =
|
||||
callback(view, output, &mut ViewContext::new(app, window_id, view_id));
|
||||
let _ = tx.send(result);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
async move {
|
||||
if rx.await.is_err() {
|
||||
log::error!("sender unexpectedly dropped before receiver");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a future to run on a background thread, invoking a callback on
|
||||
/// the _main_ thread upon completion.
|
||||
///
|
||||
/// This function is useful in situations where a long-running process needs
|
||||
/// to occur (e.g.: a network request), after which the view needs to be
|
||||
/// updated based on the result.
|
||||
///
|
||||
/// The callback receives the output of the future, if any, in addition to
|
||||
/// mutable references to the spawning view and its context, allowing for
|
||||
/// dirtying of the view (via [`Self::notify`]) if appropriate.
|
||||
///
|
||||
/// The future can be aborted by calling `abort` on the returned `SpawnedFutureHandle`. Note the
|
||||
/// future will only be aborted the _next_ time the future is polled.
|
||||
///
|
||||
/// See [`Self::spawn_abortable`] for an alternative version of this function that accepts an
|
||||
/// `on_abort` function that is called when the future is aborted.
|
||||
pub fn spawn<S, F, U>(&mut self, future: S, callback: F) -> SpawnedFutureHandle
|
||||
where
|
||||
S: crate::r#async::Spawnable,
|
||||
<S as Future>::Output: crate::r#async::SpawnableOutput,
|
||||
F: 'static + FnOnce(&mut T, <S as Future>::Output, &mut ViewContext<T>) -> U,
|
||||
U: 'static,
|
||||
{
|
||||
self.spawn_abortable::<S, _, _>(
|
||||
future,
|
||||
|view, output, ctx| {
|
||||
callback(view, output, ctx);
|
||||
},
|
||||
|_, _| {},
|
||||
)
|
||||
}
|
||||
|
||||
/// Schedules a future to run on a background thread, invoking the `on_resolve`
|
||||
/// callback on the _main_ thread upon completion. If the future is aborted, the
|
||||
/// `on_abort` function is called.
|
||||
///
|
||||
/// This function is useful in situations where a long-running process needs
|
||||
/// to occur (e.g.: a network request), after which the view needs to be
|
||||
/// updated based on the result.
|
||||
///
|
||||
/// The `on_resolve` callback receives the output of the future, if any, in addition to
|
||||
/// mutable references to the spawning view and its context, allowing for
|
||||
/// dirtying of the model (via [`Self::notify`]) if appropriate.
|
||||
///
|
||||
/// The future can be aborted by calling `abort` on the returned `SpawnedFutureHandle`. Note, a
|
||||
/// future is not immediately killed on `abort`--it will only be aborted once the future's
|
||||
/// `poll` method returns.
|
||||
///
|
||||
/// See [`Self::spawn`] for an alternative version of this function that doesn't
|
||||
/// require a callback if/when the future is aborted.
|
||||
pub fn spawn_abortable<S, F, A>(
|
||||
&mut self,
|
||||
future: S,
|
||||
on_resolve: F,
|
||||
on_abort: A,
|
||||
) -> SpawnedFutureHandle
|
||||
where
|
||||
S: crate::r#async::Spawnable,
|
||||
<S as Future>::Output: crate::r#async::SpawnableOutput,
|
||||
F: 'static + FnOnce(&mut T, <S as Future>::Output, &mut ViewContext<T>),
|
||||
A: 'static + FnOnce(&mut T, &mut ViewContext<T>),
|
||||
{
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let (abort_handle, abort_registration) = AbortHandle::new_pair();
|
||||
self.app
|
||||
.background_executor()
|
||||
.spawn_boxed(Box::pin(async move {
|
||||
let abortable = Abortable::new(future, abort_registration);
|
||||
if tx.send(abortable.await).is_err() {
|
||||
log::error!("Error sending background task result to main thread",);
|
||||
}
|
||||
}))
|
||||
.detach();
|
||||
|
||||
let future = self.spawn_local(rx, |view, rx_result, ctx| {
|
||||
let output = match rx_result {
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
log::error!("sender unexpectedly dropped before receiver");
|
||||
on_abort(view, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Call the appropriate callback based on the output of resolving the future. If the
|
||||
// future returned `Ok`, the future was not aborted so we can call `on_resolve`. If
|
||||
// the future returned `Err`--the future was aborted.
|
||||
match output {
|
||||
Ok(output) => on_resolve(view, output, ctx),
|
||||
Err(_) => on_abort(view, ctx),
|
||||
}
|
||||
});
|
||||
|
||||
let future_id = self.app.register_spawned_future(future.boxed());
|
||||
SpawnedFutureHandle::new(abort_handle, future_id)
|
||||
}
|
||||
|
||||
/// Schedules a stream to be polled on the main thread, invoking callbacks
|
||||
/// upon the production of each item and upon the completion of the stream.
|
||||
///
|
||||
/// This function is useful in situations where a view wants to process a
|
||||
/// stream of events (say, a debounced stream of mouse movements) and update
|
||||
/// itself in response to each.
|
||||
///
|
||||
/// The callbacks receive mutable references to the spawning view and its
|
||||
/// context, allowing for updating of the view's internal state and dirtying
|
||||
/// it (via [`Self::notify`]) if appropriate.
|
||||
pub fn spawn_stream_local<S, F, G>(
|
||||
&mut self,
|
||||
stream: S,
|
||||
mut on_item: F,
|
||||
mut on_done: G,
|
||||
) -> SpawnedLocalStream
|
||||
where
|
||||
S: 'static + crate::r#async::Stream,
|
||||
S::Item: SpawnableOutput,
|
||||
F: 'static + FnMut(&mut T, S::Item, &mut ViewContext<T>),
|
||||
G: 'static + FnMut(&mut T, &mut ViewContext<T>),
|
||||
{
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let task_id = self.app.spawn_stream_local(stream, tx);
|
||||
self.app.task_callbacks.insert(
|
||||
task_id,
|
||||
TaskCallback::ViewFromStream {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
on_item: Box::new(move |view, output, app, window_id, view_id| {
|
||||
let view = view.as_any_mut().downcast_mut().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
|
||||
let output = *output.downcast().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
on_item(view, output, &mut ctx);
|
||||
}),
|
||||
on_done: Box::new(move |view, app, window_id, view_id| {
|
||||
let view = view.as_any_mut().downcast_mut().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
on_done(view, &mut ctx);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
SpawnedLocalStream::new(
|
||||
async move {
|
||||
if rx.await.is_err() {
|
||||
log::error!("sender unexpectedly dropped before receiver");
|
||||
}
|
||||
}
|
||||
.boxed_local(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn close_window(&mut self) {
|
||||
self.app
|
||||
.windows()
|
||||
.close_window_async(self.window_id, TerminationMode::Cancellable);
|
||||
}
|
||||
|
||||
/// Minimizes the window which this View is in.
|
||||
pub fn minimize_window(&mut self) {
|
||||
if let Some(window) = self.app.windows().platform_window(self.window_id) {
|
||||
window.minimize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximizes the window which this View is in, unless that window is already maximized, then it
|
||||
/// restores it, i.e. "un-maximizes" it.
|
||||
pub fn toggle_maximized_window(&mut self) {
|
||||
if let Some(window) = self.app.windows().platform_window(self.window_id) {
|
||||
window.toggle_maximized();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_fullscreen(&mut self) {
|
||||
if let Some(window) = self.app.windows().platform_window(self.window_id) {
|
||||
window.toggle_fullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn foreground_executor(&self) -> &Rc<Foreground> {
|
||||
self.app.foreground_executor()
|
||||
}
|
||||
|
||||
pub fn background_executor(&self) -> &Arc<Background> {
|
||||
self.app.background_executor()
|
||||
}
|
||||
|
||||
/// Create a window showing a modal dialog native to the platform. The modal will synchronously
|
||||
/// block all other interactions with the app until dismissed. Each button can have a callback
|
||||
/// attached to it in the [`crate::modals::ModalButton`] struct.
|
||||
pub fn show_native_platform_modal(
|
||||
&mut self,
|
||||
view_alert: AlertDialogWithCallbacks<ViewModalCallback<T>>,
|
||||
) {
|
||||
let weak_handle = self.handle();
|
||||
let app_alert = AlertDialogWithCallbacks::for_app(
|
||||
view_alert.message_text,
|
||||
view_alert.info_text,
|
||||
view_alert
|
||||
.button_data
|
||||
.into_iter()
|
||||
.map(|button| {
|
||||
let weak_handle = self.handle();
|
||||
ModalButton::for_app(button.title, move |app| {
|
||||
if let Some(handle) = weak_handle.upgrade(app) {
|
||||
app.update_view(&handle, |view, ctx| {
|
||||
(button.on_click)(view, ctx);
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
move |app| {
|
||||
if let Some(handle) = weak_handle.upgrade(app) {
|
||||
app.update_view(&handle, |view, ctx| {
|
||||
(view_alert.on_disable)(view, ctx);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
self.app.show_native_platform_modal(app_alert);
|
||||
}
|
||||
|
||||
pub fn set_cursor_shape(&mut self, cursor: Cursor) {
|
||||
self.app
|
||||
.set_cursor_shape(cursor, self.window_id, self.view_id)
|
||||
}
|
||||
|
||||
pub fn reset_cursor(&mut self) {
|
||||
self.app.reset_cursor()
|
||||
}
|
||||
|
||||
/// Creates a handle which background tasks can use to spawn work for this view. Spawned tasks
|
||||
/// are executed on the main thread in the context of the view, and results are sent back to
|
||||
/// the background task.
|
||||
///
|
||||
/// Note that the spawner *does not* keep a strong reference to the view. If the view is
|
||||
/// dropped, any pending or future tasks will be discarded.
|
||||
pub fn spawner(&mut self) -> ViewSpawner<T> {
|
||||
let (task_tx, task_rx) = async_channel::unbounded();
|
||||
let (completion_tx, _completion_rx) = futures::channel::oneshot::channel();
|
||||
|
||||
let task_id = self.app.spawn_stream_local(task_rx, completion_tx);
|
||||
self.app.task_callbacks.insert(
|
||||
task_id,
|
||||
TaskCallback::ViewFromStream {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
on_item: Box::new(move |view, task, app, window_id, view_id| {
|
||||
let view = view
|
||||
.as_any_mut()
|
||||
.downcast_mut()
|
||||
.expect("unexpected view type");
|
||||
let task: ViewTask<T> = *task
|
||||
.downcast()
|
||||
.expect("task from spawner should be ViewTask<T>");
|
||||
let mut ctx = ViewContext::new(app, window_id, view_id);
|
||||
task(view, &mut ctx);
|
||||
}),
|
||||
on_done: Box::new(move |_view, _app, _window_id, _view_id| {}),
|
||||
},
|
||||
);
|
||||
|
||||
ViewSpawner {
|
||||
task_sender: task_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for ViewContext<'_, T> {
|
||||
type Target = AppContext;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.app
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::DerefMut for ViewContext<'_, T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.app
|
||||
}
|
||||
}
|
||||
|
||||
/// A task which must run in the context of a view of type `V`.
|
||||
type ViewTask<V> = Box<dyn FnOnce(&mut V, &mut ViewContext<V>) + Send + 'static>;
|
||||
|
||||
/// A handle for spawning view tasks from background threads.
|
||||
pub struct ViewSpawner<V> {
|
||||
task_sender: async_channel::Sender<ViewTask<V>>,
|
||||
}
|
||||
|
||||
impl<V> ViewSpawner<V> {
|
||||
/// Spawn a task that will execute on the main thread, in the context of a view.
|
||||
pub async fn spawn<R: Send + 'static>(
|
||||
&self,
|
||||
work: impl FnOnce(&mut V, &mut ViewContext<V>) -> R + Send + 'static,
|
||||
) -> Result<R, ViewDropped> {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
self.task_sender
|
||||
.send(Box::new(move |me, ctx| {
|
||||
let result = work(me, ctx);
|
||||
// If the background task has dropped the receiver, then we don't need to send
|
||||
// the result, and there's no one to inform regardless.
|
||||
let _ = tx.send(result);
|
||||
}))
|
||||
.await
|
||||
.map_err(|_| ViewDropped)?;
|
||||
|
||||
rx.await.map_err(|_| ViewDropped)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when a view has been dropped, and so references to it are invalid.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
#[error("View has been dropped")]
|
||||
pub struct ViewDropped;
|
||||
|
||||
impl<V> ModelAsRef for ViewContext<'_, V> {
|
||||
fn model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T {
|
||||
self.app.model(handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V> ReadModel for ViewContext<'_, V> {
|
||||
fn read_model<T, F, S>(&self, handle: &ModelHandle<T>, read: F) -> S
|
||||
where
|
||||
T: Entity,
|
||||
F: FnOnce(&T, &AppContext) -> S,
|
||||
{
|
||||
self.app.read_model(handle, read)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: View> UpdateModel for ViewContext<'_, V> {
|
||||
fn update_model<T, F, S>(&mut self, handle: &ModelHandle<T>, update: F) -> S
|
||||
where
|
||||
T: Entity,
|
||||
F: FnOnce(&mut T, &mut ModelContext<T>) -> S,
|
||||
{
|
||||
self.app.update_model(handle, update)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: View> ViewAsRef for ViewContext<'_, V> {
|
||||
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T {
|
||||
self.app.view(handle)
|
||||
}
|
||||
|
||||
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T> {
|
||||
self.app.try_view(handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: View> UpdateView for ViewContext<'_, V> {
|
||||
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
|
||||
where
|
||||
T: View,
|
||||
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
|
||||
{
|
||||
self.app.update_view(handle, update)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: View> ReadView for ViewContext<'_, V> {
|
||||
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
|
||||
where
|
||||
T: View,
|
||||
F: FnOnce(&T, &AppContext) -> S,
|
||||
{
|
||||
self.app.read_view(handle, read)
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: View> GetSingletonModelHandle for ViewContext<'_, V> {
|
||||
fn get_singleton_model_handle<T: crate::SingletonEntity>(&self) -> ModelHandle<T> {
|
||||
self.app.get_singleton_model_handle()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "context_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,197 @@
|
||||
use crate::elements::Empty;
|
||||
use crate::platform::WindowStyle;
|
||||
use crate::{App, AppContext, Element, Entity, TypedActionView};
|
||||
|
||||
#[test]
|
||||
fn test_spawn_from_view() {
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl super::View for View {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
handle.update(&mut app, move |_, c| {
|
||||
c.spawn(async { 7 }, move |me, output, _| {
|
||||
tx.send(()).unwrap();
|
||||
me.count = output;
|
||||
})
|
||||
});
|
||||
rx.await.unwrap();
|
||||
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
handle.read(&app, |view, _| assert_eq!(view.count, 7));
|
||||
handle.update(&mut app, move |_, c| {
|
||||
c.spawn(async { 14 }, move |me, output, _| {
|
||||
tx.send(()).unwrap();
|
||||
me.count = output;
|
||||
})
|
||||
});
|
||||
rx.await.unwrap();
|
||||
handle.read(&app, |view, _| assert_eq!(view.count, 14));
|
||||
});
|
||||
}
|
||||
|
||||
#[ignore]
|
||||
#[test]
|
||||
fn test_spawn_abortable_from_view() {
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
enum SpawnedOutcome {
|
||||
#[default]
|
||||
NotStarted,
|
||||
Aborted,
|
||||
Resolved {
|
||||
value: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
spawned_outcome: SpawnedOutcome,
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl super::View for View {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
handle.update(&mut app, |_, c| {
|
||||
c.spawn_abortable(
|
||||
async { 7 },
|
||||
move |me, output, _| {
|
||||
tx.send(()).unwrap();
|
||||
me.spawned_outcome = SpawnedOutcome::Resolved { value: output }
|
||||
},
|
||||
|_, _| {},
|
||||
)
|
||||
});
|
||||
rx.await.unwrap();
|
||||
|
||||
handle.read(&app, |view, _| {
|
||||
assert_eq!(view.spawned_outcome, SpawnedOutcome::Resolved { value: 7 })
|
||||
});
|
||||
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
handle.update(&mut app, move |_, c| {
|
||||
let abort_handle = c.spawn_abortable(
|
||||
async { 7 },
|
||||
|_, _, _| {},
|
||||
move |me, _| {
|
||||
me.spawned_outcome = SpawnedOutcome::Aborted;
|
||||
tx.send(()).unwrap();
|
||||
},
|
||||
);
|
||||
abort_handle.abort();
|
||||
});
|
||||
|
||||
rx.await.unwrap();
|
||||
|
||||
// The call future passed to `spawn_abortable` was successfully aborted.
|
||||
handle.read(&app, |view, _| {
|
||||
assert_eq!(view.spawned_outcome, SpawnedOutcome::Aborted)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_view_spawner() {
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl super::View for View {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let spawner = handle.update(&mut app, |_, ctx| ctx.spawner());
|
||||
|
||||
// Test a single spawned task.
|
||||
let result = spawner
|
||||
.spawn(|view, ctx| {
|
||||
view.count += 42;
|
||||
ctx.notify();
|
||||
view.count
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 42);
|
||||
handle.read(&app, |view, _| assert_eq!(view.count, 42));
|
||||
|
||||
// Test multiple spawned tasks.
|
||||
let task1 = spawner.spawn(|view, _| {
|
||||
view.count *= 2;
|
||||
view.count
|
||||
});
|
||||
|
||||
let task2 = spawner.spawn(|view, _| {
|
||||
view.count += 10;
|
||||
view.count
|
||||
});
|
||||
|
||||
let (result1, result2) = futures::future::join(task1, task2).await;
|
||||
|
||||
// Note: The exact final value depends on task execution order but both tasks should succeed.
|
||||
assert!(result1.is_ok());
|
||||
assert!(result2.is_ok());
|
||||
|
||||
handle.read(&app, |view, _| {
|
||||
assert!(view.count > 42);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::{
|
||||
any::TypeId,
|
||||
fmt::{self, Debug},
|
||||
marker::PhantomData,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::{core::RefCounts, AppContext, EntityId, WindowId};
|
||||
|
||||
use super::{context::ViewContext, View};
|
||||
|
||||
/// A strong reference to a particular [`View`] instance within the application.
|
||||
///
|
||||
/// Handles structures are used in place of references (e.g.: `&View`) to avoid
|
||||
/// the complexity of reference lifetimes and appeasing the borrow checker. A
|
||||
/// handle can be combined with a reference to the application state (e.g.:
|
||||
/// [`AppContext`]) to get access to the actual [`View`] instance behind the
|
||||
/// handle.
|
||||
pub struct ViewHandle<T> {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
view_type: PhantomData<T>,
|
||||
ref_counts: Weak<Mutex<RefCounts>>,
|
||||
}
|
||||
|
||||
impl<T: View> ViewHandle<T> {
|
||||
pub(in crate::core) fn new(
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
ref_counts: &Arc<Mutex<RefCounts>>,
|
||||
) -> Self {
|
||||
ref_counts.lock().inc_entity(view_id);
|
||||
Self {
|
||||
window_id,
|
||||
view_id,
|
||||
view_type: PhantomData,
|
||||
ref_counts: Arc::downgrade(ref_counts),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn downgrade(&self) -> WeakViewHandle<T> {
|
||||
WeakViewHandle::new(self.view_id)
|
||||
}
|
||||
|
||||
/// Returns the current window this view belongs to.
|
||||
///
|
||||
/// This looks up the window from the view_to_window mapping, which may differ
|
||||
/// from the window where the view was originally created if the view has been
|
||||
/// transferred between windows.
|
||||
pub fn window_id(&self, app: &AppContext) -> WindowId {
|
||||
app.view_to_window
|
||||
.get(&self.view_id)
|
||||
.copied()
|
||||
.unwrap_or(self.window_id)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> EntityId {
|
||||
self.view_id
|
||||
}
|
||||
|
||||
/// Convert a ViewHandle to a reference of the underlying View.
|
||||
pub fn as_ref<'a, A: ViewAsRef>(&self, app: &'a A) -> &'a T {
|
||||
app.view(self)
|
||||
}
|
||||
|
||||
/// Try to convert a ViewHandle to a reference of the underlying View.
|
||||
/// Returns `None` if the view is currently borrowed (circular reference).
|
||||
pub fn try_as_ref<'a, A: ViewAsRef>(&self, app: &'a A) -> Option<&'a T> {
|
||||
app.try_view(self)
|
||||
}
|
||||
|
||||
/// Reads a value out of the underlying View. This is especially useful when the view
|
||||
/// has a function that requires a `ViewContext` since `as_ref` does not create a `ViewHandle`
|
||||
/// to the view.
|
||||
pub fn read<A, F, S>(&self, app: &A, read: F) -> S
|
||||
where
|
||||
A: crate::ReadView,
|
||||
F: FnOnce(&T, &AppContext) -> S,
|
||||
{
|
||||
app.read_view(self, read)
|
||||
}
|
||||
|
||||
/// Updates a value within the underlying View.
|
||||
pub fn update<A, F, S>(&self, app: &mut A, update: F) -> S
|
||||
where
|
||||
A: UpdateView,
|
||||
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
|
||||
{
|
||||
app.update_view(self, update)
|
||||
}
|
||||
|
||||
pub fn is_focused(&self, app: &AppContext) -> bool {
|
||||
app.focused_view_id(self.window_id(app)) == Some(self.view_id)
|
||||
}
|
||||
|
||||
// TODO: This is the same as the `is_self_or_child_focused` function in ViewContext.
|
||||
// Moving forward we should figure out a better interface to check whether a specific
|
||||
// view is focused or not.
|
||||
pub fn is_self_or_child_focused(&self, app: &mut AppContext) -> bool {
|
||||
let window_id = self.window_id(app);
|
||||
app.check_view_or_child_focused(window_id, &self.view_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for ViewHandle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().inc_entity(self.view_id);
|
||||
}
|
||||
|
||||
Self {
|
||||
window_id: self.window_id,
|
||||
view_id: self.view_id,
|
||||
view_type: PhantomData,
|
||||
ref_counts: self.ref_counts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialEq for ViewHandle<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.window_id == other.window_id && self.view_id == other.view_id
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Eq for ViewHandle<T> {}
|
||||
|
||||
impl<T> Debug for ViewHandle<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct(&format!("ViewHandle<{}>", core::any::type_name::<T>()))
|
||||
.field("window_id", &self.window_id)
|
||||
.field("view_id", &self.view_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for ViewHandle<T> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().dec_view(self.window_id, self.view_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl<T> Send for ViewHandle<T> {}
|
||||
unsafe impl<T> Sync for ViewHandle<T> {}
|
||||
|
||||
/// A type-erased strong reference to a particular [`View`] instance within the
|
||||
/// application.
|
||||
///
|
||||
/// `AnyViewHandle` is used within the core UI framework in places where we need
|
||||
/// to hold a strong reference to a `View`, but don't want to add a generic type
|
||||
/// parameter to the containing structure. See `root_view` in
|
||||
/// [`Window`](crate::core::Window) for an example.
|
||||
pub(in crate::core) struct AnyViewHandle {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
view_type: TypeId,
|
||||
ref_counts: Weak<Mutex<RefCounts>>,
|
||||
}
|
||||
|
||||
impl AnyViewHandle {
|
||||
pub fn id(&self) -> EntityId {
|
||||
self.view_id
|
||||
}
|
||||
|
||||
/// Returns the current window this view belongs to.
|
||||
pub fn window_id(&self, app: &AppContext) -> WindowId {
|
||||
app.view_to_window
|
||||
.get(&self.view_id)
|
||||
.copied()
|
||||
.unwrap_or(self.window_id)
|
||||
}
|
||||
|
||||
pub fn is<T: 'static>(&self) -> bool {
|
||||
TypeId::of::<T>() == self.view_type
|
||||
}
|
||||
|
||||
pub fn downcast<T: View>(self) -> Option<ViewHandle<T>> {
|
||||
if self.is::<T>() {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
return Some(ViewHandle::new(self.window_id, self.view_id, &ref_counts));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for AnyViewHandle {
|
||||
fn clone(&self) -> Self {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().inc_entity(self.view_id);
|
||||
}
|
||||
|
||||
Self {
|
||||
view_id: self.view_id,
|
||||
window_id: self.window_id,
|
||||
view_type: self.view_type,
|
||||
ref_counts: self.ref_counts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: View> From<&ViewHandle<T>> for AnyViewHandle {
|
||||
fn from(handle: &ViewHandle<T>) -> Self {
|
||||
if let Some(ref_counts) = handle.ref_counts.upgrade() {
|
||||
ref_counts.lock().inc_entity(handle.view_id);
|
||||
}
|
||||
AnyViewHandle {
|
||||
window_id: handle.window_id,
|
||||
view_id: handle.view_id,
|
||||
view_type: TypeId::of::<T>(),
|
||||
ref_counts: handle.ref_counts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: View> From<ViewHandle<T>> for AnyViewHandle {
|
||||
fn from(handle: ViewHandle<T>) -> Self {
|
||||
(&handle).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AnyViewHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref_counts) = self.ref_counts.upgrade() {
|
||||
ref_counts.lock().dec_view(self.window_id, self.view_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A weak reference to a particular [`View`] instance within the application.
|
||||
///
|
||||
/// `WeakViewHandle` is useful when a view wants to hold onto its own handle -
|
||||
/// holding a strong reference via `ViewHandle` would create a reference cycle
|
||||
/// that prevents the application from ever dropping the view.
|
||||
pub struct WeakViewHandle<T> {
|
||||
view_id: EntityId,
|
||||
view_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: View> WeakViewHandle<T> {
|
||||
pub(super) fn new(view_id: EntityId) -> Self {
|
||||
Self {
|
||||
view_id,
|
||||
view_type: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upgrade(&self, app: &AppContext) -> Option<ViewHandle<T>> {
|
||||
// Look up the current window for this view
|
||||
let window_id = app.view_to_window.get(&self.view_id).copied()?;
|
||||
|
||||
if app
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.and_then(|w| w.views.get(&self.view_id))
|
||||
.is_some()
|
||||
{
|
||||
Some(ViewHandle::new(window_id, self.view_id, &app.ref_counts))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> EntityId {
|
||||
self.view_id
|
||||
}
|
||||
|
||||
/// Returns the current window this view belongs to, if any.
|
||||
pub fn window_id(&self, app: &AppContext) -> Option<WindowId> {
|
||||
app.view_to_window.get(&self.view_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for WeakViewHandle<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
view_id: self.view_id,
|
||||
view_type: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for WeakViewHandle<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct(&format!("WeakViewHandle<{}>", core::any::type_name::<T>()))
|
||||
.field("view_id", &self.view_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl<T> Send for WeakViewHandle<T> {}
|
||||
unsafe impl<T> Sync for WeakViewHandle<T> {}
|
||||
|
||||
pub trait ViewAsRef {
|
||||
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T;
|
||||
|
||||
/// Try to get a reference to the view. Returns `None` if the view is
|
||||
/// currently borrowed (e.g., during a circular reference scenario).
|
||||
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T>;
|
||||
}
|
||||
|
||||
pub trait ReadView: ViewAsRef {
|
||||
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
|
||||
where
|
||||
T: View,
|
||||
F: FnOnce(&T, &AppContext) -> S;
|
||||
}
|
||||
|
||||
pub trait UpdateView: ReadView {
|
||||
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
|
||||
where
|
||||
T: View,
|
||||
F: FnOnce(&mut T, &mut ViewContext<T>) -> S;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
mod context;
|
||||
mod handle;
|
||||
|
||||
use crate::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent},
|
||||
keymap, Action, AppContext, CursorInfo, Element, Entity,
|
||||
};
|
||||
|
||||
pub use self::{context::*, handle::*};
|
||||
|
||||
use super::EntityId;
|
||||
|
||||
pub enum FocusContext {
|
||||
SelfFocused,
|
||||
DescendentFocused(EntityId),
|
||||
}
|
||||
|
||||
impl FocusContext {
|
||||
pub fn is_self_focused(&self) -> bool {
|
||||
matches!(self, Self::SelfFocused)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BlurContext {
|
||||
SelfBlurred,
|
||||
DescendentBlurred(EntityId),
|
||||
}
|
||||
|
||||
impl BlurContext {
|
||||
pub fn is_self_blurred(&self) -> bool {
|
||||
matches!(self, Self::SelfBlurred)
|
||||
}
|
||||
}
|
||||
|
||||
/// An interface for interactive, renderable UI components.
|
||||
///
|
||||
/// Conceptually, an implementation of [`View`] is analogous to a React
|
||||
/// component - a structure that holds instance state and can be asked to render
|
||||
/// itself, a process that produces a tree of rendering primitives (in [`warpui`](crate),
|
||||
/// these are structures that implement [`Element`]; in React, these are DOM
|
||||
/// elements).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use warpui_core::{*, elements::Rect};
|
||||
///
|
||||
/// struct MyView {}
|
||||
///
|
||||
/// impl Entity for MyView {
|
||||
/// type Event = ();
|
||||
/// }
|
||||
///
|
||||
/// impl View for MyView {
|
||||
/// fn ui_name() -> &'static str { "MyView" }
|
||||
/// fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
/// Rect::new().finish()
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait View: Entity {
|
||||
/// Returns a unique name for this implementation of View.
|
||||
fn ui_name() -> &'static str;
|
||||
|
||||
/// Produces an [`Element`] tree representation of this view.
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element>;
|
||||
|
||||
/// Handles the view or its descendent receiving focus.
|
||||
/// Which view received focus is indicated by the [`FocusContext`].
|
||||
fn on_focus(&mut self, _focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
/// Accessibility (a11y) support for [`View`]s.
|
||||
///
|
||||
/// Whenever the view is focused (i.e. [`View::on_focus`]), the provided a11y content
|
||||
/// is read out through the native screen reader (e.g. VoiceOver in MacOS).
|
||||
///
|
||||
/// While the contents default to [`None`] (i.e. no a11y content), each view
|
||||
/// is encouraged to provide sensible a11y content so that visually-impaired users
|
||||
/// can follow along in the application.
|
||||
fn accessibility_contents(&self, _ctx: &AppContext) -> Option<AccessibilityContent> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Reports the active cursor position for the view, if any.
|
||||
/// This only applies to [`View`]s that have some sort of text editor.
|
||||
///
|
||||
/// We intentionally provide _immutable_ access to the [`ViewContext`];
|
||||
/// querying the active cursor position shouldn't necessitate writes.
|
||||
fn active_cursor_position(&self, _ctx: &ViewContext<Self>) -> Option<CursorInfo> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Handles the view or its descendent losing focus.
|
||||
/// Which view lost focus is indicated by the [`BlurContext`].
|
||||
fn on_blur(&mut self, _blur_ctx: &BlurContext, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
/// Handles the view's containing window closing.
|
||||
fn on_window_closed(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
/// Called when the view is transferred from one window to another.
|
||||
/// Views can override this to update any window-specific state.
|
||||
fn on_window_transferred(
|
||||
&mut self,
|
||||
_source_window_id: super::super::WindowId,
|
||||
_target_window_id: super::super::WindowId,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Returns a representation of the current UI context for use in computing
|
||||
/// the set of valid actions/keyboard shortcuts.
|
||||
fn keymap_context(&self, _: &AppContext) -> keymap::Context {
|
||||
Self::default_keymap_context()
|
||||
}
|
||||
|
||||
/// Returns the default context for a view.
|
||||
fn default_keymap_context() -> keymap::Context {
|
||||
let mut ctx = keymap::Context::default();
|
||||
ctx.set.insert(Self::ui_name());
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Allows a view to hook into any interactions with it or its children.
|
||||
///
|
||||
/// A valid interaction must be handled by the view or its children, and includes:
|
||||
/// - all mouse events, except [`Event::MouseMoved`] and [`Event::ScrollWheel`]
|
||||
/// - all keyboard events, including all [`CustomAction`]s and [`StandardAction::Paste`]
|
||||
fn self_or_child_interacted_with(&self, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
/// Returns the current [`AccessibilityData`] for this view, if `Some`. Returning a valid
|
||||
/// [`AccessibilityData`] struct here indicates that this view should belong in the
|
||||
/// accessibility tree of this application.
|
||||
fn accessibility_data(&self, _ctx: &mut ViewContext<Self>) -> Option<AccessibilityData> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The accessibility data of a current view.
|
||||
pub struct AccessibilityData {
|
||||
/// The contents of the view.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// An interface for a structure (typically a [`View`]) that handle actions
|
||||
/// of a particular type.
|
||||
pub trait TypedActionView {
|
||||
type Action: Action;
|
||||
|
||||
/// Handles an action of type [`Self::Action`](TypedActionView::Action)
|
||||
/// that was dispatched from this view or any descendant.
|
||||
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
/// TypedActionViews can implement another way to provide context about what’s going on with the app. After each `handle_action` call, the UI framework calls `action_accessibility_contents(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) -> ActionAccessibilityContent`.
|
||||
///
|
||||
/// ### When and how to use it?
|
||||
/// This method should be implemented for all the meaningful Actions. For example, actions related to mouse movement are not meaningful, but action related to user copying the selected content - is.
|
||||
/// If your Action enum has tons of actions, and only some of them are meaningful, you can use helper implementations from the `warpui::accessibility` (like `ActionAccessibilityContent::Default()` or `ActionAccessibility_content::from_debug()`).
|
||||
fn action_accessibility_contents(
|
||||
&mut self,
|
||||
_action: &Self::Action,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) -> ActionAccessibilityContent {
|
||||
ActionAccessibilityContent::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use core::fmt;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{core::view::AnyViewHandle, AnyView, EntityId};
|
||||
|
||||
/// A unique identifier for a window.
|
||||
///
|
||||
/// These are globally unique and not reused across the lifetime of the
|
||||
/// application.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct WindowId(usize);
|
||||
|
||||
impl WindowId {
|
||||
/// Constructs a new globally-unique window ID.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub fn new() -> WindowId {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
WindowId(raw)
|
||||
}
|
||||
|
||||
pub fn from_usize(value: usize) -> WindowId {
|
||||
WindowId(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WindowId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure holding all application state that is linked to a particular
|
||||
/// window.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Window {
|
||||
/// The set of views owned by this window, keyed by view ID.
|
||||
pub views: HashMap<EntityId, Box<dyn AnyView>>,
|
||||
|
||||
/// A handle to the window's root view (top of the view hierarchy), if any.
|
||||
pub root_view: Option<AnyViewHandle>,
|
||||
|
||||
/// The ID of the currently focused view, if any.
|
||||
pub focused_view: Option<EntityId>,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod root_view;
|
||||
pub mod view_tree_debug_view;
|
||||
|
||||
pub(crate) use root_view::DebugRootView;
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
elements::ChildView, AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::view_tree_debug_view::ViewTreeDebugView;
|
||||
|
||||
/// A root view for a window that provides debugging tools for the UI framework.
|
||||
pub(crate) struct DebugRootView {
|
||||
child: ViewHandle<ViewTreeDebugView>,
|
||||
}
|
||||
|
||||
impl TypedActionView for DebugRootView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
impl DebugRootView {
|
||||
pub fn new(
|
||||
target_window_id: WindowId,
|
||||
view_parent_map: HashMap<EntityId, EntityId>,
|
||||
root_view_id: EntityId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let child = ctx.add_typed_action_view(|ctx| {
|
||||
ViewTreeDebugView::new(target_window_id, view_parent_map, root_view_id, ctx)
|
||||
});
|
||||
Self { child }
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DebugRootView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for DebugRootView {
|
||||
fn ui_name() -> &'static str {
|
||||
"DebugRootView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.child).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::{
|
||||
elements::{
|
||||
Align, Container, Fill, Hoverable, MouseStateHandle, ScrollStateHandle, Scrollable,
|
||||
ScrollableElement, ScrollbarWidth, Text, UniformList, UniformListState,
|
||||
},
|
||||
AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext, WeakViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
/// Turns a map of parent->children into a list of (view, tree_depth) pairs,
|
||||
/// ordered via depth-first traversal of the input map.
|
||||
fn populate_view_list(
|
||||
view_children_map: &HashMap<EntityId, Vec<EntityId>>,
|
||||
current_view_id: EntityId,
|
||||
depth: usize,
|
||||
view_list: &mut Vec<(EntityId, usize)>,
|
||||
) {
|
||||
view_list.push((current_view_id, depth));
|
||||
if let Some(children) = view_children_map.get(¤t_view_id) {
|
||||
for child in children {
|
||||
populate_view_list(view_children_map, *child, depth + 1, view_list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper structure containing state necessary to render information about a
|
||||
/// single view in a window's view hierarchy.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ViewInfo {
|
||||
view_id: EntityId,
|
||||
view_depth: usize,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl ViewInfo {
|
||||
fn render(&self, window_id: WindowId, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let spacing = " ".repeat(self.view_depth);
|
||||
let view_id = self.view_id;
|
||||
let view_name = ctx
|
||||
.view_name(window_id, view_id)
|
||||
.expect("view should exist");
|
||||
|
||||
Hoverable::new(self.mouse_state_handle.clone(), |mouse_state| {
|
||||
let text = Text::new_inline(
|
||||
format!("{spacing}{view_name} ({view_id:?})"),
|
||||
// This relies on an expectation that the first font loaded is
|
||||
// a reasonable one to draw this view with, but we lack a better
|
||||
// method, at the moment, to intentionally select a font.
|
||||
// TODO(vorporeal): Don't arbitrarily pick font family 0.
|
||||
crate::fonts::FamilyId(0),
|
||||
13.,
|
||||
);
|
||||
let background_color = if mouse_state.is_hovered() {
|
||||
ColorU::new(0, 143, 143, 255)
|
||||
} else {
|
||||
ColorU::transparent_black()
|
||||
};
|
||||
Container::new(text.finish())
|
||||
.with_background_color(background_color)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ViewTreeDebugAction::HighlightView(window_id, view_id));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions that can be taken within the View Tree debug view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ViewTreeDebugAction {
|
||||
/// Visually highlights a particular view in a given window.
|
||||
HighlightView(WindowId, EntityId),
|
||||
}
|
||||
|
||||
/// A view to help visualize and interact with the view hierarchy for a
|
||||
/// particular window.
|
||||
///
|
||||
/// This only includes views that had been laid out at some point prior to the
|
||||
/// creation of this view. At present, this view caches the view hierarchy at
|
||||
/// creation time and does not dynamically update it as new views are laid out
|
||||
/// and rendered.
|
||||
pub(super) struct ViewTreeDebugView {
|
||||
handle: WeakViewHandle<Self>,
|
||||
target_window_id: WindowId,
|
||||
view_info: Vec<ViewInfo>,
|
||||
uniform_list_state: UniformListState,
|
||||
scroll_state_handle: ScrollStateHandle,
|
||||
}
|
||||
|
||||
impl ViewTreeDebugView {
|
||||
pub fn new(
|
||||
target_window_id: WindowId,
|
||||
view_parent_map: HashMap<EntityId, EntityId>,
|
||||
root_view_id: EntityId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let mut view_children_map: HashMap<EntityId, Vec<EntityId>> = Default::default();
|
||||
for (child, parent) in view_parent_map.into_iter() {
|
||||
view_children_map.entry(parent).or_default().push(child);
|
||||
}
|
||||
|
||||
let mut view_list: Vec<(EntityId, usize)> = vec![];
|
||||
populate_view_list(&view_children_map, root_view_id, 0, &mut view_list);
|
||||
|
||||
let view_info = view_list
|
||||
.into_iter()
|
||||
.map(|(view_id, view_depth)| ViewInfo {
|
||||
view_id,
|
||||
view_depth,
|
||||
mouse_state_handle: MouseStateHandle::default(),
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
Self {
|
||||
handle: ctx.handle(),
|
||||
target_window_id,
|
||||
view_info,
|
||||
uniform_list_state: Default::default(),
|
||||
scroll_state_handle: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ViewTreeDebugView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for ViewTreeDebugView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ViewTreeDebugView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
let handle = self.handle.clone();
|
||||
let window_id = self.target_window_id;
|
||||
|
||||
let list = UniformList::new(
|
||||
self.uniform_list_state.clone(),
|
||||
self.view_info.len(),
|
||||
move |range, ctx| {
|
||||
handle
|
||||
.upgrade(ctx)
|
||||
.into_iter()
|
||||
.flat_map(|handle| {
|
||||
handle
|
||||
.as_ref(ctx)
|
||||
.view_info
|
||||
.iter()
|
||||
.skip(range.start)
|
||||
.take(range.len())
|
||||
.cloned()
|
||||
})
|
||||
.map(|view_info| view_info.render(window_id, ctx))
|
||||
.collect_vec()
|
||||
.into_iter()
|
||||
},
|
||||
);
|
||||
|
||||
let scrollable = Scrollable::vertical(
|
||||
self.scroll_state_handle.clone(),
|
||||
list.finish_scrollable(),
|
||||
ScrollbarWidth::Auto,
|
||||
Fill::Solid(ColorU::new(255, 255, 255, 50)),
|
||||
Fill::Solid(ColorU::new(255, 255, 255, 150)),
|
||||
Fill::Solid(ColorU::black()),
|
||||
);
|
||||
|
||||
let view_content = Align::new(
|
||||
Container::new(scrollable.finish())
|
||||
.with_uniform_padding(8.)
|
||||
.finish(),
|
||||
)
|
||||
.top_left();
|
||||
|
||||
Container::new(view_content.finish())
|
||||
.with_background_color(ColorU::black())
|
||||
.with_padding_top(25.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ViewTreeDebugView {
|
||||
type Action = ViewTreeDebugAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ViewTreeDebugAction::HighlightView(window_id, view_id) => {
|
||||
if let Some(presenter) = ctx.presenter(*window_id) {
|
||||
presenter
|
||||
.as_ref()
|
||||
.borrow_mut()
|
||||
.set_highlighted_view(*view_id);
|
||||
}
|
||||
// This is a hacky way to get the other window to redraw, but it
|
||||
// works. :)
|
||||
ctx.invalidate_all_views();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use crate::{
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
};
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SelectableElement, Selection, SelectionFragment, SizeConstraint,
|
||||
};
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
pub struct Align {
|
||||
child: Box<dyn Element>,
|
||||
alignment: Vector2F,
|
||||
size: Option<Vector2F>,
|
||||
}
|
||||
|
||||
/// By default, Align centers a child element
|
||||
impl Align {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
child,
|
||||
alignment: Vector2F::zero(),
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top_center(mut self) -> Self {
|
||||
self.alignment = vec2f(0.0, -1.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn top_right(mut self) -> Self {
|
||||
self.alignment = vec2f(1.0, -1.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn top_left(mut self) -> Self {
|
||||
self.alignment = vec2f(-1., -1.);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bottom_center(mut self) -> Self {
|
||||
self.alignment = vec2f(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bottom_right(mut self) -> Self {
|
||||
self.alignment = vec2f(1.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bottom_left(mut self) -> Self {
|
||||
self.alignment = vec2f(-1., 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn right(mut self) -> Self {
|
||||
self.alignment = vec2f(1.0, 0.);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn left(mut self) -> Self {
|
||||
self.alignment = vec2f(-1., 0.);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Align {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let mut size = constraint.max;
|
||||
|
||||
let child_constraint = SizeConstraint::new(Vector2F::zero(), constraint.max);
|
||||
let child_size = self.child.layout(child_constraint, ctx, app);
|
||||
|
||||
if size.x().is_infinite() {
|
||||
size.set_x(child_size.x().max(constraint.min.x()));
|
||||
}
|
||||
if size.y().is_infinite() {
|
||||
size.set_y(child_size.y().max(constraint.min.y()));
|
||||
}
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let self_center = self.size.unwrap() / 2.0;
|
||||
let self_target = self_center + self_center * self.alignment;
|
||||
let child_center = self.child.size().unwrap() / 2.0;
|
||||
let mut child_target = child_center + child_center * self.alignment;
|
||||
// Make sure the child_target cannot extend past self which may happen if child size is
|
||||
// larger than self size.
|
||||
child_target = child_target.min(self_target);
|
||||
let child_origin = origin - (child_target - self_target);
|
||||
self.child.paint(child_origin, ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for Align {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use crate::presenter::ChildView;
|
||||
@@ -0,0 +1,99 @@
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use super::Point;
|
||||
use crate::{
|
||||
event::DispatchedEvent, AfterLayoutContext, AppContext, ClipBounds, Element, EventContext,
|
||||
LayoutContext, PaintContext, SizeConstraint,
|
||||
};
|
||||
use std::any::Any;
|
||||
|
||||
/// Element that clips a child to its bounds
|
||||
pub struct Clipped {
|
||||
origin: Option<Point>,
|
||||
size: Option<Vector2F>,
|
||||
child: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl Clipped {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
origin: None,
|
||||
size: None,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sized(child: Box<dyn Element>, size: Vector2F) -> Self {
|
||||
Self {
|
||||
origin: None,
|
||||
size: Some(size),
|
||||
child,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Clipped {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let origin_point = Point::from_vec2f(origin, ctx.scene.z_index());
|
||||
self.origin = Some(origin_point);
|
||||
|
||||
// Get current clip bounds (if any) to ensure that the next layer respects them.
|
||||
let current_bounds = ctx.scene.visible_rect(
|
||||
origin_point,
|
||||
self.size()
|
||||
.expect("Clipped element should have a size at time of paint"),
|
||||
);
|
||||
|
||||
// Clipping works by creating a separate layer for an element with clip bounds.
|
||||
// If current_bounds is None, this means that we shouldn't paint anything.
|
||||
if let Some(bounds) = current_bounds {
|
||||
ctx.scene.start_layer(ClipBounds::BoundedBy(bounds));
|
||||
self.child.paint(origin, ctx, app);
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
// Only dispatch the event to the child if it has been painted.
|
||||
self.child.origin().is_some() && self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size.or_else(|| self.child.size())
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn parent_data(&self) -> Option<&dyn Any> {
|
||||
self.child.parent_data()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clipped_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,469 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
use crate::scene::ClipBounds;
|
||||
use crate::units::{IntoPixels, Pixels};
|
||||
use crate::{
|
||||
event::DispatchedEvent, AfterLayoutContext, AppContext, Element, EventContext, LayoutContext,
|
||||
PaintContext, SizeConstraint,
|
||||
};
|
||||
|
||||
use super::{
|
||||
new_scrollable::util::scroll_delta_for_axis, Axis, F32Ext, Fill, Point, ScrollData,
|
||||
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Selection, Vector2FExt,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ScrollToPositionMode {
|
||||
/// Scroll the minimum amount to bring as much of the element into view
|
||||
/// as possible.
|
||||
FullyIntoView,
|
||||
/// Show as much of the element as possible, prioritising the top (leading)
|
||||
/// edge. Behaves like [`FullyIntoView`] when the element fits within the
|
||||
/// viewport, but when the element is taller than the viewport it aligns
|
||||
/// the element's top with the viewport's top.
|
||||
TopIntoView,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ScrollTarget {
|
||||
pub position_id: String,
|
||||
pub mode: ScrollToPositionMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ClippedScrollData {
|
||||
scroll_start_px: Pixels,
|
||||
pub(super) scroll_to_position: Option<ScrollTarget>,
|
||||
selection_scroll_anchor: Option<ClippedSelectionScrollAnchor>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ClippedSelectionScrollAnchor {
|
||||
selection: Selection,
|
||||
scroll_start_px: Pixels,
|
||||
}
|
||||
|
||||
impl ClippedSelectionScrollAnchor {
|
||||
fn matches(&self, selection: Selection) -> bool {
|
||||
self.selection == selection
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ClippedScrollStateHandle {
|
||||
/// The scroll state for the [`Scrollable`] that wraps the [`ClippedScrollable`].
|
||||
/// This is included as part of this handle for ergonomics; otherwise, each
|
||||
/// [`ClippedScrollable`] consumer would need to separately maintain a
|
||||
/// [`ScrollStateHandle`] and a [`ClippedScrollStateHandle`].
|
||||
scrollable_data: ScrollStateHandle,
|
||||
pub(super) clipped_scroll_data: Arc<Mutex<ClippedScrollData>>,
|
||||
}
|
||||
|
||||
impl ClippedScrollStateHandle {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn scroll_to(&self, start: Pixels) {
|
||||
self.clipped_scroll_data.lock().scroll_start_px = start.max(Pixels::zero());
|
||||
}
|
||||
|
||||
pub fn scroll_start(&self) -> Pixels {
|
||||
self.clipped_scroll_data.lock().scroll_start_px
|
||||
}
|
||||
|
||||
pub fn scroll_by(&self, delta: Pixels) {
|
||||
self.scroll_to(self.scroll_start() + delta);
|
||||
}
|
||||
|
||||
/// Records `selection` as the current selection scroll anchor (if not already recorded) and
|
||||
/// returns a copy of it whose coordinates have been compensated for any scroll that has
|
||||
/// happened since the anchor was first recorded.
|
||||
///
|
||||
/// This is **not** a pure transformation — it mutates the handle's internal
|
||||
/// `selection_scroll_anchor` state as a side effect:
|
||||
/// - Passing `None` clears the anchor and returns `None`.
|
||||
/// - Passing a `Selection` that does not match the currently recorded anchor replaces the
|
||||
/// anchor with a new one capturing `selection` at the current scroll position and returns
|
||||
/// `selection` unchanged.
|
||||
/// - Passing a `Selection` that matches the currently recorded anchor leaves the anchor in
|
||||
/// place and returns `selection` shifted by the delta between the current scroll position
|
||||
/// and the anchor's recorded scroll position.
|
||||
///
|
||||
/// The net effect is that as long as callers feed the same selection in across repaints, the
|
||||
/// returned selection tracks the underlying content even while the surface is being scrolled.
|
||||
pub(crate) fn anchor_and_adjust_selection_for_scroll(
|
||||
&self,
|
||||
selection: Option<Selection>,
|
||||
axis: Axis,
|
||||
) -> Option<Selection> {
|
||||
let Some(selection) = selection else {
|
||||
self.clipped_scroll_data.lock().selection_scroll_anchor = None;
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut scroll_data = self.clipped_scroll_data.lock();
|
||||
let scroll_start_px = scroll_data.scroll_start_px;
|
||||
let anchor_scroll_start = match scroll_data.selection_scroll_anchor {
|
||||
Some(anchor) if anchor.matches(selection) => anchor.scroll_start_px,
|
||||
_ => {
|
||||
scroll_data.selection_scroll_anchor = Some(ClippedSelectionScrollAnchor {
|
||||
selection,
|
||||
scroll_start_px,
|
||||
});
|
||||
scroll_start_px
|
||||
}
|
||||
};
|
||||
|
||||
let scroll_delta = (scroll_start_px - anchor_scroll_start).as_f32().along(axis);
|
||||
Some(Selection {
|
||||
start: selection.start - scroll_delta,
|
||||
end: selection.end - scroll_delta,
|
||||
is_rect: selection.is_rect,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn clear_selection_scroll_anchor(&self) {
|
||||
self.clipped_scroll_data.lock().selection_scroll_anchor = None;
|
||||
}
|
||||
|
||||
pub fn set_start(&self, position: f32) {
|
||||
self.scrollable_data.lock().unwrap().started = Some(position);
|
||||
}
|
||||
|
||||
pub fn reset_start(&self) {
|
||||
self.scrollable_data.lock().unwrap().started = None;
|
||||
}
|
||||
|
||||
pub fn start(&self) -> Option<f32> {
|
||||
self.scrollable_data.lock().unwrap().started
|
||||
}
|
||||
|
||||
/// Scrolls the bounds of the element described by `target` into view.
|
||||
/// This is a no-op if the position is already in view or is not within
|
||||
/// the bounds of the `ClippedScrollable`.
|
||||
pub fn scroll_to_position(&self, target: ScrollTarget) {
|
||||
self.clipped_scroll_data.lock().scroll_to_position = Some(target);
|
||||
}
|
||||
|
||||
pub fn hovered(&self) -> bool {
|
||||
self.scrollable_data.lock().unwrap().hovered
|
||||
}
|
||||
|
||||
pub fn set_hovered(&self, hovered: bool) {
|
||||
self.scrollable_data.lock().unwrap().hovered = hovered;
|
||||
}
|
||||
|
||||
pub(in crate::elements) fn set_child_hovered(&self, hovered: bool) {
|
||||
self.scrollable_data
|
||||
.lock()
|
||||
.expect("lock should be held")
|
||||
.child_hovered = hovered;
|
||||
}
|
||||
|
||||
pub(in crate::elements) fn child_hovered(&self) -> bool {
|
||||
self.scrollable_data
|
||||
.lock()
|
||||
.expect("lock should be held")
|
||||
.child_hovered
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements a generic scrollable interface around an arbitrary child element
|
||||
/// tree using clipping to control what's rendered.
|
||||
/// Note that this scroll path is by its nature slow because in order to
|
||||
/// use it we need to fully lay out the child tree, determine its size
|
||||
/// paint it, and then clip it.
|
||||
/// It's much better to have a child that explicitly implements ScrollableElement
|
||||
/// where possible, but it's fine to use this when that's not possible.
|
||||
///
|
||||
/// TODO: there is currently a bug with constraint-passing when nesting
|
||||
/// [`ClippedScrollable`]s (e.g. to get clipped scrolling in both directions).
|
||||
pub struct ClippedScrollable {
|
||||
axis: Axis,
|
||||
child: Box<dyn Element>,
|
||||
state: ClippedScrollStateHandle,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
/// When true, the child constraint's min on the main axis is set to the
|
||||
/// incoming constraint's max on the main axis (if finite). This allows
|
||||
/// the child (e.g. an [`Align`]) to know the visible height and center
|
||||
/// content within the scrollable area.
|
||||
fill_min_main_axis: bool,
|
||||
}
|
||||
|
||||
impl ClippedScrollable {
|
||||
fn new(axis: Axis, child: Box<dyn Element>, state: ClippedScrollStateHandle) -> Self {
|
||||
Self {
|
||||
axis,
|
||||
child,
|
||||
state,
|
||||
size: None,
|
||||
origin: None,
|
||||
fill_min_main_axis: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new [`Scrollable`] element that scrolls vertically,
|
||||
/// using a [`ClippedScrollable`] as the concrete [`ScrollableElement`].
|
||||
pub fn vertical(
|
||||
state: ClippedScrollStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
scrollbar_track_background: Fill,
|
||||
) -> Scrollable {
|
||||
Scrollable::vertical(
|
||||
state.scrollable_data.clone(),
|
||||
ClippedScrollable::new(Axis::Vertical, child, state).finish_scrollable(),
|
||||
scrollbar_size,
|
||||
nonactive_scrollbar_thumb_background,
|
||||
active_scrollbar_thumb_background,
|
||||
scrollbar_track_background,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`vertical`](Self::vertical), but passes the visible height as
|
||||
/// the child's min-height constraint. This allows the child (e.g. wrapped
|
||||
/// in [`Align`]) to center its content within the visible area while
|
||||
/// still being scrollable when content overflows.
|
||||
pub fn vertical_centered(
|
||||
state: ClippedScrollStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
scrollbar_track_background: Fill,
|
||||
) -> Scrollable {
|
||||
let mut cs = ClippedScrollable::new(Axis::Vertical, child, state.clone());
|
||||
cs.fill_min_main_axis = true;
|
||||
Scrollable::vertical(
|
||||
state.scrollable_data.clone(),
|
||||
cs.finish_scrollable(),
|
||||
scrollbar_size,
|
||||
nonactive_scrollbar_thumb_background,
|
||||
active_scrollbar_thumb_background,
|
||||
scrollbar_track_background,
|
||||
)
|
||||
}
|
||||
|
||||
/// Constructs a new [`Scrollable`] element that scrolls horizontally,
|
||||
/// using a [`ClippedScrollable`] as the concrete [`ScrollableElement`].
|
||||
pub fn horizontal(
|
||||
state: ClippedScrollStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
scrollbar_track_background: Fill,
|
||||
) -> Scrollable {
|
||||
Scrollable::horizontal(
|
||||
state.scrollable_data.clone(),
|
||||
ClippedScrollable::new(Axis::Horizontal, child, state).finish_scrollable(),
|
||||
scrollbar_size,
|
||||
nonactive_scrollbar_thumb_background,
|
||||
active_scrollbar_thumb_background,
|
||||
scrollbar_track_background,
|
||||
)
|
||||
}
|
||||
|
||||
fn paint_internal(
|
||||
&mut self,
|
||||
origin: Vector2F,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
size: Vector2F,
|
||||
) {
|
||||
ctx.scene
|
||||
.start_layer(ClipBounds::BoundedBy(RectF::new(origin, size)));
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
|
||||
// It's possible that children elements of this ClippedScrollabe are not a part
|
||||
// of a stack and therefore won't have their position's flushed to the position cache.
|
||||
// The start() and end() calls here ensure that the positions are saved so we can scroll
|
||||
// to the position of a child.
|
||||
ctx.position_cache.start();
|
||||
self.child.paint(
|
||||
origin - self.state.scroll_start().as_f32().along(self.axis),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
ctx.position_cache.end();
|
||||
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
|
||||
/// Scrolls the provided `position_id` into view, if it exists, and paints the object.
|
||||
fn scroll_to_position_and_paint(
|
||||
&mut self,
|
||||
origin: Vector2F,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
size: Vector2F,
|
||||
position_id: String,
|
||||
mode: ScrollToPositionMode,
|
||||
) {
|
||||
// The relevant position can be a child of the `ClippedScrollable` so we need to first paint the
|
||||
// `ClippedScrollable` before we can determine the position, scroll the position into view, and paint the element as intended.
|
||||
// In order to prevent the first paint from having side effects, we clone the scene
|
||||
// before we invoke the first paint.
|
||||
//
|
||||
// Cloning the scene is cheap! On a bundled app, the following operations take < 10 microseconds:
|
||||
// - 100 warp tabs open
|
||||
// - Set line height to 0.2 and fill the block list and make a large number of glyphs
|
||||
// - Expanded all folders in warp drive and opened command palette (to check non-view ported elements)
|
||||
// - Render many images (as it turns out the scene only holds a rect and Arc, not the image content itself)
|
||||
// We want to avoid excesively cloning the scene though, because calling clone on the scene on multiple
|
||||
// `ClippedScrollable` elements in the paint code path caused this latency to be an order of magnitude
|
||||
// higher (300 microseconds).
|
||||
let cached_scene = ctx.scene.clone();
|
||||
self.paint_internal(origin, ctx, app, size);
|
||||
|
||||
if let Some(position_bounds) = ctx.position_cache.get_position(position_id) {
|
||||
let child_bounds = self.child.bounds().expect("bounds on child should be set");
|
||||
// It doesn't make sense to scroll to a position that is unrelated to the `ClippedScrollable`
|
||||
// so no-op if it is not within the bounds of the child element.
|
||||
if child_bounds.contains_rect(position_bounds) {
|
||||
let scroll_top = self.state.scroll_start();
|
||||
let viewport_bounds = self.bounds().expect("bounds should be set");
|
||||
|
||||
let scroll_delta =
|
||||
scroll_delta_for_axis(self.axis, viewport_bounds, position_bounds, mode);
|
||||
|
||||
self.state
|
||||
.scroll_to(scroll_top + scroll_delta.into_pixels());
|
||||
|
||||
*ctx.scene = cached_scene;
|
||||
self.paint_internal(origin, ctx, app, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ClippedScrollable {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
// The child should only be constrained horizontally, and allowed to grow
|
||||
// as tall as it desires. The height of the ClippedScrollable will still be
|
||||
// constrained by the incoming constraints.
|
||||
let mut child_constraint = SizeConstraint::tight_on_cross_axis(self.axis, constraint);
|
||||
|
||||
// When fill_min_main_axis is set, pass the visible size along the main
|
||||
// axis as the child's min constraint so centering elements (e.g. Align)
|
||||
// can fill and center their content within the visible area.
|
||||
if self.fill_min_main_axis {
|
||||
let visible = constraint.max.along(self.axis);
|
||||
if visible.is_finite() {
|
||||
match self.axis {
|
||||
Axis::Vertical => child_constraint.min.set_y(visible),
|
||||
Axis::Horizontal => child_constraint.min.set_x(visible),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let child_size = self.child.layout(child_constraint, ctx, app);
|
||||
let size = constraint.apply(child_size);
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let size = self.size().expect("size should be set by paint time");
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(origin, size));
|
||||
|
||||
let scroll_target = self
|
||||
.state
|
||||
.clipped_scroll_data
|
||||
.lock()
|
||||
.scroll_to_position
|
||||
.take();
|
||||
if let Some(ScrollTarget { position_id, mode }) = scroll_target {
|
||||
self.scroll_to_position_and_paint(origin, ctx, app, size, position_id, mode);
|
||||
} else {
|
||||
self.paint_internal(origin, ctx, app, size);
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
// Make sure that the new layout doesn't put the scroll bar in an invalid
|
||||
// location.
|
||||
if let Some(scroll_data) = self.scroll_data(app) {
|
||||
let max_scroll_top =
|
||||
(scroll_data.total_size - scroll_data.visible_px).max(Pixels::zero());
|
||||
let scroll_top = scroll_data.scroll_start;
|
||||
if scroll_top > max_scroll_top {
|
||||
self.state.scroll_to(max_scroll_top);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollableElement for ClippedScrollable {
|
||||
fn scroll_data(&self, _app: &AppContext) -> Option<ScrollData> {
|
||||
Some(ScrollData {
|
||||
scroll_start: self.state.scroll_start(),
|
||||
visible_px: (self.size()?.along(self.axis)).into_pixels(),
|
||||
total_size: self.child.size()?.along(self.axis).into_pixels(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scroll(&mut self, delta: Pixels, ctx: &mut EventContext) {
|
||||
let scroll_start = self.state.scroll_start();
|
||||
let child_size: Pixels = self
|
||||
.child
|
||||
.size()
|
||||
.expect("child should be laid out before scrolling")
|
||||
.along(self.axis)
|
||||
.into_pixels();
|
||||
|
||||
let clipped_size = self
|
||||
.size
|
||||
.expect("should be laid out before scrolling")
|
||||
.along(self.axis)
|
||||
.into_pixels();
|
||||
if child_size > clipped_size {
|
||||
let new_scroll_start = (scroll_start - delta)
|
||||
.max(Pixels::zero())
|
||||
.min(child_size - clipped_size);
|
||||
if (scroll_start - new_scroll_start).as_f32().abs() > f32::EPSILON {
|
||||
self.state.scroll_to(new_scroll_start);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_handle_scroll_wheel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clipped_scrollable_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
elements::{Axis, ConstrainedBox, Empty, Flex, ParentElement, SavePosition, Stack},
|
||||
platform::WindowStyle,
|
||||
units::IntoPixels,
|
||||
App, Element, Entity, Presenter, TypedActionView, WindowInvalidation,
|
||||
};
|
||||
|
||||
use super::{ClippedScrollStateHandle, ClippedScrollable, ScrollTarget, ScrollToPositionMode};
|
||||
|
||||
macro_rules! assert_float_eq {
|
||||
($lhs:expr, $rhs:expr) => {{
|
||||
let lhs = $lhs;
|
||||
let rhs = $rhs;
|
||||
assert!(
|
||||
(lhs - rhs).abs() < f32::EPSILON,
|
||||
"{} ({}) != {} ({})",
|
||||
lhs,
|
||||
stringify!($lhs),
|
||||
rhs,
|
||||
stringify!($rhs)
|
||||
);
|
||||
}};
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
scroll_handle: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
|
||||
fn render(&self, _: &crate::AppContext) -> Box<dyn crate::Element> {
|
||||
let mut children = vec![];
|
||||
for i in 0..10 {
|
||||
children.push(
|
||||
SavePosition::new(
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_height(20.)
|
||||
.with_width(100.)
|
||||
.finish(),
|
||||
&format!("child_{i}"),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(
|
||||
ClippedScrollable::new(
|
||||
Axis::Vertical,
|
||||
Flex::column().with_children(children).finish(),
|
||||
self.scroll_handle.clone(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_to_position() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scroll_state = view.read(app, |view, _| view.scroll_handle.clone());
|
||||
let window_size = vec2f(100., 100.);
|
||||
let scale_factor = 1.;
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
// The `ClippedScrollable` has 10 elements in total, each with a height of 20.
|
||||
// The window height is 100 so, with a scroll top of 0, the first 5 elements should be
|
||||
// in view.
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
|
||||
// An element fully below the scrollable area should be the last item in view
|
||||
// after we scroll to it.
|
||||
scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: "child_6".to_string(),
|
||||
mode: ScrollToPositionMode::FullyIntoView,
|
||||
});
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
assert_float_eq!(scroll_state.scroll_start().as_f32(), 40.);
|
||||
|
||||
// An element fully above the scrollable area should be the first item in view after
|
||||
// it's scrolled to.
|
||||
scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: "child_1".to_string(),
|
||||
mode: ScrollToPositionMode::FullyIntoView,
|
||||
});
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
assert_float_eq!(scroll_state.scroll_start().as_f32(), 20.);
|
||||
|
||||
// An element fully within the viewport should no-op.
|
||||
scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: "child_3".to_string(),
|
||||
mode: ScrollToPositionMode::FullyIntoView,
|
||||
});
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
assert_float_eq!(scroll_state.scroll_start().as_f32(), 20.);
|
||||
|
||||
// An element that is partially above the viewport should be scrolled fully within the viewport.
|
||||
// First, make the scroll top 1.0 pixels. We need to call build scene after this so the
|
||||
// position cache is updated appropriately.
|
||||
scroll_state.clipped_scroll_data.lock().scroll_start_px = (1_f32).into_pixels();
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
|
||||
// Now we can invoke the scroll to position API and verify the correct result.
|
||||
scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: "child_0".to_string(),
|
||||
mode: ScrollToPositionMode::FullyIntoView,
|
||||
});
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
assert_float_eq!(scroll_state.scroll_start().as_f32(), 0.);
|
||||
|
||||
// An element that is partially below the viewport should be scrolled fully within the viewport.
|
||||
// First, make the scroll top 1.0 pixels. We need to call build scene after this so the
|
||||
// position cache is updated appropriately.
|
||||
scroll_state.clipped_scroll_data.lock().scroll_start_px = (1_f32).into_pixels();
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
|
||||
// Now we can invoke the scroll to position API and verify the correct result.
|
||||
scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: "child_5".to_string(),
|
||||
mode: ScrollToPositionMode::FullyIntoView,
|
||||
});
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size, scale_factor, None, ctx);
|
||||
assert_float_eq!(scroll_state.scroll_start().as_f32(), 20.);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
elements::{
|
||||
ChildAnchor, ConstrainedBox, DispatchEventResult, EventHandler, Hoverable,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Rect,
|
||||
Stack, ZIndex,
|
||||
},
|
||||
platform::WindowStyle,
|
||||
App, AppContext, Entity, Event, Presenter, TypedActionView, ViewContext, WindowInvalidation,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
enum ElementIdentifier {
|
||||
BottomStack,
|
||||
TopStackBase,
|
||||
TopStackOverlay,
|
||||
Hoverable,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
// Maps identifier to number of mouse down events
|
||||
mouse_downs: HashMap<ElementIdentifier, usize>,
|
||||
mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_action("clipped_test:mouse_down", View::mouse_down);
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_down on element {identifier:?}");
|
||||
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn ui_name() -> &'static str {
|
||||
"clipped_test_view"
|
||||
}
|
||||
|
||||
// The element tree looks like the following:
|
||||
// - Scene
|
||||
// - Stack
|
||||
// - Bottom Stack
|
||||
// - Base (This acts as the base layer for the scene)
|
||||
// - BottomStack
|
||||
// - Top Stack
|
||||
// - TopStackBase (Bottom)
|
||||
// - TopStackOverlay (Top)
|
||||
//
|
||||
// --------------------------------
|
||||
// | TopStackBase | Hoverable | |
|
||||
// | (Clipped) | | | |
|
||||
// | --|---------- |
|
||||
// | | |
|
||||
// | -----------|------- |
|
||||
// | | Overlap | | |
|
||||
// |----------------- | |
|
||||
// | | | |
|
||||
// | | | |
|
||||
// | |TopStackOverlay | |
|
||||
// | ------------------- |
|
||||
// | |
|
||||
// | |
|
||||
// |-------------- |
|
||||
// | | |
|
||||
// | | |
|
||||
// | | |
|
||||
// | | |
|
||||
// |BottomStack | |
|
||||
// --------------------------------
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut bottom_stack = Stack::new();
|
||||
|
||||
bottom_stack.add_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(100.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
);
|
||||
bottom_stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action("clipped_test:mouse_down", ElementIdentifier::BottomStack);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 75.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
let mut top_stack = Stack::new();
|
||||
|
||||
top_stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action("clipped_test:mouse_down", ElementIdentifier::TopStackBase);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
top_stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action(
|
||||
"clipped_test:mouse_down",
|
||||
ElementIdentifier::TopStackOverlay,
|
||||
);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(15., 15.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
top_stack.add_positioned_child(
|
||||
Hoverable::new(self.mouse_state.clone(), |_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(20.)
|
||||
.with_height(8.)
|
||||
.finish()
|
||||
})
|
||||
.on_click(|evt, _, _| {
|
||||
evt.dispatch_action("clipped_test:mouse_down", ElementIdentifier::Hoverable);
|
||||
})
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(15., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(bottom_stack.finish());
|
||||
stack.add_child(Clipped::sized(top_stack.finish(), Vector2F::new(25., 25.)).finish());
|
||||
|
||||
// Force the Stack to take up the full size of the window by pulling
|
||||
// the minimum size constraint up to the size of the window.
|
||||
ConstrainedBox::new(stack.finish())
|
||||
.with_min_width(f32::MAX)
|
||||
.with_min_height(f32::MAX)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipped_element_click_handling() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 9);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Click on the bottom stack. This should work because the bottom
|
||||
// stack is not clipped.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the top stack base. This should work because the
|
||||
// click is within the clipped range of the top stack.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the overlap between top stack base and top stack overlay.
|
||||
// This should work because the click is still within the clipped
|
||||
// range of the top stack.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(20., 20.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the part of top stack overlay not overlapping with base.
|
||||
// This should not work because it is outside of the clip bound of the
|
||||
// base stack.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(30., 30.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the overlap between hoverable and top stack overlay.
|
||||
// This should work because the click is still within the clipped
|
||||
// range of the top stack.
|
||||
// Note Hoverable needs both mouse down and mouse up to fire the
|
||||
// on click event.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(20., 5.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(20., 5.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the part of hoverable not overlapping with base.
|
||||
// This should not work because it is outside of the clip bound of the
|
||||
// base stack.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(30., 5.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(30., 5.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
*view
|
||||
.mouse_downs
|
||||
.get(&ElementIdentifier::BottomStack)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
*view
|
||||
.mouse_downs
|
||||
.get(&ElementIdentifier::TopStackBase)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
*view
|
||||
.mouse_downs
|
||||
.get(&ElementIdentifier::TopStackOverlay)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
*view.mouse_downs.get(&ElementIdentifier::Hoverable).unwrap()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use crate::{
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
};
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SelectableElement, Selection, SelectionFragment, SizeConstraint,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
pub struct ConstrainedBox {
|
||||
child: Box<dyn Element>,
|
||||
constraint: SizeConstraint,
|
||||
}
|
||||
|
||||
impl ConstrainedBox {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
child,
|
||||
constraint: SizeConstraint {
|
||||
min: Vector2F::zero(),
|
||||
max: Vector2F::splat(f32::INFINITY),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_width(mut self, max_width: f32) -> Self {
|
||||
self.constraint.max.set_x(max_width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_min_width(mut self, min_width: f32) -> Self {
|
||||
self.constraint.min.set_x(min_width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_height(mut self, max_height: f32) -> Self {
|
||||
self.constraint.max.set_y(max_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_min_height(mut self, min_height: f32) -> Self {
|
||||
self.constraint.min.set_y(min_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_height(mut self, height: f32) -> Self {
|
||||
self.constraint.min.set_y(height);
|
||||
self.constraint.max.set_y(height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_width(mut self, width: f32) -> Self {
|
||||
self.constraint.min.set_x(width);
|
||||
self.constraint.max.set_x(width);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ConstrainedBox {
|
||||
fn layout(
|
||||
&mut self,
|
||||
mut constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
constraint.min = constraint.min.max(self.constraint.min);
|
||||
constraint.max = constraint.max.min(self.constraint.max);
|
||||
constraint.min = constraint.min.min(constraint.max);
|
||||
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for ConstrainedBox {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, DropShadow, Element, EventContext, Fill, LayoutContext, Margin,
|
||||
Overdraw, Padding, PaintContext, Point, SelectableElement, Selection, SelectionFragment,
|
||||
SizeConstraint,
|
||||
};
|
||||
pub use crate::scene::{Border, CornerRadius, Radius};
|
||||
use crate::{
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
ClipBounds, Gradient,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
pub struct Container {
|
||||
margin: Margin,
|
||||
padding: Padding,
|
||||
overdraw: Overdraw,
|
||||
background: Fill,
|
||||
border: Border,
|
||||
corner_radius: CornerRadius,
|
||||
drop_shadow: Option<DropShadow>,
|
||||
foreground_overlay: Option<Fill>,
|
||||
child: Box<dyn Element>,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
#[cfg(debug_assertions)]
|
||||
/// Captures the location of the constructor call site. This is used for debugging purposes.
|
||||
construction_location: Option<&'static std::panic::Location<'static>>,
|
||||
}
|
||||
|
||||
impl Container {
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
margin: Margin::default(),
|
||||
padding: Padding::default(),
|
||||
overdraw: Overdraw::default(),
|
||||
background: Fill::None,
|
||||
border: Border::default(),
|
||||
corner_radius: CornerRadius::default(),
|
||||
foreground_overlay: None,
|
||||
drop_shadow: None,
|
||||
child,
|
||||
size: None,
|
||||
origin: None,
|
||||
#[cfg(debug_assertions)]
|
||||
construction_location: Some(std::panic::Location::caller()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_drop_shadow(mut self, drop_shadow: DropShadow) -> Self {
|
||||
self.drop_shadow = Some(drop_shadow);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_foreground_overlay<F>(mut self, overlay: F) -> Self
|
||||
where
|
||||
F: Into<Fill>,
|
||||
{
|
||||
self.foreground_overlay = Some(overlay.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_margin_top(mut self, margin: f32) -> Self {
|
||||
self.margin.top = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_margin_bottom(mut self, margin: f32) -> Self {
|
||||
self.margin.bottom = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_margin_left(mut self, margin: f32) -> Self {
|
||||
self.margin.left = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_margin_right(mut self, margin: f32) -> Self {
|
||||
self.margin.right = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_uniform_margin(mut self, margin: f32) -> Self {
|
||||
self.margin = Margin {
|
||||
top: margin,
|
||||
left: margin,
|
||||
bottom: margin,
|
||||
right: margin,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_uniform_padding(mut self, padding: f32) -> Self {
|
||||
self.padding = Padding {
|
||||
top: padding,
|
||||
left: padding,
|
||||
bottom: padding,
|
||||
right: padding,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_padding_right(mut self, padding: f32) -> Self {
|
||||
self.padding.right = padding;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the horizontal margin (`margin_left` and `margin_right`) to that of `margin`.
|
||||
pub fn with_horizontal_margin(mut self, margin: f32) -> Self {
|
||||
self.margin.left = margin;
|
||||
self.margin.right = margin;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the vertical margin (`margin_top` and `margin_bottom`) to that of `margin`.
|
||||
pub fn with_vertical_margin(mut self, margin: f32) -> Self {
|
||||
self.margin.top = margin;
|
||||
self.margin.bottom = margin;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the horizontal padding (`padding_left` and `padding_right`) to that of `padding`.
|
||||
pub fn with_horizontal_padding(mut self, padding: f32) -> Self {
|
||||
self.padding.left = padding;
|
||||
self.padding.right = padding;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the vertical padding (`padding_top` and `padding_bottom`) to that of `padding`.
|
||||
pub fn with_vertical_padding(mut self, padding: f32) -> Self {
|
||||
self.padding.top = padding;
|
||||
self.padding.bottom = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_padding_left(mut self, padding: f32) -> Self {
|
||||
self.padding.left = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_padding_bottom(mut self, padding: f32) -> Self {
|
||||
self.padding.bottom = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_padding_top(mut self, padding: f32) -> Self {
|
||||
self.padding.top = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_padding(mut self, padding: Padding) -> Self {
|
||||
self.padding = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background<F>(mut self, fill: F) -> Self
|
||||
where
|
||||
F: Into<Fill>,
|
||||
{
|
||||
self.background = fill.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background_color(mut self, color: ColorU) -> Self {
|
||||
self.background = Fill::Solid(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_horizontal_background_gradient(
|
||||
mut self,
|
||||
start_color: ColorU,
|
||||
end_color: ColorU,
|
||||
) -> Self {
|
||||
self.background = Fill::Gradient {
|
||||
start: vec2f(0.0, 0.0),
|
||||
end: vec2f(1.0, 0.0),
|
||||
start_color,
|
||||
end_color,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background_gradient(
|
||||
mut self,
|
||||
start: Vector2F,
|
||||
end: Vector2F,
|
||||
gradient: Gradient,
|
||||
) -> Self {
|
||||
self.background = Fill::Gradient {
|
||||
start,
|
||||
end,
|
||||
start_color: gradient.start,
|
||||
end_color: gradient.end,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_border(mut self, border: impl Into<Border>) -> Self {
|
||||
self.border = border.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_overdraw_bottom(mut self, overdraw: f32) -> Self {
|
||||
self.overdraw.bottom = overdraw;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_overdraw_left(mut self, overdraw: f32) -> Self {
|
||||
self.overdraw.left = overdraw;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_vertical_overdraw(mut self, overdraw: f32) -> Self {
|
||||
self.overdraw.top = overdraw;
|
||||
self.overdraw.bottom = overdraw;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_corner_radius(mut self, radius: CornerRadius) -> Self {
|
||||
self.corner_radius = radius;
|
||||
self
|
||||
}
|
||||
|
||||
fn margin_size(&self) -> Vector2F {
|
||||
vec2f(
|
||||
self.margin.left + self.margin.right,
|
||||
self.margin.top + self.margin.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
fn padding_size(&self) -> Vector2F {
|
||||
vec2f(
|
||||
self.padding.left + self.padding.right,
|
||||
self.padding.top + self.padding.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
fn border_size(&self) -> Vector2F {
|
||||
let mut x = 0.0;
|
||||
if self.border.left {
|
||||
x += self.border.width;
|
||||
}
|
||||
if self.border.right {
|
||||
x += self.border.width;
|
||||
}
|
||||
|
||||
let mut y = 0.0;
|
||||
if self.border.top {
|
||||
y += self.border.width;
|
||||
}
|
||||
if self.border.bottom {
|
||||
y += self.border.width;
|
||||
}
|
||||
|
||||
vec2f(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Container {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let size_buffer = self.margin_size() + self.padding_size() + self.border_size();
|
||||
|
||||
let child_constraint = SizeConstraint {
|
||||
min: (constraint.min - size_buffer).max(Vector2F::zero()),
|
||||
max: (constraint.max - size_buffer).max(Vector2F::zero()),
|
||||
};
|
||||
let child_size = self.child.layout(child_constraint, ctx, app);
|
||||
let size = child_size + size_buffer;
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
let size = self.size.unwrap() - self.margin_size()
|
||||
+ vec2f(self.overdraw.right, self.overdraw.bottom);
|
||||
let origin = origin + vec2f(self.margin.left, self.margin.top)
|
||||
- vec2f(self.overdraw.left, self.overdraw.top);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
ctx.scene
|
||||
.set_location_for_panic_logging(self.construction_location);
|
||||
|
||||
let rect = ctx
|
||||
.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(origin, size))
|
||||
.with_background(self.background)
|
||||
.with_border(self.border)
|
||||
.with_corner_radius(self.corner_radius);
|
||||
if let Some(drop_shadow) = self.drop_shadow {
|
||||
rect.with_drop_shadow(drop_shadow);
|
||||
}
|
||||
|
||||
let mut child_origin = origin
|
||||
+ vec2f(self.overdraw.left, self.overdraw.top)
|
||||
+ vec2f(self.padding.left, self.padding.top);
|
||||
if self.border.left {
|
||||
child_origin.set_x(child_origin.x() + self.border.width);
|
||||
}
|
||||
if self.border.top {
|
||||
child_origin.set_y(child_origin.y() + self.border.width);
|
||||
}
|
||||
self.child.paint(child_origin, ctx, app);
|
||||
|
||||
// Start a new layer on top of the current container to render the foreground overlay.
|
||||
if let Some(overlay) = self.foreground_overlay {
|
||||
ctx.scene.start_layer(ClipBounds::ActiveLayer);
|
||||
ctx.scene.set_active_layer_click_through();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
ctx.scene
|
||||
.set_location_for_panic_logging(self.construction_location);
|
||||
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(origin, size))
|
||||
.with_background(overlay)
|
||||
.with_corner_radius(self.corner_radius);
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for Container {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "container_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
elements::{ConstrainedBox, DispatchEventResult, EventHandler, Rect, ZIndex},
|
||||
platform::WindowStyle,
|
||||
App, AppContext, Entity, Event, Presenter, TypedActionView, ViewContext, WindowInvalidation,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
enum ElementIdentifier {
|
||||
BottomContainer,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
// Maps identifier to number of mouse down events
|
||||
mouse_downs: HashMap<ElementIdentifier, usize>,
|
||||
}
|
||||
|
||||
fn init(app: &mut AppContext) {
|
||||
app.add_action("container_test:mouse_down", View::mouse_down);
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_down on element {identifier:?}");
|
||||
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn ui_name() -> &'static str {
|
||||
"container_test_view"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(100.)
|
||||
.with_width(100.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action(
|
||||
"container_test:mouse_down",
|
||||
ElementIdentifier::BottomContainer,
|
||||
);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_foreground_overlay(Fill::Solid(ColorU::white()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_element_overlay_click_handling() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 2);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(50., 50.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
*view
|
||||
.mouse_downs
|
||||
.get(&ElementIdentifier::BottomContainer)
|
||||
.unwrap()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Module containing the definition of [`DebugElement`].
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SizeConstraint,
|
||||
};
|
||||
use crate::event::DispatchedEvent;
|
||||
use crate::scene::{Border, Dash};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
/// A debug element that draws a dashed around its child. Intended for quick visual debugging.
|
||||
pub struct DebugElement {
|
||||
child: Box<dyn Element>,
|
||||
color: ColorU,
|
||||
border_width: f32,
|
||||
dash: Dash,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
}
|
||||
|
||||
/// Options for configuration of the [`DebugElement`].
|
||||
#[derive(Default)]
|
||||
pub struct DebugOptions {
|
||||
/// The color to use for the border. Defaults to red.
|
||||
pub color_override: Option<ColorU>,
|
||||
/// The width of the border. Defaults to 2.0.
|
||||
pub border_width_override: Option<f32>,
|
||||
/// The dash pattern to use for the border. Defaults to a 4.0 dash and 2.0 gap.
|
||||
pub dash_override: Option<Dash>,
|
||||
}
|
||||
|
||||
impl DebugElement {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self::new_with_options(child, DebugOptions::default())
|
||||
}
|
||||
|
||||
pub fn new_with_options(child: Box<dyn Element>, options: DebugOptions) -> Self {
|
||||
Self {
|
||||
child,
|
||||
color: options
|
||||
.color_override
|
||||
.unwrap_or(ColorU::new(255, 0, 0, 255)),
|
||||
border_width: options.border_width_override.unwrap_or(2.0),
|
||||
dash: options.dash_override.unwrap_or(Dash {
|
||||
dash_length: 4.0,
|
||||
gap_length: 2.0,
|
||||
force_consistent_gap_length: true,
|
||||
}),
|
||||
size: None,
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DebugElement {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let child_size = self.child.layout(constraint, ctx, app);
|
||||
self.size = Some(child_size);
|
||||
child_size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
|
||||
self.child.paint(origin, ctx, app);
|
||||
|
||||
let size = self.size.unwrap_or(Vector2F::zero());
|
||||
|
||||
// Draw a dashed border around the child without including the size of the
|
||||
// border in the overall size of the element.
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(origin, size))
|
||||
.with_border(
|
||||
Border::all(self.border_width)
|
||||
.with_border_color(self.color)
|
||||
.with_dashed_border(self.dash),
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Debug {
|
||||
fn debug(self) -> Box<dyn Element>;
|
||||
|
||||
fn debug_with_options(self, options: DebugOptions) -> Box<dyn Element>;
|
||||
}
|
||||
|
||||
impl Debug for Box<dyn Element> {
|
||||
fn debug(self) -> Box<dyn Element> {
|
||||
DebugElement::new(self).finish()
|
||||
}
|
||||
|
||||
fn debug_with_options(self, options: DebugOptions) -> Box<dyn Element> {
|
||||
Box::new(DebugElement::new_with_options(self, options))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
use super::Point;
|
||||
use crate::{
|
||||
event::DispatchedEvent, AfterLayoutContext, AppContext, ClipBounds, Element, Event,
|
||||
EventContext, LayoutContext, PaintContext, SizeConstraint,
|
||||
};
|
||||
|
||||
type DismissCallback = Box<dyn FnMut(&mut EventContext, &AppContext)>;
|
||||
|
||||
/// Element that is used to dismiss its child element.
|
||||
/// Clicking on this element is equivalent to clicking outside the child element.
|
||||
pub struct Dismiss {
|
||||
child: Box<dyn Element>,
|
||||
dismiss_handler: Option<DismissCallback>,
|
||||
origin: Option<Point>,
|
||||
/// Whether or not the element should make the rest of the window unresponsive. All mouse events
|
||||
/// are handled by the [`Dismiss`] rather than being propagated further down in the element
|
||||
/// hierarchy.
|
||||
prevent_interaction_with_other_elements: bool,
|
||||
}
|
||||
|
||||
impl Dismiss {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
child,
|
||||
dismiss_handler: None,
|
||||
origin: None,
|
||||
prevent_interaction_with_other_elements: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a handler for when the dismiss is clicked
|
||||
pub fn on_dismiss<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext),
|
||||
{
|
||||
self.dismiss_handler = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Prevents interactions with any other elements outside of the [`Dismiss`]. All events are
|
||||
/// handled by this element and are _not_ propagated further down the element hierarchy.
|
||||
pub fn prevent_interaction_with_other_elements(mut self) -> Self {
|
||||
self.prevent_interaction_with_other_elements = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Dismiss {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
|
||||
if !self.prevent_interaction_with_other_elements {
|
||||
// Create a new layer for the contents so that we can distinguish click events that happen
|
||||
// outside of the child element we want to dismiss
|
||||
ctx.scene.start_layer(ClipBounds::ActiveLayer);
|
||||
self.child.paint(origin, ctx, app);
|
||||
ctx.scene.stop_layer();
|
||||
} else {
|
||||
// Create an invisible rect underneath the child that spans the window and prevents all
|
||||
// underlayed elements from responding to events, until a click occurs.
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(Vector2F::zero(), ctx.window_size));
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
if self.child.dispatch_event(event, ctx, app) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let z_index = self.z_index().unwrap();
|
||||
|
||||
match (
|
||||
self.dismiss_handler.as_mut(),
|
||||
event.at_z_index(z_index, ctx),
|
||||
) {
|
||||
// If the event is available at the root z-index, that means it isn't covered by the
|
||||
// child element, which means the user is clicking outside of the child element
|
||||
(Some(handler), Some(Event::LeftMouseDown { .. })) => {
|
||||
handler(ctx, app);
|
||||
}
|
||||
(None, Some(Event::LeftMouseDown { .. })) => {
|
||||
log::warn!("Dismiss underlay was clicked but no handler was set!");
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::elements::DropTargetData;
|
||||
use crate::platform::Cursor;
|
||||
use crate::{
|
||||
elements::Point, AfterLayoutContext, AppContext, Element, EventContext, LayoutContext,
|
||||
PaintContext, SizeConstraint,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
event::{DispatchedEvent, Event},
|
||||
presenter::PositionCache,
|
||||
scene::{ClipBounds, ZIndex},
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
/// The default drag threshold used when no value is explicitly set by the creator
|
||||
const DEFAULT_DRAG_THRESHOLD: f32 = 5.;
|
||||
|
||||
/// Opaque state container for maintaining drag across re-renders
|
||||
///
|
||||
/// Cheap to clone so that the owning View can easily create new Elements with the state
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DraggableState {
|
||||
inner: Arc<Mutex<DragState>>,
|
||||
suppress_overlay_paint: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl DraggableState {
|
||||
/// Determine if the current state represents an actual drag or not
|
||||
pub fn is_dragging(&self) -> bool {
|
||||
matches!(*self.inner.lock(), DragState::Dragging { .. })
|
||||
}
|
||||
|
||||
/// When true, the drag overlay visual will not be painted.
|
||||
/// Used during preview capture to exclude the drag ghost from the captured frame.
|
||||
pub fn suppress_overlay_paint(&self) -> bool {
|
||||
self.suppress_overlay_paint.load(AtomicOrdering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set whether to suppress painting the drag overlay visual.
|
||||
pub fn set_suppress_overlay_paint(&self, suppress: bool) {
|
||||
self.suppress_overlay_paint
|
||||
.store(suppress, AtomicOrdering::Relaxed);
|
||||
}
|
||||
|
||||
/// Copy the actual drag state value out of the container
|
||||
fn read(&self) -> DragState {
|
||||
*self.inner.lock()
|
||||
}
|
||||
|
||||
/// Returns the cursor offset within the draggable element, if drag state is available.
|
||||
pub fn cursor_offset_within_element(&self) -> Option<Vector2F> {
|
||||
match self.read() {
|
||||
DragState::None => None,
|
||||
DragState::WaitingToDrag {
|
||||
mouse_down_offset, ..
|
||||
} => Some(-mouse_down_offset),
|
||||
DragState::Dragging { mouse_offset, .. } => Some(-mouse_offset),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn adjust_mouse_position(&self, delta: Vector2F) {
|
||||
let mut guard = self.inner.lock();
|
||||
if let DragState::Dragging { mouse_position, .. } = &mut *guard {
|
||||
*mouse_position += delta;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_dragging(&self, new_mouse_position: Vector2F, new_mouse_offset: Vector2F) {
|
||||
self.store(DragState::Dragging {
|
||||
mouse_position: new_mouse_position,
|
||||
mouse_offset: new_mouse_offset,
|
||||
is_on_accepted_drop_target: false,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cancel_drag(&self) {
|
||||
self.store(DragState::None);
|
||||
}
|
||||
|
||||
/// Update the drag state with a new value
|
||||
fn store(&self, new_state: DragState) {
|
||||
*self.inner.lock() = new_state;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal state tracking whether or not we are dragging the element and the parameters of the
|
||||
/// drag
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum DragState {
|
||||
/// No dragging is happening
|
||||
#[default]
|
||||
None,
|
||||
/// The mouse is held down, but has not yet moved beyond the drag threshold
|
||||
WaitingToDrag {
|
||||
/// The position where the mouse down occurred, used to check against the threshold
|
||||
mouse_down_position: Vector2F,
|
||||
/// The offset from the mouse down position to the natural origin of the element
|
||||
mouse_down_offset: Vector2F,
|
||||
},
|
||||
Dragging {
|
||||
/// The most recently reported position of the mouse
|
||||
mouse_position: Vector2F,
|
||||
/// The offset from the mouse to the origin of the Element
|
||||
///
|
||||
/// This is determined by the mouse position when dragging starts and is used during
|
||||
/// dragging to calculate the dragged origin via `origin = mouse_position + mouse_offset`
|
||||
mouse_offset: Vector2F,
|
||||
/// Whether the dragged element is currently on an accepted drop target.
|
||||
is_on_accepted_drop_target: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The axis to which a draggable is fixed, limiting it to only move in one direction
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum DragAxis {
|
||||
HorizontalOnly,
|
||||
VerticalOnly,
|
||||
}
|
||||
|
||||
type BoundsCallback = Box<dyn FnMut(&PositionCache, Vector2F) -> Option<RectF>>;
|
||||
|
||||
/// The bounds for dragging this element.
|
||||
///
|
||||
/// This can be set to a fixed rectangle in the scene or to a callback that is used to calculate
|
||||
/// the bounds.
|
||||
enum DragBounds {
|
||||
None,
|
||||
Fixed(RectF),
|
||||
Callback(BoundsCallback),
|
||||
}
|
||||
|
||||
impl DragBounds {
|
||||
fn calculate(
|
||||
&mut self,
|
||||
position_cache: &PositionCache,
|
||||
window_size: Vector2F,
|
||||
) -> Option<RectF> {
|
||||
match self {
|
||||
DragBounds::None => None,
|
||||
DragBounds::Fixed(bounds) => Some(*bounds),
|
||||
DragBounds::Callback(callback) => callback(position_cache, window_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, RectF)>;
|
||||
|
||||
/// Handler when a `Draggable` is dragged and dropped. Includes the data of a [`crate::elements::DropTarget`] if
|
||||
/// the `Draggable` was dropped on a `DropTarget`.
|
||||
type DragDropHandler =
|
||||
Box<dyn FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>)>;
|
||||
|
||||
pub enum AcceptedByDropTarget {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
/// Callback that determines whether this [`Draggable`] can be dropped on a `DropTarget` that
|
||||
/// contains [`DropTargetData`].
|
||||
type AcceptedByDropTargetHandler =
|
||||
Box<dyn Fn(&dyn DropTargetData, &AppContext) -> AcceptedByDropTarget>;
|
||||
|
||||
/// A container element that can be freely dragged and dropped around the screen
|
||||
///
|
||||
/// Dragging starts when the mouse is held down and dragged at least a threshold distance (default
|
||||
/// 5 pixels) away from where it started. Dragging stops when the mouse is released.
|
||||
///
|
||||
/// ## Layout and Painting
|
||||
///
|
||||
/// While dragging, the Element is still laid out using its original position in the Element tree,
|
||||
/// however it is painted at the location of the mouse. Once dragging stops, it returns to being
|
||||
/// painted in the normal tree element position (i.e. the dragged position is not maintained after
|
||||
/// dragging stops).
|
||||
///
|
||||
/// ## Limiting the Draggable Area
|
||||
///
|
||||
/// There are two complementary ways to limit the space that the Element can be dragged:
|
||||
///
|
||||
/// 1. Specifying a drag axis so that the Element can only be dragged in one direction.
|
||||
/// 2. Specifying bounds that limit the Element to only dragging within a specific rectangle.
|
||||
///
|
||||
/// ### Fixed Axis
|
||||
///
|
||||
/// To limit the Draggable to only move in a single direction, call `with_drag_axis` and pass it
|
||||
/// the appropriate direction (either `DragAxis::HorizontalOnly` or `DragAxis::VerticalOnly`). The
|
||||
/// Element will be able to freely move in the given direction, but will not move at all in the
|
||||
/// perpendicular direction.
|
||||
///
|
||||
/// ### Bounding Box
|
||||
///
|
||||
/// To specify a bounding box in which the Element is confined, call either:
|
||||
///
|
||||
/// * `with_drag_bounds` - Takes a fixed `RectF` value and uses that for the bounds.
|
||||
/// * `with_drag_bounds_callback` - Takes a callback—which accepts a `&StackContext` and the window
|
||||
/// size—that returns an `Option<RectF>` to indicate the bounds (or lack thereof).
|
||||
///
|
||||
/// In either case, if a bounding rectangle exists, the Element will be confined to only drag
|
||||
/// completely within that rectangle. If it happens that the bounding box is smaller than the
|
||||
/// Element, the top-left corner will be fixed within the bounding box and any overflow will happen
|
||||
/// to the right or below the bounds.
|
||||
///
|
||||
/// If you specify a callback for calculating the bounds, it will be called each time the Element
|
||||
/// is painted and the value will be cached for subsequent events.
|
||||
///
|
||||
/// ## Callbacks
|
||||
///
|
||||
/// There are three event callbacks that can be used to react to dragging:
|
||||
///
|
||||
/// - `on_drag_start`: Called when the `drag_threshold` is crossed and dragging begins.
|
||||
/// - `on_drag`: Called on mouse move while dragging.
|
||||
/// - `on_drop`: Called on mouse up when dragging stops. If the `Draggable` was dropped on
|
||||
/// a [`crate::elements::DropTarget`] the data of that `DropTarget` is passed as a parameter.
|
||||
///
|
||||
/// All of the callbacks receive three parameters:
|
||||
///
|
||||
/// - An `&mut EventContext`
|
||||
/// - An `&AppContext`
|
||||
/// - A `RectF` representing the current painted position and size of the Element.
|
||||
///
|
||||
/// Note: For `on_drag_start`, the `RectF` passed will be the original position of the Element when
|
||||
/// the mouse was first pressed, not the shifted position after crossing the threshold.
|
||||
///
|
||||
/// ## Child Events
|
||||
///
|
||||
/// While this element is actively being dragged, all events to the child Element will be
|
||||
/// suppressed, the drag behavior will take precedence over any other events.
|
||||
///
|
||||
///
|
||||
/// ## Drop Targets
|
||||
///
|
||||
/// `Draggable`s can optionally be dropped on [`crate::elements::DropTarget`]s. When dropped, the
|
||||
/// [`:DropTargetData`] of the `DropTarget` is included as a parameter to identify the `DropTarget`
|
||||
/// the element was dropped on.
|
||||
pub struct Draggable {
|
||||
state: DraggableState,
|
||||
child: Box<dyn Element>,
|
||||
alternate_drag_element: Option<Box<dyn Element>>,
|
||||
child_max_z_index: Option<ZIndex>,
|
||||
unmodified_origin: Option<Vector2F>,
|
||||
drag_threshold: f32,
|
||||
drag_axis: Option<DragAxis>,
|
||||
drag_bounds: DragBounds,
|
||||
// Cache of the bounds value, used to avoid redundant calls to `DragBounds::Callback`. This is
|
||||
// updated on every call to `paint` so that even if the Element is laid out again, the value is
|
||||
// accurate for subsequent events.
|
||||
bounds_cache: Option<RectF>,
|
||||
/// If true, keeps the original element visible in its original position during drag.
|
||||
keep_original_visible: bool,
|
||||
|
||||
start_handler: Option<Handler>,
|
||||
drag_handler: Option<DragDropHandler>,
|
||||
is_accepted_by_drop_target_handler: Option<AcceptedByDropTargetHandler>,
|
||||
drop_handler: Option<DragDropHandler>,
|
||||
/// Whether to use the copy cursor while dragging on a valid drop target.
|
||||
use_copy_cursor_when_dragging_over_drop_target: bool,
|
||||
}
|
||||
|
||||
impl Draggable {
|
||||
pub fn new(state: DraggableState, child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
child,
|
||||
alternate_drag_element: None,
|
||||
child_max_z_index: None,
|
||||
unmodified_origin: None,
|
||||
drag_threshold: DEFAULT_DRAG_THRESHOLD,
|
||||
drag_axis: None,
|
||||
drag_bounds: DragBounds::None,
|
||||
bounds_cache: None,
|
||||
keep_original_visible: false,
|
||||
start_handler: None,
|
||||
drag_handler: None,
|
||||
is_accepted_by_drop_target_handler: None,
|
||||
drop_handler: None,
|
||||
use_copy_cursor_when_dragging_over_drop_target: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom drag threshold, in pixels.
|
||||
pub fn with_drag_threshold(mut self, threshold: f32) -> Self {
|
||||
self.drag_threshold = threshold;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a custom drag axis.
|
||||
pub fn with_drag_axis(mut self, axis: DragAxis) -> Self {
|
||||
self.drag_axis = Some(axis);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets an alternate element to be rendered while the drag is active
|
||||
pub fn with_alternate_drag_element(mut self, element: Box<dyn Element>) -> Self {
|
||||
self.alternate_drag_element = Some(element);
|
||||
self
|
||||
}
|
||||
|
||||
/// When true, keeps the original element visible in its original position during drag,
|
||||
/// showing both the original and the dragged copy.
|
||||
pub fn with_keep_original_visible(mut self, keep_visible: bool) -> Self {
|
||||
self.keep_original_visible = keep_visible;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set custom bounds to limit where the element can be dragged.
|
||||
///
|
||||
/// Note: If the bounds are smaller than the element along either axis, the top-left corner
|
||||
/// will be clamped to the minimum value of the bounds along that axis.
|
||||
pub fn with_drag_bounds(mut self, bounds: RectF) -> Self {
|
||||
self.drag_bounds = DragBounds::Fixed(bounds);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to use the copy cursor when dragging over a drop target.
|
||||
pub fn use_copy_cursor_when_dragging_over_drop_target(mut self) -> Self {
|
||||
self.use_copy_cursor_when_dragging_over_drop_target = true;
|
||||
self
|
||||
}
|
||||
/// Set a custom bounds callback used to calculate the limits of dragging.
|
||||
///
|
||||
/// The value will be calculated whenever the element is painted and cached for use in
|
||||
/// subsequent events.
|
||||
///
|
||||
/// Note: If the bounds are smaller than the element along either axis, the top-left corner
|
||||
/// will be clamped to the minimum value of the bounds along that axis.
|
||||
pub fn with_drag_bounds_callback<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&PositionCache, Vector2F) -> Option<RectF> + 'static,
|
||||
{
|
||||
self.drag_bounds = DragBounds::Callback(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback which will be called on mouse down when dragging starts.
|
||||
pub fn on_drag_start<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext, RectF) + 'static,
|
||||
{
|
||||
self.start_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback which will be called on mouse move while dragging.
|
||||
pub fn on_drag<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
|
||||
{
|
||||
self.drag_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback which will be called on mouse up when dragging ends.
|
||||
pub fn on_drop<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
|
||||
{
|
||||
self.drop_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback to determine if a given DropTarget will accept this draggable
|
||||
/// for drop or drag callbacks
|
||||
pub fn with_accepted_by_drop_target_fn<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&dyn DropTargetData, &AppContext) -> AcceptedByDropTarget + 'static,
|
||||
{
|
||||
self.is_accepted_by_drop_target_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback which will be called on mouse down when dragging starts.
|
||||
pub fn set_on_drag_start<F>(&mut self, callback: F)
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext, RectF) + 'static,
|
||||
{
|
||||
self.start_handler = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Add a callback which will be called on mouse move while dragging.
|
||||
pub fn set_on_drag<F>(&mut self, callback: F)
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
|
||||
{
|
||||
self.drag_handler = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Add a callback which will be called on mouse up when dragging ends.
|
||||
pub fn set_on_drop<F>(&mut self, callback: F)
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
|
||||
{
|
||||
self.drop_handler = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Determine the drag origin based on the specified axis and any cached bounds.
|
||||
fn drag_origin(&self, mouse_position: Vector2F, mouse_offset: Vector2F) -> Vector2F {
|
||||
let unclamped_origin = match self.drag_axis {
|
||||
// By default, we allow full drag in both directions, so we can use Vector addition
|
||||
// to determine the appropriate origin.
|
||||
None => mouse_position + mouse_offset,
|
||||
Some(DragAxis::HorizontalOnly) => {
|
||||
// For horizontal-only drag, we use the x value from the mouse position and keep
|
||||
// the default y value from the laid-out element
|
||||
let x = mouse_position.x() + mouse_offset.x();
|
||||
let y = self.unmodified_origin.expect("origin should exist").y();
|
||||
Vector2F::new(x, y)
|
||||
}
|
||||
Some(DragAxis::VerticalOnly) => {
|
||||
// Similarly, for vertical-only drag, we use the x value from the laid-out element
|
||||
// and the y value from the mouse
|
||||
let x = self.unmodified_origin.expect("origin should exist").x();
|
||||
let y = mouse_position.y() + mouse_offset.y();
|
||||
Vector2F::new(x, y)
|
||||
}
|
||||
};
|
||||
|
||||
match self.bounds_cache {
|
||||
Some(bounds) => {
|
||||
let size = self.size().expect("size should be set");
|
||||
|
||||
let min_x = bounds.min_x();
|
||||
let max_x = (bounds.max_x() - size.x()).max(min_x);
|
||||
let x = unclamped_origin.x().clamp(min_x, max_x);
|
||||
|
||||
let min_y = bounds.min_y();
|
||||
let max_y = (bounds.max_y() - size.y()).max(min_y);
|
||||
let y = unclamped_origin.y().clamp(min_y, max_y);
|
||||
|
||||
Vector2F::new(x, y)
|
||||
}
|
||||
None => unclamped_origin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`DropTargetData`] for a [`crate::elements::DropTarget`] that overlaps with
|
||||
/// `rect`. Returns `None` if there is a no `DropTarget` at the location.
|
||||
///
|
||||
/// If multiple `DropTarget`s are matched, the one with the smallest size (by area) is
|
||||
/// returned. If the areas are the same, then we return the drop target with the closest center
|
||||
/// to the drag position center.
|
||||
fn compute_drop_target_data(
|
||||
rect: RectF,
|
||||
accepted_function: &AcceptedByDropTargetHandler,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> Option<Arc<dyn DropTargetData>> {
|
||||
ctx.drop_target_data()
|
||||
.filter(|drop_target_position| {
|
||||
drop_target_position.bounds().intersection(rect).is_some()
|
||||
&& match accepted_function(drop_target_position.data().as_ref(), app) {
|
||||
AcceptedByDropTarget::Yes => true,
|
||||
AcceptedByDropTarget::No => false,
|
||||
}
|
||||
})
|
||||
.sorted_by(|position_a, position_b| {
|
||||
if position_a.area().round() != position_b.area().round() {
|
||||
position_a.area().cmp(&position_b.area())
|
||||
} else {
|
||||
let drag_center = rect.center();
|
||||
let position_a_center_distance =
|
||||
(drag_center - position_a.bounds().center()).length();
|
||||
let position_b_center_distance =
|
||||
(drag_center - position_b.bounds().center()).length();
|
||||
if position_a_center_distance < position_b_center_distance {
|
||||
Ordering::Less
|
||||
} else {
|
||||
Ordering::Greater
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(|drop_target_position| drop_target_position.data().clone())
|
||||
.next()
|
||||
}
|
||||
|
||||
/// Computes the mouse offset based on whether or not there's a specified alternate child. If there's
|
||||
/// an alternate child, the calculated offset will be based on the ratio of the sizes to the base child
|
||||
/// versus the alternate child.
|
||||
///
|
||||
/// From the user's perspective, this ensures that the mouse position is in the same relative position
|
||||
/// in the alternate element as where they started the drag.
|
||||
fn compute_mouse_offset(&self, base_mouse_offset: Vector2F, child_size: Vector2F) -> Vector2F {
|
||||
if let Some(alternate_child) = &self.alternate_drag_element {
|
||||
let alternate_child_size = alternate_child.size().expect("size should exist");
|
||||
|
||||
let size_difference_ratio = Vector2F::new(
|
||||
alternate_child_size.x() / child_size.x(),
|
||||
alternate_child_size.y() / child_size.y(),
|
||||
);
|
||||
Vector2F::new(
|
||||
base_mouse_offset.x() * size_difference_ratio.x(),
|
||||
base_mouse_offset.y() * size_difference_ratio.y(),
|
||||
)
|
||||
} else {
|
||||
base_mouse_offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Draggable {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
if let Some(alternate_child) = &mut self.alternate_drag_element {
|
||||
// For the alternate drag element, we ignore parent size contraints
|
||||
alternate_child.layout(
|
||||
SizeConstraint::new(vec2f(0.0, 0.0), vec2f(f32::INFINITY, f32::INFINITY)),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
if let Some(alternate_child) = &mut self.alternate_drag_element {
|
||||
alternate_child.after_layout(ctx, app);
|
||||
}
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
// We always cache the laid-out origin for the element, even if we are drawing it elsewhere
|
||||
// for a drag. This allows us to look up the unmodified origin when calculating position
|
||||
// for fixed-axis draggables
|
||||
self.unmodified_origin = Some(origin);
|
||||
|
||||
// Update the bounds cache based on the provided drag bounds, if necessary
|
||||
self.bounds_cache = self
|
||||
.drag_bounds
|
||||
.calculate(ctx.position_cache, ctx.window_size);
|
||||
|
||||
match self.state.read() {
|
||||
DragState::None | DragState::WaitingToDrag { .. } => {
|
||||
// If we aren't dragging or we haven't yet passed the drag threshold, we paint the
|
||||
// element in its normal location.
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
DragState::Dragging {
|
||||
mouse_position,
|
||||
mouse_offset,
|
||||
..
|
||||
} => {
|
||||
if self.keep_original_visible || self.state.suppress_overlay_paint() {
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
// Paint the dragged element on an overlay layer so it appears
|
||||
// above anything we drag over.
|
||||
if !self.state.suppress_overlay_paint() {
|
||||
ctx.scene.start_overlay_layer(ClipBounds::None);
|
||||
let drag_origin = self.drag_origin(mouse_position, mouse_offset);
|
||||
if let Some(alternate_child) = &mut self.alternate_drag_element {
|
||||
alternate_child.paint(drag_origin, ctx, app);
|
||||
} else {
|
||||
self.child.paint(drag_origin, ctx, app);
|
||||
}
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
}
|
||||
}
|
||||
// After drawing the child (and stopping the overlay layer, if appropriate), the max
|
||||
// z-index in the scene will represent the highest point drawn by the child.
|
||||
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let size = self.size().expect("size should exist");
|
||||
let current_state = self.state.read();
|
||||
|
||||
let handled = match current_state {
|
||||
DragState::None | DragState::WaitingToDrag { .. } => {
|
||||
// If we have not yet started dragging, then we always pass events to the child
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
DragState::Dragging { .. } => {
|
||||
// If we are dragging, then we suppress all child events for the duration
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
match event.raw_event() {
|
||||
Event::LeftMouseDown { position, .. } => {
|
||||
let origin = self.origin().expect("origin should exist");
|
||||
if let Some(rect) = ctx.visible_rect(origin, size) {
|
||||
let max_z_index = self.child_max_z_index.expect("child z index should exist");
|
||||
// Only start dragging if the mouse is within the element and not covered by
|
||||
// an element on a higher layer
|
||||
if rect.contains_point(*position)
|
||||
&& !ctx.is_covered(Point::from_vec2f(*position, max_z_index))
|
||||
{
|
||||
let base_mouse_offset = origin.xy() - *position;
|
||||
let mouse_down_offset = self.compute_mouse_offset(base_mouse_offset, size);
|
||||
self.state.store(DragState::WaitingToDrag {
|
||||
mouse_down_position: *position,
|
||||
mouse_down_offset,
|
||||
});
|
||||
|
||||
ctx.set_cursor(Cursor::PointingHand, max_z_index);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
Event::LeftMouseUp { .. } => match current_state {
|
||||
DragState::None => handled,
|
||||
DragState::WaitingToDrag { .. } => {
|
||||
self.state.store(DragState::None);
|
||||
ctx.reset_cursor();
|
||||
true
|
||||
}
|
||||
DragState::Dragging {
|
||||
mouse_offset,
|
||||
mouse_position,
|
||||
..
|
||||
} => {
|
||||
let origin = self.drag_origin(mouse_position, mouse_offset);
|
||||
let rect = RectF::new(origin, size);
|
||||
|
||||
self.state.store(DragState::None);
|
||||
|
||||
let draggable_data = if let Some(accepted_fn) =
|
||||
self.is_accepted_by_drop_target_handler.as_ref()
|
||||
{
|
||||
Self::compute_drop_target_data(rect, accepted_fn, ctx, app)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(callback) = self.drop_handler.as_mut() {
|
||||
callback(ctx, app, rect, draggable_data.as_deref());
|
||||
}
|
||||
|
||||
ctx.reset_cursor();
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
},
|
||||
Event::LeftMouseDragged { position, .. } => match current_state {
|
||||
DragState::None => handled,
|
||||
DragState::WaitingToDrag {
|
||||
mouse_down_position,
|
||||
mouse_down_offset,
|
||||
} => {
|
||||
let drag_start_distance = (mouse_down_position - *position).length();
|
||||
if drag_start_distance > self.drag_threshold {
|
||||
// If the drag has moved beyond the `drag_threshold`, then we officially
|
||||
// start the drag and fire the `on_drag_start` callback.
|
||||
self.state.store(DragState::Dragging {
|
||||
mouse_offset: mouse_down_offset,
|
||||
mouse_position: *position,
|
||||
is_on_accepted_drop_target: false,
|
||||
});
|
||||
|
||||
// Note: For the `on_drag_start` callback, we pass the position that the
|
||||
// mouse down happened, since that is where the element was at the start
|
||||
// of the drag.
|
||||
let origin = self.drag_origin(mouse_down_position, mouse_down_offset);
|
||||
let rect = RectF::new(origin, size);
|
||||
dispatch_callback(self.start_handler.as_mut(), ctx, app, rect);
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
} else {
|
||||
handled
|
||||
}
|
||||
}
|
||||
DragState::Dragging {
|
||||
mouse_offset,
|
||||
is_on_accepted_drop_target: was_on_accepted_drop_target,
|
||||
..
|
||||
} => {
|
||||
let origin = self.drag_origin(*position, mouse_offset);
|
||||
let rect = RectF::new(origin, size);
|
||||
|
||||
let draggable_data = if let Some(accepted_fn) =
|
||||
self.is_accepted_by_drop_target_handler.as_ref()
|
||||
{
|
||||
Self::compute_drop_target_data(rect, accepted_fn, ctx, app)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let is_on_accepted_drop_target = draggable_data.is_some();
|
||||
|
||||
if self.use_copy_cursor_when_dragging_over_drop_target {
|
||||
let max_z_index =
|
||||
self.child_max_z_index.expect("child z index should exist");
|
||||
match (was_on_accepted_drop_target, is_on_accepted_drop_target) {
|
||||
(true, false) => {
|
||||
ctx.set_cursor(Cursor::PointingHand, max_z_index);
|
||||
}
|
||||
(false, true) => {
|
||||
ctx.set_cursor(Cursor::DragCopy, max_z_index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.state.store(DragState::Dragging {
|
||||
mouse_offset,
|
||||
mouse_position: *position,
|
||||
is_on_accepted_drop_target,
|
||||
});
|
||||
dispatch_drag_drop_callback(
|
||||
self.drag_handler.as_mut(),
|
||||
ctx,
|
||||
app,
|
||||
rect,
|
||||
draggable_data.as_deref(),
|
||||
);
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
},
|
||||
_ => handled,
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
match self.state.read() {
|
||||
DragState::None | DragState::WaitingToDrag { .. } => self.child.size(),
|
||||
DragState::Dragging { .. } => {
|
||||
if let Some(alternate_child) = &self.alternate_drag_element {
|
||||
alternate_child.size()
|
||||
} else {
|
||||
self.child.size()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
match self.state.read() {
|
||||
DragState::None | DragState::WaitingToDrag { .. } => self.child.origin(),
|
||||
DragState::Dragging { .. } => {
|
||||
if let Some(alternate_child) = &self.alternate_drag_element {
|
||||
alternate_child.origin()
|
||||
} else {
|
||||
self.child.origin()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_callback(
|
||||
callback: Option<&mut Handler>,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
rect: RectF,
|
||||
) {
|
||||
if let Some(callback) = callback {
|
||||
callback(ctx, app, rect);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_drag_drop_callback(
|
||||
callback: Option<&mut DragDropHandler>,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
rect: RectF,
|
||||
drop_target_data: Option<&dyn DropTargetData>,
|
||||
) {
|
||||
if let Some(callback) = callback {
|
||||
callback(ctx, app, rect, drop_target_data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use crate::elements::Point;
|
||||
use crate::event::DispatchedEvent;
|
||||
use crate::{AppContext, Element, EventContext, LayoutContext, PaintContext, SizeConstraint};
|
||||
use ordered_float::OrderedFloat;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Trait to identify data that is passed to a [`crate::elements::Draggable`] when dropped on
|
||||
/// a [`DropTarget`].
|
||||
pub trait DropTargetData: Debug + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
/// Position for a [`DropTarget`] with the data should be passed to the
|
||||
/// [`crate::elements::Draggable`] when dropped.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DropTargetPosition {
|
||||
bounds: RectF,
|
||||
drop_data: Arc<dyn DropTargetData>,
|
||||
}
|
||||
|
||||
impl DropTargetPosition {
|
||||
pub fn bounds(&self) -> RectF {
|
||||
self.bounds
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &Arc<dyn DropTargetData> {
|
||||
&self.drop_data
|
||||
}
|
||||
|
||||
/// Returns the area encompassed by this drop target position.
|
||||
pub fn area(&self) -> OrderedFloat<f32> {
|
||||
OrderedFloat::from(self.bounds.width() * self.bounds.height())
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that marks whether a [`crate::elements::Draggable`] was dropped on top of it.
|
||||
///
|
||||
/// Each `DropTarget` is instantiated with data that implements the [`DropTargetData`] trait. When
|
||||
/// an item is dropped on the `DropTarget`, the `Draggable` includes the data in the `on_drop`
|
||||
/// callback.
|
||||
pub struct DropTarget {
|
||||
child: Box<dyn Element>,
|
||||
data: Arc<dyn DropTargetData>,
|
||||
}
|
||||
|
||||
impl DropTarget {
|
||||
pub fn new(child: Box<dyn Element>, data: impl DropTargetData + 'static) -> Self {
|
||||
Self {
|
||||
child,
|
||||
data: Arc::new(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DropTarget {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &crate::AppContext) {
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.child.paint(origin, ctx, app);
|
||||
|
||||
let Some(bounds) = self.child.bounds() else {
|
||||
return;
|
||||
};
|
||||
ctx.position_cache
|
||||
.cache_drop_target_position(DropTargetPosition {
|
||||
bounds,
|
||||
drop_data: self.data.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod draggable;
|
||||
mod drop_target;
|
||||
|
||||
pub use draggable::*;
|
||||
pub use drop_target::*;
|
||||
@@ -0,0 +1,182 @@
|
||||
use super::Point;
|
||||
use super::ZIndex;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
event::DispatchedEvent, platform::Cursor, AfterLayoutContext, AppContext, Element, Event,
|
||||
EventContext, LayoutContext, PaintContext, SizeConstraint,
|
||||
};
|
||||
|
||||
/// Shared handle for drag-to-resize state, following the same `Arc<Mutex<_>>`
|
||||
/// pattern as `ResizableStateHandle`. The view creates the handle once at
|
||||
/// construction and passes it into `DragResizeElement` each render.
|
||||
pub type DragResizeHandle = Arc<Mutex<DragResizeState>>;
|
||||
|
||||
pub fn drag_resize_handle() -> DragResizeHandle {
|
||||
Arc::new(Mutex::new(DragResizeState::default()))
|
||||
}
|
||||
|
||||
/// Tracks whether a drag-to-resize operation is in progress.
|
||||
#[derive(Default)]
|
||||
pub struct DragResizeState {
|
||||
is_dragging: bool,
|
||||
last_y: f32,
|
||||
}
|
||||
|
||||
impl DragResizeState {
|
||||
fn begin(&mut self, y: f32) {
|
||||
self.is_dragging = true;
|
||||
self.last_y = y;
|
||||
}
|
||||
|
||||
fn end(&mut self) {
|
||||
self.is_dragging = false;
|
||||
}
|
||||
|
||||
fn is_dragging(&self) -> bool {
|
||||
self.is_dragging
|
||||
}
|
||||
|
||||
/// Compute the vertical delta since the last event and update `last_y`.
|
||||
fn consume_delta(&mut self, y: f32) -> f32 {
|
||||
let delta = y - self.last_y;
|
||||
self.last_y = y;
|
||||
delta
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback invoked during a resize drag. Receives the vertical delta (pixels).
|
||||
type ResizeUpdateFn = Box<dyn Fn(f32, &mut EventContext, &AppContext)>;
|
||||
|
||||
/// Callback invoked when a resize drag finishes.
|
||||
pub type ResizeEndFn = Box<dyn Fn(&mut EventContext, &AppContext)>;
|
||||
|
||||
/// An element that enables drag-to-resize on its entire surface area.
|
||||
///
|
||||
/// The element dispatches events to its child first. If the child does not
|
||||
/// handle a `LeftMouseDown`, the element begins a resize operation. Subsequent
|
||||
/// `LeftMouseDragged` / `LeftMouseUp` events are captured via `raw_event()` so
|
||||
/// they work regardless of cursor position (same pattern used by `Resizable`).
|
||||
pub struct DragResizeElement {
|
||||
child: Box<dyn Element>,
|
||||
handle: DragResizeHandle,
|
||||
on_resize_update: ResizeUpdateFn,
|
||||
on_resize_end: Option<ResizeEndFn>,
|
||||
origin: Option<Point>,
|
||||
child_max_z_index: Option<ZIndex>,
|
||||
}
|
||||
|
||||
impl DragResizeElement {
|
||||
pub fn new(
|
||||
handle: DragResizeHandle,
|
||||
child: Box<dyn Element>,
|
||||
on_resize_update: impl Fn(f32, &mut EventContext, &AppContext) + 'static,
|
||||
on_resize_end: Option<ResizeEndFn>,
|
||||
) -> Self {
|
||||
Self {
|
||||
child,
|
||||
handle,
|
||||
on_resize_update: Box::new(on_resize_update),
|
||||
on_resize_end,
|
||||
origin: None,
|
||||
child_max_z_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, DragResizeState> {
|
||||
// This is the same (slightly scary) pattern as `Resizable::state()`.
|
||||
// Poisoning should only occur after a prior panic (already in a bad state).
|
||||
self.handle.lock().expect("DragResizeState lock poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DragResizeElement {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
self.child.paint(origin, ctx, app);
|
||||
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
// Always let the child see the event first.
|
||||
let child_handled = self.child.dispatch_event(event, ctx, app);
|
||||
|
||||
// Use raw_event() for drag/up so they are position-independent.
|
||||
match event.raw_event() {
|
||||
Event::LeftMouseDown { position, .. } => {
|
||||
if child_handled {
|
||||
return true;
|
||||
}
|
||||
// Check if the click is within our bounds.
|
||||
if let (Some(origin), Some(size)) = (self.origin, self.size()) {
|
||||
if let Some(rect) = ctx.visible_rect(origin, size) {
|
||||
if rect.contains_point(*position) {
|
||||
self.state().begin(position.y());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Event::LeftMouseDragged { position, .. } => {
|
||||
if self.state().is_dragging() {
|
||||
if let Some(z_index) = self.child_max_z_index {
|
||||
ctx.set_cursor(Cursor::ResizeUpDown, z_index);
|
||||
}
|
||||
let delta = self.state().consume_delta(position.y());
|
||||
(self.on_resize_update)(delta, ctx, app);
|
||||
return true;
|
||||
}
|
||||
child_handled
|
||||
}
|
||||
Event::LeftMouseUp { .. } => {
|
||||
if self.state().is_dragging() {
|
||||
self.state().end();
|
||||
if let Some(on_end) = &self.on_resize_end {
|
||||
(on_end)(ctx, app);
|
||||
}
|
||||
ctx.reset_cursor();
|
||||
return true;
|
||||
}
|
||||
child_handled
|
||||
}
|
||||
Event::MouseMoved { .. } => {
|
||||
if self.state().is_dragging() {
|
||||
if let Some(z_index) = self.child_max_z_index {
|
||||
ctx.set_cursor(Cursor::ResizeUpDown, z_index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
child_handled
|
||||
}
|
||||
_ => child_handled,
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::event::DispatchedEvent;
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SizeConstraint,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Empty {
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
}
|
||||
|
||||
impl Empty {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: None,
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Empty {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
_: &mut LayoutContext,
|
||||
_: &AppContext,
|
||||
) -> Vector2F {
|
||||
// Set the size of the element to be the max constraint. If the max constraint is unbounded
|
||||
// use the min constraint to avoid rendering an unbounded-sized element.
|
||||
let max_constraint = constraint.max;
|
||||
|
||||
let x = if max_constraint.x().is_infinite() {
|
||||
constraint.min.x()
|
||||
} else {
|
||||
max_constraint.x()
|
||||
};
|
||||
|
||||
let y = if max_constraint.y().is_infinite() {
|
||||
constraint.min.y()
|
||||
} else {
|
||||
max_constraint.y()
|
||||
};
|
||||
|
||||
let size = Vector2F::new(x, y);
|
||||
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_: &DispatchedEvent,
|
||||
_: &mut EventContext,
|
||||
_: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
use crate::{
|
||||
event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState},
|
||||
keymap::Keystroke,
|
||||
platform::keyboard::KeyCode,
|
||||
};
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext,
|
||||
LayoutContext, PaintContext, Point, SizeConstraint, ZIndex,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::cell::RefCell;
|
||||
|
||||
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult>;
|
||||
type KeyHandler = Box<dyn FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult>;
|
||||
type ScrollHandler = Box<
|
||||
dyn FnMut(&mut EventContext, &AppContext, &Vector2F, &ModifiersState) -> DispatchEventResult,
|
||||
>;
|
||||
type ModifierStateChangedHandler =
|
||||
Box<dyn FnMut(&mut EventContext, &AppContext, &KeyCode, &KeyState) -> DispatchEventResult>;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MouseInBehavior {
|
||||
/// Whether to fire the `mouse_in` event on synthetic events, which are events the UI
|
||||
/// framework generates so in order to trigger hover effects when the underlying view has
|
||||
/// changed even though the mouse hasn't actually moved. Typically elements should handle
|
||||
/// sythetic hovers, but there are some cases where it's the incorrect behavior.
|
||||
pub fire_on_synthetic_events: bool,
|
||||
/// Whether to fire the `mouse_in` event when the element is covered by another element.
|
||||
/// This is true by default, but some elements may want to configure this behavior.
|
||||
pub fire_when_covered: bool,
|
||||
}
|
||||
|
||||
impl Default for MouseInBehavior {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fire_on_synthetic_events: true,
|
||||
fire_when_covered: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EventHandler {
|
||||
child: Box<dyn Element>,
|
||||
/// Allow this element to handle events even if a descendent already handled it.
|
||||
always_handle: bool,
|
||||
left_mouse_down: Option<RefCell<Handler>>,
|
||||
left_mouse_up: Option<RefCell<Handler>>,
|
||||
middle_mouse_down: Option<RefCell<Handler>>,
|
||||
right_mouse_down: Option<RefCell<Handler>>,
|
||||
forward_mouse_down: Option<RefCell<Handler>>,
|
||||
back_mouse_down: Option<RefCell<Handler>>,
|
||||
mouse_in: Option<RefCell<Handler>>,
|
||||
mouse_in_behavior: MouseInBehavior,
|
||||
mouse_out: Option<RefCell<Handler>>,
|
||||
mouse_dragged: Option<RefCell<Handler>>,
|
||||
scroll_wheel: Option<RefCell<ScrollHandler>>,
|
||||
keydown: Option<RefCell<KeyHandler>>,
|
||||
modifier_state_changed: Option<RefCell<ModifierStateChangedHandler>>,
|
||||
origin: Option<Point>,
|
||||
// This is a short-term solution for properly handling events on stacks. A stack will always
|
||||
// put its children on higher z-indexes than its origin, so a hit test using the standard
|
||||
// `z_index` method would always result in the event being covered (by the children of the
|
||||
// stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element.
|
||||
// Then we use that upper bound to do the hit testing, which means a parent will always get
|
||||
// events from its children, regardless of whether they are stacks or not.
|
||||
child_max_z_index: Option<ZIndex>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
child,
|
||||
always_handle: false,
|
||||
left_mouse_down: None,
|
||||
left_mouse_up: None,
|
||||
middle_mouse_down: None,
|
||||
right_mouse_down: None,
|
||||
forward_mouse_down: None,
|
||||
back_mouse_down: None,
|
||||
mouse_in: None,
|
||||
mouse_out: None,
|
||||
mouse_dragged: None,
|
||||
scroll_wheel: None,
|
||||
keydown: None,
|
||||
modifier_state_changed: None,
|
||||
origin: None,
|
||||
child_max_z_index: None,
|
||||
mouse_in_behavior: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_always_handle(mut self) -> Self {
|
||||
self.always_handle = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_keydown<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult,
|
||||
{
|
||||
self.keydown = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_modifier_state_changed<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static
|
||||
+ FnMut(&mut EventContext, &AppContext, &KeyCode, &KeyState) -> DispatchEventResult,
|
||||
{
|
||||
self.modifier_state_changed = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_left_mouse_down<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.left_mouse_down = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_left_mouse_up<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.left_mouse_up = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_right_mouse_down<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.right_mouse_down = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_middle_mouse_down<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.middle_mouse_down = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_forward_mouse_down<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.forward_mouse_down = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_back_mouse_down<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.back_mouse_down = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_mouse_in<F>(mut self, callback: F, mouse_in_behavior: Option<MouseInBehavior>) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.mouse_in = Some(RefCell::new(Box::new(callback)));
|
||||
self.mouse_in_behavior = mouse_in_behavior.unwrap_or_default();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_mouse_out<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.mouse_out = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_mouse_dragged<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
|
||||
{
|
||||
self.mouse_dragged = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_scroll_wheel<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static
|
||||
+ FnMut(&mut EventContext, &AppContext, &Vector2F, &ModifiersState) -> DispatchEventResult,
|
||||
{
|
||||
self.scroll_wheel = Some(RefCell::new(Box::new(callback)));
|
||||
self
|
||||
}
|
||||
|
||||
fn dispatch_callback(
|
||||
&self,
|
||||
callback: Option<&RefCell<Handler>>,
|
||||
ctx: &mut EventContext,
|
||||
position: Vector2F,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
if let Some(callback) = callback.as_ref() {
|
||||
if let Some(rect) = ctx.visible_rect(self.origin.unwrap(), self.size().unwrap()) {
|
||||
if rect.contains_point(position) {
|
||||
return match callback.borrow_mut()(ctx, app, position) {
|
||||
DispatchEventResult::PropagateToParent => false,
|
||||
DispatchEventResult::StopPropagation => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for EventHandler {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
self.child.paint(origin, ctx, app);
|
||||
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let handled = self.child.dispatch_event(event, ctx, app);
|
||||
if handled && !self.always_handle {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(z_index) = self.child_max_z_index else {
|
||||
log::error!(
|
||||
"Dispatching event {:?} on EventHandler element which was never painted.",
|
||||
EventDiscriminants::from(event.raw_event())
|
||||
);
|
||||
return false;
|
||||
};
|
||||
match event.at_z_index(z_index, ctx) {
|
||||
Some(Event::MouseMoved {
|
||||
position,
|
||||
is_synthetic,
|
||||
..
|
||||
}) => {
|
||||
let MouseInBehavior {
|
||||
fire_on_synthetic_events,
|
||||
fire_when_covered,
|
||||
} = self.mouse_in_behavior;
|
||||
let is_covered = ctx.is_covered(Point::from_vec2f(
|
||||
*position,
|
||||
self.child_max_z_index.expect("child max z index not set"),
|
||||
));
|
||||
let should_fire = (!is_synthetic || fire_on_synthetic_events)
|
||||
&& (fire_when_covered || !is_covered);
|
||||
if should_fire
|
||||
&& self.dispatch_callback(self.mouse_in.as_ref(), ctx, *position, app)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if self.dispatch_callback(self.mouse_out.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::LeftMouseDragged { position, .. }) => {
|
||||
if self.dispatch_callback(self.mouse_dragged.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
if self.dispatch_callback(self.mouse_in.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
if self.dispatch_callback(self.mouse_out.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::LeftMouseDown { position, .. }) => {
|
||||
if self.dispatch_callback(self.left_mouse_down.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::LeftMouseUp { position, .. }) => {
|
||||
if self.dispatch_callback(self.left_mouse_up.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::MiddleMouseDown { position, .. }) => {
|
||||
if self.dispatch_callback(self.middle_mouse_down.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::RightMouseDown { position, .. }) => {
|
||||
if self.dispatch_callback(self.right_mouse_down.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::BackMouseDown { position, .. }) => {
|
||||
if self.dispatch_callback(self.back_mouse_down.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::ForwardMouseDown { position, .. }) => {
|
||||
if self.dispatch_callback(self.forward_mouse_down.as_ref(), ctx, *position, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some(Event::KeyDown { keystroke, .. }) => {
|
||||
if let Some(callback) = self.keydown.as_ref() {
|
||||
return match callback.borrow_mut()(ctx, app, keystroke) {
|
||||
DispatchEventResult::PropagateToParent => false,
|
||||
DispatchEventResult::StopPropagation => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(Event::ModifierKeyChanged { key_code, state }) => {
|
||||
if let Some(callback) = self.modifier_state_changed.as_ref() {
|
||||
return match callback.borrow_mut()(ctx, app, key_code, state) {
|
||||
DispatchEventResult::PropagateToParent => false,
|
||||
DispatchEventResult::StopPropagation => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(Event::ScrollWheel {
|
||||
position,
|
||||
delta,
|
||||
precise: _,
|
||||
modifiers,
|
||||
}) => {
|
||||
if let Some(callback) = self.scroll_wheel.as_ref() {
|
||||
if let Some(rect) = ctx.visible_rect(self.origin.unwrap(), self.size().unwrap())
|
||||
{
|
||||
if rect.contains_point(*position) {
|
||||
return match callback.borrow_mut()(ctx, app, delta, modifiers) {
|
||||
DispatchEventResult::PropagateToParent => false,
|
||||
DispatchEventResult::StopPropagation => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "event_handler_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,621 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
elements::{
|
||||
ChildAnchor, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Rect, Stack,
|
||||
},
|
||||
platform::WindowStyle,
|
||||
App, AppContext, Entity, EntityId, Presenter, TypedActionView, ViewContext, WindowInvalidation,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
enum ElementIdentifier {
|
||||
Base,
|
||||
Inset,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
// Maps identifier to number of mouse down events
|
||||
mouse_downs: HashMap<ElementIdentifier, usize>,
|
||||
mouse_ins: HashMap<ElementIdentifier, usize>,
|
||||
mouse_in_behavior: MouseInBehavior,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_action("event_handler_test:mouse_down", View::mouse_down);
|
||||
app.add_action("event_handler_test:mouse_in", View::mouse_in);
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn mouse_in(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
let entry = self.mouse_ins.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn new(mouse_in_behavior: MouseInBehavior) -> Self {
|
||||
Self {
|
||||
mouse_in_behavior,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn ui_name() -> &'static str {
|
||||
"event_handler_test_view"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut inner_stack = Stack::new();
|
||||
inner_stack.add_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(100.)
|
||||
.with_width(100.)
|
||||
.finish(),
|
||||
);
|
||||
inner_stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Inset);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_in(
|
||||
|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_in", ElementIdentifier::Inset);
|
||||
DispatchEventResult::StopPropagation
|
||||
},
|
||||
Some(self.mouse_in_behavior),
|
||||
)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 75.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(
|
||||
EventHandler::new(inner_stack.finish())
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Base);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_in(
|
||||
|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_in", ElementIdentifier::Base);
|
||||
DispatchEventResult::StopPropagation
|
||||
},
|
||||
Some(self.mouse_in_behavior),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Overlay);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_in(
|
||||
|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_in", ElementIdentifier::Overlay);
|
||||
DispatchEventResult::StopPropagation
|
||||
},
|
||||
Some(self.mouse_in_behavior),
|
||||
)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(75., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layered_click_handling() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 5);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Click on the overlay
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the inset
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the top-left area of the base
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the bottom-right area of the base
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
*view.mouse_downs.get(&ElementIdentifier::Overlay).unwrap()
|
||||
);
|
||||
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
|
||||
assert_eq!(2, *view.mouse_downs.get(&ElementIdentifier::Base).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_mouse_in_behavior() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 5);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Non-synthetic move over the overlay
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(90., 10.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Non-synthetic move over the inset
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(10., 90.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Non-synthetic move over top left the base
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(10., 10.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Non-synthetic move over the bottom right of base
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(90., 90.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Overlay).unwrap());
|
||||
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Inset).unwrap());
|
||||
// Only 2 events should be fired because 1) the inset is a child of the base
|
||||
// and doesn't propagate events to its parent 2) the overlay event is not propagated
|
||||
// to the base.
|
||||
assert_eq!(2, *view.mouse_ins.get(&ElementIdentifier::Base).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mouse_in_behavior_dont_fire_on_synthetic_events() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::new(MouseInBehavior {
|
||||
fire_on_synthetic_events: false,
|
||||
fire_when_covered: true,
|
||||
})
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 5);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Non-synthetic move over the overlay
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(90., 10.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: true,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
0,
|
||||
*view
|
||||
.mouse_ins
|
||||
.get(&ElementIdentifier::Overlay)
|
||||
.unwrap_or(&0)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
*view.mouse_ins.get(&ElementIdentifier::Inset).unwrap_or(&0)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
*view.mouse_ins.get(&ElementIdentifier::Base).unwrap_or(&0)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mouse_in_behavior_dont_fire_when_covered() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::new(MouseInBehavior {
|
||||
fire_on_synthetic_events: true,
|
||||
fire_when_covered: false,
|
||||
})
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 5);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Non-synthetic move over the overlay
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(90., 10.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Non-synthetic move over the inset
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(10., 90.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Non-synthetic move over top left the base
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(10., 10.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Non-synthetic move over the bottom right of base
|
||||
ctx.simulate_window_event(
|
||||
Event::MouseMoved {
|
||||
position: vec2f(90., 90.),
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Overlay).unwrap());
|
||||
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Inset).unwrap());
|
||||
assert_eq!(2, *view.mouse_ins.get(&ElementIdentifier::Base).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// For testing event propagation
|
||||
#[derive(Debug)]
|
||||
enum PropagationViewAction {
|
||||
MouseDown(ElementIdentifier),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PropagationView {
|
||||
// Maps identifier to number of mouse down events
|
||||
mouse_downs: HashMap<ElementIdentifier, usize>,
|
||||
allow_propagation: bool,
|
||||
}
|
||||
|
||||
impl PropagationView {
|
||||
fn mouse_down(&mut self, identifier: &ElementIdentifier) -> bool {
|
||||
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn set_propagation(&mut self, allow_propagation: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.allow_propagation = allow_propagation;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PropagationView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for PropagationView {
|
||||
fn ui_name() -> &'static str {
|
||||
"event_handler_test_propagation_view"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let allow_propagation = self.allow_propagation;
|
||||
|
||||
let handler = EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(100.)
|
||||
.with_width(100.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(move |evt, _, _| {
|
||||
evt.dispatch_typed_action(PropagationViewAction::MouseDown(ElementIdentifier::Inset));
|
||||
if allow_propagation {
|
||||
DispatchEventResult::PropagateToParent
|
||||
} else {
|
||||
DispatchEventResult::StopPropagation
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
EventHandler::new(handler)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_typed_action(PropagationViewAction::MouseDown(
|
||||
ElementIdentifier::Base,
|
||||
));
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for PropagationView {
|
||||
type Action = PropagationViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, _: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
PropagationViewAction::MouseDown(identifier) => {
|
||||
self.mouse_down(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_and_rebuild_scene(
|
||||
presenter: &Rc<RefCell<Presenter>>,
|
||||
root_view_id: EntityId,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(root_view_id);
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
presenter.borrow_mut().invalidate(invalidation, ctx);
|
||||
presenter
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_propagation() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, view) =
|
||||
app.add_window(WindowStyle::NotStealFocus, |_| PropagationView::default());
|
||||
|
||||
let root_view_id = view.id();
|
||||
app.update(move |ctx| {
|
||||
invalidate_and_rebuild_scene(
|
||||
&ctx.presenter(window_id).expect("Window should exist"),
|
||||
root_view_id,
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Click on the inset with propagation disabled
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
ctx.presenter(window_id)
|
||||
.expect("window should exist")
|
||||
.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(&app, |view, _| {
|
||||
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
|
||||
assert_eq!(view.mouse_downs.get(&ElementIdentifier::Base), None);
|
||||
});
|
||||
|
||||
// Allow propagation
|
||||
view.update(&mut app, |view, ctx| {
|
||||
view.set_propagation(true, ctx);
|
||||
});
|
||||
|
||||
app.update(move |ctx| {
|
||||
// Click on the inset with propagation enabled
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
ctx.presenter(window_id)
|
||||
.expect("window should exist")
|
||||
.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
// Both the inset and the base should have received the even
|
||||
view.read(&app, |view, _| {
|
||||
assert_eq!(2, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
|
||||
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Base).unwrap());
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# Flex Element Debugging Guide
|
||||
|
||||
This guide helps diagnose and fix common Flex layout panics in WarpUI.
|
||||
|
||||
## Quick Reference: Error Messages → Fixes
|
||||
|
||||
### Error: `flex contains flexible children but has an infinite constraint along the flex axis`
|
||||
|
||||
**Cause**: A `Flex` with `MainAxisSize::Min` (the default) contains an `Expanded` or `Shrinkable` child but has no max constraint along the main axis.
|
||||
|
||||
**Fixes** (in order of preference):
|
||||
1. Remove `Expanded`/`Shrinkable` from the child if growing isn't necessary
|
||||
2. Add a max constraint to the `Flex` or an ancestor using `ConstrainedBox`:
|
||||
- For `Flex::row()`: add `max_width`
|
||||
- For `Flex::column()`: add `max_height`
|
||||
3. If the `Flex` is inside another `Flex`, ensure the parent passes down bounded constraints
|
||||
|
||||
### Error: `A flex that should expand to a max space can't be rendered in an infinite max constraint`
|
||||
|
||||
**Cause**: A `Flex` has `MainAxisSize::Max` but no ancestor provides a maximum size constraint.
|
||||
|
||||
**Fixes**:
|
||||
1. Remove `.with_main_axis_size(MainAxisSize::Max)` if the Flex doesn't need to fill its parent (use default `MainAxisSize::Min`)
|
||||
2. Add a `ConstrainedBox` with a max constraint to the `Flex` or an ancestor
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Two Types of Children
|
||||
- **Flexible children** (`Expanded`, `Shrinkable`): Size is calculated by dividing remaining space by flex ratio
|
||||
- **Non-flexible children**: Laid out using their intrinsic size but still respect the max constraints from their parent `Flex`
|
||||
|
||||
### Important Behaviors
|
||||
|
||||
1. **`Expanded` only works as direct child of `Flex`** - wrapping in `Container`/`ConstrainedBox` breaks it
|
||||
|
||||
2. **`Expanded` doesn't force growth** - unlike CSS `flex-grow`, it only grants the *ability* to grow. Elements like `Text` don't expand by default; wrap in `Align` if needed
|
||||
|
||||
3. **`MainAxisSize::Min` + `Expanded` = effectively `MainAxisSize::Max`** - the `Expanded` child will grow to fill available space anyway
|
||||
|
||||
4. **Nested `Flex` with `MainAxisSize::Max`** - putting a `Flex` with `MainAxisSize::Max` inside another `Flex` with `MainAxisSize::Max` will cause a layout panic when neither receives a max constraint from an ancestor
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Centering horizontally (requires bounded parent)
|
||||
```rust
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_child(element)
|
||||
.finish()
|
||||
```
|
||||
|
||||
### Centering vertically (requires bounded parent)
|
||||
```rust
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(element)
|
||||
.finish()
|
||||
```
|
||||
|
||||
### Spacing groups apart (e.g., left/right aligned items)
|
||||
```rust
|
||||
Flex::row()
|
||||
.with_child(left_element)
|
||||
.with_child(Expanded::new(1.0, Empty::new())) // spacer
|
||||
.with_child(right_element)
|
||||
.finish()
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
1. Run with `RUST_BACKTRACE=full` to identify which element(s) cause the panic
|
||||
2. Check the element hierarchy for unbounded `Flex` containers
|
||||
3. Trace constraints from root to the failing element - find where max constraint is lost
|
||||
4. Avoid unnecessary `MainAxisSize::Max` - only use when the `Flex` *must* fill its parent
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,669 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
use crate::elements::{Align, SavePosition, Stack};
|
||||
use crate::geometry::rect::RectF;
|
||||
|
||||
use crate::platform::WindowStyle;
|
||||
use crate::{
|
||||
elements::{ConstrainedBox, ParentElement, Rect},
|
||||
App, Entity, Presenter, TypedActionView, WindowId, WindowInvalidation,
|
||||
};
|
||||
|
||||
type RenderFn = dyn Fn(&AppContext) -> Box<dyn Element> + 'static;
|
||||
|
||||
struct TestDynamicView {
|
||||
render: Box<RenderFn>,
|
||||
}
|
||||
|
||||
impl TestDynamicView {
|
||||
fn new(render: impl Fn(&AppContext) -> Box<dyn Element> + 'static) -> Self {
|
||||
Self {
|
||||
render: Box::new(render),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TestDynamicView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for TestDynamicView {
|
||||
fn ui_name() -> &'static str {
|
||||
"Flex::tests::TestDynamicView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
(self.render)(app)
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestDynamicView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
/// Asserts that the bounds of all the painted rects match that of `rects`.
|
||||
fn assert_bounds_of_rects(
|
||||
app: &mut App,
|
||||
window_id: WindowId,
|
||||
rects: impl IntoIterator<Item = RectF>,
|
||||
) {
|
||||
let presenter_ref = app
|
||||
.presenter(window_id)
|
||||
.expect("Test window should have a presenter since first frame is rendered.");
|
||||
let presenter = presenter_ref.borrow();
|
||||
let scene = presenter
|
||||
.scene()
|
||||
.expect("Presenter should have rendered a scene after the test_view was updated.");
|
||||
|
||||
let actual_rects = scene
|
||||
.layers()
|
||||
.next()
|
||||
.into_iter()
|
||||
.flat_map(|layer| layer.rects.iter())
|
||||
.map(|rect| rect.bounds);
|
||||
|
||||
itertools::assert_equal(actual_rects, rects);
|
||||
}
|
||||
|
||||
struct View {
|
||||
flex_main_axis_size: MainAxisSize,
|
||||
flex_main_axis_alignment: MainAxisAlignment,
|
||||
flex_cross_axis_alignment: CrossAxisAlignment,
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn new(
|
||||
axis_size: MainAxisSize,
|
||||
main_axis_alignment: MainAxisAlignment,
|
||||
cross_axis_alignment: CrossAxisAlignment,
|
||||
) -> Self {
|
||||
Self {
|
||||
flex_main_axis_size: axis_size,
|
||||
flex_main_axis_alignment: main_axis_alignment,
|
||||
flex_cross_axis_alignment: cross_axis_alignment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = String;
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let flex = Flex::row()
|
||||
.with_children([
|
||||
SavePosition::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(20.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
"view_1",
|
||||
)
|
||||
.finish(),
|
||||
SavePosition::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
"view_2",
|
||||
)
|
||||
.finish(),
|
||||
SavePosition::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
"view_3",
|
||||
)
|
||||
.finish(),
|
||||
])
|
||||
.with_cross_axis_alignment(self.flex_cross_axis_alignment)
|
||||
.with_main_axis_alignment(self.flex_main_axis_alignment)
|
||||
.with_main_axis_size(self.flex_main_axis_size);
|
||||
|
||||
Stack::new()
|
||||
.with_child(
|
||||
Align::new(SavePosition::new(flex.finish(), "flex").finish())
|
||||
.top_left()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_main_axis_alignment() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::new(
|
||||
MainAxisSize::Min,
|
||||
MainAxisAlignment::Start,
|
||||
CrossAxisAlignment::Start,
|
||||
)
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).expect("root view should exist"));
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
let window_size = RectF::new(Vector2F::zero(), vec2f(300., 300.));
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
let view_1_size = vec2f(50., 20.);
|
||||
let view_2_size = vec2f(50., 30.);
|
||||
let view_3_size = vec2f(50., 50.);
|
||||
|
||||
// The view has a min axis size, so each element should be rendered right next to
|
||||
// each other and and the flex should take up the total size of the elements.
|
||||
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_main_axis_alignment = MainAxisAlignment::Start;
|
||||
view.flex_main_axis_size = MainAxisSize::Max;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
// The view has a flex axis alignment of start, so ensure that each child element
|
||||
// is rendered next to each other, but that the flex expands out to the max size of
|
||||
// the window.
|
||||
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_main_axis_alignment = MainAxisAlignment::SpaceBetween;
|
||||
view.flex_main_axis_size = MainAxisSize::Max;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
// Ensure that the the elements are evenly spaced (with no extra space at the
|
||||
// beginning or end) and that the flex expands out to the max size of the window.
|
||||
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(125., 0.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(250., 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_main_axis_alignment = MainAxisAlignment::SpaceEvenly;
|
||||
view.flex_main_axis_size = MainAxisSize::Max;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
// Ensure that the the elements are evenly spaced, including space before and after
|
||||
// the child elements, and that the flex expands out to the max size of the window.
|
||||
assert_eq!(view_1, RectF::new(vec2f(37.5, 0.), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(125., 0.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(212.5, 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_main_axis_alignment = MainAxisAlignment::End;
|
||||
view.flex_main_axis_size = MainAxisSize::Max;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
// The view has a flex axis alignment of end, so ensure that each child element
|
||||
// is rendered next to each other, but that the flex expands out to the max size of
|
||||
// the window.
|
||||
assert_eq!(view_3, RectF::new(vec2f(250., 0.), view_3_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(200., 0.), view_2_size));
|
||||
assert_eq!(view_1, RectF::new(vec2f(150., 0.), view_1_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_row_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test basic row spacing
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
Flex::row()
|
||||
.with_spacing(10.)
|
||||
.with_children([
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(60.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(40.)
|
||||
.finish(),
|
||||
])
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Children should have 10px spacing between them
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), vec2f(50., 30.)),
|
||||
RectF::new(vec2f(60., 0.), vec2f(60., 30.)), // 50 + 10
|
||||
RectF::new(vec2f(130., 0.), vec2f(40., 30.)), // 50 + 10 + 60 + 10
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_column_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test column spacing
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
Flex::column()
|
||||
.with_spacing(15.)
|
||||
.with_children([
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(60.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(40.)
|
||||
.with_width(80.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(70.)
|
||||
.finish(),
|
||||
])
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Children should have 15px vertical spacing between them
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), vec2f(60., 30.)),
|
||||
RectF::new(vec2f(0., 45.), vec2f(80., 40.)), // 30 + 15
|
||||
RectF::new(vec2f(0., 100.), vec2f(70., 25.)), // 30 + 15 + 40 + 15
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_spacing_with_center_alignment() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test spacing with center alignment
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_spacing(20.)
|
||||
.with_children([
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(40.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(40.)
|
||||
.finish(),
|
||||
])
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(300.)
|
||||
.with_height(100.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Total content width: 40 + 20 + 40 = 100
|
||||
// Remaining space: 300 - 100 = 200
|
||||
// Leading space: 200 / 2 = 100
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(100., 0.), vec2f(40., 30.)),
|
||||
RectF::new(vec2f(160., 0.), vec2f(40., 30.)), // 100 + 40 + 20
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_spacing_empty() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test empty flex with spacing
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| Flex::row().with_spacing(15.).finish())
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Empty flex should render no children
|
||||
assert_bounds_of_rects(app, window_id, []);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_spacing_single_child() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test single child with spacing (should have no effect)
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
Flex::row()
|
||||
.with_spacing(20.)
|
||||
.with_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(30.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Single child should be positioned at origin regardless of spacing
|
||||
assert_bounds_of_rects(app, window_id, [RectF::new(vec2f(0., 0.), vec2f(50., 30.))]);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_cross_axis_alignment() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::new(
|
||||
MainAxisSize::Min,
|
||||
MainAxisAlignment::Start,
|
||||
CrossAxisAlignment::Start,
|
||||
)
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).expect("root view should exist"));
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
let window_size = RectF::new(Vector2F::zero(), vec2f(300., 300.));
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1_size = vec2f(50., 20.);
|
||||
let view_2_size = vec2f(50., 30.);
|
||||
let view_3_size = vec2f(50., 50.);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_cross_axis_alignment = CrossAxisAlignment::Center;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
assert_eq!(view_1, RectF::new(vec2f(0., 15.), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(50., 10.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_cross_axis_alignment = CrossAxisAlignment::End;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
assert_eq!(view_1, RectF::new(vec2f(0., 30.), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(50., 20.), view_2_size));
|
||||
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
|
||||
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
|
||||
|
||||
view.update(ctx, |view, _ctx| {
|
||||
view.flex_cross_axis_alignment = CrossAxisAlignment::Stretch;
|
||||
});
|
||||
|
||||
presenter.invalidate(invalidation.clone(), ctx);
|
||||
presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
let view_1 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_1")
|
||||
.expect("position should exist");
|
||||
let view_2 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_2")
|
||||
.expect("position should exist");
|
||||
let view_3 = presenter
|
||||
.position_cache()
|
||||
.get_position("view_3")
|
||||
.expect("position should exist");
|
||||
let flex = presenter
|
||||
.position_cache()
|
||||
.get_position("flex")
|
||||
.expect("position should exist");
|
||||
|
||||
assert_eq!(view_1, RectF::new(vec2f(0., 0.), view_1_size));
|
||||
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
|
||||
// view 3 is a Flex::row(), so applying cross-axis stretch to its
|
||||
// parent should cause the child flex height to fill the parent's
|
||||
// maximum height (which, in this case, is the height of the window).
|
||||
assert_eq!(
|
||||
view_3,
|
||||
RectF::new(
|
||||
vec2f(100., 0.),
|
||||
vec2f(view_3_size.x(), window_size.height())
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
flex,
|
||||
RectF::new(Vector2F::zero(), vec2f(150., window_size.height()))
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
use crate::elements::AxisOrientation;
|
||||
use crate::event::DispatchedEvent;
|
||||
use crate::ClipBounds;
|
||||
|
||||
use super::cross_axis_size;
|
||||
use super::AppContext;
|
||||
use super::Axis;
|
||||
use super::CrossAxisAlignment;
|
||||
use super::Element;
|
||||
use super::EventContext;
|
||||
use super::LayoutContext;
|
||||
use super::MainAxisSize;
|
||||
use super::PaintContext;
|
||||
use super::Point;
|
||||
use super::SizeConstraint;
|
||||
use super::Vector2FExt;
|
||||
use crate::elements::flex::{main_axis_size, size_along_axis, LayoutState};
|
||||
use crate::elements::MainAxisAlignment;
|
||||
use ordered_float::OrderedFloat;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
/// An element that positions its children in horizontal or vertical runs, leaving space in between
|
||||
/// each run.
|
||||
///
|
||||
/// This element can be thought of as a bare-bones version of a flex element with the `flex-wrap`
|
||||
/// property set in CSS. Children are laid out greedily until they can no longer fit on the current
|
||||
/// run, in which case a new run is created with the child as the first element. If a child exceeds
|
||||
/// the incoming size constraints it is clamped to the constraint max and clipped during painting.
|
||||
/// Children that can't fit in any run along the cross axis are not laid out or painted.
|
||||
pub struct Wrap {
|
||||
axis: Axis,
|
||||
orientation: AxisOrientation,
|
||||
children: Vec<WrapChild>,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
spacing: f32,
|
||||
runs: Vec<Run>,
|
||||
run_spacing: f32,
|
||||
main_axis_alignment: MainAxisAlignment,
|
||||
main_axis_size: MainAxisSize,
|
||||
cross_axis_alignment: CrossAxisAlignment,
|
||||
}
|
||||
|
||||
impl Wrap {
|
||||
pub fn new(axis: Axis) -> Self {
|
||||
Self {
|
||||
axis,
|
||||
orientation: AxisOrientation::Normal,
|
||||
children: vec![],
|
||||
size: None,
|
||||
origin: None,
|
||||
spacing: 0.,
|
||||
runs: vec![],
|
||||
run_spacing: 0.,
|
||||
main_axis_alignment: MainAxisAlignment::Start,
|
||||
main_axis_size: MainAxisSize::Max,
|
||||
cross_axis_alignment: CrossAxisAlignment::Start,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row() -> Self {
|
||||
Self::new(Axis::Horizontal)
|
||||
}
|
||||
|
||||
pub fn column() -> Self {
|
||||
Self::new(Axis::Vertical)
|
||||
}
|
||||
|
||||
pub fn with_reverse_orientation(mut self) -> Self {
|
||||
self.orientation = AxisOrientation::Reverse;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_spacing(mut self, spacing: f32) -> Self {
|
||||
self.spacing = spacing;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the specified amount of `spacing` between each run when positioning children.
|
||||
pub fn with_run_spacing(mut self, spacing: f32) -> Self {
|
||||
self.run_spacing = spacing;
|
||||
self
|
||||
}
|
||||
|
||||
fn size_along_cross_axis(runs: &[Run], run_spacing: f32) -> f32 {
|
||||
let run_height: f32 = runs.iter().map(|run| run.size_along_cross_axis).sum();
|
||||
run_height + run_spacing * (runs.len().saturating_sub(1)) as f32
|
||||
}
|
||||
|
||||
/// Specifies the strategy to render children in each run when there is remaining space.
|
||||
pub fn with_main_axis_alignment(mut self, alignment: MainAxisAlignment) -> Self {
|
||||
self.main_axis_alignment = alignment;
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies the strategy to size the overall element when there is remaining space after
|
||||
/// runs.
|
||||
pub fn with_main_axis_size(mut self, size: MainAxisSize) -> Self {
|
||||
self.main_axis_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cross_axis_alignment(mut self, alignment: CrossAxisAlignment) -> Self {
|
||||
self.cross_axis_alignment = alignment;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<Box<dyn Element>> for Wrap {
|
||||
fn extend<T: IntoIterator<Item = Box<dyn Element>>>(&mut self, iter: T) {
|
||||
self.children.extend(iter.into_iter().map(WrapChild::new));
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Wrap {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.children.iter_mut().for_each(WrapChild::reset);
|
||||
self.runs.clear();
|
||||
|
||||
let max_constraint_along_cross_axis = constraint.max_along(self.axis.invert());
|
||||
let max_constraint_along_main_axis = constraint.max_along(self.axis);
|
||||
|
||||
let mut current_run = RunBuilder::default();
|
||||
|
||||
for child in &mut self.children {
|
||||
let child_constraint = match child.data() {
|
||||
Some(child_data) if child_data.fill_run() => {
|
||||
// If the child expands/shrinks based on the remaining space, then lay it out
|
||||
// with _that_ as the max constraint along its main axis, rather than an
|
||||
// infinite max constraint.
|
||||
|
||||
let mut remaining_space_along_main_axis =
|
||||
max_constraint_along_main_axis - current_run.size_along_main_axis;
|
||||
|
||||
let should_create_new_run = match child_data {
|
||||
WrapParentData::FillRemainingSpaceInRun { min_space, .. } => {
|
||||
// If there's insufficient space along the main axis, start a new run rather
|
||||
// than trying to lay out the child with the remaining space. This prevents
|
||||
// calling child.layout() with a maximum constraint that's less than whatever
|
||||
// minimum it might've set.
|
||||
remaining_space_along_main_axis < min_space
|
||||
}
|
||||
WrapParentData::FillEntireRun => true,
|
||||
};
|
||||
|
||||
if should_create_new_run {
|
||||
let mut new_run = RunBuilder::default();
|
||||
std::mem::swap(&mut new_run, &mut current_run);
|
||||
self.runs.push(new_run.build(
|
||||
self.spacing,
|
||||
max_constraint_along_main_axis,
|
||||
self.main_axis_alignment,
|
||||
self.axis,
|
||||
));
|
||||
remaining_space_along_main_axis = max_constraint_along_main_axis;
|
||||
}
|
||||
|
||||
// Let the child expand along the cross axis as well.
|
||||
let remaining_space_along_cross_axis = max_constraint_along_cross_axis
|
||||
- Self::size_along_cross_axis(self.runs.as_slice(), self.run_spacing);
|
||||
|
||||
match self.axis {
|
||||
Axis::Horizontal => SizeConstraint::new(
|
||||
vec2f(0., constraint.min.y()),
|
||||
vec2f(
|
||||
remaining_space_along_main_axis,
|
||||
remaining_space_along_cross_axis,
|
||||
),
|
||||
),
|
||||
Axis::Vertical => SizeConstraint::new(
|
||||
vec2f(constraint.min.x(), 0.),
|
||||
vec2f(
|
||||
remaining_space_along_cross_axis,
|
||||
remaining_space_along_main_axis,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
// Lay out the child so that it has an infinite max constraint along its main axis. The
|
||||
// incoming max size constraint is respected along the cross axis.
|
||||
_ => SizeConstraint::child_constraint_along_axis(self.axis, constraint),
|
||||
};
|
||||
let size = child.layout(child_constraint, ctx, app);
|
||||
|
||||
// If the child individually exceeds the incoming size constraints, clamp it
|
||||
// to the constraint max so it doesn't overflow the container. We continue
|
||||
// laying out subsequent children rather than stopping entirely.
|
||||
let size = vec2f(
|
||||
size.x().min(constraint.max.x()),
|
||||
size.y().min(constraint.max.y()),
|
||||
);
|
||||
|
||||
let child_size_along_main_axis = size.along(self.axis);
|
||||
let child_size_along_cross_axis = size.along(self.axis.invert());
|
||||
|
||||
// The child doesn't fit in the current run--create a new run.
|
||||
if child_size_along_main_axis + current_run.size_along_main_axis
|
||||
> max_constraint_along_main_axis
|
||||
{
|
||||
let mut new_run = RunBuilder::default();
|
||||
std::mem::swap(&mut new_run, &mut current_run);
|
||||
self.runs.push(new_run.build(
|
||||
self.spacing,
|
||||
max_constraint_along_main_axis,
|
||||
self.main_axis_alignment,
|
||||
self.axis,
|
||||
));
|
||||
}
|
||||
|
||||
if child_size_along_cross_axis > current_run.size_along_cross_axis {
|
||||
// If the new size would cause the element to exceed the max size along the
|
||||
// cross axis--don't add the item to the run and immediately break.
|
||||
let total_run_size_on_cross_axis = child_size_along_cross_axis
|
||||
+ Self::size_along_cross_axis(self.runs.as_slice(), self.run_spacing);
|
||||
if total_run_size_on_cross_axis > max_constraint_along_cross_axis {
|
||||
break;
|
||||
}
|
||||
current_run.size_along_cross_axis = child_size_along_cross_axis;
|
||||
}
|
||||
|
||||
current_run.num_children += 1;
|
||||
current_run.size_along_main_axis += child_size_along_main_axis;
|
||||
// Add the spacing between the child and the next child (were we to add one).
|
||||
current_run.size_along_main_axis += self.spacing;
|
||||
}
|
||||
|
||||
if current_run.num_children > 0 {
|
||||
self.runs.push(current_run.build(
|
||||
self.spacing,
|
||||
max_constraint_along_main_axis,
|
||||
self.main_axis_alignment,
|
||||
self.axis,
|
||||
))
|
||||
}
|
||||
|
||||
let size_along_cross_axis = Self::size_along_cross_axis(&self.runs, self.run_spacing);
|
||||
let size_along_main_axis = match self.main_axis_size {
|
||||
MainAxisSize::Min => {
|
||||
// Use the largest run along the main axis as the overall element width.
|
||||
self.runs
|
||||
.iter()
|
||||
.map(|run| OrderedFloat(run.size_along_main_axis))
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
.0
|
||||
}
|
||||
MainAxisSize::Max => constraint.max_along(self.axis),
|
||||
};
|
||||
|
||||
let size = match self.axis {
|
||||
Axis::Horizontal => vec2f(size_along_main_axis, size_along_cross_axis),
|
||||
Axis::Vertical => vec2f(size_along_cross_axis, size_along_main_axis),
|
||||
};
|
||||
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &crate::AppContext) {
|
||||
for child in &mut self.children {
|
||||
child.after_layout(ctx, app)
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let mut num_children_painted = 0;
|
||||
let original_origin = origin;
|
||||
|
||||
let wrap_size = self.size.expect("size should exist at paint time");
|
||||
let clip_bounds = RectF::new(origin, wrap_size);
|
||||
|
||||
// Clip children to the wrap's own bounds so oversized items don't overflow.
|
||||
ctx.scene
|
||||
.start_layer(ClipBounds::BoundedByActiveLayerAnd(clip_bounds));
|
||||
|
||||
let mut origin = origin;
|
||||
|
||||
// If the axis is reversed, offset the origin position by the length of the flex along its main axis,
|
||||
if let AxisOrientation::Reverse = self.orientation {
|
||||
let size_shift = size_along_axis(main_axis_size(wrap_size, self.axis), self.axis);
|
||||
origin += size_shift;
|
||||
};
|
||||
|
||||
for run in &self.runs {
|
||||
let mut run_origin = match self.orientation {
|
||||
AxisOrientation::Normal => origin + run.layout_state.leading_space,
|
||||
AxisOrientation::Reverse => origin - run.layout_state.leading_space,
|
||||
};
|
||||
|
||||
for child in self
|
||||
.children
|
||||
.iter_mut()
|
||||
.skip(num_children_painted)
|
||||
.take(run.num_children)
|
||||
{
|
||||
let child_size = child.size().expect("child size should exist at paint time");
|
||||
let child_cross_size = cross_axis_size(child_size, self.axis);
|
||||
|
||||
let child_cross_shift = match self.cross_axis_alignment {
|
||||
CrossAxisAlignment::Center => {
|
||||
run.size_along_cross_axis / 2. - child_cross_size / 2.
|
||||
}
|
||||
CrossAxisAlignment::Start => 0.,
|
||||
CrossAxisAlignment::End => run.size_along_cross_axis - child_cross_size,
|
||||
CrossAxisAlignment::Stretch => 0.,
|
||||
};
|
||||
|
||||
// Paint the child and offset the origin by the size of the child along the main
|
||||
// axis.
|
||||
match self.orientation {
|
||||
AxisOrientation::Normal => {
|
||||
child.paint(
|
||||
run_origin + size_along_axis(child_cross_shift, self.axis.invert()),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
if let Some(child_size) = child.size() {
|
||||
run_origin +=
|
||||
size_along_axis(main_axis_size(child_size, self.axis), self.axis);
|
||||
}
|
||||
run_origin += run.layout_state.between_space;
|
||||
}
|
||||
AxisOrientation::Reverse => {
|
||||
if let Some(child_size) = child.size() {
|
||||
run_origin -=
|
||||
size_along_axis(main_axis_size(child_size, self.axis), self.axis);
|
||||
}
|
||||
child.paint(run_origin, ctx, app);
|
||||
run_origin -= run.layout_state.between_space;
|
||||
}
|
||||
};
|
||||
}
|
||||
num_children_painted += run.num_children;
|
||||
|
||||
// We're finished painting the run. Update the origin to be at the start of the new run.
|
||||
origin += size_along_axis(
|
||||
run.size_along_cross_axis + self.run_spacing,
|
||||
self.axis.invert(),
|
||||
);
|
||||
}
|
||||
|
||||
ctx.scene.stop_layer();
|
||||
self.origin = Some(Point::from_vec2f(original_origin, ctx.scene.z_index()));
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let mut handled = false;
|
||||
for child in &mut self.children {
|
||||
let child_dispatch = child.dispatch_event(event, ctx, app);
|
||||
handled |= child_dispatch;
|
||||
}
|
||||
handled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WrapParentData {
|
||||
FillRemainingSpaceInRun {
|
||||
/// If `true`, the child element will be laid out with the run's remaining space, rather than
|
||||
/// infinite width. This allows children to expand to fill runs.
|
||||
fill_run: bool,
|
||||
|
||||
/// The minimum space along the main axis that this child needs. Generally, child elements
|
||||
/// should reserve required space in their [`Element::layout`] implementations instead.
|
||||
/// However, for flexible children, we sometimes need a minimum here.
|
||||
min_space: f32,
|
||||
},
|
||||
FillEntireRun,
|
||||
}
|
||||
|
||||
/// Convenience wrapper for a [`Wrap`] child that must consume the entire run.
|
||||
///
|
||||
/// When a child is wrapped in `WrapFillEntireRun`, the `Wrap` layout will place that child alone
|
||||
/// on its own run and treat it as occupying all remaining main-axis space for that run. This is
|
||||
/// useful for elements like wide cards or chips that should expand to the full width of the
|
||||
/// current row instead of sharing the row with other wrapped children.
|
||||
pub struct WrapFillEntireRun(WrapFill);
|
||||
|
||||
impl WrapFillEntireRun {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self(WrapFill {
|
||||
parent_data: WrapParentData::FillEntireRun,
|
||||
child,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Box<dyn Element> {
|
||||
self.0.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for children of a [`Wrap`] element that preferentially expand to fill the current
|
||||
/// row/column before starting a new row/column.
|
||||
pub struct WrapFill {
|
||||
parent_data: WrapParentData,
|
||||
child: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl WrapFill {
|
||||
pub fn new(min_space: f32, child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
parent_data: WrapParentData::FillRemainingSpaceInRun {
|
||||
fill_run: true,
|
||||
min_space,
|
||||
},
|
||||
child,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WrapParentData {
|
||||
fn fill_run(&self) -> bool {
|
||||
match self {
|
||||
WrapParentData::FillRemainingSpaceInRun { fill_run, .. } => *fill_run,
|
||||
WrapParentData::FillEntireRun => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for WrapFill {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn parent_data(&self) -> Option<&dyn std::any::Any> {
|
||||
Some(&self.parent_data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct to encapsulate a child of a `Wrap` element that may not be painted or laid out
|
||||
/// depending on the number of elements that fit into the `Wrap` given incoming size constraints.
|
||||
struct WrapChild {
|
||||
element: Box<dyn Element>,
|
||||
is_laid_out: bool,
|
||||
is_painted: bool,
|
||||
}
|
||||
|
||||
impl WrapChild {
|
||||
fn new(element: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
element,
|
||||
is_laid_out: false,
|
||||
is_painted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn data(&self) -> Option<WrapParentData> {
|
||||
self.element
|
||||
.parent_data()
|
||||
.and_then(|data| data.downcast_ref())
|
||||
.copied()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.is_laid_out = false;
|
||||
self.is_painted = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for WrapChild {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.is_laid_out = true;
|
||||
self.element.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &crate::AppContext) {
|
||||
if self.is_laid_out {
|
||||
self.element.after_layout(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
if self.is_laid_out {
|
||||
self.element.paint(origin, ctx, app);
|
||||
self.is_painted = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.element.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.element.origin()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
if self.is_painted {
|
||||
self.element.dispatch_event(event, ctx, app)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A given run of a `Wrap` element.
|
||||
#[derive(Debug)]
|
||||
struct Run {
|
||||
/// The size along the cross axis of the run. This is functionally the max size on the cross
|
||||
/// axis of the elements within the run.
|
||||
size_along_cross_axis: f32,
|
||||
/// The size along the main axis of the run. This is the sum of each element within the run's
|
||||
/// size, plus the leading space and space between each child.
|
||||
size_along_main_axis: f32,
|
||||
/// The number of children of the parent `Wrap` element that are rendered within this run.
|
||||
num_children: usize,
|
||||
/// Metadata used to layout the run. This is used to properly respect `MainAxisAlignment` within
|
||||
/// each run.
|
||||
layout_state: LayoutState,
|
||||
}
|
||||
|
||||
/// Builder type to construct a `Run`.
|
||||
#[derive(Debug, Default)]
|
||||
struct RunBuilder {
|
||||
/// The size along the cross axis of the run. This is functionally the max size on the cross
|
||||
/// axis of the elements within the run.
|
||||
size_along_cross_axis: f32,
|
||||
/// The main axis size along the run.
|
||||
size_along_main_axis: f32,
|
||||
/// The number of children of the parent `Wrap` element that are rendered within this run.
|
||||
num_children: usize,
|
||||
}
|
||||
|
||||
impl RunBuilder {
|
||||
fn build(
|
||||
self,
|
||||
spacing: f32,
|
||||
max_constraint_along_main_axis: f32,
|
||||
main_axis_alignment: MainAxisAlignment,
|
||||
axis: Axis,
|
||||
) -> Run {
|
||||
// We added spacing after every child, but we only want spacing _between_ children,
|
||||
// so subtract the (extra) spacing after the last child.
|
||||
let size_along_main_axis = self.size_along_main_axis - spacing;
|
||||
|
||||
let layout_state = LayoutState::compute(
|
||||
self.num_children,
|
||||
spacing,
|
||||
max_constraint_along_main_axis - size_along_main_axis,
|
||||
main_axis_alignment,
|
||||
axis,
|
||||
);
|
||||
|
||||
let size_along_main_axis = size_along_main_axis
|
||||
+ layout_state.leading_space.along(axis)
|
||||
+ layout_state.between_space.along(axis) * (self.num_children as f32 - 1.);
|
||||
|
||||
Run {
|
||||
size_along_cross_axis: self.size_along_cross_axis,
|
||||
size_along_main_axis,
|
||||
num_children: self.num_children,
|
||||
layout_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "wrap_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,811 @@
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::elements::ConstrainedBox;
|
||||
use crate::elements::Container;
|
||||
use crate::elements::Empty;
|
||||
use crate::elements::ParentElement;
|
||||
use crate::elements::Rect;
|
||||
use crate::platform::WindowStyle;
|
||||
use crate::Entity;
|
||||
use crate::View;
|
||||
use crate::WindowId;
|
||||
use crate::{App, TypedActionView};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestRootView {
|
||||
parent_size: Vector2F,
|
||||
children_sizes: Vec<Vector2F>,
|
||||
axis: Axis,
|
||||
run_spacing: f32,
|
||||
}
|
||||
|
||||
impl TestRootView {
|
||||
pub fn new(
|
||||
parent_size: Vector2F,
|
||||
children_sizes: Vec<Vector2F>,
|
||||
axis: Axis,
|
||||
run_spacing: f32,
|
||||
) -> Self {
|
||||
Self {
|
||||
parent_size,
|
||||
children_sizes,
|
||||
axis,
|
||||
run_spacing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TestRootView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestRootView {
|
||||
fn ui_name() -> &'static str {
|
||||
"Wrap::tests::TestRootView"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut wrap = Wrap::new(self.axis).with_run_spacing(self.run_spacing);
|
||||
wrap.extend(self.children_sizes.iter().map(|size| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(size.y())
|
||||
.with_width(size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(self.parent_size.x())
|
||||
.with_height(self.parent_size.y())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestRootView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
type RenderFn = dyn Fn(&AppContext) -> Box<dyn Element> + 'static;
|
||||
|
||||
struct TestDynamicView {
|
||||
render: Box<RenderFn>,
|
||||
}
|
||||
|
||||
impl TestDynamicView {
|
||||
fn new(render: impl Fn(&AppContext) -> Box<dyn Element> + 'static) -> Self {
|
||||
Self {
|
||||
render: Box::new(render),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TestDynamicView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestDynamicView {
|
||||
fn ui_name() -> &'static str {
|
||||
"Wrap::tests::TestDynamicView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
(self.render)(app)
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestDynamicView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
/// Asserts that the bounds of all the painted rects match that of `rects`.
|
||||
fn assert_bounds_of_rects(
|
||||
app: &mut App,
|
||||
window_id: WindowId,
|
||||
rects: impl IntoIterator<Item = RectF>,
|
||||
) {
|
||||
let presenter_ref = app
|
||||
.presenter(window_id)
|
||||
.expect("Test window should have a presenter since first frame is rendered.");
|
||||
let presenter = presenter_ref.borrow();
|
||||
let scene = presenter
|
||||
.scene()
|
||||
.expect("Presenter should have rendered a scene after the test_view was updated.");
|
||||
|
||||
let actual_rects = scene
|
||||
.layers()
|
||||
.flat_map(|layer| layer.rects.iter())
|
||||
.map(|rect| rect.bounds);
|
||||
|
||||
itertools::assert_equal(actual_rects, rects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_row_wraps_across_runs() {
|
||||
App::test((), |mut app| async move {
|
||||
let child_size = vec2f(100., 100.);
|
||||
let app = &mut app;
|
||||
|
||||
// Attempt to render 4 100x100 rects into a 250x250 box. This should result in the first
|
||||
// two rects rendered in a row, followed by a 10px horizontal spacing, followed by the
|
||||
// next two rects rendered in a row.
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestRootView::new(
|
||||
vec2f(250., 250.),
|
||||
vec![child_size; 4],
|
||||
Axis::Horizontal,
|
||||
10.,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(100., 0.), child_size),
|
||||
RectF::new(vec2f(0., 110.), child_size),
|
||||
RectF::new(vec2f(100., 110.), child_size),
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_wraps_across_runs() {
|
||||
App::test((), |mut app| async move {
|
||||
let child_size = vec2f(100., 100.);
|
||||
let app = &mut app;
|
||||
|
||||
// Attempt to render 4 100x100 rects into a 250x250 box. This should result in the first
|
||||
// two rects rendered in a column, followed by a 10px vertical spacing, followed by the
|
||||
// next two rects rendered in a column.
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestRootView::new(vec2f(250., 250.), vec![child_size; 4], Axis::Vertical, 10.)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(0., 100.), child_size),
|
||||
RectF::new(vec2f(110., 0.), child_size),
|
||||
RectF::new(vec2f(110., 100.), child_size),
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
/// Tests that elements within a `Wrap` are not rendered if they can't be fit within the max
|
||||
/// size constraint along the cross axis.
|
||||
#[test]
|
||||
fn test_wrap_with_too_many_elements() {
|
||||
App::test((), |mut app| async move {
|
||||
let child_size = vec2f(100., 100.);
|
||||
let app = &mut app;
|
||||
|
||||
// Attempt to render 10 100x100 rects into a 250x250 box. This should result in the
|
||||
// first two rects rendered in a row, followed by a 10px horizontal spacing, followed by
|
||||
// the next two rects rendered in a row. Only four of the ten rects can fit in the
|
||||
// parent box.
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestRootView::new(vec2f(250., 250.), vec![child_size; 10], Axis::Vertical, 10.)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Only 4 elements are painted--even though there were 10 initial elements passed to the
|
||||
// wrap.
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(0., 100.), child_size),
|
||||
RectF::new(vec2f(110., 0.), child_size),
|
||||
RectF::new(vec2f(110., 100.), child_size),
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
/// Tests that when the first item exceeds the max size constraint, it is clamped for layout
|
||||
/// purposes and clipped during paint. Subsequent items that fit are still laid out.
|
||||
#[test]
|
||||
fn test_wrap_first_element_exceeds_size_constraint() {
|
||||
App::test((), |mut app| async move {
|
||||
let child_size = vec2f(100., 100.);
|
||||
let mut children = vec![vec2f(500., 500.)];
|
||||
children.extend(vec![child_size; 2]);
|
||||
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestRootView::new(vec2f(250., 250.), children, Axis::Vertical, 10.)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// The first oversized element is clamped to 250x250 for layout, filling the
|
||||
// entire space. Subsequent items can't fit on the cross axis.
|
||||
// The child is laid out at 250x500 (cross axis is clamped by ConstrainedBox
|
||||
// to 250, main axis stays 500), then clamped to 250x250 for run placement.
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[RectF::new(vec2f(0., 0.), vec2f(250., 500.))],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
/// Tests that when the second element exceeds the size constraint, it is clamped and
|
||||
/// subsequent items continue to be laid out if they fit.
|
||||
#[test]
|
||||
fn test_second_element_exceeds_size_constraint() {
|
||||
App::test((), |mut app| async move {
|
||||
let child_size = vec2f(100., 100.);
|
||||
let children = vec![child_size, vec2f(300., 300.), child_size, child_size];
|
||||
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestRootView::new(vec2f(250., 250.), children, Axis::Vertical, 10.)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// The first element (100x100) is in column 1. The oversized second element
|
||||
// (250x300 after ConstrainedBox clamp, then 250x250 for layout) doesn't fit
|
||||
// in the current run and creates a new one. Its clamped cross-axis size (250)
|
||||
// plus existing runs (100 + 10) exceeds 250, so only the first child fits.
|
||||
assert_bounds_of_rects(app, window_id, [RectF::new(vec2f(0., 0.), child_size)]);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_size_along_row() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
// Use a Container so there's a rect for the Wrap element's bounds.
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Wrap::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_children([
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(100.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(100.)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
// Ensure the Wrap has _more_ than enough space for the two children.
|
||||
.with_max_width(250.)
|
||||
.with_max_height(250.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
|
||||
test_view.update(&mut app, |_, ctx| ctx.notify());
|
||||
|
||||
// The Wrap element is painted using the minimum space needed for the two children, even
|
||||
// though it could expand further.
|
||||
assert_bounds_of_rects(
|
||||
&mut app,
|
||||
window_id,
|
||||
[RectF::new(vec2f(0., 0.), vec2f(200., 100.))],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_element_within_size_constraint() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
ConstrainedBox::new(
|
||||
Wrap::row()
|
||||
.with_children([
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(100.)
|
||||
.finish(),
|
||||
WrapFill::new(200., Rect::new().finish()).finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_width(400.)
|
||||
.with_height(100.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
|
||||
test_view.update(&mut app, |_, ctx| ctx.notify());
|
||||
|
||||
// Both children are painted, and the second expands to fill available space.
|
||||
assert_bounds_of_rects(
|
||||
&mut app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), vec2f(100., 100.)),
|
||||
RectF::new(vec2f(100., 0.), vec2f(300., 100.)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test with spacing between children in the same run
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(100., 50.);
|
||||
// Create a Wrap that can fit 3 children horizontally with spacing
|
||||
let mut wrap = Wrap::row().with_spacing(10.);
|
||||
wrap.extend((0..3).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(350.) // 100 + 10 + 100 + 10 + 100 = 320, so they fit in one run
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(100., 50.);
|
||||
// All 3 children should be in the same run with 10px spacing between them
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(110., 0.), child_size), // 100 + 10
|
||||
RectF::new(vec2f(220., 0.), child_size), // 100 + 10 + 100 + 10
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_spacing_with_wrapping() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test with spacing that forces wrapping
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(100., 50.);
|
||||
let mut wrap = Wrap::row().with_spacing(20.);
|
||||
wrap.extend((0..4).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(250.) // Can only fit 2 children per run: 100 + 20 + 100 = 220
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(100., 50.);
|
||||
// First 2 children in first run, next 2 in second run
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(120., 0.), child_size), // 100 + 20
|
||||
RectF::new(vec2f(0., 50.), child_size), // New run
|
||||
RectF::new(vec2f(120., 50.), child_size), // 100 + 20 in second run
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_run_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test run_spacing (vertical spacing between runs)
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(80., 40.);
|
||||
let mut wrap = Wrap::row().with_run_spacing(30.);
|
||||
wrap.extend((0..4).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(170.) // Can fit 2 children per run: 80 + 80 = 160
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(80., 40.);
|
||||
// Two runs with 30px spacing between them
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(80., 0.), child_size),
|
||||
RectF::new(vec2f(0., 70.), child_size), // 40 + 30
|
||||
RectF::new(vec2f(80., 70.), child_size), // 40 + 30
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_both_spacings() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test both spacing and run_spacing together
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(60., 30.);
|
||||
let mut wrap = Wrap::row()
|
||||
.with_spacing(15.) // 15px between children in same run
|
||||
.with_run_spacing(25.); // 25px between runs
|
||||
wrap.extend((0..6).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(200.) // Can fit 2 children per run: 60 + 15 + 60 = 135
|
||||
.with_height(300.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(60., 30.);
|
||||
// 3 runs with 2 children each
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
// First run
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(75., 0.), child_size), // 60 + 15
|
||||
// Second run (30 + 25 = 55)
|
||||
RectF::new(vec2f(0., 55.), child_size),
|
||||
RectF::new(vec2f(75., 55.), child_size),
|
||||
// Third run (30 + 25 + 30 + 25 = 110)
|
||||
RectF::new(vec2f(0., 110.), child_size),
|
||||
RectF::new(vec2f(75., 110.), child_size),
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_column_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test spacing in column wrap
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(50., 80.);
|
||||
let mut wrap = Wrap::column().with_spacing(12.);
|
||||
wrap.extend((0..4).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(200.)
|
||||
.with_height(185.) // Can fit 2 children per column: 80 + 12 + 80 = 172
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(50., 80.);
|
||||
// Two columns with 2 children each
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(0., 92.), child_size), // 80 + 12
|
||||
RectF::new(vec2f(50., 0.), child_size), // New column
|
||||
RectF::new(vec2f(50., 92.), child_size), // 80 + 12 in second column
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_column_run_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test run_spacing in column wrap (horizontal spacing between columns)
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(40., 70.);
|
||||
let mut wrap = Wrap::column().with_run_spacing(20.);
|
||||
wrap.extend((0..4).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(200.)
|
||||
.with_height(145.) // Can fit 2 children per column: 70 + 70 = 140
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(40., 70.);
|
||||
// Two columns with 20px horizontal spacing between them
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(0., 70.), child_size),
|
||||
RectF::new(vec2f(60., 0.), child_size), // 40 + 20
|
||||
RectF::new(vec2f(60., 70.), child_size), // 40 + 20
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_spacing_edge_cases() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test single child with spacing (should have no effect)
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(80., 40.);
|
||||
let mut wrap = Wrap::row().with_spacing(20.);
|
||||
wrap.extend((0..1).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(300.)
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(80., 40.);
|
||||
// Single child should be positioned at origin regardless of spacing
|
||||
assert_bounds_of_rects(app, window_id, [RectF::new(vec2f(0., 0.), child_size)]);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_zero_spacing() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test zero spacing and zero run_spacing
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let child_size = vec2f(60., 30.);
|
||||
let mut wrap = Wrap::row()
|
||||
.with_spacing(0.) // No spacing between children
|
||||
.with_run_spacing(0.); // No spacing between runs
|
||||
wrap.extend((0..4).map(|_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(child_size.y())
|
||||
.with_width(child_size.x())
|
||||
.finish()
|
||||
}));
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(130.) // Can fit 2 children per run: 60 + 60 = 120
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let child_size = vec2f(60., 30.);
|
||||
// Children should be tightly packed with no gaps
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), child_size),
|
||||
RectF::new(vec2f(60., 0.), child_size), // No spacing
|
||||
RectF::new(vec2f(0., 30.), child_size), // New run, no run_spacing
|
||||
RectF::new(vec2f(60., 30.), child_size), // No spacing in second run
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_empty() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Test empty wrap with spacing settings
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let wrap = Wrap::row().with_spacing(15.).with_run_spacing(25.);
|
||||
// No children added
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(300.)
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Empty wrap should render no children
|
||||
assert_bounds_of_rects(app, window_id, []);
|
||||
})
|
||||
}
|
||||
|
||||
/// Tests the core fix: an oversized item in a row wrap is clamped so that
|
||||
/// subsequent items continue to be laid out on later runs.
|
||||
#[test]
|
||||
fn test_oversized_item_does_not_block_subsequent_items() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
// Container is 300x200 (row wrap). Children:
|
||||
// 1) 100x50 (fits)
|
||||
// 2) 500x50 (exceeds 300 on main axis, will be clamped to 300x50)
|
||||
// 3) 100x50 (fits on a new run after the oversized item)
|
||||
// 4) 100x50 (fits on the same run as child 3)
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
let mut wrap = Wrap::row();
|
||||
wrap.extend([
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(50.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(500.)
|
||||
.with_height(50.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(50.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(50.)
|
||||
.finish(),
|
||||
]);
|
||||
|
||||
ConstrainedBox::new(wrap.finish())
|
||||
.with_width(300.)
|
||||
.with_height(200.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
test_view.update(app, |_, ctx| ctx.notify());
|
||||
|
||||
// Row 1: child 1 (100x50)
|
||||
// Row 2: child 2, laid out at 300x50 (ConstrainedBox cross-clamp) then clamped
|
||||
// to 300x50 for layout — it takes the full main axis.
|
||||
// Row 3: child 3 + child 4 side by side.
|
||||
// Previously, the break at line 190 would have stopped all layout after child 2.
|
||||
assert_bounds_of_rects(
|
||||
app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), vec2f(100., 50.)),
|
||||
// The oversized child paints at its full 500px width; the clip
|
||||
// layer on the Wrap visually clips it to 300px.
|
||||
RectF::new(vec2f(0., 50.), vec2f(500., 50.)),
|
||||
RectF::new(vec2f(0., 100.), vec2f(100., 50.)),
|
||||
RectF::new(vec2f(100., 100.), vec2f(100., 50.)),
|
||||
],
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fill_element_exceeds_size_constraint() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
TestDynamicView::new(|_| {
|
||||
ConstrainedBox::new(
|
||||
Wrap::row()
|
||||
.with_children([
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(100.)
|
||||
.with_height(100.)
|
||||
.finish(),
|
||||
WrapFill::new(200., Rect::new().finish()).finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_width(250.)
|
||||
.with_height(250.)
|
||||
.finish()
|
||||
})
|
||||
});
|
||||
|
||||
test_view.update(&mut app, |_, ctx| ctx.notify());
|
||||
|
||||
// Both children are painted, and the second wraps to a new row while filling the remaining
|
||||
// height.
|
||||
assert_bounds_of_rects(
|
||||
&mut app,
|
||||
window_id,
|
||||
[
|
||||
RectF::new(vec2f(0., 0.), vec2f(100., 100.)),
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 150.)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
use super::{
|
||||
FormattedTextElement, FrameMouseHandlers, HeadingFontSizeMultipliers, HighlightedHyperlink,
|
||||
HyperlinkSupport, LaidOutTextFrame, SecretRange,
|
||||
};
|
||||
use crate::text::BlockHeaderSize;
|
||||
use crate::{
|
||||
elements::{Point, SelectableElement, ZIndex},
|
||||
fonts::FamilyId,
|
||||
text_layout::TextFrame,
|
||||
};
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{rect::RectF, vector::vec2f};
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
use super::apply_secret_replacements;
|
||||
|
||||
#[test]
|
||||
fn test_default_heading_font_size_multipliers() {
|
||||
let multipliers = HeadingFontSizeMultipliers::default();
|
||||
|
||||
// Test that default values match the BlockHeaderSize ratios
|
||||
assert_eq!(
|
||||
multipliers.h1,
|
||||
BlockHeaderSize::Header1.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
multipliers.h2,
|
||||
BlockHeaderSize::Header2.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
multipliers.h3,
|
||||
BlockHeaderSize::Header3.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
multipliers.h4,
|
||||
BlockHeaderSize::Header4.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
multipliers.h5,
|
||||
BlockHeaderSize::Header5.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
multipliers.h6,
|
||||
BlockHeaderSize::Header6.font_size_multiplication_ratio()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_multiplier_method() {
|
||||
let multipliers = HeadingFontSizeMultipliers::default();
|
||||
|
||||
// Test valid heading levels
|
||||
assert_eq!(multipliers.get_multiplier(1), multipliers.h1);
|
||||
assert_eq!(multipliers.get_multiplier(2), multipliers.h2);
|
||||
assert_eq!(multipliers.get_multiplier(3), multipliers.h3);
|
||||
assert_eq!(multipliers.get_multiplier(4), multipliers.h4);
|
||||
assert_eq!(multipliers.get_multiplier(5), multipliers.h5);
|
||||
assert_eq!(multipliers.get_multiplier(6), multipliers.h6);
|
||||
|
||||
// Test invalid heading levels return 1.0
|
||||
assert_eq!(multipliers.get_multiplier(0), 1.0);
|
||||
assert_eq!(multipliers.get_multiplier(7), 1.0);
|
||||
assert_eq!(multipliers.get_multiplier(999), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_heading_font_size_multipliers() {
|
||||
let custom_multipliers = HeadingFontSizeMultipliers {
|
||||
h1: 2.0,
|
||||
h2: 1.8,
|
||||
h3: 1.5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Test custom values
|
||||
assert_eq!(custom_multipliers.h1, 2.0);
|
||||
assert_eq!(custom_multipliers.h2, 1.8);
|
||||
assert_eq!(custom_multipliers.h3, 1.5);
|
||||
|
||||
// Test that other values still use defaults
|
||||
assert_eq!(
|
||||
custom_multipliers.h4,
|
||||
BlockHeaderSize::Header4.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
custom_multipliers.h5,
|
||||
BlockHeaderSize::Header5.font_size_multiplication_ratio()
|
||||
);
|
||||
assert_eq!(
|
||||
custom_multipliers.h6,
|
||||
BlockHeaderSize::Header6.font_size_multiplication_ratio()
|
||||
);
|
||||
}
|
||||
|
||||
fn sr(char_start: usize, char_end: usize, byte_start: usize, byte_end: usize) -> SecretRange {
|
||||
SecretRange {
|
||||
char_range: char_start..char_end,
|
||||
byte_range: byte_start..byte_end,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_replacements_with_multibyte_and_prefix() {
|
||||
let original = "令狐冲abcXYZ"; // Multibyte + ASCII
|
||||
let mut text = format!("{}{}", "• ", original);
|
||||
let glyph_offset = 3; // prefix length in chars
|
||||
|
||||
// Secret over chars [2..5): "冲ab"
|
||||
let start_byte = original
|
||||
.chars()
|
||||
.take(2)
|
||||
.map(|c| c.len_utf8())
|
||||
.sum::<usize>();
|
||||
let secret_bytes_len = original
|
||||
.chars()
|
||||
.skip(2)
|
||||
.take(3)
|
||||
.map(|c| c.len_utf8())
|
||||
.sum::<usize>();
|
||||
let secret = sr(2, 5, start_byte, start_byte + secret_bytes_len);
|
||||
|
||||
let replacements = vec![(secret, Cow::Owned("***".to_string()))];
|
||||
apply_secret_replacements(&mut text, glyph_offset, &replacements);
|
||||
|
||||
assert_eq!(text, format!("{}{}", "• ", "令狐***cXYZ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_multiple_replacements_in_descending_order() {
|
||||
let original = "abcdefg";
|
||||
let mut text = format!("{}{}", "• ", original);
|
||||
let glyph_offset = 3;
|
||||
|
||||
// [1..3) => "bc", [4..6) => "ef"
|
||||
let s1 = sr(1, 3, 1, 3);
|
||||
let s2 = sr(4, 6, 4, 6);
|
||||
let replacements = vec![(s1, Cow::Borrowed("**")), (s2, Cow::Borrowed("##"))];
|
||||
|
||||
apply_secret_replacements(&mut text, glyph_offset, &replacements);
|
||||
assert_eq!(text, format!("{}{}", "• ", "a**d##g"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_matters_when_replacement_changes_length() {
|
||||
// This test demonstrates why we apply replacements in descending order.
|
||||
// Here, the first replacement shortens the text; if applied before the second,
|
||||
// the second range would be misaligned relative to the original char indices.
|
||||
let original = "abcdefghi";
|
||||
let mut text = original.to_string();
|
||||
let glyph_offset = 0;
|
||||
|
||||
// Two secrets in original char coordinates: [1..5) => "bcde" then [5..8) => "fgh"
|
||||
// Replace first with a shorter string, second with equal length.
|
||||
let s1 = sr(1, 5, 1, 5);
|
||||
let s2 = sr(5, 8, 5, 8);
|
||||
let replacements = vec![
|
||||
(s1, Cow::Borrowed("*")), // length 1 instead of 4
|
||||
(s2, Cow::Borrowed("###")), // same length as original 3
|
||||
];
|
||||
|
||||
apply_secret_replacements(&mut text, glyph_offset, &replacements);
|
||||
// With descending-order application, expected result is:
|
||||
// apply s2 first: abcde###i
|
||||
// then s1: a*###i
|
||||
assert_eq!(text, "a*###i");
|
||||
}
|
||||
|
||||
fn select_first_character(text: &str, _click_offset: ByteOffset) -> Option<Range<ByteOffset>> {
|
||||
(!text.is_empty()).then_some(ByteOffset::zero()..ByteOffset::from(1))
|
||||
}
|
||||
|
||||
fn test_formatted_text_element(text: &str, origin_x: f32, origin_y: f32) -> FormattedTextElement {
|
||||
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(text),
|
||||
])]);
|
||||
let text_frame = Arc::new(TextFrame::mock(text));
|
||||
let frame_bounds = RectF::new(
|
||||
vec2f(origin_x, origin_y),
|
||||
vec2f(text_frame.max_width(), text_frame.height()),
|
||||
);
|
||||
let mouse_handlers = Rc::new(RefCell::new(FrameMouseHandlers::default()));
|
||||
|
||||
let mut element = FormattedTextElement::new(
|
||||
formatted_text,
|
||||
13.0,
|
||||
FamilyId(0),
|
||||
FamilyId(0),
|
||||
ColorU::black(),
|
||||
HighlightedHyperlink::default(),
|
||||
);
|
||||
element.origin = Some(Point::new(origin_x, origin_y, ZIndex::new(0)));
|
||||
element.size = Some(frame_bounds.size());
|
||||
element.laid_out_text = vec![LaidOutTextFrame::Text {
|
||||
text_frame,
|
||||
frame_bounds,
|
||||
bottom_padding: 0.0,
|
||||
raw_text: text.to_string(),
|
||||
mouse_handlers: mouse_handlers.clone(),
|
||||
}];
|
||||
element.text_frame_mouse_handlers = vec![mouse_handlers];
|
||||
element.hyperlink_support = HyperlinkSupport {
|
||||
highlighted_hyperlink: Arc::new(Mutex::new(None)),
|
||||
hyperlink_font_color: ColorU::black(),
|
||||
};
|
||||
element
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_select_returns_none_when_point_is_outside_horizontal_bounds() {
|
||||
let element = test_formatted_text_element("hello", 10.0, 20.0);
|
||||
let point = vec2f(10.0 + 100.0, 25.0);
|
||||
|
||||
assert!(element
|
||||
.smart_select(point, select_first_character)
|
||||
.is_none());
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
use super::{Point, SelectableElement, Selection, SelectionFragment, ZIndex};
|
||||
use crate::platform::Cursor;
|
||||
use crate::text::word_boundaries::WordBoundariesPolicy;
|
||||
use crate::text::{IsRect, SelectionDirection, SelectionType};
|
||||
use crate::TaskId;
|
||||
use crate::{
|
||||
event::DispatchedEvent, AfterLayoutContext, AppContext, Element, Event, EventContext,
|
||||
PaintContext,
|
||||
};
|
||||
use instant::Instant;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::mem;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
/// First arg is is_hovered. True when hovering in, false when hovering out.
|
||||
type HoverHandler = Box<dyn FnMut(bool, &mut EventContext, &AppContext, Vector2F)>;
|
||||
type ClickHandler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F)>;
|
||||
|
||||
pub struct Hoverable {
|
||||
child: Box<dyn Element>,
|
||||
state: MouseStateHandle,
|
||||
origin: Option<Point>,
|
||||
hover_handler: Option<HoverHandler>,
|
||||
// A click is comprised of a mouse down and a mouse up,
|
||||
// both within the hoverable.
|
||||
click_handler: Option<ClickHandler>,
|
||||
mouse_down_handler: Option<ClickHandler>,
|
||||
double_click_handler: Option<ClickHandler>,
|
||||
middle_click_handler: Option<ClickHandler>,
|
||||
right_click_handler: Option<ClickHandler>,
|
||||
forward_click_handler: Option<ClickHandler>,
|
||||
back_click_handler: Option<ClickHandler>,
|
||||
disabled: bool,
|
||||
hover_in_delay: Option<Duration>,
|
||||
hover_out_delay: Option<Duration>,
|
||||
skip_synthetic_hover_out: bool,
|
||||
hover_cursor: Option<Cursor>,
|
||||
reset_cursor_after_click: bool,
|
||||
// This is a short-term solution for properly handling events on stacks. A stack will always
|
||||
// put its children on higher z-indexes than its origin, so a hit test using the standard
|
||||
// `z_index` method would always result in the event being covered (by the children of the
|
||||
// stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element.
|
||||
// Then we use that upper bound to do the hit testing, which means a parent will always get
|
||||
// events from its children, regardless of whether they are stacks or not.
|
||||
child_max_z_index: Option<ZIndex>,
|
||||
//
|
||||
suppress_drag: bool,
|
||||
defer_events_to_children: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MouseState {
|
||||
click_count: Option<u32>,
|
||||
|
||||
/// Whether the element should be considered hovered.
|
||||
///
|
||||
/// When there are hover delays, this does not necessarily
|
||||
/// mean that the mouse is actively over the element;
|
||||
/// see [`Self::is_mouse_over_element`] and [`Self::hovered`] for more details.
|
||||
pub(crate) is_hovered: bool,
|
||||
|
||||
/// Whether the mouse is currently over the element.
|
||||
///
|
||||
/// This property is _not_ delayed by hover delays.
|
||||
is_mouse_over_element: bool,
|
||||
|
||||
/// Keep track of whether the last event changing the hover
|
||||
/// state is a synthetic mouse move. If there are two consecutive
|
||||
/// events that both want to alter the hover state, we stop the
|
||||
/// invocation to prevent the potential infinite loop. Note that
|
||||
/// any non-synthetic event should reset this state to false.
|
||||
last_event_is_synthetic_hover: bool,
|
||||
|
||||
/// A timer that starts when the mouse begins hovering the element.
|
||||
///
|
||||
/// Only [`Some`] if [`Hoverable::hover_in_delay`] is set.
|
||||
hover_in_timer: Option<HoverTimer>,
|
||||
|
||||
/// A timer that starts when the mouse is no longer hovering the element.
|
||||
///
|
||||
/// Only [`Some`] if [`Hoverable::hover_out_delay`] is set.
|
||||
hover_out_timer: Option<HoverTimer>,
|
||||
}
|
||||
|
||||
impl MouseState {
|
||||
/// True iff the element is actively being clicked.
|
||||
pub fn is_clicked(&self) -> bool {
|
||||
self.click_count.is_some()
|
||||
}
|
||||
|
||||
/// [`Some`] iff the element is actively being clicked.
|
||||
/// The number represents how many clicks were registered
|
||||
/// in the corresponding mouse down event.
|
||||
pub fn click_count(&self) -> Option<u32> {
|
||||
self.click_count
|
||||
}
|
||||
|
||||
/// True iff the element is considered hovered.
|
||||
///
|
||||
/// This does not necessarily imply that the mouse
|
||||
/// is actively hovering the element because this
|
||||
/// takes into account any delays. For example,
|
||||
/// if there is a hover-in delay, this will be
|
||||
/// true _after_ the delay (if the mouse is still covering the element).
|
||||
/// See [`Self::is_mouse_over_element`] for that.
|
||||
pub fn is_hovered(&self) -> bool {
|
||||
self.is_hovered
|
||||
}
|
||||
|
||||
/// True iff the mouse is currently over the element.
|
||||
/// This is not affected by any hover delays.
|
||||
pub fn is_mouse_over_element(&self) -> bool {
|
||||
self.is_mouse_over_element
|
||||
}
|
||||
|
||||
pub fn reset_hover_state(&mut self) {
|
||||
self.is_hovered = false;
|
||||
}
|
||||
|
||||
/// Fully clear interaction state. Useful when a click triggers navigation or focus changes,
|
||||
/// and the original element will no longer receive follow-up mouse events (e.g. mouseup).
|
||||
/// This prevents immediate re-hover from synthetic mouse events during layout.
|
||||
pub fn reset_interaction_state(&mut self) {
|
||||
// Clear pressed state so clicked styles don't persist
|
||||
self.click_count = None;
|
||||
// Clear hover states so hover styles/tooltips don't persist
|
||||
self.is_hovered = false;
|
||||
self.is_mouse_over_element = false;
|
||||
// Treat the next synthetic hover as a no-op (avoids instant re-hover during layout)
|
||||
self.last_event_is_synthetic_hover = true;
|
||||
// Cancel any pending hover timers
|
||||
self.hover_in_timer = None;
|
||||
self.hover_out_timer = None;
|
||||
}
|
||||
|
||||
fn set_hover_timer(&mut self, timer_type: HoverTimerType, hover_timer: HoverTimer) {
|
||||
match timer_type {
|
||||
HoverTimerType::HoverIn => self.hover_in_timer = Some(hover_timer),
|
||||
HoverTimerType::HoverOut => self.hover_out_timer = Some(hover_timer),
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_timer(&self, timer_type: HoverTimerType) -> Option<&HoverTimer> {
|
||||
match timer_type {
|
||||
HoverTimerType::HoverIn => self.hover_in_timer.as_ref(),
|
||||
HoverTimerType::HoverOut => self.hover_out_timer.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_hover_timer(&mut self, timer_type: HoverTimerType) -> Option<HoverTimer> {
|
||||
match timer_type {
|
||||
HoverTimerType::HoverIn => self.hover_in_timer.take(),
|
||||
HoverTimerType::HoverOut => self.hover_out_timer.take(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type MouseStateHandle = Arc<Mutex<MouseState>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct HoverTimer {
|
||||
hover_at: Instant,
|
||||
timer_id: TaskId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum HoverTimerType {
|
||||
HoverIn,
|
||||
HoverOut,
|
||||
}
|
||||
|
||||
impl HoverTimerType {
|
||||
fn opposite(&self) -> HoverTimerType {
|
||||
match self {
|
||||
Self::HoverIn => Self::HoverOut,
|
||||
Self::HoverOut => Self::HoverIn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hoverable {
|
||||
pub fn new<F>(state: MouseStateHandle, build_child: F) -> Self
|
||||
where
|
||||
F: FnOnce(&MouseState) -> Box<dyn Element>,
|
||||
{
|
||||
let child = build_child(&state.lock().unwrap());
|
||||
Self {
|
||||
child,
|
||||
state,
|
||||
origin: None,
|
||||
hover_handler: None,
|
||||
click_handler: None,
|
||||
mouse_down_handler: None,
|
||||
double_click_handler: None,
|
||||
middle_click_handler: None,
|
||||
right_click_handler: None,
|
||||
forward_click_handler: None,
|
||||
back_click_handler: None,
|
||||
hover_in_delay: None,
|
||||
hover_out_delay: None,
|
||||
skip_synthetic_hover_out: false,
|
||||
hover_cursor: None,
|
||||
reset_cursor_after_click: false,
|
||||
disabled: false,
|
||||
child_max_z_index: None,
|
||||
suppress_drag: true,
|
||||
defer_events_to_children: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds additional behavior on hover to any existing hover handler, instead
|
||||
/// of replacing the existing handler.
|
||||
pub fn additional_on_hover<F>(mut self, mut callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(bool, &mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
let Some(mut hover_handler) = self.hover_handler else {
|
||||
return self.on_hover(callback);
|
||||
};
|
||||
|
||||
hover_handler = Box::new(move |is_hovered, ctx, app, pos| {
|
||||
hover_handler(is_hovered, ctx, app, pos);
|
||||
callback(is_hovered, ctx, app, pos);
|
||||
});
|
||||
self.hover_handler = Some(hover_handler);
|
||||
self
|
||||
}
|
||||
|
||||
/// Fires whenever [`MouseState::hovered`] changes.
|
||||
pub fn on_hover<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(bool, &mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.hover_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Fires when the mouse is released within the hoverable after it was pressed within the hoverable.
|
||||
pub fn on_click<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.click_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Fires on `LeftMouseDown` (instead of on mouse up).
|
||||
/// Useful when an action should happen immediately on press (e.g. tab activation).
|
||||
pub fn on_mouse_down<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.mouse_down_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_double_click<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.double_click_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_middle_click<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.middle_click_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_right_click<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.right_click_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_back_click<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.back_click_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_forward_click<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
self.forward_click_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a delay between the time that the mouse hovers
|
||||
/// over the element and the time that the mouse state is
|
||||
/// considered hovered via [`MouseState::hovered`], including
|
||||
/// when the [`Hoverable::on_hover`] fires.
|
||||
pub fn with_hover_in_delay(mut self, delay: Duration) -> Self {
|
||||
self.hover_in_delay = Some(delay);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a delay between the time that the mouse stops hovering
|
||||
/// over the element and the time that the mouse state is
|
||||
/// considered unhovered via [`MouseState::hovered`], including
|
||||
/// when the [`Hoverable::on_hover`] fires.
|
||||
pub fn with_hover_out_delay(mut self, delay: Duration) -> Self {
|
||||
self.hover_out_delay = Some(delay);
|
||||
self
|
||||
}
|
||||
|
||||
/// Skip firing [`Hoverable::on_hover`] when an item is hovered on synthetic mouse events.
|
||||
/// Synthetic events are generated by the UI framework when layout changes,
|
||||
/// even though the mouse hasn't actually moved.
|
||||
pub fn with_skip_synthetic_hover_out(mut self) -> Self {
|
||||
self.skip_synthetic_hover_out = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Change the mouse cursor when hovered
|
||||
pub fn with_cursor(mut self, cursor: Cursor) -> Self {
|
||||
self.hover_cursor = Some(cursor);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_reset_cursor_after_click(mut self) -> Self {
|
||||
self.reset_cursor_after_click = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_propagate_drag(mut self) -> Self {
|
||||
self.suppress_drag = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// When enabled, skips this Hoverable's click handler if a child element
|
||||
/// already handled the click event.
|
||||
pub fn with_defer_events_to_children(mut self) -> Self {
|
||||
self.defer_events_to_children = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disable(mut self) -> Self {
|
||||
self.disabled = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn state(&mut self) -> MutexGuard<'_, MouseState> {
|
||||
self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Determine if the mouse is currently over the element.
|
||||
///
|
||||
/// If there is another element above this one at the cursor position, then we treat that as
|
||||
/// outside the element for purposes of [`MouseState`].
|
||||
fn is_mouse_over_element(&self, ctx: &EventContext, position: Vector2F) -> bool {
|
||||
let Some(origin) = self.origin else {
|
||||
log::warn!("self.origin was None in `Hoverable::is_mouse_over_element`");
|
||||
return false;
|
||||
};
|
||||
let Some(size) = self.size() else {
|
||||
log::warn!("self.size() was None in `Hoverable::is_mouse_over_element`");
|
||||
return false;
|
||||
};
|
||||
let Some(z_index) = self.child_max_z_index else {
|
||||
log::warn!("self.child_max_z_index was None in `Hoverable::is_mouse_over_element`");
|
||||
return false;
|
||||
};
|
||||
|
||||
let is_hovering = ctx
|
||||
.visible_rect(origin, size)
|
||||
.is_some_and(|bound| bound.contains_point(position));
|
||||
|
||||
let point = Point::from_vec2f(position, z_index);
|
||||
let is_covered = ctx.is_covered(point);
|
||||
|
||||
is_hovering && !is_covered
|
||||
}
|
||||
|
||||
fn set_cursor(&mut self, ctx: &mut EventContext) {
|
||||
if let Some((z_index, cursor)) = self.z_index().zip(self.hover_cursor) {
|
||||
ctx.set_cursor(cursor, z_index);
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_cursor(&mut self, ctx: &mut EventContext) {
|
||||
if self.hover_cursor.is_some() {
|
||||
ctx.reset_cursor();
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_delay(&self, is_hovered: bool) -> Option<Duration> {
|
||||
if is_hovered {
|
||||
self.hover_in_delay
|
||||
} else {
|
||||
self.hover_out_delay
|
||||
}
|
||||
}
|
||||
|
||||
/// The main handler for [`Event::MouseMoved`] events.
|
||||
fn handle_mouse_moved(
|
||||
&mut self,
|
||||
position: Vector2F,
|
||||
is_synthetic: bool,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let was_mouse_over_element = self.state().is_mouse_over_element;
|
||||
let is_hovered = self.is_mouse_over_element(ctx, position);
|
||||
self.state().is_mouse_over_element = is_hovered;
|
||||
|
||||
// The type of timer that we might need to set, if there's a corresponding delay.
|
||||
let hover_timer_type = if is_hovered {
|
||||
HoverTimerType::HoverIn
|
||||
} else {
|
||||
HoverTimerType::HoverOut
|
||||
};
|
||||
|
||||
// If there's a pending hover task for the opposite delay,
|
||||
// cancel it because we're now handling a new hover action.
|
||||
if let Some(timer) = self.state().take_hover_timer(hover_timer_type.opposite()) {
|
||||
ctx.clear_notify_timer(timer.timer_id);
|
||||
}
|
||||
|
||||
// We set / reset cursors immediately (not taking into account
|
||||
// delays) because we want to reflect the correct cursor as
|
||||
// the user is moving their mouse.
|
||||
if was_mouse_over_element != is_hovered {
|
||||
if is_hovered {
|
||||
self.set_cursor(ctx);
|
||||
} else {
|
||||
self.reset_cursor(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
// If there aren't any delays, then we can just handle
|
||||
// the mouse movement immediately.
|
||||
let Some(hover_delay) = self.hover_delay(is_hovered) else {
|
||||
return self.handle_mouse_moved_without_delay(
|
||||
is_hovered,
|
||||
position,
|
||||
is_synthetic,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
// If a timer has already been started, then only handle
|
||||
// the event if the timer is expired. Otherwise, we'll wait
|
||||
// until the timer expires.
|
||||
let timer = self.state().hover_timer(hover_timer_type).cloned();
|
||||
if let Some(timer) = timer {
|
||||
if Instant::now() >= timer.hover_at {
|
||||
return self.handle_mouse_moved_without_delay(
|
||||
is_hovered,
|
||||
position,
|
||||
is_synthetic,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If a timer has not been started, start it now.
|
||||
let (timer_id, hover_at) = ctx.notify_after(hover_delay);
|
||||
self.state()
|
||||
.set_hover_timer(hover_timer_type, HoverTimer { hover_at, timer_id });
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Handles [`Event::MouseMoved`] events when the
|
||||
/// element is going transitioning between hovered <-> unhovered
|
||||
/// states (identified by `is_hovered`).
|
||||
///
|
||||
/// This does _not_ take into account any delays; the handler
|
||||
/// immediately sets the hovered state and fires any related
|
||||
/// callbacks.
|
||||
fn handle_mouse_moved_without_delay(
|
||||
&mut self,
|
||||
is_hovered: bool,
|
||||
position: Vector2F,
|
||||
is_synthetic: bool,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
// If there's no change in hover-state, then there's
|
||||
// no work to do.
|
||||
//
|
||||
// Note: we intentionally compare the `hovered` property
|
||||
// and not the `is_mouse_over_element` property.
|
||||
let was_hovered = self.state().is_hovered;
|
||||
if was_hovered == is_hovered {
|
||||
return false;
|
||||
}
|
||||
self.state().is_hovered = is_hovered;
|
||||
|
||||
// We should only handle this event if not both the previous and current instance of the state change
|
||||
// is triggered by a synthetic mouse event. This is to prevent infinite loops when a child element
|
||||
// conditional on the state of the hoverable might in return trigger the state change of the hoverable.
|
||||
//
|
||||
// TODO: we should re-consider this approach. It can lead to missed `on_hover` dispatches.
|
||||
let was_synthetic = mem::replace(
|
||||
&mut self.state().last_event_is_synthetic_hover,
|
||||
is_synthetic,
|
||||
);
|
||||
if was_synthetic && is_synthetic {
|
||||
log::warn!(
|
||||
"Not handling MouseMoved event in Hoverable due to back-to-back synthetic events."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip synthetic hover-out events if configured to do so.
|
||||
if !is_hovered && is_synthetic && self.skip_synthetic_hover_out {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there's a [`Hoverable::on_hover`] callback registered, call it.
|
||||
if let Some(handler) = self.hover_handler.as_mut() {
|
||||
handler(is_hovered, ctx, app, position);
|
||||
};
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Hoverable {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: crate::SizeConstraint,
|
||||
ctx: &mut crate::LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
self.child.paint(origin, ctx, app);
|
||||
|
||||
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let handled = self.child.dispatch_event(event, ctx, app);
|
||||
if self.disabled {
|
||||
return handled;
|
||||
}
|
||||
|
||||
if self.defer_events_to_children && handled {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.bounds().is_none() {
|
||||
return handled;
|
||||
}
|
||||
|
||||
if !matches!(event.raw_event(), Event::MouseMoved { .. }) {
|
||||
self.state().last_event_is_synthetic_hover = false;
|
||||
}
|
||||
|
||||
// If there's a mouse-down event outside of the element,
|
||||
// there's nothing to do except reset the hover state
|
||||
// (because there might have been a hover delay in-progress).
|
||||
if let Some(position) = event.raw_event().mouse_down_position() {
|
||||
if !self.is_mouse_over_element(ctx, position) {
|
||||
self.state().is_hovered = false;
|
||||
self.state().is_mouse_over_element = false;
|
||||
return handled;
|
||||
}
|
||||
}
|
||||
|
||||
match event.raw_event() {
|
||||
Event::MiddleMouseDown { position, .. } => {
|
||||
if let Some(handler) = self.middle_click_handler.as_mut() {
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::BackMouseDown { position, .. } => {
|
||||
if let Some(handler) = self.back_click_handler.as_mut() {
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::ForwardMouseDown { position, .. } => {
|
||||
if let Some(handler) = self.forward_click_handler.as_mut() {
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::RightMouseDown { position, .. } => {
|
||||
if let Some(handler) = self.right_click_handler.as_mut() {
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::LeftMouseDown {
|
||||
click_count,
|
||||
position,
|
||||
..
|
||||
} => {
|
||||
// Mouse-down sets the mouse state handle accordingly.
|
||||
self.state().click_count = Some(*click_count);
|
||||
|
||||
// Fire the mouse-down handler immediately if one is set.
|
||||
if let Some(handler) = self.mouse_down_handler.as_mut() {
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
// We mark this as handled if we have a handler waiting to take action on the mouse-up event.
|
||||
if self.click_handler.is_some()
|
||||
|| (*click_count == 2 && self.double_click_handler.is_some())
|
||||
{
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::LeftMouseUp { position, .. } => {
|
||||
// Mouse-up should always reset clicked and double-clicked to false.
|
||||
let click_count = self.state().click_count.take();
|
||||
|
||||
// If the event occurs outside the element, don't handle it.
|
||||
if !self.is_mouse_over_element(ctx, *position) {
|
||||
return handled;
|
||||
}
|
||||
|
||||
if self.reset_cursor_after_click {
|
||||
ctx.reset_cursor();
|
||||
}
|
||||
|
||||
// The double-clicked handler takes precendence. However, we should still fall back to the single-click handler
|
||||
// on a double-click if there's no double-click handler set.
|
||||
if matches!(click_count, Some(2)) && self.double_click_handler.is_some() {
|
||||
let handler = self
|
||||
.double_click_handler
|
||||
.as_mut()
|
||||
.expect("handler should exist");
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
} else if click_count.is_some() && self.click_handler.is_some() {
|
||||
let handler = self.click_handler.as_mut().expect("handler should exist");
|
||||
handler(ctx, app, *position);
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::MouseMoved {
|
||||
position,
|
||||
is_synthetic,
|
||||
..
|
||||
} => {
|
||||
if self.handle_mouse_moved(*position, *is_synthetic, ctx, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Event::LeftMouseDragged { .. } => {
|
||||
if self.suppress_drag && self.state().is_clicked() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for Hoverable {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hoverable_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,712 @@
|
||||
use super::*;
|
||||
use crate::elements::DispatchEventResult;
|
||||
use crate::r#async::Timer;
|
||||
use crate::{
|
||||
elements::{
|
||||
ChildAnchor, ConstrainedBox, EventHandler, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Rect, Stack, Text,
|
||||
},
|
||||
fonts::FamilyId,
|
||||
platform::WindowStyle,
|
||||
App, AppContext, Entity, Event, Presenter, TypedActionView, ViewContext, WindowInvalidation,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
enum ElementIdentifier {
|
||||
BottomStack,
|
||||
HoverableElementBottomLeft,
|
||||
HoverableElementTopRight,
|
||||
}
|
||||
|
||||
fn mouse_moved_event(position: Vector2F) -> Event {
|
||||
Event::MouseMoved {
|
||||
position,
|
||||
cmd: false,
|
||||
shift: false,
|
||||
is_synthetic: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
// Maps identifier to number of mouse down events
|
||||
mouse_downs: HashMap<ElementIdentifier, usize>,
|
||||
mouse_ups: HashMap<ElementIdentifier, usize>,
|
||||
hover_ins: HashMap<ElementIdentifier, usize>,
|
||||
hover_outs: HashMap<ElementIdentifier, usize>,
|
||||
bottom_mouse_state: MouseStateHandle,
|
||||
top_mouse_state: MouseStateHandle,
|
||||
|
||||
/// If [`Some`], the top-right hoverable will
|
||||
/// have a hover-in delay with this duration.
|
||||
hover_in_delay: Option<Duration>,
|
||||
|
||||
/// If [`Some`], the top-right hoverable will
|
||||
/// have a hover-out delay with this duration.
|
||||
hover_out_delay: Option<Duration>,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_action("hoverable_test:mouse_down", View::mouse_down);
|
||||
app.add_action("hoverable_test:mouse_up", View::mouse_up);
|
||||
app.add_action("hoverable_test:hover_in", View::hover_in);
|
||||
app.add_action("hoverable_test:hover_out", View::hover_out);
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn with_hover_in_delay(mut self, delay: Duration) -> Self {
|
||||
self.hover_in_delay = Some(delay);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_hover_out_delay(mut self, delay: Duration) -> Self {
|
||||
self.hover_out_delay = Some(delay);
|
||||
self
|
||||
}
|
||||
|
||||
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_down on element {identifier:?}");
|
||||
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn mouse_up(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_up on element {identifier:?}");
|
||||
let entry = self.mouse_ups.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn hover_in(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording hover in on element {identifier:?}");
|
||||
let entry = self.hover_ins.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn hover_out(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording hover out on element {identifier:?}");
|
||||
let entry = self.hover_outs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn num_hover_in_events(&self, identifier: &ElementIdentifier) -> usize {
|
||||
*self.hover_ins.get(identifier).unwrap_or(&0)
|
||||
}
|
||||
|
||||
fn num_hover_out_events(&self, identifier: &ElementIdentifier) -> usize {
|
||||
*self.hover_outs.get(identifier).unwrap_or(&0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn ui_name() -> &'static str {
|
||||
"hoverable_test_view"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut stack = Stack::new();
|
||||
|
||||
stack.add_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(100.)
|
||||
.with_width(100.)
|
||||
.finish(),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
Hoverable::new(self.bottom_mouse_state.clone(), |_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish()
|
||||
})
|
||||
.on_click(|evt, _, _| {
|
||||
evt.dispatch_action(
|
||||
"hoverable_test:mouse_down",
|
||||
ElementIdentifier::HoverableElementBottomLeft,
|
||||
);
|
||||
})
|
||||
.on_hover(|hovered, evt, _, _| {
|
||||
let action_name = if hovered {
|
||||
"hoverable_test:hover_in"
|
||||
} else {
|
||||
"hoverable_test:hover_out"
|
||||
};
|
||||
evt.dispatch_action(action_name, ElementIdentifier::HoverableElementBottomLeft);
|
||||
})
|
||||
.with_cursor(Cursor::Crosshair)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 75.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
let mut hoverable = Hoverable::new(self.top_mouse_state.clone(), |_| {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish()
|
||||
})
|
||||
.on_hover(|hovered, evt, _, _| {
|
||||
let action_name = if hovered {
|
||||
"hoverable_test:hover_in"
|
||||
} else {
|
||||
"hoverable_test:hover_out"
|
||||
};
|
||||
evt.dispatch_action(action_name, ElementIdentifier::HoverableElementTopRight);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand);
|
||||
|
||||
if let Some(delay) = self.hover_in_delay {
|
||||
hoverable = hoverable.with_hover_in_delay(delay);
|
||||
}
|
||||
|
||||
if let Some(delay) = self.hover_out_delay {
|
||||
hoverable = hoverable.with_hover_out_delay(delay);
|
||||
}
|
||||
|
||||
stack.add_positioned_child(
|
||||
hoverable.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(75., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(70.)
|
||||
.with_width(70.)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(15., 15.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
let mut scene = Stack::new();
|
||||
scene.add_child(
|
||||
EventHandler::new(stack.finish())
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action(
|
||||
"hoverable_test:mouse_down",
|
||||
ElementIdentifier::BottomStack,
|
||||
);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_left_mouse_up(|evt, _, _| {
|
||||
evt.dispatch_action("hoverable_test:mouse_up", ElementIdentifier::BottomStack);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
scene.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hoverable_element_click_handling() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Click on the hoverable element on the bottom left corner.
|
||||
// This event should be handled by the Hoverable as it has a
|
||||
// click_handler.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Mouse up on the hoverable element on the bottom left corner.
|
||||
// This should trigger the click_handler and increment the mouse_down
|
||||
// count on HoverableElement by 1.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the hoverable element on the upper right corner.
|
||||
// This event should be handled by the BottomStack instead of Hoverable
|
||||
// as the element does not have a click_handler.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Mouse up on the hoverable element on the top right corner.
|
||||
// Since the element does not have a click_handler, this should
|
||||
// be captured by the base stack.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(90., 10.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the base stack.
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
2,
|
||||
*view
|
||||
.mouse_downs
|
||||
.get(&ElementIdentifier::BottomStack)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
*view
|
||||
.mouse_downs
|
||||
.get(&ElementIdentifier::HoverableElementBottomLeft)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
*view.mouse_ups.get(&ElementIdentifier::BottomStack).unwrap()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hoverable_element_hover_handling_no_delay() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Make sure there are no hover events to start.
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementBottomLeft)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementBottomLeft)
|
||||
);
|
||||
});
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Move the mouse over the hoverable element in the bottom left corner.
|
||||
// This event should be handled immediately by the hover handler
|
||||
// without delay.
|
||||
let event = mouse_moved_event(vec2f(10., 90.));
|
||||
|
||||
// Before the event, the cursor should have it's default shape.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
|
||||
// After the event, the cursor should have the set cursor shape.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Crosshair);
|
||||
|
||||
// Move the mouse to over the covering element. Still over the bottom-left
|
||||
// hoverable, but since it's covered, it should be treated as not hovering
|
||||
let event = mouse_moved_event(vec2f(20., 80.));
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
|
||||
// Move the mouse back to the bottom-left hoverable (not over the covering element)
|
||||
// This should trigger another hover event
|
||||
let event = mouse_moved_event(vec2f(10., 90.));
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Crosshair);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
2,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementBottomLeft)
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementBottomLeft)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hoverable_element_hover_handling_with_hover_in_delay() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::default().with_hover_in_delay(Duration::from_millis(500))
|
||||
});
|
||||
|
||||
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
|
||||
let presenter_clone = presenter.clone();
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.borrow_mut().invalidate(invalidation, ctx);
|
||||
presenter
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
|
||||
// Move the mouse over the hoverable in the top-left corner.
|
||||
// This should not immmediately trigger hover events because this hoverable
|
||||
// has a 0.5s hover-in delay.
|
||||
let event = mouse_moved_event(vec2f(90., 10.));
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter);
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
|
||||
// The cursor, however, should be updated immediately.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
|
||||
// Wait 1s for the delay to complete, then verify that we got a hover event from the
|
||||
// top-right Hoverable
|
||||
Timer::after(Duration::from_secs(1)).await;
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
|
||||
app.update(move |ctx| {
|
||||
// Move the mouse away from the hoverable.
|
||||
// There's no hover-out delay so there shouldn't
|
||||
// be any delay in registering the hover-out event.
|
||||
let event = mouse_moved_event(vec2f(100., 100.));
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter_clone);
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hoverable_element_hover_handling_with_hover_out_delay() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::default().with_hover_out_delay(Duration::from_millis(500))
|
||||
});
|
||||
|
||||
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
|
||||
let presenter_clone = presenter.clone();
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.borrow_mut().invalidate(invalidation, ctx);
|
||||
presenter
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
|
||||
// Move the mouse over the hoverable in the top-left corner.
|
||||
// This should immmediately trigger a hover event because there is no
|
||||
// hover-in delay.
|
||||
let event = mouse_moved_event(vec2f(90., 10.));
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter);
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
|
||||
app.update(move |ctx| {
|
||||
// Move the mouse away from the hoverable.
|
||||
// This should not immmediately trigger hover events because
|
||||
// this hoverable has a 0.5s hover-out delay.
|
||||
let event = mouse_moved_event(vec2f(100., 100.));
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter_clone);
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
|
||||
// The cursor, however, should be updated immediately.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
|
||||
Timer::after(Duration::from_millis(1000)).await;
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hoverable_element_hover_handling_with_hover_in_out_delay() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
View::default()
|
||||
.with_hover_out_delay(Duration::from_millis(500))
|
||||
.with_hover_in_delay(Duration::from_millis(500))
|
||||
});
|
||||
|
||||
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.borrow_mut().invalidate(invalidation, ctx);
|
||||
presenter
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
|
||||
// Move the mouse over the hoverable in the top-left corner.
|
||||
// This should not immmediately trigger hover events because
|
||||
// this hoverable has a 0.5s hover-out delay.
|
||||
let event = mouse_moved_event(vec2f(90., 10.));
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
|
||||
// The cursor should still update immediately.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
|
||||
|
||||
// Move the mouse away from the hoverable in the top-left corner.
|
||||
// Again, there's a hover-out delay so no hover events should be
|
||||
// fired still.
|
||||
let event = mouse_moved_event(vec2f(100., 100.));
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
|
||||
// The cursor should still update immediately.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
|
||||
|
||||
// Move it back over the hoverable and wait.
|
||||
let event = mouse_moved_event(vec2f(90., 10.));
|
||||
ctx.simulate_window_event(event.clone(), window_id, presenter);
|
||||
ctx.set_last_mouse_move_event(window_id, event);
|
||||
|
||||
// The cursor should still update immediately.
|
||||
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
|
||||
// After waiting, there should ultimately be just one hover-in event
|
||||
// for the final mouse movement.
|
||||
//
|
||||
// The other hover-in and hover-out events should have been dropped
|
||||
// due to the mouse moving in and out of the hoverable during the
|
||||
// delay period.
|
||||
Timer::after(Duration::from_millis(1000)).await;
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Why would Elements that haven't been painted need to receive any mouse events?
|
||||
// Shouldn't paint happen BEFORE any user interaction? Yes, but remember that Elements are
|
||||
// disposable, and may get discarded and created anew between invalidations. Most of the time,
|
||||
// Elements do get painted again immediately after creation. However, sometimes Elements can
|
||||
// get scrolled out of view, e.g. outside a ClippedScrollable, and may not get painted. If the
|
||||
// element, like an Editor, is still focused while out of view, it needs to respond to events
|
||||
// without panicking. This test just never paints the Hoverable, and dispatches events on it.
|
||||
#[test]
|
||||
fn test_unpainted_hoverable_receives_click_events_without_panic() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let hover_state = MouseStateHandle::default();
|
||||
let mut hoverable = Hoverable::new(hover_state, |_state| {
|
||||
Text::new_inline("foobar", FamilyId(0), 10.0).finish()
|
||||
});
|
||||
|
||||
app.update(move |ctx| {
|
||||
// We can't use ctx.simulate_window_event for click events here because that would
|
||||
// require us to paint the scene by calling presenter.build_scene. That's because
|
||||
// that code path uses the sizes and origins of the elements to determine which
|
||||
// elements to dispatch the click event on. Instead, we need to call the dispatch_event
|
||||
// method on the Element directly. That requires us to mock the EventContext, which we
|
||||
// can pluck from the Presenter like so:
|
||||
let mut event_ctx = presenter.mock_event_context(ctx.font_cache());
|
||||
|
||||
let mouse_down = Event::LeftMouseDown {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
};
|
||||
hoverable.dispatch_event(&DispatchedEvent::from(mouse_down), &mut event_ctx, ctx);
|
||||
|
||||
let mouse_up = Event::LeftMouseUp {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
hoverable.dispatch_event(&DispatchedEvent::from(mouse_up), &mut event_ctx, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::{Element, Point};
|
||||
use crate::{
|
||||
assets::asset_cache::{AssetCache, AssetSource, AssetState},
|
||||
event::DispatchedEvent,
|
||||
image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache},
|
||||
AfterLayoutContext, AppContext, EventContext, LayoutContext, PaintContext, SingletonEntity,
|
||||
SizeConstraint,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
/// An element that renders a monochrome icon. This differs from `Svg` in that it sets the color dynamically
|
||||
/// instead of statically from the SVG itself.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Icon {
|
||||
path: &'static str,
|
||||
opacity: f32,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
color: ColorU,
|
||||
#[cfg(debug_assertions)]
|
||||
/// Captures the location of the constructor call site. This is used for debugging purposes.
|
||||
constructor_location: Option<&'static std::panic::Location<'static>>,
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
pub fn new(path: &'static str, color: impl Into<ColorU>) -> Self {
|
||||
Self {
|
||||
path,
|
||||
opacity: 1.,
|
||||
size: None,
|
||||
color: color.into(),
|
||||
origin: None,
|
||||
#[cfg(debug_assertions)]
|
||||
constructor_location: Some(std::panic::Location::caller()),
|
||||
}
|
||||
}
|
||||
pub fn with_opacity(mut self, opacity: f32) -> Self {
|
||||
self.opacity = opacity;
|
||||
self
|
||||
}
|
||||
pub fn with_color(mut self, color: impl Into<ColorU>) -> Self {
|
||||
self.color = color.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Icon {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
_: &mut LayoutContext,
|
||||
_: &AppContext,
|
||||
) -> Vector2F {
|
||||
let size = constraint.max;
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let bounds = (self.size.unwrap() * ctx.scene.scale_factor()).to_i32();
|
||||
|
||||
// If the x or y bounds are less than or equal to 0, don't attempt to paint the icon.
|
||||
if bounds.x() <= 0 || bounds.y() <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let asset_cache = AssetCache::as_ref(app);
|
||||
match ImageCache::as_ref(app).image(
|
||||
// Right now, the location of SVG files is hard-coded to be the app bundle. In the future,
|
||||
// to make icons a fetch-able asset, we should modify the API of Icon to accept an AssetSource,
|
||||
// exactly how Image does.
|
||||
AssetSource::Bundled { path: self.path },
|
||||
bounds,
|
||||
FitType::Contain,
|
||||
AnimatedImageBehavior::FullAnimation,
|
||||
CacheOption::BySize,
|
||||
ctx.max_texture_dimension_2d,
|
||||
asset_cache,
|
||||
) {
|
||||
AssetState::Loaded { data } => match data.as_ref() {
|
||||
Image::Static(image) => {
|
||||
let logical_image_size = image.size().to_f32() / ctx.scene.scale_factor();
|
||||
let origin = origin + ((self.size().unwrap() - logical_image_size) / 2.0);
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
ctx.scene
|
||||
.set_location_for_panic_logging(self.constructor_location);
|
||||
|
||||
ctx.scene.draw_icon(
|
||||
RectF::new(origin, logical_image_size),
|
||||
image.clone(),
|
||||
self.opacity,
|
||||
self.color,
|
||||
);
|
||||
}
|
||||
Image::Animated(_image) => {
|
||||
log::info!("Animated icons are currently not supported");
|
||||
}
|
||||
},
|
||||
AssetState::Loading { handle } => {
|
||||
ctx.repaint_after_load(handle);
|
||||
}
|
||||
AssetState::Evicted => {
|
||||
log::warn!("Unable to render svg because it was evicted");
|
||||
}
|
||||
AssetState::FailedToLoad(err) => {
|
||||
log::warn!("Unable to render svg: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_: &DispatchedEvent,
|
||||
_: &mut EventContext,
|
||||
_: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use super::{CornerRadius, Element, Point};
|
||||
use crate::{
|
||||
assets::asset_cache::{AssetCache, AssetSource, AssetState},
|
||||
event::DispatchedEvent,
|
||||
image_cache::{AnimatedImage, AnimatedImageBehavior, FitType, ImageCache, StaticImage},
|
||||
AfterLayoutContext, AppContext, EventContext, LayoutContext, PaintContext, SingletonEntity,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
pub use crate::image_cache::CacheOption;
|
||||
use instant::Instant;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F, Vector2I};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct Image {
|
||||
source: AssetSource,
|
||||
opacity: f32,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
fit_type: FitType,
|
||||
animated_image_behavior: AnimatedImageBehavior,
|
||||
cache_option: CacheOption,
|
||||
started_at: Option<Instant>,
|
||||
corner_radius: CornerRadius,
|
||||
top_aligned: bool,
|
||||
right_aligned: bool,
|
||||
|
||||
/// The "back up" element to render when an asset is not ready or encountered an error.
|
||||
/// This could be None in two situations: (1) the caller does not provide a before_load_element
|
||||
/// or (2) the caller provided one but it's no longer needed due to the image having loaded.
|
||||
before_load_element: Option<Box<dyn Element>>,
|
||||
|
||||
/// To avoid duplicating delayed repaint, we store whether or not we've requested a
|
||||
/// repaint on behalf of this element.
|
||||
///
|
||||
/// Note: we use this for asset loading but not for animation repaint. An animated image
|
||||
/// may request several repaints in its lifetime.
|
||||
requested_repaint_after_load: bool,
|
||||
#[cfg(debug_assertions)]
|
||||
/// Captures the location of the constructor call site. This is used for debugging purposes.
|
||||
constructor_location: Option<&'static std::panic::Location<'static>>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Creates an image element with an explicit [`CacheOption`].
|
||||
///
|
||||
/// Use [`CacheOption::BySize`] for images rendered at a fixed size (icons, thumbnails);
|
||||
/// a CPU-resized copy is cached per size.
|
||||
/// Use [`CacheOption::Original`] for images whose display size changes continuously
|
||||
/// (e.g. background images); only the original asset is cached and the GPU scales it.
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
pub fn new(source: AssetSource, cache_option: CacheOption) -> Self {
|
||||
Self {
|
||||
source,
|
||||
opacity: 1.,
|
||||
size: None,
|
||||
origin: None,
|
||||
fit_type: FitType::Contain,
|
||||
animated_image_behavior: AnimatedImageBehavior::default(),
|
||||
cache_option,
|
||||
started_at: None,
|
||||
corner_radius: CornerRadius::default(),
|
||||
top_aligned: false,
|
||||
right_aligned: false,
|
||||
before_load_element: None,
|
||||
requested_repaint_after_load: false,
|
||||
#[cfg(debug_assertions)]
|
||||
constructor_location: Some(std::panic::Location::caller()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_corner_radius(mut self, radius: CornerRadius) -> Self {
|
||||
self.corner_radius = radius;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_opacity(mut self, opacity: f32) -> Self {
|
||||
self.opacity = opacity;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cover(mut self) -> Self {
|
||||
self.fit_type = FitType::Cover;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn contain(mut self) -> Self {
|
||||
self.fit_type = FitType::Contain;
|
||||
self
|
||||
}
|
||||
|
||||
/// Stretches the image to fill the element bounds without preserving the aspect ratio.
|
||||
pub fn stretch(mut self) -> Self {
|
||||
self.fit_type = FitType::Stretch;
|
||||
self
|
||||
}
|
||||
|
||||
/// Renders animated image sources as a static preview of their first frame.
|
||||
pub fn first_frame_preview(mut self) -> Self {
|
||||
self.animated_image_behavior = AnimatedImageBehavior::FirstFramePreview;
|
||||
self
|
||||
}
|
||||
|
||||
/// Aligns the image to the top of the element bounds instead of centering vertically.
|
||||
/// Useful for cover-fit images where the bottom should be clipped rather than
|
||||
/// cropping equally from top and bottom.
|
||||
pub fn top_aligned(mut self) -> Self {
|
||||
self.top_aligned = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Aligns the image to the right of the element bounds and pins it to the top.
|
||||
/// Useful for contain-fit images where the image is narrower than the element
|
||||
/// and the empty space should appear on the left rather than being split on both sides.
|
||||
pub fn right_aligned(mut self) -> Self {
|
||||
self.right_aligned = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables animated images for the current image element. The start time indicates
|
||||
/// the timestamp at which the animated image started rendering. The element uses
|
||||
/// this timestamp to calculate which frame of the animation to display at a given
|
||||
/// moment.
|
||||
/// Animations are still fairly experimental, so you should do extensive testing to
|
||||
/// make sure there's no performance degradation from using an animation.
|
||||
pub fn enable_animation_with_start_time(mut self, started_at: Instant) -> Self {
|
||||
self.started_at = Some(started_at);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn before_load(mut self, element: Box<dyn Element>) -> Self {
|
||||
self.before_load_element = Some(element);
|
||||
self
|
||||
}
|
||||
|
||||
fn paint_static_image(
|
||||
&mut self,
|
||||
image: Arc<StaticImage>,
|
||||
size: Vector2F,
|
||||
origin: Vector2F,
|
||||
bounds: Vector2I,
|
||||
ctx: &mut PaintContext,
|
||||
) {
|
||||
let desired_image_size = match self.cache_option {
|
||||
CacheOption::Original => {
|
||||
dimensions(image.size().to_f32(), bounds.to_f32(), self.fit_type)
|
||||
}
|
||||
_ => image.size().to_f32(),
|
||||
};
|
||||
let logical_image_size = desired_image_size / ctx.scene.scale_factor();
|
||||
let Some(rect) = image_rect(
|
||||
size,
|
||||
origin,
|
||||
logical_image_size,
|
||||
self.top_aligned,
|
||||
self.right_aligned,
|
||||
) else {
|
||||
self.origin = None;
|
||||
log::error!(
|
||||
"invalid image rect before draw_image source={:?} element_size=({}, {}) image_size=({}, {}) desired_image_size=({}, {}) logical_image_size=({}, {}) origin=({}, {}) bounds=({}, {}) fit_type={:?} cache_option={:?}",
|
||||
self.source,
|
||||
size.x(),
|
||||
size.y(),
|
||||
image.width(),
|
||||
image.height(),
|
||||
desired_image_size.x(),
|
||||
desired_image_size.y(),
|
||||
logical_image_size.x(),
|
||||
logical_image_size.y(),
|
||||
origin.x(),
|
||||
origin.y(),
|
||||
bounds.x(),
|
||||
bounds.y(),
|
||||
self.fit_type,
|
||||
self.cache_option,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
self.origin = Some(Point::from_vec2f(rect.origin(), ctx.scene.z_index()));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
ctx.scene
|
||||
.set_location_for_panic_logging(self.constructor_location);
|
||||
|
||||
ctx.scene
|
||||
.draw_image(rect, image, self.opacity, self.corner_radius);
|
||||
}
|
||||
|
||||
fn paint_animated_image(
|
||||
&mut self,
|
||||
animated_image: Arc<AnimatedImage>,
|
||||
size: Vector2F,
|
||||
origin: Vector2F,
|
||||
bounds: Vector2I,
|
||||
ctx: &mut PaintContext,
|
||||
) {
|
||||
// If self.started_at is not provided, we set it to current time
|
||||
// so only the first frame is shown.
|
||||
let started_at = self.started_at.unwrap_or_else(Instant::now);
|
||||
let elapsed_time = started_at.elapsed().as_millis();
|
||||
// After about ~50 days, casting `elapsed_time` to a u32 will
|
||||
// silently overflow. The gif may jump and start playing from a
|
||||
// different frame.
|
||||
match animated_image.get_current_frame(elapsed_time as u32) {
|
||||
Ok((frame, remaining_delay)) => {
|
||||
self.paint_static_image(frame.clone(), size, origin, bounds, ctx);
|
||||
// Only repaint if self.started_at is set. Otherwise
|
||||
// enable_animation_with_start_time has not been called
|
||||
// and we shouldn't animate.
|
||||
if self.started_at.is_some() {
|
||||
ctx.repaint_after(Duration::from_millis(remaining_delay as u64));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Unable to retrieve current frame from image: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_rect(
|
||||
size: Vector2F,
|
||||
origin: Vector2F,
|
||||
logical_image_size: Vector2F,
|
||||
top_aligned: bool,
|
||||
right_aligned: bool,
|
||||
) -> Option<RectF> {
|
||||
let offset = if right_aligned {
|
||||
vec2f(size.x() - logical_image_size.x(), 0.0)
|
||||
} else if top_aligned {
|
||||
vec2f((size.x() - logical_image_size.x()) / 2.0, 0.0)
|
||||
} else {
|
||||
(size - logical_image_size) / 2.0
|
||||
};
|
||||
let origin = origin + offset;
|
||||
let rect = RectF::new(origin, logical_image_size);
|
||||
if rect.origin().x().is_finite()
|
||||
&& rect.origin().y().is_finite()
|
||||
&& rect.size().x().is_finite()
|
||||
&& rect.size().y().is_finite()
|
||||
{
|
||||
Some(rect)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns desired dimensions of the image given the original size (x, y), desired container size
|
||||
/// (dest_x, dest_y) and fit_type.
|
||||
/// Returns a vector with new dimensions maintaining the original aspect ratio,
|
||||
/// unless the FitType is `Stretch`.
|
||||
fn dimensions(original: Vector2F, dest: Vector2F, fit_type: FitType) -> Vector2F {
|
||||
let ratio_x = dest.x() / original.x();
|
||||
let ratio_y = dest.y() / original.y();
|
||||
|
||||
let ratio = match fit_type {
|
||||
FitType::Contain => ratio_x.min(ratio_y),
|
||||
FitType::Cover => ratio_x.max(ratio_y),
|
||||
FitType::Stretch => {
|
||||
// Stretch doesn't maintain aspect ratio
|
||||
return dest;
|
||||
}
|
||||
};
|
||||
|
||||
let x = original.x() * ratio;
|
||||
let y = original.y() * ratio;
|
||||
|
||||
vec2f(x.max(1.), y.max(1.)).round()
|
||||
}
|
||||
|
||||
impl Element for Image {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let size = constraint.max;
|
||||
self.size = Some(size);
|
||||
|
||||
if let Some(before_load_element) = self.before_load_element.as_mut() {
|
||||
before_load_element.layout(constraint, ctx, app);
|
||||
}
|
||||
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
if let Some(before_load_element) = self.before_load_element.as_mut() {
|
||||
before_load_element.after_layout(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let Some(size) = self.size else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bounds = (size * ctx.scene.scale_factor()).to_i32();
|
||||
if !size.x().is_finite()
|
||||
|| !size.y().is_finite()
|
||||
|| size.x() <= 0.0
|
||||
|| size.y() <= 0.0
|
||||
|| bounds.x() <= 0
|
||||
|| bounds.y() <= 0
|
||||
{
|
||||
log::warn!(
|
||||
"image paint with suspicious size source={:?} element_size=({}, {}) bounds=({}, {}) fit_type={:?} cache_option={:?}",
|
||||
self.source,
|
||||
size.x(),
|
||||
size.y(),
|
||||
bounds.x(),
|
||||
bounds.y(),
|
||||
self.fit_type,
|
||||
self.cache_option,
|
||||
);
|
||||
}
|
||||
let assert_cache = AssetCache::as_ref(app);
|
||||
let image = ImageCache::as_ref(app).image(
|
||||
self.source.clone(),
|
||||
bounds,
|
||||
self.fit_type,
|
||||
self.animated_image_behavior,
|
||||
self.cache_option,
|
||||
ctx.max_texture_dimension_2d,
|
||||
assert_cache,
|
||||
);
|
||||
|
||||
match image {
|
||||
AssetState::Loading { handle } => {
|
||||
if !self.requested_repaint_after_load {
|
||||
ctx.repaint_after_load(handle);
|
||||
self.requested_repaint_after_load = true;
|
||||
}
|
||||
|
||||
if let Some(before_load_element) = self.before_load_element.as_mut() {
|
||||
before_load_element.paint(origin, ctx, app);
|
||||
}
|
||||
}
|
||||
AssetState::Evicted => {
|
||||
if let Some(before_load_element) = self.before_load_element.as_mut() {
|
||||
before_load_element.paint(origin, ctx, app);
|
||||
}
|
||||
}
|
||||
AssetState::FailedToLoad(_) => {
|
||||
if let Some(before_load_element) = self.before_load_element.as_mut() {
|
||||
before_load_element.paint(origin, ctx, app);
|
||||
}
|
||||
}
|
||||
AssetState::Loaded { data } => {
|
||||
// Don't waste time calling layout() and after_layout() on the backup element once the main
|
||||
// one has loaded.
|
||||
self.before_load_element = None;
|
||||
|
||||
match data.as_ref() {
|
||||
crate::image_cache::Image::Static(static_image) => {
|
||||
self.paint_static_image(static_image.clone(), size, origin, bounds, ctx)
|
||||
}
|
||||
crate::image_cache::Image::Animated(animated_image) => {
|
||||
self.paint_animated_image(animated_image.clone(), size, origin, bounds, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_: &DispatchedEvent,
|
||||
_: &mut EventContext,
|
||||
_: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "image_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_rect_returns_none_for_nan_origin() {
|
||||
assert!(image_rect(
|
||||
vec2f(164.0, 164.0),
|
||||
vec2f(f32::NAN, 874.725),
|
||||
vec2f(163.75, 163.75),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Utilities for editing lists.
|
||||
|
||||
use enum_iterator::Sequence;
|
||||
use std::fmt;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "list_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Technically we don't need to cap these numbers. But the list
|
||||
/// would be hard to render and read when the text gets too long.
|
||||
/// For now, we cap the alphabet list at 26*3 = 78 and roman list at
|
||||
/// 30, which should be sufficient for most of the use cases.
|
||||
const MAX_ALPHABET_NUM: usize = 78;
|
||||
const MAX_ROMAN_NUM: usize = 30;
|
||||
|
||||
/// The indentation level we support in unordered and ordered list.
|
||||
#[derive(Eq, PartialEq, Clone, Copy, Debug, Hash, Sequence, PartialOrd, Ord)]
|
||||
pub enum ListIndentLevel {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
}
|
||||
|
||||
impl ListIndentLevel {
|
||||
/// Only supports for indent level up to 2. If the indent level is greater than 2, it will snap
|
||||
/// to [`ListIndentLevel::Three`].
|
||||
pub fn from_usize(indent_level: usize) -> Self {
|
||||
match indent_level {
|
||||
0 => Self::One,
|
||||
1 => Self::Two,
|
||||
2 => Self::Three,
|
||||
_ => {
|
||||
log::warn!("Only support indent level up to 2");
|
||||
Self::Three
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supports for any indent level. If the indent level is greater than 2, it will return
|
||||
/// the result of [`Self::from_usize`] with the indentation level mod 3 (cyclic).
|
||||
pub fn from_usize_unbounded(indentation_level: usize) -> Self {
|
||||
match indentation_level {
|
||||
0 => Self::One,
|
||||
1 => Self::Two,
|
||||
2 => Self::Three,
|
||||
_ => Self::from_usize(indentation_level % 3),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_usize(&self) -> usize {
|
||||
match self {
|
||||
Self::One => 0,
|
||||
Self::Two => 1,
|
||||
Self::Three => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_right(self) -> Self {
|
||||
match self {
|
||||
Self::One => Self::Two,
|
||||
Self::Two | Self::Three => Self::Three,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_left(self) -> Self {
|
||||
match self {
|
||||
Self::Three => Self::Two,
|
||||
Self::One | Self::Two => Self::One,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the string representation of the number for the ordered list item of the given indentation.
|
||||
pub fn list_number_string(&self, number: usize) -> String {
|
||||
match self {
|
||||
ListIndentLevel::One => number.to_string(),
|
||||
ListIndentLevel::Two => number_to_alphabet(number.saturating_sub(1)),
|
||||
ListIndentLevel::Three => number_to_roman(number.saturating_sub(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ListIndentLevel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
ListIndentLevel::One => "1",
|
||||
ListIndentLevel::Two => "2",
|
||||
ListIndentLevel::Three => "3",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracker for ordered list numbers.
|
||||
#[derive(Default)]
|
||||
pub struct ListNumbering {
|
||||
/// The current list index at each indent level, from 0 to the current indent level.
|
||||
/// * When entering a new sublist (the indent level increases), we start its index at the first
|
||||
/// item's number (or 1 for auto-numbered items).
|
||||
/// * When exiting a sublist (the indent level decreases), we truncate to the parent indent
|
||||
/// level so that numbering from one sublist doesn't affect later sublists that happen to be
|
||||
/// at the same indent level.
|
||||
/// * Within one list/sublist, we only use the first item's number, and auto-number all
|
||||
/// subsequent items.
|
||||
indices_by_level: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct OrderedListLabel {
|
||||
/// The numerical value of the label.
|
||||
pub label_index: usize,
|
||||
/// The displayed string of the label.
|
||||
pub display_label: String,
|
||||
}
|
||||
|
||||
impl ListNumbering {
|
||||
/// Construct a new numbering tracker.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns `true` if the next list item is explicitly numberable (i.e. if the `number`
|
||||
/// parameter to [`Self::advance`] will be respected).
|
||||
pub fn can_number(&self, indent: usize) -> bool {
|
||||
self.indices_by_level.len() <= indent
|
||||
}
|
||||
|
||||
/// Advance to the next ordered list item, returning its index.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// * `indent` the current list indent level, starting at 0.
|
||||
/// * `number` the number assigned to the list, if present. If the item is not the first at its
|
||||
/// indent level, this number is ignored. This matches Markdown's behavior and the semantics
|
||||
/// of the HTML `start` attribute.
|
||||
pub fn advance(&mut self, indent: usize, number: Option<usize>) -> OrderedListLabel {
|
||||
let can_number = self.can_number(indent);
|
||||
self.indices_by_level.resize(indent + 1, 0);
|
||||
|
||||
// Panic-safety: Due to the resize above, `self.indices` contains exactly `indent + 1`
|
||||
// items, so `indent` is a valid index.
|
||||
let slot = &mut self.indices_by_level[indent];
|
||||
|
||||
match number {
|
||||
Some(number) if can_number => *slot = number,
|
||||
_ => *slot += 1,
|
||||
}
|
||||
|
||||
let indentation_level = ListIndentLevel::from_usize_unbounded(indent);
|
||||
OrderedListLabel {
|
||||
label_index: *slot,
|
||||
display_label: indentation_level.list_number_string(*slot),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset after encountering a non-ordered-list item.
|
||||
pub fn reset(&mut self) {
|
||||
self.indices_by_level.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a number into a alphabet for ordered lists. We would repeat the alphabet to
|
||||
/// represent any number larger than 26. For example, 27 would be "aa".
|
||||
/// Note that this is 0-based so 0 -> 'a', 1 -> 'b'.
|
||||
fn number_to_alphabet(num: usize) -> String {
|
||||
// Cap it to the max number of alphabet repeats.
|
||||
let capped_num = num % MAX_ALPHABET_NUM;
|
||||
|
||||
let num_repeat = capped_num / 26;
|
||||
let remainder = (capped_num % 26) as u8;
|
||||
|
||||
let alphabet = (remainder + 97) as char;
|
||||
alphabet.to_string().repeat(num_repeat + 1)
|
||||
}
|
||||
|
||||
/// Convert a number into a roman number for ordered lists.
|
||||
/// Note that this is 0-based so 0 -> 'i', 1 -> 'ii'.
|
||||
fn number_to_roman(num: usize) -> String {
|
||||
// Cap it to the max number we want to represent.
|
||||
let mut capped_num = (num % MAX_ROMAN_NUM) + 1;
|
||||
|
||||
let roman_pairs = [("x", 10), ("ix", 9), ("v", 5), ("iv", 4), ("i", 1)];
|
||||
let mut result = String::new();
|
||||
for (name, value) in roman_pairs.iter() {
|
||||
while capped_num >= *value {
|
||||
capped_num -= value;
|
||||
result.push_str(name);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use super::{number_to_alphabet, number_to_roman, ListNumbering};
|
||||
use crate::elements::OrderedListLabel;
|
||||
|
||||
#[test]
|
||||
fn test_entirely_automatic() {
|
||||
let mut numbering = ListNumbering::new();
|
||||
// Start at level 0.
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "1".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 2,
|
||||
display_label: "2".to_owned()
|
||||
}
|
||||
);
|
||||
// Indent, which should start over again at 1.
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "a".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 2,
|
||||
display_label: "b".to_owned()
|
||||
}
|
||||
);
|
||||
// Un-indent, which should resume at 3.
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 3,
|
||||
display_label: "3".to_owned()
|
||||
}
|
||||
);
|
||||
// Re-indent, which should restart at 1.
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "a".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_indent_jump() {
|
||||
// Ensure that we can skip indent levels.
|
||||
let mut numbering = ListNumbering::new();
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "1".to_owned()
|
||||
}
|
||||
);
|
||||
// Skip multiple levels of indentation.
|
||||
assert_eq!(
|
||||
numbering.advance(4, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "a".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(4, None),
|
||||
OrderedListLabel {
|
||||
label_index: 2,
|
||||
display_label: "b".to_owned()
|
||||
}
|
||||
);
|
||||
// Skip multiple levels un-indenting.
|
||||
assert_eq!(
|
||||
numbering.advance(2, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "i".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "a".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 2,
|
||||
display_label: "2".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assigned_numbers() {
|
||||
let mut numbering = ListNumbering::new();
|
||||
assert_eq!(
|
||||
numbering.advance(0, Some(4)),
|
||||
OrderedListLabel {
|
||||
label_index: 4,
|
||||
display_label: "4".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 5,
|
||||
display_label: "5".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 6,
|
||||
display_label: "6".to_owned()
|
||||
}
|
||||
);
|
||||
// Assigned numbers not at the start should be ignored.
|
||||
assert_eq!(
|
||||
numbering.advance(0, Some(1)),
|
||||
OrderedListLabel {
|
||||
label_index: 7,
|
||||
display_label: "7".to_owned()
|
||||
}
|
||||
);
|
||||
// Assigned numbers at a new indent level are respected.
|
||||
assert_eq!(
|
||||
numbering.advance(1, Some(3)),
|
||||
OrderedListLabel {
|
||||
label_index: 3,
|
||||
display_label: "c".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 4,
|
||||
display_label: "d".to_owned()
|
||||
}
|
||||
);
|
||||
// The custom start number shouldn't be lost when un-indenting.
|
||||
assert_eq!(
|
||||
numbering.advance(0, Some(2)),
|
||||
OrderedListLabel {
|
||||
label_index: 8,
|
||||
display_label: "8".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut numbering = ListNumbering::new();
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "1".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "a".to_owned()
|
||||
}
|
||||
);
|
||||
numbering.reset();
|
||||
// After a reset, all levels should be 1.
|
||||
assert_eq!(
|
||||
numbering.advance(1, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "a".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
numbering.advance(0, None),
|
||||
OrderedListLabel {
|
||||
label_index: 1,
|
||||
display_label: "1".to_owned()
|
||||
}
|
||||
);
|
||||
|
||||
// This assigned number is ignored, since it's not at the start of a list.
|
||||
assert_eq!(
|
||||
numbering.advance(0, Some(5)),
|
||||
OrderedListLabel {
|
||||
label_index: 2,
|
||||
display_label: "2".to_owned()
|
||||
}
|
||||
);
|
||||
|
||||
numbering.reset();
|
||||
// This assigned number is kept because of the reset.
|
||||
assert_eq!(
|
||||
numbering.advance(0, Some(5)),
|
||||
OrderedListLabel {
|
||||
label_index: 5,
|
||||
display_label: "5".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_to_roman() {
|
||||
assert_eq!(number_to_roman(0), "i");
|
||||
assert_eq!(number_to_roman(5), "vi");
|
||||
assert_eq!(number_to_roman(29), "xxx");
|
||||
assert_eq!(number_to_roman(30), "i");
|
||||
assert_eq!(number_to_roman(61), "ii");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_to_alphabet() {
|
||||
assert_eq!(number_to_alphabet(0), "a");
|
||||
assert_eq!(number_to_alphabet(25), "z");
|
||||
assert_eq!(number_to_alphabet(26), "aa");
|
||||
assert_eq!(number_to_alphabet(27), "bb");
|
||||
assert_eq!(number_to_alphabet(77), "zzz");
|
||||
assert_eq!(number_to_alphabet(78), "a");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use crate::{
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SelectableElement, Selection, SelectionFragment, SizeConstraint,
|
||||
};
|
||||
|
||||
/// `MinSize` ensures that it takes up *at least* the minimum size constraint specified by its
|
||||
/// parent. It's similar to [`super::Align`] but will not grow to fill the maximum space available.
|
||||
pub struct MinSize {
|
||||
child: Box<dyn Element>,
|
||||
size: Option<Vector2F>,
|
||||
}
|
||||
|
||||
impl MinSize {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self { child, size: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for MinSize {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let child_constraint = SizeConstraint::new(Vector2F::zero(), constraint.max);
|
||||
let mut size = self.child.layout(child_constraint, ctx, app);
|
||||
size.set_x(size.x().max(constraint.min.x()));
|
||||
size.set_y(size.y().max(constraint.min.y()));
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
let self_center = self.size.expect("Size must be set during paint") / 2.0;
|
||||
let child_center = self
|
||||
.child
|
||||
.size()
|
||||
.expect("Child size must be set during paint")
|
||||
/ 2.0;
|
||||
|
||||
let child_origin = origin - (child_center - self_center);
|
||||
self.child.paint(child_origin, ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for MinSize {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
mod align;
|
||||
mod child_view;
|
||||
mod clipped;
|
||||
mod clipped_scrollable;
|
||||
mod constrained_box;
|
||||
mod container;
|
||||
#[cfg(debug_assertions)]
|
||||
mod debug;
|
||||
mod dismiss;
|
||||
mod drag;
|
||||
pub mod drag_resize;
|
||||
mod empty;
|
||||
mod event_handler;
|
||||
mod flex;
|
||||
mod formatted_text_element;
|
||||
mod hoverable;
|
||||
mod icon;
|
||||
mod image;
|
||||
mod list;
|
||||
mod min_size;
|
||||
pub mod new_scrollable;
|
||||
mod percentage;
|
||||
mod rect;
|
||||
pub mod resizable;
|
||||
mod scrollable;
|
||||
mod selectable_area;
|
||||
pub mod shared_scrollbar;
|
||||
pub mod shimmering_text;
|
||||
mod size_constraint_switch;
|
||||
mod stack;
|
||||
pub mod table;
|
||||
mod text;
|
||||
mod uniform_list;
|
||||
mod viewported_list;
|
||||
|
||||
pub use align::*;
|
||||
pub use child_view::*;
|
||||
pub use clipped::*;
|
||||
pub use clipped_scrollable::*;
|
||||
pub use constrained_box::*;
|
||||
pub use container::*;
|
||||
#[cfg(debug_assertions)]
|
||||
pub use debug::*;
|
||||
pub use dismiss::*;
|
||||
pub use drag::*;
|
||||
pub use drag_resize::*;
|
||||
pub use empty::*;
|
||||
pub use event_handler::*;
|
||||
pub use flex::*;
|
||||
pub use formatted_text_element::*;
|
||||
pub use hoverable::*;
|
||||
pub use icon::*;
|
||||
pub use image::*;
|
||||
pub use list::*;
|
||||
pub use min_size::*;
|
||||
pub use new_scrollable::NewScrollable;
|
||||
pub use percentage::*;
|
||||
pub use rect::*;
|
||||
pub use resizable::*;
|
||||
pub use scrollable::*;
|
||||
pub use selectable_area::*;
|
||||
pub use shared_scrollbar::*;
|
||||
pub use size_constraint_switch::*;
|
||||
pub use stack::*;
|
||||
pub use table::{
|
||||
RowBackground, Table, TableColumnWidth, TableConfig, TableHeader, TableState, TableStateHandle,
|
||||
TableVerticalSizing,
|
||||
};
|
||||
pub use text::*;
|
||||
pub use uniform_list::*;
|
||||
pub use viewported_list::*;
|
||||
|
||||
use crate::event::ModifiersState;
|
||||
use crate::platform::Cursor;
|
||||
use crate::{
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
Gradient,
|
||||
};
|
||||
pub use crate::{
|
||||
scene::Dash, scene::ZIndex, AfterLayoutContext, AppContext, Event, EventContext, LayoutContext,
|
||||
PaintContext, SizeConstraint,
|
||||
};
|
||||
use core::fmt;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use std::sync::MutexGuard;
|
||||
|
||||
/// The result of dispatching an event.
|
||||
/// This is (future) return type of `dispatch_event`.
|
||||
/// This will eventually replace the current boolean return type, to be more explicit about
|
||||
/// which events should continue to propagate to parent elements and which should stop.
|
||||
pub enum DispatchEventResult {
|
||||
/// The event should continue to propagate to parent elements.
|
||||
PropagateToParent,
|
||||
/// The event should not propagate to parent elements.
|
||||
StopPropagation,
|
||||
}
|
||||
|
||||
pub trait Element {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F;
|
||||
|
||||
fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext);
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext);
|
||||
|
||||
fn size(&self) -> Option<Vector2F>;
|
||||
|
||||
fn origin(&self) -> Option<Point>;
|
||||
|
||||
fn z_index(&self) -> Option<ZIndex> {
|
||||
self.origin().map(|p| p.z_index())
|
||||
}
|
||||
|
||||
fn bounds(&self) -> Option<RectF> {
|
||||
try_rect_with_z(self.origin(), self.size())
|
||||
}
|
||||
|
||||
fn parent_data(&self) -> Option<&dyn Any> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Should be implemented alongside the SelectableElement trait. If implemented, it
|
||||
/// should return the element as a SelectableElement.
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle an event from the OS (e.g. Mouse or Keyboard events)
|
||||
///
|
||||
/// Note: For each OS event, this is called on the root Element of the Element tree. Each
|
||||
/// Element is then itself responsible for calling `dispatch_event` on its children. The
|
||||
/// expectations for how an event propagates through the Element tree are:
|
||||
///
|
||||
/// 1. Each Element that handles an event in some meaningful way will first verify that the
|
||||
/// event applies to them by doing any necessary hit testing.
|
||||
/// 2. Each parent Element will unconditionally pass the event to its children by calling
|
||||
/// `dispatch_event` on them, which allows the children to make their own determination
|
||||
/// of whether or not the event applies.
|
||||
/// 3. Elements should return true if they handled the event and don't want it to propagate
|
||||
/// to parent elements, and false if they want it to propagate to parent elements.
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool;
|
||||
|
||||
fn finish(self) -> Box<dyn Element>
|
||||
where
|
||||
Self: 'static + Sized,
|
||||
{
|
||||
Box::new(self)
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn type_name(&self) -> &'static str {
|
||||
std::any::type_name::<Self>()
|
||||
}
|
||||
|
||||
/// Returns the text content of this element, if it contains text.
|
||||
/// This is primarily used for testing to verify rendered text content.
|
||||
/// Container elements should aggregate text from their children.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ParentElement: Extend<Box<dyn Element>> + Sized {
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
fn add_children(&mut self, children: impl IntoIterator<Item = Box<dyn Element>>) {
|
||||
self.extend(children);
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
fn add_child(&mut self, child: Box<dyn Element>) {
|
||||
self.extend(Some(child))
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
fn with_children(mut self, children: impl IntoIterator<Item = Box<dyn Element>>) -> Self {
|
||||
self.add_children(children);
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
fn with_child(self, child: Box<dyn Element>) -> Self {
|
||||
self.with_children(Some(child))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ParentElement for T where T: Extend<Box<dyn Element>> {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SelectionFragment {
|
||||
pub text: String,
|
||||
pub origin: Point,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Point {
|
||||
xy: Vector2F,
|
||||
z_index: ZIndex,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub fn new(x: f32, y: f32, z_index: ZIndex) -> Self {
|
||||
Self {
|
||||
xy: vec2f(x, y),
|
||||
z_index,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_vec2f(xy: Vector2F, z_index: ZIndex) -> Self {
|
||||
Self { xy, z_index }
|
||||
}
|
||||
|
||||
pub fn x(&self) -> f32 {
|
||||
self.xy.x()
|
||||
}
|
||||
|
||||
pub fn y(&self) -> f32 {
|
||||
self.xy.y()
|
||||
}
|
||||
|
||||
pub fn xy(&self) -> Vector2F {
|
||||
self.xy
|
||||
}
|
||||
|
||||
pub fn z_index(&self) -> ZIndex {
|
||||
self.z_index
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Axis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
pub fn invert(self) -> Self {
|
||||
match self {
|
||||
Self::Horizontal => Self::Vertical,
|
||||
Self::Vertical => Self::Horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_point(self, pos_along_main_axis: f32, pos_along_inverse_axis: f32) -> Vector2F {
|
||||
match self {
|
||||
Self::Horizontal => vec2f(pos_along_main_axis, pos_along_inverse_axis),
|
||||
Self::Vertical => vec2f(pos_along_inverse_axis, pos_along_main_axis),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AxisOrientation {
|
||||
Normal,
|
||||
Reverse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum Fill {
|
||||
#[default]
|
||||
None,
|
||||
Solid(ColorU),
|
||||
Gradient {
|
||||
start: Vector2F,
|
||||
end: Vector2F,
|
||||
start_color: ColorU,
|
||||
end_color: ColorU,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ColorU> for Fill {
|
||||
fn from(color: ColorU) -> Self {
|
||||
Fill::Solid(color)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Margin {
|
||||
top: f32,
|
||||
left: f32,
|
||||
bottom: f32,
|
||||
right: f32,
|
||||
}
|
||||
|
||||
impl Margin {
|
||||
pub const fn uniform(margin: f32) -> Self {
|
||||
Margin {
|
||||
top: margin,
|
||||
left: margin,
|
||||
bottom: margin,
|
||||
right: margin,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_left(mut self, margin: f32) -> Self {
|
||||
self.left = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_right(mut self, margin: f32) -> Self {
|
||||
self.right = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_top(mut self, margin: f32) -> Self {
|
||||
self.top = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_bottom(mut self, margin: f32) -> Self {
|
||||
self.bottom = margin;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn top(&self) -> f32 {
|
||||
self.top
|
||||
}
|
||||
|
||||
pub fn left(&self) -> f32 {
|
||||
self.left
|
||||
}
|
||||
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.bottom
|
||||
}
|
||||
|
||||
pub fn right(&self) -> f32 {
|
||||
self.right
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Padding {
|
||||
top: f32,
|
||||
left: f32,
|
||||
bottom: f32,
|
||||
right: f32,
|
||||
}
|
||||
|
||||
impl Padding {
|
||||
pub const fn uniform(padding: f32) -> Self {
|
||||
Self {
|
||||
top: padding,
|
||||
left: padding,
|
||||
bottom: padding,
|
||||
right: padding,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_top(mut self, padding: f32) -> Self {
|
||||
self.top = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_left(mut self, padding: f32) -> Self {
|
||||
self.left = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_bottom(mut self, padding: f32) -> Self {
|
||||
self.bottom = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_right(mut self, padding: f32) -> Self {
|
||||
self.right = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_vertical(mut self, vertical: f32) -> Self {
|
||||
self.top = vertical;
|
||||
self.bottom = vertical;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_horizontal(mut self, horizontal: f32) -> Self {
|
||||
self.left = horizontal;
|
||||
self.right = horizontal;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn top(&self) -> f32 {
|
||||
self.top
|
||||
}
|
||||
|
||||
pub fn left(&self) -> f32 {
|
||||
self.left
|
||||
}
|
||||
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.bottom
|
||||
}
|
||||
|
||||
pub fn right(&self) -> f32 {
|
||||
self.right
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Overdraw {
|
||||
top: f32,
|
||||
left: f32,
|
||||
bottom: f32,
|
||||
right: f32,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub const fn new(width: f32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
color: Fill::None,
|
||||
top: false,
|
||||
left: false,
|
||||
bottom: false,
|
||||
right: false,
|
||||
dash: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all(width: f32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
color: Fill::None,
|
||||
top: true,
|
||||
left: true,
|
||||
bottom: true,
|
||||
right: true,
|
||||
dash: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top(width: f32) -> Self {
|
||||
let mut border = Self::new(width);
|
||||
border.top = true;
|
||||
border
|
||||
}
|
||||
|
||||
pub fn left(width: f32) -> Self {
|
||||
let mut border = Self::new(width);
|
||||
border.left = true;
|
||||
border
|
||||
}
|
||||
|
||||
pub fn bottom(width: f32) -> Self {
|
||||
let mut border = Self::new(width);
|
||||
border.bottom = true;
|
||||
border
|
||||
}
|
||||
|
||||
pub fn right(width: f32) -> Self {
|
||||
let mut border = Self::new(width);
|
||||
border.right = true;
|
||||
border
|
||||
}
|
||||
|
||||
pub fn with_sides(mut self, top: bool, left: bool, bottom: bool, right: bool) -> Self {
|
||||
self.top = top;
|
||||
self.left = left;
|
||||
self.bottom = bottom;
|
||||
self.right = right;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_border_fill<F>(mut self, fill: F) -> Self
|
||||
where
|
||||
F: Into<Fill>,
|
||||
{
|
||||
self.color = fill.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_border_color(mut self, color: ColorU) -> Self {
|
||||
self.color = Fill::Solid(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_horizontal_border_gradient(mut self, gradient: Gradient) -> Self {
|
||||
self.color = Fill::Gradient {
|
||||
start: vec2f(0.0, 0.0),
|
||||
end: vec2f(1.0, 0.0),
|
||||
start_color: gradient.start,
|
||||
end_color: gradient.end,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_border_gradient(
|
||||
mut self,
|
||||
start: Vector2F,
|
||||
end: Vector2F,
|
||||
gradient: Gradient,
|
||||
) -> Self {
|
||||
self.color = Fill::Gradient {
|
||||
start,
|
||||
end,
|
||||
start_color: gradient.start,
|
||||
end_color: gradient.end,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// Note: only implemented for sharp corners. ***DO NOT*** use for elements with corner radius != 0, as this causes visual bugs.
|
||||
pub fn with_dashed_border(mut self, dash: Dash) -> Self {
|
||||
self.dash = Some(dash);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ColorU> for Border {
|
||||
fn from(value: ColorU) -> Self {
|
||||
Border::all(1.).with_border_color(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
pub fn start(&self) -> Vector2F {
|
||||
match self {
|
||||
Self::Gradient { start, .. } => *start,
|
||||
_ => vec2f(0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(&self) -> Vector2F {
|
||||
match self {
|
||||
Self::Gradient { end, .. } => *end,
|
||||
_ => vec2f(1.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_color(&self) -> ColorU {
|
||||
match self {
|
||||
Self::Gradient { start_color, .. } => *start_color,
|
||||
Self::Solid(color) => *color,
|
||||
Self::None => ColorU::transparent_black(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end_color(&self) -> ColorU {
|
||||
match self {
|
||||
Self::Gradient { end_color, .. } => *end_color,
|
||||
Self::Solid(color) => *color,
|
||||
Self::None => ColorU::transparent_black(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extends the `Vector2F` API to provider richer APIs for
|
||||
/// element-related computations.
|
||||
pub trait Vector2FExt {
|
||||
/// Converts the 2D vector to a scalar according to the given `axis`.
|
||||
fn along(self, axis: Axis) -> f32;
|
||||
|
||||
/// Projects the 2D vector onto the given `axis`.
|
||||
/// e.g. (5, 2) -> (5, 0), along the x-axis.
|
||||
fn project_onto(self, axis: Axis) -> Vector2F;
|
||||
|
||||
/// [`fmt::Display`] impl to format this `Vector2F` as a point.
|
||||
fn display_point(self) -> Vector2FDisplayPoint;
|
||||
|
||||
/// [`fmt::Display`] impl to format this `Vector2F` as a size.
|
||||
fn display_size(self) -> Vector2FDisplaySize;
|
||||
}
|
||||
|
||||
impl Vector2FExt for Vector2F {
|
||||
fn along(self, axis: Axis) -> f32 {
|
||||
match axis {
|
||||
Axis::Horizontal => self.x(),
|
||||
Axis::Vertical => self.y(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_onto(self, axis: Axis) -> Vector2F {
|
||||
match axis {
|
||||
Axis::Horizontal => vec2f(self.x(), 0.),
|
||||
Axis::Vertical => vec2f(0., self.y()),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_point(self) -> Vector2FDisplayPoint {
|
||||
Vector2FDisplayPoint(self)
|
||||
}
|
||||
|
||||
fn display_size(self) -> Vector2FDisplaySize {
|
||||
Vector2FDisplaySize(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Vector2FDisplaySize(Vector2F);
|
||||
|
||||
impl fmt::Display for Vector2FDisplaySize {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// We have to call .fmt directly so that formatting options are propagated.
|
||||
self.0.x().fmt(f)?;
|
||||
f.write_str("x")?;
|
||||
self.0.y().fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Vector2FDisplayPoint(Vector2F);
|
||||
|
||||
impl fmt::Display for Vector2FDisplayPoint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// We have to call .fmt directly so that formatting options are propagated.
|
||||
f.write_str("(")?;
|
||||
self.0.x().fmt(f)?;
|
||||
f.write_str(", ")?;
|
||||
self.0.y().fmt(f)?;
|
||||
f.write_str(")")
|
||||
}
|
||||
}
|
||||
|
||||
/// Extends the `f32` API to provider richer APIs for
|
||||
/// element-related computations.
|
||||
pub trait F32Ext {
|
||||
/// Converts the `f32` to a 2D vector along the provided `axis`.
|
||||
fn along(self, axis: Axis) -> Vector2F;
|
||||
}
|
||||
|
||||
impl F32Ext for f32 {
|
||||
fn along(self, axis: Axis) -> Vector2F {
|
||||
match axis {
|
||||
Axis::Horizontal => vec2f(self, 0.),
|
||||
Axis::Vertical => vec2f(0., self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extends the `RectF` API to provider richer APIs for
|
||||
/// element-related computations.
|
||||
pub trait RectFExt {
|
||||
/// Returns the minimum value along the given `axis`.
|
||||
fn min_along(self, axis: Axis) -> f32;
|
||||
|
||||
/// Returns the maximum value along the given `axis`.
|
||||
fn max_along(self, axis: Axis) -> f32;
|
||||
}
|
||||
|
||||
impl RectFExt for RectF {
|
||||
fn min_along(self, axis: Axis) -> f32 {
|
||||
match axis {
|
||||
Axis::Horizontal => self.min_x(),
|
||||
Axis::Vertical => self.min_y(),
|
||||
}
|
||||
}
|
||||
|
||||
fn max_along(self, axis: Axis) -> f32 {
|
||||
match axis {
|
||||
Axis::Horizontal => self.max_x(),
|
||||
Axis::Vertical => self.max_y(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_rect(origin: Option<Vector2F>, size: Option<Vector2F>) -> Option<RectF> {
|
||||
origin.and_then(|origin| size.map(|size| RectF::new(origin, size)))
|
||||
}
|
||||
|
||||
pub fn try_rect_with_z(origin: Option<Point>, size: Option<Vector2F>) -> Option<RectF> {
|
||||
origin.and_then(|origin| size.map(|size| RectF::new(origin.xy(), size)))
|
||||
}
|
||||
|
||||
/// The click handler provides the caller with the clicked text chunk index in
|
||||
/// the provided clickable char ranges and the string corresponds to that chunk,
|
||||
/// if one of the clickable chunks were clicked
|
||||
pub type ClickHandler = Box<dyn FnMut(&ModifiersState, &mut EventContext, &AppContext)>;
|
||||
/// The hover handler is called when the mouse either hovers or unhovers over a
|
||||
/// hoverable char range, with the first argument being is_hovering.
|
||||
pub type HoverHandler = Box<dyn FnMut(bool, &mut EventContext, &AppContext)>;
|
||||
|
||||
pub(crate) struct ClickableCharRange {
|
||||
pub(crate) char_range: Range<usize>,
|
||||
pub(crate) click_handler: ClickHandler,
|
||||
}
|
||||
|
||||
pub(crate) struct HoverableCharRange {
|
||||
pub(crate) char_range: Range<usize>,
|
||||
pub(crate) hover_handler: HoverHandler,
|
||||
pub(crate) cursor_on_hover: Option<Cursor>,
|
||||
pub(crate) mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl HoverableCharRange {
|
||||
fn mouse_state(&self) -> MutexGuard<'_, MouseState> {
|
||||
self.mouse_state
|
||||
.lock()
|
||||
.expect("The hoverable range should lock mouse state")
|
||||
}
|
||||
}
|
||||
|
||||
/// SecretRange is used to store both the char range and byte range of a secret.
|
||||
/// We need to do this since several APIs e.g. hover/click APIs, use char ranges,
|
||||
/// whereas text-related APIs e.g. Regex and replace_range, use byte ranges.
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone)]
|
||||
pub struct SecretRange {
|
||||
pub char_range: Range<usize>,
|
||||
pub byte_range: Range<usize>,
|
||||
}
|
||||
|
||||
impl SecretRange {
|
||||
/// Extends the current range to include the provided range.
|
||||
pub fn extend_range_end(&mut self, other: &SecretRange) {
|
||||
self.char_range.end = self.char_range.end.max(other.char_range.end);
|
||||
self.byte_range.end = self.byte_range.end.max(other.byte_range.end);
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PartialClickableElement {
|
||||
/// clickable_char_ranges is the vector of char ranges that the caller can
|
||||
/// specify, where the callback will be called if any character in one of
|
||||
/// those char ranges was clicked
|
||||
fn with_clickable_char_range<F>(
|
||||
self,
|
||||
_clickable_char_range: Range<usize>,
|
||||
_callback: F,
|
||||
) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&ModifiersState, &mut EventContext, &AppContext);
|
||||
|
||||
/// Registers a callback that is called when a character in the given hoverable_char_range
|
||||
/// is hovered or unhovered.
|
||||
fn with_hoverable_char_range<F>(
|
||||
self,
|
||||
hoverable_char_range: Range<usize>,
|
||||
mouse_state: MouseStateHandle,
|
||||
cursor_on_hover: Option<Cursor>,
|
||||
callback: F,
|
||||
) -> Self
|
||||
where
|
||||
F: 'static + FnMut(bool, &mut EventContext, &AppContext);
|
||||
|
||||
/// Replace in the given range of the text with the replacement text.
|
||||
fn replace_text_range(&mut self, range: SecretRange, replacement: Cow<'static, str>);
|
||||
}
|
||||
|
||||
/// An element that can be selected, for use with the SelectableArea element.
|
||||
/// It is expected that an element implementing this trait (i.e. Text)
|
||||
/// also implements as_selectable_element().
|
||||
pub trait SelectableElement {
|
||||
/// Return the element's selected fragments.
|
||||
fn get_selection(
|
||||
&self,
|
||||
_selection_start: Vector2F,
|
||||
_selection_end: Vector2F,
|
||||
_is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>>;
|
||||
|
||||
/// Semantically expands the absolute selection point based on the unit.
|
||||
/// Does nothing if the unit is Char because there is no need to expand.
|
||||
/// Expands to the start of the unit if expand_to_start is true, otherwise
|
||||
/// expands to the end of the unit.
|
||||
/// If the absolute point before the element's bounds and expand_to_start is true,
|
||||
/// should expand to the start of the element. Similarly, if the absolute point is after
|
||||
/// the element's bounds and expand_to_start is false, should expand to the end of the element.
|
||||
/// Otherwise, should return None.
|
||||
fn expand_selection(
|
||||
&self,
|
||||
_absolute_point: Vector2F,
|
||||
_direction: SelectionDirection,
|
||||
_unit: SelectionType,
|
||||
_word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F>;
|
||||
|
||||
/// Returns None if neither point is in the element.
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
_absolute_point: Vector2F,
|
||||
_absolute_point_other: Vector2F,
|
||||
) -> Option<bool>;
|
||||
|
||||
/// Runs smart selection on a point.
|
||||
/// Should return None if the point is outside the element vertically,
|
||||
/// but should snap to the nearest line if out of bounds horizontally.
|
||||
fn smart_select(
|
||||
&self,
|
||||
_absolute_point: Vector2F,
|
||||
_smart_select_fn: SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)>;
|
||||
|
||||
/// The union of the returned regions defines the area within which a mouse click is considered
|
||||
/// to be a click performed on the element's selection. Should return an empty vector for
|
||||
/// elements that don't define any selection-specific click behaviors.
|
||||
fn calculate_clickable_bounds(&self, _current_selection: Option<Selection>) -> Vec<RectF>;
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
elements::{
|
||||
new_scrollable::{util::child_constraint_for_axis, ScrollableAxis},
|
||||
Axis, ClippedScrollStateHandle, ScrollData, ScrollStateHandle, SelectableElement,
|
||||
Vector2FExt,
|
||||
},
|
||||
event::DispatchedEvent,
|
||||
units::{IntoPixels, Pixels},
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
use super::{
|
||||
util::{scroll_clipped_scrollable_handle_with_delta, scroll_delta_for_axis},
|
||||
NewScrollableElement, SingleAxisConfig,
|
||||
};
|
||||
|
||||
use crate::elements::ScrollTarget;
|
||||
|
||||
/// Holds different scroll state handle type that depends on
|
||||
/// whether the caller wants automatic or manual scrolling.
|
||||
///
|
||||
/// This config is used for dual axis scrolling.
|
||||
pub enum AxisConfiguration {
|
||||
/// The child element is responsible for managing the scroll state manually.
|
||||
/// This means it has to 1) report scroll position to the scrollable at every
|
||||
/// frame. 2) expose API to allow scrollable to scroll to a certain position.
|
||||
Manual(ScrollStateHandle),
|
||||
/// The scrolling behavior is managed automatically by the scrollable. Note that
|
||||
/// this has worse performance than the manual variation since we need to layout the
|
||||
/// child with infinite bounds and clip to the visible viewport.
|
||||
Clipped(ClippedAxisConfiguration),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClippedAxisConfiguration {
|
||||
pub handle: ClippedScrollStateHandle,
|
||||
/// An optional max size the child should be laid out with in this axis.
|
||||
pub max_size: Option<f32>,
|
||||
/// Equivalent of [`crate::elements::CrossAxisAlignment::Stretch`].
|
||||
pub stretch_child: bool,
|
||||
}
|
||||
|
||||
impl AxisConfiguration {
|
||||
/// Scroll data with the given axis' configuration. If it's clipped, we will read it from the scroll state handle.
|
||||
/// Otherwise, read it from the child element.
|
||||
fn scroll_data(
|
||||
&self,
|
||||
viewport_size: Vector2F,
|
||||
child: &dyn NewScrollableElement,
|
||||
axis: Axis,
|
||||
app: &AppContext,
|
||||
) -> ScrollData {
|
||||
match self {
|
||||
Self::Manual(_) => child
|
||||
.scroll_data(axis, app)
|
||||
.expect("Axis is set to manual scrolling. Child should implement this axis"),
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => {
|
||||
handle.scroll_data(viewport_size, child.size().expect("Should exist"), axis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the underlying element with the given axis' configuration. If it's clipped, update the scroll state handle.
|
||||
/// Otherwise, call scroll on the child element.
|
||||
fn scroll_to(
|
||||
&self,
|
||||
child: &mut dyn NewScrollableElement,
|
||||
viewport_size: Vector2F,
|
||||
delta: Pixels,
|
||||
axis: Axis,
|
||||
ctx: &mut EventContext,
|
||||
) {
|
||||
match self {
|
||||
Self::Manual(_) => child.scroll(delta, axis, ctx),
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => {
|
||||
scroll_clipped_scrollable_handle_with_delta(
|
||||
handle,
|
||||
child
|
||||
.size()
|
||||
.expect("Size should exist")
|
||||
.along(axis)
|
||||
.into_pixels(),
|
||||
viewport_size.along(axis).into_pixels(),
|
||||
delta,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the start drag position for the scroll state.
|
||||
fn set_start(&self, position: f32) {
|
||||
match self {
|
||||
Self::Manual(handle) => handle.lock().unwrap().started = Some(position),
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => handle.set_start(position),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the start drag position to None for the scroll state.
|
||||
fn reset_start(&self) {
|
||||
match self {
|
||||
Self::Manual(handle) => handle.lock().unwrap().started = None,
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => handle.reset_start(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read out the start drag postion state from scroll handle.
|
||||
fn start(&self) -> Option<f32> {
|
||||
match self {
|
||||
Self::Manual(handle) => handle.lock().unwrap().started,
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => handle.start(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset of the paint origin. If the child is managed manually on this axis, this should
|
||||
/// return 0 (child does viewporting itself). Otherwise, return the current scroll start from
|
||||
/// handle.
|
||||
fn paint_origin_offset(&self) -> f32 {
|
||||
match self {
|
||||
Self::Manual(_) => 0.,
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => {
|
||||
handle.scroll_start().as_f32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the axis' scrollbar is hovered.
|
||||
fn hovered(&self) -> bool {
|
||||
match self {
|
||||
Self::Manual(handle) => handle.lock().unwrap().hovered,
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => handle.hovered(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_hovered(&self, hovered: bool) {
|
||||
match self {
|
||||
Self::Manual(handle) => handle.lock().unwrap().hovered = hovered,
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => handle.set_hovered(hovered),
|
||||
}
|
||||
}
|
||||
|
||||
fn child_hovered(&self) -> bool {
|
||||
match self {
|
||||
Self::Manual(handle) => handle.lock().expect("lock should be held").child_hovered,
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => handle.child_hovered(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_child_hovered(&self, hovered: bool) {
|
||||
match self {
|
||||
Self::Manual(handle) => {
|
||||
handle.lock().expect("lock should be held").child_hovered = hovered
|
||||
}
|
||||
Self::Clipped(ClippedAxisConfiguration { handle, .. }) => {
|
||||
handle.set_child_hovered(hovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the updated size constraint based on the axis configuration.
|
||||
fn size_constraint(
|
||||
&self,
|
||||
axis: Axis,
|
||||
constraint: SizeConstraint,
|
||||
scrollbar_size_with_padding: Vector2F,
|
||||
) -> (f32, f32) {
|
||||
let (mut min_size, mut max_size) = child_constraint_for_axis(
|
||||
axis,
|
||||
constraint,
|
||||
matches!(self, AxisConfiguration::Clipped { .. }),
|
||||
scrollbar_size_with_padding,
|
||||
)
|
||||
.constraint_for_axis(axis);
|
||||
|
||||
if let AxisConfiguration::Clipped(ClippedAxisConfiguration {
|
||||
max_size: Some(max_width),
|
||||
stretch_child,
|
||||
..
|
||||
}) = self
|
||||
{
|
||||
max_size = max_size.min(*max_width);
|
||||
if *stretch_child {
|
||||
min_size = match axis {
|
||||
Axis::Horizontal => constraint.max.x(),
|
||||
Axis::Vertical => constraint.max.y(),
|
||||
}
|
||||
}
|
||||
}
|
||||
(min_size, max_size)
|
||||
}
|
||||
}
|
||||
|
||||
/// For manual scrolling, there could be three different scenarios:
|
||||
/// * Manually managed scrolling on vertical + automatically managed scrolling on horizontal
|
||||
/// * Manually managed scrolling on horizontal + automatically managed scrolling on vertical
|
||||
/// * Manually managed scrolling on vertical and horizontal
|
||||
///
|
||||
/// For automatic scrolling, both axes have to be automatically managed. This relaxes
|
||||
/// the child element requirement to be Element instead of ScrollableElement.
|
||||
pub enum DualAxisConfig {
|
||||
/// The child element is responsible for managing the scroll state manually.
|
||||
/// This means it has to 1) report scroll position to the scrollable at every
|
||||
/// frame. 2) expose API to allow scrollable to scroll to a certain position.
|
||||
Manual {
|
||||
horizontal: AxisConfiguration,
|
||||
vertical: AxisConfiguration,
|
||||
child: Box<dyn NewScrollableElement>,
|
||||
},
|
||||
/// The scrolling behavior is managed automatically by the scrollable. Note that
|
||||
/// this has worse performance than the manual variation since we need to layout the
|
||||
/// child with infinite bounds and clip to the visible viewport.
|
||||
Clipped {
|
||||
horizontal: ClippedAxisConfiguration,
|
||||
vertical: ClippedAxisConfiguration,
|
||||
child: Box<dyn Element>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DualAxisConfig {
|
||||
/// At run-time, validate if the passed-in axis config is valid.
|
||||
pub(super) fn validate(&self) {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let DualAxisConfig::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} = self
|
||||
{
|
||||
if matches!(horizontal, AxisConfiguration::Clipped { .. })
|
||||
&& matches!(vertical, AxisConfiguration::Clipped { .. })
|
||||
{
|
||||
panic!(
|
||||
"Tried to render a Manual scrollable with Clipped scrolling on both axes. Consider using DualAxisConfig::Clipped instead."
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(horizontal, AxisConfiguration::Manual(_))
|
||||
&& matches!(child.axis(), ScrollableAxis::Vertical)
|
||||
{
|
||||
panic!(
|
||||
"Set horizontal scrolling to be manual when the child element could only be scrolled on vertical axis"
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(vertical, AxisConfiguration::Manual(_))
|
||||
&& matches!(child.axis(), ScrollableAxis::Horizontal)
|
||||
{
|
||||
panic!(
|
||||
"Set vertical scrolling to be manual when the child element could only be scrolled on horizontal axis"
|
||||
);
|
||||
}
|
||||
}
|
||||
log::trace!("Validated axes constructor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Return ScrollData for the given axis.
|
||||
pub(super) fn scroll_data(
|
||||
&self,
|
||||
viewport_size: Vector2F,
|
||||
axis: Axis,
|
||||
app: &AppContext,
|
||||
) -> ScrollData {
|
||||
match &self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} => match axis {
|
||||
Axis::Horizontal => {
|
||||
horizontal.scroll_data(viewport_size, child.as_ref(), Axis::Horizontal, app)
|
||||
}
|
||||
Axis::Vertical => {
|
||||
vertical.scroll_data(viewport_size, child.as_ref(), Axis::Vertical, app)
|
||||
}
|
||||
},
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.handle.scroll_data(
|
||||
viewport_size,
|
||||
child.size().expect("Should exist"),
|
||||
axis,
|
||||
),
|
||||
Axis::Vertical => vertical.handle.scroll_data(
|
||||
viewport_size,
|
||||
child.size().expect("Should exist"),
|
||||
axis,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout the child element in the dual axis case and return the final scrollable size.
|
||||
pub(super) fn layout_child(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
scrollbar_size_with_padding: Vector2F,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let (horizontal_min, horizontal_max) = match &self {
|
||||
// Take just the constraint for the horizontal axis.
|
||||
Self::Manual { horizontal, .. } => horizontal.size_constraint(
|
||||
Axis::Horizontal,
|
||||
constraint,
|
||||
scrollbar_size_with_padding,
|
||||
),
|
||||
// If clipped, this should be just 0 to infinity.
|
||||
Self::Clipped {
|
||||
horizontal:
|
||||
ClippedAxisConfiguration {
|
||||
stretch_child,
|
||||
max_size,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => {
|
||||
let max_size = max_size.unwrap_or(f32::INFINITY);
|
||||
(
|
||||
if *stretch_child {
|
||||
max_size.min(constraint.max.x()) - scrollbar_size_with_padding.x()
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
max_size,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let (vertical_min, vertical_max) = match &self {
|
||||
// Take just the constraint for the vertical axis.
|
||||
Self::Manual { vertical, .. } => {
|
||||
vertical.size_constraint(Axis::Vertical, constraint, scrollbar_size_with_padding)
|
||||
}
|
||||
// If clipped, this should be just 0 to infinity.
|
||||
Self::Clipped {
|
||||
vertical:
|
||||
ClippedAxisConfiguration {
|
||||
stretch_child,
|
||||
max_size,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => {
|
||||
let max_size = max_size.unwrap_or(f32::INFINITY);
|
||||
(
|
||||
if *stretch_child {
|
||||
max_size.min(constraint.max.y()) - scrollbar_size_with_padding.y()
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
max_size,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let child_constraint = SizeConstraint {
|
||||
min: vec2f(horizontal_min, vertical_min),
|
||||
max: vec2f(horizontal_max, vertical_max),
|
||||
};
|
||||
|
||||
let child_size = match self {
|
||||
Self::Manual { child, .. } => child.layout(child_constraint, ctx, app),
|
||||
Self::Clipped {
|
||||
child,
|
||||
horizontal,
|
||||
vertical,
|
||||
} => {
|
||||
let child_size = child.layout(child_constraint, ctx, app);
|
||||
// Reset scroll position if child becomes smaller than current scroll position
|
||||
// OR if viewport becomes larger than child size
|
||||
if child_size.x() < horizontal.handle.scroll_start().as_f32()
|
||||
|| constraint.max.x() >= child_size.x()
|
||||
{
|
||||
horizontal.handle.scroll_to(Pixels::zero());
|
||||
} else {
|
||||
// If viewport is still smaller than child but would cause unnecessary clipping,
|
||||
// adjust scroll position to show rightmost content
|
||||
let max_scroll = (child_size.x() - constraint.max.x()).max(0.0);
|
||||
if horizontal.handle.scroll_start().as_f32() > max_scroll {
|
||||
horizontal.handle.scroll_to(max_scroll.into_pixels());
|
||||
}
|
||||
}
|
||||
|
||||
if child_size.y() < vertical.handle.scroll_start().as_f32()
|
||||
|| constraint.max.y() >= child_size.y()
|
||||
{
|
||||
vertical.handle.scroll_to(Pixels::zero());
|
||||
} else {
|
||||
let max_scroll = (child_size.y() - constraint.max.y()).max(0.0);
|
||||
if vertical.handle.scroll_start().as_f32() > max_scroll {
|
||||
vertical.handle.scroll_to(max_scroll.into_pixels());
|
||||
}
|
||||
}
|
||||
child_size
|
||||
}
|
||||
};
|
||||
|
||||
debug_assert!(
|
||||
child_size.y().is_finite(),
|
||||
"Scrollable's child should not have infinite height"
|
||||
);
|
||||
debug_assert!(
|
||||
child_size.x().is_finite(),
|
||||
"Scrollable's child should not have infinite width"
|
||||
);
|
||||
|
||||
constraint.apply(child_size + scrollbar_size_with_padding)
|
||||
}
|
||||
|
||||
/// Invoke child's after_layout and return the updated ScrollData.
|
||||
pub(super) fn after_layout(
|
||||
&mut self,
|
||||
viewport_size: Vector2F,
|
||||
ctx: &mut AfterLayoutContext,
|
||||
app: &AppContext,
|
||||
) -> (ScrollData, ScrollData) {
|
||||
match self {
|
||||
Self::Manual { child, .. } => {
|
||||
child.after_layout(ctx, app);
|
||||
}
|
||||
Self::Clipped { child, .. } => {
|
||||
child.after_layout(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
let horizontal = self.scroll_data(viewport_size, Axis::Horizontal, app);
|
||||
let vertical = self.scroll_data(viewport_size, Axis::Vertical, app);
|
||||
(horizontal, vertical)
|
||||
}
|
||||
|
||||
pub(super) fn paint_child(
|
||||
&mut self,
|
||||
origin: Vector2F,
|
||||
size: Vector2F,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
match self {
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} => {
|
||||
let vertical_scroll_target = vertical
|
||||
.handle
|
||||
.clipped_scroll_data
|
||||
.lock()
|
||||
.scroll_to_position
|
||||
.take();
|
||||
let horizontal_scroll_target = horizontal
|
||||
.handle
|
||||
.clipped_scroll_data
|
||||
.lock()
|
||||
.scroll_to_position
|
||||
.take();
|
||||
if let Ok(scroll_to_position) =
|
||||
ScrollToPosition::try_from((horizontal_scroll_target, vertical_scroll_target))
|
||||
{
|
||||
scroll_to_position_and_paint_clipped(
|
||||
child,
|
||||
origin,
|
||||
size,
|
||||
scroll_to_position,
|
||||
&vertical.handle,
|
||||
&horizontal.handle,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
} else {
|
||||
paint_clipped_internal(
|
||||
child,
|
||||
origin,
|
||||
&vertical.handle,
|
||||
&horizontal.handle,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} => {
|
||||
let offset = vec2f(
|
||||
horizontal.paint_origin_offset(),
|
||||
vertical.paint_origin_offset(),
|
||||
);
|
||||
let child_origin = origin - offset;
|
||||
child.paint(child_origin, ctx, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_event_to_child(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.dispatch_event(event, ctx, app),
|
||||
Self::Clipped { child, .. } => child.dispatch_event(event, ctx, app),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_bounds(&self) -> Option<RectF> {
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.bounds(),
|
||||
Self::Clipped { child, .. } => child.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.as_selectable_element(),
|
||||
Self::Clipped { child, .. } => child.as_selectable_element(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_offset(&self) -> Vector2F {
|
||||
match self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => vec2f(
|
||||
horizontal.paint_origin_offset(),
|
||||
vertical.paint_origin_offset(),
|
||||
),
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => vec2f(
|
||||
horizontal.handle.scroll_start().as_f32(),
|
||||
vertical.handle.scroll_start().as_f32(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_hovered(&self) -> bool {
|
||||
match self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => horizontal.child_hovered() || vertical.child_hovered(),
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => horizontal.handle.child_hovered() || vertical.handle.child_hovered(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_child_hovered(&self, hovered: bool) {
|
||||
match self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => {
|
||||
horizontal.set_child_hovered(hovered);
|
||||
vertical.set_child_hovered(hovered);
|
||||
}
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => {
|
||||
horizontal.handle.set_child_hovered(hovered);
|
||||
vertical.handle.set_child_hovered(hovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given axis scrollbar is hovered.
|
||||
pub(super) fn hovered(&self, axis: Axis) -> bool {
|
||||
match self {
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.handle.hovered(),
|
||||
Axis::Vertical => vertical.handle.hovered(),
|
||||
},
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.hovered(),
|
||||
Axis::Vertical => vertical.hovered(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_hovered(&self, axis: Axis, hovered: bool) {
|
||||
match self {
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.handle.set_hovered(hovered),
|
||||
Axis::Vertical => vertical.handle.set_hovered(hovered),
|
||||
},
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.set_hovered(hovered),
|
||||
Axis::Vertical => vertical.set_hovered(hovered),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_drag_start(&self, position: Vector2F, axis: Axis) {
|
||||
match self {
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.handle.set_start(position.along(axis)),
|
||||
Axis::Vertical => vertical.handle.set_start(position.along(axis)),
|
||||
},
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.set_start(position.along(axis)),
|
||||
Axis::Vertical => vertical.set_start(position.along(axis)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current drag start position. We also return the axis here to distinguish between
|
||||
/// the two scrollbars.
|
||||
pub(super) fn drag_start(&self) -> Option<(f32, Axis)> {
|
||||
match self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => horizontal
|
||||
.start()
|
||||
.map(|start| (start, Axis::Horizontal))
|
||||
.or_else(|| vertical.start().map(|start| (start, Axis::Vertical))),
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => horizontal
|
||||
.handle
|
||||
.start()
|
||||
.map(|start| (start, Axis::Horizontal))
|
||||
.or_else(|| vertical.handle.start().map(|start| (start, Axis::Vertical))),
|
||||
}
|
||||
}
|
||||
|
||||
/// End the current drag session.
|
||||
pub(super) fn end_drag(&self, axis: Axis) {
|
||||
match self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.reset_start(),
|
||||
Axis::Vertical => vertical.reset_start(),
|
||||
},
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
..
|
||||
} => match axis {
|
||||
Axis::Horizontal => horizontal.handle.reset_start(),
|
||||
Axis::Vertical => vertical.handle.reset_start(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll child on the given axis with delta.
|
||||
pub(super) fn scroll_to(
|
||||
&mut self,
|
||||
viewport_size: Vector2F,
|
||||
delta: Pixels,
|
||||
axis: Axis,
|
||||
ctx: &mut EventContext,
|
||||
) {
|
||||
// Early return if scroll delta is below sensitivity threshold.
|
||||
if delta.as_f32().abs() < f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Manual {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} => match axis {
|
||||
Axis::Horizontal => {
|
||||
horizontal.scroll_to(child.as_mut(), viewport_size, delta, axis, ctx)
|
||||
}
|
||||
Axis::Vertical => {
|
||||
vertical.scroll_to(child.as_mut(), viewport_size, delta, axis, ctx)
|
||||
}
|
||||
},
|
||||
Self::Clipped {
|
||||
horizontal,
|
||||
vertical,
|
||||
child,
|
||||
} => {
|
||||
let child_size = child.size().expect("Size should exist");
|
||||
let axis_config = match axis {
|
||||
Axis::Horizontal => horizontal,
|
||||
Axis::Vertical => vertical,
|
||||
};
|
||||
scroll_clipped_scrollable_handle_with_delta(
|
||||
&axis_config.handle,
|
||||
child_size.along(axis).into_pixels(),
|
||||
viewport_size.along(axis).into_pixels(),
|
||||
delta,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate whether given the current scroll state, would the scroll delta have any effect on the scrollable.
|
||||
/// We can then use this to filter whether to handle a scroll wheel event or not.
|
||||
pub(super) fn can_scroll_delta(
|
||||
&self,
|
||||
viewport_size: Vector2F,
|
||||
delta: Vector2F,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let horizontal_data = self.scroll_data(viewport_size, Axis::Horizontal, app);
|
||||
let vertical_data = self.scroll_data(viewport_size, Axis::Vertical, app);
|
||||
|
||||
SingleAxisConfig::can_scroll_delta_dimension(&horizontal_data, delta.x())
|
||||
|| SingleAxisConfig::can_scroll_delta_dimension(&vertical_data, delta.y())
|
||||
}
|
||||
|
||||
pub(super) fn should_handle_scroll_wheel(&self, axis: Axis) -> bool {
|
||||
match self {
|
||||
// If the scrolling is managed automatically, assume we should handle scroll wheel.
|
||||
Self::Clipped { .. } => true,
|
||||
Self::Manual { child, .. } => child.axis_should_handle_scroll_wheel(axis),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains position ID(s) on either horizontal, vertical, or both axes.
|
||||
///
|
||||
/// This enum is similar to representing each axis with an Option<String> except that it prevents
|
||||
/// (None, None) from being a possible state.
|
||||
enum ScrollToPosition {
|
||||
Dual {
|
||||
horizontal: ScrollTarget,
|
||||
vertical: ScrollTarget,
|
||||
},
|
||||
Horizontal(ScrollTarget),
|
||||
Vertical(ScrollTarget),
|
||||
}
|
||||
|
||||
impl TryFrom<(Option<ScrollTarget>, Option<ScrollTarget>)> for ScrollToPosition {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: (Option<ScrollTarget>, Option<ScrollTarget>)) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
(None, None) => Err(()),
|
||||
(None, Some(target)) => Ok(Self::Vertical(target)),
|
||||
(Some(target), None) => Ok(Self::Horizontal(target)),
|
||||
(Some(horizontal), Some(vertical)) => Ok(Self::Dual {
|
||||
horizontal,
|
||||
vertical,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_clipped_internal(
|
||||
child: &mut Box<dyn Element>,
|
||||
origin: Vector2F,
|
||||
vertical: &ClippedScrollStateHandle,
|
||||
horizontal: &ClippedScrollStateHandle,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
// If the child is clipped on an axis, the offset there is just the scroll_start
|
||||
// of the scroll handle.
|
||||
let offset = vec2f(
|
||||
horizontal.scroll_start().as_f32(),
|
||||
vertical.scroll_start().as_f32(),
|
||||
);
|
||||
let child_origin = origin - offset;
|
||||
|
||||
// It's possible that children elements of this ClippedScrollabe are not a part
|
||||
// of a stack and therefore won't have their position's flushed to the position cache.
|
||||
// The start() and end() calls here ensure that the positions are saved so we can scroll
|
||||
// to the position of a child.
|
||||
ctx.position_cache.start();
|
||||
child.paint(child_origin, ctx, app);
|
||||
ctx.position_cache.end();
|
||||
}
|
||||
|
||||
/// Scrolls the provided `position_id` into view, if it exists, and paints the object.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scroll_to_position_and_paint_clipped(
|
||||
child: &mut Box<dyn Element>,
|
||||
origin: Vector2F,
|
||||
size: Vector2F,
|
||||
scroll_to_position: ScrollToPosition,
|
||||
vertical: &ClippedScrollStateHandle,
|
||||
horizontal: &ClippedScrollStateHandle,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
// The relevant position can be a child of the `ClippedScrollable` so we need to first paint the
|
||||
// `ClippedScrollable` before we can determine the position, scroll the position into view, and
|
||||
// paint the element as intended. In order to prevent the first paint from having side effects,
|
||||
// we clone the scene before we invoke the first paint.
|
||||
//
|
||||
// Cloning the scene is cheap! On a bundled app, the following operations take < 10 microseconds:
|
||||
// - 100 warp tabs open
|
||||
// - Set line height to 0.2 and fill the block list and make a large number of glyphs
|
||||
// - Expanded all folders in warp drive and opened command palette (to check non-view ported elements)
|
||||
// - Render many images (as it turns out the scene only holds a rect and Arc, not the image content itself)
|
||||
// We want to avoid excesively cloning the scene though, because calling clone on the scene on multiple
|
||||
// `ClippedScrollable` elements in the paint code path caused this latency to be an order of magnitude
|
||||
// higher (300 microseconds).
|
||||
let cached_scene = ctx.scene.clone();
|
||||
paint_clipped_internal(child, origin, vertical, horizontal, ctx, app);
|
||||
|
||||
let child_bounds = child.bounds().expect("bounds on child should be set");
|
||||
let viewport_bounds = RectF::new(origin, size);
|
||||
|
||||
if let ScrollToPosition::Horizontal(ref target)
|
||||
| ScrollToPosition::Dual {
|
||||
horizontal: ref target,
|
||||
..
|
||||
} = scroll_to_position
|
||||
{
|
||||
if let Some(position_bounds) = ctx.position_cache.get_position(&target.position_id) {
|
||||
// It doesn't make sense to scroll to a position that is unrelated to the
|
||||
// `ClippedScrollable` so no-op if it is not within the bounds of the child element.
|
||||
if child_bounds.intersects(position_bounds) {
|
||||
let horizontal_delta = scroll_delta_for_axis(
|
||||
Axis::Horizontal,
|
||||
viewport_bounds,
|
||||
position_bounds,
|
||||
target.mode,
|
||||
);
|
||||
horizontal.scroll_to(horizontal.scroll_start() + horizontal_delta.into_pixels());
|
||||
} else {
|
||||
log::warn!(
|
||||
"bounds of position ID {}, {position_bounds:?}, are not contained \
|
||||
in scrollable child bounds, {child_bounds:?}",
|
||||
target.position_id,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::warn!("Position cache does not contain id: {}", target.position_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let ScrollToPosition::Vertical(ref target)
|
||||
| ScrollToPosition::Dual {
|
||||
vertical: ref target,
|
||||
..
|
||||
} = scroll_to_position
|
||||
{
|
||||
if let Some(position_bounds) = ctx.position_cache.get_position(&target.position_id) {
|
||||
// It doesn't make sense to scroll to a position that is unrelated to the
|
||||
// `ClippedScrollable` so no-op if it is not within the bounds of the child element.
|
||||
if child_bounds.intersects(position_bounds) {
|
||||
let vertical_delta = scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
viewport_bounds,
|
||||
position_bounds,
|
||||
target.mode,
|
||||
);
|
||||
vertical.scroll_to(vertical.scroll_start() + vertical_delta.into_pixels());
|
||||
} else {
|
||||
log::warn!(
|
||||
"bounds of position ID {}, {position_bounds:?}, are not contained \
|
||||
in scrollable child bounds, {child_bounds:?}",
|
||||
target.position_id,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::warn!("Position cache does not contain id: {}", target.position_id);
|
||||
}
|
||||
}
|
||||
|
||||
*ctx.scene = cached_scene;
|
||||
paint_clipped_internal(child, origin, vertical, horizontal, ctx, app);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,425 @@
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
use crate::{
|
||||
elements::{
|
||||
new_scrollable::util::child_constraint_for_axis, Axis, ClippedScrollStateHandle, F32Ext,
|
||||
ScrollData, ScrollStateHandle, SelectableElement, Vector2FExt,
|
||||
},
|
||||
event::DispatchedEvent,
|
||||
units::{IntoPixels, Pixels},
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
use super::{
|
||||
util::{scroll_clipped_scrollable_handle_with_delta, scroll_delta_for_axis},
|
||||
NewScrollableElement, ScrollableAxis,
|
||||
};
|
||||
|
||||
use crate::elements::{ScrollTarget, ScrollToPositionMode};
|
||||
|
||||
/// Holds state that depends on whether the scrolling axis should
|
||||
/// be managed automatically by the scrollable (clipped) or it should
|
||||
/// be managed manually by the child.
|
||||
///
|
||||
/// This config should only be used for single axis scrolling.
|
||||
pub enum SingleAxisConfig {
|
||||
/// The child element is responsible for managing the scroll state manually.
|
||||
/// This means it has to 1) report scroll position to the scrollable at every
|
||||
/// frame. 2) expose API to allow scrollable to scroll to a certain position.
|
||||
Manual {
|
||||
handle: ScrollStateHandle,
|
||||
child: Box<dyn NewScrollableElement>,
|
||||
},
|
||||
/// The scrolling behavior is managed automatically by the scrollable. Note that
|
||||
/// this has worse performance than the manual variation since we need to layout the
|
||||
/// child with infinite bounds and clip to the visible viewport.
|
||||
Clipped {
|
||||
handle: ClippedScrollStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SingleAxisConfig {
|
||||
/// At run-time, validate if the passed-in axis config is valid.
|
||||
pub(super) fn validate(&self, axis: Axis) {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let SingleAxisConfig::Manual { child, .. } = self {
|
||||
if matches!(axis, Axis::Horizontal)
|
||||
&& matches!(child.axis(), ScrollableAxis::Vertical)
|
||||
{
|
||||
panic!(
|
||||
"Set horizontal scrolling to be manual when the child element could only be scrolled on vertical axis"
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(axis, Axis::Vertical)
|
||||
&& matches!(child.axis(), ScrollableAxis::Horizontal)
|
||||
{
|
||||
panic!(
|
||||
"Set vertical scrolling to be manual when the child element could only be scrolled on horizontal axis"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout the child element in the single axis case and return the final scrollable size.
|
||||
pub(super) fn layout_child(
|
||||
&mut self,
|
||||
axis: Axis,
|
||||
constraint: SizeConstraint,
|
||||
scrollbar_size_with_padding: Vector2F,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let child_constraint = child_constraint_for_axis(
|
||||
axis,
|
||||
constraint,
|
||||
matches!(self, Self::Clipped { .. }),
|
||||
scrollbar_size_with_padding,
|
||||
);
|
||||
|
||||
let child_size = match self {
|
||||
Self::Manual { child, .. } => child.layout(child_constraint, ctx, app),
|
||||
Self::Clipped { child, handle } => {
|
||||
let child_size = child.layout(child_constraint, ctx, app);
|
||||
let axis_size = match axis {
|
||||
Axis::Horizontal => child_size.x(),
|
||||
Axis::Vertical => child_size.y(),
|
||||
};
|
||||
let viewport_size = match axis {
|
||||
Axis::Horizontal => constraint.max.x(),
|
||||
Axis::Vertical => constraint.max.y(),
|
||||
};
|
||||
|
||||
if axis_size < handle.scroll_start().as_f32() || viewport_size >= axis_size {
|
||||
handle.scroll_to(Pixels::zero());
|
||||
} else {
|
||||
// If viewport is still smaller than child but would cause unnecessary clipping,
|
||||
// adjust scroll position to show rightmost/bottommost content
|
||||
let max_scroll = (axis_size - viewport_size).max(0.0);
|
||||
if handle.scroll_start().as_f32() > max_scroll {
|
||||
handle.scroll_to(max_scroll.into_pixels());
|
||||
}
|
||||
}
|
||||
child_size
|
||||
}
|
||||
};
|
||||
|
||||
debug_assert!(
|
||||
child_size.y().is_finite(),
|
||||
"Scrollable's child should not have infinite height"
|
||||
);
|
||||
debug_assert!(
|
||||
child_size.x().is_finite(),
|
||||
"Scrollable's child should not have infinite width"
|
||||
);
|
||||
|
||||
constraint.apply(child_size + scrollbar_size_with_padding)
|
||||
}
|
||||
|
||||
/// Invoke child's after_layout and return the updated ScrollData.
|
||||
pub(super) fn after_layout(
|
||||
&mut self,
|
||||
axis: Axis,
|
||||
viewport_size: Vector2F,
|
||||
ctx: &mut AfterLayoutContext,
|
||||
app: &AppContext,
|
||||
) -> ScrollData {
|
||||
match self {
|
||||
Self::Manual { child, .. } => {
|
||||
child.after_layout(ctx, app);
|
||||
child
|
||||
.scroll_data(axis, app)
|
||||
.expect("Child should have size at after layout")
|
||||
}
|
||||
Self::Clipped { child, handle } => {
|
||||
child.after_layout(ctx, app);
|
||||
handle.scroll_data(
|
||||
viewport_size,
|
||||
child
|
||||
.size()
|
||||
.expect("Child should have size at after layout"),
|
||||
axis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn paint_child(
|
||||
&mut self,
|
||||
axis: Axis,
|
||||
origin: Vector2F,
|
||||
size: Vector2F,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
match self {
|
||||
Self::Clipped { handle, child } => {
|
||||
let scroll_target = handle.clipped_scroll_data.lock().scroll_to_position.take();
|
||||
if let Some(ScrollTarget { position_id, mode }) = scroll_target {
|
||||
scroll_to_position_and_paint_clipped(
|
||||
child,
|
||||
axis,
|
||||
origin,
|
||||
size,
|
||||
position_id,
|
||||
mode,
|
||||
handle,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
} else {
|
||||
paint_clipped_internal(child, axis, origin, handle, ctx, app);
|
||||
}
|
||||
}
|
||||
Self::Manual { child, .. } => {
|
||||
child.paint(origin, ctx, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_bounds(&self) -> Option<RectF> {
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.bounds(),
|
||||
Self::Clipped { child, .. } => child.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.as_selectable_element(),
|
||||
Self::Clipped { child, .. } => child.as_selectable_element(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_offset(&self, axis: Axis) -> Vector2F {
|
||||
match self {
|
||||
Self::Manual { .. } => Vector2F::zero(),
|
||||
Self::Clipped { handle, .. } => handle.scroll_start().as_f32().along(axis),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_hovered(&self) -> bool {
|
||||
match self {
|
||||
Self::Manual { handle, .. } => {
|
||||
handle.lock().expect("lock should be held").child_hovered
|
||||
}
|
||||
Self::Clipped { handle, .. } => handle.child_hovered(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_child_hovered(&self, hovered: bool) {
|
||||
match self {
|
||||
Self::Manual { handle, .. } => {
|
||||
handle.lock().expect("lock should be held").child_hovered = hovered
|
||||
}
|
||||
Self::Clipped { handle, .. } => handle.set_child_hovered(hovered),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hovered(&self) -> bool {
|
||||
match self {
|
||||
Self::Clipped { handle, .. } => handle.hovered(),
|
||||
Self::Manual { handle, .. } => handle.lock().unwrap().hovered,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_hovered(&self, hovered: bool) {
|
||||
match self {
|
||||
Self::Clipped { handle, .. } => handle.set_hovered(hovered),
|
||||
Self::Manual { handle, .. } => handle.lock().unwrap().hovered = hovered,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_event_to_child(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.dispatch_event(event, ctx, app),
|
||||
Self::Clipped { child, .. } => child.dispatch_event(event, ctx, app),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_drag_start(&self, position: Vector2F, axis: Axis) {
|
||||
match self {
|
||||
Self::Clipped { handle, .. } => handle.set_start(position.along(axis)),
|
||||
Self::Manual { handle, .. } => {
|
||||
handle.lock().unwrap().started = Some(position.along(axis))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn drag_start(&self) -> Option<f32> {
|
||||
match self {
|
||||
Self::Clipped { handle, .. } => handle.start(),
|
||||
Self::Manual { handle, .. } => handle.lock().unwrap().started,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn end_drag(&self) {
|
||||
match self {
|
||||
Self::Clipped { handle, .. } => handle.reset_start(),
|
||||
Self::Manual { handle, .. } => handle.lock().unwrap().started = None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_data(
|
||||
&self,
|
||||
axis: Axis,
|
||||
viewport_size: Vector2F,
|
||||
app: &AppContext,
|
||||
) -> ScrollData {
|
||||
match self {
|
||||
Self::Clipped { handle, child } => handle.scroll_data(
|
||||
viewport_size,
|
||||
child.size().expect("Size should exist"),
|
||||
axis,
|
||||
),
|
||||
Self::Manual { child, .. } => child
|
||||
.scroll_data(axis, app)
|
||||
.expect("Axis is set to manual scrolling. Child should implement this axis"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll child on the given axis with delta.
|
||||
pub(super) fn scroll_to(
|
||||
&mut self,
|
||||
viewport_size: Vector2F,
|
||||
delta: Pixels,
|
||||
axis: Axis,
|
||||
ctx: &mut EventContext,
|
||||
) {
|
||||
// Early return if scroll delta is below sensitivity threshold.
|
||||
if delta.as_f32().abs() < f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Manual { child, .. } => child.scroll(delta, axis, ctx),
|
||||
Self::Clipped { handle, child } => {
|
||||
let child_size = child.size().expect("Size should exist");
|
||||
scroll_clipped_scrollable_handle_with_delta(
|
||||
handle,
|
||||
child_size.along(axis).into_pixels(),
|
||||
viewport_size.along(axis).into_pixels(),
|
||||
delta,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_handle_scroll_wheel(&self, axis: Axis) -> bool {
|
||||
match self {
|
||||
// If the scrolling is managed automatically, assume we should handle scroll wheel.
|
||||
Self::Clipped { .. } => true,
|
||||
Self::Manual { child, .. } => child.axis_should_handle_scroll_wheel(axis),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate whether given the current scroll state, would the scroll delta have any effect on the scrollable.
|
||||
/// We can then use this to filter whether to handle a scroll wheel event or not.
|
||||
pub(super) fn can_scroll_delta(
|
||||
&self,
|
||||
axis: Axis,
|
||||
viewport_size: Vector2F,
|
||||
delta: Vector2F,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let scroll_data = self.scroll_data(axis, viewport_size, app);
|
||||
let delta = delta.along(axis);
|
||||
|
||||
Self::can_scroll_delta_dimension(&scroll_data, delta)
|
||||
}
|
||||
|
||||
/// Calculate whether given the current scroll state, would the scroll delta have any effect on the scrollable in a single dimension.
|
||||
pub(super) fn can_scroll_delta_dimension(scroll_data: &ScrollData, delta: f32) -> bool {
|
||||
// If the scroll delta is 0, there is no effect.
|
||||
// If the scrollable is at the start of travel, and the delta is positive, there is no effect.
|
||||
// If the scrollable is at the end of travel, and the delta is negative, there is no effect.
|
||||
if (delta == 0.0)
|
||||
|| (delta > 0.0 && scroll_data.scroll_start <= 0.0.into_pixels())
|
||||
|| (delta < 0.0
|
||||
&& scroll_data.scroll_start + scroll_data.visible_px >= scroll_data.total_size)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_clipped_internal(
|
||||
child: &mut Box<dyn Element>,
|
||||
axis: Axis,
|
||||
origin: Vector2F,
|
||||
scroll_state: &ClippedScrollStateHandle,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let offset = scroll_state.scroll_start().as_f32().along(axis);
|
||||
let child_origin = origin - offset;
|
||||
|
||||
// It's possible that children elements of this ClippedScrollable are not a part
|
||||
// of a stack and therefore won't have their position's flushed to the position cache.
|
||||
// The start() and end() calls here ensure that the positions are saved so we can scroll
|
||||
// to the position of a child.
|
||||
ctx.position_cache.start();
|
||||
child.paint(child_origin, ctx, app);
|
||||
ctx.position_cache.end();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scroll_to_position_and_paint_clipped(
|
||||
child: &mut Box<dyn Element>,
|
||||
axis: Axis,
|
||||
origin: Vector2F,
|
||||
size: Vector2F,
|
||||
position_id: String,
|
||||
mode: ScrollToPositionMode,
|
||||
scroll_state: &ClippedScrollStateHandle,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
// The relevant position can be a child of the `ClippedScrollable` so we need to first paint the
|
||||
// `ClippedScrollable` before we can determine the position, scroll the position into view, and
|
||||
// paint the element as intended. In order to prevent the first paint from having side effects,
|
||||
// we clone the scene before we invoke the first paint.
|
||||
//
|
||||
// Cloning the scene is cheap! On a bundled app, the following operations take < 10 microseconds:
|
||||
// - 100 warp tabs open
|
||||
// - Set line height to 0.2 and fill the block list and make a large number of glyphs
|
||||
// - Expanded all folders in warp drive and opened command palette (to check non-view ported elements)
|
||||
// - Render many images (as it turns out the scene only holds a rect and Arc, not the image content itself)
|
||||
// We want to avoid excesively cloning the scene though, because calling clone on the scene on multiple
|
||||
// `ClippedScrollable` elements in the paint code path caused this latency to be an order of magnitude
|
||||
// higher (300 microseconds).
|
||||
let cached_scene = ctx.scene.clone();
|
||||
paint_clipped_internal(child, axis, origin, scroll_state, ctx, app);
|
||||
|
||||
if let Some(position_bounds) = ctx.position_cache.get_position(&position_id) {
|
||||
let child_bounds = child.bounds().expect("bounds on child should be set");
|
||||
// It doesn't make sense to scroll to a position that is unrelated to the
|
||||
// `ClippedScrollable` so no-op if it is not within the bounds of the child element.
|
||||
if child_bounds.intersects(position_bounds) {
|
||||
let viewport_bounds = RectF::new(origin, size);
|
||||
let delta = scroll_delta_for_axis(axis, viewport_bounds, position_bounds, mode);
|
||||
scroll_state.scroll_to(scroll_state.scroll_start() + delta.into_pixels());
|
||||
} else {
|
||||
log::warn!(
|
||||
"bounds of position ID {position_id}, {position_bounds:?}, are not contained \
|
||||
in scrollable child bounds, {child_bounds:?}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::warn!("Position cache does not contain id: {position_id}");
|
||||
}
|
||||
|
||||
*ctx.scene = cached_scene;
|
||||
paint_clipped_internal(child, axis, origin, scroll_state, ctx, app);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
elements::{
|
||||
project_scroll_delta_by_sensitivity, Axis, ClippedScrollStateHandle, RectFExt as _,
|
||||
ScrollToPositionMode,
|
||||
},
|
||||
units::Pixels,
|
||||
EventContext, SizeConstraint,
|
||||
};
|
||||
|
||||
/// Calculate the child size constraint for a given axis.
|
||||
/// For a clipped element, lay it out unbounded on the main axis but apply constraint on the cross axis.
|
||||
/// For a manual element, lay it out bounded with the incoming size constraint. Note that we need to
|
||||
/// subtract the total scrollbar offset to take into account the spacing it takes in the viewport.
|
||||
pub(super) fn child_constraint_for_axis(
|
||||
axis: Axis,
|
||||
constraint: SizeConstraint,
|
||||
is_clipped: bool,
|
||||
scrollbar_size_with_padding: Vector2F,
|
||||
) -> SizeConstraint {
|
||||
let incoming_constraint = if is_clipped {
|
||||
match axis {
|
||||
Axis::Horizontal => SizeConstraint {
|
||||
min: vec2f(0.0, constraint.min.y()),
|
||||
max: vec2f(f32::INFINITY, constraint.max.y()),
|
||||
},
|
||||
Axis::Vertical => SizeConstraint {
|
||||
min: vec2f(constraint.min.x(), 0.),
|
||||
max: vec2f(constraint.max.x(), f32::INFINITY),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
constraint
|
||||
};
|
||||
|
||||
SizeConstraint {
|
||||
min: (incoming_constraint.min - scrollbar_size_with_padding).max(Vector2F::zero()),
|
||||
max: (incoming_constraint.max - scrollbar_size_with_padding).max(Vector2F::zero()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the ClippedScrollStateHandle to match scrolling with the given delta.
|
||||
pub(super) fn scroll_clipped_scrollable_handle_with_delta(
|
||||
handle: &ClippedScrollStateHandle,
|
||||
child_size: Pixels,
|
||||
viewport_size: Pixels,
|
||||
delta: Pixels,
|
||||
ctx: &mut EventContext,
|
||||
) {
|
||||
let scroll_start = handle.scroll_start();
|
||||
|
||||
if child_size > viewport_size {
|
||||
// The max scroll start here is the total child size - viewport size.
|
||||
// ==================
|
||||
// | |
|
||||
// | |
|
||||
// | max_scroll_top |
|
||||
// | |
|
||||
// | |
|
||||
// ==================
|
||||
// | viewport |
|
||||
// ==================
|
||||
let new_scroll_start = (scroll_start - delta)
|
||||
.max(Pixels::zero())
|
||||
.min(child_size - viewport_size);
|
||||
|
||||
// If the scroll start positions have changed, scroll and re-render.
|
||||
if (scroll_start - new_scroll_start).as_f32().abs() > f32::EPSILON {
|
||||
handle.scroll_to(new_scroll_start);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust scroll delta based on the set sensitivity level:
|
||||
/// - If horizontal delta * sensitivity > vertical delta, set vertical delta to zero.
|
||||
/// - If vertical delta * sensitivity > horizontal delta, set horizontal delta to zero.
|
||||
pub(super) fn adjust_scroll_delta_with_sensitivity_config(
|
||||
delta: Vector2F,
|
||||
sensitivity: f32,
|
||||
) -> Vector2F {
|
||||
project_scroll_delta_by_sensitivity(delta, sensitivity)
|
||||
}
|
||||
|
||||
// Viewport
|
||||
// ┌──────┴───────┐
|
||||
// ┌─────┲━━━━━━━━━━━━━━┱────────┐ ┐
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ ┌──┐ │ │
|
||||
// │ ┃ ┃ │**│ │ ├─Viewport
|
||||
// │ ┃ ┃ └──┘ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┗━━━━━━━━━━━━━━┛ │ ┘
|
||||
// │ │
|
||||
// │ │
|
||||
// │ │
|
||||
// └─────────────────────────────┘
|
||||
// Viewport
|
||||
// ┌──────┴───────┐
|
||||
// delta
|
||||
// ┌──┴──┐
|
||||
// ┌───────────┲━━━━━━━━━━━━━━┱──┐ ┐
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┌──┨ │ │
|
||||
// │ ┃ │**┃ │ ├─Viewport
|
||||
// │ ┃ └──┨ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┃ ┃ │ │
|
||||
// │ ┗━━━━━━━━━━━━━━┛ │ ┘
|
||||
// │ │
|
||||
// │ │
|
||||
// │ │
|
||||
// └─────────────────────────────┘
|
||||
/// Calculate the scroll delta (in pixels) needed to bring the element delimited by
|
||||
/// `position_bounds` into view within `viewport_bounds` on the given axis.
|
||||
///
|
||||
/// The behaviour depends on `mode`:
|
||||
/// - [`ScrollToPositionMode::FullyIntoView`]: scrolls the minimum amount to make the
|
||||
/// entire element visible. When the element is larger than the viewport, no scroll
|
||||
/// is performed.
|
||||
/// - [`ScrollToPositionMode::TopIntoView`]: behaves like `FullyIntoView` when the
|
||||
/// element fits in the viewport. When the element is larger, aligns the element's
|
||||
/// leading edge with the viewport's leading edge.
|
||||
pub(crate) fn scroll_delta_for_axis(
|
||||
axis: Axis,
|
||||
viewport_bounds: RectF,
|
||||
position_bounds: RectF,
|
||||
mode: ScrollToPositionMode,
|
||||
) -> f32 {
|
||||
let viewport_max_along_axis = viewport_bounds.max_along(axis);
|
||||
let viewport_min_along_axis = viewport_bounds.min_along(axis);
|
||||
let max_position_along_axis = position_bounds.max_along(axis);
|
||||
let min_position_along_axis = position_bounds.min_along(axis);
|
||||
|
||||
let viewport_size = viewport_max_along_axis - viewport_min_along_axis;
|
||||
let element_size = max_position_along_axis - min_position_along_axis;
|
||||
|
||||
if element_size > viewport_size {
|
||||
match mode {
|
||||
ScrollToPositionMode::FullyIntoView => 0.0,
|
||||
ScrollToPositionMode::TopIntoView => min_position_along_axis - viewport_min_along_axis,
|
||||
}
|
||||
} else if max_position_along_axis > viewport_max_along_axis {
|
||||
max_position_along_axis - viewport_max_along_axis
|
||||
} else if min_position_along_axis < viewport_min_along_axis {
|
||||
min_position_along_axis - viewport_min_along_axis
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scroll_delta_for_axis_fully_into_view() {
|
||||
let mode = ScrollToPositionMode::FullyIntoView;
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Horizontal,
|
||||
RectF::new(vec2f(100., 0.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(400., 50.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
100.
|
||||
);
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Horizontal,
|
||||
RectF::new(vec2f(200., 0.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(100., 50.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
-100.
|
||||
);
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Horizontal,
|
||||
RectF::new(vec2f(100., 0.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(325., 50.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
25.
|
||||
);
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Horizontal,
|
||||
RectF::new(vec2f(100., 0.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(150., 50.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
0.
|
||||
);
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Horizontal,
|
||||
RectF::new(vec2f(100., 0.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 50.), vec2f(350., 50.)),
|
||||
mode,
|
||||
),
|
||||
0.
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_delta_for_axis_top_into_view() {
|
||||
let mode = ScrollToPositionMode::TopIntoView;
|
||||
|
||||
// --- Element LARGER than the viewport ---
|
||||
|
||||
// Element taller than viewport, below viewport: align top with
|
||||
// viewport top.
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 400.), vec2f(50., 300.)),
|
||||
mode,
|
||||
),
|
||||
300.
|
||||
);
|
||||
|
||||
// Element taller than viewport, above viewport: align top with
|
||||
// viewport top.
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 200.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 100.), vec2f(50., 300.)),
|
||||
mode,
|
||||
),
|
||||
-100.
|
||||
);
|
||||
|
||||
// Element taller than viewport, top at viewport top: align top
|
||||
// (delta = 0).
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 100.), vec2f(50., 300.)),
|
||||
mode,
|
||||
),
|
||||
0.
|
||||
);
|
||||
|
||||
// Element taller than viewport, top visible but bottom extends
|
||||
// past: align top with viewport top (shows max content from top).
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 200.), vec2f(50., 300.)),
|
||||
mode,
|
||||
),
|
||||
100.
|
||||
);
|
||||
|
||||
// Element taller than viewport, spans entire viewport (top above,
|
||||
// bottom below): align top with viewport top.
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 50.), vec2f(50., 400.)),
|
||||
mode,
|
||||
),
|
||||
-50.
|
||||
);
|
||||
|
||||
// --- Element FITS in the viewport (delegates to FullyIntoView) ---
|
||||
|
||||
// Small element below viewport: scroll down (bottom to viewport
|
||||
// bottom).
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 400.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
100.
|
||||
);
|
||||
|
||||
// Small element above viewport: scroll up (top to viewport top).
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 200.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 100.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
-100.
|
||||
);
|
||||
|
||||
// Small element fully visible: no scroll.
|
||||
assert_eq!(
|
||||
scroll_delta_for_axis(
|
||||
Axis::Vertical,
|
||||
RectF::new(vec2f(0., 100.), vec2f(250., 250.)),
|
||||
RectF::new(vec2f(50., 150.), vec2f(50., 50.)),
|
||||
mode,
|
||||
),
|
||||
0.
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
/// An element that constrains its child to a percentage of the available size, either width or height.
|
||||
pub struct Percentage {
|
||||
width_percentage: Option<f32>,
|
||||
height_percentage: Option<f32>,
|
||||
child: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl Percentage {
|
||||
/// Constrain width of child to a percentage of the available max width.
|
||||
pub fn width(percentage: f32, child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
width_percentage: Some(percentage),
|
||||
height_percentage: None,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrain height of child to a percentage of the available max height.
|
||||
pub fn height(percentage: f32, child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
width_percentage: None,
|
||||
height_percentage: Some(percentage),
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrain both width and height of child to a percentage of the available max width and height.
|
||||
pub fn both(width_percentage: f32, height_percentage: f32, child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
width_percentage: Some(width_percentage),
|
||||
height_percentage: Some(height_percentage),
|
||||
child,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Percentage {
|
||||
fn layout(
|
||||
&mut self,
|
||||
mut constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
if let Some(width_percentage) = self.width_percentage {
|
||||
let width_percentage = width_percentage.clamp(0.0, 1.0);
|
||||
constraint.max.set_x(constraint.max.x() * width_percentage);
|
||||
}
|
||||
if let Some(height_percentage) = self.height_percentage {
|
||||
let height_percentage = height_percentage.clamp(0.0, 1.0);
|
||||
constraint.max.set_y(constraint.max.y() * height_percentage);
|
||||
}
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &crate::event::DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, Fill, LayoutContext, PaintContext,
|
||||
Point, SizeConstraint,
|
||||
};
|
||||
use crate::event::DispatchedEvent;
|
||||
pub use crate::scene::Border;
|
||||
pub use crate::scene::{CornerRadius, DropShadow};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
pub struct Rect {
|
||||
background: Fill,
|
||||
drop_shadow: Option<DropShadow>,
|
||||
border: Border,
|
||||
corner_radius: CornerRadius,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
#[cfg(debug_assertions)]
|
||||
/// Custom panic location, set with [`Rect::set_location_for_panic_logging`]
|
||||
constructor_location: Option<&'static std::panic::Location<'static>>,
|
||||
}
|
||||
|
||||
impl Default for Rect {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
drop_shadow: None,
|
||||
background: Fill::None,
|
||||
border: Border::default(),
|
||||
corner_radius: CornerRadius::default(),
|
||||
size: None,
|
||||
origin: None,
|
||||
#[cfg(debug_assertions)]
|
||||
constructor_location: Some(std::panic::Location::caller()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_corner_radius(mut self, radius: CornerRadius) -> Self {
|
||||
self.corner_radius = radius;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background<F>(mut self, fill: F) -> Self
|
||||
where
|
||||
F: Into<Fill>,
|
||||
{
|
||||
self.background = fill.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background_color(mut self, color: ColorU) -> Self {
|
||||
self.background = Fill::Solid(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_drop_shadow(mut self, drop_shadow: DropShadow) -> Self {
|
||||
self.drop_shadow = Some(drop_shadow);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_horizontal_background_gradient(
|
||||
mut self,
|
||||
start_color: ColorU,
|
||||
end_color: ColorU,
|
||||
) -> Self {
|
||||
self.background = Fill::Gradient {
|
||||
start: vec2f(0.0, 0.0),
|
||||
end: vec2f(1.0, 0.0),
|
||||
start_color,
|
||||
end_color,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background_gradient(
|
||||
mut self,
|
||||
start: Vector2F,
|
||||
end: Vector2F,
|
||||
start_color: ColorU,
|
||||
end_color: ColorU,
|
||||
) -> Self {
|
||||
self.background = Fill::Gradient {
|
||||
start,
|
||||
end,
|
||||
start_color,
|
||||
end_color,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_border(mut self, border: Border) -> Self {
|
||||
self.border = border;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Rect {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
_ctx: &mut LayoutContext,
|
||||
_app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let size = constraint.max;
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
let size = self.size.unwrap();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
ctx.scene
|
||||
.set_location_for_panic_logging(self.constructor_location);
|
||||
|
||||
let rect = ctx
|
||||
.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(origin, size))
|
||||
.with_background(self.background)
|
||||
.with_border(self.border)
|
||||
.with_corner_radius(self.corner_radius);
|
||||
if let Some(drop_shadow) = self.drop_shadow {
|
||||
rect.with_drop_shadow(drop_shadow);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_event: &DispatchedEvent,
|
||||
_ctx: &mut EventContext,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, track_caller)]
|
||||
fn finish(mut self) -> Box<dyn Element>
|
||||
where
|
||||
Self: 'static + Sized,
|
||||
{
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
self.constructor_location = Some(std::panic::Location::caller());
|
||||
}
|
||||
Box::new(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
use std::{
|
||||
mem,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
event::DispatchedEvent, platform::Cursor, AfterLayoutContext, AppContext, Element,
|
||||
EventContext, PaintContext, SizeConstraint,
|
||||
};
|
||||
|
||||
use super::{Fill, Point, ZIndex};
|
||||
|
||||
const DRAGBAR_WIDTH: f32 = 5.0;
|
||||
|
||||
/// A UI element with internal resizing ability.
|
||||
///
|
||||
/// This element takes a ResizableStateHandle to receive a starting size and
|
||||
/// manage dimensions as the element is resized.
|
||||
///
|
||||
/// Supports both horizontal and vertical resizing via the ResizeDirection.
|
||||
///
|
||||
/// TODO:
|
||||
/// - Take a configurable dragbar size instead of always using 5.0
|
||||
pub struct Resizable {
|
||||
child: Box<dyn Element>,
|
||||
origin: Option<Vector2F>,
|
||||
dragbar: Dragbar,
|
||||
state_handle: ResizableStateHandle,
|
||||
size: Option<Vector2F>,
|
||||
bounds_callback: Option<BoundsCallback>,
|
||||
resize_handler: Option<Handler>,
|
||||
start_resize_handler: Option<Handler>,
|
||||
end_resize_handler: Option<Handler>,
|
||||
hovering_dragbar: bool,
|
||||
direction: ResizeDirection,
|
||||
origin_delta: Vector2F,
|
||||
dragbar_offset: f32,
|
||||
}
|
||||
|
||||
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext)>;
|
||||
pub type BoundsCallback = Box<dyn FnMut(Vector2F) -> (f32, f32)>;
|
||||
|
||||
/// Similar to MouseStateHandle, the view that incorporates the owner can instantiate,
|
||||
/// read, and set the state.
|
||||
pub type ResizableStateHandle = Arc<Mutex<ResizableState>>;
|
||||
pub fn resizable_state_handle(size: f32) -> ResizableStateHandle {
|
||||
Arc::new(Mutex::new(ResizableState::new(size)))
|
||||
}
|
||||
|
||||
pub struct ResizableState {
|
||||
size: f32,
|
||||
bounds: Option<(f32, f32)>,
|
||||
mode: ResizableMode,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum ResizableMode {
|
||||
Dragging {
|
||||
last_position: Vector2F,
|
||||
},
|
||||
#[default]
|
||||
Stationary,
|
||||
}
|
||||
|
||||
impl ResizableState {
|
||||
pub fn new(size: f32) -> Self {
|
||||
Self {
|
||||
size,
|
||||
bounds: None,
|
||||
mode: Default::default(),
|
||||
}
|
||||
}
|
||||
pub fn size(&self) -> f32 {
|
||||
self.size
|
||||
}
|
||||
|
||||
pub fn clamp_size(&mut self) {
|
||||
if let Some((min, max)) = self.bounds {
|
||||
self.size = self.size.clamp(min, max);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_for_resize(
|
||||
&mut self,
|
||||
position: Vector2F,
|
||||
origin: Option<Vector2F>,
|
||||
dragbar_side: DragBarSide,
|
||||
) -> Option<Vector2F> {
|
||||
if let ResizableMode::Dragging { last_position } = self.mode {
|
||||
self.resize(last_position, position, origin, dragbar_side)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_resizing(&self) -> bool {
|
||||
matches!(self.mode, ResizableMode::Dragging { .. })
|
||||
}
|
||||
|
||||
fn resize(
|
||||
&mut self,
|
||||
old_position: Vector2F,
|
||||
new_position: Vector2F,
|
||||
origin: Option<Vector2F>,
|
||||
dragbar_side: DragBarSide,
|
||||
) -> Option<Vector2F> {
|
||||
let mut resized = false;
|
||||
|
||||
if let Some(origin) = origin {
|
||||
let delta = match dragbar_side {
|
||||
DragBarSide::Right => new_position.x() - old_position.x(),
|
||||
DragBarSide::Left => old_position.x() - new_position.x(),
|
||||
DragBarSide::Bottom => new_position.y() - old_position.y(),
|
||||
DragBarSide::Top => old_position.y() - new_position.y(),
|
||||
};
|
||||
|
||||
let old_size = self.size;
|
||||
if delta.abs() >= f32::EPSILON {
|
||||
resized = true;
|
||||
self.size += delta;
|
||||
self.clamp_size();
|
||||
}
|
||||
let size = self.size;
|
||||
|
||||
// The last position should reflect the latest position of the dragbar.
|
||||
let last_position = match dragbar_side {
|
||||
// With a right-side dragbar, the latest position of the dragbar will
|
||||
// be the old origin of the element plus the new width/height.
|
||||
DragBarSide::Right => origin + vec2f(size, 0.),
|
||||
// With a left-side dragbar, the latest position of the dragbar will
|
||||
// be the old origin of the element minus the bounded delta of the drag.
|
||||
DragBarSide::Left => origin - vec2f(size - old_size, 0.),
|
||||
// With a bottom-side dragbar, the latest position of the dragbar will
|
||||
// be the old origin of the element plus the new height.
|
||||
DragBarSide::Bottom => origin + vec2f(0., size),
|
||||
// With a top-side dragbar, the latest position of the dragbar will
|
||||
// be the old origin of the element minus the bounded delta of the drag.
|
||||
DragBarSide::Top => origin - vec2f(0., size - old_size),
|
||||
};
|
||||
|
||||
let origin_delta = match dragbar_side {
|
||||
DragBarSide::Right => Vector2F::zero(),
|
||||
DragBarSide::Left => vec2f(old_size - size, 0.),
|
||||
DragBarSide::Bottom => Vector2F::zero(),
|
||||
DragBarSide::Top => vec2f(0., old_size - size),
|
||||
};
|
||||
|
||||
self.mode = ResizableMode::Dragging { last_position };
|
||||
|
||||
if resized {
|
||||
Some(origin_delta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_resizing(&mut self, position: Vector2F) {
|
||||
self.mode = ResizableMode::Dragging {
|
||||
last_position: position,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn end_resizing(&mut self) {
|
||||
self.mode = ResizableMode::Stationary;
|
||||
}
|
||||
|
||||
pub fn set_size(&mut self, new_size: f32) {
|
||||
self.size = new_size;
|
||||
}
|
||||
}
|
||||
|
||||
struct Dragbar {
|
||||
bounds: Option<RectF>,
|
||||
origin: Option<Point>,
|
||||
size: Option<Vector2F>,
|
||||
z_index: Option<ZIndex>,
|
||||
color: Fill,
|
||||
side: DragBarSide,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
pub enum DragBarSide {
|
||||
Left,
|
||||
#[default]
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
pub enum ResizeDirection {
|
||||
#[default]
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl Dragbar {
|
||||
pub fn new() -> Self {
|
||||
let color = Fill::Solid(ColorU::transparent_black());
|
||||
Self {
|
||||
bounds: None,
|
||||
origin: None,
|
||||
size: None,
|
||||
z_index: None,
|
||||
color,
|
||||
side: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Resizable {
|
||||
pub fn new(state_handle: ResizableStateHandle, child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
child,
|
||||
origin: None,
|
||||
state_handle,
|
||||
size: None,
|
||||
bounds_callback: None,
|
||||
resize_handler: None,
|
||||
start_resize_handler: None,
|
||||
end_resize_handler: None,
|
||||
dragbar: Dragbar::new(),
|
||||
hovering_dragbar: false,
|
||||
direction: ResizeDirection::Horizontal,
|
||||
origin_delta: Vector2F::zero(),
|
||||
dragbar_offset: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a callback which will be called on a resize.
|
||||
/// Generally, this should trigger a re-render in the parent.
|
||||
pub fn on_resize<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext) + 'static,
|
||||
{
|
||||
self.resize_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_start_resizing<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext) + 'static,
|
||||
{
|
||||
self.start_resize_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_end_resizing<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: FnMut(&mut EventContext, &AppContext) + 'static,
|
||||
{
|
||||
self.end_resize_handler = Some(Box::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a function that computes the (min, max) bounds on the width/height
|
||||
/// of the resizable. The bounds are updated at paint time.
|
||||
pub fn with_bounds_callback(mut self, callback: BoundsCallback) -> Self {
|
||||
self.bounds_callback = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_dragbar_color(mut self, color: Fill) -> Self {
|
||||
self.dragbar.color = color;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_dragbar_side(mut self, side: DragBarSide) -> Self {
|
||||
self.dragbar.side = side;
|
||||
// Automatically set direction based on side
|
||||
self.direction = match side {
|
||||
DragBarSide::Left | DragBarSide::Right => ResizeDirection::Horizontal,
|
||||
DragBarSide::Top | DragBarSide::Bottom => ResizeDirection::Vertical,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets an offset for the dragbar position.
|
||||
/// Positive values move the dragbar outwards (away from the center of the element).
|
||||
/// Negative values move the dragbar inwards (towards the center of the element).
|
||||
pub fn with_dragbar_offset(mut self, offset: f32) -> Self {
|
||||
self.dragbar_offset = offset;
|
||||
self
|
||||
}
|
||||
|
||||
fn state(&mut self) -> MutexGuard<'_, ResizableState> {
|
||||
self.state_handle
|
||||
.lock()
|
||||
.expect("Resizable state should be accessible")
|
||||
}
|
||||
|
||||
/// Determine if the mouse is hovering over the dragbar
|
||||
///
|
||||
/// If there is another element above this one at the cursor position, then we treat that as
|
||||
/// outside the element for purposes of MouseState
|
||||
fn is_mouse_hovering_dragbar(&self, ctx: &EventContext, position: Vector2F) -> bool {
|
||||
let Some(dragbar_origin) = self.dragbar.origin else {
|
||||
log::warn!("self.origin was None in `Hoverable::is_mouse_in`");
|
||||
return false;
|
||||
};
|
||||
let Some(dragbar_size) = self.dragbar.size else {
|
||||
log::warn!("self.size() was None in `Hoverable::is_mouse_in`");
|
||||
return false;
|
||||
};
|
||||
let Some(z_index) = self.dragbar.z_index else {
|
||||
log::warn!("self.child_max_z_index was None in `Hoverable::is_mouse_in`");
|
||||
return false;
|
||||
};
|
||||
|
||||
let is_hovering = ctx
|
||||
.visible_rect(dragbar_origin, dragbar_size)
|
||||
.is_some_and(|bound| bound.contains_point(position));
|
||||
|
||||
let point = Point::from_vec2f(position, z_index);
|
||||
let is_covered = ctx.is_covered(point);
|
||||
|
||||
is_hovering && !is_covered
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Resizable {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: crate::SizeConstraint,
|
||||
ctx: &mut crate::LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
// Use the window size to set bounds on the width/height
|
||||
if let Some(bounds_callback) = self.bounds_callback.as_mut() {
|
||||
let mut new_bounds = bounds_callback(ctx.window_size);
|
||||
if new_bounds.0 > new_bounds.1 {
|
||||
log::error!("Resizable: min bound is greater than max bound");
|
||||
new_bounds = (new_bounds.0, new_bounds.0);
|
||||
}
|
||||
self.state().bounds = Some(new_bounds);
|
||||
|
||||
// With new bounds, we should also clamp the current width/height.
|
||||
self.state().clamp_size();
|
||||
}
|
||||
|
||||
let size = self.state().size;
|
||||
|
||||
// We set the child constraints to never be greater than the current width/height constraint.
|
||||
let child_constraint = match self.direction {
|
||||
ResizeDirection::Horizontal => SizeConstraint {
|
||||
min: (constraint.min)
|
||||
.max(Vector2F::zero())
|
||||
.min(Vector2F::new(size, f32::MAX)),
|
||||
max: (constraint.max)
|
||||
.max(Vector2F::zero())
|
||||
.min(Vector2F::new(size, f32::MAX)),
|
||||
},
|
||||
ResizeDirection::Vertical => SizeConstraint {
|
||||
min: (constraint.min)
|
||||
.max(Vector2F::zero())
|
||||
.min(Vector2F::new(f32::MAX, size)),
|
||||
max: (constraint.max)
|
||||
.max(Vector2F::zero())
|
||||
.min(Vector2F::new(f32::MAX, size)),
|
||||
},
|
||||
};
|
||||
let child_size = self.child.layout(child_constraint, ctx, app);
|
||||
|
||||
let size = child_size;
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.child.paint(origin, ctx, app);
|
||||
|
||||
// Draw the dragbar and record its size and position
|
||||
let child_size = self.child.size().unwrap();
|
||||
let (dragbar_origin, dragbar_size) = match self.dragbar.side {
|
||||
DragBarSide::Left => (
|
||||
origin - vec2f(self.dragbar_offset, 0.),
|
||||
vec2f(DRAGBAR_WIDTH, child_size.y()),
|
||||
),
|
||||
DragBarSide::Right => (
|
||||
origin + vec2f(child_size.x() - DRAGBAR_WIDTH + self.dragbar_offset, 0.),
|
||||
vec2f(DRAGBAR_WIDTH, child_size.y()),
|
||||
),
|
||||
DragBarSide::Top => (
|
||||
origin - vec2f(0., self.dragbar_offset),
|
||||
vec2f(child_size.x(), DRAGBAR_WIDTH),
|
||||
),
|
||||
DragBarSide::Bottom => (
|
||||
origin + vec2f(0., child_size.y() - DRAGBAR_WIDTH + self.dragbar_offset),
|
||||
vec2f(child_size.x(), DRAGBAR_WIDTH),
|
||||
),
|
||||
};
|
||||
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(dragbar_origin, dragbar_size))
|
||||
.with_background(self.dragbar.color);
|
||||
|
||||
self.dragbar.bounds = Some(RectF::new(dragbar_origin, dragbar_size));
|
||||
self.dragbar.origin = Some(Point::from_vec2f(dragbar_origin, ctx.scene.z_index()));
|
||||
self.dragbar.size = Some(dragbar_size);
|
||||
self.dragbar.z_index = Some(ctx.scene.max_active_z_index());
|
||||
|
||||
self.origin = Some(origin);
|
||||
self.origin_delta = Vector2F::zero();
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let child_handled = self.child.dispatch_event(event, ctx, app);
|
||||
|
||||
match event.raw_event() {
|
||||
crate::Event::LeftMouseDown { position, .. } => {
|
||||
// If a mouse-down on the dragbar element occurred, put the view into resizing mode
|
||||
if self
|
||||
.dragbar
|
||||
.bounds
|
||||
.is_some_and(|bounds| bounds.contains_point(*position))
|
||||
{
|
||||
self.state().begin_resizing(*position);
|
||||
dispatch_callback(self.resize_handler.as_mut(), ctx, app);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
crate::Event::LeftMouseUp { .. } => {
|
||||
// If a mouse-up occurs, take the view out of resizing mode
|
||||
if self.state().is_resizing() {
|
||||
ctx.reset_cursor();
|
||||
self.state().end_resizing();
|
||||
dispatch_callback(self.end_resize_handler.as_mut(), ctx, app);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
crate::Event::LeftMouseDragged { position, .. } => {
|
||||
if self.state().is_resizing() {
|
||||
let dragbar_side = self.dragbar.side;
|
||||
let origin = self.origin.map(|origin| origin + self.origin_delta);
|
||||
let resized = self
|
||||
.state()
|
||||
.check_for_resize(*position, origin, dragbar_side);
|
||||
self.origin_delta += resized.unwrap_or_default();
|
||||
if resized.is_some() {
|
||||
dispatch_callback(self.resize_handler.as_mut(), ctx, app)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
crate::Event::MouseMoved { position, .. } => {
|
||||
// A mouse event over the dragbar should set the cursor
|
||||
let Some(z_index) = self.z_index() else {
|
||||
log::warn!("self.z_index() was None in `Resizable`");
|
||||
return false;
|
||||
};
|
||||
let hovering_dragbar = self.is_mouse_hovering_dragbar(ctx, *position);
|
||||
let was_already_hovering =
|
||||
mem::replace(&mut self.hovering_dragbar, hovering_dragbar);
|
||||
|
||||
if hovering_dragbar && !was_already_hovering {
|
||||
let cursor = match self.direction {
|
||||
ResizeDirection::Horizontal => Cursor::ResizeLeftRight,
|
||||
ResizeDirection::Vertical => Cursor::ResizeUpDown,
|
||||
};
|
||||
ctx.set_cursor(cursor, z_index);
|
||||
} else if !hovering_dragbar && was_already_hovering {
|
||||
ctx.reset_cursor();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
child_handled
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_callback(callback: Option<&mut Handler>, ctx: &mut EventContext, app: &AppContext) {
|
||||
if let Some(callback) = callback {
|
||||
callback(ctx, app);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Axis, Element, Event, EventContext, Fill, LayoutContext,
|
||||
PaintContext, Point, SizeConstraint, Vector2FExt, ZIndex,
|
||||
};
|
||||
use crate::elements::F32Ext;
|
||||
use crate::event::ModifiersState;
|
||||
pub use crate::scene::CornerRadius;
|
||||
use crate::units::{IntoPixels, Pixels};
|
||||
use crate::ClipBounds;
|
||||
use crate::{event::DispatchedEvent, scene::Radius};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
use std::mem;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
pub const LEFT_PADDING: f32 = 2.;
|
||||
const RIGHT_PADDING: f32 = 2.;
|
||||
const MINIMUM_HEIGHT: f32 = 20.;
|
||||
|
||||
/// The number of pixels-per-line when dealing with a cocoa scroll event
|
||||
/// that lacks precision (i.e. [`hasPreciseScrollingDeltas`](https://developer.apple.com/documentation/appkit/nsevent/1525758-hasprecisescrollingdeltas?language=objc))
|
||||
/// is false. While some mouse devices provide finer scroll deltas
|
||||
/// (in pixels), other generic devices don't and we thus have to convert the
|
||||
/// provided non-precise scroll deltas (which are in terms of lines) into pixels.
|
||||
///
|
||||
/// While we could use the application line-height to calculate the number of pixels,
|
||||
/// this requires us to couple the scrolling APIs with `Lines`, which doesn't apply
|
||||
/// for horizontal scrolling.
|
||||
///
|
||||
/// We also decided to not use [`CGEventSourceGetPixelsPerLine`](https://developer.apple.com/documentation/coregraphics/1408775-cgeventsourcegetpixelsperline)
|
||||
/// because it defaults to ~10 pixels per line, which makes scrolling feel slow compared to other applications.
|
||||
///
|
||||
/// The value we chose is inspired by the value that Chromium and Flutter use:
|
||||
/// - https://chromium.googlesource.com/chromium/src/+/9306606fbbd1ebf51cfe23ea6bcfa19a1ff43363/ui/events/cocoa/events_mac.mm#158
|
||||
/// - https://github.com/flutter/engine/blob/cc925b0021330759e18960e1ccbd7e55dec3c375/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm#L768-L775.
|
||||
///
|
||||
/// TODO: currently, this constant reflects the value that makes sense for MacOS (cocoa) scroll events.
|
||||
/// Ideally, we should hide this implementation detail at the platform level and have consumers
|
||||
/// solely operate with pixel-based scroll events.
|
||||
const NUM_PIXELS_PER_LINE: Pixels = Pixels::new(40.);
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ScrollState {
|
||||
pub started: Option<f32>,
|
||||
pub hovered: bool,
|
||||
pub child_hovered: bool,
|
||||
}
|
||||
|
||||
pub type ScrollStateHandle = Arc<Mutex<ScrollState>>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ScrollData {
|
||||
/// The number of pixels that the child element has been scrolled from its start.
|
||||
/// For a vertically scrollable element, this is equivalent to
|
||||
/// [`scrollTop`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop).
|
||||
/// For a horizontally scrollable element, this is equivalent to
|
||||
/// [`scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft).
|
||||
pub scroll_start: Pixels,
|
||||
|
||||
/// The number of pixels of the child element that are visible in the currently scrolled region.
|
||||
pub visible_px: Pixels,
|
||||
|
||||
/// The size of the scrollable element's content.
|
||||
/// This is not necessarily the child element's size (e.g. if the child is viewported).
|
||||
/// For a vertically scrollable element, this is equivalent to
|
||||
/// [`scrollHeight`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight).
|
||||
/// For a horizontally scrollable element, this is equivalent to
|
||||
/// [`scrollWidth`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollWidth).
|
||||
pub total_size: Pixels,
|
||||
}
|
||||
|
||||
pub trait ScrollableElement: Element {
|
||||
/// Returns scrolling data that the child computes and that the [`Scrollable`]
|
||||
/// uses to update its internal state. If the child is scrollable
|
||||
/// (i.e. the child has been laid out), this must be [`Some`].
|
||||
fn scroll_data(&self, app: &AppContext) -> Option<ScrollData>;
|
||||
|
||||
/// Scrolls the element by the given `delta` (in pixels).
|
||||
fn scroll(&mut self, delta: Pixels, ctx: &mut EventContext);
|
||||
|
||||
/// By default, scrollable elements are responsible for their own wheel handling.
|
||||
/// Override to return true if you want the parent scrollable to handle the wheel.
|
||||
fn should_handle_scroll_wheel(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn finish_scrollable(self) -> Box<dyn ScrollableElement>
|
||||
where
|
||||
Self: 'static + Sized,
|
||||
{
|
||||
Box::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum inspired by scrollbar-width css property.
|
||||
/// It includes 2 basic sizes.
|
||||
///
|
||||
/// See [mdn](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width).
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use warpui_core::elements::ScrollbarWidth;
|
||||
///
|
||||
/// // Default width of 8.
|
||||
/// let y = ScrollbarWidth::Auto;
|
||||
///
|
||||
/// // Width of 0. to make the scrollbar invisible
|
||||
/// let z = ScrollbarWidth::None;
|
||||
/// ```
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub enum ScrollbarWidth {
|
||||
#[default]
|
||||
Auto,
|
||||
None,
|
||||
Custom(f32),
|
||||
}
|
||||
|
||||
impl ScrollbarWidth {
|
||||
pub const fn as_f32(&self) -> f32 {
|
||||
match *self {
|
||||
ScrollbarWidth::Auto => 8.,
|
||||
ScrollbarWidth::None => 0.,
|
||||
ScrollbarWidth::Custom(width) => width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic element to handle scrolling of an underlying element.
|
||||
/// Delegates to the underlying child element to update child-specific
|
||||
/// scrolling parameters.
|
||||
///
|
||||
/// Supports both vertical and horizontal scrolling via the [`Scrollable::vertical`]
|
||||
/// and [`Scrollable::horizontal`] APIs, respectively.
|
||||
pub struct Scrollable {
|
||||
axis: Axis,
|
||||
child: Box<dyn ScrollableElement>,
|
||||
state: ScrollStateHandle,
|
||||
origin: Option<Point>,
|
||||
|
||||
/// The size of the [`Scrollable`], as determined during layout.
|
||||
scrollable_size: Option<Vector2F>,
|
||||
|
||||
/// The color of the scrollbar thumb when not hovered/active.
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
/// The color of the scrollbar thumb when hovered/active.
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
/// The color of the scrollbar track.
|
||||
scrollbar_track_background: Fill,
|
||||
|
||||
/// The size of the scrollbar in pixels.
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
/// The bounds of the whole scrollbar gutter.
|
||||
scrollbar_track_bounds: Option<RectF>,
|
||||
/// The relative position of the thumb within the scrollbar.
|
||||
scrollbar_position_percentage: Option<f32>,
|
||||
/// The relative height of the thumb compared to the whole scrollbar.
|
||||
scrollbar_size_percentage: Option<f32>,
|
||||
/// The bounds for the scrollbar thumb.
|
||||
scrollbar_thumb_bounds: Option<RectF>,
|
||||
/// The origin for the scrollbar thumb.
|
||||
scrollbar_thumb_origin: Option<Vector2F>,
|
||||
/// Padding between child element and the scrollbar.
|
||||
padding_between_child_and_scrollbar: f32,
|
||||
/// Padding after the scrollbar.
|
||||
padding_after_scrollbar: f32,
|
||||
|
||||
// This is a short-term solution for properly handling events on stacks. A stack will always
|
||||
// put its children on higher z-indexes than its origin, so a hit test using the standard
|
||||
// `z_index` method would always result in the event being covered (by the children of the
|
||||
// stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element.
|
||||
// Then we use that upper bound to do the hit testing, which means a parent will always get
|
||||
// events from its children, regardless of whether they are stacks or not.
|
||||
child_max_z_index: Option<ZIndex>,
|
||||
|
||||
// The scrollbar is the runway for the draggable scrollbar. By default the scollbox renders to
|
||||
// the side of the child element. This setting makes the scrollbar render over the child instead.
|
||||
overlayed_scrollbar: bool,
|
||||
}
|
||||
|
||||
impl Scrollable {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
axis: Axis,
|
||||
state: ScrollStateHandle,
|
||||
child: Box<dyn ScrollableElement>,
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
scrollbar_track_background: Fill,
|
||||
) -> Self {
|
||||
Self {
|
||||
axis,
|
||||
child,
|
||||
scrollbar_size,
|
||||
nonactive_scrollbar_thumb_background,
|
||||
active_scrollbar_thumb_background,
|
||||
scrollbar_track_background,
|
||||
state,
|
||||
origin: None,
|
||||
scrollable_size: None,
|
||||
scrollbar_track_bounds: None,
|
||||
scrollbar_position_percentage: None,
|
||||
scrollbar_size_percentage: None,
|
||||
scrollbar_thumb_bounds: None,
|
||||
scrollbar_thumb_origin: None,
|
||||
padding_between_child_and_scrollbar: LEFT_PADDING,
|
||||
padding_after_scrollbar: RIGHT_PADDING,
|
||||
child_max_z_index: None,
|
||||
overlayed_scrollbar: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a vertically scrollable element.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn vertical(
|
||||
state: ScrollStateHandle,
|
||||
child: Box<dyn ScrollableElement>,
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
scrollbar_track_background: Fill,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
Axis::Vertical,
|
||||
state,
|
||||
child,
|
||||
scrollbar_size,
|
||||
nonactive_scrollbar_thumb_background,
|
||||
active_scrollbar_thumb_background,
|
||||
scrollbar_track_background,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a horizontally scrollable element.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn horizontal(
|
||||
state: ScrollStateHandle,
|
||||
child: Box<dyn ScrollableElement>,
|
||||
scrollbar_size: ScrollbarWidth,
|
||||
nonactive_scrollbar_thumb_background: Fill,
|
||||
active_scrollbar_thumb_background: Fill,
|
||||
scrollbar_track_background: Fill,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
Axis::Horizontal,
|
||||
state,
|
||||
child,
|
||||
scrollbar_size,
|
||||
nonactive_scrollbar_thumb_background,
|
||||
active_scrollbar_thumb_background,
|
||||
scrollbar_track_background,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the padding between the child element and the scrollbar.
|
||||
pub fn with_padding_start(mut self, padding_start: f32) -> Self {
|
||||
self.padding_between_child_and_scrollbar = padding_start;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the padding after the scrollbar.
|
||||
pub fn with_padding_end(mut self, padding_end: f32) -> Self {
|
||||
self.padding_after_scrollbar = padding_end;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_overlayed_scrollbar(mut self) -> Self {
|
||||
self.overlayed_scrollbar = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn state(&mut self) -> MutexGuard<'_, ScrollState> {
|
||||
self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
fn mouse_dragged(&mut self, position: Vector2F, ctx: &mut EventContext, app: &AppContext) {
|
||||
let previous_dragging_position = self.state().started;
|
||||
if let Some(previous_dragging_position) = previous_dragging_position {
|
||||
let position_along_axis = position.along(self.axis);
|
||||
self.start_scrolling(position);
|
||||
self.jump_to_position(
|
||||
previous_dragging_position.into_pixels(),
|
||||
position_along_axis.into_pixels(),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_to_position(
|
||||
&mut self,
|
||||
previous_position_along_axis: Pixels,
|
||||
new_position_along_axis: Pixels,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let total_size = self.total_size(app);
|
||||
let scroll_start = self.scroll_start(app);
|
||||
let scroll_remaining = self.scroll_remaining(app);
|
||||
|
||||
// We need to use the original scrollbar size before resizing to calculate the scroll speed.
|
||||
let scrollbar_size_percentage_before_resize =
|
||||
(total_size - scroll_start - scroll_remaining) / total_size;
|
||||
|
||||
// We don't want to update the scroll position if you're scrolled to the top and the cursor is above
|
||||
// the element or if you're scrolled to the bottom and the cursor is below the element.
|
||||
// TODO(kevin): Do we need the scroll_start <= 0 check?
|
||||
if (scroll_remaining <= Pixels::zero()
|
||||
&& new_position_along_axis > previous_position_along_axis)
|
||||
|| (scroll_start <= Pixels::zero()
|
||||
&& previous_position_along_axis > new_position_along_axis)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = previous_position_along_axis - new_position_along_axis;
|
||||
|
||||
// The scroll speed should be proportional to the total number of lines.
|
||||
// Assume we have moved the scrollbar by a distance x, the number of lines scrolled
|
||||
// should be calculated by x / total_height * total_number_of_lines.
|
||||
self.child
|
||||
.scroll(delta / scrollbar_size_percentage_before_resize, ctx);
|
||||
}
|
||||
|
||||
fn mousewheel(&mut self, delta: Vector2F, precise: bool, ctx: &mut EventContext) {
|
||||
if self
|
||||
.scrollbar_size_percentage
|
||||
.expect("should be set at event dispatching time")
|
||||
< 1.
|
||||
{
|
||||
let delta_along_axis = delta.along(self.axis);
|
||||
if precise {
|
||||
self.child.scroll(delta_along_axis.into_pixels(), ctx);
|
||||
} else {
|
||||
// If the scroll was not `precise`, we need to convert the delta (which is
|
||||
// actually in terms of `Lines`) to the right number of `Pixels`.
|
||||
// See the comment on [`SCROLLBAR_PIXELS_PER_COCOA_TICK`] for more details.
|
||||
self.child.scroll(
|
||||
(delta_along_axis * NUM_PIXELS_PER_LINE.as_f32()).into_pixels(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the child's [`ScrollData`], assuming the child has been laid out.
|
||||
fn scroll_data(&self, app: &AppContext) -> ScrollData {
|
||||
self.child
|
||||
.scroll_data(app)
|
||||
.expect("ScrollData should be some to be scrollable")
|
||||
}
|
||||
|
||||
fn scroll_start(&self, app: &AppContext) -> Pixels {
|
||||
self.scroll_data(app).scroll_start
|
||||
}
|
||||
|
||||
/// The number of pixels that the child is still scrollable (biased towards its end).
|
||||
/// For example, for a vertically scrollable element, this would be the number of pixels
|
||||
/// that the child can still be scrolled down.
|
||||
fn scroll_remaining(&self, app: &AppContext) -> Pixels {
|
||||
let scroll_data = self.scroll_data(app);
|
||||
scroll_data.total_size - scroll_data.scroll_start - scroll_data.visible_px
|
||||
}
|
||||
|
||||
fn total_size(&self, app: &AppContext) -> Pixels {
|
||||
self.scroll_data(app).total_size
|
||||
}
|
||||
|
||||
fn start_scrolling(&mut self, position: Vector2F) {
|
||||
self.state().started = Some(position.along(self.axis));
|
||||
}
|
||||
|
||||
fn end_scrolling(&mut self) {
|
||||
self.state().started = None
|
||||
}
|
||||
|
||||
/// Returns the `original_size` that has its inverted axis dimension changed to `dimension_along_inverted_axis`.
|
||||
fn size_along_inverted_axis(
|
||||
&self,
|
||||
original_size: Vector2F,
|
||||
dimension_along_inverted_axis: f32,
|
||||
) -> Vector2F {
|
||||
match self.axis {
|
||||
Axis::Horizontal => vec2f(original_size.x(), dimension_along_inverted_axis),
|
||||
Axis::Vertical => vec2f(dimension_along_inverted_axis, original_size.y()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Scrollable {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let scrollbar_size = self.scrollbar_size.as_f32().along(self.axis.invert());
|
||||
let padding = (self.padding_between_child_and_scrollbar + self.padding_after_scrollbar)
|
||||
.along(self.axis.invert());
|
||||
|
||||
let child_constraint = if self.overlayed_scrollbar {
|
||||
// If the scrollbar is overlayed, the child can span the entire constraint.
|
||||
SizeConstraint {
|
||||
min: constraint.min.max(Vector2F::zero()),
|
||||
max: constraint.max.max(Vector2F::zero()),
|
||||
}
|
||||
} else {
|
||||
// If the scrollbar is not overlayed, we must save room for the scrollbar.
|
||||
SizeConstraint {
|
||||
min: (constraint.min - scrollbar_size - padding).max(Vector2F::zero()),
|
||||
max: (constraint.max - scrollbar_size - padding).max(Vector2F::zero()),
|
||||
}
|
||||
};
|
||||
|
||||
let child_size = self.child.layout(child_constraint, ctx, app);
|
||||
debug_assert!(
|
||||
child_size.y().is_finite(),
|
||||
"Scrollable's child should not have infinite height"
|
||||
);
|
||||
debug_assert!(
|
||||
child_size.x().is_finite(),
|
||||
"Scrollable's child should not have infinite width"
|
||||
);
|
||||
|
||||
// If the scrollbar is not overlayed, we add back its size to get the overall size
|
||||
// of the scrollable element.
|
||||
let size = if self.overlayed_scrollbar {
|
||||
child_size
|
||||
} else {
|
||||
child_size + scrollbar_size + padding
|
||||
};
|
||||
|
||||
self.scrollable_size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
|
||||
let scroll_data = self.scroll_data(app);
|
||||
let total_size = scroll_data.total_size;
|
||||
|
||||
let minimum_size_percentage =
|
||||
(MINIMUM_HEIGHT / self.scrollable_size.unwrap().along(self.axis)).min(1.);
|
||||
let size_percentage =
|
||||
(scroll_data.visible_px / total_size).max(minimum_size_percentage.into_pixels());
|
||||
|
||||
self.scrollbar_size_percentage = Some(size_percentage.as_f32());
|
||||
|
||||
// The scrollbar position is calculated with the ratio between scroll top and scroll bottom.
|
||||
let scroll_start = self.scroll_start(app);
|
||||
let scroll_remaining = self.scroll_remaining(app);
|
||||
|
||||
let scrollbar_position_percentage = scroll_start / (scroll_start + scroll_remaining);
|
||||
self.scrollbar_position_percentage = Some(scrollbar_position_percentage.as_f32());
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
self.child.paint(origin, ctx, app);
|
||||
let scrollable_size = self
|
||||
.scrollable_size
|
||||
.expect("size should have been set during layout");
|
||||
|
||||
// The origin of the scrollbar track is the maximum coordinate (along the inverted axis)
|
||||
// subtracted by the size of the scrollbar. For example, for a vertically scrollable element,
|
||||
// the origin will be the maximum x coordinate subtracted by the size of the scrollbar.
|
||||
let scrollbar_track_length = self.scrollbar_size.as_f32()
|
||||
+ self.padding_between_child_and_scrollbar
|
||||
+ self.padding_after_scrollbar;
|
||||
let scrollbar_track_origin = origin + scrollable_size.project_onto(self.axis.invert())
|
||||
- scrollbar_track_length.along(self.axis.invert());
|
||||
let scrollbar_track_size =
|
||||
self.size_along_inverted_axis(scrollable_size, scrollbar_track_length);
|
||||
|
||||
let scrollbar_track_bounds = RectF::new(scrollbar_track_origin, scrollbar_track_size);
|
||||
self.scrollbar_track_bounds = Some(scrollbar_track_bounds);
|
||||
|
||||
// If the scrollbar is overlayed over the child, it should be at a higher z-index.
|
||||
if self.overlayed_scrollbar {
|
||||
ctx.scene
|
||||
.start_layer(ClipBounds::BoundedBy(scrollbar_track_bounds));
|
||||
}
|
||||
let scrollbar = ctx
|
||||
.scene
|
||||
.draw_rect_with_hit_recording(scrollbar_track_bounds);
|
||||
|
||||
// If the scrollbar is overlayed, make it transparent. If neither the scrollbar nor the child
|
||||
// is hovered, make it have no fill.
|
||||
if !self.state().hovered && !self.state().child_hovered {
|
||||
scrollbar.with_background(Fill::None);
|
||||
} else if self.overlayed_scrollbar {
|
||||
scrollbar.with_background(Fill::Solid(ColorU::transparent_black()));
|
||||
} else {
|
||||
scrollbar.with_background(self.scrollbar_track_background);
|
||||
}
|
||||
|
||||
let scrollbar_size_percentage = self.scrollbar_size_percentage.unwrap();
|
||||
let scrollbar_position_percentage = self.scrollbar_position_percentage.unwrap();
|
||||
if scrollbar_size_percentage < 1. {
|
||||
let scrollbar_thumb_size = self.size_along_inverted_axis(
|
||||
scrollable_size * scrollbar_size_percentage,
|
||||
self.scrollbar_size.as_f32(),
|
||||
);
|
||||
let scrollbar_thumb_origin = scrollbar_track_origin
|
||||
+ self.size_along_inverted_axis(
|
||||
(scrollable_size - scrollbar_thumb_size) * scrollbar_position_percentage,
|
||||
self.padding_between_child_and_scrollbar,
|
||||
);
|
||||
|
||||
self.scrollbar_thumb_bounds =
|
||||
Some(RectF::new(scrollbar_thumb_origin, scrollbar_thumb_size));
|
||||
self.scrollbar_thumb_origin = Some(scrollbar_thumb_origin);
|
||||
|
||||
let hovered = self.state().hovered;
|
||||
let child_hovered = self.state().child_hovered;
|
||||
let background = if hovered {
|
||||
self.active_scrollbar_thumb_background
|
||||
} else if child_hovered {
|
||||
self.nonactive_scrollbar_thumb_background
|
||||
} else {
|
||||
Fill::None
|
||||
};
|
||||
|
||||
ctx.scene
|
||||
.draw_rect_with_hit_recording(RectF::new(
|
||||
scrollbar_thumb_origin,
|
||||
scrollbar_thumb_size,
|
||||
))
|
||||
.with_background(background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
|
||||
} else {
|
||||
self.scrollbar_thumb_origin = Some(origin);
|
||||
self.scrollbar_thumb_bounds = Some(RectF::new(vec2f(0., 0.), vec2f(0., 0.)));
|
||||
}
|
||||
|
||||
// See comment above about the layering of the scrollbar and scrollbar.
|
||||
if self.overlayed_scrollbar {
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
|
||||
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let handled = self.child.dispatch_event(event, ctx, app);
|
||||
let z_index = *self.child_max_z_index.as_ref().unwrap();
|
||||
|
||||
match event.raw_event() {
|
||||
Event::LeftMouseDragged { position, .. } => {
|
||||
let is_dragging = self.state().started.is_some();
|
||||
if !is_dragging {
|
||||
return handled;
|
||||
}
|
||||
self.mouse_dragged(*position, ctx, app);
|
||||
true
|
||||
}
|
||||
Event::LeftMouseDown { position, .. } => {
|
||||
if ctx.is_covered(Point::from_vec2f(*position, z_index)) {
|
||||
return handled;
|
||||
}
|
||||
|
||||
let Some(thumb_bounds) = self.scrollbar_thumb_bounds else {
|
||||
log::warn!(
|
||||
"Expected scrollbar thumb bounds to exist in dispatch_event, but got None"
|
||||
);
|
||||
return handled;
|
||||
};
|
||||
|
||||
if thumb_bounds.contains_point(*position) {
|
||||
self.start_scrolling(*position);
|
||||
|
||||
// Dispatch an action in tests so we can perform assertions
|
||||
// on clicks.
|
||||
#[cfg(test)]
|
||||
ctx.dispatch_action("scrollable_click::on_thumb", ());
|
||||
|
||||
true
|
||||
} else if self
|
||||
.scrollbar_track_bounds
|
||||
.is_some_and(|bounds| bounds.contains_point(*position))
|
||||
{
|
||||
// If mouse down happens in the x range of scrollbar but not on the thumb,
|
||||
// we should scroll to the mouse down position.
|
||||
let previous_position = thumb_bounds.center().along(self.axis);
|
||||
self.jump_to_position(
|
||||
previous_position.into_pixels(),
|
||||
position.along(self.axis).into_pixels(),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
|
||||
// Dispatch an action in tests so we can perform assertions
|
||||
// on clicks.
|
||||
#[cfg(test)]
|
||||
ctx.dispatch_action("scrollable_click::on_gutter", ());
|
||||
|
||||
true
|
||||
} else {
|
||||
handled
|
||||
}
|
||||
}
|
||||
Event::LeftMouseUp { .. } => {
|
||||
let previous_dragging_position = self.state().started;
|
||||
if previous_dragging_position.is_some() {
|
||||
self.end_scrolling();
|
||||
true
|
||||
} else {
|
||||
handled
|
||||
}
|
||||
}
|
||||
Event::MouseMoved { position, .. } => {
|
||||
let is_dragging = self.state().started.is_some();
|
||||
|
||||
if is_dragging {
|
||||
return handled;
|
||||
}
|
||||
let is_covered = ctx.is_covered(Point::from_vec2f(*position, z_index));
|
||||
|
||||
let mouse_in = self
|
||||
.scrollbar_thumb_bounds
|
||||
.unwrap()
|
||||
.contains_point(*position)
|
||||
&& !is_covered;
|
||||
let was_hovered = mem::replace(&mut self.state().hovered, mouse_in);
|
||||
|
||||
let mouse_in_child = self
|
||||
.child
|
||||
.bounds()
|
||||
.unwrap_or_default()
|
||||
.contains_point(*position)
|
||||
&& !is_covered;
|
||||
let child_was_hovered =
|
||||
mem::replace(&mut self.state().child_hovered, mouse_in_child);
|
||||
|
||||
if was_hovered != mouse_in || child_was_hovered != mouse_in_child {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
if mouse_in {
|
||||
true
|
||||
} else {
|
||||
handled
|
||||
}
|
||||
}
|
||||
Event::ScrollWheel {
|
||||
position,
|
||||
delta,
|
||||
precise,
|
||||
modifiers: ModifiersState { ctrl: false, .. },
|
||||
} => {
|
||||
if !self.child.should_handle_scroll_wheel() {
|
||||
return handled;
|
||||
}
|
||||
|
||||
if self.bounds().unwrap().contains_point(*position)
|
||||
&& !ctx.is_covered(Point::from_vec2f(*position, z_index))
|
||||
{
|
||||
self.mousewheel(*delta, *precise, ctx);
|
||||
return true;
|
||||
}
|
||||
handled
|
||||
}
|
||||
_ => handled,
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.scrollable_size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scrollable_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,661 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
elements::{ClippedScrollStateHandle, ClippedScrollable, DispatchEventResult, Flex},
|
||||
platform::WindowStyle,
|
||||
TypedActionView,
|
||||
};
|
||||
use crate::{
|
||||
elements::{ConstrainedBox, EventHandler, ParentElement, Rect, Stack},
|
||||
presenter::DispatchedActionKind,
|
||||
App, AppContext, Entity, Event, Presenter, ViewContext, WindowInvalidation,
|
||||
};
|
||||
|
||||
/// Since we support scrolling in both vertical and horizontal directions,
|
||||
/// this macro makes it easier to define tests for both directions. Simply
|
||||
/// define an axis-agnostic "test function" that is _essentially_ a test
|
||||
/// but has two main differences:
|
||||
/// - it isn't decorated with the #[test] macro, and
|
||||
/// - it takes a `axis: Axis` argument.
|
||||
///
|
||||
/// Then, you can use this macro to turn that test function into two real tests
|
||||
/// (one for each scrollable direction).
|
||||
macro_rules! define_axis_agnostic_tests {
|
||||
($test_function:ident) => {
|
||||
concat_idents::concat_idents!(test_name = $test_function, _, vertical {
|
||||
#[test]
|
||||
fn test_name() {
|
||||
$test_function(Axis::Vertical);
|
||||
}
|
||||
});
|
||||
|
||||
concat_idents::concat_idents!(test_name = $test_function, _, horizontal {
|
||||
#[test]
|
||||
fn test_name() {
|
||||
$test_function(Axis::Horizontal);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
fn create_presenter_and_render<F, T>(
|
||||
app: &mut App,
|
||||
build_root_view: F,
|
||||
window_size: Vector2F,
|
||||
) -> Rc<RefCell<Presenter>>
|
||||
where
|
||||
T: crate::View + TypedActionView,
|
||||
F: FnOnce(&mut ViewContext<T>) -> T,
|
||||
{
|
||||
let (window_id, _view) = app.add_window(WindowStyle::NotStealFocus, build_root_view);
|
||||
|
||||
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.borrow_mut().invalidate(invalidation, ctx);
|
||||
let _ = presenter
|
||||
.borrow_mut()
|
||||
.build_scene(window_size, 1., None, ctx);
|
||||
presenter
|
||||
})
|
||||
}
|
||||
|
||||
struct BasicScrollableView {
|
||||
/// [`Axis::Horizontal`] will create a horizontally scrollable view.
|
||||
/// [`Axis::Vertical`] will create a verticall scrollable view.
|
||||
axis: Axis,
|
||||
// maps view id to number of mouse downs
|
||||
mouse_downs: HashMap<usize, u32>,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
scroll_area_size: f32,
|
||||
num_elements: usize,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_action("test_view:mouse_down", BasicScrollableView::mouse_down);
|
||||
}
|
||||
|
||||
impl BasicScrollableView {
|
||||
const ITEM_SIZE: f32 = 50.;
|
||||
const SCROLLBAR_SIZE: ScrollbarWidth = ScrollbarWidth::Auto;
|
||||
|
||||
fn new(axis: Axis, scroll_area_size: f32, num_elements: usize) -> Self {
|
||||
Self {
|
||||
axis,
|
||||
scroll_area_size,
|
||||
num_elements,
|
||||
clipped_scroll_state: Default::default(),
|
||||
mouse_downs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_down(&mut self, view_id: &usize, _ctx: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_down on view_id {view_id}");
|
||||
let entry = self.mouse_downs.entry(*view_id).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for BasicScrollableView {
|
||||
type Event = String;
|
||||
}
|
||||
|
||||
impl crate::core::View for BasicScrollableView {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut flex = Flex::new(self.axis);
|
||||
for i in 0..self.num_elements {
|
||||
let id = i + 1;
|
||||
flex.add_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(move |evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_down", id);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(self.axis, Axis::Vertical) {
|
||||
ConstrainedBox::new(
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
ConstrainedBox::new(flex.finish())
|
||||
.with_height(self.num_elements as f32 * 50.)
|
||||
.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(self.scroll_area_size)
|
||||
.finish()
|
||||
} else {
|
||||
ConstrainedBox::new(
|
||||
ClippedScrollable::horizontal(
|
||||
self.clipped_scroll_state.clone(),
|
||||
ConstrainedBox::new(flex.finish())
|
||||
.with_width(self.num_elements as f32 * 50.)
|
||||
.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(self.scroll_area_size)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for BasicScrollableView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
const STACKED_VIEW_LENGTH: f32 = 100.;
|
||||
|
||||
/// Similar to [`BasicScrollableView`] except the scrollable
|
||||
/// view is an element on a [`Stack`].
|
||||
struct StackedScrollableView {
|
||||
axis: Axis,
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
impl StackedScrollableView {
|
||||
fn new(axis: Axis) -> Self {
|
||||
Self {
|
||||
axis,
|
||||
clipped_scroll_state: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for StackedScrollableView {
|
||||
type Event = String;
|
||||
}
|
||||
|
||||
impl crate::core::View for StackedScrollableView {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut inner_stack = Stack::new();
|
||||
inner_stack.add_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(STACKED_VIEW_LENGTH)
|
||||
.with_width(STACKED_VIEW_LENGTH)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if matches!(self.axis, Axis::Vertical) {
|
||||
ConstrainedBox::new(
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
inner_stack.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
// Make the scrollable element half as large as the child so that
|
||||
// there is something to scroll.
|
||||
.with_height(STACKED_VIEW_LENGTH / 2.)
|
||||
.finish()
|
||||
} else {
|
||||
ConstrainedBox::new(
|
||||
ClippedScrollable::horizontal(
|
||||
self.clipped_scroll_state.clone(),
|
||||
inner_stack.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
Fill::None,
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
// Make the scrollable element half as large as the child so that
|
||||
// there is something to scroll.
|
||||
.with_width(STACKED_VIEW_LENGTH / 2.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"StackedView"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for StackedScrollableView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
/// Tests if clipped scrolling works along `axis`.
|
||||
fn test_clipped_scrolling(axis: Axis) {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
BasicScrollableView::new(axis, 200., 10)
|
||||
});
|
||||
|
||||
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let presenter_clone = presenter.clone();
|
||||
app.update(move |ctx| {
|
||||
presenter_clone.borrow_mut().invalidate(invalidation, ctx);
|
||||
let _ = presenter_clone
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(1000., 1000.), 1., None, ctx);
|
||||
|
||||
// Fire event on first child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter_clone.clone(),
|
||||
);
|
||||
|
||||
// Trigger a scroll to make the second child be at the start
|
||||
// of the visible area
|
||||
ctx.simulate_window_event(
|
||||
Event::ScrollWheel {
|
||||
position: vec2f(15., 15.),
|
||||
delta: -(50_f32.along(axis)),
|
||||
precise: true,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter_clone.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
|
||||
assert_eq!(None, view.mouse_downs.get(&2));
|
||||
assert!(view.clipped_scroll_state.scroll_start() > Pixels::zero());
|
||||
});
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let presenter_clone = presenter.clone();
|
||||
app.update(move |ctx| {
|
||||
presenter_clone.borrow_mut().invalidate(invalidation, ctx);
|
||||
let _ = presenter_clone
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(1000., 1000.), 1., None, ctx);
|
||||
|
||||
// Fire event on second child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter_clone.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
|
||||
assert_eq!(1, *view.mouse_downs.get(&2).unwrap());
|
||||
});
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let presenter_clone = presenter;
|
||||
app.update(move |ctx| {
|
||||
presenter_clone.borrow_mut().invalidate(invalidation, ctx);
|
||||
let _ = presenter_clone
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(1000., 1000.), 1., None, ctx);
|
||||
|
||||
// Trigger a scroll back to the start
|
||||
ctx.simulate_window_event(
|
||||
Event::ScrollWheel {
|
||||
position: vec2f(15., 15.),
|
||||
delta: 50_f32.along(axis),
|
||||
precise: true,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter_clone.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
// Make sure scroll start is reset to zero
|
||||
assert!(view.clipped_scroll_state.scroll_start().as_f32().abs() < f32::EPSILON);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn test_clipped_scrolling_no_scrollbars(axis: Axis) {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
BasicScrollableView::new(axis, 500., 10)
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let _ = presenter.build_scene(vec2f(1000., 1000.), 1., None, ctx);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Fire event on first child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Try to trigger a scroll (but there shouldn't actually be one)
|
||||
ctx.simulate_window_event(
|
||||
Event::ScrollWheel {
|
||||
position: vec2f(15., 15.),
|
||||
delta: -(25_f32.along(axis)),
|
||||
precise: true,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
|
||||
assert!(view.clipped_scroll_state.scroll_start().as_f32().abs() < f32::EPSILON);
|
||||
});
|
||||
|
||||
presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let _ = presenter.build_scene(vec2f(1000., 1000.), 1., None, ctx);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Fire another event on the first child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
assert_eq!(2, *view.mouse_downs.get(&1).unwrap());
|
||||
assert_eq!(None, view.mouse_downs.get(&2));
|
||||
assert!(view.clipped_scroll_state.scroll_start().as_f32().abs() < f32::EPSILON);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn test_stacked_view_scroll_handling(axis: Axis) {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
StackedScrollableView::new(axis)
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(
|
||||
vec2f(STACKED_VIEW_LENGTH, STACKED_VIEW_LENGTH),
|
||||
1.,
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 3);
|
||||
|
||||
// Try to scroll the stacked element
|
||||
ctx.simulate_window_event(
|
||||
Event::ScrollWheel {
|
||||
position: vec2f(25., 25.),
|
||||
delta: -(25_f32.along(axis)),
|
||||
precise: true,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
assert!(view.clipped_scroll_state.scroll_start() > Pixels::zero());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn test_clicks_in_scrollbar_gutter_change_scroll_position(axis: Axis) {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
|
||||
let scroll_area_size = 200.;
|
||||
// This should make the scrollbar thumb half the size of the scrollbar.
|
||||
let num_elements =
|
||||
(scroll_area_size / BasicScrollableView::ITEM_SIZE * 2.).round() as usize;
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
BasicScrollableView::new(axis, scroll_area_size, num_elements)
|
||||
});
|
||||
|
||||
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.borrow_mut().invalidate(invalidation, ctx);
|
||||
let _ = presenter
|
||||
.borrow_mut()
|
||||
.build_scene(vec2f(1000., 1000.), 1., None, ctx);
|
||||
|
||||
// Assert that the scrollable view is scrolled to the start.
|
||||
view.read(ctx, |view, _ctx| {
|
||||
assert_eq!(view.clipped_scroll_state.scroll_start(), Pixels::zero());
|
||||
});
|
||||
|
||||
// Click on the scrollbar gutter (somewhere before the scrollbar thumb).
|
||||
let click_position = axis.to_point(
|
||||
scroll_area_size - 5.,
|
||||
1000. - (BasicScrollableView::SCROLLBAR_SIZE.as_f32() / 2.),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: click_position,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Assert that the scrollable view is no longer scrolled to the
|
||||
// top.
|
||||
view.read(ctx, |view, _ctx| {
|
||||
assert_ne!(view.clipped_scroll_state.scroll_start(), Pixels::zero());
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn test_ignores_clicks_outside_scrollbar_bounds(axis: Axis) {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
|
||||
let scroll_area_size = 100.;
|
||||
// This should make the scrollbar thumb half the size of the scrollbar.
|
||||
let num_elements =
|
||||
(scroll_area_size / BasicScrollableView::ITEM_SIZE * 2.).round() as usize;
|
||||
|
||||
let window_length = 1000.;
|
||||
let window_size = vec2f(window_length, window_length);
|
||||
let presenter = create_presenter_and_render(
|
||||
app,
|
||||
|_| BasicScrollableView::new(axis, scroll_area_size, num_elements),
|
||||
window_size,
|
||||
);
|
||||
|
||||
app.update(move |ctx| {
|
||||
// Define a macro to help us determine which actions would be
|
||||
// produced if we dispatched the given event.
|
||||
macro_rules! actions_for_dispatched_event {
|
||||
($event:expr) => {{
|
||||
let result = presenter
|
||||
.borrow_mut()
|
||||
.dispatch_event($event, ctx);
|
||||
result.actions.iter().flat_map(|action| {
|
||||
match action.kind {
|
||||
DispatchedActionKind::Legacy { name, .. } => Some(name),
|
||||
_ => None
|
||||
}
|
||||
}).collect_vec()
|
||||
}};
|
||||
}
|
||||
|
||||
let click_point_along_inverse_axis = window_length - (BasicScrollableView::SCROLLBAR_SIZE.as_f32() / 2.);
|
||||
|
||||
// Ensure that a click on the scrollbar thumb is handled.
|
||||
let click_position = axis.to_point(5., click_point_along_inverse_axis);
|
||||
let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
|
||||
position: click_position,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
});
|
||||
assert_eq!(actions, vec!["scrollable_click::on_thumb"], "Should handle clicks on the scrollbar thumb");
|
||||
|
||||
// Ensure that a click on the scrollbar gutter (i.e. outside of the thumb) is handled.
|
||||
let click_position = axis.to_point(scroll_area_size - 5., click_point_along_inverse_axis);
|
||||
let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
|
||||
position: click_position,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
});
|
||||
assert_eq!(actions, vec!["scrollable_click::on_gutter"], "Should handle clicks on the scrollbar gutter");
|
||||
|
||||
// Ensure that a click event below the scrollbar isn't handled.
|
||||
let click_position = axis.to_point(scroll_area_size + 5., click_point_along_inverse_axis);
|
||||
let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
|
||||
position: click_position,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
});
|
||||
assert!(actions.is_empty(), "Should not handle click events that are outside the vertical bounds of the scrollbar");
|
||||
|
||||
// Ensure that a click event to the left of the scrollbar isn't handled.
|
||||
let click_position = axis.to_point(scroll_area_size - 5., 100.);
|
||||
let actions = actions_for_dispatched_event!(Event::LeftMouseDown {
|
||||
position: click_position,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
});
|
||||
assert!(actions.is_empty(), "Should not handle click events that are outside the horizontal bounds of the scrollbar");
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
define_axis_agnostic_tests!(test_clipped_scrolling);
|
||||
define_axis_agnostic_tests!(test_clipped_scrolling_no_scrollbars);
|
||||
define_axis_agnostic_tests!(test_stacked_view_scroll_handling);
|
||||
define_axis_agnostic_tests!(test_clicks_in_scrollbar_gutter_change_scroll_position);
|
||||
define_axis_agnostic_tests!(test_ignores_clicks_outside_scrollbar_bounds);
|
||||
@@ -0,0 +1,749 @@
|
||||
//! This element adds cross-element selectability to the UI framework.
|
||||
//!
|
||||
//! Any elements underneath a SelectableArea which both implement SelectableElement and
|
||||
//! pass in a selectable state handle will be selectable underneath that SelectableArea.
|
||||
//!
|
||||
//! For an example of basic usage, refer to the selectable UI sample.
|
||||
|
||||
use super::SelectionFragment;
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, ColorU, Element, Event, EventContext, LayoutContext,
|
||||
PaintContext, Point, SizeConstraint,
|
||||
};
|
||||
use crate::event::{DispatchedEvent, ModifiersState};
|
||||
use crate::text::word_boundaries::WordBoundariesPolicy;
|
||||
use crate::text::{IsRect, SelectionDirection, SelectionType};
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
/// A function that, given some content and a double-click index offset in that content,
|
||||
/// returns the resulting smart selection range.
|
||||
pub type SmartSelectFn = fn(content: &str, click_offset: ByteOffset) -> Option<Range<ByteOffset>>;
|
||||
|
||||
pub struct SelectableArea {
|
||||
child: Box<dyn Element>,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
selection_handler: SelectionHandler,
|
||||
selection_updated_handler: Option<SelectionUpdatedHandler>,
|
||||
selection_right_click_handler: Option<SelectionRightClickHandler>,
|
||||
|
||||
// To preserve selections when scrolling, the selectable area stores the current selection's
|
||||
// state as points relative to the origin. When rendering the selection, these points
|
||||
// are then converted back to absolute points using the origin.
|
||||
selectable_area_state: SelectionHandle,
|
||||
|
||||
word_boundaries_policy: WordBoundariesPolicy,
|
||||
|
||||
smart_select_fn: Option<SmartSelectFn>,
|
||||
|
||||
should_support_rect_select: bool,
|
||||
}
|
||||
|
||||
/// Stores the selection start and end points. We include the option to store
|
||||
/// bounds alongside raw selection points since we may need to clamp the selection
|
||||
/// points to the SelectableArea's bounds when it's not laid out (i.e. to support
|
||||
/// across-block selections with AI blocks).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct InternalSelection {
|
||||
/// The point where the user first clicked before dragging.
|
||||
/// This could be after tail if the selection is reversed.
|
||||
pub head: Option<SelectionBound>,
|
||||
/// The latest point the user dragged the selection to.
|
||||
/// This could be before head if the selection is reversed.
|
||||
pub tail: Option<SelectionBound>,
|
||||
/// The head of the selection after semantic expansion. Note the direction of expansion
|
||||
/// depends on whether the selection was reversed.
|
||||
/// This could be after tail if the selection is reversed.
|
||||
pub expanded_head: Option<SelectionBound>,
|
||||
/// The tail of the selection after semantic expansion. Note the direction of expansion
|
||||
/// depends on whether the selection was reversed.
|
||||
/// This could be before head if the selection is reversed.
|
||||
pub expanded_tail: Option<SelectionBound>,
|
||||
/// The initial smart selection on double-click, set only if
|
||||
/// smart_select_fn successfully returned a smart selection.
|
||||
/// We store this separately because selection updates after dragging should never be smaller than
|
||||
/// the initial smart selection range.
|
||||
pub initial_smart_selection: Option<InitialSmartSelection>,
|
||||
/// The semantic selection unit.
|
||||
pub unit: SelectionType,
|
||||
pub is_selecting: bool,
|
||||
/// If true, head is after tail.
|
||||
pub is_reversed: bool,
|
||||
/// Whether we should return the smart selection's start when computing the selection start.
|
||||
/// This is caching whether the smart selection's start is earlier than the expanded start.
|
||||
pub should_use_smart_start: bool,
|
||||
/// Whether we should return the smart selection's end when computing the selection end.
|
||||
/// This is caching whether the smart selection's end is later than the expanded end.
|
||||
pub should_use_smart_end: bool,
|
||||
}
|
||||
|
||||
/// The initial smart selection on double-click, set only if
|
||||
/// smart_select_fn successfully returned a smart selection.
|
||||
/// We store this separately because selection updates after dragging should never be smaller than
|
||||
/// the initial smart selection range.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct InitialSmartSelection {
|
||||
/// Always before end.
|
||||
pub start: SelectionBound,
|
||||
/// Always after start.
|
||||
pub end: SelectionBound,
|
||||
}
|
||||
|
||||
impl InternalSelection {
|
||||
// Returns the start point of the selection, using expanded points
|
||||
// if they exist. This is always before end.
|
||||
pub fn start(&self) -> Option<SelectionBound> {
|
||||
if self.should_use_smart_start {
|
||||
self.initial_smart_selection
|
||||
.map(|smart_selection| smart_selection.start)
|
||||
} else if self.is_reversed {
|
||||
self.expanded_tail.or(self.tail)
|
||||
} else {
|
||||
self.expanded_head.or(self.head)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the end point of the selection, using expanded points
|
||||
// if they exist. This is always after start.
|
||||
pub fn end(&self) -> Option<SelectionBound> {
|
||||
if self.should_use_smart_end {
|
||||
self.initial_smart_selection
|
||||
.map(|smart_selection| smart_selection.end)
|
||||
} else if self.is_reversed {
|
||||
self.expanded_head.or(self.head)
|
||||
} else {
|
||||
self.expanded_tail.or(self.tail)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the current selection state.
|
||||
///
|
||||
/// This is `pub` so callers may imperatively clear selection state in cases where a
|
||||
/// selection-clearing mouse or keyboard event is handled prior to being received by this
|
||||
/// `SelectableArea`.
|
||||
pub fn clear(&mut self) {
|
||||
*self = InternalSelection::default();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Selection {
|
||||
pub start: Vector2F,
|
||||
pub end: Vector2F,
|
||||
pub is_rect: IsRect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum SelectionBound {
|
||||
/// Relative to the SelectableArea's origin
|
||||
Relative(Vector2F),
|
||||
/// Start from the top left point of the SelectableArea.
|
||||
TopLeft,
|
||||
/// Start from the bottom right of the SelectableArea.
|
||||
BottomRight,
|
||||
/// Start from the top column of the SelectableArea. The row is defined by the x_bound.
|
||||
Top { x_bound: f32 },
|
||||
/// Start from the bottom column of the SelectableArea. The row is defined by the x_bound.
|
||||
Bottom { x_bound: f32 },
|
||||
}
|
||||
|
||||
impl SelectionBound {
|
||||
fn as_absolute_point(&self, size: Vector2F, origin: Vector2F) -> Vector2F {
|
||||
match self {
|
||||
SelectionBound::Relative(point) => *point + origin,
|
||||
SelectionBound::TopLeft => origin,
|
||||
SelectionBound::BottomRight => origin + size,
|
||||
SelectionBound::Top { x_bound } => vec2f(*x_bound, origin.y()),
|
||||
SelectionBound::Bottom { x_bound } => vec2f(*x_bound, size.y() + origin.y()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectionUpdateArgs {
|
||||
pub selection: Option<String>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref SELECTED_HIGHLIGHT_COLOR: ColorU =
|
||||
ColorU::new(118, 167, 250, (0.4 * 255.) as u8);
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct SelectionHandle {
|
||||
selection: Arc<Mutex<InternalSelection>>,
|
||||
}
|
||||
pub type SelectionHandler = Box<dyn FnMut(SelectionUpdateArgs, &mut EventContext, &AppContext)>;
|
||||
type SelectionUpdatedHandler = Box<dyn FnMut(&mut EventContext, &AppContext)>;
|
||||
type SelectionRightClickHandler = Box<dyn FnMut(&mut EventContext, Vector2F)>;
|
||||
|
||||
impl SelectionHandle {
|
||||
/// This isn't meant for general use. It's used specifically in cases where a selection is started
|
||||
/// outside the SelectableArea's bounds and the SelectableArea's start point needs to be clamped manually.
|
||||
pub fn start_selection_outside(&self, bound: SelectionBound, unit: SelectionType) {
|
||||
let mut selection = self.selection.lock().expect("Should not be poisoned.");
|
||||
selection.head = Some(bound);
|
||||
selection.unit = unit;
|
||||
selection.is_selecting = true;
|
||||
}
|
||||
|
||||
/// Whether there is an active selection in the SelectableArea.
|
||||
/// An active selection is not necessarily a non-empty selection.
|
||||
pub fn is_selecting(&self) -> bool {
|
||||
self.selection
|
||||
.lock()
|
||||
.expect("Should not be poisoned.")
|
||||
.is_selecting
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.selection
|
||||
.lock()
|
||||
.expect("Mutex is not poisoned.")
|
||||
.clear();
|
||||
}
|
||||
|
||||
#[cfg(feature = "integration_tests")]
|
||||
pub fn selection_type(&self) -> SelectionType {
|
||||
self.selection.lock().expect("Mutex is not poisoned.").unit
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableArea {
|
||||
pub fn new<F>(
|
||||
selectable_area_state: SelectionHandle,
|
||||
selection_handler: F,
|
||||
child: Box<dyn Element>,
|
||||
) -> Self
|
||||
where
|
||||
F: 'static + FnMut(SelectionUpdateArgs, &mut EventContext, &AppContext),
|
||||
{
|
||||
Self {
|
||||
child,
|
||||
size: None,
|
||||
origin: None,
|
||||
selectable_area_state,
|
||||
selection_handler: Box::new(selection_handler),
|
||||
selection_updated_handler: None,
|
||||
selection_right_click_handler: None,
|
||||
word_boundaries_policy: WordBoundariesPolicy::Default,
|
||||
smart_select_fn: None,
|
||||
should_support_rect_select: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_support_rect_select(mut self) -> Self {
|
||||
self.should_support_rect_select = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_word_boundaries_policy(self, word_boundaries_policy: WordBoundariesPolicy) -> Self {
|
||||
Self {
|
||||
word_boundaries_policy,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_smart_select_fn(self, smart_select_fn: Option<SmartSelectFn>) -> Self {
|
||||
Self {
|
||||
smart_select_fn,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection updated handler is invoked only when a selection is actively being made.
|
||||
/// Clearing the text selection in a `SelectableArea` via `LeftMouseDown` doesn't count.
|
||||
pub fn on_selection_updated<F>(self, selection_updated_handler: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext),
|
||||
{
|
||||
Self {
|
||||
selection_updated_handler: Some(Box::new(selection_updated_handler)),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_selection_right_click<F>(self, selection_right_click_fn: F) -> Self
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, Vector2F),
|
||||
{
|
||||
Self {
|
||||
selection_right_click_handler: Some(Box::new(selection_right_click_fn)),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears any existing selection, and starts a new selection if the click is in the element.
|
||||
/// Does not handle cases where a selection is started outside the `SelectableArea`'s bounds.
|
||||
/// Returns `true` if a new selection was successfully started.
|
||||
fn on_mouse_down(
|
||||
&mut self,
|
||||
position: Vector2F,
|
||||
modifiers: &ModifiersState,
|
||||
click_count: u32,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let Some(selectable_child_ref) = self.child.as_selectable_element() else {
|
||||
return false;
|
||||
};
|
||||
// Clear any previously existing selection on mouse down.
|
||||
let mut selection_state = self
|
||||
.selectable_area_state
|
||||
.selection
|
||||
.lock()
|
||||
.expect("Should not be poisoned.");
|
||||
selection_state.clear();
|
||||
|
||||
// Only if this click was in the element, start a new selection.
|
||||
if !is_mouse_in(self.origin, self.size, ctx, position) {
|
||||
return false;
|
||||
}
|
||||
let Some(origin) = self.origin else {
|
||||
return false;
|
||||
};
|
||||
selection_state.is_selecting = true;
|
||||
|
||||
let unit = if click_count == 1 {
|
||||
if self.should_support_rect_select && modifiers.alt && modifiers.cmd {
|
||||
SelectionType::Rect
|
||||
} else {
|
||||
SelectionType::Simple
|
||||
}
|
||||
} else if click_count == 2 {
|
||||
SelectionType::Semantic
|
||||
} else {
|
||||
SelectionType::Lines
|
||||
};
|
||||
selection_state.unit = unit;
|
||||
selection_state.head = Some(SelectionBound::Relative(position - origin.xy));
|
||||
selection_state.tail = Some(SelectionBound::Relative(position - origin.xy));
|
||||
|
||||
// First, try smart selection if it's configured and this is a double click.
|
||||
let smart_select_range = match (unit, self.smart_select_fn) {
|
||||
(SelectionType::Semantic, Some(smart_select_fn)) => {
|
||||
selectable_child_ref.smart_select(position, smart_select_fn)
|
||||
}
|
||||
// In all other cases, use the default selection expansion
|
||||
_ => None,
|
||||
};
|
||||
let (expanded_head, expanded_tail) = match smart_select_range {
|
||||
// If smart_select_range is set, use that as expanded head / tail.
|
||||
Some((start, end)) => {
|
||||
selection_state.initial_smart_selection = Some(InitialSmartSelection {
|
||||
start: SelectionBound::Relative(start - origin.xy),
|
||||
end: SelectionBound::Relative(end - origin.xy),
|
||||
});
|
||||
(Some(start), Some(end))
|
||||
}
|
||||
_ => {
|
||||
// Otherwise, expand the selection normally.
|
||||
let expanded_head = selectable_child_ref.expand_selection(
|
||||
position,
|
||||
SelectionDirection::Backward,
|
||||
unit,
|
||||
&self.word_boundaries_policy,
|
||||
);
|
||||
let expanded_tail = selectable_child_ref.expand_selection(
|
||||
position,
|
||||
SelectionDirection::Forward,
|
||||
unit,
|
||||
&self.word_boundaries_policy,
|
||||
);
|
||||
(expanded_head, expanded_tail)
|
||||
}
|
||||
};
|
||||
|
||||
// Set the expanded head and tail. Since the resulting expanded selection could be
|
||||
// non-empty, we should invoke the selection update handler as well.
|
||||
if let Some((head, tail)) = expanded_head.zip(expanded_tail) {
|
||||
selection_state.expanded_head = Some(SelectionBound::Relative(head - origin.xy));
|
||||
selection_state.expanded_tail = Some(SelectionBound::Relative(tail - origin.xy));
|
||||
|
||||
if head != tail {
|
||||
if let Some(selection_updated_handler) = self.selection_updated_handler.as_mut() {
|
||||
selection_updated_handler(ctx, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// By this point, we've determined that a selection has successfully started.
|
||||
// Returning `true` ensures that parent `SelectableArea`s don't attempt to start their
|
||||
// own text selections at the same time.
|
||||
true
|
||||
}
|
||||
|
||||
fn on_right_mouse_down(&mut self, position: Vector2F, ctx: &mut EventContext) -> bool {
|
||||
// Ignore this right-click unless it took place within this SelectableArea element
|
||||
if !is_mouse_in(self.origin, self.size, ctx, position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_selection = self.get_current_selection_absolute();
|
||||
let Some(selectable_child_ref) = self.child.as_selectable_element() else {
|
||||
return false;
|
||||
};
|
||||
let Some(right_click_handler) = self.selection_right_click_handler.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let clickable_bounds = selectable_child_ref.calculate_clickable_bounds(current_selection);
|
||||
let is_within_bounds = clickable_bounds
|
||||
.iter()
|
||||
.any(|bounds| bounds.contains_point(position));
|
||||
|
||||
if is_within_bounds {
|
||||
let origin = self
|
||||
.origin
|
||||
.expect("Origin should be defined before mouse clicks")
|
||||
.xy();
|
||||
let position_in_block = position - origin;
|
||||
right_click_handler(ctx, position_in_block);
|
||||
}
|
||||
is_within_bounds
|
||||
}
|
||||
|
||||
/// Updates the selection using the latest tail position (where the user dragged to).
|
||||
/// Expands selections as needed and computes whether the selection is_reversed.
|
||||
/// Returns whether the selection was actually updated.
|
||||
fn update_selection(&mut self, tail_absolute_position: Vector2F) -> bool {
|
||||
let Some(selectable_child_ref) = self.child.as_selectable_element() else {
|
||||
return false;
|
||||
};
|
||||
let mut selection_state = self
|
||||
.selectable_area_state
|
||||
.selection
|
||||
.lock()
|
||||
.expect("Should not be poisoned.");
|
||||
|
||||
// We can only update the selection if we have a selection head the selection was initiated from.
|
||||
let (Some(relative_selection_head), Some(origin), Some(size)) =
|
||||
(selection_state.head, self.origin, self.size)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let new_selection_tail = SelectionBound::Relative(tail_absolute_position - origin.xy);
|
||||
// Don't update or cache the selection if it hasn't changed
|
||||
if selection_state
|
||||
.tail
|
||||
.is_some_and(|old_selection_tail| old_selection_tail == new_selection_tail)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the selection's raw end.
|
||||
selection_state.tail = Some(new_selection_tail);
|
||||
|
||||
// Compute whether the selection is reversed. This is needed
|
||||
// to determine how to expand the selection.
|
||||
let head_absolute_position = relative_selection_head.as_absolute_point(size, origin.xy());
|
||||
let is_reversed = if matches!(
|
||||
relative_selection_head,
|
||||
SelectionBound::TopLeft | SelectionBound::Top { .. }
|
||||
) {
|
||||
Some(false)
|
||||
} else if matches!(
|
||||
relative_selection_head,
|
||||
SelectionBound::BottomRight | SelectionBound::Bottom { .. }
|
||||
) {
|
||||
Some(true)
|
||||
} else {
|
||||
// If the end is before the start, this is a reversed selection.
|
||||
selectable_child_ref
|
||||
.is_point_semantically_before(tail_absolute_position, head_absolute_position)
|
||||
};
|
||||
// If we can't tell whether the selection is reversed, don't do semantic expansion.
|
||||
let Some(is_reversed_selection) = is_reversed else {
|
||||
// We return true here because the selection was already successfully updated with the latest unexpanded tail.
|
||||
return true;
|
||||
};
|
||||
|
||||
let (head_direction, tail_direction) = if is_reversed_selection {
|
||||
// If this is a reversed selection, the tail (point the user dragged to) should be expanded backward
|
||||
// since it will be the start of the selection.
|
||||
(SelectionDirection::Forward, SelectionDirection::Backward)
|
||||
} else {
|
||||
// If this is a forward selection, the head (point user originally clicked before dragging)
|
||||
// should be expanded backward since it will be the start of the selection.
|
||||
(SelectionDirection::Backward, SelectionDirection::Forward)
|
||||
};
|
||||
|
||||
// We always need to expand the new tail.
|
||||
// If we're changing the value of is_reversed, we also need to re-expand
|
||||
// the head, since the direction of head expansion changes.
|
||||
// There are expected cases where only one is expanded successfully and not the other.
|
||||
// For example, if a semantic selection was started outside the selectable area and then
|
||||
// dragged in, the original head would be a max/min bound of the selectable area which
|
||||
// can't always be expanded.
|
||||
let expanded_tail = selectable_child_ref.expand_selection(
|
||||
tail_absolute_position,
|
||||
tail_direction,
|
||||
selection_state.unit,
|
||||
&self.word_boundaries_policy,
|
||||
);
|
||||
selection_state.expanded_tail =
|
||||
expanded_tail.map(|expanded_tail| SelectionBound::Relative(expanded_tail - origin.xy));
|
||||
if selection_state.is_reversed != is_reversed_selection {
|
||||
let expanded_head = selectable_child_ref.expand_selection(
|
||||
head_absolute_position,
|
||||
head_direction,
|
||||
selection_state.unit,
|
||||
&self.word_boundaries_policy,
|
||||
);
|
||||
selection_state.expanded_head = expanded_head
|
||||
.map(|expanded_head| SelectionBound::Relative(expanded_head - origin.xy));
|
||||
}
|
||||
selection_state.is_reversed = is_reversed_selection;
|
||||
// Now that we've set the new expanded head and tail, make sure our new selection is not smaller than
|
||||
// the original smart selection if there was one.
|
||||
// First reset the cached values to get the selection start/end without considering the initial smart selection.
|
||||
selection_state.should_use_smart_start = false;
|
||||
selection_state.should_use_smart_end = false;
|
||||
let (Some(new_start), Some(new_end)) = (selection_state.start(), selection_state.end())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let Some(initial_smart_selection) = selection_state.initial_smart_selection else {
|
||||
return true;
|
||||
};
|
||||
// Use the smart selection start/end if they would make the selection range bigger than the expanded selection.
|
||||
if selectable_child_ref
|
||||
.is_point_semantically_before(
|
||||
initial_smart_selection
|
||||
.start
|
||||
.as_absolute_point(size, origin.xy()),
|
||||
new_start.as_absolute_point(size, origin.xy()),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
selection_state.should_use_smart_start = true
|
||||
}
|
||||
if selectable_child_ref
|
||||
.is_point_semantically_before(
|
||||
new_end.as_absolute_point(size, origin.xy()),
|
||||
initial_smart_selection
|
||||
.end
|
||||
.as_absolute_point(size, origin.xy()),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
selection_state.should_use_smart_end = true
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// Returns the current selection in absolute coordinates.
|
||||
fn get_current_selection_absolute(&self) -> Option<Selection> {
|
||||
let (Some(origin), Some(size)) = (self.origin, self.size) else {
|
||||
return None;
|
||||
};
|
||||
let selection = self
|
||||
.selectable_area_state
|
||||
.selection
|
||||
.lock()
|
||||
.expect("Should not be poisoned.");
|
||||
let (Some(start), Some(end)) = (selection.start(), selection.end()) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(Selection {
|
||||
start: start.as_absolute_point(size, origin.xy()),
|
||||
end: end.as_absolute_point(size, origin.xy()),
|
||||
is_rect: selection.unit.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn get_current_selection_text_fragments(&self) -> Option<Vec<SelectionFragment>> {
|
||||
let updated_selection = self.get_current_selection_absolute()?;
|
||||
let selectable_child_ref = self.child.as_selectable_element()?;
|
||||
|
||||
// Order selected text fragments
|
||||
selectable_child_ref.get_selection(
|
||||
updated_selection.start,
|
||||
updated_selection.end,
|
||||
updated_selection.is_rect,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_current_selection_empty(&self) -> bool {
|
||||
self.get_current_selection_text_fragments()
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
fn invoke_selection_handler(&mut self, ctx: &mut EventContext, app: &AppContext) {
|
||||
let text_fragments = self.get_current_selection_text_fragments();
|
||||
let update_args = SelectionUpdateArgs {
|
||||
// If `text_fragments` is `None`, we still need to invoke the selection_handler accordingly.
|
||||
// Otherwise, clicking away from text within an AIBlock won't clear the underlying selected_text state.
|
||||
selection: text_fragments.map(order_and_concatenate_fragments),
|
||||
};
|
||||
(self.selection_handler)(update_args, ctx, app);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if the mouse is over the element
|
||||
fn is_mouse_in(
|
||||
origin: Option<Point>,
|
||||
size: Option<Vector2F>,
|
||||
ctx: &EventContext,
|
||||
position: Vector2F,
|
||||
) -> bool {
|
||||
let Some(origin) = origin else {
|
||||
log::warn!("self.origin was None in `SelectableArea::is_mouse_in`");
|
||||
return false;
|
||||
};
|
||||
let Some(size) = size else {
|
||||
log::warn!("self.size() was None in `SelectableArea::is_mouse_in`");
|
||||
return false;
|
||||
};
|
||||
|
||||
ctx.visible_rect(origin, size)
|
||||
.is_some_and(|bound| bound.contains_point(position))
|
||||
}
|
||||
|
||||
fn order_and_concatenate_fragments(mut selection_fragments: Vec<SelectionFragment>) -> String {
|
||||
selection_fragments.sort_by(|a, b| {
|
||||
if a.origin.y() == b.origin.y() {
|
||||
a.origin.x().total_cmp(&b.origin.x())
|
||||
} else {
|
||||
a.origin.y().total_cmp(&b.origin.y())
|
||||
}
|
||||
});
|
||||
|
||||
selection_fragments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<&str>>()
|
||||
.concat()
|
||||
}
|
||||
|
||||
impl Element for SelectableArea {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let child_constraint = SizeConstraint {
|
||||
min: (constraint.min).max(Vector2F::zero()),
|
||||
max: (constraint.max).max(Vector2F::zero()),
|
||||
};
|
||||
let child_size = self.child.layout(child_constraint, ctx, app);
|
||||
let size = child_size;
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
ctx.current_selection = self.get_current_selection_absolute();
|
||||
self.child.paint(origin, ctx, app);
|
||||
ctx.current_selection = None;
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
// Only dispatch to the child if we're not in the middle of a non-empty selection.
|
||||
// Nested `SelectableArea` elements should pick up on mouse events if said events
|
||||
// are not being used to create a non-trivial selection in this `SelectableArea`.
|
||||
// Do not handle the event with this element if the child handles it, as doing so
|
||||
// could result in nested `SelectableArea` elements being unnecessarily cleared.
|
||||
let should_dispatch_to_child =
|
||||
!self.selectable_area_state.is_selecting() || self.is_current_selection_empty();
|
||||
if should_dispatch_to_child {
|
||||
let handled = self.child.as_mut().dispatch_event(event, ctx, app);
|
||||
if handled {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
match event.raw_event() {
|
||||
Event::LeftMouseDown {
|
||||
position,
|
||||
click_count,
|
||||
modifiers,
|
||||
..
|
||||
} => {
|
||||
let selection_started =
|
||||
self.on_mouse_down(*position, modifiers, *click_count, ctx, app);
|
||||
|
||||
// Invoking the selection handler is necessary to notify parent views that we've
|
||||
// cleared the internal selection state of the `SelectableArea`.
|
||||
self.invoke_selection_handler(ctx, app);
|
||||
selection_started
|
||||
}
|
||||
Event::LeftMouseDragged { position, .. } => {
|
||||
if !self.selectable_area_state.is_selecting() {
|
||||
return false;
|
||||
}
|
||||
let (Some(origin), Some(size)) = (self.origin, self.size) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let selection_updated = self.update_selection(*position);
|
||||
if !selection_updated {
|
||||
return false;
|
||||
}
|
||||
if let Some(selection_updated_handler) = self.selection_updated_handler.as_mut() {
|
||||
selection_updated_handler(ctx, app);
|
||||
}
|
||||
|
||||
// Materialize and cache the selected text if SelectableArea is about to go off-screen.
|
||||
// Since origin isn't available when SelectableArea is off-screen, we aren't able to
|
||||
// materialize the selection on mouse up if that that's the case. As a workaround,
|
||||
// we cache it here ahead of time.
|
||||
if origin.y() < 0.
|
||||
|| origin.y() + size.y() > app.windows().active_display_bounds().height()
|
||||
{
|
||||
self.invoke_selection_handler(ctx, app)
|
||||
}
|
||||
|
||||
// Returning true ensures that this SelectableArea's ongoing selections won't
|
||||
// conflict with parent or child SelectableAreas in the element tree.
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
Event::LeftMouseUp { position, .. } => {
|
||||
self.selectable_area_state
|
||||
.selection
|
||||
.lock()
|
||||
.expect("Should not be poisoned.")
|
||||
.is_selecting = false;
|
||||
self.invoke_selection_handler(ctx, app);
|
||||
|
||||
// If the mouse is inside this element no other element needs to handle this event
|
||||
// because this is the "lowest level" element so we return `true`. If the mouse is
|
||||
// outside this element we return `false` so other elements can handle the event
|
||||
// as well. We need to handle `LeftMouseUp` in either case to support selections
|
||||
// across elements. Note that this behavior may need to change in the future.
|
||||
is_mouse_in(self.origin, self.size, ctx, *position)
|
||||
}
|
||||
Event::RightMouseDown { position, .. } => self.on_right_mouse_down(*position, ctx),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
use crate::units::{IntoPixels, Pixels};
|
||||
|
||||
use super::{Axis, F32Ext, ScrollData, ScrollbarWidth, Vector2FExt};
|
||||
|
||||
pub const DEFAULT_SCROLLBAR_PADDING_BETWEEN_CHILD_AND_TRACK: f32 = 2.0;
|
||||
pub const DEFAULT_SCROLLBAR_PADDING_AFTER_TRACK: f32 = 2.0;
|
||||
pub const DEFAULT_SCROLL_WHEEL_PIXELS_PER_LINE: f32 = 40.0;
|
||||
pub const MIN_SCROLLBAR_THUMB_LENGTH: f32 = 20.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScrollbarAppearance {
|
||||
pub scrollbar_width: ScrollbarWidth,
|
||||
pub overlaid_scrollbar: bool,
|
||||
pub padding_between_child_and_scrollbar: f32,
|
||||
pub padding_after_scrollbar: f32,
|
||||
}
|
||||
|
||||
impl ScrollbarAppearance {
|
||||
pub fn new(scrollbar_width: ScrollbarWidth, overlaid_scrollbar: bool) -> Self {
|
||||
Self {
|
||||
scrollbar_width,
|
||||
overlaid_scrollbar,
|
||||
padding_between_child_and_scrollbar: DEFAULT_SCROLLBAR_PADDING_BETWEEN_CHILD_AND_TRACK,
|
||||
padding_after_scrollbar: DEFAULT_SCROLLBAR_PADDING_AFTER_TRACK,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_axis_spacing(&self, include_overlaid_scrollbar: bool) -> f32 {
|
||||
if !include_overlaid_scrollbar && self.overlaid_scrollbar {
|
||||
0.0
|
||||
} else {
|
||||
self.padding_between_child_and_scrollbar + self.padding_after_scrollbar
|
||||
}
|
||||
}
|
||||
|
||||
fn scrollbar_track_length(&self, include_overlaid_scrollbar: bool) -> f32 {
|
||||
if !include_overlaid_scrollbar && self.overlaid_scrollbar {
|
||||
0.0
|
||||
} else {
|
||||
self.scrollbar_width.as_f32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScrollbarGeometry {
|
||||
pub track_bounds: RectF,
|
||||
pub thumb_bounds: RectF,
|
||||
pub scrollbar_size_percentage: f32,
|
||||
pub scrollbar_position_percentage: f32,
|
||||
}
|
||||
|
||||
impl ScrollbarGeometry {
|
||||
pub fn has_thumb(&self) -> bool {
|
||||
self.scrollbar_size_percentage < 1.0 && !self.thumb_bounds.is_empty()
|
||||
}
|
||||
|
||||
pub fn thumb_center_along(&self, axis: Axis) -> Pixels {
|
||||
self.thumb_bounds.center().along(axis).into_pixels()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_scroll_delta_by_sensitivity(delta: Vector2F, sensitivity: f32) -> Vector2F {
|
||||
if delta.x().abs() * sensitivity > delta.y().abs() {
|
||||
delta.project_onto(Axis::Horizontal)
|
||||
} else if delta.y().abs() * sensitivity > delta.x().abs() {
|
||||
delta.project_onto(Axis::Vertical)
|
||||
} else {
|
||||
delta
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_scrollbar_geometry(
|
||||
axis: Axis,
|
||||
origin: Vector2F,
|
||||
scrollable_size: Vector2F,
|
||||
scroll_data: ScrollData,
|
||||
appearance: ScrollbarAppearance,
|
||||
) -> ScrollbarGeometry {
|
||||
let scrollable_size_with_padding = match axis {
|
||||
Axis::Horizontal => vec2f(
|
||||
0.0,
|
||||
appearance.scrollbar_track_length(true) + appearance.cross_axis_spacing(true),
|
||||
),
|
||||
Axis::Vertical => vec2f(
|
||||
appearance.scrollbar_track_length(true) + appearance.cross_axis_spacing(true),
|
||||
0.0,
|
||||
),
|
||||
};
|
||||
let viewport_size = (scrollable_size - scrollable_size_with_padding).max(Vector2F::zero());
|
||||
let scrollbar_track_length = scrollable_size_with_padding.along(axis.invert());
|
||||
let scrollbar_track_origin = origin + scrollable_size.project_onto(axis.invert())
|
||||
- scrollbar_track_length.along(axis.invert());
|
||||
let scrollbar_track_size = scrollbar_size(axis, viewport_size, scrollbar_track_length);
|
||||
let track_bounds = RectF::new(scrollbar_track_origin, scrollbar_track_size);
|
||||
|
||||
let (scrollbar_size_percentage, scrollbar_position_percentage) =
|
||||
scrollbar_percentages(scroll_data, viewport_size.along(axis));
|
||||
|
||||
if scrollbar_size_percentage >= 1.0 {
|
||||
return ScrollbarGeometry {
|
||||
track_bounds,
|
||||
thumb_bounds: RectF::new(vec2f(0.0, 0.0), vec2f(0.0, 0.0)),
|
||||
scrollbar_size_percentage,
|
||||
scrollbar_position_percentage,
|
||||
};
|
||||
}
|
||||
|
||||
let thumb_size = scrollbar_size(
|
||||
axis,
|
||||
viewport_size * scrollbar_size_percentage,
|
||||
appearance.scrollbar_width.as_f32(),
|
||||
);
|
||||
let thumb_origin = scrollbar_track_origin
|
||||
+ scrollbar_size(
|
||||
axis,
|
||||
(viewport_size - thumb_size).max(Vector2F::zero()) * scrollbar_position_percentage,
|
||||
appearance.padding_between_child_and_scrollbar,
|
||||
);
|
||||
|
||||
ScrollbarGeometry {
|
||||
track_bounds,
|
||||
thumb_bounds: RectF::new(thumb_origin, thumb_size),
|
||||
scrollbar_size_percentage,
|
||||
scrollbar_position_percentage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_delta_for_pointer_movement(
|
||||
previous_position_along_axis: Pixels,
|
||||
new_position_along_axis: Pixels,
|
||||
scroll_data: ScrollData,
|
||||
) -> Pixels {
|
||||
if scroll_data.total_size <= Pixels::zero()
|
||||
|| scroll_data.visible_px <= Pixels::zero()
|
||||
|| scroll_data.visible_px >= scroll_data.total_size
|
||||
{
|
||||
return Pixels::zero();
|
||||
}
|
||||
|
||||
let scroll_size_percentage = scroll_data.visible_px / scroll_data.total_size;
|
||||
if scroll_size_percentage <= Pixels::zero() {
|
||||
return Pixels::zero();
|
||||
}
|
||||
|
||||
(previous_position_along_axis - new_position_along_axis) / scroll_size_percentage
|
||||
}
|
||||
|
||||
fn scrollbar_percentages(scroll_data: ScrollData, scrollable_pixels: f32) -> (f32, f32) {
|
||||
if scroll_data.total_size <= Pixels::zero() {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
|
||||
let minimum_size_percentage = (MIN_SCROLLBAR_THUMB_LENGTH / scrollable_pixels).min(1.0);
|
||||
let size_percentage = (scroll_data.visible_px / scroll_data.total_size)
|
||||
.max(Pixels::new(minimum_size_percentage))
|
||||
.as_f32();
|
||||
let scroll_remaining =
|
||||
scroll_data.total_size - scroll_data.scroll_start - scroll_data.visible_px;
|
||||
let position_percentage = if scroll_data.scroll_start + scroll_remaining <= Pixels::zero() {
|
||||
0.0
|
||||
} else {
|
||||
(scroll_data.scroll_start / (scroll_data.scroll_start + scroll_remaining)).as_f32()
|
||||
};
|
||||
|
||||
(size_percentage, position_percentage)
|
||||
}
|
||||
|
||||
pub(crate) fn scrollbar_size(
|
||||
axis: Axis,
|
||||
scrollable_size: Vector2F,
|
||||
scrollbar_track_length: f32,
|
||||
) -> Vector2F {
|
||||
match axis {
|
||||
Axis::Horizontal => vec2f(scrollable_size.x(), scrollbar_track_length),
|
||||
Axis::Vertical => vec2f(scrollbar_track_length, scrollable_size.y()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
mod config;
|
||||
mod glyph_index;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::f32::consts::PI;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::color::ColorU;
|
||||
pub use crate::elements::shimmering_text::config::ShimmerConfig;
|
||||
use crate::elements::shimmering_text::glyph_index::GlyphIndex;
|
||||
use crate::elements::{Axis, Point, DEFAULT_UI_LINE_HEIGHT_RATIO};
|
||||
use crate::fonts::{FamilyId, Properties};
|
||||
use crate::geometry::rect::RectF;
|
||||
use crate::geometry::vector::{vec2f, Vector2F};
|
||||
use crate::platform::LineStyle;
|
||||
use crate::text_layout::{
|
||||
ClipConfig, Line, PaintStyleOverride, StyleAndFont, TextStyle, DEFAULT_TOP_BOTTOM_RATIO,
|
||||
};
|
||||
use crate::{AppContext, Element, PaintContext, SizeConstraint};
|
||||
use instant::Instant;
|
||||
use rangemap::RangeMap;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
/// A key to determine whether we need to re-layout text to a given invocation of #layout to this
|
||||
/// element.
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
struct LayoutKey {
|
||||
text: Cow<'static, str>,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
max_width: f32,
|
||||
}
|
||||
|
||||
struct StateInternal {
|
||||
laid_out_key: Option<LayoutKey>,
|
||||
laid_out_line: Option<Arc<Line>>,
|
||||
/// A list of the character index of every glyph in the line. In other words, index 0 contains
|
||||
/// A mapping from glyph index in the line to the character index for that glyph.
|
||||
/// In other words, key 0 contains the character index of the first glyph. We store this as a map
|
||||
/// to be resilient to ligatures: the ligature 'fi' is two characters but should only have one fade.
|
||||
/// to ligatures: the ligature "fi" is two characters but should only have one fade.
|
||||
glyph_indices_in_order: HashMap<GlyphIndex<usize>, CharOffset>,
|
||||
animation_start_time: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ShimmeringTextStateHandle(Arc<Mutex<StateInternal>>);
|
||||
|
||||
impl Default for ShimmeringTextStateHandle {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ShimmeringTextStateHandle {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(StateInternal {
|
||||
laid_out_key: None,
|
||||
laid_out_line: None,
|
||||
glyph_indices_in_order: HashMap::default(),
|
||||
animation_start_time: Instant::now(),
|
||||
})))
|
||||
}
|
||||
|
||||
fn get(&self) -> MutexGuard<'_, StateInternal> {
|
||||
self.0.lock().expect("Mutex should not be poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that displays the given text using given `base_color` with a shimmer that animates
|
||||
/// from left to right with the given `shimmer_color`.
|
||||
///
|
||||
/// See [`ShimmerConfig`] for adjusting configuration options, such as the duration, size, and
|
||||
/// frequency of the shimmer.
|
||||
pub struct ShimmeringTextElement {
|
||||
text: Cow<'static, str>,
|
||||
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
|
||||
base_color: ColorU,
|
||||
shimmer_color: ColorU,
|
||||
|
||||
config: ShimmerConfig,
|
||||
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
|
||||
handle: ShimmeringTextStateHandle,
|
||||
}
|
||||
|
||||
impl ShimmeringTextElement {
|
||||
pub fn new(
|
||||
text: impl Into<Cow<'static, str>>,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
base_color: ColorU,
|
||||
shimmer_color: ColorU,
|
||||
config: ShimmerConfig,
|
||||
state_handle: ShimmeringTextStateHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
font_family,
|
||||
font_size,
|
||||
base_color,
|
||||
shimmer_color,
|
||||
config,
|
||||
size: None,
|
||||
origin: None,
|
||||
handle: state_handle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the center of the shimmer as a fractional glyph index along the "track".
|
||||
fn shimmer_center(&self, number_of_glyphs: usize, state: &StateInternal) -> GlyphIndex<f32> {
|
||||
if number_of_glyphs <= 1 {
|
||||
return GlyphIndex(0.0);
|
||||
}
|
||||
|
||||
let period_s = self.config.period.as_secs_f32();
|
||||
let elapsed_s = state.animation_start_time.elapsed().as_secs_f32();
|
||||
// Get the percent of the way through we are of the current loop.
|
||||
let progress = (elapsed_s / period_s).fract();
|
||||
|
||||
// Compute the total number of glyphs the band needs to travel.
|
||||
let span = (number_of_glyphs as f32 - 1.0) + (2.0 * self.config.padding as f32);
|
||||
// Get the fractional glyph index for the center of the band, factoring in that the center
|
||||
// can be negative (before any of the text)
|
||||
GlyphIndex((progress * span) - self.config.padding as f32)
|
||||
}
|
||||
|
||||
/// Returns how strong the shimmer effect should be for a given glyph based on how far it is
|
||||
/// from the center of the shimmer.
|
||||
fn intensity_at(&self, glyph_index: GlyphIndex<usize>, center: GlyphIndex<f32>) -> f32 {
|
||||
let dist = (glyph_index.as_f32().0 - center.0).abs();
|
||||
// If the distance is greater than the size of the band, there's no intensity.
|
||||
if dist >= self.config.shimmer_radius as f32 {
|
||||
return 0.0;
|
||||
}
|
||||
// Use a cosine wave to generate the intensity otherwise and normalize it to [0,1].
|
||||
let theta = (dist / self.config.shimmer_radius as f32) * PI;
|
||||
(theta.cos() + 1.0) * 0.5
|
||||
}
|
||||
|
||||
fn glyph_index_to_character_index_map(line: &Line) -> HashMap<GlyphIndex<usize>, CharOffset> {
|
||||
line.runs
|
||||
.iter()
|
||||
.flat_map(|run| run.glyphs.iter())
|
||||
.enumerate()
|
||||
.map(|(glyph_index, glyph)| (GlyphIndex(glyph_index), CharOffset::from(glyph.index)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_color_overrides(&self) -> PaintStyleOverride {
|
||||
let state = self.handle.get();
|
||||
|
||||
let glyph_indices_in_order = &state.glyph_indices_in_order;
|
||||
|
||||
let n = glyph_indices_in_order.len();
|
||||
if n == 0 {
|
||||
return PaintStyleOverride::default();
|
||||
}
|
||||
|
||||
let center = self.shimmer_center(n, &state);
|
||||
|
||||
let mut overrides = RangeMap::new();
|
||||
for (glyph_index, char_index) in glyph_indices_in_order.iter() {
|
||||
let intensity = self.intensity_at(*glyph_index, center);
|
||||
let color = self
|
||||
.base_color
|
||||
.to_f32()
|
||||
.lerp(self.shimmer_color.to_f32(), intensity)
|
||||
.to_u8();
|
||||
overrides.insert(char_index.as_usize()..char_index.as_usize() + 1, color);
|
||||
}
|
||||
|
||||
PaintStyleOverride::default().with_color(overrides)
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ShimmeringTextElement {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut crate::LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let mut state = self.handle.get();
|
||||
|
||||
let text_len = self.text.chars().count();
|
||||
let styles = [(
|
||||
0..text_len,
|
||||
StyleAndFont::new(self.font_family, Properties::default(), TextStyle::new()),
|
||||
)];
|
||||
|
||||
let max_width = constraint.max_along(Axis::Horizontal);
|
||||
|
||||
let layout_key = LayoutKey {
|
||||
text: self.text.clone(),
|
||||
font_family: self.font_family,
|
||||
font_size: self.font_size,
|
||||
max_width,
|
||||
};
|
||||
|
||||
// Determine whether we need to relayout the text.
|
||||
let line = match state.laid_out_line.clone() {
|
||||
Some(line) if Some(&layout_key) == state.laid_out_key.as_ref() => line,
|
||||
_ => {
|
||||
let line = ctx.text_layout_cache.layout_line(
|
||||
self.text.as_ref(),
|
||||
LineStyle {
|
||||
font_size: self.font_size,
|
||||
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
&styles,
|
||||
max_width,
|
||||
ClipConfig::default(),
|
||||
&app.font_cache().text_layout_system(),
|
||||
);
|
||||
|
||||
// Restart the animation if the font or font size has changed.
|
||||
let should_restart_animation = match (&layout_key, state.laid_out_key.as_ref()) {
|
||||
(new_layout_key, Some(old_layout_key)) => {
|
||||
new_layout_key.font_family != old_layout_key.font_family
|
||||
|| new_layout_key.font_size != old_layout_key.font_size
|
||||
|| new_layout_key.text != old_layout_key.text
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
|
||||
if should_restart_animation {
|
||||
state.animation_start_time = Instant::now();
|
||||
}
|
||||
|
||||
state.glyph_indices_in_order = Self::glyph_index_to_character_index_map(&line);
|
||||
state.laid_out_line = Some(line.clone());
|
||||
state.laid_out_key = Some(layout_key);
|
||||
|
||||
line
|
||||
}
|
||||
};
|
||||
|
||||
let size = vec2f(
|
||||
line.width.max(constraint.min.x()).min(constraint.max.x()),
|
||||
line.height(),
|
||||
);
|
||||
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, _: &mut crate::AfterLayoutContext, _: &AppContext) {}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
/// Duration, in ms, for which to repaint. Approximately 30fps.
|
||||
const REPAINT_DURATION: u64 = 32;
|
||||
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
|
||||
let Some(size) = self.size else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(line) = self.handle.get().laid_out_line.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.repaint_after(Duration::from_millis(REPAINT_DURATION));
|
||||
|
||||
let bounds = RectF::from_points(origin, origin + size);
|
||||
let style_overrides = self.build_color_overrides();
|
||||
|
||||
line.paint(
|
||||
bounds,
|
||||
&style_overrides,
|
||||
self.base_color,
|
||||
app.font_cache(),
|
||||
ctx.scene,
|
||||
);
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_: &crate::event::DispatchedEvent,
|
||||
_: &mut crate::EventContext,
|
||||
_: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for the ShimmeringText element.
|
||||
///
|
||||
/// The shimmer moves through each glyph in the text with a configurable number of "padding"
|
||||
/// surrounding the text to ensure the shimmer moves smoothly into and out of the text.
|
||||
///
|
||||
/// For example: Consider if the text is "foo" with the following configuration options:
|
||||
/// * `period`: 2s
|
||||
/// * `shimmer_radius`: 6
|
||||
/// * `padding`: 8
|
||||
/// This would mean that the shimmer would travel 19 glyphs (the
|
||||
/// padding + the 3 characters in the text) over the course of 2 seconds. Any glyph within 6 glyphs
|
||||
/// of the center will be considered part of the shimmer.
|
||||
/// NOTE this means that part of the shimmer would span a glyph range that isn't visible to the user.
|
||||
/// This is purposeful so that the shimmer smoothly moves into and out of the text range.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShimmerConfig {
|
||||
/// How long the shimmer should take from the start to the end of the track.
|
||||
pub period: Duration,
|
||||
/// The radius of the shimmer in fractional glyphs. Any glyph more than this distance away from
|
||||
/// the center of the shimmer is displayed with no intensity.
|
||||
pub shimmer_radius: usize,
|
||||
/// Any extra padding of the shimmer, in fractional glyphs. Padding is added around the overall
|
||||
/// laid out glyphs to ensure the shimmer is smooth as it enters and exits the text.
|
||||
pub padding: usize,
|
||||
}
|
||||
|
||||
impl Default for ShimmerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
period: Duration::from_secs(3),
|
||||
shimmer_radius: 6,
|
||||
padding: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub(super) struct GlyphIndex<T>(pub(super) T);
|
||||
|
||||
impl GlyphIndex<usize> {
|
||||
pub(super) fn as_f32(&self) -> GlyphIndex<f32> {
|
||||
GlyphIndex(self.0 as f32)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use super::Point;
|
||||
use crate::{
|
||||
event::DispatchedEvent, AfterLayoutContext, AppContext, Element, EventContext, LayoutContext,
|
||||
PaintContext, SizeConstraint,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Size {
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
/// Conditions on the element's [`SizeConstraint`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum SizeConstraintCondition {
|
||||
/// A condition in which the [`SizeConstraint`] is valid if its max width is less than the
|
||||
/// contained f32.
|
||||
WidthLessThan(f32),
|
||||
|
||||
/// A condition in which the [`SizeConstraint`] is valid if its max height is less than the
|
||||
/// contained f32.
|
||||
HeightLessThan(f32),
|
||||
|
||||
/// A condition in which the [`SizeConstraint`] is valid if its max width and height are _both_
|
||||
/// less than width and height in the contained [`Size`].
|
||||
SizeSmallerThan(Size),
|
||||
}
|
||||
|
||||
impl SizeConstraintCondition {
|
||||
fn is_valid_size_constraint(&self, constraint: &SizeConstraint) -> bool {
|
||||
match self {
|
||||
SizeConstraintCondition::WidthLessThan(max_width) => constraint.max.x() < *max_width,
|
||||
SizeConstraintCondition::HeightLessThan(max_height) => constraint.max.y() < *max_height,
|
||||
SizeConstraintCondition::SizeSmallerThan(Size {
|
||||
width: max_width,
|
||||
height: max_height,
|
||||
}) => constraint.max.x() < *max_width && constraint.max.y() < *max_height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Element that determines which child element to render based on its [`SizeConstraint`]. This
|
||||
/// element may be used to implement responsive layouts for different window sizes.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let switch = SizeConstraintSwitch::new(default_element, [
|
||||
/// (SizeConstraintCondition::WidthLessThan(400.), narrow_width_element)
|
||||
/// (SizeConstraintCondition::WidthLessThan(800.), medium_width_element)
|
||||
/// ]);
|
||||
/// ```
|
||||
pub struct SizeConstraintSwitch {
|
||||
default_child: Box<dyn Element>,
|
||||
children: Vec<(SizeConstraintCondition, Box<dyn Element>)>,
|
||||
active_child_index: Option<usize>,
|
||||
|
||||
/// A cached copy of the [`SizeConstraint`] passed to the element's most recent `layout()`
|
||||
/// call. `None` if `layout()` has not yet been called on this element.
|
||||
///
|
||||
/// This is used to ensure that the correct child is `paint()`-ed when the element's
|
||||
/// [`SizeConstraint`] changes during the element's lifetime.
|
||||
cached_size_constraint: Option<SizeConstraint>,
|
||||
}
|
||||
|
||||
impl SizeConstraintSwitch {
|
||||
/// Children's [`SizeConstraintCondition`]s are checked in the order that they are passed into
|
||||
/// this constructor. If more than one child's condition is satisfied, the child that appeared
|
||||
/// earlier in the `children` argument will be rendered.
|
||||
pub fn new(
|
||||
default_child: Box<dyn Element>,
|
||||
children: impl Into<Vec<(SizeConstraintCondition, Box<dyn Element>)>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
default_child,
|
||||
children: children.into(),
|
||||
active_child_index: None,
|
||||
cached_size_constraint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the child that should be rendered.
|
||||
fn active_child(&self) -> &dyn Element {
|
||||
self.active_child_index
|
||||
.and_then(|index| self.children.get(index).map(|child| &child.1))
|
||||
.unwrap_or(&self.default_child)
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the child that should be rendered.
|
||||
fn active_child_mut(&mut self) -> &mut dyn Element {
|
||||
self.active_child_index
|
||||
.and_then(|index| self.children.get_mut(index))
|
||||
.map(|(_, child)| child.as_mut())
|
||||
.unwrap_or_else(|| self.default_child.as_mut())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for SizeConstraintSwitch {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
if self.cached_size_constraint.map(|constraint| constraint.max) != Some(constraint.max)
|
||||
|| self.cached_size_constraint.map(|constraint| constraint.min) != Some(constraint.min)
|
||||
{
|
||||
self.active_child_index = self.children.iter().position(|(constraint_condition, _)| {
|
||||
constraint_condition.is_valid_size_constraint(&constraint)
|
||||
});
|
||||
}
|
||||
self.cached_size_constraint = Some(constraint);
|
||||
self.active_child_mut().layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.active_child_mut().after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.active_child_mut().paint(origin, ctx, app)
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.active_child_mut().dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.active_child().size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.active_child().origin()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.active_child().debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "size_constraint_switch_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,274 @@
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
elements::{ChildView, ConstrainedBox, Rect},
|
||||
platform::WindowStyle,
|
||||
App, Entity, TypedActionView, View, ViewContext, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
lazy_static! {
|
||||
static ref PARENT_SIZE_FOR_DEFAULT_CHILD: Vector2F = vec2f(600., 600.);
|
||||
static ref PARENT_SIZE_FOR_CONDITIONAL_CHILD: Vector2F = vec2f(400., 400.);
|
||||
static ref DEFAULT_CHILD_SIZE: Vector2F = vec2f(300., 300.);
|
||||
static ref CONDITIONAL_CHILD_SIZE: Vector2F = vec2f(200., 200.);
|
||||
}
|
||||
|
||||
const CONDIITIONAL_CHILD_THRESHOLD: f32 = 500.;
|
||||
|
||||
struct TestChildView {
|
||||
condition: SizeConstraintCondition,
|
||||
}
|
||||
|
||||
impl TestChildView {
|
||||
fn new(condition: SizeConstraintCondition) -> Self {
|
||||
Self { condition }
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TestChildView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestChildView {
|
||||
fn ui_name() -> &'static str {
|
||||
"SizeConstraintSwitch::tests::TestChildView"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
SizeConstraintSwitch::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(DEFAULT_CHILD_SIZE.x())
|
||||
.with_height(DEFAULT_CHILD_SIZE.y())
|
||||
.finish(),
|
||||
vec![(
|
||||
self.condition,
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(CONDITIONAL_CHILD_SIZE.x())
|
||||
.with_height(CONDITIONAL_CHILD_SIZE.y())
|
||||
.finish(),
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRootView {
|
||||
parent_size: Vector2F,
|
||||
child_handle: ViewHandle<TestChildView>,
|
||||
}
|
||||
|
||||
impl TestRootView {
|
||||
pub fn new(
|
||||
parent_size: Vector2F,
|
||||
condition: SizeConstraintCondition,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let child_handle = ctx.add_view(|_ctx| TestChildView::new(condition));
|
||||
Self {
|
||||
parent_size,
|
||||
child_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TestRootView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestRootView {
|
||||
fn ui_name() -> &'static str {
|
||||
"SizeConstraintSwitch::tests::TestRootView"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(ChildView::new(&self.child_handle).finish())
|
||||
.with_max_width(self.parent_size.x())
|
||||
.with_max_height(self.parent_size.y())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestRootView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
fn assert_rendered_rect_with_size(app: &mut App, window_id: WindowId, size: Vector2F) {
|
||||
let presenter_ref = app
|
||||
.presenter(window_id)
|
||||
.expect("Test window should have a presenter since first frame is rendered.");
|
||||
let presenter = presenter_ref.borrow();
|
||||
let scene = presenter
|
||||
.scene()
|
||||
.expect("Presenter should have rendered a scene after the test_view was updated.");
|
||||
|
||||
assert_eq!(
|
||||
scene
|
||||
.layers()
|
||||
.collect_vec()
|
||||
.first()
|
||||
.unwrap()
|
||||
.rects
|
||||
.iter()
|
||||
.map(|r| { r.bounds.size() })
|
||||
.collect::<Vec<_>>(),
|
||||
vec![size]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_default_child_when_no_conditions_match() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestRootView::new(
|
||||
*PARENT_SIZE_FOR_DEFAULT_CHILD,
|
||||
SizeConstraintCondition::WidthLessThan(CONDIITIONAL_CHILD_THRESHOLD),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_element_with_max_width_condition() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestRootView::new(
|
||||
*PARENT_SIZE_FOR_DEFAULT_CHILD,
|
||||
SizeConstraintCondition::WidthLessThan(CONDIITIONAL_CHILD_THRESHOLD),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
|
||||
test_view.update(app, |test_view, ctx| {
|
||||
test_view.parent_size = *PARENT_SIZE_FOR_CONDITIONAL_CHILD;
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *CONDITIONAL_CHILD_SIZE);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_element_with_max_height_condition() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestRootView::new(
|
||||
*PARENT_SIZE_FOR_DEFAULT_CHILD,
|
||||
SizeConstraintCondition::HeightLessThan(CONDIITIONAL_CHILD_THRESHOLD),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
|
||||
test_view.update(app, |test_view, ctx| {
|
||||
test_view.parent_size = *PARENT_SIZE_FOR_CONDITIONAL_CHILD;
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *CONDITIONAL_CHILD_SIZE);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_element_with_max_size_condition() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestRootView::new(
|
||||
*PARENT_SIZE_FOR_DEFAULT_CHILD,
|
||||
SizeConstraintCondition::SizeSmallerThan(Size {
|
||||
width: CONDIITIONAL_CHILD_THRESHOLD,
|
||||
height: CONDIITIONAL_CHILD_THRESHOLD,
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
|
||||
test_view.update(app, |test_view, ctx| {
|
||||
test_view.parent_size = *PARENT_SIZE_FOR_CONDITIONAL_CHILD;
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *CONDITIONAL_CHILD_SIZE);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_condition_doesnt_match_with_valid_width_only() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestRootView::new(
|
||||
*PARENT_SIZE_FOR_DEFAULT_CHILD,
|
||||
SizeConstraintCondition::SizeSmallerThan(Size {
|
||||
width: CONDIITIONAL_CHILD_THRESHOLD,
|
||||
height: CONDIITIONAL_CHILD_THRESHOLD,
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
|
||||
test_view.update(app, |test_view, ctx| {
|
||||
test_view.parent_size = vec2f(
|
||||
PARENT_SIZE_FOR_CONDITIONAL_CHILD.x(),
|
||||
test_view.parent_size.y(),
|
||||
);
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_condition_doesnt_match_with_valid_height_only() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
TestRootView::new(
|
||||
*PARENT_SIZE_FOR_DEFAULT_CHILD,
|
||||
SizeConstraintCondition::SizeSmallerThan(Size {
|
||||
width: CONDIITIONAL_CHILD_THRESHOLD,
|
||||
height: CONDIITIONAL_CHILD_THRESHOLD,
|
||||
}),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
test_view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
|
||||
test_view.update(app, |test_view, ctx| {
|
||||
test_view.parent_size = vec2f(
|
||||
test_view.parent_size.x(),
|
||||
PARENT_SIZE_FOR_CONDITIONAL_CHILD.y(),
|
||||
);
|
||||
ctx.notify();
|
||||
});
|
||||
assert_rendered_rect_with_size(app, window_id, *DEFAULT_CHILD_SIZE);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Stack lets you render multiple elements "on top of each other". It lets you offset elements
|
||||
//! from the parent element or between different layers in one Stack, etc.
|
||||
//!
|
||||
//! Stacks order their elements in z-space using layers.
|
||||
//! E.g.
|
||||
//! stack 1
|
||||
//! --> start layer 1
|
||||
//! child 1
|
||||
//! <-- stop layer 1
|
||||
//! --> start layer 2
|
||||
//! stack 2
|
||||
//! --> start layer 3
|
||||
//! child 2
|
||||
//! <-- stop layer 3
|
||||
//! --> start layer 4
|
||||
//! child 3
|
||||
//! <-- stop layer 4
|
||||
//! <-- stop layer 2
|
||||
//! --> start layer 5
|
||||
//! child 4
|
||||
//! <-- stop layer 5
|
||||
//!
|
||||
//! Note that by default, all Stack's children contribute to its final size computation. For
|
||||
//! example, if you wanted to render a small square, and then a bigger translucent square that
|
||||
//! covers it.
|
||||
//! However, if you'd rather have them arranged differently, use `Stack::add_positioned_child`. The
|
||||
//! simple way of thinking about the two is that using `Stack::add_child` renders a stack as if all
|
||||
//! the layers were "merged" together, while positioned children are rendered as separate layers.
|
||||
//! More context here: https://medium.flutterdevs.com/stack-and-positioned-widget-in-flutter-3d1a7b30b09a
|
||||
|
||||
mod offset_positioning;
|
||||
mod overlay;
|
||||
mod positioned;
|
||||
mod save_position;
|
||||
|
||||
pub use offset_positioning::*;
|
||||
use overlay::Overlay;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use positioned::*;
|
||||
pub use save_position::*;
|
||||
|
||||
use crate::{
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SelectableElement, Selection, SelectionFragment, SizeConstraint,
|
||||
};
|
||||
use crate::ClipBounds;
|
||||
use log::warn;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub enum EventDispatchMode {
|
||||
/// Current behavior: dispatch event to every child regardless of
|
||||
/// whether a prior child already handled it.
|
||||
#[default]
|
||||
Broadcast,
|
||||
/// Waterfall: stop dispatching to subsequent children once one
|
||||
/// reports the event as handled.
|
||||
Waterfall,
|
||||
}
|
||||
|
||||
struct StackChild {
|
||||
element: Box<dyn Element>,
|
||||
painted: bool,
|
||||
}
|
||||
|
||||
impl StackChild {
|
||||
fn new(element: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
element,
|
||||
painted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Stack {
|
||||
children: Vec<StackChild>,
|
||||
size: Option<Vector2F>,
|
||||
origin: Option<Point>,
|
||||
constrain_absolute_children: bool,
|
||||
event_dispatch_mode: EventDispatchMode,
|
||||
}
|
||||
|
||||
/// Since this is in the UI package, I can't access feature flags.
|
||||
/// We can flip this bool to disable this functionality if we
|
||||
/// run into any other regressions. When false, all stacks will constrain
|
||||
/// their absolute positioned children, which is behavior we'd like to move
|
||||
/// away from.
|
||||
const SHOULD_ENABLE_NEW_STACK_CONSTRAINT_BEHAVIOR: bool = true;
|
||||
|
||||
impl Stack {
|
||||
pub fn new() -> Self {
|
||||
Stack {
|
||||
children: Default::default(),
|
||||
size: Default::default(),
|
||||
origin: Default::default(),
|
||||
constrain_absolute_children: !SHOULD_ENABLE_NEW_STACK_CONSTRAINT_BEHAVIOR,
|
||||
event_dispatch_mode: if cfg!(debug_assertions) {
|
||||
EventDispatchMode::Waterfall
|
||||
} else {
|
||||
EventDispatchMode::Broadcast
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_constrain_absolute_children(mut self) -> Self {
|
||||
self.constrain_absolute_children = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_event_dispatch_mode(mut self, mode: EventDispatchMode) -> Self {
|
||||
self.event_dispatch_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a new child to the stack with a specific positioning.
|
||||
pub fn with_positioned_child(
|
||||
mut self,
|
||||
child: Box<dyn Element>,
|
||||
positioning: OffsetPositioning,
|
||||
) -> Self {
|
||||
self.add_positioned_child(child, positioning);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a new child to the stack with a specific positioning.
|
||||
pub fn add_positioned_child(
|
||||
&mut self,
|
||||
child: Box<dyn Element>,
|
||||
positioning: OffsetPositioning,
|
||||
) {
|
||||
self.extend(Some(
|
||||
Positioned::new(child).with_offset(positioning).finish(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Add a new child to the stack as an overlay
|
||||
///
|
||||
/// The child (and its children) will be layered above the normal UI elements. This will allow
|
||||
/// it to float above the rest of the UI—useful for things like dropdowns and menus. The new
|
||||
/// layer will be unclipped by default.
|
||||
pub fn add_overlay_child(&mut self, child: Box<dyn Element>) {
|
||||
self.extend(Some(Overlay::new(child).finish()));
|
||||
}
|
||||
|
||||
/// Add a new child to the stack as an overlay with a specific positioning
|
||||
///
|
||||
/// The child (and its children) will be layered above the normal UI elements. This will allow
|
||||
/// it to float above the rest of the UI—useful for things like dropdowns and menus. The new
|
||||
/// layer will be unclipped by default.
|
||||
pub fn with_positioned_overlay_child(
|
||||
mut self,
|
||||
child: Box<dyn Element>,
|
||||
positioning: OffsetPositioning,
|
||||
) -> Self {
|
||||
self.add_positioned_overlay_child(child, positioning);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a new child to the stack as an overlay with a specific positioning
|
||||
///
|
||||
/// The child (and its children) will be layered above the normal UI elements. This will allow
|
||||
/// it to float above the rest of the UI—useful for things like dropdowns and menus. The new
|
||||
/// layer will be unclipped by default.
|
||||
pub fn add_positioned_overlay_child(
|
||||
&mut self,
|
||||
child: Box<dyn Element>,
|
||||
positioning: OffsetPositioning,
|
||||
) {
|
||||
self.add_positioned_child(Overlay::new(child).finish(), positioning);
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Stack {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
let mut size = constraint.min;
|
||||
for child in &mut self.children {
|
||||
if child
|
||||
.element
|
||||
.parent_data()
|
||||
.and_then(|d| d.downcast_ref::<OffsetPositioning>())
|
||||
.is_none()
|
||||
{
|
||||
// Only take child size into account if it's not an absolutely positioned element.
|
||||
// (Absolutely positioned elements must have an `OffsetPositioning` parent_data.
|
||||
size = size.max(child.element.layout(constraint, ctx, app));
|
||||
}
|
||||
}
|
||||
|
||||
let absolute_constraints = if self.constrain_absolute_children {
|
||||
constraint
|
||||
} else {
|
||||
SizeConstraint::new(Vector2F::zero(), ctx.window_size)
|
||||
};
|
||||
for child in &mut self.children {
|
||||
if let Some(offset_positioning) = child
|
||||
.element
|
||||
.parent_data()
|
||||
.and_then(|d| d.downcast_ref::<OffsetPositioning>())
|
||||
{
|
||||
child.element.layout(
|
||||
offset_positioning.size_constraint(
|
||||
size,
|
||||
ctx.window_size,
|
||||
absolute_constraints,
|
||||
ctx.position_cache,
|
||||
),
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
for child in &mut self.children {
|
||||
child.element.after_layout(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
let parent_rect = self.bounds().unwrap();
|
||||
for child in &mut self.children {
|
||||
ctx.scene.start_layer(ClipBounds::ActiveLayer);
|
||||
ctx.position_cache.start();
|
||||
let child_origin = if let Some(offset_positioning) = child
|
||||
.element
|
||||
.parent_data()
|
||||
.and_then(|d| d.downcast_ref::<OffsetPositioning>())
|
||||
{
|
||||
let child_size = child.element.size().unwrap();
|
||||
match (
|
||||
offset_positioning.x_axis.compute_child_position(
|
||||
child_size,
|
||||
parent_rect,
|
||||
ctx.window_size,
|
||||
ctx.position_cache,
|
||||
),
|
||||
offset_positioning.y_axis.compute_child_position(
|
||||
child_size,
|
||||
parent_rect,
|
||||
ctx.window_size,
|
||||
ctx.position_cache,
|
||||
),
|
||||
) {
|
||||
(Ok(x), Ok(y)) => vec2f(x, y),
|
||||
(x_res, y_res) => {
|
||||
// Log a warning when position computation fails.
|
||||
// This can happen when conditional positioning fails or when position cache
|
||||
// doesn't have the required position data.
|
||||
if !offset_positioning.x_axis.anchor.is_conditional()
|
||||
&& !offset_positioning.y_axis.anchor.is_conditional()
|
||||
{
|
||||
warn!(
|
||||
"Failed to compute position for stack child element. Skipping child. X: {x_res:?}, Y: {y_res:?}."
|
||||
);
|
||||
}
|
||||
|
||||
ctx.position_cache.end();
|
||||
ctx.scene.stop_layer();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
origin
|
||||
};
|
||||
|
||||
child.element.paint(child_origin, ctx, app);
|
||||
child.painted = true;
|
||||
|
||||
ctx.position_cache.end();
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let mut handled = false;
|
||||
|
||||
match self.event_dispatch_mode {
|
||||
EventDispatchMode::Broadcast => {
|
||||
for child in self.children.iter_mut() {
|
||||
// We should not dispatch event to children that are not painted.
|
||||
if child.painted {
|
||||
handled |= child.element.dispatch_event(event, ctx, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
EventDispatchMode::Waterfall => {
|
||||
// For waterfall, we want to dispatch event to children in the reverse order (top first).
|
||||
for child in self.children.iter_mut().rev() {
|
||||
// We should not dispatch event to children that are not painted.
|
||||
if child.painted && child.element.dispatch_event(event, ctx, app) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
let texts: Vec<String> = self
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|child| child.element.debug_text_content())
|
||||
.collect();
|
||||
if texts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(texts.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for Stack {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
let mut selection_fragments = Vec::new();
|
||||
for child in self.children.iter() {
|
||||
if let Some(selectable_child) = child.element.as_selectable_element() {
|
||||
if let Some(child_fragments) =
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
{
|
||||
selection_fragments.extend(child_fragments);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !selection_fragments.is_empty() {
|
||||
return Some(selection_fragments);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
for child in self.children.iter() {
|
||||
if let Some(selectable_child) = child.element.as_selectable_element() {
|
||||
if let Some(selection) = selectable_child.expand_selection(
|
||||
point,
|
||||
direction,
|
||||
unit,
|
||||
word_boundaries_policy,
|
||||
) {
|
||||
return Some(selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
for child in self.children.iter() {
|
||||
if let Some(selectable_child) = child.element.as_selectable_element() {
|
||||
if let Some(is_point_semantically_before) = selectable_child
|
||||
.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
{
|
||||
return Some(is_point_semantically_before);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
for child in self.children.iter() {
|
||||
if let Some(selectable_child) = child.element.as_selectable_element() {
|
||||
if let Some(selection) =
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
{
|
||||
return Some(selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
let mut clickable_bounds = Vec::new();
|
||||
for child in self.children.iter() {
|
||||
if let Some(selectable_child) = child.element.as_selectable_element() {
|
||||
clickable_bounds
|
||||
.append(&mut selectable_child.calculate_clickable_bounds(current_selection));
|
||||
}
|
||||
}
|
||||
clickable_bounds
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<Box<dyn Element>> for Stack {
|
||||
fn extend<T: IntoIterator<Item = Box<dyn Element>>>(&mut self, children: T) {
|
||||
self.children
|
||||
.extend(children.into_iter().map(StackChild::new))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,798 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::{
|
||||
elements::{Clipped, DispatchEventResult},
|
||||
platform::WindowStyle,
|
||||
TypedActionView,
|
||||
};
|
||||
use crate::{
|
||||
elements::{ConstrainedBox, EventHandler, ParentElement, Rect, ZIndex},
|
||||
App, AppContext, Entity, Event, Presenter, ViewContext, ViewHandle, WindowId,
|
||||
WindowInvalidation,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
// maps view id to number of mouse downs
|
||||
mouse_downs: HashMap<usize, u32>,
|
||||
mouse_ups: HashMap<usize, u32>,
|
||||
mouse_dragged: HashMap<usize, u32>,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_action("test_view:mouse_down", View::mouse_down);
|
||||
app.add_action("test_view:mouse_up", View::mouse_up);
|
||||
app.add_action("test_view:mouse_dragged", View::mouse_dragged);
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn mouse_down(&mut self, view_id: &usize, _ctx: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_down on view_id {view_id}");
|
||||
let entry = self.mouse_downs.entry(*view_id).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn mouse_up(&mut self, view_id: &usize, _ctx: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_up on view_id {view_id}");
|
||||
let entry = self.mouse_ups.entry(*view_id).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn mouse_dragged(&mut self, view_id: &usize, _ctx: &mut ViewContext<Self>) -> bool {
|
||||
log::info!("Recording mouse_dragged on view_id {view_id}");
|
||||
let entry = self.mouse_dragged.entry(*view_id).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = String;
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut s = Stack::new();
|
||||
s.add_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_down", 0usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_left_mouse_up(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_up", 0usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_dragged(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_dragged", 0usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
s.add_child(
|
||||
Positioned::new(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_down", 1usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_left_mouse_up(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_up", 1usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_dragged(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_dragged", 1usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_offset(OffsetPositioning::offset_from_parent(
|
||||
vec2f(25., 25.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
))
|
||||
.finish(),
|
||||
);
|
||||
s.add_child(
|
||||
Positioned::new(
|
||||
Clipped::sized(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_down", 2usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_left_mouse_up(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_up", 2usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.on_mouse_dragged(|evt_ctx, _ctx, _position| {
|
||||
evt_ctx.dispatch_action("test_view:mouse_dragged", 2usize);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
vec2f(25., 25.),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_offset(OffsetPositioning::offset_from_parent(
|
||||
vec2f(100., 100.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
))
|
||||
.finish(),
|
||||
);
|
||||
s.finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST_CHILD_POSITION_ID: &str = "RelativePositionedView::first_child_position_id";
|
||||
|
||||
/// A view for testing that renders the second child in a stack based on what's specified in
|
||||
/// `second_child_positioning`.
|
||||
#[derive(Default)]
|
||||
struct RelativePositionedView {
|
||||
second_child_positioning: Option<OffsetPositioning>,
|
||||
second_child_size: Option<Vector2F>,
|
||||
}
|
||||
|
||||
impl RelativePositionedView {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
second_child_positioning: None,
|
||||
second_child_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn first_child_position_id() -> &'static str {
|
||||
FIRST_CHILD_POSITION_ID
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RelativePositionedView {
|
||||
type Event = String;
|
||||
}
|
||||
|
||||
impl crate::core::View for RelativePositionedView {
|
||||
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
let mut s = Stack::new();
|
||||
s.add_child(
|
||||
SavePosition::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish(),
|
||||
FIRST_CHILD_POSITION_ID,
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(second_child_positioning) = &self.second_child_positioning {
|
||||
s.add_child(
|
||||
Positioned::new(if let Some(second_child_size) = &self.second_child_size {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_width(second_child_size.x())
|
||||
.with_height(second_child_size.y())
|
||||
.finish()
|
||||
} else {
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(50.)
|
||||
.with_width(50.)
|
||||
.finish()
|
||||
})
|
||||
.with_offset(second_child_positioning.clone())
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
// Force the Stack to take up the full size of the window by pulling
|
||||
// the minimum size constraint up to the size of the window.
|
||||
ConstrainedBox::new(s.finish())
|
||||
.with_min_width(f32::MAX)
|
||||
.with_min_height(f32::MAX)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for RelativePositionedView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paint_sets_z_index() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(300., 300.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 5);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Fire event on first child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDragged {
|
||||
position: vec2f(15., 15.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Fire event on second child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(30., 30.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(30., 30.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDragged {
|
||||
position: vec2f(30., 30.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Fire event on third child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(120., 120.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(120., 120.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDragged {
|
||||
position: vec2f(120., 120.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Fire event on clipped part of third child
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(140., 140.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseUp {
|
||||
position: vec2f(140., 140.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDragged {
|
||||
position: vec2f(140., 140.),
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _ctx| {
|
||||
assert_eq!(1, *view.mouse_downs.get(&0).unwrap());
|
||||
assert_eq!(1, *view.mouse_downs.get(&1).unwrap());
|
||||
assert_eq!(1, *view.mouse_downs.get(&2).unwrap());
|
||||
assert_eq!(1, *view.mouse_ups.get(&0).unwrap());
|
||||
assert_eq!(1, *view.mouse_ups.get(&1).unwrap());
|
||||
assert_eq!(1, *view.mouse_ups.get(&2).unwrap());
|
||||
assert_eq!(1, *view.mouse_dragged.get(&0).unwrap());
|
||||
assert_eq!(1, *view.mouse_dragged.get(&1).unwrap());
|
||||
assert_eq!(1, *view.mouse_dragged.get(&2).unwrap());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_positioning() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
RelativePositionedView::new()
|
||||
});
|
||||
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
vec2f(25., 25.),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
PositionedElementAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
RectF::new(vec2f(25., 25.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Update the view to position the top right of the child offset from the top right of
|
||||
// the parent (this should mean part of the child is clipped offscreen on the left).
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::offset_from_save_position_element(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
vec2f(25., 25.),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
PositionedElementAnchor::TopLeft,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
RectF::new(vec2f(-25., 25.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Offset with the same position, but bound horizontally to the parent so the element is
|
||||
// no longer clipped past the left side of the screen.
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Right),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
RectF::new(vec2f(0., 25.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Now just bound vertically to the parent. This should not change the positioning since
|
||||
// the element is already bound vertically within teh parent.
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Right),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
RectF::new(vec2f(-25., 25.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Update the view to position the top left of the child offset from the top right of the
|
||||
// parent.
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
RectF::new(vec2f(75., 25.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Now, bound vertically with the parent--this should have no effect here since the
|
||||
// child is fully contained within its parent.
|
||||
let new_positioning = OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(25.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
);
|
||||
|
||||
position_child_and_assert_location(
|
||||
new_positioning,
|
||||
RectF::new(vec2f(75., 25.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Position the child's bottom right corner on the parent's bottom right corner. With
|
||||
// no offset this means they should be stacked directly on top of each other.
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Right),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Bottom),
|
||||
),
|
||||
),
|
||||
RectF::new(vec2f(0., 0.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view.clone(),
|
||||
);
|
||||
|
||||
// Align the child vertically from the parent and horizontally from the child.
|
||||
position_child_and_assert_location(
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(5.),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(5.),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
),
|
||||
),
|
||||
RectF::new(vec2f(55., 5.), vec2f(50., 50.)),
|
||||
app,
|
||||
window_id,
|
||||
view,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_positioning_bound_to_window_by_size() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
RelativePositionedView::new()
|
||||
});
|
||||
let window_size = view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
ctx.windows()
|
||||
.platform_window(window_id)
|
||||
.expect("Window should exist for platform.")
|
||||
.size()
|
||||
});
|
||||
|
||||
let offset = vec2f(25., 25.);
|
||||
let positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
offset,
|
||||
PositionedElementOffsetBounds::WindowBySize,
|
||||
PositionedElementAnchor::BottomRight,
|
||||
ChildAnchor::TopLeft,
|
||||
);
|
||||
view.update(app, |view, ctx| {
|
||||
view.second_child_positioning = Some(positioning);
|
||||
|
||||
// Set the offset-positioned child's size to the window size so the bounding
|
||||
// behavior is actually tested.
|
||||
view.second_child_size = Some(window_size);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
// Simulate a render frame to ensure the scene is built.
|
||||
app.update(|ctx| ctx.simulate_render_frame(window_id));
|
||||
|
||||
let presenter_ref = app
|
||||
.presenter(window_id)
|
||||
.expect("Test window should have a presenter since first frame is rendered.");
|
||||
let presenter = presenter_ref.borrow();
|
||||
let scene = presenter
|
||||
.scene()
|
||||
.expect("Presenter should have rendered a scene after the view was updated.");
|
||||
|
||||
// The expected bounds should go from the anchor position with offset to the edge of
|
||||
// the window bounds. Note the usage of `RectF::from_points`, which specifies top-left
|
||||
// and bottom-right coordinates, rather than the default `RectF::new()` constructor.
|
||||
let expected_bounds = RectF::from_points(vec2f(75., 75.), window_size);
|
||||
assert_eq!(
|
||||
scene
|
||||
.layers()
|
||||
.collect_vec()
|
||||
.get(2)
|
||||
.unwrap()
|
||||
.rects
|
||||
.iter()
|
||||
.map(|r| { r.bounds })
|
||||
.collect::<Vec<_>>(),
|
||||
vec![expected_bounds]
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_positioning_bound_to_window_by_position() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
RelativePositionedView::new()
|
||||
});
|
||||
let window_size = view.update(app, |_, ctx| {
|
||||
ctx.notify();
|
||||
ctx.windows()
|
||||
.platform_window(window_id)
|
||||
.expect("Window should exist for platform.")
|
||||
.size()
|
||||
});
|
||||
|
||||
let offset = vec2f(25., 25.);
|
||||
let positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
RelativePositionedView::first_child_position_id(),
|
||||
offset,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
PositionedElementAnchor::BottomRight,
|
||||
ChildAnchor::TopLeft,
|
||||
);
|
||||
view.update(app, |view, ctx| {
|
||||
view.second_child_positioning = Some(positioning);
|
||||
|
||||
// Set the offset-positioned child's size to the window size so the bounding
|
||||
// behavior is actually tested.
|
||||
view.second_child_size = Some(window_size);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let presenter_ref = app
|
||||
.presenter(window_id)
|
||||
.expect("Test window should have a presenter since first frame is rendered.");
|
||||
let presenter = presenter_ref.borrow();
|
||||
let scene = presenter
|
||||
.scene()
|
||||
.expect("Presenter should have rendered a scene after the view was updated.");
|
||||
|
||||
// The expected bounds should have a modified position to accomodate the size of the
|
||||
// positioned child (it should be moved back to (0,0) from it's 'default' (75, 75).
|
||||
//
|
||||
// Note the usage of `RectF::from_points`, which specifies top-left
|
||||
// and bottom-right coordinates, rather than the default `RectF::new()` constructor.
|
||||
let expected_bounds = RectF::from_points(vec2f(0., 0.), window_size);
|
||||
assert_eq!(
|
||||
scene
|
||||
.layers()
|
||||
.collect_vec()
|
||||
.get(2)
|
||||
.unwrap()
|
||||
.rects
|
||||
.iter()
|
||||
.map(|r| { r.bounds })
|
||||
.collect::<Vec<_>>(),
|
||||
vec![expected_bounds]
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_positioning_bound_to_missing_anchor() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| {
|
||||
let mut view = RelativePositionedView::new();
|
||||
|
||||
view.second_child_positioning = Some(OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
"nonexistent_anchor",
|
||||
PositionedElementOffsetBounds::WindowBySize,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Middle, XAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
"nonexistent_anchor",
|
||||
PositionedElementOffsetBounds::WindowBySize,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
|
||||
)
|
||||
.with_conditional_anchor(),
|
||||
));
|
||||
|
||||
view
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let invalidation = WindowInvalidation {
|
||||
updated: HashSet::from([app.root_view_id(window_id).expect("Root view must exist")]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
|
||||
let window_size = RectF::new(Vector2F::zero(), vec2f(300., 300.));
|
||||
let scene = presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 3);
|
||||
|
||||
let stack_layer = scene.layers().nth(2).expect("Should be 3 layers");
|
||||
assert!(
|
||||
stack_layer.rects.is_empty(),
|
||||
"Relative-positioned element should not have been laid out"
|
||||
);
|
||||
// In addition to the assertion that there's no rect for the second
|
||||
// child, this implicitly tests that we don't panic during layout.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Positions the second child using the positioning and asserts the child is at bounds
|
||||
/// indicated within `expected_child_bounds`.
|
||||
fn position_child_and_assert_location(
|
||||
positioning: OffsetPositioning,
|
||||
expected_child_bounds: RectF,
|
||||
app: &mut App,
|
||||
window_id: WindowId,
|
||||
view: ViewHandle<RelativePositionedView>,
|
||||
) {
|
||||
view.update(app, |view, _| {
|
||||
view.second_child_positioning = Some(positioning);
|
||||
});
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let window_size = RectF::new(Vector2F::zero(), vec2f(300., 300.));
|
||||
let scene = presenter.build_scene(window_size.size(), 1., None, ctx);
|
||||
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 3);
|
||||
|
||||
assert_eq!(
|
||||
scene
|
||||
.layers()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.rects
|
||||
.iter()
|
||||
.map(|r| r.bounds)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![RectF::new(Vector2F::zero(), vec2f(50., 50.))]
|
||||
);
|
||||
assert_eq!(
|
||||
scene
|
||||
.layers()
|
||||
.nth(2)
|
||||
.unwrap()
|
||||
.rects
|
||||
.iter()
|
||||
.map(|r| { r.bounds })
|
||||
.collect::<Vec<_>>(),
|
||||
vec![expected_child_bounds]
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
use crate::{presenter::PositionCache, SizeConstraint};
|
||||
|
||||
/// Defines the positioning for an element in a stack on both X and Y axes relative to the parent of
|
||||
/// the stack or another child element within the stack. The child element is anchored to
|
||||
/// the other element by `Anchor` and then offset on both axes by the specified offset.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct OffsetPositioning {
|
||||
pub(super) x_axis: PositioningAxis<XAxisAnchor>,
|
||||
pub(super) y_axis: PositioningAxis<YAxisAnchor>,
|
||||
}
|
||||
|
||||
impl OffsetPositioning {
|
||||
pub fn from_axes(
|
||||
x_axis: PositioningAxis<XAxisAnchor>,
|
||||
y_axis: PositioningAxis<YAxisAnchor>,
|
||||
) -> Self {
|
||||
OffsetPositioning { x_axis, y_axis }
|
||||
}
|
||||
|
||||
/// Returns an `OffsetPositioning` that may be used to position a stack child element relative
|
||||
/// to the stack parent's bounding rectangle.
|
||||
///
|
||||
/// `bounds` specifies bounding behavior, if any, that should be used when calculating the stack
|
||||
/// child's final size and position. See docs on [`Bound`] for more detail.
|
||||
///
|
||||
/// `parent_anchor` is used to determine the exact position on the parent's bounding rectangle
|
||||
/// relative to which the child will be positioned, using the child_anchor-determined position
|
||||
/// on the child's bounding rect.
|
||||
///
|
||||
/// For example, to position the bottom right of the child offset from the top-left corner of
|
||||
/// the parent, pass `parent_anchor`: [`ParentAnchor::TopLeft`] and
|
||||
/// `child_anchor`: [`ChildAnchor::BottomRight`].
|
||||
pub fn offset_from_parent(
|
||||
offset: Vector2F,
|
||||
bound: ParentOffsetBounds,
|
||||
parent_anchor: ParentAnchor,
|
||||
child_anchor: ChildAnchor,
|
||||
) -> Self {
|
||||
let parent_anchor: Anchor = parent_anchor.into();
|
||||
let child_anchor: Anchor = child_anchor.into();
|
||||
Self::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
bound,
|
||||
OffsetType::Pixel(offset.x()),
|
||||
AnchorPair::new(parent_anchor.x(), child_anchor.x()),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
bound,
|
||||
OffsetType::Pixel(offset.y()),
|
||||
AnchorPair::new(parent_anchor.y(), child_anchor.y()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns an `OffsetPositioning` that may be used to position a stack child element relative
|
||||
/// to an arbitrary 'anchor' element that is wrapped and rendered within a [`SavePosition`].
|
||||
///
|
||||
/// `bound` specifies bounding behavior, if any, that should be used when calculating the stack
|
||||
/// child's final size and position. See docs on [`Bound`] for more detail.
|
||||
///
|
||||
/// `save_position_element_anchor` is used to determine the exact position on the anchor
|
||||
/// element's bounding rectangle relative to which the child will be positioned, using the
|
||||
/// child_anchor-determined position on the child's bounding rect.
|
||||
///
|
||||
/// For example, to position the bottom right of the child offset from the top-left corner of
|
||||
/// the anchor, pass `save_position_element_anchor`: [`PositionedElementAnchor::TopLeft`] and
|
||||
/// `child_anchor`: [`ChildAnchor::BottomRight`].
|
||||
pub fn offset_from_save_position_element(
|
||||
saved_position_id: impl Into<String>,
|
||||
offset: Vector2F,
|
||||
bounds: PositionedElementOffsetBounds,
|
||||
save_position_element_anchor: PositionedElementAnchor,
|
||||
child_anchor: ChildAnchor,
|
||||
) -> Self {
|
||||
let position_id = saved_position_id.into();
|
||||
let child_anchor: Anchor = child_anchor.into();
|
||||
let save_position_element_anchor: Anchor = save_position_element_anchor.into();
|
||||
Self::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
position_id.clone(),
|
||||
bounds,
|
||||
OffsetType::Pixel(offset.x()),
|
||||
AnchorPair::new(save_position_element_anchor.x(), child_anchor.x()),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
position_id,
|
||||
bounds,
|
||||
OffsetType::Pixel(offset.y()),
|
||||
AnchorPair::new(save_position_element_anchor.y(), child_anchor.y()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the size constraint to be used for the stack child, according to the
|
||||
/// anchors and bounding behaviors specified in the [`PositionAxis`]'s.
|
||||
pub fn size_constraint(
|
||||
&self,
|
||||
parent_size: Vector2F,
|
||||
window_size: Vector2F,
|
||||
default_constraint: SizeConstraint,
|
||||
position_cache: &PositionCache,
|
||||
) -> SizeConstraint {
|
||||
let default_max_width = default_constraint.max.x();
|
||||
let size_constraint_max_x = self
|
||||
.x_axis
|
||||
.compute_max_child_width(parent_size.x(), window_size.x(), position_cache)
|
||||
.unwrap_or_else(|err| {
|
||||
// In production, or if the element is conditionally-rendered by
|
||||
// its x-axis anchor, use the default max width instead of panicking.
|
||||
debug_assert!(
|
||||
self.x_axis.anchor.is_conditional(),
|
||||
"Couldn't compute max child width: {err}"
|
||||
);
|
||||
None
|
||||
})
|
||||
.unwrap_or(default_max_width);
|
||||
|
||||
let default_max_height = default_constraint.max.y();
|
||||
let size_constraint_max_y = self
|
||||
.y_axis
|
||||
.compute_max_child_height(parent_size.y(), window_size.y(), position_cache)
|
||||
.unwrap_or_else(|err| {
|
||||
debug_assert!(
|
||||
self.y_axis.anchor.is_conditional(),
|
||||
"Couldn't compute max child height: {err}"
|
||||
);
|
||||
None
|
||||
})
|
||||
.unwrap_or(default_max_height);
|
||||
|
||||
SizeConstraint {
|
||||
min: vec2f(
|
||||
default_constraint.min.x().min(size_constraint_max_x),
|
||||
default_constraint.min.y().min(size_constraint_max_y),
|
||||
),
|
||||
max: vec2f(size_constraint_max_x, size_constraint_max_y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounding behaviors that may be applied to parent-offset stack children (added via
|
||||
/// [`OffsetPositioning::offset_from_parent`].
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ParentOffsetBounds {
|
||||
/// The element's position may be adjusted to ensure it does not overflow its parent's bounds
|
||||
/// or the window's bounds. Size remains fixed.
|
||||
ParentByPosition,
|
||||
|
||||
/// The element's size may be adjusted to ensure it does not overflow its parent's bounds.
|
||||
/// Position remains fixed.
|
||||
ParentBySize,
|
||||
|
||||
/// The element's position may be adjusted to ensure it does not overflow the window's bounds.
|
||||
WindowByPosition,
|
||||
|
||||
/// The element's position and size is unbounded relative to its parent element or window.
|
||||
Unbounded,
|
||||
}
|
||||
|
||||
/// Bounding behaviors that may be applied to [`SavePosition`] element-offset stack children
|
||||
/// (added via [`OffsetPositioning::offset_from_save_position_element`].
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum PositionedElementOffsetBounds {
|
||||
/// The element's position may be adjusted to ensure it does not overflow its parent's bounds
|
||||
/// or the window's bounds. Size remains fixed.
|
||||
ParentByPosition,
|
||||
|
||||
/// The element's position may be adjusted to ensure it does not overflow the bound of the
|
||||
/// element it is anchored to. Size remains fixed.
|
||||
AnchoredElement,
|
||||
|
||||
/// The element's position may be adjusted to ensure it does not overflow the window's bounds.
|
||||
/// Size remains fixed.
|
||||
WindowByPosition,
|
||||
|
||||
/// The element's size may be adjusted to ensure it does not overflow the window's bounds.
|
||||
/// Position remains fixed.
|
||||
WindowBySize,
|
||||
|
||||
/// The element's position and size is unbounded relative to its parent element or window.
|
||||
Unbounded,
|
||||
}
|
||||
|
||||
/// An 'anchor' point on the child element representing the point on the child's bounding rectangle
|
||||
/// that should be positioned to the parent or [`SavePosition`]ed element.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ChildAnchor {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
TopMiddle,
|
||||
MiddleLeft,
|
||||
MiddleRight,
|
||||
Center,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
BottomMiddle,
|
||||
}
|
||||
|
||||
/// An 'anchor' point on the parent element representing the point on the parents's bounding
|
||||
/// rectangle that should be used in concert with offset to position the [`Stack`]'s child element.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ParentAnchor {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
TopMiddle,
|
||||
MiddleLeft,
|
||||
MiddleRight,
|
||||
Center,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
BottomMiddle,
|
||||
}
|
||||
|
||||
/// An 'anchor' point on the [`SavePosition`]ed element representing the point on its bounding
|
||||
/// rectangle that should be used in concert with offset to position the [`Stack`]'s child element.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum PositionedElementAnchor {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
TopMiddle,
|
||||
MiddleLeft,
|
||||
MiddleRight,
|
||||
Center,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
BottomMiddle,
|
||||
}
|
||||
|
||||
// An 'anchor' point on an element's bounding rectangle.
|
||||
//
|
||||
// A pair of [`Anchor`]s constitutes an [`AnchorPair`], which can be used to determine the position
|
||||
// of one element relative to another.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Anchor {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
TopMiddle,
|
||||
MiddleLeft,
|
||||
MiddleRight,
|
||||
Center,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
BottomMiddle,
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
fn x(&self) -> XAxisAnchor {
|
||||
match self {
|
||||
Anchor::TopLeft | Anchor::MiddleLeft | Anchor::BottomLeft => XAxisAnchor::Left,
|
||||
Anchor::TopRight | Anchor::MiddleRight | Anchor::BottomRight => XAxisAnchor::Right,
|
||||
Anchor::Center | Anchor::TopMiddle | Anchor::BottomMiddle => XAxisAnchor::Middle,
|
||||
}
|
||||
}
|
||||
|
||||
fn y(&self) -> YAxisAnchor {
|
||||
match self {
|
||||
Anchor::TopLeft | Anchor::TopRight | Anchor::TopMiddle => YAxisAnchor::Top,
|
||||
Anchor::MiddleLeft | Anchor::MiddleRight | Anchor::Center => YAxisAnchor::Middle,
|
||||
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomMiddle => YAxisAnchor::Bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChildAnchor> for Anchor {
|
||||
fn from(child_anchor_point: ChildAnchor) -> Self {
|
||||
match child_anchor_point {
|
||||
ChildAnchor::TopLeft => Anchor::TopLeft,
|
||||
ChildAnchor::TopRight => Anchor::TopRight,
|
||||
ChildAnchor::TopMiddle => Anchor::TopMiddle,
|
||||
ChildAnchor::MiddleLeft => Anchor::MiddleLeft,
|
||||
ChildAnchor::MiddleRight => Anchor::MiddleRight,
|
||||
ChildAnchor::Center => Anchor::Center,
|
||||
ChildAnchor::BottomLeft => Anchor::BottomLeft,
|
||||
ChildAnchor::BottomRight => Anchor::BottomRight,
|
||||
ChildAnchor::BottomMiddle => Anchor::BottomMiddle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParentAnchor> for Anchor {
|
||||
fn from(parent_anchor_point: ParentAnchor) -> Self {
|
||||
match parent_anchor_point {
|
||||
ParentAnchor::TopLeft => Anchor::TopLeft,
|
||||
ParentAnchor::TopRight => Anchor::TopRight,
|
||||
ParentAnchor::TopMiddle => Anchor::TopMiddle,
|
||||
ParentAnchor::MiddleLeft => Anchor::MiddleLeft,
|
||||
ParentAnchor::MiddleRight => Anchor::MiddleRight,
|
||||
ParentAnchor::Center => Anchor::Center,
|
||||
ParentAnchor::BottomLeft => Anchor::BottomLeft,
|
||||
ParentAnchor::BottomRight => Anchor::BottomRight,
|
||||
ParentAnchor::BottomMiddle => Anchor::BottomMiddle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PositionedElementAnchor> for Anchor {
|
||||
fn from(positioned_element_anchor_point: PositionedElementAnchor) -> Self {
|
||||
match positioned_element_anchor_point {
|
||||
PositionedElementAnchor::TopLeft => Anchor::TopLeft,
|
||||
PositionedElementAnchor::TopRight => Anchor::TopRight,
|
||||
PositionedElementAnchor::TopMiddle => Anchor::TopMiddle,
|
||||
PositionedElementAnchor::MiddleLeft => Anchor::MiddleLeft,
|
||||
PositionedElementAnchor::MiddleRight => Anchor::MiddleRight,
|
||||
PositionedElementAnchor::Center => Anchor::Center,
|
||||
PositionedElementAnchor::BottomLeft => Anchor::BottomLeft,
|
||||
PositionedElementAnchor::BottomRight => Anchor::BottomRight,
|
||||
PositionedElementAnchor::BottomMiddle => Anchor::BottomMiddle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum XAxisAnchor {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum YAxisAnchor {
|
||||
Top,
|
||||
Bottom,
|
||||
Middle,
|
||||
}
|
||||
|
||||
pub trait AxisAnchor {}
|
||||
impl AxisAnchor for XAxisAnchor {}
|
||||
impl AxisAnchor for YAxisAnchor {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnchorPair<T>
|
||||
where
|
||||
T: AxisAnchor + Clone,
|
||||
{
|
||||
from: T,
|
||||
to: T,
|
||||
}
|
||||
|
||||
impl<T> AnchorPair<T>
|
||||
where
|
||||
T: AxisAnchor + Clone,
|
||||
{
|
||||
pub fn new(from: T, to: T) -> Self {
|
||||
AnchorPair { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal enum used to represents the bounds of an element.
|
||||
///
|
||||
/// Users of the [`Stack`] should refer to [`ParentOffsetBounds`] and
|
||||
/// [`PositionedElementOffsetBounds`], which define the public API surface for specifying bounding
|
||||
/// behavior.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Bounds {
|
||||
/// The element's position or size is bound to the parent element.
|
||||
Parent(ParentOffsetBounds),
|
||||
|
||||
/// The element's position or size is bound to the anchor [`SavePosition`]-ed element.
|
||||
PositionedElement(PositionedElementOffsetBounds),
|
||||
}
|
||||
|
||||
/// Type of offposition offsets.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum OffsetType {
|
||||
/// Pixel value of the offset.
|
||||
Pixel(f32),
|
||||
/// Percentage offset based on the size of the anchored element. For example
|
||||
/// if the percentage is 0.5 and the anchored element has a width of 100, the
|
||||
/// pixel value of the offset will be 0.5 * 100. = 50. Note that this value
|
||||
/// can only be between 0. and 1.
|
||||
Percentage(f32),
|
||||
}
|
||||
|
||||
/// Specifies how to position a child element on a given axis. The child element is anchored from
|
||||
/// a corner (left/right on the x-axis, top-bottom on the y-axis) of the parent or relative element
|
||||
/// onto a corner of the child element and then offset by `offset`.
|
||||
#[derive(Clone)]
|
||||
pub struct PositioningAxis<T>
|
||||
where
|
||||
T: AxisAnchor + Clone,
|
||||
{
|
||||
/// The anchor to position the element relative to.
|
||||
pub(super) anchor: PositioningAnchor,
|
||||
|
||||
/// Specifies the bounding behavior of the positioned element.
|
||||
bounds: Bounds,
|
||||
|
||||
/// Specifies the pair of points on the anchor element and positioned element's bounding
|
||||
/// rectangles which are used to calculate the element's final offset position.
|
||||
anchor_pair: AnchorPair<T>,
|
||||
|
||||
/// Constant 'offset' applied to the element's final position, after calculating the anchor or
|
||||
/// parent element's anchored position. This could be either a pixel or percentage value.
|
||||
offset: OffsetType,
|
||||
}
|
||||
|
||||
/// The anchor element that an element is positioned relative to. Currently,
|
||||
/// we support positioning relative to:
|
||||
/// * The element's parent
|
||||
/// * An anchor element identified by its saved position
|
||||
#[derive(Clone)]
|
||||
pub(super) enum PositioningAnchor {
|
||||
RelativeToSavedPosition {
|
||||
/// The ID in the saved positions cache to anchor against. This ID must have been
|
||||
/// passed to the `SavePosition` element that wraps the anchor element
|
||||
position_id: String,
|
||||
/// Whether or not the element's display is conditional on the anchor element.
|
||||
/// If the anchor's position is not saved, the element will not be rendered,
|
||||
/// instead of panicking. This is useful when the anchor element is itself
|
||||
/// only sometimes rendered (for example, it's a cursor/selection).
|
||||
conditional: bool,
|
||||
},
|
||||
RelativeToParent,
|
||||
}
|
||||
|
||||
impl PositioningAnchor {
|
||||
/// Whether or not the element's display is conditional on the anchor element
|
||||
/// having been rendered.
|
||||
pub fn is_conditional(&self) -> bool {
|
||||
match self {
|
||||
PositioningAnchor::RelativeToParent => false,
|
||||
PositioningAnchor::RelativeToSavedPosition { conditional, .. } => *conditional,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PositioningAxis<T>
|
||||
where
|
||||
T: AxisAnchor + Clone,
|
||||
{
|
||||
pub fn relative_to_stack_child(
|
||||
position_id: impl Into<String>,
|
||||
bounds: PositionedElementOffsetBounds,
|
||||
offset: OffsetType,
|
||||
anchor_pair: AnchorPair<T>,
|
||||
) -> Self {
|
||||
Self {
|
||||
anchor: PositioningAnchor::RelativeToSavedPosition {
|
||||
position_id: position_id.into(),
|
||||
conditional: false,
|
||||
},
|
||||
bounds: Bounds::PositionedElement(bounds),
|
||||
anchor_pair,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn relative_to_parent(
|
||||
bounds: ParentOffsetBounds,
|
||||
offset: OffsetType,
|
||||
anchor_pair: AnchorPair<T>,
|
||||
) -> Self {
|
||||
Self {
|
||||
anchor: PositioningAnchor::RelativeToParent,
|
||||
bounds: Bounds::Parent(bounds),
|
||||
anchor_pair,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditionally position the element along this axis. If the element
|
||||
/// cannot be positioned relative to its anchor, it will be skipped, rather
|
||||
/// than causing a panic.
|
||||
///
|
||||
/// This is only supported for elements positioned relative to a stack child.
|
||||
pub fn with_conditional_anchor(mut self) -> Self {
|
||||
if let PositioningAnchor::RelativeToSavedPosition { conditional, .. } = &mut self.anchor {
|
||||
*conditional = true;
|
||||
} else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"Can only use conditional_anchor with child-relative positioning"
|
||||
);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl PositioningAxis<XAxisAnchor> {
|
||||
// Computes where on the x axis the stack child should be positioned given the element it's
|
||||
// anchored to, the child's size, and the parent the stack is rendered into. The anchored
|
||||
// element can be another child in the stack or the parent of the stack.
|
||||
pub(super) fn compute_child_position(
|
||||
&self,
|
||||
child_size: Vector2F,
|
||||
parent_rect: RectF,
|
||||
window_size: Vector2F,
|
||||
position_cache: &PositionCache,
|
||||
) -> Result<f32, String> {
|
||||
let anchor_element_rect = match &self.anchor {
|
||||
PositioningAnchor::RelativeToSavedPosition { position_id, .. } => {
|
||||
match position_cache.get_position(position_id) {
|
||||
Some(position) => position,
|
||||
None => {
|
||||
return Err(format!("Position not set for {position_id:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
PositioningAnchor::RelativeToParent => parent_rect,
|
||||
};
|
||||
|
||||
let anchor_x = match self.anchor_pair.from {
|
||||
XAxisAnchor::Left => anchor_element_rect.origin().x(),
|
||||
XAxisAnchor::Right => anchor_element_rect.max_x(),
|
||||
XAxisAnchor::Middle => {
|
||||
anchor_element_rect.origin().x() + anchor_element_rect.width() / 2.
|
||||
}
|
||||
};
|
||||
|
||||
let pixel_offset = match self.offset {
|
||||
OffsetType::Percentage(ratio) => {
|
||||
let total_width = match self.bounds {
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::AnchoredElement) => {
|
||||
(anchor_element_rect.width() - child_size.x()).max(0.)
|
||||
}
|
||||
_ => anchor_element_rect.width(),
|
||||
};
|
||||
total_width * ratio
|
||||
}
|
||||
OffsetType::Pixel(value) => value,
|
||||
};
|
||||
|
||||
let child_position_x = match self.anchor_pair.to {
|
||||
XAxisAnchor::Left => anchor_x,
|
||||
XAxisAnchor::Right => anchor_x - child_size.x(),
|
||||
XAxisAnchor::Middle => anchor_x - child_size.x() / 2.,
|
||||
} + pixel_offset;
|
||||
|
||||
let bounded_position_x = match self.bounds {
|
||||
Bounds::Parent(ParentOffsetBounds::ParentByPosition)
|
||||
| Bounds::PositionedElement(PositionedElementOffsetBounds::ParentByPosition) => {
|
||||
// our first try: find a position for the element to sit comfortably
|
||||
// within the left/right bounds of the parent.
|
||||
let mut bounded_position_x = child_position_x.clamp(
|
||||
parent_rect.min_x(),
|
||||
(parent_rect.max_x() - child_size.x()).max(parent_rect.min_x()),
|
||||
);
|
||||
|
||||
// now, check if this position will cause the element to bleed offscreen.
|
||||
// if so, make it right-align with its parent.
|
||||
if bounded_position_x + child_size.x() > window_size.x() {
|
||||
bounded_position_x = parent_rect.max_x() - child_size.x();
|
||||
}
|
||||
|
||||
// oops, we now made the left go offscreen.
|
||||
// just center the element in the window then.
|
||||
if bounded_position_x < 0.0 {
|
||||
bounded_position_x = (window_size.x() - child_size.x()) / 2.0;
|
||||
}
|
||||
|
||||
bounded_position_x
|
||||
}
|
||||
Bounds::Parent(ParentOffsetBounds::ParentBySize) => {
|
||||
child_position_x.clamp(parent_rect.min_x(), parent_rect.max_x())
|
||||
}
|
||||
Bounds::Parent(ParentOffsetBounds::WindowByPosition)
|
||||
| Bounds::PositionedElement(PositionedElementOffsetBounds::WindowByPosition) => {
|
||||
child_position_x.clamp(0., (window_size.x() - child_size.x()).max(0.))
|
||||
}
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::WindowBySize) => {
|
||||
child_position_x.clamp(0., window_size.x())
|
||||
}
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::AnchoredElement) => {
|
||||
child_position_x.clamp(
|
||||
anchor_element_rect.min_x(),
|
||||
(anchor_element_rect.max_x() - child_size.x()).max(anchor_element_rect.min_x()),
|
||||
)
|
||||
}
|
||||
_ => child_position_x,
|
||||
};
|
||||
Ok(bounded_position_x)
|
||||
}
|
||||
|
||||
pub(super) fn compute_max_child_width(
|
||||
&self,
|
||||
max_parent_width: f32,
|
||||
window_width: f32,
|
||||
position_cache: &PositionCache,
|
||||
) -> Result<Option<f32>, String> {
|
||||
match self.bounds {
|
||||
Bounds::Parent(ParentOffsetBounds::ParentBySize) => {
|
||||
let parent_anchor_x =
|
||||
Self::parent_anchor_x(self.anchor_pair.from, max_parent_width, self.offset);
|
||||
let mut max_child_width = match self.anchor_pair.to {
|
||||
XAxisAnchor::Left => max_parent_width - parent_anchor_x,
|
||||
XAxisAnchor::Right => parent_anchor_x,
|
||||
XAxisAnchor::Middle => {
|
||||
(max_parent_width - parent_anchor_x).min(parent_anchor_x) * 2.
|
||||
}
|
||||
};
|
||||
max_child_width = max_child_width.clamp(0., max_parent_width);
|
||||
Ok(Some(max_child_width))
|
||||
}
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::WindowBySize) => {
|
||||
match &self.anchor {
|
||||
PositioningAnchor::RelativeToSavedPosition { position_id, .. } => {
|
||||
let anchor_position_x = Self::positioned_element_anchor_x(
|
||||
position_id.as_str(),
|
||||
self.anchor_pair.from,
|
||||
self.offset,
|
||||
position_cache,
|
||||
)?;
|
||||
|
||||
let max_width = match self.anchor_pair.to {
|
||||
XAxisAnchor::Left => window_width - anchor_position_x,
|
||||
XAxisAnchor::Right => anchor_position_x,
|
||||
XAxisAnchor::Middle => {
|
||||
(window_width - anchor_position_x).min(anchor_position_x) * 2.
|
||||
}
|
||||
};
|
||||
Ok(Some(max_width.clamp(0., window_width)))
|
||||
}
|
||||
PositioningAnchor::RelativeToParent => {
|
||||
debug_assert!(false, "Bounding element size to window is not supported for parent-offset stack children.");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the x coordinate within the parent's max size constraint for the anchor point on the
|
||||
// Parent element relative to which the stack child is positioned.
|
||||
//
|
||||
// If there is a `SavePosition` element (and the stack child is positioned relative to it,
|
||||
// rather than its parent), then returns `None`.
|
||||
fn parent_anchor_x(anchor: XAxisAnchor, width: f32, offset: OffsetType) -> f32 {
|
||||
let pixel_offset = match offset {
|
||||
OffsetType::Percentage(ratio) => ratio * width,
|
||||
OffsetType::Pixel(value) => value,
|
||||
};
|
||||
let parent_anchor_position_x = match anchor {
|
||||
XAxisAnchor::Left => 0.,
|
||||
XAxisAnchor::Right => width,
|
||||
XAxisAnchor::Middle => width / 2.,
|
||||
};
|
||||
parent_anchor_position_x + pixel_offset
|
||||
}
|
||||
|
||||
// Returns the x coordinate for the anchor point on the `SavePosition` element relative to
|
||||
// which the stack child is positioned.
|
||||
//
|
||||
// If there is no `SavePosition` element (and the stack child is positioned relative to the
|
||||
// parent), then returns `None`.
|
||||
fn positioned_element_anchor_x(
|
||||
position_id: &str,
|
||||
anchor: XAxisAnchor,
|
||||
offset: OffsetType,
|
||||
position_cache: &PositionCache,
|
||||
) -> Result<f32, String> {
|
||||
if let Some(anchor_element_position) = position_cache.get_position(position_id) {
|
||||
let pixel_offset = match offset {
|
||||
OffsetType::Pixel(value) => value,
|
||||
OffsetType::Percentage(ratio) => ratio * anchor_element_position.width(),
|
||||
};
|
||||
let anchor_position_x = match anchor {
|
||||
XAxisAnchor::Left => anchor_element_position.min_x(),
|
||||
XAxisAnchor::Right => anchor_element_position.max_x(),
|
||||
XAxisAnchor::Middle => anchor_element_position.center().x(),
|
||||
};
|
||||
Ok(anchor_position_x + pixel_offset)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Position not found for element with position_id {position_id}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PositioningAxis<XAxisAnchor> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
anchor_pair: AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
bounds: Bounds::Parent(ParentOffsetBounds::Unbounded),
|
||||
offset: OffsetType::Pixel(0.),
|
||||
anchor: PositioningAnchor::RelativeToParent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PositioningAxis<YAxisAnchor> {
|
||||
// Computes where on the y axis the stack child should be positioned given the element it's
|
||||
// anchored to, the child's size, and the parent the stack is rendered into. The anchored
|
||||
// element can be another child in the stack or the parent of the stack.
|
||||
pub(super) fn compute_child_position(
|
||||
&self,
|
||||
child_size: Vector2F,
|
||||
parent_rect: RectF,
|
||||
window_size: Vector2F,
|
||||
position_cache: &PositionCache,
|
||||
) -> Result<f32, String> {
|
||||
let anchor_element_rect = match &self.anchor {
|
||||
PositioningAnchor::RelativeToSavedPosition { position_id, .. } => {
|
||||
match position_cache.get_position(position_id) {
|
||||
Some(position) => position,
|
||||
None => {
|
||||
return Err(format!("Position not set for {position_id:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
PositioningAnchor::RelativeToParent => parent_rect,
|
||||
};
|
||||
|
||||
let anchor_y = match self.anchor_pair.from {
|
||||
YAxisAnchor::Top => anchor_element_rect.origin().y(),
|
||||
YAxisAnchor::Bottom => anchor_element_rect.max_y(),
|
||||
YAxisAnchor::Middle => anchor_element_rect.center().y(),
|
||||
};
|
||||
|
||||
let pixel_offset = match self.offset {
|
||||
OffsetType::Percentage(ratio) => {
|
||||
let total_height = match self.bounds {
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::AnchoredElement) => {
|
||||
(anchor_element_rect.height() - child_size.y()).max(0.)
|
||||
}
|
||||
_ => anchor_element_rect.height(),
|
||||
};
|
||||
total_height * ratio
|
||||
}
|
||||
OffsetType::Pixel(value) => value,
|
||||
};
|
||||
|
||||
let child_position_y = match self.anchor_pair.to {
|
||||
YAxisAnchor::Top => anchor_y,
|
||||
YAxisAnchor::Bottom => anchor_y - child_size.y(),
|
||||
YAxisAnchor::Middle => anchor_y - (child_size.y() / 2.),
|
||||
} + pixel_offset;
|
||||
|
||||
let bounded_position_y = match self.bounds {
|
||||
Bounds::Parent(ParentOffsetBounds::ParentByPosition)
|
||||
| Bounds::PositionedElement(PositionedElementOffsetBounds::ParentByPosition) => {
|
||||
// our first try: find a position for the element to sit comfortably
|
||||
// within the upper/lower bounds of the parent.
|
||||
let mut bounded_position_y = child_position_y.clamp(
|
||||
parent_rect.min_y(),
|
||||
(parent_rect.max_y() - child_size.y()).max(parent_rect.min_y()),
|
||||
);
|
||||
|
||||
// now, check if this position will cause the element to bleed offscreen.
|
||||
// if so, make it bottom-align with its parent.
|
||||
if bounded_position_y + child_size.y() > window_size.y() {
|
||||
bounded_position_y = parent_rect.max_y() - child_size.y();
|
||||
}
|
||||
|
||||
// oops, we now made the top go offscreen.
|
||||
// just center the element in the window then.
|
||||
if bounded_position_y < 0.0 {
|
||||
bounded_position_y = (window_size.y() - child_size.y()) / 2.0;
|
||||
}
|
||||
|
||||
bounded_position_y
|
||||
}
|
||||
Bounds::Parent(ParentOffsetBounds::ParentBySize) => {
|
||||
child_position_y.clamp(parent_rect.min_y(), parent_rect.max_y())
|
||||
}
|
||||
Bounds::Parent(ParentOffsetBounds::WindowByPosition)
|
||||
| Bounds::PositionedElement(PositionedElementOffsetBounds::WindowByPosition) => {
|
||||
child_position_y.clamp(0., (window_size.y() - child_size.y()).max(0.))
|
||||
}
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::WindowBySize) => {
|
||||
child_position_y.clamp(0., window_size.y())
|
||||
}
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::AnchoredElement) => {
|
||||
child_position_y.clamp(
|
||||
anchor_element_rect.min_y(),
|
||||
(anchor_element_rect.max_y() - child_size.y()).max(anchor_element_rect.min_y()),
|
||||
)
|
||||
}
|
||||
_ => child_position_y,
|
||||
};
|
||||
|
||||
Ok(bounded_position_y)
|
||||
}
|
||||
|
||||
/// Returns the maximum height of the positioned child based on the window height, maximum
|
||||
/// height given by the parent's size constraint, and the [`OffsetPositioning`]'s
|
||||
/// bounding behavior.
|
||||
pub(super) fn compute_max_child_height(
|
||||
&self,
|
||||
max_parent_height: f32,
|
||||
window_height: f32,
|
||||
position_cache: &PositionCache,
|
||||
) -> Result<Option<f32>, String> {
|
||||
match self.bounds {
|
||||
Bounds::Parent(ParentOffsetBounds::ParentBySize) => {
|
||||
let parent_anchor_y =
|
||||
Self::parent_anchor_y(self.anchor_pair.from, max_parent_height, self.offset);
|
||||
let max_child_height = match self.anchor_pair.to {
|
||||
YAxisAnchor::Top => max_parent_height - parent_anchor_y,
|
||||
YAxisAnchor::Bottom => parent_anchor_y,
|
||||
YAxisAnchor::Middle => {
|
||||
(max_parent_height - parent_anchor_y).min(parent_anchor_y) * 2.
|
||||
}
|
||||
};
|
||||
Ok(Some(max_child_height.clamp(0., max_parent_height)))
|
||||
}
|
||||
Bounds::PositionedElement(PositionedElementOffsetBounds::WindowBySize) => {
|
||||
match &self.anchor {
|
||||
PositioningAnchor::RelativeToSavedPosition { position_id, .. } => {
|
||||
let anchor_position_y = Self::positioned_element_anchor_y(
|
||||
position_id.as_str(),
|
||||
self.anchor_pair.from,
|
||||
self.offset,
|
||||
position_cache,
|
||||
)?;
|
||||
let max_height = match self.anchor_pair.to {
|
||||
YAxisAnchor::Top => window_height - anchor_position_y,
|
||||
YAxisAnchor::Bottom => anchor_position_y,
|
||||
YAxisAnchor::Middle => {
|
||||
(window_height - anchor_position_y).min(anchor_position_y) * 2.
|
||||
}
|
||||
};
|
||||
Ok(Some(max_height.clamp(0., window_height)))
|
||||
}
|
||||
PositioningAnchor::RelativeToParent => {
|
||||
debug_assert!(false, "Bounding element size to window is not supported for parent-offset stack children.");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the y coordinate within the parent's max size constraint for the anchor point on the
|
||||
// Parent element relative to which the stack child is positioned.
|
||||
//
|
||||
// If there is a `SavePosition` element (and the stack child is positioned relative to it,
|
||||
// rather than its parent), then returns `None`.
|
||||
fn parent_anchor_y(anchor: YAxisAnchor, height: f32, offset: OffsetType) -> f32 {
|
||||
let pixel_offset = match offset {
|
||||
OffsetType::Percentage(ratio) => ratio * height,
|
||||
OffsetType::Pixel(value) => value,
|
||||
};
|
||||
let parent_anchor_position_y = match anchor {
|
||||
YAxisAnchor::Top => 0.,
|
||||
YAxisAnchor::Bottom => height,
|
||||
YAxisAnchor::Middle => height / 2.,
|
||||
};
|
||||
parent_anchor_position_y + pixel_offset
|
||||
}
|
||||
|
||||
// Returns the y coordinate for the anchor point on the `SavePosition` element relative to
|
||||
// which the stack child is positioned.
|
||||
//
|
||||
// If there is no `SavePosition` element (and the stack child is positioned relative to the
|
||||
// parent), then returns `None`.
|
||||
fn positioned_element_anchor_y(
|
||||
position_id: &str,
|
||||
anchor: YAxisAnchor,
|
||||
offset: OffsetType,
|
||||
position_cache: &PositionCache,
|
||||
) -> Result<f32, String> {
|
||||
if let Some(positioned_element_position) = position_cache.get_position(position_id) {
|
||||
let pixel_offset = match offset {
|
||||
OffsetType::Pixel(value) => value,
|
||||
OffsetType::Percentage(ratio) => ratio * positioned_element_position.height(),
|
||||
};
|
||||
let anchor_position_y = match anchor {
|
||||
YAxisAnchor::Top => positioned_element_position.min_y(),
|
||||
YAxisAnchor::Bottom => positioned_element_position.max_y(),
|
||||
YAxisAnchor::Middle => positioned_element_position.center().y(),
|
||||
};
|
||||
Ok(anchor_position_y + pixel_offset)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Position not found for element with position_id {position_id}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PositioningAxis<YAxisAnchor> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
anchor_pair: AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
|
||||
bounds: Bounds::Parent(ParentOffsetBounds::Unbounded),
|
||||
offset: OffsetType::Pixel(0.),
|
||||
anchor: PositioningAnchor::RelativeToParent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_positioning_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,934 @@
|
||||
use super::*;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
static ref OFFSET: Vector2F = vec2f(5., 10.);
|
||||
static ref WINDOW_SIZE: Vector2F = vec2f(100., 100.);
|
||||
|
||||
static ref SMALL_CHILD_SIZE: Vector2F = vec2f(10., 10.);
|
||||
// Use a size for the child that is sufficiently large to test various bounding behavior.
|
||||
static ref CHILD_SIZE: Vector2F = vec2f(75., 75.);
|
||||
static ref DEFAULT_SIZE_CONSTRAINT: SizeConstraint = SizeConstraint {
|
||||
min: vec2f(0., 0.),
|
||||
max: vec2f(50., 50.)
|
||||
};
|
||||
static ref POSITIONED_ELEMENT_RECT: RectF = RectF::new(vec2f(50., 50.), vec2f(15., 15.));
|
||||
static ref SMALL_PARENT_RECT: RectF = RectF::new(vec2f(50., 50.), vec2f(25., 25.));
|
||||
static ref PARENT_RECT: RectF = RectF::new(vec2f(25., 25.), vec2f(50., 50.));
|
||||
static ref PARENT_ANCHORS: Vec<ParentAnchor> = vec![
|
||||
ParentAnchor::TopLeft,
|
||||
ParentAnchor::TopMiddle,
|
||||
ParentAnchor::TopRight,
|
||||
ParentAnchor::MiddleLeft,
|
||||
ParentAnchor::MiddleRight,
|
||||
ParentAnchor::Center,
|
||||
ParentAnchor::BottomLeft,
|
||||
ParentAnchor::BottomMiddle,
|
||||
ParentAnchor::BottomRight,
|
||||
];
|
||||
static ref POSITIONED_ELEMENT_ANCHORS: Vec<PositionedElementAnchor> = vec![
|
||||
PositionedElementAnchor::TopLeft,
|
||||
PositionedElementAnchor::TopMiddle,
|
||||
PositionedElementAnchor::TopRight,
|
||||
PositionedElementAnchor::MiddleLeft,
|
||||
PositionedElementAnchor::MiddleRight,
|
||||
PositionedElementAnchor::Center,
|
||||
PositionedElementAnchor::BottomLeft,
|
||||
PositionedElementAnchor::BottomMiddle,
|
||||
PositionedElementAnchor::BottomRight,
|
||||
];
|
||||
static ref CHILD_ANCHORS: Vec<ChildAnchor> = vec![
|
||||
ChildAnchor::TopLeft,
|
||||
ChildAnchor::TopMiddle,
|
||||
ChildAnchor::TopRight,
|
||||
ChildAnchor::MiddleLeft,
|
||||
ChildAnchor::MiddleRight,
|
||||
ChildAnchor::Center,
|
||||
ChildAnchor::BottomLeft,
|
||||
ChildAnchor::BottomMiddle,
|
||||
ChildAnchor::BottomRight,
|
||||
];
|
||||
}
|
||||
const SAVE_POSITION_ID: &str = "SAVE_POSITION_ID";
|
||||
|
||||
/// Returns the coordinates of the parent's anchor point used to position the child.
|
||||
fn parent_anchor_point(anchor: ParentAnchor) -> (f32, f32) {
|
||||
(
|
||||
match anchor {
|
||||
ParentAnchor::TopLeft | ParentAnchor::MiddleLeft | ParentAnchor::BottomLeft => {
|
||||
PARENT_RECT.min_x()
|
||||
}
|
||||
ParentAnchor::TopMiddle | ParentAnchor::Center | ParentAnchor::BottomMiddle => {
|
||||
PARENT_RECT.center().x()
|
||||
}
|
||||
ParentAnchor::TopRight | ParentAnchor::MiddleRight | ParentAnchor::BottomRight => {
|
||||
PARENT_RECT.max_x()
|
||||
}
|
||||
} + OFFSET.x(),
|
||||
match anchor {
|
||||
ParentAnchor::TopLeft | ParentAnchor::TopMiddle | ParentAnchor::TopRight => {
|
||||
PARENT_RECT.min_y()
|
||||
}
|
||||
ParentAnchor::MiddleLeft | ParentAnchor::MiddleRight | ParentAnchor::Center => {
|
||||
PARENT_RECT.center().y()
|
||||
}
|
||||
ParentAnchor::BottomLeft | ParentAnchor::BottomMiddle | ParentAnchor::BottomRight => {
|
||||
PARENT_RECT.max_y()
|
||||
}
|
||||
} + OFFSET.y(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the coordinates of the `SavePositioned` element's anchor point used to position the
|
||||
/// child.
|
||||
fn positioned_element_anchor_point(anchor: PositionedElementAnchor) -> (f32, f32) {
|
||||
(
|
||||
match anchor {
|
||||
PositionedElementAnchor::TopLeft
|
||||
| PositionedElementAnchor::MiddleLeft
|
||||
| PositionedElementAnchor::BottomLeft => POSITIONED_ELEMENT_RECT.min_x(),
|
||||
PositionedElementAnchor::TopMiddle
|
||||
| PositionedElementAnchor::Center
|
||||
| PositionedElementAnchor::BottomMiddle => POSITIONED_ELEMENT_RECT.center().x(),
|
||||
PositionedElementAnchor::TopRight
|
||||
| PositionedElementAnchor::MiddleRight
|
||||
| PositionedElementAnchor::BottomRight => POSITIONED_ELEMENT_RECT.max_x(),
|
||||
} + OFFSET.x(),
|
||||
match anchor {
|
||||
PositionedElementAnchor::TopLeft
|
||||
| PositionedElementAnchor::TopMiddle
|
||||
| PositionedElementAnchor::TopRight => POSITIONED_ELEMENT_RECT.min_y(),
|
||||
PositionedElementAnchor::MiddleLeft
|
||||
| PositionedElementAnchor::MiddleRight
|
||||
| PositionedElementAnchor::Center => POSITIONED_ELEMENT_RECT.center().y(),
|
||||
PositionedElementAnchor::BottomLeft
|
||||
| PositionedElementAnchor::BottomMiddle
|
||||
| PositionedElementAnchor::BottomRight => POSITIONED_ELEMENT_RECT.max_y(),
|
||||
} + OFFSET.y(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the absolute (x, y) position of a child element rendered against a given parent
|
||||
/// using `PositionedElementOffsetBounds::ParentByPosition` bounds.
|
||||
fn get_absolute_x_y_position_for_child_element(
|
||||
child_size: Vector2F,
|
||||
parent_rect: RectF,
|
||||
positioned_element_anchor: PositionedElementAnchor,
|
||||
child_anchor: ChildAnchor,
|
||||
) -> Vector2F {
|
||||
let offset_positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
SAVE_POSITION_ID,
|
||||
*OFFSET,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
positioned_element_anchor,
|
||||
child_anchor,
|
||||
);
|
||||
|
||||
let mut position_cache = PositionCache::new();
|
||||
position_cache.start();
|
||||
position_cache
|
||||
.cache_position_indefinitely(SAVE_POSITION_ID.to_owned(), *POSITIONED_ELEMENT_RECT);
|
||||
position_cache.end();
|
||||
|
||||
let child_position_x = offset_positioning
|
||||
.x_axis
|
||||
.compute_child_position(child_size, parent_rect, *WINDOW_SIZE, &position_cache)
|
||||
.expect("Failed to compute child position x.");
|
||||
|
||||
let child_position_y = offset_positioning
|
||||
.y_axis
|
||||
.compute_child_position(child_size, parent_rect, *WINDOW_SIZE, &position_cache)
|
||||
.expect("Failed to compute child position y.");
|
||||
|
||||
vec2f(child_position_x, child_position_y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_parent_unbounded() {
|
||||
for parent_anchor in PARENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_parent(
|
||||
*OFFSET,
|
||||
ParentOffsetBounds::Unbounded,
|
||||
*parent_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let position_cache = PositionCache::new();
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since there is no bounding behavior.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
let (anchor_x, anchor_y) = parent_anchor_point(*parent_anchor);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the parent's
|
||||
// anchor point.
|
||||
let expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
let child_position_x = offset_positioning.x_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_x.is_ok());
|
||||
assert_eq!(child_position_x.unwrap(), expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the parent's
|
||||
// anchor point.
|
||||
let expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
let child_position_y = offset_positioning.y_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_y.is_ok());
|
||||
assert_eq!(child_position_y.unwrap(), expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_parent_bound_to_parent_with_position() {
|
||||
for parent_anchor in PARENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_parent(
|
||||
*OFFSET,
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
*parent_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let position_cache = PositionCache::new();
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since the bounding behavior adjusts
|
||||
// position.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
let (anchor_x, anchor_y) = parent_anchor_point(*parent_anchor);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the parent's
|
||||
// anchor point.
|
||||
let mut expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
expected_child_x = expected_child_x
|
||||
.min(PARENT_RECT.max_x() - CHILD_SIZE.x())
|
||||
.max(PARENT_RECT.min_x());
|
||||
|
||||
let child_position_x = offset_positioning.x_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_x.is_ok());
|
||||
assert_eq!(child_position_x.unwrap(), expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the parent anchor
|
||||
// point.
|
||||
let mut expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
expected_child_y = expected_child_y
|
||||
.min(PARENT_RECT.max_y() - CHILD_SIZE.y())
|
||||
.max(PARENT_RECT.min_y());
|
||||
|
||||
let child_position_y = offset_positioning.y_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_y.is_ok());
|
||||
assert_eq!(child_position_y.unwrap(), expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_bound_to_anchor() {
|
||||
for ratio in 0..10 {
|
||||
let normalized_ratio = ratio as f32 / 10.;
|
||||
let x_axis = PositioningAxis::relative_to_stack_child(
|
||||
SAVE_POSITION_ID,
|
||||
PositionedElementOffsetBounds::AnchoredElement,
|
||||
OffsetType::Percentage(normalized_ratio),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
);
|
||||
let y_axis = PositioningAxis::relative_to_stack_child(
|
||||
SAVE_POSITION_ID,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
|
||||
);
|
||||
|
||||
let offset_positioning = OffsetPositioning::from_axes(x_axis, y_axis);
|
||||
|
||||
let mut position_cache = PositionCache::new();
|
||||
position_cache.start();
|
||||
position_cache
|
||||
.cache_position_indefinitely(SAVE_POSITION_ID.to_owned(), *POSITIONED_ELEMENT_RECT);
|
||||
position_cache.end();
|
||||
|
||||
// Use a smaller child size to make sure it's not clipped by the anchored element.
|
||||
let expected_child_x = POSITIONED_ELEMENT_RECT.origin_x()
|
||||
+ (POSITIONED_ELEMENT_RECT.width() - SMALL_CHILD_SIZE.x()) * normalized_ratio;
|
||||
let expected_child_y = POSITIONED_ELEMENT_RECT.center().y() - SMALL_CHILD_SIZE.y() / 2.;
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since there is no bounding behavior.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
let child_position_x = offset_positioning.x_axis.compute_child_position(
|
||||
*SMALL_CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_x.is_ok());
|
||||
assert_eq!(child_position_x.unwrap(), expected_child_x);
|
||||
|
||||
let child_position_y = offset_positioning.y_axis.compute_child_position(
|
||||
*SMALL_CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
|
||||
assert!(child_position_y.is_ok());
|
||||
assert_eq!(child_position_y.unwrap(), expected_child_y);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_parent_bound_to_window_with_position() {
|
||||
for parent_anchor in PARENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_parent(
|
||||
*OFFSET,
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
*parent_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let position_cache = PositionCache::new();
|
||||
let (anchor_x, anchor_y) = parent_anchor_point(*parent_anchor);
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since the bounding behavior adjusts
|
||||
// position.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the parent's
|
||||
// anchor point.
|
||||
let mut expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
expected_child_x = expected_child_x
|
||||
.min(WINDOW_SIZE.x() - CHILD_SIZE.x())
|
||||
.max(0.);
|
||||
|
||||
let child_position_x = offset_positioning.x_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_x.is_ok());
|
||||
assert_eq!(child_position_x.unwrap(), expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the parent anchor
|
||||
// point.
|
||||
let mut expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
expected_child_y = expected_child_y
|
||||
.min(WINDOW_SIZE.y() - CHILD_SIZE.y())
|
||||
.max(0.);
|
||||
|
||||
let child_position_y = offset_positioning.y_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
|
||||
assert!(child_position_y.is_ok());
|
||||
assert_eq!(child_position_y.unwrap(), expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_parent_bound_to_parent_with_size() {
|
||||
for parent_anchor in PARENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_parent(
|
||||
*OFFSET,
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
*parent_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let position_cache = PositionCache::new();
|
||||
let (anchor_x, anchor_y) = parent_anchor_point(*parent_anchor);
|
||||
|
||||
// Compute the expected size constraint based on the parent's size and expected
|
||||
// position of child element within the parent's bounding rect.
|
||||
let mut expected_size_constraint_max_width = match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => {
|
||||
PARENT_RECT.max_x() - anchor_x
|
||||
}
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
(anchor_x - PARENT_RECT.min_x()).min(PARENT_RECT.max_x() - anchor_x) * 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
anchor_x - PARENT_RECT.min_x()
|
||||
}
|
||||
};
|
||||
expected_size_constraint_max_width =
|
||||
expected_size_constraint_max_width.clamp(0., PARENT_RECT.width());
|
||||
|
||||
let mut expected_size_constraint_max_height = match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => {
|
||||
PARENT_RECT.max_y() - anchor_y
|
||||
}
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
(anchor_y - PARENT_RECT.min_y()).min(PARENT_RECT.max_y() - anchor_y) * 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft | ChildAnchor::BottomMiddle | ChildAnchor::BottomRight => {
|
||||
anchor_y - PARENT_RECT.min_y()
|
||||
}
|
||||
};
|
||||
expected_size_constraint_max_height =
|
||||
expected_size_constraint_max_height.clamp(0., PARENT_RECT.height());
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(
|
||||
size_constraint.max,
|
||||
vec2f(
|
||||
expected_size_constraint_max_width,
|
||||
expected_size_constraint_max_height
|
||||
)
|
||||
);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the parent's
|
||||
// anchor point.
|
||||
let mut expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
expected_child_x = expected_child_x.clamp(PARENT_RECT.min_x(), PARENT_RECT.max_x());
|
||||
|
||||
let child_position_x = offset_positioning.x_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_x.is_ok());
|
||||
assert_eq!(child_position_x.unwrap(), expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
expected_child_y = expected_child_y.clamp(PARENT_RECT.min_y(), PARENT_RECT.max_y());
|
||||
|
||||
let child_position_y = offset_positioning.y_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
|
||||
assert!(child_position_y.is_ok());
|
||||
assert_eq!(child_position_y.unwrap(), expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_unbounded() {
|
||||
for positioned_element_anchor in POSITIONED_ELEMENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
SAVE_POSITION_ID,
|
||||
*OFFSET,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
*positioned_element_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let mut position_cache = PositionCache::new();
|
||||
position_cache.start();
|
||||
position_cache
|
||||
.cache_position_indefinitely(SAVE_POSITION_ID.to_owned(), *POSITIONED_ELEMENT_RECT);
|
||||
position_cache.end();
|
||||
let (anchor_x, anchor_y) = positioned_element_anchor_point(*positioned_element_anchor);
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since there is no bounding behavior.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
let child_position_x = offset_positioning.x_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
assert!(child_position_x.is_ok());
|
||||
assert_eq!(child_position_x.unwrap(), expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
let child_position_y = offset_positioning.y_axis.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*PARENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
);
|
||||
|
||||
assert!(child_position_y.is_ok());
|
||||
assert_eq!(child_position_y.unwrap(), expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_bound_to_parent_with_position() {
|
||||
for positioned_element_anchor in POSITIONED_ELEMENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
SAVE_POSITION_ID,
|
||||
*OFFSET,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
*positioned_element_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let mut position_cache = PositionCache::new();
|
||||
position_cache.start();
|
||||
position_cache
|
||||
.cache_position_indefinitely(SAVE_POSITION_ID.to_owned(), *POSITIONED_ELEMENT_RECT);
|
||||
position_cache.end();
|
||||
let (anchor_x, anchor_y) = positioned_element_anchor_point(*positioned_element_anchor);
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since the bounding behavior adjusts
|
||||
// position.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
expected_child_x = expected_child_x
|
||||
.min(PARENT_RECT.max_x() - CHILD_SIZE.x())
|
||||
.max(PARENT_RECT.min_x());
|
||||
|
||||
let child_position_x = offset_positioning
|
||||
.x_axis
|
||||
.compute_child_position(*CHILD_SIZE, *PARENT_RECT, *WINDOW_SIZE, &position_cache)
|
||||
.expect("Failed to compute child position x.");
|
||||
assert_eq!(child_position_x, expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
expected_child_y = expected_child_y
|
||||
.min(PARENT_RECT.max_y() - CHILD_SIZE.y())
|
||||
.max(PARENT_RECT.min_y());
|
||||
|
||||
let child_position_y = offset_positioning
|
||||
.y_axis
|
||||
.compute_child_position(*CHILD_SIZE, *PARENT_RECT, *WINDOW_SIZE, &position_cache)
|
||||
.expect("Failed to compute child position y.");
|
||||
assert_eq!(child_position_y, expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_bound_to_parent_with_position_with_window_overflow() {
|
||||
for positioned_element_anchor in POSITIONED_ELEMENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
assert_eq!(
|
||||
// positioning a 60x60 rect relatively between (50,50) and (75,75) will result in
|
||||
// the element getting pushed up+left until it aligns with its parent's max bounds.
|
||||
get_absolute_x_y_position_for_child_element(
|
||||
vec2f(60., 60.),
|
||||
*SMALL_PARENT_RECT,
|
||||
*positioned_element_anchor,
|
||||
*child_anchor
|
||||
),
|
||||
vec2f(15.0, 15.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_bound_to_parent_with_position_with_double_window_overflow() {
|
||||
for positioned_element_anchor in POSITIONED_ELEMENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
assert_eq!(
|
||||
// positioning an 80x80 rect relatively between (25,25) and (75,75) will result in
|
||||
// the element not being able to align itself either with the lower or upper bounds
|
||||
// of the parent - in both cases it would be pushed offscreen.
|
||||
// so instead, it centers itself.
|
||||
get_absolute_x_y_position_for_child_element(
|
||||
vec2f(80., 80.),
|
||||
*PARENT_RECT,
|
||||
*positioned_element_anchor,
|
||||
*child_anchor
|
||||
),
|
||||
vec2f(10.0, 10.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_bound_to_window_with_position() {
|
||||
for positioned_element_anchor in POSITIONED_ELEMENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
SAVE_POSITION_ID,
|
||||
*OFFSET,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
*positioned_element_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let mut position_cache = PositionCache::new();
|
||||
position_cache.start();
|
||||
position_cache
|
||||
.cache_position_indefinitely(SAVE_POSITION_ID.to_owned(), *POSITIONED_ELEMENT_RECT);
|
||||
position_cache.end();
|
||||
let (anchor_x, anchor_y) = positioned_element_anchor_point(*positioned_element_anchor);
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
// The size constraint should be unchanged since the bounding behavior adjusts
|
||||
// position.
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(size_constraint.max, DEFAULT_SIZE_CONSTRAINT.max);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
expected_child_x = expected_child_x
|
||||
.min(WINDOW_SIZE.x() - CHILD_SIZE.x())
|
||||
.max(0.);
|
||||
|
||||
let child_position_x = offset_positioning
|
||||
.x_axis
|
||||
.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*POSITIONED_ELEMENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
)
|
||||
.expect("Failed to compute child position x.");
|
||||
assert_eq!(child_position_x, expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
expected_child_y = expected_child_y
|
||||
.min(WINDOW_SIZE.y() - CHILD_SIZE.y())
|
||||
.max(0.);
|
||||
|
||||
let child_position_y = offset_positioning
|
||||
.y_axis
|
||||
.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*POSITIONED_ELEMENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
)
|
||||
.expect("Failed to compute child position y.");
|
||||
assert_eq!(child_position_y, expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_from_positioned_element_bound_to_window_with_size() {
|
||||
for positioned_element_anchor in POSITIONED_ELEMENT_ANCHORS.iter() {
|
||||
for child_anchor in CHILD_ANCHORS.iter() {
|
||||
let offset_positioning = OffsetPositioning::offset_from_save_position_element(
|
||||
SAVE_POSITION_ID,
|
||||
*OFFSET,
|
||||
PositionedElementOffsetBounds::WindowBySize,
|
||||
*positioned_element_anchor,
|
||||
*child_anchor,
|
||||
);
|
||||
|
||||
let mut position_cache = PositionCache::new();
|
||||
position_cache.start();
|
||||
position_cache
|
||||
.cache_position_indefinitely(SAVE_POSITION_ID.to_owned(), *POSITIONED_ELEMENT_RECT);
|
||||
position_cache.end();
|
||||
let (anchor_x, anchor_y) = positioned_element_anchor_point(*positioned_element_anchor);
|
||||
|
||||
// Compute the expected size constraint based on the window bounds and expected
|
||||
// position of the child element.
|
||||
let expected_size_constraint_max_width = match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => {
|
||||
WINDOW_SIZE.x() - anchor_x
|
||||
}
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
anchor_x.min(WINDOW_SIZE.x() - anchor_x) * 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
anchor_x
|
||||
}
|
||||
};
|
||||
let expected_size_constraint_max_height = match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => {
|
||||
WINDOW_SIZE.y() - anchor_y
|
||||
}
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
anchor_y.min(WINDOW_SIZE.y() - anchor_y) * 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft | ChildAnchor::BottomMiddle | ChildAnchor::BottomRight => {
|
||||
anchor_y
|
||||
}
|
||||
};
|
||||
|
||||
let size_constraint = offset_positioning.size_constraint(
|
||||
PARENT_RECT.size(),
|
||||
*WINDOW_SIZE,
|
||||
*DEFAULT_SIZE_CONSTRAINT,
|
||||
&position_cache,
|
||||
);
|
||||
|
||||
assert_eq!(size_constraint.min, DEFAULT_SIZE_CONSTRAINT.min);
|
||||
assert_eq!(
|
||||
size_constraint.max,
|
||||
vec2f(
|
||||
expected_size_constraint_max_width,
|
||||
expected_size_constraint_max_height
|
||||
)
|
||||
);
|
||||
|
||||
// Compute the expected x-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_x = anchor_x
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::MiddleLeft | ChildAnchor::BottomLeft => 0.,
|
||||
ChildAnchor::TopMiddle | ChildAnchor::Center | ChildAnchor::BottomMiddle => {
|
||||
CHILD_SIZE.x() / 2.
|
||||
}
|
||||
ChildAnchor::TopRight | ChildAnchor::MiddleRight | ChildAnchor::BottomRight => {
|
||||
CHILD_SIZE.x()
|
||||
}
|
||||
};
|
||||
expected_child_x = expected_child_x.clamp(0., WINDOW_SIZE.x());
|
||||
|
||||
let child_position_x = offset_positioning
|
||||
.x_axis
|
||||
.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*POSITIONED_ELEMENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
)
|
||||
.expect("Failed to compute child position x.");
|
||||
assert_eq!(child_position_x, expected_child_x);
|
||||
|
||||
// Compute the expected y-axis position of the child relative to the positioned
|
||||
// element's anchor point.
|
||||
let mut expected_child_y = anchor_y
|
||||
- match child_anchor {
|
||||
ChildAnchor::TopLeft | ChildAnchor::TopMiddle | ChildAnchor::TopRight => 0.,
|
||||
ChildAnchor::MiddleLeft | ChildAnchor::MiddleRight | ChildAnchor::Center => {
|
||||
CHILD_SIZE.y() / 2.
|
||||
}
|
||||
ChildAnchor::BottomLeft
|
||||
| ChildAnchor::BottomMiddle
|
||||
| ChildAnchor::BottomRight => CHILD_SIZE.y(),
|
||||
};
|
||||
expected_child_y = expected_child_y.clamp(0., WINDOW_SIZE.y());
|
||||
|
||||
let child_position_y = offset_positioning
|
||||
.y_axis
|
||||
.compute_child_position(
|
||||
*CHILD_SIZE,
|
||||
*POSITIONED_ELEMENT_RECT,
|
||||
*WINDOW_SIZE,
|
||||
&position_cache,
|
||||
)
|
||||
.expect("Failed to compute child position y.");
|
||||
assert_eq!(child_position_y, expected_child_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::{
|
||||
elements::Point, event::DispatchedEvent, geometry::vector::Vector2F, AfterLayoutContext,
|
||||
AppContext, ClipBounds, Element, EventContext, LayoutContext, PaintContext, SizeConstraint,
|
||||
};
|
||||
|
||||
/// Internal elements used to support the `add_overlay_child` and `add_positioned_overlay_child`
|
||||
/// APIs within the `Stack`. It is a thin wrapper around the child, creating a new Overlay layer
|
||||
/// and painting the child within that layer, so that it is drawn above the normal UI elements.
|
||||
pub(super) struct Overlay {
|
||||
child: Box<dyn Element>,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub fn new(child: Box<dyn Element>) -> Self {
|
||||
Self { child }
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Overlay {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app)
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
ctx.scene.start_overlay_layer(ClipBounds::None);
|
||||
self.child.paint(origin, ctx, app);
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::any::Any;
|
||||
|
||||
use super::OffsetPositioning;
|
||||
use crate::elements::Selection;
|
||||
use crate::{
|
||||
elements::{Point, SelectableElement, SelectionFragment},
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
pub(super) struct Positioned {
|
||||
child: Box<dyn Element>,
|
||||
parent_data: OffsetPositioning,
|
||||
}
|
||||
|
||||
impl Positioned {
|
||||
pub(super) fn new(child: Box<dyn Element>) -> Self {
|
||||
Self {
|
||||
child,
|
||||
parent_data: OffsetPositioning::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn with_offset(mut self, positioning: OffsetPositioning) -> Self {
|
||||
self.parent_data = positioning;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Positioned {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn parent_data(&self) -> Option<&dyn Any> {
|
||||
Some(&self.parent_data)
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for Positioned {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
use crate::elements::Selection;
|
||||
use crate::EntityId;
|
||||
use crate::{
|
||||
elements::{Point, SelectableElement, SelectionFragment},
|
||||
event::DispatchedEvent,
|
||||
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
|
||||
SizeConstraint,
|
||||
};
|
||||
|
||||
pub struct SavePosition {
|
||||
child: Box<dyn Element>,
|
||||
position_id: String,
|
||||
for_single_frame: bool,
|
||||
}
|
||||
|
||||
impl SavePosition {
|
||||
pub fn new(child: Box<dyn Element>, position_id: &str) -> Self {
|
||||
Self {
|
||||
child,
|
||||
position_id: position_id.to_string(),
|
||||
for_single_frame: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only saves the position for a single frame. At the start
|
||||
/// of rendering the next frame the position is cleared.
|
||||
pub fn for_single_frame(mut self) -> Self {
|
||||
self.for_single_frame = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for SavePosition {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.child.layout(constraint, ctx, app)
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
self.child.after_layout(ctx, app);
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
if self.for_single_frame {
|
||||
ctx.position_cache.cache_position_for_one_frame(
|
||||
self.position_id.clone(),
|
||||
RectF::new(
|
||||
origin,
|
||||
self.child.size().expect("size must be set before paint"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ctx.position_cache.cache_position_indefinitely(
|
||||
self.position_id.clone(),
|
||||
RectF::new(
|
||||
origin,
|
||||
self.child.size().expect("size must be set before paint"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
self.child.paint(origin, ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
self.child.dispatch_event(event, ctx, app)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.child.size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.child.origin()
|
||||
}
|
||||
|
||||
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
|
||||
Some(self as &dyn SelectableElement)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn debug_text_content(&self) -> Option<String> {
|
||||
self.child.debug_text_content()
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectableElement for SavePosition {
|
||||
fn get_selection(
|
||||
&self,
|
||||
selection_start: Vector2F,
|
||||
selection_end: Vector2F,
|
||||
is_rect: IsRect,
|
||||
) -> Option<Vec<SelectionFragment>> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.get_selection(selection_start, selection_end, is_rect)
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_selection(
|
||||
&self,
|
||||
point: Vector2F,
|
||||
direction: SelectionDirection,
|
||||
unit: SelectionType,
|
||||
word_boundaries_policy: &WordBoundariesPolicy,
|
||||
) -> Option<Vector2F> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_point_semantically_before(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
absolute_point_other: Vector2F,
|
||||
) -> Option<bool> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_select(
|
||||
&self,
|
||||
absolute_point: Vector2F,
|
||||
smart_select_fn: crate::elements::SmartSelectFn,
|
||||
) -> Option<(Vector2F, Vector2F)> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.and_then(|selectable_child| {
|
||||
selectable_child.smart_select(absolute_point, smart_select_fn)
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
|
||||
self.child
|
||||
.as_selectable_element()
|
||||
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_rich_content_position_id(view_id: &EntityId) -> String {
|
||||
format!("rich_content_position_{view_id}")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
use super::*;
|
||||
|
||||
fn create_empty_element() -> Box<dyn Element> {
|
||||
Box::new(super::super::Empty::new())
|
||||
}
|
||||
|
||||
fn create_header(width: TableColumnWidth) -> TableHeader {
|
||||
TableHeader::new(create_empty_element()).with_width(width)
|
||||
}
|
||||
|
||||
fn create_test_state() -> TableStateHandle {
|
||||
TableStateHandle::new(0, |_, _| vec![])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Construction Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_table_construction_with_row_render_fn() {
|
||||
let state = TableStateHandle::new(3, |_, _| {
|
||||
vec![
|
||||
create_empty_element(),
|
||||
create_empty_element(),
|
||||
create_empty_element(),
|
||||
]
|
||||
});
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
]);
|
||||
|
||||
assert_eq!(table.total_row_count(), 3);
|
||||
assert_eq!(table.column_count(), 3);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Column Width Computation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_fixed_only() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
create_header(TableColumnWidth::Fixed(150.0)),
|
||||
create_header(TableColumnWidth::Fixed(50.0)),
|
||||
]);
|
||||
|
||||
let widths = table.compute_column_widths(500.0, &[0.0, 0.0, 0.0]);
|
||||
assert_eq!(widths, vec![100.0, 150.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_flex_only() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Flex(1.0)),
|
||||
create_header(TableColumnWidth::Flex(2.0)),
|
||||
create_header(TableColumnWidth::Flex(1.0)),
|
||||
]);
|
||||
|
||||
let widths = table.compute_column_widths(400.0, &[0.0, 0.0, 0.0]);
|
||||
assert_eq!(widths, vec![100.0, 200.0, 100.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_fraction() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Fraction(0.25)),
|
||||
create_header(TableColumnWidth::Fraction(0.5)),
|
||||
create_header(TableColumnWidth::Fraction(0.25)),
|
||||
]);
|
||||
|
||||
let widths = table.compute_column_widths(400.0, &[0.0, 0.0, 0.0]);
|
||||
assert_eq!(widths, vec![100.0, 200.0, 100.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_mixed() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
create_header(TableColumnWidth::Flex(1.0)),
|
||||
create_header(TableColumnWidth::Fraction(0.2)),
|
||||
]);
|
||||
|
||||
let widths = table.compute_column_widths(500.0, &[0.0, 0.0, 0.0]);
|
||||
assert_eq!(widths[0], 100.0);
|
||||
assert_eq!(widths[2], 100.0);
|
||||
assert_eq!(widths[1], 300.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_intrinsic_scaling() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Intrinsic),
|
||||
create_header(TableColumnWidth::Intrinsic),
|
||||
]);
|
||||
|
||||
let widths = table.compute_column_widths(100.0, &[150.0, 150.0]);
|
||||
assert_eq!(widths[0], 50.0);
|
||||
assert_eq!(widths[1], 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_intrinsic_no_scaling_needed() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Intrinsic),
|
||||
create_header(TableColumnWidth::Intrinsic),
|
||||
]);
|
||||
|
||||
let widths = table.compute_column_widths(400.0, &[100.0, 100.0]);
|
||||
assert_eq!(widths[0], 100.0);
|
||||
assert_eq!(widths[1], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_widths_empty_table() {
|
||||
let state = create_test_state();
|
||||
let table = Table::new(state, 800.0, 500.0);
|
||||
|
||||
let widths = table.compute_column_widths(500.0, &[]);
|
||||
assert!(widths.is_empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TableStateHandle Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_table_state_handle_new() {
|
||||
let state = create_test_state();
|
||||
assert!(state.column_widths().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_state_handle_shared_across_clones() {
|
||||
let state = create_test_state();
|
||||
let state_clone = state.clone();
|
||||
|
||||
{
|
||||
let mut inner = state.inner.borrow_mut();
|
||||
inner.column_widths = vec![100.0, 200.0];
|
||||
}
|
||||
|
||||
assert_eq!(state_clone.column_widths(), vec![100.0, 200.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_state_handle_row_count() {
|
||||
let state = TableStateHandle::new(100, |_, _| vec![]);
|
||||
assert_eq!(state.row_count(), 100);
|
||||
|
||||
state.set_row_count(200);
|
||||
assert_eq!(state.row_count(), 200);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Function Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_lefts() {
|
||||
let widths = vec![100.0, 150.0, 50.0];
|
||||
let lefts = Table::compute_column_lefts(&widths);
|
||||
assert_eq!(lefts, vec![0.0, 100.0, 250.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_lefts_empty() {
|
||||
let widths: Vec<f32> = vec![];
|
||||
let lefts = Table::compute_column_lefts(&widths);
|
||||
assert!(lefts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_column_lefts_single() {
|
||||
let widths = vec![100.0];
|
||||
let lefts = Table::compute_column_lefts(&widths);
|
||||
assert_eq!(lefts, vec![0.0]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TableConfig Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_table_config_default() {
|
||||
let config = TableConfig::default();
|
||||
assert_eq!(config.border_width, 1.0);
|
||||
assert_eq!(config.cell_padding, 8.0);
|
||||
assert!(config.row_background.alternating.is_none());
|
||||
assert_eq!(config.vertical_sizing, TableVerticalSizing::Viewported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_column_width_default() {
|
||||
let width = TableColumnWidth::default();
|
||||
assert!(matches!(width, TableColumnWidth::Flex(1.0)));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Virtualization Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_sumtree_initialization_with_row_count() {
|
||||
let state = TableStateHandle::new(100, |_, _| vec![]);
|
||||
state.set_row_count(100);
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
assert_eq!(inner.row_count, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sumtree_row_height_invalidation() {
|
||||
let state = TableStateHandle::new(10, |_, _| vec![]);
|
||||
|
||||
{
|
||||
let mut inner = state.inner.borrow_mut();
|
||||
let mut tree = SumTree::new();
|
||||
for _ in 0..10 {
|
||||
tree.push(TableRowItem {
|
||||
height: Some(Pixels::new(50.0)),
|
||||
});
|
||||
}
|
||||
inner.rows = tree;
|
||||
inner.last_measured_row_index = 9;
|
||||
}
|
||||
|
||||
state.invalidate_row_height(5);
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
let mut cursor = inner.rows.cursor::<RowCount, ()>();
|
||||
cursor.seek(&RowCount(5), sum_tree::SeekBias::Right);
|
||||
if let Some(item) = cursor.item() {
|
||||
assert!(item.height.is_none());
|
||||
}
|
||||
assert!(inner.last_measured_row_index <= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_to_row_updates_scroll_offset() {
|
||||
let state = TableStateHandle::new(100, |_, _| vec![]);
|
||||
|
||||
state.scroll_to_row(50, None);
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
assert_eq!(inner.scroll_top.row_index.0, 50);
|
||||
assert_eq!(inner.scroll_top.offset_from_start, Pixels::zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_to_row_with_offset() {
|
||||
let state = TableStateHandle::new(100, |_, _| vec![]);
|
||||
|
||||
state.scroll_to_row(25, Some(Pixels::new(10.0)));
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
assert_eq!(inner.scroll_top.row_index.0, 25);
|
||||
assert_eq!(inner.scroll_top.offset_from_start, Pixels::new(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approximate_height_with_measured_rows() {
|
||||
let state = TableStateHandle::new(10, |_, _| vec![]);
|
||||
|
||||
{
|
||||
let mut inner = state.inner.borrow_mut();
|
||||
inner.header_height = Pixels::new(40.0);
|
||||
let mut tree = SumTree::new();
|
||||
for _ in 0..10 {
|
||||
tree.push(TableRowItem {
|
||||
height: Some(Pixels::new(30.0)),
|
||||
});
|
||||
}
|
||||
inner.rows = tree;
|
||||
}
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
let approx_height = inner.approximate_height();
|
||||
assert_eq!(approx_height, Pixels::new(340.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approximate_height_estimates_unmeasured_rows() {
|
||||
let state = TableStateHandle::new(10, |_, _| vec![]);
|
||||
|
||||
{
|
||||
let mut inner = state.inner.borrow_mut();
|
||||
inner.header_height = Pixels::new(40.0);
|
||||
let mut tree = SumTree::new();
|
||||
for i in 0..10 {
|
||||
tree.push(TableRowItem {
|
||||
height: if i < 5 { Some(Pixels::new(30.0)) } else { None },
|
||||
});
|
||||
}
|
||||
inner.rows = tree;
|
||||
}
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
let approx_height = inner.approximate_height();
|
||||
assert_eq!(approx_height, Pixels::new(340.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_start_row_idx_tracking() {
|
||||
let state = TableStateHandle::new(100, |_, _| vec![]);
|
||||
|
||||
{
|
||||
let mut inner = state.inner.borrow_mut();
|
||||
inner.visible_start_row_idx = 42;
|
||||
}
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
assert_eq!(inner.visible_start_row_idx, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_scroll_offset_respects_viewport() {
|
||||
let state = TableStateHandle::new(10, |_, _| vec![]);
|
||||
|
||||
{
|
||||
let mut inner = state.inner.borrow_mut();
|
||||
inner.header_height = Pixels::new(40.0);
|
||||
inner.viewport_height = Pixels::new(200.0);
|
||||
let mut tree = SumTree::new();
|
||||
for _ in 0..10 {
|
||||
tree.push(TableRowItem {
|
||||
height: Some(Pixels::new(50.0)),
|
||||
});
|
||||
}
|
||||
inner.rows = tree;
|
||||
}
|
||||
|
||||
let inner = state.inner.borrow();
|
||||
let max_scroll_px = inner.header_height + inner.rows.summary().height - inner.viewport_height;
|
||||
assert!(max_scroll_px > Pixels::zero());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multiple Layout Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_visible_row_count_starts_at_zero() {
|
||||
let state = TableStateHandle::new(5, |_, _| {
|
||||
vec![create_empty_element(), create_empty_element()]
|
||||
});
|
||||
let table = Table::new(state, 400.0, 300.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
]);
|
||||
|
||||
assert_eq!(table.visible_row_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_children_vector_is_initially_empty() {
|
||||
let state = TableStateHandle::new(10, |_, _| {
|
||||
vec![create_empty_element(), create_empty_element()]
|
||||
});
|
||||
let table = Table::new(state, 400.0, 300.0).with_headers(vec![
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
create_header(TableColumnWidth::Fixed(100.0)),
|
||||
]);
|
||||
|
||||
assert_eq!(table.visible_row_count(), 0);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
use float_cmp::assert_approx_eq;
|
||||
|
||||
use crate::scene::ZIndex;
|
||||
use crate::App;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_laid_out_text_height() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|_ctx| {
|
||||
let text_frame = TextFrame::mock("foo\nbar\nbaz");
|
||||
let line_count = text_frame.lines().len();
|
||||
let laid_out_text = LaidOutText::Frame(Arc::new(text_frame));
|
||||
let height = laid_out_text.height();
|
||||
let expected = 13. * 1.2 * line_count as f32;
|
||||
assert_approx_eq!(f32, height, expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// We calculate height of a line by multiplying the line's font size by line
|
||||
/// height ratio. This test ensures that the height of a laid out line respects
|
||||
/// this calculation.
|
||||
#[test]
|
||||
fn test_laid_out_line_height() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|_ctx| {
|
||||
let line = Line::mock_from_str("foo");
|
||||
let laid_out_line = LaidOutText::Line(Arc::new(line));
|
||||
let height = laid_out_line.height();
|
||||
|
||||
// 13 and 1.2 are the default font size and line height ratios, respectively.
|
||||
let expected = 13. * 1.2;
|
||||
assert_approx_eq!(f32, height, expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_line_char_hit_testing_respects_y_bounds() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|_ctx| {
|
||||
let mut line = Line::mock_from_str("foo");
|
||||
line.width = 30.;
|
||||
line.runs[0].width = 30.;
|
||||
let line = Arc::new(line);
|
||||
let line_height = line.height();
|
||||
let mut text = Text::new_inline("foo", crate::fonts::FamilyId(0), 13.);
|
||||
text.laid_out_text = LaidOutText::Line(Arc::clone(&line));
|
||||
text.origin = Some(Point::from_vec2f(vec2f(10., 20.), ZIndex::new(0)));
|
||||
|
||||
assert!(text.get_char_index(&vec2f(10., 19.9)).is_none());
|
||||
assert!(text
|
||||
.get_char_index(&vec2f(10., 20. + line_height + 0.1))
|
||||
.is_none());
|
||||
assert!(text
|
||||
.get_char_index(&vec2f(10., 20. + line_height / 2.))
|
||||
.is_some());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_non_overlapping_ranges() {
|
||||
let highlight = Highlight::new();
|
||||
|
||||
let range1 = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![1, 2, 3],
|
||||
};
|
||||
let range2 = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![5, 6, 7],
|
||||
};
|
||||
|
||||
let result = HighlightedRange::merge_overlapping_ranges(vec![range1.clone(), range2.clone()]);
|
||||
|
||||
assert_eq!(result, vec![range1, range2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_contiguous_ranges() {
|
||||
let highlight = Highlight::new();
|
||||
|
||||
let range1 = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![1, 2, 3],
|
||||
};
|
||||
let range2 = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![4, 5, 6],
|
||||
};
|
||||
|
||||
let result = HighlightedRange::merge_overlapping_ranges(vec![range1.clone(), range2.clone()]);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![1, 2, 3, 4, 5, 6],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_overlapping_ranges() {
|
||||
let highlight = Highlight::new();
|
||||
|
||||
let range1 = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![1, 2, 3],
|
||||
};
|
||||
let range2 = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![3, 4, 5],
|
||||
};
|
||||
|
||||
let result = HighlightedRange::merge_overlapping_ranges(vec![range1.clone(), range2.clone()]);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![1, 2, 3, 4, 5],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_single_range() {
|
||||
let highlight = Highlight::new();
|
||||
|
||||
let range = HighlightedRange {
|
||||
highlight,
|
||||
highlight_indices: vec![1, 2, 3],
|
||||
};
|
||||
|
||||
let result = HighlightedRange::merge_overlapping_ranges(vec![range.clone()]);
|
||||
|
||||
assert_eq!(result, vec![range]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_empty_ranges() {
|
||||
let result = HighlightedRange::merge_overlapping_ranges(vec![]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_adjacent_non_contiguous_ranges() {
|
||||
let highlight1 = Highlight::new();
|
||||
let highlight2 = Highlight::new();
|
||||
|
||||
let range1 = HighlightedRange {
|
||||
highlight: highlight1,
|
||||
highlight_indices: vec![1, 2],
|
||||
};
|
||||
let range2 = HighlightedRange {
|
||||
highlight: highlight2,
|
||||
highlight_indices: vec![4, 5],
|
||||
};
|
||||
|
||||
let result = HighlightedRange::merge_overlapping_ranges(vec![range1.clone(), range2.clone()]);
|
||||
|
||||
assert_eq!(result, vec![range1, range2]);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use crate::event::{DispatchedEvent, ModifiersState};
|
||||
|
||||
use super::{
|
||||
try_rect_with_z, AfterLayoutContext, AppContext, Element, Event, EventContext, LayoutContext,
|
||||
PaintContext, Point, ScrollData, ScrollableElement, SizeConstraint, ZIndex,
|
||||
};
|
||||
|
||||
use crate::units::{IntoLines, IntoPixels, Lines, Pixels};
|
||||
use crate::ClipBounds;
|
||||
use async_channel::Sender;
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use std::{cmp, ops::Range, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UniformListState(Arc<Mutex<StateInner>>);
|
||||
|
||||
struct StateInner {
|
||||
/// The number of lines from the visible viewport to the top.
|
||||
scroll_top: Lines,
|
||||
scroll_to: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for UniformListState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UniformListState {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(StateInner {
|
||||
scroll_top: Default::default(),
|
||||
scroll_to: None,
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn scroll_to(&self, item_ix: usize) {
|
||||
self.0.lock().scroll_to = Some(item_ix);
|
||||
}
|
||||
|
||||
pub fn scroll_top(&self) -> Lines {
|
||||
self.0.lock().scroll_top
|
||||
}
|
||||
|
||||
/// Adjusts the current scroll position by the given number of lines.
|
||||
/// Negative values scroll towards the top of the list.
|
||||
pub fn add_scroll_top(&self, delta: f32) {
|
||||
let mut state = self.0.lock();
|
||||
state.scroll_top = (state.scroll_top + delta.into_lines()).max(Lines::zero());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UniformList<F, G>
|
||||
where
|
||||
F: Fn(Range<usize>, &AppContext) -> G,
|
||||
G: Iterator<Item = Box<dyn Element>>,
|
||||
{
|
||||
state: UniformListState,
|
||||
item_count: usize,
|
||||
build_items: F,
|
||||
scroll_max: Option<Lines>,
|
||||
items: Vec<Box<dyn Element>>,
|
||||
origin: Option<Point>,
|
||||
size: Option<Vector2F>,
|
||||
line_height: Option<Pixels>,
|
||||
visible_items_tx: Option<Sender<Range<usize>>>,
|
||||
// This is a short-term solution for properly handling events on stacks. A stack will always
|
||||
// put its children on higher z-indexes than its origin, so a hit test using the standard
|
||||
// `z_index` method would always result in the event being covered (by the children of the
|
||||
// stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element.
|
||||
// Then we use that upper bound to do the hit testing, which means a parent will always get
|
||||
// events from its children, regardless of whether they are stacks or not.
|
||||
child_max_z_index: Option<ZIndex>,
|
||||
}
|
||||
|
||||
impl<F, G> UniformList<F, G>
|
||||
where
|
||||
F: Fn(Range<usize>, &AppContext) -> G,
|
||||
G: Iterator<Item = Box<dyn Element>>,
|
||||
{
|
||||
pub fn new(state: UniformListState, item_count: usize, build_items: F) -> Self {
|
||||
Self {
|
||||
state,
|
||||
item_count,
|
||||
build_items,
|
||||
scroll_max: None,
|
||||
items: Vec::new(),
|
||||
origin: None,
|
||||
size: None,
|
||||
line_height: None,
|
||||
visible_items_tx: None,
|
||||
child_max_z_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies the visible items using the given Sender.
|
||||
pub fn notify_visible_items(mut self, visible_items_tx: Sender<Range<usize>>) -> Self {
|
||||
self.visible_items_tx = Some(visible_items_tx);
|
||||
self
|
||||
}
|
||||
|
||||
fn scroll_internal(
|
||||
&self,
|
||||
position: Vector2F,
|
||||
delta: Vector2F,
|
||||
precise: bool,
|
||||
ctx: &mut EventContext,
|
||||
_: &AppContext,
|
||||
) -> bool {
|
||||
if !self.rect().unwrap().contains_point(position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let delta = if precise {
|
||||
// Non-precise scrolling is in terms of pixels, so convert it to lines.
|
||||
delta.y() / self.items.first().unwrap().size().unwrap().y()
|
||||
} else {
|
||||
delta.y()
|
||||
};
|
||||
|
||||
let mut state = self.state.0.lock();
|
||||
state.scroll_top = (state.scroll_top - delta.into_lines())
|
||||
.max(Lines::zero())
|
||||
.min(self.scroll_max.unwrap());
|
||||
|
||||
ctx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
fn autoscroll(&mut self, list_height: Pixels, item_height: Pixels) {
|
||||
let mut state = self.state.0.lock();
|
||||
|
||||
// The scroll_max can be negative if the list height is much bigger than item_height *
|
||||
// item. Negative scroll_max results in random behavior where the list is rendered with
|
||||
// "shadow" elements.
|
||||
// To handle this, we make sure that it's set to either a positive number or 0.
|
||||
let test: Pixels = list_height / item_height;
|
||||
let scroll_max = (self.item_count as f32 - (test).as_f32())
|
||||
.max(0.)
|
||||
.into_lines();
|
||||
if state.scroll_top > scroll_max {
|
||||
state.scroll_top = scroll_max;
|
||||
}
|
||||
|
||||
if let Some(item_ix) = state.scroll_to.take() {
|
||||
let item_top = (item_ix as f32).into_lines();
|
||||
let item_bottom = item_top + 1.0.into_lines();
|
||||
|
||||
if item_top < state.scroll_top {
|
||||
state.scroll_top = item_top;
|
||||
} else if item_bottom > (state.scroll_top + list_height.to_lines(item_height)) {
|
||||
state.scroll_top = item_bottom - list_height.to_lines(item_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_top(&self) -> Lines {
|
||||
self.state.0.lock().scroll_top
|
||||
}
|
||||
|
||||
fn rect(&self) -> Option<RectF> {
|
||||
try_rect_with_z(self.origin, self.size)
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, G> Element for UniformList<F, G>
|
||||
where
|
||||
F: Fn(Range<usize>, &AppContext) -> G,
|
||||
G: Iterator<Item = Box<dyn Element>>,
|
||||
{
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
ctx: &mut LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> Vector2F {
|
||||
if constraint.max.y().is_infinite() {
|
||||
unimplemented!(
|
||||
"UniformList does not support being rendered with an unconstrained height"
|
||||
);
|
||||
}
|
||||
let mut size = constraint.max;
|
||||
let mut item_constraint =
|
||||
SizeConstraint::new(vec2f(size.x(), 0.0), vec2f(size.x(), f32::INFINITY));
|
||||
|
||||
let first_item = (self.build_items)(0..1, app).next();
|
||||
if let Some(mut first_item) = first_item {
|
||||
let mut item_size = first_item.layout(item_constraint, ctx, app);
|
||||
item_size.set_x(size.x());
|
||||
item_constraint.min = item_size;
|
||||
item_constraint.max = item_size;
|
||||
|
||||
self.line_height = Some(item_size.y().into_pixels());
|
||||
let scroll_height = self.item_count as f32 * item_size.y();
|
||||
if scroll_height < size.y() {
|
||||
size.set_y(size.y().min(scroll_height).max(constraint.min.y()));
|
||||
}
|
||||
|
||||
self.autoscroll(size.y().into_pixels(), item_size.y().into_pixels());
|
||||
|
||||
let start = cmp::min(self.scroll_top().as_f64() as usize, self.item_count);
|
||||
let end = cmp::min(
|
||||
self.item_count,
|
||||
start + (size.y() / item_size.y()).ceil() as usize + 1,
|
||||
);
|
||||
|
||||
if let Some(visible_items_notifier) = &self.visible_items_tx {
|
||||
visible_items_notifier
|
||||
.try_send(start..end)
|
||||
.expect("unable to send visible_items");
|
||||
};
|
||||
|
||||
self.items.clear();
|
||||
self.items.extend((self.build_items)(start..end, app));
|
||||
|
||||
self.scroll_max =
|
||||
Some((self.item_count as f32 - size.y() / item_size.y()).into_lines());
|
||||
|
||||
for item in &mut self.items {
|
||||
item.layout(item_constraint, ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
self.size = Some(size);
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
|
||||
for item in &mut self.items {
|
||||
item.after_layout(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
ctx.scene.start_layer(ClipBounds::BoundedBy(RectF::new(
|
||||
origin,
|
||||
self.size().unwrap(),
|
||||
)));
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
if let Some(item) = self.items.first() {
|
||||
let item_height = item.size().unwrap().y();
|
||||
|
||||
let mut item_origin =
|
||||
origin - vec2f(0.0, self.scroll_top().as_f64().fract() as f32 * item_height);
|
||||
for item in &mut self.items {
|
||||
item.paint(item_origin, ctx, app);
|
||||
item_origin += vec2f(0.0, item_height);
|
||||
}
|
||||
}
|
||||
ctx.scene.stop_layer();
|
||||
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &DispatchedEvent,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
if self.items.iter_mut().fold(false, |was_handled, child| {
|
||||
let current_handled = child.dispatch_event(event, ctx, app);
|
||||
was_handled || current_handled
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let z_index = *self.child_max_z_index.as_ref().unwrap();
|
||||
|
||||
if let Some(Event::ScrollWheel {
|
||||
position,
|
||||
delta,
|
||||
precise,
|
||||
modifiers: ModifiersState { ctrl: false, .. },
|
||||
}) = event.at_z_index(z_index, ctx)
|
||||
{
|
||||
self.scroll_internal(*position, *delta, *precise, ctx, app)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, G> ScrollableElement for UniformList<F, G>
|
||||
where
|
||||
F: Fn(Range<usize>, &AppContext) -> G,
|
||||
G: Iterator<Item = Box<dyn Element>>,
|
||||
{
|
||||
#[allow(clippy::unwrap_in_result)]
|
||||
fn scroll_data(&self, _app: &AppContext) -> Option<ScrollData> {
|
||||
let line_height = self.line_height.unwrap_or_default();
|
||||
Some(ScrollData {
|
||||
scroll_start: self
|
||||
.state
|
||||
.scroll_top()
|
||||
.to_pixels(self.line_height.unwrap_or_default()),
|
||||
visible_px: match self.line_height {
|
||||
Some(_line_height) => {
|
||||
(self.size.expect("Size must be set during layout").y()).into_pixels()
|
||||
}
|
||||
None => Pixels::zero(),
|
||||
},
|
||||
total_size: (self.item_count as f32).into_lines().to_pixels(line_height),
|
||||
})
|
||||
}
|
||||
|
||||
fn scroll(&mut self, delta: Pixels, ctx: &mut EventContext) {
|
||||
let mut state = self.state.0.lock();
|
||||
state.scroll_top = (state.scroll_top
|
||||
- delta.to_lines(self.line_height.unwrap_or_default()))
|
||||
.max(Lines::zero())
|
||||
.min(self.scroll_max.unwrap());
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "uniform_list_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
elements::{
|
||||
ChildAnchor, ConstrainedBox, DispatchEventResult, EventHandler, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Rect, Stack,
|
||||
},
|
||||
platform::WindowStyle,
|
||||
App, AppContext, Entity, Presenter, TypedActionView, ViewContext, WindowInvalidation,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
enum ElementIdentifier {
|
||||
Base,
|
||||
Inset,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct View {
|
||||
// Maps identifier to number of mouse down events
|
||||
mouse_downs: HashMap<ElementIdentifier, usize>,
|
||||
list_state: UniformListState,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_action("event_handler_test:mouse_down", View::mouse_down);
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
|
||||
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
|
||||
*entry += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for View {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl crate::core::View for View {
|
||||
fn ui_name() -> &'static str {
|
||||
"event_handler_test_view"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
UniformList::new(self.list_state.clone(), 1, move |_, _| {
|
||||
let mut inner_stack = Stack::new();
|
||||
inner_stack.add_child(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(100.)
|
||||
.with_width(100.)
|
||||
.finish(),
|
||||
);
|
||||
inner_stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Inset);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 75.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(
|
||||
EventHandler::new(inner_stack.finish())
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action(
|
||||
"event_handler_test:mouse_down",
|
||||
ElementIdentifier::Base,
|
||||
);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(Rect::new().finish())
|
||||
.with_height(25.)
|
||||
.with_width(25.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(|evt, _, _| {
|
||||
evt.dispatch_action(
|
||||
"event_handler_test:mouse_down",
|
||||
ElementIdentifier::Overlay,
|
||||
);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(75., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
|
||||
[stack.finish()].into_iter()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for View {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uniform_layered_click_handling() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
app.update(init);
|
||||
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
|
||||
|
||||
let mut presenter = Presenter::new(window_id);
|
||||
|
||||
let mut updated = HashSet::new();
|
||||
updated.insert(app.root_view_id(window_id).unwrap());
|
||||
let invalidation = WindowInvalidation {
|
||||
updated,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app.update(move |ctx| {
|
||||
presenter.invalidate(invalidation, ctx);
|
||||
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
|
||||
assert_eq!(scene.z_index(), ZIndex::new(0));
|
||||
assert_eq!(scene.layer_count(), 6);
|
||||
let presenter = Rc::new(RefCell::new(presenter));
|
||||
|
||||
// Click on the overlay
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the inset
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the top-left area of the base
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(10., 10.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter.clone(),
|
||||
);
|
||||
|
||||
// Click on the bottom-right area of the base
|
||||
ctx.simulate_window_event(
|
||||
Event::LeftMouseDown {
|
||||
position: vec2f(90., 90.),
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
is_first_mouse: false,
|
||||
},
|
||||
window_id,
|
||||
presenter,
|
||||
);
|
||||
});
|
||||
|
||||
view.read(app, |view, _| {
|
||||
assert_eq!(
|
||||
1,
|
||||
*view.mouse_downs.get(&ElementIdentifier::Overlay).unwrap()
|
||||
);
|
||||
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
|
||||
assert_eq!(2, *view.mouse_downs.get(&ElementIdentifier::Base).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! Module containing the definition of [`List`], an element that holds elements of various sizes
|
||||
//! and only lays out the elements that are visible in the viewport.
|
||||
|
||||
use std::{
|
||||
ops::{AddAssign, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use derivative::Derivative;
|
||||
use derive_more::AddAssign;
|
||||
use ordered_float::OrderedFloat;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
use sum_tree::SumTree;
|
||||
|
||||
use crate::{
|
||||
units::{IntoPixels, Pixels},
|
||||
ClipBounds,
|
||||
};
|
||||
|
||||
use super::{
|
||||
new_scrollable::{NewScrollableElement, ScrollableAxis},
|
||||
AppContext, Axis, Element, ScrollData, ScrollableElement, SizeConstraint,
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd)]
|
||||
pub struct ScrollOffset {
|
||||
/// The item that is scrolled to.
|
||||
list_item_index: Count,
|
||||
/// Number of pixels offset from the start of the item.
|
||||
offset_from_start: Pixels,
|
||||
}
|
||||
|
||||
impl ScrollOffset {
|
||||
/// The item that is scrolled to.
|
||||
pub fn list_item_index(&self) -> usize {
|
||||
self.list_item_index.0
|
||||
}
|
||||
|
||||
/// Number of pixels offset from the start of the item.
|
||||
pub fn offset_from_start(&self) -> Pixels {
|
||||
self.offset_from_start
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the callback function used to adjust scroll position
|
||||
/// when an element's height is invalidated and re-measured.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct ScrollPreservation<T> {
|
||||
/// Called after re-measuring to compute a scroll adjustment.
|
||||
/// Arguments: (invalidated_index, captured_context, app_context) -> new_scroll_offset
|
||||
///
|
||||
/// Only called for the currently scrolled item, so the callback does not
|
||||
/// need access to the list state inner.
|
||||
adjustment_fn: Box<dyn Fn(usize, &T, &AppContext) -> Option<Pixels>>,
|
||||
}
|
||||
|
||||
/// Internal state of the [`List`] that is shared across multiple renders of the element.
|
||||
#[derive(Clone)]
|
||||
pub struct ListState<T>(Rc<RefCell<ListStateInner<T>>>);
|
||||
|
||||
impl<T> ListState<T> {
|
||||
/// Creates a new ListState with scroll preservation.
|
||||
///
|
||||
/// The `adjustment_fn` is called after re-measuring to compute a new absolute scroll offset.
|
||||
/// It receives the invalidated index and the captured scroll context.
|
||||
/// Only called for the currently scrolled item.
|
||||
///
|
||||
/// Returns the list state and a receiver for scroll events. The list sends
|
||||
/// the current scroll offset through the channel whenever the user scrolls.
|
||||
pub fn new_with_scroll_preservation(
|
||||
render_fn: impl Fn(usize, ScrollOffset, &AppContext) -> Box<dyn Element> + 'static,
|
||||
adjustment_fn: impl Fn(usize, &T, &AppContext) -> Option<Pixels> + 'static,
|
||||
) -> (Self, async_channel::Receiver<ScrollOffset>) {
|
||||
let render_fn = Arc::new(render_fn);
|
||||
let (tx, rx) = async_channel::bounded(5);
|
||||
let inner = ListStateInner::new_with_scroll_preservation(render_fn, adjustment_fn, tx);
|
||||
(Self(Rc::new(RefCell::new(inner))), rx)
|
||||
}
|
||||
|
||||
/// Adds an item to the list.
|
||||
pub fn add_item(&self) {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
inner.add_item();
|
||||
}
|
||||
|
||||
/// Invalidates the height of the item at the given index, forcing it
|
||||
/// to be re-measured on the next layout pass. If scroll preservation is
|
||||
/// configured and the current scroll item's height becomes `None`,
|
||||
/// the adjustment function will run during layout to preserve scroll position.
|
||||
pub fn invalidate_height_for_index(&self, index: usize) {
|
||||
self.0.borrow_mut().invalidate_height_for_index(index);
|
||||
}
|
||||
|
||||
/// Removes a specific item from the list.
|
||||
pub fn remove(&self, index: usize) {
|
||||
self.0.borrow_mut().remove(index);
|
||||
}
|
||||
|
||||
pub fn scroll_to(&self, index: usize) {
|
||||
self.0.borrow_mut().scroll_to(index, None);
|
||||
}
|
||||
|
||||
/// An offset of 0 means the top of the item is at the top of the viewport
|
||||
/// A negative offset means we've moved up from that, so the item above is visible
|
||||
/// A positive offset means we've moved down from that to partway through the item
|
||||
pub fn scroll_to_with_offset(&self, index: usize, offset_from_start: Pixels) {
|
||||
self.0
|
||||
.borrow_mut()
|
||||
.scroll_to(index, Some(offset_from_start));
|
||||
}
|
||||
|
||||
pub fn is_scrolled_to_item(&self, index: usize) -> bool {
|
||||
self.0.borrow().scroll_top.list_item_index.0 == index
|
||||
}
|
||||
|
||||
pub fn get_scroll_index(&self) -> usize {
|
||||
self.0.borrow().scroll_top.list_item_index.0
|
||||
}
|
||||
|
||||
pub fn get_scroll_offset(&self) -> Pixels {
|
||||
self.0.borrow().scroll_top.offset_from_start
|
||||
}
|
||||
|
||||
pub fn get_viewport_height(&self) -> Pixels {
|
||||
self.0.borrow().viewport_height
|
||||
}
|
||||
|
||||
/// Sets the persistent scroll context used by explicit height invalidation
|
||||
/// during layout. Call this when scrolling settles so the adjustment
|
||||
/// function has context to recompute scroll position.
|
||||
pub fn set_scroll_context(&self, context: Option<T>) {
|
||||
self.0.borrow_mut().current_scroll_context = context;
|
||||
}
|
||||
|
||||
pub fn is_vertical_range_visible(
|
||||
&self,
|
||||
item_index: usize,
|
||||
start_offset: Pixels,
|
||||
end_offset: Pixels,
|
||||
) -> bool {
|
||||
let inner = self.0.borrow();
|
||||
|
||||
// Get absolute positions of the targets
|
||||
let (start_absolute, end_absolute) = {
|
||||
let mut cursor = inner.content.cursor::<Count, Height>();
|
||||
cursor.seek(&Count(item_index), sum_tree::SeekBias::Right);
|
||||
let item_start = cursor.start().0 .0;
|
||||
(item_start + start_offset, item_start + end_offset)
|
||||
};
|
||||
|
||||
// Get absolute viewport range
|
||||
let viewport_top = inner.scroll_top_pixels();
|
||||
let viewport_bottom = viewport_top + inner.viewport_height;
|
||||
|
||||
start_absolute >= viewport_top
|
||||
&& start_absolute <= viewport_bottom
|
||||
&& end_absolute >= viewport_top
|
||||
&& end_absolute <= viewport_bottom
|
||||
}
|
||||
}
|
||||
|
||||
impl ListState<()> {
|
||||
pub fn new(
|
||||
render_fn: impl Fn(usize, ScrollOffset, &AppContext) -> Box<dyn Element> + 'static,
|
||||
) -> Self {
|
||||
let render_fn = Arc::new(render_fn);
|
||||
Self(Rc::new(RefCell::new(ListStateInner::new(render_fn))))
|
||||
}
|
||||
}
|
||||
|
||||
type ListItemRenderFn = dyn Fn(usize, ScrollOffset, &AppContext) -> Box<dyn Element>;
|
||||
|
||||
/// An element that holds elements of various sizes and only lays out the elements that are visible in the viewport.
|
||||
/// If each element is provably the same size, consider using [`UniformList`] instead for a vastly simpler API.
|
||||
///
|
||||
/// In order for viewporting to work, the [`List`] element assumes that each item does not change in height once it
|
||||
/// is laid out. If an item's height changes, [`ListState::invalidate_height_for_index`] must be called in order
|
||||
/// to invalidate the cached height of the item.
|
||||
pub struct List<T: 'static = ()> {
|
||||
list_state: ListState<T>,
|
||||
children: Vec<Box<dyn Element>>,
|
||||
size: Vector2F,
|
||||
origin: Option<super::Point>,
|
||||
}
|
||||
|
||||
impl<T: 'static> List<T> {
|
||||
pub fn new(list_state: ListState<T>) -> Self {
|
||||
Self {
|
||||
list_state,
|
||||
children: Vec::new(),
|
||||
size: Vector2F::zero(),
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_vertically(&mut self, delta: Pixels, ctx: &mut super::EventContext) {
|
||||
let mut list_state = self.list_state.0.borrow_mut();
|
||||
|
||||
let viewport_height = self.size.y().into_pixels();
|
||||
let scroll_max = (list_state.approximate_height() - viewport_height).max(Pixels::zero());
|
||||
let current_scroll_top = list_state.scroll_top_pixels();
|
||||
let new_scroll_top = (current_scroll_top - delta)
|
||||
.max(Pixels::zero())
|
||||
.min(scroll_max);
|
||||
|
||||
list_state.scroll_top = list_state.absolute_pixels_to_scroll_offset(new_scroll_top);
|
||||
list_state.broadcast_scroll_event();
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn vertical_scroll_data(&self) -> ScrollData {
|
||||
let list_state = self.list_state.0.borrow();
|
||||
ScrollData {
|
||||
scroll_start: list_state.scroll_top_pixels(),
|
||||
visible_px: self.size.y().into_pixels(),
|
||||
total_size: list_state.approximate_height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all visible items in the viewport, and call the render function.
|
||||
/// Update the size sumtree and return the new child elements.
|
||||
fn render_visible_items(
|
||||
&self,
|
||||
child_constraint: super::SizeConstraint,
|
||||
list_state: &mut ListStateInner<T>,
|
||||
ctx: &mut super::LayoutContext,
|
||||
app: &AppContext,
|
||||
) -> (Range<usize>, Vec<Box<dyn Element>>) {
|
||||
// Iterate through items and layout only those that fit in the viewport
|
||||
let mut measured_items = Vec::new();
|
||||
let mut rendered_height = 0.;
|
||||
let mut children = vec![];
|
||||
let mut cursor = list_state.content.cursor::<Count, Count>();
|
||||
cursor.seek(
|
||||
&list_state.scroll_top.list_item_index,
|
||||
sum_tree::SeekBias::Right,
|
||||
);
|
||||
|
||||
let cursor_start = *cursor.start();
|
||||
|
||||
for (index, _) in cursor.enumerate() {
|
||||
// Break if we've filled the viewport.
|
||||
if rendered_height >= list_state.viewport_height.as_f32() {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut element =
|
||||
(list_state.render_fn)(index + cursor_start.0, list_state.scroll_top, app);
|
||||
let element_size = element.layout(child_constraint, ctx, app);
|
||||
|
||||
measured_items.push(ListItem {
|
||||
height: Some(element_size.y().into_pixels()),
|
||||
});
|
||||
children.push(element);
|
||||
|
||||
// If this is the first item, the element could only be partially in the viewport.
|
||||
// If that's the case, we only want to include the portion that is actually in the viewport.
|
||||
if index == 0 {
|
||||
rendered_height +=
|
||||
element_size.y() - list_state.scroll_top.offset_from_start.as_f32();
|
||||
} else {
|
||||
rendered_height += element_size.y();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the sum tree with the newly measured items.
|
||||
let measured_range = cursor_start.0..(cursor_start.0 + measured_items.len());
|
||||
let new_items = {
|
||||
let mut cursor = list_state.content.cursor::<Count, ()>();
|
||||
|
||||
let mut new_items =
|
||||
cursor.slice(&Count(measured_range.start), sum_tree::SeekBias::Right);
|
||||
new_items.extend(measured_items);
|
||||
|
||||
cursor.seek(&Count(measured_range.end), sum_tree::SeekBias::Right);
|
||||
|
||||
new_items.push_tree(cursor.suffix());
|
||||
new_items
|
||||
};
|
||||
|
||||
list_state.content = new_items;
|
||||
list_state.last_measured_index = list_state
|
||||
.content
|
||||
.summary()
|
||||
.measured_count
|
||||
.saturating_sub(1);
|
||||
|
||||
(measured_range, children)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static> Element for List<T> {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: super::SizeConstraint,
|
||||
ctx: &mut super::LayoutContext,
|
||||
app: &super::AppContext,
|
||||
) -> Vector2F {
|
||||
// Create a child constraint with unbounded vertical height.
|
||||
// List items should be laid out at their natural height - the List handles
|
||||
// viewport clipping and scroll management internally.
|
||||
let child_constraint = SizeConstraint {
|
||||
min: vec2f(constraint.min.x(), 0.),
|
||||
max: vec2f(constraint.max.x(), f32::INFINITY),
|
||||
};
|
||||
|
||||
self.children.clear();
|
||||
let mut list_state = self.list_state.0.borrow_mut();
|
||||
|
||||
// Check if the current scroll item's height has been invalidated (set to None)
|
||||
// before we measure anything. This drives scroll preservation after rendering.
|
||||
let scroll_item_index = list_state.scroll_top.list_item_index.0;
|
||||
let scroll_item_was_invalidated = list_state.height_for_index(scroll_item_index).is_none();
|
||||
|
||||
{
|
||||
// Ensure every item up to the scroll position is measured so that the sum tree is up-to-date
|
||||
// before we seek into it by height.
|
||||
if list_state.scroll_top.list_item_index.0 > list_state.last_measured_index {
|
||||
let start_index = list_state.last_measured_index;
|
||||
let end_index = list_state.scroll_top.list_item_index.0;
|
||||
|
||||
let new_items = {
|
||||
let mut cursor = list_state.content.cursor::<Count, Count>();
|
||||
let mut new_items =
|
||||
cursor.slice(&Count(start_index), sum_tree::SeekBias::Right);
|
||||
for index in 0..(end_index - start_index) {
|
||||
let mut element =
|
||||
(list_state.render_fn)(index + start_index, list_state.scroll_top, app);
|
||||
let element_size = element.layout(child_constraint, ctx, app);
|
||||
new_items.push(ListItem {
|
||||
height: Some(element_size.y().into_pixels()),
|
||||
});
|
||||
cursor.next();
|
||||
}
|
||||
new_items.push_tree(cursor.suffix());
|
||||
new_items
|
||||
};
|
||||
|
||||
list_state.content = new_items;
|
||||
list_state.last_measured_index = list_state
|
||||
.content
|
||||
.summary()
|
||||
.measured_count
|
||||
.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a negative offset, we should render the previous item(s) above the current item
|
||||
// However we only render items from the current index downwards, so we convert to a positive offset on an earlier item
|
||||
if list_state.scroll_top.offset_from_start < Pixels::zero() {
|
||||
list_state.scroll_top = list_state.absolute_pixels_to_scroll_offset(
|
||||
list_state.scroll_top_pixels().max(Pixels::zero()),
|
||||
);
|
||||
}
|
||||
|
||||
let size = Vector2F::new(constraint.max.x(), constraint.max.y());
|
||||
let viewport_height = size.y();
|
||||
|
||||
list_state.viewport_height = viewport_height.into_pixels();
|
||||
|
||||
(_, self.children) = self.render_visible_items(child_constraint, &mut list_state, ctx, app);
|
||||
|
||||
// Ensure the scroll top never exceeds the maximum scroll position.
|
||||
let max_scroll_top = list_state.max_scroll_offset(viewport_height.into_pixels());
|
||||
if list_state.scroll_top > max_scroll_top {
|
||||
list_state.scroll_top = max_scroll_top;
|
||||
(_, self.children) =
|
||||
self.render_visible_items(child_constraint, &mut list_state, ctx, app);
|
||||
}
|
||||
|
||||
// If the current scroll item was invalidated (height was None at layout start),
|
||||
// apply scroll preservation to maintain visual position.
|
||||
if scroll_item_was_invalidated {
|
||||
if let Some(scroll_ctx) = &list_state.current_scroll_context {
|
||||
if let Some(scroll_preservation) = &list_state.scroll_preservation {
|
||||
if let Some(new_scroll_offset) =
|
||||
(scroll_preservation.adjustment_fn)(scroll_item_index, scroll_ctx, app)
|
||||
{
|
||||
list_state.scroll_top.offset_from_start = new_scroll_offset;
|
||||
(_, self.children) =
|
||||
self.render_visible_items(child_constraint, &mut list_state, ctx, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.size = size;
|
||||
size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, ctx: &mut super::AfterLayoutContext, app: &super::AppContext) {
|
||||
for child in &mut self.children {
|
||||
child.after_layout(ctx, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut super::PaintContext, app: &super::AppContext) {
|
||||
self.origin = Some(super::Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
ctx.scene.start_layer(ClipBounds::BoundedBy(RectF::new(
|
||||
origin,
|
||||
self.size().expect("size should be set at paint time"),
|
||||
)));
|
||||
|
||||
let list_state = self.list_state.0.borrow();
|
||||
|
||||
let mut origin = origin;
|
||||
// Offset the origin by the scroll top offset since the child may be only partially in the viewport.
|
||||
origin.set_y(origin.y() - list_state.scroll_top.offset_from_start.as_f32());
|
||||
|
||||
for child in &mut self.children {
|
||||
child.paint(origin, ctx, app);
|
||||
let child_height = child.size().expect("Child should exist at paint time").y();
|
||||
origin.set_y(origin.y() + child_height);
|
||||
}
|
||||
|
||||
ctx.scene.stop_layer();
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
Some(self.size)
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<super::Point> {
|
||||
self.origin
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &crate::event::DispatchedEvent,
|
||||
ctx: &mut super::EventContext,
|
||||
app: &super::AppContext,
|
||||
) -> bool {
|
||||
let mut handled = false;
|
||||
for child in &mut self.children {
|
||||
let child_dispatch = child.dispatch_event(event, ctx, app);
|
||||
handled |= child_dispatch;
|
||||
}
|
||||
|
||||
handled
|
||||
}
|
||||
}
|
||||
|
||||
// T: 'static is required by the struct definition (List<T: 'static>), which
|
||||
// needs it because ScrollableElement::finish_scrollable requires Self: 'static.
|
||||
impl<T: 'static> ScrollableElement for List<T> {
|
||||
fn scroll_data(&self, _app: &super::AppContext) -> Option<super::ScrollData> {
|
||||
Some(self.vertical_scroll_data())
|
||||
}
|
||||
|
||||
fn scroll(&mut self, delta: Pixels, ctx: &mut super::EventContext) {
|
||||
self.scroll_vertically(delta, ctx);
|
||||
}
|
||||
|
||||
fn should_handle_scroll_wheel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static> NewScrollableElement for List<T> {
|
||||
fn axis(&self) -> ScrollableAxis {
|
||||
ScrollableAxis::Vertical
|
||||
}
|
||||
|
||||
fn scroll_data(&self, axis: Axis, _app: &AppContext) -> Option<ScrollData> {
|
||||
match axis {
|
||||
Axis::Horizontal => None,
|
||||
Axis::Vertical => Some(self.vertical_scroll_data()),
|
||||
}
|
||||
}
|
||||
|
||||
fn axis_should_handle_scroll_wheel(&self, _axis: super::Axis) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn scroll(&mut self, delta: Pixels, axis: super::Axis, ctx: &mut super::EventContext) {
|
||||
match axis {
|
||||
Axis::Horizontal => {}
|
||||
Axis::Vertical => self.scroll_vertically(delta, ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Derivative)]
|
||||
#[derivative(Debug)]
|
||||
struct ListItem {
|
||||
/// Whether this element has been laid out and painted.
|
||||
height: Option<Pixels>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct LayoutSummary {
|
||||
// Total height of all items in the sum tree.
|
||||
height: Pixels,
|
||||
// Total number of items in the sum tree.
|
||||
count: usize,
|
||||
// Number of items that have been measured.
|
||||
measured_count: usize,
|
||||
}
|
||||
|
||||
impl sum_tree::Item for ListItem {
|
||||
type Summary = LayoutSummary;
|
||||
|
||||
fn summary(&self) -> Self::Summary {
|
||||
let height = self.height;
|
||||
LayoutSummary {
|
||||
height: height.unwrap_or_default(),
|
||||
count: 1,
|
||||
measured_count: height.is_some() as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<&LayoutSummary> for LayoutSummary {
|
||||
fn add_assign(&mut self, rhs: &LayoutSummary) {
|
||||
self.height += rhs.height;
|
||||
self.count += rhs.count;
|
||||
self.measured_count += rhs.measured_count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of a list item, in pixels.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Height(OrderedFloat<Pixels>);
|
||||
|
||||
impl From<Pixels> for Height {
|
||||
fn from(value: Pixels) -> Self {
|
||||
Self(OrderedFloat(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for Height {
|
||||
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
||||
self.0 += summary.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, AddAssign)]
|
||||
struct Count(usize);
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, LayoutSummary> for Count {
|
||||
fn add_summary(&mut self, summary: &'a LayoutSummary) {
|
||||
self.0 += summary.count;
|
||||
}
|
||||
}
|
||||
|
||||
struct ListStateInner<T> {
|
||||
content: SumTree<ListItem>,
|
||||
scroll_top: ScrollOffset,
|
||||
/// The last known measured item where every item _up to_ this item has been measured.
|
||||
///
|
||||
/// This can differ from the number of measured items in the sumtree if an item in the middle
|
||||
/// of the measured range was invalidated.
|
||||
last_measured_index: usize,
|
||||
render_fn: Arc<ListItemRenderFn>,
|
||||
viewport_height: Pixels,
|
||||
/// Optional scroll preservation callback.
|
||||
scroll_preservation: Option<ScrollPreservation<T>>,
|
||||
/// Persistently stored scroll context, updated by the consumer (e.g. code
|
||||
/// review view) on every scroll event. Used during layout to adjust scroll
|
||||
/// position when the current scroll item's height was invalidated.
|
||||
current_scroll_context: Option<T>,
|
||||
/// Scroll position updates are sent through this channel whenever the
|
||||
/// user scrolls. Created by [`ListState::new_with_scroll_preservation`].
|
||||
/// Cleared automatically if the receiver is dropped (channel closed).
|
||||
scroll_tx: Option<async_channel::Sender<ScrollOffset>>,
|
||||
}
|
||||
|
||||
impl ListStateInner<()> {
|
||||
fn new(render_fn: Arc<ListItemRenderFn>) -> Self {
|
||||
Self {
|
||||
content: SumTree::new(),
|
||||
scroll_top: ScrollOffset::default(),
|
||||
last_measured_index: 0,
|
||||
render_fn,
|
||||
viewport_height: Pixels::zero(),
|
||||
scroll_preservation: None,
|
||||
current_scroll_context: None,
|
||||
scroll_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ListStateInner<T> {
|
||||
fn new_with_scroll_preservation(
|
||||
render_fn: Arc<ListItemRenderFn>,
|
||||
adjustment_fn: impl Fn(usize, &T, &AppContext) -> Option<Pixels> + 'static,
|
||||
scroll_tx: async_channel::Sender<ScrollOffset>,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: SumTree::new(),
|
||||
scroll_top: ScrollOffset::default(),
|
||||
last_measured_index: 0,
|
||||
render_fn,
|
||||
viewport_height: Pixels::zero(),
|
||||
scroll_preservation: Some(ScrollPreservation {
|
||||
adjustment_fn: Box::new(adjustment_fn),
|
||||
}),
|
||||
current_scroll_context: None,
|
||||
scroll_tx: Some(scroll_tx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ListStateInner<T> {
|
||||
/// Gets the height of an item at the given index.
|
||||
pub fn height_for_index(&self, index: usize) -> Option<Pixels> {
|
||||
let mut cursor = self.content.cursor::<Count, ()>();
|
||||
cursor.seek(&Count(index), sum_tree::SeekBias::Right);
|
||||
cursor.item().and_then(|item| item.height)
|
||||
}
|
||||
|
||||
/// Converts an absolute pixel position from the top into a (item_index, offset) scroll position.
|
||||
/// This handles both measured and unmeasured regions of the list.
|
||||
fn absolute_pixels_to_scroll_offset(&self, absolute_pixels: Pixels) -> ScrollOffset {
|
||||
// If we're scrolling to something that hasn't been measured, we determine which
|
||||
// item to scroll to based on the average height of the measured items. We can't
|
||||
// seek into the sum tree because the sum tree only has the height of _measured_ items
|
||||
// and we still want to scroll to an approximate position in the list instead of just to the end.
|
||||
// If the new position is within the measured range, we can directly seek into the sum tree
|
||||
// to get an exact location to scroll to.
|
||||
let approximate_item = (absolute_pixels / self.average_height_per_measured_item()).as_f32();
|
||||
let approximate_item = approximate_item.min(self.content.summary().count as f32);
|
||||
|
||||
let approximate_scroll_item = approximate_item.floor() as usize;
|
||||
let approximate_scroll_offset =
|
||||
(approximate_item.fract()).into_pixels() * self.average_height_per_measured_item();
|
||||
|
||||
if approximate_scroll_item > self.last_measured_index {
|
||||
ScrollOffset {
|
||||
list_item_index: Count(approximate_scroll_item),
|
||||
offset_from_start: approximate_scroll_offset,
|
||||
}
|
||||
} else {
|
||||
let new_scroll_item = {
|
||||
let mut cursor = self.content.cursor::<Height, Count>();
|
||||
cursor.seek(&Height(absolute_pixels.into()), sum_tree::SeekBias::Right);
|
||||
*cursor.start()
|
||||
};
|
||||
|
||||
let height = {
|
||||
let mut cursor = self.content.cursor::<Count, Height>();
|
||||
cursor.seek(&new_scroll_item, sum_tree::SeekBias::Right);
|
||||
cursor.start().0
|
||||
};
|
||||
|
||||
let offset = absolute_pixels - height.0;
|
||||
|
||||
ScrollOffset {
|
||||
list_item_index: new_scroll_item,
|
||||
offset_from_start: offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the approximate height of the list based on the elements that have been measured so far.
|
||||
/// If all elements have been measured, this returns the exact height.
|
||||
fn approximate_height(&self) -> Pixels {
|
||||
let summary = self.content.summary();
|
||||
|
||||
if summary.count == summary.measured_count {
|
||||
return summary.height;
|
||||
}
|
||||
let total_height = summary.height;
|
||||
let total_items = summary.count;
|
||||
let measured_items = summary.measured_count;
|
||||
|
||||
if measured_items == 0 {
|
||||
return Pixels::zero();
|
||||
}
|
||||
|
||||
((total_height.as_f32() / measured_items as f32) * total_items as f32).into_pixels()
|
||||
}
|
||||
|
||||
/// Returns the average height of the items that have been measured so far.
|
||||
fn average_height_per_measured_item(&self) -> Pixels {
|
||||
let summary = self.content.summary();
|
||||
let total_height = summary.height;
|
||||
let measured_items = summary.measured_count;
|
||||
|
||||
(total_height.as_f32() / measured_items as f32).into_pixels()
|
||||
}
|
||||
|
||||
fn invalidate_height_for_index(&mut self, index: usize) {
|
||||
let (new_tree, last_measured) = {
|
||||
let mut cursor = self.content.cursor::<Count, ()>();
|
||||
let mut new_items = cursor.slice(&Count(index), sum_tree::SeekBias::Right);
|
||||
// The last measured item is now the last measured item _before_ the index we're invalidating.
|
||||
let last_measured = new_items.summary().measured_count.saturating_sub(1);
|
||||
|
||||
let list_item = ListItem { height: None };
|
||||
|
||||
new_items.push(list_item);
|
||||
cursor.next();
|
||||
new_items.push_tree(cursor.suffix());
|
||||
(new_items, last_measured)
|
||||
};
|
||||
|
||||
self.content = new_tree;
|
||||
self.last_measured_index = self.last_measured_index.min(last_measured);
|
||||
}
|
||||
|
||||
fn remove(&mut self, index: usize) {
|
||||
let (new_tree, last_measured) = {
|
||||
let mut cursor = self.content.cursor::<Count, ()>();
|
||||
let mut new_items = cursor.slice(&Count(index), sum_tree::SeekBias::Right);
|
||||
cursor.next();
|
||||
// The last measured item is now the last measured item _before_ the index we're invalidating.
|
||||
let last_measured = new_items.summary().measured_count.saturating_sub(1);
|
||||
new_items.push_tree(cursor.suffix());
|
||||
(new_items, last_measured)
|
||||
};
|
||||
|
||||
self.content = new_tree;
|
||||
self.last_measured_index = self.last_measured_index.min(last_measured);
|
||||
if self.scroll_top.list_item_index.0 > index {
|
||||
self.scroll_top.list_item_index.0 -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of pixels scrolled from the top.
|
||||
fn scroll_top_pixels(&self) -> Pixels {
|
||||
let mut cursor = self.content.cursor::<Count, Height>();
|
||||
cursor.seek(&self.scroll_top.list_item_index, sum_tree::SeekBias::Right);
|
||||
cursor.start().0 .0 + self.scroll_top.offset_from_start
|
||||
}
|
||||
|
||||
fn add_item(&mut self) {
|
||||
self.content.push(ListItem { height: None });
|
||||
}
|
||||
|
||||
fn scroll_to(&mut self, index: usize, offset_from_start: Option<Pixels>) {
|
||||
let new_scroll_top = ScrollOffset {
|
||||
list_item_index: Count(index),
|
||||
offset_from_start: offset_from_start.unwrap_or(Pixels::zero()),
|
||||
};
|
||||
self.scroll_top = new_scroll_top;
|
||||
self.broadcast_scroll_event();
|
||||
}
|
||||
|
||||
/// Sends the current scroll position through the channel, if any.
|
||||
/// If the channel is full, the event is silently dropped (the consumer
|
||||
/// uses debouncing and will catch up on the next event).
|
||||
fn broadcast_scroll_event(&mut self) {
|
||||
if let Some(tx) = &self.scroll_tx {
|
||||
if tx.is_closed() {
|
||||
self.scroll_tx = None;
|
||||
} else {
|
||||
let _ = tx.try_send(self.scroll_top);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the maximum scroll offset based on the approximate height of the list.
|
||||
fn max_scroll_offset(&self, viewport_height: Pixels) -> ScrollOffset {
|
||||
let max_scroll_top = (self.approximate_height() - viewport_height).max(Pixels::zero());
|
||||
|
||||
let index = {
|
||||
let mut cursor = self.content.cursor::<Height, Count>();
|
||||
cursor.seek(&Height(max_scroll_top.into()), sum_tree::SeekBias::Right);
|
||||
*cursor.start()
|
||||
};
|
||||
|
||||
let height = {
|
||||
let mut cursor = self.content.cursor::<Count, Height>();
|
||||
cursor.seek(&index, sum_tree::SeekBias::Right);
|
||||
cursor.start().0
|
||||
};
|
||||
|
||||
let offset = max_scroll_top - height.0;
|
||||
|
||||
ScrollOffset {
|
||||
list_item_index: index,
|
||||
offset_from_start: offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewported_list_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user