Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+272
View File
@@ -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 dont 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 dont 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® its easy to ship features that are not accessible).
//!
//! WarpUI framework right now provides 3 ways of announcing whats 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 dont have (and I dont 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. Its 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 whats 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 users
//! 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 features page) and whats the
//! keybinding to access it;
//! - If theres 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. Its 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.
/// Systems 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),
}
}
}
+19
View File
@@ -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 {}
+16
View File
@@ -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]>>;
}
+143
View File
@@ -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)
}
}
+105
View File
@@ -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)
})
}
}
+140
View File
@@ -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) {}
}
+447
View File
@@ -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");
}
+47
View File
@@ -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)
}
}
+68
View File
@@ -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;
}
+666
View File
@@ -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));
})
}
+255
View File
@@ -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;
}
+26
View File
@@ -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"
);
});
});
}
+919
View File
@@ -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 its 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 its 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);
});
});
}
+318
View File
@@ -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;
}
+165
View File
@@ -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 whats 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()
}
}
+50
View File
@@ -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>,
}
+4
View File
@@ -0,0 +1,4 @@
mod root_view;
pub mod view_tree_debug_view;
pub(crate) use root_view::DebugRootView;
+45
View File
@@ -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(&current_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();
}
}
}
}
+193
View File
@@ -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()
);
});
});
}
+122
View File
@@ -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))
}
}
+124
View File
@@ -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()
}
}
+75
View File
@@ -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);
});
});
}
+134
View File
@@ -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
}
}
+390
View File
@@ -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());
}
+190
View File
@@ -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");
}
+138
View File
@@ -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()
}
}
+799
View File
@@ -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)
}
}
+166
View File
@@ -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