Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+106
View File
@@ -0,0 +1,106 @@
[package]
name = "galaxyui_core"
version = "0.1.0"
edition = "2021"
authors.workspace = true
publish.workspace = true
license = "MIT"
[features]
traces = []
# Feature enabled only when app is compiled for integration tests.
integration_tests = ["dep:minimp4", "dep:openh264"]
# If enabled, writes named telemetry events to the info log. Should never be
# enabled in release builds without a PII audit of telemetry data.
log_named_telemetry_events = []
schema_gen = ["dep:schemars"]
settings_value = ["dep:settings_value"]
# Enables utilities for testing.
test-util = []
[dependencies]
anyhow = "1.0"
async-broadcast.workspace = true
async-channel.workspace = true
async-executor = "1.4.1"
async-task.workspace = true
async-fs.workspace = true
bounded-vec-deque = "0.1"
bytes.workspace = true
cfg-if.workspace = true
chrono = "0.4"
dashmap.workspace = true
derivative.workspace = true
derive_more.workspace = true
dirs.workspace = true
enum-iterator.workspace = true
float-cmp.workspace = true
futures = "0.3"
futures-lite.workspace = true
futures-util = { version = "0.3", default-features = false }
image.workspace = true
minimp4 = { workspace = true, optional = true }
openh264 = { workspace = true, optional = true }
infer = "0.19.0"
instant.workspace = true
itertools.workspace = true
lazy_static = "1.4.0"
log = "0.4"
schemars = { version = "1", optional = true }
settings_value = { workspace = true, optional = true, features = ["derive"] }
markdown_parser.workspace = true
num-derive = "0.4.2"
num-traits.workspace = true
num_cpus = "1.0"
ordered-float = "3.0.0"
parking_lot.workspace = true
pathfinder_color.workspace = true
pathfinder_geometry.workspace = true
rand.workspace = true
rangemap.workspace = true
raw-window-handle = "0.6.2"
resvg.workspace = true
rstar = "0.12.2"
rustc-hash.workspace = true
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json.workspace = true
similar.workspace = true
smallvec.workspace = true
string-offset.workspace = true
strum.workspace = true
strum_macros.workspace = true
sum_tree.workspace = true
tempfile.workspace = true
thiserror.workspace = true
titlecase = "1.0"
tokio.workspace = true
trait-set = "0.3.0"
vec1.workspace = true
galaxy_util.workspace = true
[dev-dependencies]
command = { workspace = true, features = ["test-util"] }
concat-idents.workspace = true
tempfile = "3.2.0"
ctor = "0.1.19"
rust-embed.workspace = true
simplelog.workspace = true
# Enable the `test-util` feature for our own package when running tests.
galaxyui_core = { path = ".", features = ["test-util"] }
[target.'cfg(not(target_family = "wasm"))'.dependencies]
async-io.workspace = true
ctrlc.workspace = true
font-kit.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
arboard = { workspace = true, features = ["image-data"] }
[target.'cfg(target_family = "wasm")'.dependencies]
gloo.workspace = true
wasm-bindgen-futures.workspace = true
woothee = "0.13.0"
[build-dependencies]
cfg_aliases.workspace = true
+54
View File
@@ -0,0 +1,54 @@
# WarpUI
## Whirlwind tour
WarpUI contains many interlocking concepts. It's difficult to explain any one part of the system without reference to other parts. Because of this, this guide tries to provide an overview by exploring the relationships between the major concepts before providing a lot of detail on any one of them.
Rust's strict ownership rules are a challenge for user interfaces, where multi-directional dataflow is often critical. If every object has one and only one owner, how do we express things like event handlers?
## The global App object, entities, and handles
WarpUI solves this problem with the `App` object, which is the sole owner of all the views and models in the application. We collectively refer to views and models as **entities**. Entities can hold references to other entities via **handles**. A handle provides access to an entity in specific, limited circumstances. Take for example a multi-tabbed terminal. The application window is occupied by a `WorkspaceView`, and we want this workspace to contain multiple `TerminalView`s. Rather than holding the `TerminalView`s directly, the `Workspace` instead holds a vector of `ViewHandle<TerminalView>`.
```rust
struct WorkspaceView {
sessions: Vec<ViewHandle<TerminalView>>,
}
```
On its own, a `ViewHandle` can't do much. Its existence prevents the referenced view from being discarded by the global `App` object, but it doesn't provide direct access to the referenced view. A handle is basically a glorified identifier. To convert a handle into an actual reference, you need a reference to an **app context** object, which will be provided by the global `App` object at specific points in time.
An example of one of those times could be the `render` method on `WorkspaceView`, which will be called by the framework whenever the workspace's on-screen representation is updated. One of the parameters to `render` is an `&AppContext`, which can be passed to the `as_ref` method on a `ViewHandle` to retrieve a reference to the underlying object.
Many details are elided in the code example below so we can focus on the topic at hand, but imagine we wanted to know the titles of all our terminal sessions so we could render them in tabs:
```rust
impl View for WorkspaceView {
fn render<'a>(&self, ..., ctx: &AppContext) -> ... {
let titles = self.sessions.iter().map(|handle| handle.as_ref(ctx).title()).collect::<Vec<String>>;
...
}
...
}
```
After we return from the `render` method and lose access to the `&AppContext` parameter provided during its call, we no longer have access to the terminal views they reference.
Entities are of course entitled to own any state they like directly as well. Handles are only required when an entity needs to reference an entity. The reasons it would make sense to express a piece of application state as its own entity will become clearer as we sketch in more aspects of the system.
## Elements
The framework requires all views to implement the `View` trait, and a key method of this trait is `render`, which we showcased above. This method's job is to compute a visual description of the view based on its current state, and it is called whenever the view's state changes.
To do describe the view's appearence, render returns an **element**. While a single view may exist for an arbitrary amount of time, changing its state as the user interacts with the application, an element is designed to exist for only a single frame. More precicely, elements returned by views that haven't changed are recycled across multiple frames, but conceptually you can think of an element as a throwaway object that is discarded and replaced whenever the view that returned it changes.
The framework ships with several elements that can be composed to perform common tasks such as drawing backgrounds and borders, adding padding, rendering label text, laying out elements horizontally and vertically, handlign events, etc. The stock elements are loosely based on the Flutter framework. It's also straightforward to define your own custom elements, giving you detailed control over layout and the ability to imperatively paint pixels on scene via the hardware-accelerated `Scene` API.
## Actions
### Action handlers
### Action dispatch
## Views
+13
View File
@@ -0,0 +1,13 @@
// We can use `std::process:Command` here because this is invoked within a build script,
// _not_ within the Warp binary (where it could cause a terminal to temporarily flash on
// Windows).
#![allow(clippy::disallowed_types)]
use cfg_aliases::cfg_aliases;
fn main() {
cfg_aliases! {
macos: { target_os = "macos" },
native: { not(target_family = "wasm") },
}
}
+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 (galaxyui), we don’t benefit from the built-in
//! VoiceOver integration and objc NSAccessibility APIs. This is both good and bad for our app and
//! the UI framework.
//!
//! Good parts:
//! - We actually had to think on how to add support to the UI framework to make it easier for future
//! app developers to not overlook a11y;
//! - We don’t rely on complicated defaults and the cumbersome experience of analyzing all the UI
//! elements in the app, instead we can provide a more ergonomic experience to blind users.
//!
//! Bad parts:
//! - It takes time to implement the full support (for example, we still lack the ability to focus a
//! certain UI element, like a button, and “click it” or otherwise act on it with just the keyboard);
//! - We need to think about a11y (yeah, I mentioned it in good parts, but given that a11y is usually
//! thirdwheeling next to AwesomeFeatures™ and BugFixes® it’s easy to ship features that are not accessible).
//!
//! WarpUI framework right now provides 3 ways of announcing what’s happening in the app:
//! - Accessibility Contents for the currently focused View;
//! - Accessibility Contents for the currently performed Action;
//! - On-demand emitting Accessibility Contents.
//!
//! ## Testing for a11y
//! We don’t have (and I don’t know if such a thing even exists) a way to automatically test a11y
//! features. To test it then, we just need to run the app and run VoiceOver.
//!
//! To run it - go to your System Preferences -> Accessibility -> VoiceOver, and then click
//! “Enable VoiceOver”. Note that it may be loud and distracting. It’s sometimes easier to turn off
//! the sound, and check the content of the tiny rectangle that will show on your screen together
//! with VoiceOver.
//!
//! ### What to look for?
//! - Whenever a new view opens, the user will get the information about what’s happening;
//! - The feature is keyboard accessible (a good practice would be to have it in the command palette);
//! - Any meaningful changes to the state of feature are announced (both triggered by a user’s
//! Action or a background Event);
//! - The user can quit the feature and get back to the command input with keyboard (a good
//! practice would be to keep it consistent among all the features, and quit via Escape key);
//! - User docs mention whether the feature is accessible (on the feature’s page) and what’s the
//! keybinding to access it;
//! - If there’s a video/GIF in the user docs, make sure that its content is also reflected in text.
use crate::Action;
use pathfinder_geometry::rect::RectF;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
/// Main structure describing the content VoiceOver (or other screen reading software) will receive.
pub struct AccessibilityContent {
/// The main information related to the view/action/event. Keep it short and as informative
/// as possible. It’s semi-equivalent to
/// [AccessibilityLabel](https://developer.apple.com/documentation/appkit/nsaccessibility/1534976-accessibilitylabel).
/// For example, a `value` for the command editor in our case is “Command Input”.
pub value: String,
/// Optional string that provides more context and information about available actions.
/// For example, help for the “Command Input” informs about “cmd-up” action.
pub help: Option<String>,
/// (currently unused) The rectangle that describes where the given element is on the screen.
/// System’s APIs then draw a frame around that element, making it super clear what object
/// the description is referring to.
/// Frame support is a work-in-progress in Warp and right now this field is omitted and not set.
pub frame: Option<RectF>,
/// The role a given element has. Note that we use our own, WarpUI-defined roles (vs those that
/// come from the NSAccessibility framework). The role describes the action/element/event role (
/// for example, when the “Command Input” is focused, it announces with a `TextareaRole`.
/// This is another helper field that lets the user understand what they can potentially do,
/// or what object is in focus.
pub role: WarpA11yRole,
}
/// Verbosity level of a11y announcements. By default, all announcements include both the value
/// and help (if provided). It can be changed per-app basis, in AppContext.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema_gen",
schemars(
description = "Verbosity level for screen reader announcements.",
rename_all = "snake_case"
)
)]
#[cfg_attr(feature = "settings_value", derive(settings_value::SettingsValue))]
pub enum AccessibilityVerbosity {
/// Default verbosity level, includes help string.
#[default]
#[serde(rename = "VERBOSE")]
#[cfg_attr(feature = "schema_gen", schemars(rename = "verbose"))]
Verbose,
/// Concise level, only announces `value` from AccessibilityContent.
#[serde(rename = "CONCISE")]
#[cfg_attr(feature = "schema_gen", schemars(rename = "concise"))]
Concise,
}
/// For single character strings, we want to announce extra information (such as
/// capitalization). For longer/shorter strings - we just return the string itself.
// Note: This should be localized or passed directly to voice over in a "working" format.
// For some reason, VO currently ignores out punctuation and capital letters so we need to do it
// manually. The Appkit Obj-C APIs don't provide support to enforce punctuation or change of pitch
// for capital letters, so for now we're just implementing the missing pieces by hand, yay!
fn string_announcement(s: String) -> String {
if s.len() != 1 {
return s;
}
let c = s.chars().next().expect("String has exactly 1 character");
if c.is_uppercase() {
return format!("capital {s}");
}
if c.is_ascii_punctuation() {
return match c {
'.' => "period".to_string(),
'!' => "exclamation mark".to_string(),
'~' => "tilde".to_string(),
'`' => "accent".to_string(),
'^' => "caret".to_string(),
'(' => "left parenthesis".to_string(),
')' => "right parenthesis".to_string(),
'-' => "hyphen".to_string(),
'_' => "underscore".to_string(),
'?' => "question mark".to_string(),
':' => "colon".to_string(),
';' => "semicolon".to_string(),
'"' => "double quotation mark".to_string(),
'\'' => "single quotation mark".to_string(),
'\\' => "backslash".to_string(),
'/' => "slash".to_string(),
',' => "comma".to_string(),
'[' => "left bracket".to_string(),
']' => "right bracket".to_string(),
'{' => "left brace".to_string(),
'}' => "right brace".to_string(),
'|' => "vertical line".to_string(),
// everything else seems to have proper interpretation in voiceover
_ => s,
};
}
s
}
impl AccessibilityContent {
// TODO add frame support
pub fn new_without_help<T>(value: T, role: WarpA11yRole) -> Self
where
T: Into<String>,
{
Self::new_internal::<T, String>(value, None, role)
}
pub fn new<V, H>(value: V, help: H, role: WarpA11yRole) -> Self
where
V: Into<String>,
H: Into<String>,
{
Self::new_internal(value, Some(help), role)
}
fn new_internal<V, H>(value: V, help: Option<H>, role: WarpA11yRole) -> Self
where
V: Into<String>,
H: Into<String>,
{
let value: String = value.into();
// Note that for values that are all whitespace, we still want to read them out, hence
// swapping certain whitespace characters with their "readings".
let value = if value.chars().all(char::is_whitespace) {
value
.replace(' ', " space ") // Note: order here is important, space should go first.
.replace('\t', " tab ")
.replace('\n', " newline ")
.trim()
.to_string()
} else {
string_announcement(value)
};
AccessibilityContent {
value,
help: help.map(|s| s.into()),
role,
frame: None,
}
}
pub fn with_frame(mut self, frame: Option<RectF>) -> Self {
self.frame = frame;
self
}
pub fn with_verbosity(mut self, verbosity: AccessibilityVerbosity) -> Self {
if matches!(verbosity, AccessibilityVerbosity::Concise) {
self.help = None;
}
self
}
}
#[derive(Default, Debug, Clone, Copy)]
pub enum WarpA11yRole {
ButtonRole,
CheckboxRole,
HelpRole,
ImageRole,
LinkRole,
ListRole,
MenuItemRole,
MenuRole,
PopoverRole,
ScrollareaRole,
TextRole,
TextareaRole,
TextfieldRole,
#[default]
WindowRole,
UserAction,
}
impl std::fmt::Display for WarpA11yRole {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
use WarpA11yRole::*;
let word = match self {
ButtonRole => "Button",
CheckboxRole => "Checkbox",
HelpRole => "Help",
ImageRole => "Image",
LinkRole => "Link",
ListRole => "List",
MenuItemRole => "MenuItem",
MenuRole => "Menu",
PopoverRole => "Popover",
ScrollareaRole => "Scrollarea",
TextRole => "Text",
TextareaRole => "Textarea",
TextfieldRole => "Textfield",
WindowRole => "Window",
UserAction => "Action",
};
write!(f, "{word}")
}
}
#[derive(Default)]
pub enum ActionAccessibilityContent {
#[default]
Empty,
Custom(AccessibilityContent),
CustomFn(fn(&dyn Action) -> AccessibilityContent),
}
impl ActionAccessibilityContent {
pub fn from_debug() -> Self {
Self::CustomFn(|action| {
AccessibilityContent::new_without_help(format!("{action:?}."), WarpA11yRole::UserAction)
})
}
}
impl From<Option<AccessibilityContent>> for ActionAccessibilityContent {
fn from(opt: Option<AccessibilityContent>) -> ActionAccessibilityContent {
match opt {
None => ActionAccessibilityContent::Empty,
Some(content) => ActionAccessibilityContent::Custom(content),
}
}
}
+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<galaxy_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));
})
}
@@ -0,0 +1,255 @@
use std::{
any::{type_name, TypeId},
fmt::{self, Debug},
hash::{Hash, Hasher},
marker::PhantomData,
sync::{Arc, Weak},
};
use parking_lot::Mutex;
use crate::{core::RefCounts, AppContext, Entity, EntityId, EntityLocation, Handle, ModelContext};
/// A strong reference to a particular [`Entity`] instance within the application.
///
/// Handles structures are used in place of references (e.g.: `&Entity`) to avoid
/// the complexity of reference lifetimes and appeasing the borrow checker. A
/// handle can be combined with a reference to the application state (e.g.:
/// [`AppContext`]) to get access to the actual [`Entity`] instance behind the
/// handle.
pub struct ModelHandle<T> {
model_id: EntityId,
model_type: PhantomData<T>,
ref_counts: Weak<Mutex<RefCounts>>,
}
impl<T: Entity> ModelHandle<T> {
pub(in crate::core) fn new(model_id: EntityId, ref_counts: &Arc<Mutex<RefCounts>>) -> Self {
ref_counts.lock().inc_entity(model_id);
Self {
model_id,
model_type: PhantomData,
ref_counts: Arc::downgrade(ref_counts),
}
}
pub fn downgrade(&self) -> WeakModelHandle<T> {
WeakModelHandle::new(self.model_id)
}
pub fn id(&self) -> EntityId {
self.model_id
}
pub fn as_ref<'a, A: ModelAsRef>(&self, app: &'a A) -> &'a T {
app.model(self)
}
pub fn read<A, F, S>(&self, app: &A, read: F) -> S
where
A: ReadModel,
F: FnOnce(&T, &AppContext) -> S,
{
app.read_model(self, read)
}
pub fn update<A, F, S>(&self, app: &mut A, update: F) -> S
where
A: UpdateModel,
F: FnOnce(&mut T, &mut ModelContext<T>) -> S,
{
app.update_model(self, update)
}
}
impl<T> Clone for ModelHandle<T> {
fn clone(&self) -> Self {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().inc_entity(self.model_id);
}
Self {
model_id: self.model_id,
model_type: PhantomData,
ref_counts: self.ref_counts.clone(),
}
}
}
impl<T> PartialEq for ModelHandle<T> {
fn eq(&self, other: &Self) -> bool {
self.model_id == other.model_id
}
}
impl<T> Eq for ModelHandle<T> {}
impl<T> Hash for ModelHandle<T> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.model_id.hash(state);
}
}
impl<T> std::borrow::Borrow<EntityId> for ModelHandle<T> {
fn borrow(&self) -> &EntityId {
&self.model_id
}
}
impl<T> Debug for ModelHandle<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple(&format!("ModelHandle<{}>", type_name::<T>()))
.field(&self.model_id)
.finish()
}
}
unsafe impl<T> Send for ModelHandle<T> {}
unsafe impl<T> Sync for ModelHandle<T> {}
impl<T> Drop for ModelHandle<T> {
fn drop(&mut self) {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().dec_model(self.model_id);
}
}
}
impl<T> Handle<T> for ModelHandle<T> {
fn id(&self) -> EntityId {
self.model_id
}
fn location(&self) -> EntityLocation {
EntityLocation::Model(self.model_id)
}
}
/// A type-erased strong reference to a particular [`Entity`] instance within the
/// application.
///
/// `AnyModelHandle` is used within the core UI framework in places where we need
/// to hold a strong reference to a `Entity`, but don't want to add a generic type
/// parameter to the containing structure. See `singleton_models` in
/// [`AppContext`](crate::core::AppContext) for an example.
pub struct AnyModelHandle {
model_id: EntityId,
model_type: TypeId,
ref_counts: Weak<Mutex<RefCounts>>,
}
impl AnyModelHandle {
pub fn id(&self) -> EntityId {
self.model_id
}
pub fn is<T: 'static>(&self) -> bool {
TypeId::of::<T>() == self.model_type
}
pub fn downcast<T: Entity>(self) -> Option<ModelHandle<T>> {
if self.is::<T>() {
if let Some(ref_counts) = self.ref_counts.upgrade() {
return Some(ModelHandle::new(self.model_id, &ref_counts));
}
}
None
}
pub fn downcast_ref<'a, T: Entity>(&'a self, ctx: &'a AppContext) -> Option<&'a T> {
if self.is::<T>() {
return ctx.models.get(&self.model_id)?.as_any().downcast_ref();
}
None
}
}
impl Clone for AnyModelHandle {
fn clone(&self) -> Self {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().inc_entity(self.model_id);
}
Self {
model_id: self.model_id,
model_type: self.model_type,
ref_counts: self.ref_counts.clone(),
}
}
}
impl<T: Entity> From<ModelHandle<T>> for AnyModelHandle {
fn from(handle: ModelHandle<T>) -> Self {
if let Some(ref_counts) = handle.ref_counts.upgrade() {
ref_counts.lock().inc_entity(handle.model_id);
}
Self {
model_id: handle.model_id,
model_type: TypeId::of::<T>(),
ref_counts: handle.ref_counts.clone(),
}
}
}
impl Drop for AnyModelHandle {
fn drop(&mut self) {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().dec_model(self.model_id);
}
}
}
/// A weak reference to a particular [`Entity`] instance within the application.
///
/// `WeakModelHandle` is useful when a view wants to hold onto its own handle -
/// holding a strong reference via [`ModelHandle`] would create a reference
/// cycle that prevents the application from ever dropping the model.
pub struct WeakModelHandle<T> {
model_id: EntityId,
model_type: PhantomData<T>,
}
impl<T: Entity> WeakModelHandle<T> {
pub(super) fn new(model_id: EntityId) -> Self {
Self {
model_id,
model_type: PhantomData,
}
}
pub fn upgrade(&self, app: &AppContext) -> Option<ModelHandle<T>> {
if app.models.contains_key(&self.model_id) {
Some(ModelHandle::new(self.model_id, &app.ref_counts))
} else {
None
}
}
}
impl<T> Clone for WeakModelHandle<T> {
fn clone(&self) -> Self {
Self {
model_id: self.model_id,
model_type: PhantomData,
}
}
}
pub trait ModelAsRef {
fn model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T;
}
pub trait ReadModel: ModelAsRef {
fn read_model<T, F, S>(&self, handle: &ModelHandle<T>, read: F) -> S
where
T: Entity,
F: FnOnce(&T, &AppContext) -> S;
}
pub trait UpdateModel: ReadModel {
fn update_model<T, F, S>(&mut self, handle: &ModelHandle<T>, update: F) -> S
where
T: Entity,
F: FnOnce(&mut T, &mut ModelContext<T>) -> S;
}
@@ -0,0 +1,26 @@
pub mod context;
pub mod handle;
use std::any::Any;
use crate::Entity;
pub use self::{context::*, handle::*};
pub trait AnyModel {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
}
impl<T> AnyModel for T
where
T: Entity,
{
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
@@ -0,0 +1,837 @@
use std::{cell::RefCell, rc::Rc};
use super::*;
#[test]
fn test_transfer_view_to_window_updates_window_mapping() {
#[derive(Default)]
struct TestView {
value: usize,
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let view_to_transfer = app.add_view(window_1_id, |_| TestView { value: 42 });
let view_id = view_to_transfer.id();
app.read(|ctx| {
assert_eq!(
view_to_transfer.window_id(ctx),
window_1_id,
"view should initially be in window 1"
);
});
app.read(|ctx| {
assert!(
ctx.windows[&window_1_id].views.contains_key(&view_id),
"view should be in window 1's views map"
);
assert!(
!ctx.windows[&window_2_id].views.contains_key(&view_id),
"view should not be in window 2's views map yet"
);
});
let success =
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
assert!(success, "transfer should succeed");
app.read(|ctx| {
assert_eq!(
view_to_transfer.window_id(ctx),
window_2_id,
"view should now be in window 2"
);
});
app.read(|ctx| {
assert!(
!ctx.windows[&window_1_id].views.contains_key(&view_id),
"view should no longer be in window 1's views map"
);
assert!(
ctx.windows[&window_2_id].views.contains_key(&view_id),
"view should now be in window 2's views map"
);
});
view_to_transfer.read(&app, |view, _| {
assert_eq!(
view.value, 42,
"view data should be preserved after transfer"
);
});
});
}
#[test]
fn test_transfer_view_subscriptions_continue_working() {
#[derive(Default)]
struct EmitterView;
impl Entity for EmitterView {
type Event = usize;
}
impl View for EmitterView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"EmitterView"
}
}
impl TypedActionView for EmitterView {
type Action = ();
}
#[derive(Default)]
struct SubscriberView {
received_events: Vec<usize>,
}
impl Entity for SubscriberView {
type Event = ();
}
impl View for SubscriberView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"SubscriberView"
}
}
impl TypedActionView for SubscriberView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| EmitterView);
let (window_2_id, _) =
app.add_window(WindowStyle::NotStealFocus, |_| SubscriberView::default());
let emitter = app.add_view(window_1_id, |_| EmitterView);
let subscriber = app.add_view(window_2_id, |_| SubscriberView::default());
subscriber.update(&mut app, |_, ctx| {
ctx.subscribe_to_view(&emitter, |view, _, event, _| {
view.received_events.push(*event);
});
});
emitter.update(&mut app, |_, ctx| ctx.emit(1));
subscriber.read(&app, |view, _| {
assert_eq!(
view.received_events,
vec![1],
"should receive event before transfer"
);
});
let emitter_id = emitter.id();
let success =
app.update(|ctx| ctx.transfer_view_to_window(emitter_id, window_1_id, window_2_id));
assert!(success, "transfer should succeed");
emitter.update(&mut app, |_, ctx| ctx.emit(2));
subscriber.read(&app, |view, _| {
assert_eq!(
view.received_events,
vec![1, 2],
"should receive event after transfer"
);
});
emitter.update(&mut app, |_, ctx| ctx.emit(3));
subscriber.read(&app, |view, _| {
assert_eq!(
view.received_events,
vec![1, 2, 3],
"should continue receiving events"
);
});
});
}
#[test]
fn test_transfer_view_app_subscriptions_continue_working() {
#[derive(Default)]
struct TestView;
impl Entity for TestView {
type Event = usize;
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let emitter = app.add_view(window_1_id, |_| TestView);
let received_events: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
let received_events_clone = received_events.clone();
app.update(|ctx| {
ctx.subscribe_to_view(&emitter, move |_view, event, _ctx| {
received_events_clone.borrow_mut().push(*event);
});
});
emitter.update(&mut app, |_, ctx| ctx.emit(1));
assert_eq!(
*received_events.borrow(),
vec![1],
"should receive event before transfer"
);
let emitter_id = emitter.id();
let success =
app.update(|ctx| ctx.transfer_view_to_window(emitter_id, window_1_id, window_2_id));
assert!(success, "transfer should succeed");
emitter.update(&mut app, |_, ctx| ctx.emit(2));
assert_eq!(
*received_events.borrow(),
vec![1, 2],
"should receive event after transfer"
);
});
}
#[test]
fn test_transfer_view_observations_continue_working() {
#[derive(Default)]
struct ObserverView {
observed_counts: Vec<usize>,
}
impl Entity for ObserverView {
type Event = ();
}
impl View for ObserverView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"ObserverView"
}
}
impl TypedActionView for ObserverView {
type Action = ();
}
#[derive(Default)]
struct ObservedModel {
count: usize,
}
impl Entity for ObservedModel {
type Event = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) =
app.add_window(WindowStyle::NotStealFocus, |_| ObserverView::default());
let (window_2_id, _) =
app.add_window(WindowStyle::NotStealFocus, |_| ObserverView::default());
let model = app.add_model(|_| ObservedModel { count: 0 });
let observer = app.add_view(window_1_id, |_| ObserverView::default());
observer.update(&mut app, |_, ctx| {
ctx.observe(&model, |view, observed, ctx| {
view.observed_counts.push(observed.as_ref(ctx).count);
});
});
model.update(&mut app, |m, ctx| {
m.count = 1;
ctx.notify();
});
observer.read(&app, |view, _| {
assert_eq!(
view.observed_counts,
vec![1],
"should observe before transfer"
);
});
let observer_id = observer.id();
let success =
app.update(|ctx| ctx.transfer_view_to_window(observer_id, window_1_id, window_2_id));
assert!(success, "transfer should succeed");
model.update(&mut app, |m, ctx| {
m.count = 2;
ctx.notify();
});
observer.read(&app, |view, _| {
assert_eq!(
view.observed_counts,
vec![1, 2],
"should observe after transfer"
);
});
});
}
#[test]
fn test_on_window_transferred_callback_fires() {
struct TransferTrackingView {
transfer_events: Vec<(WindowId, WindowId)>,
}
impl Entity for TransferTrackingView {
type Event = ();
}
impl View for TransferTrackingView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TransferTrackingView"
}
fn on_window_transferred(
&mut self,
source_window_id: WindowId,
target_window_id: WindowId,
_ctx: &mut ViewContext<Self>,
) {
self.transfer_events
.push((source_window_id, target_window_id));
}
}
impl TypedActionView for TransferTrackingView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) =
app.add_window(WindowStyle::NotStealFocus, |_| TransferTrackingView {
transfer_events: Vec::new(),
});
let (window_2_id, _) =
app.add_window(WindowStyle::NotStealFocus, |_| TransferTrackingView {
transfer_events: Vec::new(),
});
let view = app.add_view(window_1_id, |_| TransferTrackingView {
transfer_events: Vec::new(),
});
view.read(&app, |v, _| {
assert!(v.transfer_events.is_empty(), "no transfers yet");
});
let view_id = view.id();
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
view.read(&app, |v, _| {
assert_eq!(
v.transfer_events,
vec![(window_1_id, window_2_id)],
"callback should fire with correct window IDs"
);
});
});
}
#[test]
fn test_weak_view_handle_upgrade_after_transfer() {
#[derive(Default)]
struct TestView {
value: usize,
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let view = app.add_view(window_1_id, |_| TestView { value: 42 });
let weak = view.downgrade();
app.read(|ctx| {
let upgraded = weak.upgrade(ctx);
assert!(
upgraded.is_some(),
"weak handle should upgrade before transfer"
);
assert_eq!(
upgraded.as_ref().map(|v| v.window_id(ctx)),
Some(window_1_id)
);
});
let view_id = view.id();
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
app.read(|ctx| {
let upgraded = weak.upgrade(ctx);
assert!(
upgraded.is_some(),
"weak handle should upgrade after transfer"
);
assert_eq!(
upgraded.as_ref().map(|v| v.window_id(ctx)),
Some(window_2_id),
"upgraded handle should point to new window"
);
});
view.read(&app, |v, _| {
assert_eq!(v.value, 42, "view data preserved");
});
});
}
#[test]
fn test_transfer_nonexistent_view_returns_false() {
#[derive(Default)]
struct TestView;
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let fake_view_id = EntityId::new();
let success =
app.update(|ctx| ctx.transfer_view_to_window(fake_view_id, window_1_id, window_2_id));
assert!(!success, "transfer of nonexistent view should return false");
});
}
#[test]
fn test_transfer_to_same_window_is_noop() {
#[derive(Default)]
struct TestView {
value: usize,
}
impl Entity for TestView {
type Event = usize;
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let view = app.add_view(window_id, |_| TestView { value: 42 });
let view_id = view.id();
let success = app.update(|ctx| ctx.transfer_view_to_window(view_id, window_id, window_id));
assert!(success, "transfer to same window should return true");
app.read(|ctx| {
assert_eq!(
view.window_id(ctx),
window_id,
"view should still be in same window"
);
assert!(
ctx.windows[&window_id].views.contains_key(&view_id),
"view should still be in window's views map"
);
});
view.update(&mut app, |_, ctx| ctx.emit(1));
view.read(&app, |v, _| {
assert_eq!(v.value, 42, "view should still work normally");
});
});
}
#[test]
fn test_transfer_view_drop_and_reference_counting() {
#[derive(Default)]
struct TestView {
value: usize,
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let view = app.add_view(window_1_id, |_| TestView { value: 42 });
let view_id = view.id();
let view_clone = view.clone();
app.update(|ctx| ctx.transfer_view_to_window(view_id, window_1_id, window_2_id));
drop(view_clone);
app.read(|ctx| {
assert!(
ctx.windows[&window_2_id].views.contains_key(&view_id),
"view should still exist after dropping one handle"
);
assert!(
ctx.view_to_window.contains_key(&view_id),
"view_to_window mapping should still exist"
);
});
view.read(&app, |v, _| {
assert_eq!(v.value, 42, "view data should be intact");
});
drop(view);
// Trigger cleanup via app.update which calls flush_effects -> remove_dropped_items
app.update(|_| {});
// Verify the view was removed from the correct window (window_2, not window_1)
// and that the view_to_window mapping was cleaned up
app.read(|ctx| {
assert!(
!ctx.windows[&window_1_id].views.contains_key(&view_id),
"view should not be in original window"
);
assert!(
!ctx.windows[&window_2_id].views.contains_key(&view_id),
"view should be removed from target window after drop"
);
assert!(
!ctx.view_to_window.contains_key(&view_id),
"view_to_window mapping should be cleaned up"
);
});
});
}
#[test]
fn test_transfer_structural_children_follows_parent() {
#[derive(Default)]
struct TestView {
value: usize,
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, root_1) =
app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let parent = root_1.update(&mut app, |_, ctx| {
ctx.add_typed_action_view(|_| TestView { value: 1 })
});
let structural_child = parent.update(&mut app, |_, ctx| {
ctx.add_typed_action_view(|_| TestView { value: 2 })
});
let parent_id = parent.id();
let child_id = structural_child.id();
app.read(|ctx| {
assert!(
ctx.windows[&window_1_id].views.contains_key(&child_id),
"child should initially be in window 1"
);
});
let transferred =
app.update(|ctx| ctx.transfer_view_tree_to_window(parent_id, window_1_id, window_2_id));
assert!(
transferred.contains(&parent_id),
"parent should be in transferred list"
);
assert!(
transferred.contains(&child_id),
"structural child should be in transferred list"
);
app.read(|ctx| {
assert!(
ctx.windows[&window_2_id].views.contains_key(&parent_id),
"parent should be in window 2"
);
assert!(
ctx.windows[&window_2_id].views.contains_key(&child_id),
"structural child should be in window 2"
);
assert!(
!ctx.windows[&window_1_id].views.contains_key(&parent_id),
"parent should no longer be in window 1"
);
assert!(
!ctx.windows[&window_1_id].views.contains_key(&child_id),
"structural child should no longer be in window 1"
);
});
structural_child.read(&app, |v, _| {
assert_eq!(v.value, 2, "structural child data should be preserved");
});
});
}
#[test]
fn test_transfer_structural_grandchildren_follows_transitively() {
#[derive(Default)]
struct TestView {
value: usize,
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, root_1) =
app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView::default());
let parent = root_1.update(&mut app, |_, ctx| {
ctx.add_typed_action_view(|_| TestView { value: 1 })
});
let child = parent.update(&mut app, |_, ctx| {
ctx.add_typed_action_view(|_| TestView { value: 2 })
});
let grandchild = child.update(&mut app, |_, ctx| {
ctx.add_typed_action_view(|_| TestView { value: 3 })
});
let parent_id = parent.id();
let child_id = child.id();
let grandchild_id = grandchild.id();
let transferred =
app.update(|ctx| ctx.transfer_view_tree_to_window(parent_id, window_1_id, window_2_id));
assert!(
transferred.contains(&parent_id),
"parent should be transferred"
);
assert!(
transferred.contains(&child_id),
"child should be transferred"
);
assert!(
transferred.contains(&grandchild_id),
"grandchild should be transferred transitively"
);
app.read(|ctx| {
assert!(ctx.windows[&window_2_id].views.contains_key(&grandchild_id));
assert!(!ctx.windows[&window_1_id].views.contains_key(&grandchild_id));
});
grandchild.read(&app, |v, _| {
assert_eq!(v.value, 3, "grandchild data should be preserved");
});
});
}
#[test]
fn test_transfer_structural_children_does_not_move_unrelated_views() {
#[derive(Default)]
struct TestView;
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TestView"
}
}
impl TypedActionView for TestView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, root_1) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView);
let parent = root_1.update(&mut app, |_, ctx| ctx.add_typed_action_view(|_| TestView));
let structural_child =
parent.update(&mut app, |_, ctx| ctx.add_typed_action_view(|_| TestView));
let unrelated = app.add_view(window_1_id, |_| TestView);
let parent_id = parent.id();
let child_id = structural_child.id();
let unrelated_id = unrelated.id();
let transferred =
app.update(|ctx| ctx.transfer_view_tree_to_window(parent_id, window_1_id, window_2_id));
assert!(
transferred.contains(&parent_id),
"parent should be transferred"
);
assert!(
transferred.contains(&child_id),
"structural child should be transferred"
);
assert!(
!transferred.contains(&unrelated_id),
"unrelated view should NOT be transferred"
);
app.read(|ctx| {
assert!(
ctx.windows[&window_1_id].views.contains_key(&unrelated_id),
"unrelated view should remain in window 1"
);
assert!(
!ctx.windows[&window_2_id].views.contains_key(&unrelated_id),
"unrelated view should NOT be in window 2"
);
});
});
}
@@ -0,0 +1,919 @@
use std::{any::Any, marker::PhantomData, rc::Rc, sync::Arc};
use futures::future::{AbortHandle, Abortable};
use futures::{Future, FutureExt};
use pathfinder_geometry::rect::RectF;
use thiserror::Error;
use crate::modals::{AlertDialogWithCallbacks, ModalButton, ViewModalCallback};
use crate::platform::{
file_picker::{FilePickerConfiguration, FilePickerError},
Cursor, SaveFilePickerConfiguration, TerminationMode,
};
use crate::r#async::SpawnableOutput;
use crate::windowing::WindowManager;
use crate::{
accessibility::AccessibilityContent,
core::{Observation, Subscription, SubscriptionKey, TaskCallback},
fonts::Cache as FontCache,
notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification},
r#async::{
executor::{Background, Foreground},
SpawnedFutureHandle, SpawnedLocalStream,
},
Action, AppContext, Effect, Entity, EntityId, ModelAsRef, ModelContext, ModelHandle,
UpdateModel, WindowId,
};
use crate::{GetSingletonModelHandle, ReadModel};
use super::{
handle::{AnyViewHandle, ReadView, UpdateView, ViewAsRef, ViewHandle, WeakViewHandle},
TypedActionView, View,
};
/// Structure that combines view identifiers and a handle to the application
/// context/application state.
pub struct ViewContext<'a, T: ?Sized> {
app: &'a mut AppContext,
window_id: WindowId,
view_id: EntityId,
view_type: PhantomData<T>,
}
impl<'a, T: View> ViewContext<'a, T> {
pub(in crate::core) fn new(
app: &'a mut AppContext,
window_id: WindowId,
view_id: EntityId,
) -> Self {
Self {
app,
window_id,
view_id,
view_type: PhantomData,
}
}
/// Adds a callback that will be invoked immediately after the next frame is drawn.
/// Note that the callback is only invoked once and is discarded after it is called.
pub fn on_next_frame_drawn<F: 'static + Fn()>(&mut self, callback: F) {
self.app.on_next_frame_drawn(self.window_id, callback);
}
pub fn handle(&self) -> WeakViewHandle<T> {
WeakViewHandle::new(self.view_id)
}
pub fn window_id(&self) -> WindowId {
self.window_id
}
pub fn view_id(&self) -> EntityId {
self.view_id
}
pub fn font_cache(&self) -> &FontCache {
self.app.font_cache()
}
pub fn windows(&self) -> &WindowManager {
self.app.windows()
}
pub fn disable_key_bindings_dispatching(&mut self) {
let window_id = self.window_id;
log::info!("disabling actions for window id {window_id}");
self.disable_key_bindings(window_id)
}
pub fn enable_key_bindings_dispatching(&mut self) {
let window_id = self.window_id;
log::info!("enabling actions for window id {window_id}");
self.enable_key_bindings(window_id)
}
// Function to check if the parent view is focused.
pub fn is_self_focused(&self) -> bool {
self.app.check_view_focused(self.window_id, &self.view_id)
}
pub fn is_self_or_child_focused(&self) -> bool {
self.app
.check_view_or_child_focused(self.window_id, &self.view_id)
}
pub fn element_position_by_id<S>(&self, id: S) -> Option<RectF>
where
S: AsRef<str>,
{
let presenter = self.app.presenter(self.window_id);
if let Some(presenter) = presenter {
let borrowed_presenter = presenter.borrow();
borrowed_presenter.position_cache().get_position(id)
} else {
None
}
}
pub fn focus<S: View>(&mut self, handle: &ViewHandle<S>) {
let handle: AnyViewHandle = handle.into();
self.app.pending_effects.push_back(Effect::Focus {
window_id: handle.window_id(self.app),
view_id: handle.id(),
});
}
pub fn focus_self(&mut self) {
self.app.pending_effects.push_back(Effect::Focus {
window_id: self.window_id,
view_id: self.view_id,
});
}
pub fn add_model<S, F>(&mut self, build_model: F) -> ModelHandle<S>
where
S: Entity,
F: FnOnce(&mut ModelContext<S>) -> S,
{
self.app.add_model(build_model)
}
pub fn add_view<S, F>(&mut self, build_view: F) -> ViewHandle<S>
where
S: View,
F: FnOnce(&mut ViewContext<S>) -> S,
{
self.app.add_view(self.window_id, build_view)
}
pub fn add_typed_action_view<V, F>(&mut self, build_view: F) -> ViewHandle<V>
where
V: TypedActionView + View,
F: FnOnce(&mut ViewContext<V>) -> V,
{
// Add a new view, and set the parent view as the current context's view.
self.app
.add_typed_action_view_with_parent(self.window_id, build_view, self.view_id)
}
pub fn add_option_view<S, F>(&mut self, build_view: F) -> Option<ViewHandle<S>>
where
S: View,
F: FnOnce(&mut ViewContext<S>) -> Option<S>,
{
self.app.add_option_view(self.window_id, build_view)
}
pub fn subscribe_to_model<E, F>(&mut self, handle: &ModelHandle<E>, mut callback: F)
where
E: Entity,
E::Event: 'static,
F: 'static + FnMut(&mut T, ModelHandle<E>, &E::Event, &mut ViewContext<T>),
{
let emitter_handle = handle.downgrade();
self.app
.subscriptions
.entry(handle.id())
.or_default()
.push(Subscription::FromView {
window_id: self.window_id,
view_id: self.view_id,
callback: Box::new(move |view, payload, app, window_id, view_id| {
if let Some(emitter_handle) = emitter_handle.upgrade(app) {
let model = view.downcast_mut().expect("downcast is type safe");
let payload = payload.downcast_ref().expect("downcast is type safe");
let mut ctx = ViewContext::new(app, window_id, view_id);
callback(model, emitter_handle, payload, &mut ctx);
}
}),
});
}
pub fn subscribe_to_view<V, F>(&mut self, handle: &ViewHandle<V>, mut callback: F)
where
V: View,
V::Event: 'static,
F: 'static + FnMut(&mut T, ViewHandle<V>, &V::Event, &mut ViewContext<T>),
{
let emitter_handle = handle.downgrade();
self.app
.subscriptions
.entry(handle.id())
.or_default()
.push(Subscription::FromView {
window_id: self.window_id,
view_id: self.view_id,
callback: Box::new(move |view, payload, app, window_id, view_id| {
if let Some(emitter_handle) = emitter_handle.upgrade(app) {
let model = view.downcast_mut().expect("downcast is type safe");
let payload = payload.downcast_ref().expect("downcast is type safe");
let mut ctx = ViewContext::new(app, window_id, view_id);
callback(model, emitter_handle, payload, &mut ctx);
}
}),
});
}
pub fn unsubscribe_to_view<V>(&mut self, handle: &ViewHandle<V>)
where
V: View,
V::Event: 'static,
{
let target_entity = handle.id();
// If we're currently emitting events for this entity, defer the unsubscribe.
if let Some(ref mut pending) = self.app.pending_unsubscribes {
if pending.entity_id == target_entity {
pending
.keys
.insert(SubscriptionKey::View(self.window_id, self.view_id));
// Remove subscriptions created earlier in this emission so subscribe-then-unsubscribe ordering is preserved.
if let std::collections::hash_map::Entry::Occupied(mut entry) =
self.app.subscriptions.entry(target_entity)
{
entry.get_mut().retain(|subscription| match subscription {
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
Subscription::FromView {
window_id, view_id, ..
} => *window_id != self.window_id || *view_id != self.view_id,
});
if entry.get().is_empty() {
entry.remove();
}
}
return;
}
}
// Otherwise process immediately.
self.app
.subscriptions
.entry(target_entity)
.or_default()
.retain(|subscription| match subscription {
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
Subscription::FromView {
window_id, view_id, ..
} => *window_id != self.window_id || *view_id != self.view_id,
});
}
pub fn unsubscribe_to_model<E>(&mut self, handle: &ModelHandle<E>)
where
E: Entity,
E::Event: 'static,
{
let target_entity = handle.id();
// If we're currently emitting events for this entity, defer the unsubscribe.
if let Some(ref mut pending) = self.app.pending_unsubscribes {
if pending.entity_id == target_entity {
pending
.keys
.insert(SubscriptionKey::View(self.window_id, self.view_id));
// Remove subscriptions created earlier in this emission so subscribe-then-unsubscribe ordering is preserved.
if let std::collections::hash_map::Entry::Occupied(mut entry) =
self.app.subscriptions.entry(target_entity)
{
entry.get_mut().retain(|subscription| match subscription {
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
Subscription::FromView {
window_id, view_id, ..
} => *window_id != self.window_id || *view_id != self.view_id,
});
if entry.get().is_empty() {
entry.remove();
}
}
return;
}
}
// Otherwise process immediately.
self.app
.subscriptions
.entry(target_entity)
.or_default()
.retain(|subscription| match subscription {
Subscription::FromModel { .. } | Subscription::FromApp { .. } => true,
Subscription::FromView {
window_id, view_id, ..
} => *window_id != self.window_id || *view_id != self.view_id,
})
}
/// Prompt the user to pick file path(s) in the OS native file picker.
pub fn open_file_picker(
&mut self,
callback: impl FnOnce(Result<Vec<String>, FilePickerError>, &mut ViewContext<T>)
+ Send
+ Sync
+ 'static,
config: FilePickerConfiguration,
) {
let window_id = self.window_id;
let view_id = self.view_id;
self.app.open_file_picker(
move |result, app| {
let mut view_context = ViewContext::new(app, window_id, view_id);
callback(result, &mut view_context)
},
config,
)
}
/// Prompt the user to save a file with the OS native save file dialog.
///
/// The callback receives the chosen path (or `None` if cancelled), a mutable
/// reference to the owning view, and its `ViewContext`.
pub fn open_save_file_picker(
&mut self,
callback: impl FnOnce(Option<String>, &mut T, &mut ViewContext<T>) + Send + Sync + 'static,
config: SaveFilePickerConfiguration,
) {
let view_id = self.view_id;
self.app.open_save_file_picker(
move |path, app| {
let weak = WeakViewHandle::<T>::new(view_id);
if let Some(handle) = weak.upgrade(app) {
app.update_view(&handle, |view, ctx| {
callback(path, view, ctx);
});
}
},
config,
)
}
/// Emits the provided event on this `View`.
///
/// Unlike DOM events, these events don't bubble or otherwise automatically
/// propagate themselves up the view hierarchy. In order for another view
/// to receive any events emitted by this view, the receiver will need to
/// explicitly subscribe to this view's events by calling
/// [`Self::subscribe_to_view()`][^note].
///
/// [^note]: This subscription is on a per-instance basis, not on a per-type
/// basis.
pub fn emit(&mut self, payload: T::Event) {
self.app.pending_effects.push_back(Effect::Event {
entity_id: self.view_id,
payload: Box::new(payload),
});
}
/// When all else fails, `emit_a11y_content` comes to the rescue! In our UI framework, some stuff is just an Event, and not an Action. Sometimes a different View emits the event, while another one handles it… So instead of solving this, we simply use `emit_a11y_content(&mut self, content: AccessibilityContent)` on demand, meaning, whenever something meaningful happens and it’s not related to Action or View focusing, we should emit a11y content. A good example for this is announcing that a new update is available.
///
/// ### When and how to use it?
/// Whenever we feel like it’s important to make an announcement about events in the app. Note that this requires a ViewContext.
pub fn emit_a11y_content(&mut self, content: AccessibilityContent) {
let verbosity = self.a11y_verbosity;
self.platform_delegate
.set_accessibility_contents(content.with_verbosity(verbosity));
}
/// Delegates to the OS to request the user attention for the given window. For mac this bounces the
/// icon in the dock. If the window is already focused this is a noop.
pub fn request_user_attention(&mut self) {
let window_id = self.window_id;
self.app.request_user_attention(window_id);
}
/// Global actions are being phased out. Prefer dispatching typed actions instead of global actions.
/// Dispatch a global action to be handled by the registered handler
///
/// Note: The dispatch of the global action will be registered as an effect and flushed after
/// the current view update is complete. This will ensure that the view has been re-inserted
/// into the `AppContext`, so it will be accessible to the global action, if necessary
#[track_caller]
pub fn dispatch_global_action<A: Any>(&mut self, name: &'static str, arg: A) {
let location = std::panic::Location::caller();
self.app.pending_effects.push_back(Effect::GlobalAction {
name,
location,
arg: Box::new(arg),
});
}
pub fn dispatch_typed_action(&mut self, action: &dyn Action) {
let window_id = self.window_id;
let view_id = self.view_id;
self.dispatch_typed_action_for_view(window_id, view_id, action);
}
/// Defers dispatching a typed action until effects are flushed.
///
/// This is useful to avoid re-entrant view updates (e.g. triggering UI updates
/// while a view in the responder chain is still mid-update).
pub fn dispatch_typed_action_deferred<A: Action + 'static>(&mut self, action: A) {
self.app.pending_effects.push_back(Effect::TypedAction {
window_id: self.window_id,
view_id: self.view_id,
action: Box::new(action),
});
}
pub fn observe<S, F>(&mut self, handle: &ModelHandle<S>, mut callback: F)
where
S: Entity,
F: 'static + FnMut(&mut T, ModelHandle<S>, &mut ViewContext<T>),
{
self.app
.observations
.entry(handle.id())
.or_default()
.push(Observation::FromView {
window_id: self.window_id,
view_id: self.view_id,
callback: Box::new(move |view, observed_id, app, window_id, view_id| {
let view = view.downcast_mut().expect("downcast is type safe");
let observed = ModelHandle::new(observed_id, &app.ref_counts);
let mut ctx = ViewContext::new(app, window_id, view_id);
callback(view, observed, &mut ctx);
}),
});
}
/// Notifies the framework that this view is dirty and needs to be
/// re-rendered.
///
/// "Dirtiness" only applies to this specific instance, and not the entire
/// view hierarchy rooted at this view. Each dirty child view also needs to
/// have `ctx.notify()` called on the child's `ViewContext` in order for the
/// child to be re-rendered.
pub fn notify(&mut self) {
self.app
.pending_effects
.push_back(Effect::ViewNotification {
window_id: self.window_id,
view_id: self.view_id,
});
}
/// Requests permissions to send desktop notifications. The `on_completion callback` can be invoked to
/// propagate the outcome of the request (accepted/denied/other) back to the app.
///
/// ## Platform-Specific
/// * Linux: Always calls the `on_completion_callback` with a value of [`RequestPermissionsOutcome::Accepted`].
pub fn request_desktop_notification_permissions<F>(&mut self, on_completion_callback: F)
where
F: 'static + Send + Sync + FnOnce(&mut T, RequestPermissionsOutcome, &mut ViewContext<T>),
{
let view_id = self.view_id;
let window_id = self.window_id;
self.app.request_desktop_notification_permissions(
view_id,
window_id,
on_completion_callback,
);
}
/// Sends a desktop notification. The `on_error_callback` can be invoked to
/// propagate an error to the view that initiated the notification send.
pub fn send_desktop_notification<F>(&mut self, content: UserNotification, on_error_callback: F)
where
F: 'static + Send + Sync + FnOnce(&mut T, NotificationSendError, &mut ViewContext<T>),
{
let view_id = self.view_id;
let window_id = self.window_id;
self.app
.send_desktop_notification(content, view_id, window_id, on_error_callback);
}
/// Schedules a future to run on the main thread, invoking a callback on the
/// main thread upon completion.
///
/// The callback receives the output of the future, if any, in addition to
/// mutable references to the spawning view and its context, allowing for
/// dirtying of the view (via [`Self::notify()`]) if appropriate.
///
/// This is private to [`ViewContext`] because we shouldn't ever need to
/// poll a future on the main thread. Currently, the only use is by
/// [`Self::spawn()`] in order to pass the results of the background task to
/// a callback executed on the main thread.
///
/// TODO(vorporeal): Determine how best to eliminate this function and move
/// the relevant logic into `spawn()`.
fn spawn_local<S, F, U>(&mut self, future: S, callback: F) -> impl Future<Output = ()>
where
S: 'static + Future,
F: 'static + FnOnce(&mut T, S::Output, &mut ViewContext<T>) -> U,
U: 'static,
{
let (tx, rx) = futures::channel::oneshot::channel();
let task_id = self.app.spawn_local(future);
self.app.task_callbacks.insert(
task_id,
TaskCallback::ViewFromFuture {
window_id: self.window_id,
view_id: self.view_id,
callback: Box::new(move |view, output, app, window_id, view_id| {
let view = view.as_any_mut().downcast_mut().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
let output = *output.downcast().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
let result =
callback(view, output, &mut ViewContext::new(app, window_id, view_id));
let _ = tx.send(result);
}),
},
);
async move {
if rx.await.is_err() {
log::error!("sender unexpectedly dropped before receiver");
}
}
}
/// Schedules a future to run on a background thread, invoking a callback on
/// the _main_ thread upon completion.
///
/// This function is useful in situations where a long-running process needs
/// to occur (e.g.: a network request), after which the view needs to be
/// updated based on the result.
///
/// The callback receives the output of the future, if any, in addition to
/// mutable references to the spawning view and its context, allowing for
/// dirtying of the view (via [`Self::notify`]) if appropriate.
///
/// The future can be aborted by calling `abort` on the returned `SpawnedFutureHandle`. Note the
/// future will only be aborted the _next_ time the future is polled.
///
/// See [`Self::spawn_abortable`] for an alternative version of this function that accepts an
/// `on_abort` function that is called when the future is aborted.
pub fn spawn<S, F, U>(&mut self, future: S, callback: F) -> SpawnedFutureHandle
where
S: crate::r#async::Spawnable,
<S as Future>::Output: crate::r#async::SpawnableOutput,
F: 'static + FnOnce(&mut T, <S as Future>::Output, &mut ViewContext<T>) -> U,
U: 'static,
{
self.spawn_abortable::<S, _, _>(
future,
|view, output, ctx| {
callback(view, output, ctx);
},
|_, _| {},
)
}
/// Schedules a future to run on a background thread, invoking the `on_resolve`
/// callback on the _main_ thread upon completion. If the future is aborted, the
/// `on_abort` function is called.
///
/// This function is useful in situations where a long-running process needs
/// to occur (e.g.: a network request), after which the view needs to be
/// updated based on the result.
///
/// The `on_resolve` callback receives the output of the future, if any, in addition to
/// mutable references to the spawning view and its context, allowing for
/// dirtying of the model (via [`Self::notify`]) if appropriate.
///
/// The future can be aborted by calling `abort` on the returned `SpawnedFutureHandle`. Note, a
/// future is not immediately killed on `abort`--it will only be aborted once the future's
/// `poll` method returns.
///
/// See [`Self::spawn`] for an alternative version of this function that doesn't
/// require a callback if/when the future is aborted.
pub fn spawn_abortable<S, F, A>(
&mut self,
future: S,
on_resolve: F,
on_abort: A,
) -> SpawnedFutureHandle
where
S: crate::r#async::Spawnable,
<S as Future>::Output: crate::r#async::SpawnableOutput,
F: 'static + FnOnce(&mut T, <S as Future>::Output, &mut ViewContext<T>),
A: 'static + FnOnce(&mut T, &mut ViewContext<T>),
{
let (tx, rx) = futures::channel::oneshot::channel();
let (abort_handle, abort_registration) = AbortHandle::new_pair();
self.app
.background_executor()
.spawn_boxed(Box::pin(async move {
let abortable = Abortable::new(future, abort_registration);
if tx.send(abortable.await).is_err() {
log::error!("Error sending background task result to main thread",);
}
}))
.detach();
let future = self.spawn_local(rx, |view, rx_result, ctx| {
let output = match rx_result {
Ok(output) => output,
Err(_) => {
log::error!("sender unexpectedly dropped before receiver");
on_abort(view, ctx);
return;
}
};
// Call the appropriate callback based on the output of resolving the future. If the
// future returned `Ok`, the future was not aborted so we can call `on_resolve`. If
// the future returned `Err`--the future was aborted.
match output {
Ok(output) => on_resolve(view, output, ctx),
Err(_) => on_abort(view, ctx),
}
});
let future_id = self.app.register_spawned_future(future.boxed());
SpawnedFutureHandle::new(abort_handle, future_id)
}
/// Schedules a stream to be polled on the main thread, invoking callbacks
/// upon the production of each item and upon the completion of the stream.
///
/// This function is useful in situations where a view wants to process a
/// stream of events (say, a debounced stream of mouse movements) and update
/// itself in response to each.
///
/// The callbacks receive mutable references to the spawning view and its
/// context, allowing for updating of the view's internal state and dirtying
/// it (via [`Self::notify`]) if appropriate.
pub fn spawn_stream_local<S, F, G>(
&mut self,
stream: S,
mut on_item: F,
mut on_done: G,
) -> SpawnedLocalStream
where
S: 'static + crate::r#async::Stream,
S::Item: SpawnableOutput,
F: 'static + FnMut(&mut T, S::Item, &mut ViewContext<T>),
G: 'static + FnMut(&mut T, &mut ViewContext<T>),
{
let (tx, rx) = futures::channel::oneshot::channel();
let task_id = self.app.spawn_stream_local(stream, tx);
self.app.task_callbacks.insert(
task_id,
TaskCallback::ViewFromStream {
window_id: self.window_id,
view_id: self.view_id,
on_item: Box::new(move |view, output, app, window_id, view_id| {
let view = view.as_any_mut().downcast_mut().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
let output = *output.downcast().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
let mut ctx = ViewContext::new(app, window_id, view_id);
on_item(view, output, &mut ctx);
}),
on_done: Box::new(move |view, app, window_id, view_id| {
let view = view.as_any_mut().downcast_mut().expect("this downcast should never fail, as correct typing is statically enforced via the generic parameters on spawn_local");
let mut ctx = ViewContext::new(app, window_id, view_id);
on_done(view, &mut ctx);
}),
},
);
SpawnedLocalStream::new(
async move {
if rx.await.is_err() {
log::error!("sender unexpectedly dropped before receiver");
}
}
.boxed_local(),
)
}
pub fn close_window(&mut self) {
self.app
.windows()
.close_window_async(self.window_id, TerminationMode::Cancellable);
}
/// Minimizes the window which this View is in.
pub fn minimize_window(&mut self) {
if let Some(window) = self.app.windows().platform_window(self.window_id) {
window.minimize();
}
}
/// Maximizes the window which this View is in, unless that window is already maximized, then it
/// restores it, i.e. "un-maximizes" it.
pub fn toggle_maximized_window(&mut self) {
if let Some(window) = self.app.windows().platform_window(self.window_id) {
window.toggle_maximized();
}
}
pub fn toggle_fullscreen(&mut self) {
if let Some(window) = self.app.windows().platform_window(self.window_id) {
window.toggle_fullscreen();
}
}
pub fn foreground_executor(&self) -> &Rc<Foreground> {
self.app.foreground_executor()
}
pub fn background_executor(&self) -> &Arc<Background> {
self.app.background_executor()
}
/// Create a window showing a modal dialog native to the platform. The modal will synchronously
/// block all other interactions with the app until dismissed. Each button can have a callback
/// attached to it in the [`crate::modals::ModalButton`] struct.
pub fn show_native_platform_modal(
&mut self,
view_alert: AlertDialogWithCallbacks<ViewModalCallback<T>>,
) {
let weak_handle = self.handle();
let app_alert = AlertDialogWithCallbacks::for_app(
view_alert.message_text,
view_alert.info_text,
view_alert
.button_data
.into_iter()
.map(|button| {
let weak_handle = self.handle();
ModalButton::for_app(button.title, move |app| {
if let Some(handle) = weak_handle.upgrade(app) {
app.update_view(&handle, |view, ctx| {
(button.on_click)(view, ctx);
});
}
})
})
.collect(),
move |app| {
if let Some(handle) = weak_handle.upgrade(app) {
app.update_view(&handle, |view, ctx| {
(view_alert.on_disable)(view, ctx);
});
}
},
);
self.app.show_native_platform_modal(app_alert);
}
pub fn set_cursor_shape(&mut self, cursor: Cursor) {
self.app
.set_cursor_shape(cursor, self.window_id, self.view_id)
}
pub fn reset_cursor(&mut self) {
self.app.reset_cursor()
}
/// Creates a handle which background tasks can use to spawn work for this view. Spawned tasks
/// are executed on the main thread in the context of the view, and results are sent back to
/// the background task.
///
/// Note that the spawner *does not* keep a strong reference to the view. If the view is
/// dropped, any pending or future tasks will be discarded.
pub fn spawner(&mut self) -> ViewSpawner<T> {
let (task_tx, task_rx) = async_channel::unbounded();
let (completion_tx, _completion_rx) = futures::channel::oneshot::channel();
let task_id = self.app.spawn_stream_local(task_rx, completion_tx);
self.app.task_callbacks.insert(
task_id,
TaskCallback::ViewFromStream {
window_id: self.window_id,
view_id: self.view_id,
on_item: Box::new(move |view, task, app, window_id, view_id| {
let view = view
.as_any_mut()
.downcast_mut()
.expect("unexpected view type");
let task: ViewTask<T> = *task
.downcast()
.expect("task from spawner should be ViewTask<T>");
let mut ctx = ViewContext::new(app, window_id, view_id);
task(view, &mut ctx);
}),
on_done: Box::new(move |_view, _app, _window_id, _view_id| {}),
},
);
ViewSpawner {
task_sender: task_tx,
}
}
}
impl<T> std::ops::Deref for ViewContext<'_, T> {
type Target = AppContext;
fn deref(&self) -> &Self::Target {
self.app
}
}
impl<T> std::ops::DerefMut for ViewContext<'_, T> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.app
}
}
/// A task which must run in the context of a view of type `V`.
type ViewTask<V> = Box<dyn FnOnce(&mut V, &mut ViewContext<V>) + Send + 'static>;
/// A handle for spawning view tasks from background threads.
pub struct ViewSpawner<V> {
task_sender: async_channel::Sender<ViewTask<V>>,
}
impl<V> ViewSpawner<V> {
/// Spawn a task that will execute on the main thread, in the context of a view.
pub async fn spawn<R: Send + 'static>(
&self,
work: impl FnOnce(&mut V, &mut ViewContext<V>) -> R + Send + 'static,
) -> Result<R, ViewDropped> {
let (tx, rx) = futures::channel::oneshot::channel();
self.task_sender
.send(Box::new(move |me, ctx| {
let result = work(me, ctx);
// If the background task has dropped the receiver, then we don't need to send
// the result, and there's no one to inform regardless.
let _ = tx.send(result);
}))
.await
.map_err(|_| ViewDropped)?;
rx.await.map_err(|_| ViewDropped)
}
}
/// Error returned when a view has been dropped, and so references to it are invalid.
#[derive(Debug, Error, PartialEq, Eq)]
#[error("View has been dropped")]
pub struct ViewDropped;
impl<V> ModelAsRef for ViewContext<'_, V> {
fn model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T {
self.app.model(handle)
}
}
impl<V> ReadModel for ViewContext<'_, V> {
fn read_model<T, F, S>(&self, handle: &ModelHandle<T>, read: F) -> S
where
T: Entity,
F: FnOnce(&T, &AppContext) -> S,
{
self.app.read_model(handle, read)
}
}
impl<V: View> UpdateModel for ViewContext<'_, V> {
fn update_model<T, F, S>(&mut self, handle: &ModelHandle<T>, update: F) -> S
where
T: Entity,
F: FnOnce(&mut T, &mut ModelContext<T>) -> S,
{
self.app.update_model(handle, update)
}
}
impl<V: View> ViewAsRef for ViewContext<'_, V> {
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T {
self.app.view(handle)
}
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T> {
self.app.try_view(handle)
}
}
impl<V: View> UpdateView for ViewContext<'_, V> {
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
where
T: View,
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
{
self.app.update_view(handle, update)
}
}
impl<V: View> ReadView for ViewContext<'_, V> {
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
where
T: View,
F: FnOnce(&T, &AppContext) -> S,
{
self.app.read_view(handle, read)
}
}
impl<V: View> GetSingletonModelHandle for ViewContext<'_, V> {
fn get_singleton_model_handle<T: crate::SingletonEntity>(&self) -> ModelHandle<T> {
self.app.get_singleton_model_handle()
}
}
#[cfg(test)]
#[path = "context_test.rs"]
mod tests;
@@ -0,0 +1,197 @@
use crate::elements::Empty;
use crate::platform::WindowStyle;
use crate::{App, AppContext, Element, Entity, TypedActionView};
#[test]
fn test_spawn_from_view() {
#[derive(Default)]
struct View {
count: usize,
}
impl Entity for View {
type Event = ();
}
impl super::View for View {
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"View"
}
}
impl TypedActionView for View {
type Action = ();
}
App::test((), |mut app| async move {
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let (tx, rx) = futures::channel::oneshot::channel();
handle.update(&mut app, move |_, c| {
c.spawn(async { 7 }, move |me, output, _| {
tx.send(()).unwrap();
me.count = output;
})
});
rx.await.unwrap();
let (tx, rx) = futures::channel::oneshot::channel();
handle.read(&app, |view, _| assert_eq!(view.count, 7));
handle.update(&mut app, move |_, c| {
c.spawn(async { 14 }, move |me, output, _| {
tx.send(()).unwrap();
me.count = output;
})
});
rx.await.unwrap();
handle.read(&app, |view, _| assert_eq!(view.count, 14));
});
}
#[ignore]
#[test]
fn test_spawn_abortable_from_view() {
#[derive(Debug, Default, PartialEq)]
enum SpawnedOutcome {
#[default]
NotStarted,
Aborted,
Resolved {
value: usize,
},
}
#[derive(Default)]
struct View {
spawned_outcome: SpawnedOutcome,
}
impl Entity for View {
type Event = ();
}
impl super::View for View {
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"View"
}
}
impl TypedActionView for View {
type Action = ();
}
App::test((), |mut app| async move {
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let (tx, rx) = futures::channel::oneshot::channel();
handle.update(&mut app, |_, c| {
c.spawn_abortable(
async { 7 },
move |me, output, _| {
tx.send(()).unwrap();
me.spawned_outcome = SpawnedOutcome::Resolved { value: output }
},
|_, _| {},
)
});
rx.await.unwrap();
handle.read(&app, |view, _| {
assert_eq!(view.spawned_outcome, SpawnedOutcome::Resolved { value: 7 })
});
let (tx, rx) = futures::channel::oneshot::channel();
handle.update(&mut app, move |_, c| {
let abort_handle = c.spawn_abortable(
async { 7 },
|_, _, _| {},
move |me, _| {
me.spawned_outcome = SpawnedOutcome::Aborted;
tx.send(()).unwrap();
},
);
abort_handle.abort();
});
rx.await.unwrap();
// The call future passed to `spawn_abortable` was successfully aborted.
handle.read(&app, |view, _| {
assert_eq!(view.spawned_outcome, SpawnedOutcome::Aborted)
});
});
}
#[test]
fn test_view_spawner() {
#[derive(Default)]
struct View {
count: usize,
}
impl Entity for View {
type Event = ();
}
impl super::View for View {
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"View"
}
}
impl TypedActionView for View {
type Action = ();
}
App::test((), |mut app| async move {
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let spawner = handle.update(&mut app, |_, ctx| ctx.spawner());
// Test a single spawned task.
let result = spawner
.spawn(|view, ctx| {
view.count += 42;
ctx.notify();
view.count
})
.await
.unwrap();
assert_eq!(result, 42);
handle.read(&app, |view, _| assert_eq!(view.count, 42));
// Test multiple spawned tasks.
let task1 = spawner.spawn(|view, _| {
view.count *= 2;
view.count
});
let task2 = spawner.spawn(|view, _| {
view.count += 10;
view.count
});
let (result1, result2) = futures::future::join(task1, task2).await;
// Note: The exact final value depends on task execution order but both tasks should succeed.
assert!(result1.is_ok());
assert!(result2.is_ok());
handle.read(&app, |view, _| {
assert!(view.count > 42);
});
});
}
@@ -0,0 +1,318 @@
use std::{
any::TypeId,
fmt::{self, Debug},
marker::PhantomData,
sync::{Arc, Weak},
};
use parking_lot::Mutex;
use crate::{core::RefCounts, AppContext, EntityId, WindowId};
use super::{context::ViewContext, View};
/// A strong reference to a particular [`View`] instance within the application.
///
/// Handles structures are used in place of references (e.g.: `&View`) to avoid
/// the complexity of reference lifetimes and appeasing the borrow checker. A
/// handle can be combined with a reference to the application state (e.g.:
/// [`AppContext`]) to get access to the actual [`View`] instance behind the
/// handle.
pub struct ViewHandle<T> {
window_id: WindowId,
view_id: EntityId,
view_type: PhantomData<T>,
ref_counts: Weak<Mutex<RefCounts>>,
}
impl<T: View> ViewHandle<T> {
pub(in crate::core) fn new(
window_id: WindowId,
view_id: EntityId,
ref_counts: &Arc<Mutex<RefCounts>>,
) -> Self {
ref_counts.lock().inc_entity(view_id);
Self {
window_id,
view_id,
view_type: PhantomData,
ref_counts: Arc::downgrade(ref_counts),
}
}
pub fn downgrade(&self) -> WeakViewHandle<T> {
WeakViewHandle::new(self.view_id)
}
/// Returns the current window this view belongs to.
///
/// This looks up the window from the view_to_window mapping, which may differ
/// from the window where the view was originally created if the view has been
/// transferred between windows.
pub fn window_id(&self, app: &AppContext) -> WindowId {
app.view_to_window
.get(&self.view_id)
.copied()
.unwrap_or(self.window_id)
}
pub fn id(&self) -> EntityId {
self.view_id
}
/// Convert a ViewHandle to a reference of the underlying View.
pub fn as_ref<'a, A: ViewAsRef>(&self, app: &'a A) -> &'a T {
app.view(self)
}
/// Try to convert a ViewHandle to a reference of the underlying View.
/// Returns `None` if the view is currently borrowed (circular reference).
pub fn try_as_ref<'a, A: ViewAsRef>(&self, app: &'a A) -> Option<&'a T> {
app.try_view(self)
}
/// Reads a value out of the underlying View. This is especially useful when the view
/// has a function that requires a `ViewContext` since `as_ref` does not create a `ViewHandle`
/// to the view.
pub fn read<A, F, S>(&self, app: &A, read: F) -> S
where
A: crate::ReadView,
F: FnOnce(&T, &AppContext) -> S,
{
app.read_view(self, read)
}
/// Updates a value within the underlying View.
pub fn update<A, F, S>(&self, app: &mut A, update: F) -> S
where
A: UpdateView,
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
{
app.update_view(self, update)
}
pub fn is_focused(&self, app: &AppContext) -> bool {
app.focused_view_id(self.window_id(app)) == Some(self.view_id)
}
// TODO: This is the same as the `is_self_or_child_focused` function in ViewContext.
// Moving forward we should figure out a better interface to check whether a specific
// view is focused or not.
pub fn is_self_or_child_focused(&self, app: &mut AppContext) -> bool {
let window_id = self.window_id(app);
app.check_view_or_child_focused(window_id, &self.view_id)
}
}
impl<T> Clone for ViewHandle<T> {
fn clone(&self) -> Self {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().inc_entity(self.view_id);
}
Self {
window_id: self.window_id,
view_id: self.view_id,
view_type: PhantomData,
ref_counts: self.ref_counts.clone(),
}
}
}
impl<T> PartialEq for ViewHandle<T> {
fn eq(&self, other: &Self) -> bool {
self.window_id == other.window_id && self.view_id == other.view_id
}
}
impl<T> Eq for ViewHandle<T> {}
impl<T> Debug for ViewHandle<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct(&format!("ViewHandle<{}>", core::any::type_name::<T>()))
.field("window_id", &self.window_id)
.field("view_id", &self.view_id)
.finish()
}
}
impl<T> Drop for ViewHandle<T> {
fn drop(&mut self) {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().dec_view(self.window_id, self.view_id);
}
}
}
unsafe impl<T> Send for ViewHandle<T> {}
unsafe impl<T> Sync for ViewHandle<T> {}
/// A type-erased strong reference to a particular [`View`] instance within the
/// application.
///
/// `AnyViewHandle` is used within the core UI framework in places where we need
/// to hold a strong reference to a `View`, but don't want to add a generic type
/// parameter to the containing structure. See `root_view` in
/// [`Window`](crate::core::Window) for an example.
pub(in crate::core) struct AnyViewHandle {
window_id: WindowId,
view_id: EntityId,
view_type: TypeId,
ref_counts: Weak<Mutex<RefCounts>>,
}
impl AnyViewHandle {
pub fn id(&self) -> EntityId {
self.view_id
}
/// Returns the current window this view belongs to.
pub fn window_id(&self, app: &AppContext) -> WindowId {
app.view_to_window
.get(&self.view_id)
.copied()
.unwrap_or(self.window_id)
}
pub fn is<T: 'static>(&self) -> bool {
TypeId::of::<T>() == self.view_type
}
pub fn downcast<T: View>(self) -> Option<ViewHandle<T>> {
if self.is::<T>() {
if let Some(ref_counts) = self.ref_counts.upgrade() {
return Some(ViewHandle::new(self.window_id, self.view_id, &ref_counts));
}
}
None
}
}
impl Clone for AnyViewHandle {
fn clone(&self) -> Self {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().inc_entity(self.view_id);
}
Self {
view_id: self.view_id,
window_id: self.window_id,
view_type: self.view_type,
ref_counts: self.ref_counts.clone(),
}
}
}
impl<T: View> From<&ViewHandle<T>> for AnyViewHandle {
fn from(handle: &ViewHandle<T>) -> Self {
if let Some(ref_counts) = handle.ref_counts.upgrade() {
ref_counts.lock().inc_entity(handle.view_id);
}
AnyViewHandle {
window_id: handle.window_id,
view_id: handle.view_id,
view_type: TypeId::of::<T>(),
ref_counts: handle.ref_counts.clone(),
}
}
}
impl<T: View> From<ViewHandle<T>> for AnyViewHandle {
fn from(handle: ViewHandle<T>) -> Self {
(&handle).into()
}
}
impl Drop for AnyViewHandle {
fn drop(&mut self) {
if let Some(ref_counts) = self.ref_counts.upgrade() {
ref_counts.lock().dec_view(self.window_id, self.view_id);
}
}
}
/// A weak reference to a particular [`View`] instance within the application.
///
/// `WeakViewHandle` is useful when a view wants to hold onto its own handle -
/// holding a strong reference via `ViewHandle` would create a reference cycle
/// that prevents the application from ever dropping the view.
pub struct WeakViewHandle<T> {
view_id: EntityId,
view_type: PhantomData<T>,
}
impl<T: View> WeakViewHandle<T> {
pub(super) fn new(view_id: EntityId) -> Self {
Self {
view_id,
view_type: PhantomData,
}
}
pub fn upgrade(&self, app: &AppContext) -> Option<ViewHandle<T>> {
// Look up the current window for this view
let window_id = app.view_to_window.get(&self.view_id).copied()?;
if app
.windows
.get(&window_id)
.and_then(|w| w.views.get(&self.view_id))
.is_some()
{
Some(ViewHandle::new(window_id, self.view_id, &app.ref_counts))
} else {
None
}
}
pub fn id(&self) -> EntityId {
self.view_id
}
/// Returns the current window this view belongs to, if any.
pub fn window_id(&self, app: &AppContext) -> Option<WindowId> {
app.view_to_window.get(&self.view_id).copied()
}
}
impl<T> Clone for WeakViewHandle<T> {
fn clone(&self) -> Self {
Self {
view_id: self.view_id,
view_type: PhantomData,
}
}
}
impl<T> Debug for WeakViewHandle<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct(&format!("WeakViewHandle<{}>", core::any::type_name::<T>()))
.field("view_id", &self.view_id)
.finish()
}
}
unsafe impl<T> Send for WeakViewHandle<T> {}
unsafe impl<T> Sync for WeakViewHandle<T> {}
pub trait ViewAsRef {
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T;
/// Try to get a reference to the view. Returns `None` if the view is
/// currently borrowed (e.g., during a circular reference scenario).
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T>;
}
pub trait ReadView: ViewAsRef {
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
where
T: View,
F: FnOnce(&T, &AppContext) -> S;
}
pub trait UpdateView: ReadView {
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
where
T: View,
F: FnOnce(&mut T, &mut ViewContext<T>) -> S;
}
+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 [`galaxyui`](crate),
/// these are structures that implement [`Element`]; in React, these are DOM
/// elements).
///
/// # Example
///
/// ```
/// # use galaxyui_core::{*, elements::Rect};
///
/// struct MyView {}
///
/// impl Entity for MyView {
/// type Event = ();
/// }
///
/// impl View for MyView {
/// fn ui_name() -> &'static str { "MyView" }
/// fn render(&self, app: &AppContext) -> Box<dyn Element> {
/// Rect::new().finish()
/// }
/// }
/// ```
pub trait View: Entity {
/// Returns a unique name for this implementation of View.
fn ui_name() -> &'static str;
/// Produces an [`Element`] tree representation of this view.
fn render(&self, app: &AppContext) -> Box<dyn Element>;
/// Handles the view or its descendent receiving focus.
/// Which view received focus is indicated by the [`FocusContext`].
fn on_focus(&mut self, _focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) {}
/// Accessibility (a11y) support for [`View`]s.
///
/// Whenever the view is focused (i.e. [`View::on_focus`]), the provided a11y content
/// is read out through the native screen reader (e.g. VoiceOver in MacOS).
///
/// While the contents default to [`None`] (i.e. no a11y content), each view
/// is encouraged to provide sensible a11y content so that visually-impaired users
/// can follow along in the application.
fn accessibility_contents(&self, _ctx: &AppContext) -> Option<AccessibilityContent> {
None
}
/// Reports the active cursor position for the view, if any.
/// This only applies to [`View`]s that have some sort of text editor.
///
/// We intentionally provide _immutable_ access to the [`ViewContext`];
/// querying the active cursor position shouldn't necessitate writes.
fn active_cursor_position(&self, _ctx: &ViewContext<Self>) -> Option<CursorInfo> {
None
}
/// Handles the view or its descendent losing focus.
/// Which view lost focus is indicated by the [`BlurContext`].
fn on_blur(&mut self, _blur_ctx: &BlurContext, _ctx: &mut ViewContext<Self>) {}
/// Handles the view's containing window closing.
fn on_window_closed(&mut self, _ctx: &mut ViewContext<Self>) {}
/// Called when the view is transferred from one window to another.
/// Views can override this to update any window-specific state.
fn on_window_transferred(
&mut self,
_source_window_id: super::super::WindowId,
_target_window_id: super::super::WindowId,
_ctx: &mut ViewContext<Self>,
) {
}
/// Returns a representation of the current UI context for use in computing
/// the set of valid actions/keyboard shortcuts.
fn keymap_context(&self, _: &AppContext) -> keymap::Context {
Self::default_keymap_context()
}
/// Returns the default context for a view.
fn default_keymap_context() -> keymap::Context {
let mut ctx = keymap::Context::default();
ctx.set.insert(Self::ui_name());
ctx
}
/// Allows a view to hook into any interactions with it or its children.
///
/// A valid interaction must be handled by the view or its children, and includes:
/// - all mouse events, except [`Event::MouseMoved`] and [`Event::ScrollWheel`]
/// - all keyboard events, including all [`CustomAction`]s and [`StandardAction::Paste`]
fn self_or_child_interacted_with(&self, _ctx: &mut ViewContext<Self>) {}
/// Returns the current [`AccessibilityData`] for this view, if `Some`. Returning a valid
/// [`AccessibilityData`] struct here indicates that this view should belong in the
/// accessibility tree of this application.
fn accessibility_data(&self, _ctx: &mut ViewContext<Self>) -> Option<AccessibilityData> {
None
}
}
/// The accessibility data of a current view.
pub struct AccessibilityData {
/// The contents of the view.
pub content: String,
}
/// An interface for a structure (typically a [`View`]) that handle actions
/// of a particular type.
pub trait TypedActionView {
type Action: Action;
/// Handles an action of type [`Self::Action`](TypedActionView::Action)
/// that was dispatched from this view or any descendant.
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
/// TypedActionViews can implement another way to provide context about what’s going on with the app. After each `handle_action` call, the UI framework calls `action_accessibility_contents(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) -> ActionAccessibilityContent`.
///
/// ### When and how to use it?
/// This method should be implemented for all the meaningful Actions. For example, actions related to mouse movement are not meaningful, but action related to user copying the selected content - is.
/// If your Action enum has tons of actions, and only some of them are meaningful, you can use helper implementations from the `galaxyui::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;
@@ -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))
}
}
@@ -0,0 +1,124 @@
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use super::Point;
use crate::{
event::DispatchedEvent, AfterLayoutContext, AppContext, ClipBounds, Element, Event,
EventContext, LayoutContext, PaintContext, SizeConstraint,
};
type DismissCallback = Box<dyn FnMut(&mut EventContext, &AppContext)>;
/// Element that is used to dismiss its child element.
/// Clicking on this element is equivalent to clicking outside the child element.
pub struct Dismiss {
child: Box<dyn Element>,
dismiss_handler: Option<DismissCallback>,
origin: Option<Point>,
/// Whether or not the element should make the rest of the window unresponsive. All mouse events
/// are handled by the [`Dismiss`] rather than being propagated further down in the element
/// hierarchy.
prevent_interaction_with_other_elements: bool,
}
impl Dismiss {
pub fn new(child: Box<dyn Element>) -> Self {
Self {
child,
dismiss_handler: None,
origin: None,
prevent_interaction_with_other_elements: false,
}
}
/// Attach a handler for when the dismiss is clicked
pub fn on_dismiss<F>(mut self, handler: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext),
{
self.dismiss_handler = Some(Box::new(handler));
self
}
/// Prevents interactions with any other elements outside of the [`Dismiss`]. All events are
/// handled by this element and are _not_ propagated further down the element hierarchy.
pub fn prevent_interaction_with_other_elements(mut self) -> Self {
self.prevent_interaction_with_other_elements = true;
self
}
}
impl Element for Dismiss {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.child.after_layout(ctx, app);
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
if !self.prevent_interaction_with_other_elements {
// Create a new layer for the contents so that we can distinguish click events that happen
// outside of the child element we want to dismiss
ctx.scene.start_layer(ClipBounds::ActiveLayer);
self.child.paint(origin, ctx, app);
ctx.scene.stop_layer();
} else {
// Create an invisible rect underneath the child that spans the window and prevents all
// underlayed elements from responding to events, until a click occurs.
ctx.scene
.draw_rect_with_hit_recording(RectF::new(Vector2F::zero(), ctx.window_size));
self.child.paint(origin, ctx, app);
}
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
if self.child.dispatch_event(event, ctx, app) {
return true;
}
let z_index = self.z_index().unwrap();
match (
self.dismiss_handler.as_mut(),
event.at_z_index(z_index, ctx),
) {
// If the event is available at the root z-index, that means it isn't covered by the
// child element, which means the user is clicking outside of the child element
(Some(handler), Some(Event::LeftMouseDown { .. })) => {
handler(ctx, app);
}
(None, Some(Event::LeftMouseDown { .. })) => {
log::warn!("Dismiss underlay was clicked but no handler was set!");
}
_ => {}
};
false
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn origin(&self) -> Option<Point> {
self.origin
}
#[cfg(any(test, feature = "test-util"))]
fn debug_text_content(&self) -> Option<String> {
self.child.debug_text_content()
}
}
@@ -0,0 +1,781 @@
use std::cmp::Ordering;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::Arc;
use crate::elements::DropTargetData;
use crate::platform::Cursor;
use crate::{
elements::Point, AfterLayoutContext, AppContext, Element, EventContext, LayoutContext,
PaintContext, SizeConstraint,
};
use crate::{
event::{DispatchedEvent, Event},
presenter::PositionCache,
scene::{ClipBounds, ZIndex},
};
use itertools::Itertools;
use parking_lot::Mutex;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
/// The default drag threshold used when no value is explicitly set by the creator
const DEFAULT_DRAG_THRESHOLD: f32 = 5.;
/// Opaque state container for maintaining drag across re-renders
///
/// Cheap to clone so that the owning View can easily create new Elements with the state
#[derive(Clone, Default)]
pub struct DraggableState {
inner: Arc<Mutex<DragState>>,
suppress_overlay_paint: Arc<AtomicBool>,
}
impl DraggableState {
/// Determine if the current state represents an actual drag or not
pub fn is_dragging(&self) -> bool {
matches!(*self.inner.lock(), DragState::Dragging { .. })
}
/// When true, the drag overlay visual will not be painted.
/// Used during preview capture to exclude the drag ghost from the captured frame.
pub fn suppress_overlay_paint(&self) -> bool {
self.suppress_overlay_paint.load(AtomicOrdering::Relaxed)
}
/// Set whether to suppress painting the drag overlay visual.
pub fn set_suppress_overlay_paint(&self, suppress: bool) {
self.suppress_overlay_paint
.store(suppress, AtomicOrdering::Relaxed);
}
/// Copy the actual drag state value out of the container
fn read(&self) -> DragState {
*self.inner.lock()
}
/// Returns the cursor offset within the draggable element, if drag state is available.
pub fn cursor_offset_within_element(&self) -> Option<Vector2F> {
match self.read() {
DragState::None => None,
DragState::WaitingToDrag {
mouse_down_offset, ..
} => Some(-mouse_down_offset),
DragState::Dragging { mouse_offset, .. } => Some(-mouse_offset),
}
}
pub fn adjust_mouse_position(&self, delta: Vector2F) {
let mut guard = self.inner.lock();
if let DragState::Dragging { mouse_position, .. } = &mut *guard {
*mouse_position += delta;
}
}
pub fn set_dragging(&self, new_mouse_position: Vector2F, new_mouse_offset: Vector2F) {
self.store(DragState::Dragging {
mouse_position: new_mouse_position,
mouse_offset: new_mouse_offset,
is_on_accepted_drop_target: false,
});
}
pub fn cancel_drag(&self) {
self.store(DragState::None);
}
/// Update the drag state with a new value
fn store(&self, new_state: DragState) {
*self.inner.lock() = new_state;
}
}
/// Internal state tracking whether or not we are dragging the element and the parameters of the
/// drag
#[derive(Clone, Copy, Default)]
enum DragState {
/// No dragging is happening
#[default]
None,
/// The mouse is held down, but has not yet moved beyond the drag threshold
WaitingToDrag {
/// The position where the mouse down occurred, used to check against the threshold
mouse_down_position: Vector2F,
/// The offset from the mouse down position to the natural origin of the element
mouse_down_offset: Vector2F,
},
Dragging {
/// The most recently reported position of the mouse
mouse_position: Vector2F,
/// The offset from the mouse to the origin of the Element
///
/// This is determined by the mouse position when dragging starts and is used during
/// dragging to calculate the dragged origin via `origin = mouse_position + mouse_offset`
mouse_offset: Vector2F,
/// Whether the dragged element is currently on an accepted drop target.
is_on_accepted_drop_target: bool,
},
}
/// The axis to which a draggable is fixed, limiting it to only move in one direction
#[derive(Clone, Copy)]
pub enum DragAxis {
HorizontalOnly,
VerticalOnly,
}
type BoundsCallback = Box<dyn FnMut(&PositionCache, Vector2F) -> Option<RectF>>;
/// The bounds for dragging this element.
///
/// This can be set to a fixed rectangle in the scene or to a callback that is used to calculate
/// the bounds.
enum DragBounds {
None,
Fixed(RectF),
Callback(BoundsCallback),
}
impl DragBounds {
fn calculate(
&mut self,
position_cache: &PositionCache,
window_size: Vector2F,
) -> Option<RectF> {
match self {
DragBounds::None => None,
DragBounds::Fixed(bounds) => Some(*bounds),
DragBounds::Callback(callback) => callback(position_cache, window_size),
}
}
}
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, RectF)>;
/// Handler when a `Draggable` is dragged and dropped. Includes the data of a [`crate::elements::DropTarget`] if
/// the `Draggable` was dropped on a `DropTarget`.
type DragDropHandler =
Box<dyn FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>)>;
pub enum AcceptedByDropTarget {
Yes,
No,
}
/// Callback that determines whether this [`Draggable`] can be dropped on a `DropTarget` that
/// contains [`DropTargetData`].
type AcceptedByDropTargetHandler =
Box<dyn Fn(&dyn DropTargetData, &AppContext) -> AcceptedByDropTarget>;
/// A container element that can be freely dragged and dropped around the screen
///
/// Dragging starts when the mouse is held down and dragged at least a threshold distance (default
/// 5 pixels) away from where it started. Dragging stops when the mouse is released.
///
/// ## Layout and Painting
///
/// While dragging, the Element is still laid out using its original position in the Element tree,
/// however it is painted at the location of the mouse. Once dragging stops, it returns to being
/// painted in the normal tree element position (i.e. the dragged position is not maintained after
/// dragging stops).
///
/// ## Limiting the Draggable Area
///
/// There are two complementary ways to limit the space that the Element can be dragged:
///
/// 1. Specifying a drag axis so that the Element can only be dragged in one direction.
/// 2. Specifying bounds that limit the Element to only dragging within a specific rectangle.
///
/// ### Fixed Axis
///
/// To limit the Draggable to only move in a single direction, call `with_drag_axis` and pass it
/// the appropriate direction (either `DragAxis::HorizontalOnly` or `DragAxis::VerticalOnly`). The
/// Element will be able to freely move in the given direction, but will not move at all in the
/// perpendicular direction.
///
/// ### Bounding Box
///
/// To specify a bounding box in which the Element is confined, call either:
///
/// * `with_drag_bounds` - Takes a fixed `RectF` value and uses that for the bounds.
/// * `with_drag_bounds_callback` - Takes a callback—which accepts a `&StackContext` and the window
/// size—that returns an `Option<RectF>` to indicate the bounds (or lack thereof).
///
/// In either case, if a bounding rectangle exists, the Element will be confined to only drag
/// completely within that rectangle. If it happens that the bounding box is smaller than the
/// Element, the top-left corner will be fixed within the bounding box and any overflow will happen
/// to the right or below the bounds.
///
/// If you specify a callback for calculating the bounds, it will be called each time the Element
/// is painted and the value will be cached for subsequent events.
///
/// ## Callbacks
///
/// There are three event callbacks that can be used to react to dragging:
///
/// - `on_drag_start`: Called when the `drag_threshold` is crossed and dragging begins.
/// - `on_drag`: Called on mouse move while dragging.
/// - `on_drop`: Called on mouse up when dragging stops. If the `Draggable` was dropped on
/// a [`crate::elements::DropTarget`] the data of that `DropTarget` is passed as a parameter.
///
/// All of the callbacks receive three parameters:
///
/// - An `&mut EventContext`
/// - An `&AppContext`
/// - A `RectF` representing the current painted position and size of the Element.
///
/// Note: For `on_drag_start`, the `RectF` passed will be the original position of the Element when
/// the mouse was first pressed, not the shifted position after crossing the threshold.
///
/// ## Child Events
///
/// While this element is actively being dragged, all events to the child Element will be
/// suppressed, the drag behavior will take precedence over any other events.
///
///
/// ## Drop Targets
///
/// `Draggable`s can optionally be dropped on [`crate::elements::DropTarget`]s. When dropped, the
/// [`:DropTargetData`] of the `DropTarget` is included as a parameter to identify the `DropTarget`
/// the element was dropped on.
pub struct Draggable {
state: DraggableState,
child: Box<dyn Element>,
alternate_drag_element: Option<Box<dyn Element>>,
child_max_z_index: Option<ZIndex>,
unmodified_origin: Option<Vector2F>,
drag_threshold: f32,
drag_axis: Option<DragAxis>,
drag_bounds: DragBounds,
// Cache of the bounds value, used to avoid redundant calls to `DragBounds::Callback`. This is
// updated on every call to `paint` so that even if the Element is laid out again, the value is
// accurate for subsequent events.
bounds_cache: Option<RectF>,
/// If true, keeps the original element visible in its original position during drag.
keep_original_visible: bool,
start_handler: Option<Handler>,
drag_handler: Option<DragDropHandler>,
is_accepted_by_drop_target_handler: Option<AcceptedByDropTargetHandler>,
drop_handler: Option<DragDropHandler>,
/// Whether to use the copy cursor while dragging on a valid drop target.
use_copy_cursor_when_dragging_over_drop_target: bool,
}
impl Draggable {
pub fn new(state: DraggableState, child: Box<dyn Element>) -> Self {
Self {
state,
child,
alternate_drag_element: None,
child_max_z_index: None,
unmodified_origin: None,
drag_threshold: DEFAULT_DRAG_THRESHOLD,
drag_axis: None,
drag_bounds: DragBounds::None,
bounds_cache: None,
keep_original_visible: false,
start_handler: None,
drag_handler: None,
is_accepted_by_drop_target_handler: None,
drop_handler: None,
use_copy_cursor_when_dragging_over_drop_target: false,
}
}
/// Set a custom drag threshold, in pixels.
pub fn with_drag_threshold(mut self, threshold: f32) -> Self {
self.drag_threshold = threshold;
self
}
/// Set a custom drag axis.
pub fn with_drag_axis(mut self, axis: DragAxis) -> Self {
self.drag_axis = Some(axis);
self
}
/// Sets an alternate element to be rendered while the drag is active
pub fn with_alternate_drag_element(mut self, element: Box<dyn Element>) -> Self {
self.alternate_drag_element = Some(element);
self
}
/// When true, keeps the original element visible in its original position during drag,
/// showing both the original and the dragged copy.
pub fn with_keep_original_visible(mut self, keep_visible: bool) -> Self {
self.keep_original_visible = keep_visible;
self
}
/// Set custom bounds to limit where the element can be dragged.
///
/// Note: If the bounds are smaller than the element along either axis, the top-left corner
/// will be clamped to the minimum value of the bounds along that axis.
pub fn with_drag_bounds(mut self, bounds: RectF) -> Self {
self.drag_bounds = DragBounds::Fixed(bounds);
self
}
/// Whether to use the copy cursor when dragging over a drop target.
pub fn use_copy_cursor_when_dragging_over_drop_target(mut self) -> Self {
self.use_copy_cursor_when_dragging_over_drop_target = true;
self
}
/// Set a custom bounds callback used to calculate the limits of dragging.
///
/// The value will be calculated whenever the element is painted and cached for use in
/// subsequent events.
///
/// Note: If the bounds are smaller than the element along either axis, the top-left corner
/// will be clamped to the minimum value of the bounds along that axis.
pub fn with_drag_bounds_callback<F>(mut self, callback: F) -> Self
where
F: FnMut(&PositionCache, Vector2F) -> Option<RectF> + 'static,
{
self.drag_bounds = DragBounds::Callback(Box::new(callback));
self
}
/// Add a callback which will be called on mouse down when dragging starts.
pub fn on_drag_start<F>(mut self, callback: F) -> Self
where
F: FnMut(&mut EventContext, &AppContext, RectF) + 'static,
{
self.start_handler = Some(Box::new(callback));
self
}
/// Add a callback which will be called on mouse move while dragging.
pub fn on_drag<F>(mut self, callback: F) -> Self
where
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
{
self.drag_handler = Some(Box::new(callback));
self
}
/// Add a callback which will be called on mouse up when dragging ends.
pub fn on_drop<F>(mut self, callback: F) -> Self
where
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
{
self.drop_handler = Some(Box::new(callback));
self
}
/// Add a callback to determine if a given DropTarget will accept this draggable
/// for drop or drag callbacks
pub fn with_accepted_by_drop_target_fn<F>(mut self, callback: F) -> Self
where
F: Fn(&dyn DropTargetData, &AppContext) -> AcceptedByDropTarget + 'static,
{
self.is_accepted_by_drop_target_handler = Some(Box::new(callback));
self
}
/// Add a callback which will be called on mouse down when dragging starts.
pub fn set_on_drag_start<F>(&mut self, callback: F)
where
F: FnMut(&mut EventContext, &AppContext, RectF) + 'static,
{
self.start_handler = Some(Box::new(callback));
}
/// Add a callback which will be called on mouse move while dragging.
pub fn set_on_drag<F>(&mut self, callback: F)
where
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
{
self.drag_handler = Some(Box::new(callback));
}
/// Add a callback which will be called on mouse up when dragging ends.
pub fn set_on_drop<F>(&mut self, callback: F)
where
F: FnMut(&mut EventContext, &AppContext, RectF, Option<&dyn DropTargetData>) + 'static,
{
self.drop_handler = Some(Box::new(callback));
}
/// Determine the drag origin based on the specified axis and any cached bounds.
fn drag_origin(&self, mouse_position: Vector2F, mouse_offset: Vector2F) -> Vector2F {
let unclamped_origin = match self.drag_axis {
// By default, we allow full drag in both directions, so we can use Vector addition
// to determine the appropriate origin.
None => mouse_position + mouse_offset,
Some(DragAxis::HorizontalOnly) => {
// For horizontal-only drag, we use the x value from the mouse position and keep
// the default y value from the laid-out element
let x = mouse_position.x() + mouse_offset.x();
let y = self.unmodified_origin.expect("origin should exist").y();
Vector2F::new(x, y)
}
Some(DragAxis::VerticalOnly) => {
// Similarly, for vertical-only drag, we use the x value from the laid-out element
// and the y value from the mouse
let x = self.unmodified_origin.expect("origin should exist").x();
let y = mouse_position.y() + mouse_offset.y();
Vector2F::new(x, y)
}
};
match self.bounds_cache {
Some(bounds) => {
let size = self.size().expect("size should be set");
let min_x = bounds.min_x();
let max_x = (bounds.max_x() - size.x()).max(min_x);
let x = unclamped_origin.x().clamp(min_x, max_x);
let min_y = bounds.min_y();
let max_y = (bounds.max_y() - size.y()).max(min_y);
let y = unclamped_origin.y().clamp(min_y, max_y);
Vector2F::new(x, y)
}
None => unclamped_origin,
}
}
/// Returns the [`DropTargetData`] for a [`crate::elements::DropTarget`] that overlaps with
/// `rect`. Returns `None` if there is a no `DropTarget` at the location.
///
/// If multiple `DropTarget`s are matched, the one with the smallest size (by area) is
/// returned. If the areas are the same, then we return the drop target with the closest center
/// to the drag position center.
fn compute_drop_target_data(
rect: RectF,
accepted_function: &AcceptedByDropTargetHandler,
ctx: &mut EventContext,
app: &AppContext,
) -> Option<Arc<dyn DropTargetData>> {
ctx.drop_target_data()
.filter(|drop_target_position| {
drop_target_position.bounds().intersection(rect).is_some()
&& match accepted_function(drop_target_position.data().as_ref(), app) {
AcceptedByDropTarget::Yes => true,
AcceptedByDropTarget::No => false,
}
})
.sorted_by(|position_a, position_b| {
if position_a.area().round() != position_b.area().round() {
position_a.area().cmp(&position_b.area())
} else {
let drag_center = rect.center();
let position_a_center_distance =
(drag_center - position_a.bounds().center()).length();
let position_b_center_distance =
(drag_center - position_b.bounds().center()).length();
if position_a_center_distance < position_b_center_distance {
Ordering::Less
} else {
Ordering::Greater
}
}
})
.map(|drop_target_position| drop_target_position.data().clone())
.next()
}
/// Computes the mouse offset based on whether or not there's a specified alternate child. If there's
/// an alternate child, the calculated offset will be based on the ratio of the sizes to the base child
/// versus the alternate child.
///
/// From the user's perspective, this ensures that the mouse position is in the same relative position
/// in the alternate element as where they started the drag.
fn compute_mouse_offset(&self, base_mouse_offset: Vector2F, child_size: Vector2F) -> Vector2F {
if let Some(alternate_child) = &self.alternate_drag_element {
let alternate_child_size = alternate_child.size().expect("size should exist");
let size_difference_ratio = Vector2F::new(
alternate_child_size.x() / child_size.x(),
alternate_child_size.y() / child_size.y(),
);
Vector2F::new(
base_mouse_offset.x() * size_difference_ratio.x(),
base_mouse_offset.y() * size_difference_ratio.y(),
)
} else {
base_mouse_offset
}
}
}
impl Element for Draggable {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
if let Some(alternate_child) = &mut self.alternate_drag_element {
// For the alternate drag element, we ignore parent size contraints
alternate_child.layout(
SizeConstraint::new(vec2f(0.0, 0.0), vec2f(f32::INFINITY, f32::INFINITY)),
ctx,
app,
);
}
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
if let Some(alternate_child) = &mut self.alternate_drag_element {
alternate_child.after_layout(ctx, app);
}
self.child.after_layout(ctx, app)
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
// We always cache the laid-out origin for the element, even if we are drawing it elsewhere
// for a drag. This allows us to look up the unmodified origin when calculating position
// for fixed-axis draggables
self.unmodified_origin = Some(origin);
// Update the bounds cache based on the provided drag bounds, if necessary
self.bounds_cache = self
.drag_bounds
.calculate(ctx.position_cache, ctx.window_size);
match self.state.read() {
DragState::None | DragState::WaitingToDrag { .. } => {
// If we aren't dragging or we haven't yet passed the drag threshold, we paint the
// element in its normal location.
self.child.paint(origin, ctx, app);
}
DragState::Dragging {
mouse_position,
mouse_offset,
..
} => {
if self.keep_original_visible || self.state.suppress_overlay_paint() {
self.child.paint(origin, ctx, app);
}
// Paint the dragged element on an overlay layer so it appears
// above anything we drag over.
if !self.state.suppress_overlay_paint() {
ctx.scene.start_overlay_layer(ClipBounds::None);
let drag_origin = self.drag_origin(mouse_position, mouse_offset);
if let Some(alternate_child) = &mut self.alternate_drag_element {
alternate_child.paint(drag_origin, ctx, app);
} else {
self.child.paint(drag_origin, ctx, app);
}
ctx.scene.stop_layer();
}
}
}
// After drawing the child (and stopping the overlay layer, if appropriate), the max
// z-index in the scene will represent the highest point drawn by the child.
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
let size = self.size().expect("size should exist");
let current_state = self.state.read();
let handled = match current_state {
DragState::None | DragState::WaitingToDrag { .. } => {
// If we have not yet started dragging, then we always pass events to the child
self.child.dispatch_event(event, ctx, app)
}
DragState::Dragging { .. } => {
// If we are dragging, then we suppress all child events for the duration
false
}
};
match event.raw_event() {
Event::LeftMouseDown { position, .. } => {
let origin = self.origin().expect("origin should exist");
if let Some(rect) = ctx.visible_rect(origin, size) {
let max_z_index = self.child_max_z_index.expect("child z index should exist");
// Only start dragging if the mouse is within the element and not covered by
// an element on a higher layer
if rect.contains_point(*position)
&& !ctx.is_covered(Point::from_vec2f(*position, max_z_index))
{
let base_mouse_offset = origin.xy() - *position;
let mouse_down_offset = self.compute_mouse_offset(base_mouse_offset, size);
self.state.store(DragState::WaitingToDrag {
mouse_down_position: *position,
mouse_down_offset,
});
ctx.set_cursor(Cursor::PointingHand, max_z_index);
return true;
}
}
handled
}
Event::LeftMouseUp { .. } => match current_state {
DragState::None => handled,
DragState::WaitingToDrag { .. } => {
self.state.store(DragState::None);
ctx.reset_cursor();
true
}
DragState::Dragging {
mouse_offset,
mouse_position,
..
} => {
let origin = self.drag_origin(mouse_position, mouse_offset);
let rect = RectF::new(origin, size);
self.state.store(DragState::None);
let draggable_data = if let Some(accepted_fn) =
self.is_accepted_by_drop_target_handler.as_ref()
{
Self::compute_drop_target_data(rect, accepted_fn, ctx, app)
} else {
None
};
if let Some(callback) = self.drop_handler.as_mut() {
callback(ctx, app, rect, draggable_data.as_deref());
}
ctx.reset_cursor();
ctx.notify();
true
}
},
Event::LeftMouseDragged { position, .. } => match current_state {
DragState::None => handled,
DragState::WaitingToDrag {
mouse_down_position,
mouse_down_offset,
} => {
let drag_start_distance = (mouse_down_position - *position).length();
if drag_start_distance > self.drag_threshold {
// If the drag has moved beyond the `drag_threshold`, then we officially
// start the drag and fire the `on_drag_start` callback.
self.state.store(DragState::Dragging {
mouse_offset: mouse_down_offset,
mouse_position: *position,
is_on_accepted_drop_target: false,
});
// Note: For the `on_drag_start` callback, we pass the position that the
// mouse down happened, since that is where the element was at the start
// of the drag.
let origin = self.drag_origin(mouse_down_position, mouse_down_offset);
let rect = RectF::new(origin, size);
dispatch_callback(self.start_handler.as_mut(), ctx, app, rect);
ctx.notify();
true
} else {
handled
}
}
DragState::Dragging {
mouse_offset,
is_on_accepted_drop_target: was_on_accepted_drop_target,
..
} => {
let origin = self.drag_origin(*position, mouse_offset);
let rect = RectF::new(origin, size);
let draggable_data = if let Some(accepted_fn) =
self.is_accepted_by_drop_target_handler.as_ref()
{
Self::compute_drop_target_data(rect, accepted_fn, ctx, app)
} else {
None
};
let is_on_accepted_drop_target = draggable_data.is_some();
if self.use_copy_cursor_when_dragging_over_drop_target {
let max_z_index =
self.child_max_z_index.expect("child z index should exist");
match (was_on_accepted_drop_target, is_on_accepted_drop_target) {
(true, false) => {
ctx.set_cursor(Cursor::PointingHand, max_z_index);
}
(false, true) => {
ctx.set_cursor(Cursor::DragCopy, max_z_index);
}
_ => {}
}
}
self.state.store(DragState::Dragging {
mouse_offset,
mouse_position: *position,
is_on_accepted_drop_target,
});
dispatch_drag_drop_callback(
self.drag_handler.as_mut(),
ctx,
app,
rect,
draggable_data.as_deref(),
);
ctx.notify();
true
}
},
_ => handled,
}
}
fn size(&self) -> Option<Vector2F> {
match self.state.read() {
DragState::None | DragState::WaitingToDrag { .. } => self.child.size(),
DragState::Dragging { .. } => {
if let Some(alternate_child) = &self.alternate_drag_element {
alternate_child.size()
} else {
self.child.size()
}
}
}
}
fn origin(&self) -> Option<Point> {
match self.state.read() {
DragState::None | DragState::WaitingToDrag { .. } => self.child.origin(),
DragState::Dragging { .. } => {
if let Some(alternate_child) = &self.alternate_drag_element {
alternate_child.origin()
} else {
self.child.origin()
}
}
}
}
}
fn dispatch_callback(
callback: Option<&mut Handler>,
ctx: &mut EventContext,
app: &AppContext,
rect: RectF,
) {
if let Some(callback) = callback {
callback(ctx, app, rect);
}
}
fn dispatch_drag_drop_callback(
callback: Option<&mut DragDropHandler>,
ctx: &mut EventContext,
app: &AppContext,
rect: RectF,
drop_target_data: Option<&dyn DropTargetData>,
) {
if let Some(callback) = callback {
callback(ctx, app, rect, drop_target_data);
}
}
@@ -0,0 +1,103 @@
use crate::elements::Point;
use crate::event::DispatchedEvent;
use crate::{AppContext, Element, EventContext, LayoutContext, PaintContext, SizeConstraint};
use ordered_float::OrderedFloat;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use std::any::Any;
use std::fmt::Debug;
use std::sync::Arc;
/// Trait to identify data that is passed to a [`crate::elements::Draggable`] when dropped on
/// a [`DropTarget`].
pub trait DropTargetData: Debug + Any {
fn as_any(&self) -> &dyn Any;
}
/// Position for a [`DropTarget`] with the data should be passed to the
/// [`crate::elements::Draggable`] when dropped.
#[derive(Clone, Debug)]
pub(crate) struct DropTargetPosition {
bounds: RectF,
drop_data: Arc<dyn DropTargetData>,
}
impl DropTargetPosition {
pub fn bounds(&self) -> RectF {
self.bounds
}
pub fn data(&self) -> &Arc<dyn DropTargetData> {
&self.drop_data
}
/// Returns the area encompassed by this drop target position.
pub fn area(&self) -> OrderedFloat<f32> {
OrderedFloat::from(self.bounds.width() * self.bounds.height())
}
}
/// An element that marks whether a [`crate::elements::Draggable`] was dropped on top of it.
///
/// Each `DropTarget` is instantiated with data that implements the [`DropTargetData`] trait. When
/// an item is dropped on the `DropTarget`, the `Draggable` includes the data in the `on_drop`
/// callback.
pub struct DropTarget {
child: Box<dyn Element>,
data: Arc<dyn DropTargetData>,
}
impl DropTarget {
pub fn new(child: Box<dyn Element>, data: impl DropTargetData + 'static) -> Self {
Self {
child,
data: Arc::new(data),
}
}
}
impl Element for DropTarget {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &crate::AppContext) {
self.child.after_layout(ctx, app)
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.child.paint(origin, ctx, app);
let Some(bounds) = self.child.bounds() else {
return;
};
ctx.position_cache
.cache_drop_target_position(DropTargetPosition {
bounds,
drop_data: self.data.clone(),
});
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn origin(&self) -> Option<Point> {
self.child.origin()
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
self.child.dispatch_event(event, ctx, app)
}
}
@@ -0,0 +1,5 @@
mod draggable;
mod drop_target;
pub use draggable::*;
pub use drop_target::*;
@@ -0,0 +1,182 @@
use super::Point;
use super::ZIndex;
use pathfinder_geometry::vector::Vector2F;
use std::sync::{Arc, Mutex};
use crate::{
event::DispatchedEvent, platform::Cursor, AfterLayoutContext, AppContext, Element, Event,
EventContext, LayoutContext, PaintContext, SizeConstraint,
};
/// Shared handle for drag-to-resize state, following the same `Arc<Mutex<_>>`
/// pattern as `ResizableStateHandle`. The view creates the handle once at
/// construction and passes it into `DragResizeElement` each render.
pub type DragResizeHandle = Arc<Mutex<DragResizeState>>;
pub fn drag_resize_handle() -> DragResizeHandle {
Arc::new(Mutex::new(DragResizeState::default()))
}
/// Tracks whether a drag-to-resize operation is in progress.
#[derive(Default)]
pub struct DragResizeState {
is_dragging: bool,
last_y: f32,
}
impl DragResizeState {
fn begin(&mut self, y: f32) {
self.is_dragging = true;
self.last_y = y;
}
fn end(&mut self) {
self.is_dragging = false;
}
fn is_dragging(&self) -> bool {
self.is_dragging
}
/// Compute the vertical delta since the last event and update `last_y`.
fn consume_delta(&mut self, y: f32) -> f32 {
let delta = y - self.last_y;
self.last_y = y;
delta
}
}
/// Callback invoked during a resize drag. Receives the vertical delta (pixels).
type ResizeUpdateFn = Box<dyn Fn(f32, &mut EventContext, &AppContext)>;
/// Callback invoked when a resize drag finishes.
pub type ResizeEndFn = Box<dyn Fn(&mut EventContext, &AppContext)>;
/// An element that enables drag-to-resize on its entire surface area.
///
/// The element dispatches events to its child first. If the child does not
/// handle a `LeftMouseDown`, the element begins a resize operation. Subsequent
/// `LeftMouseDragged` / `LeftMouseUp` events are captured via `raw_event()` so
/// they work regardless of cursor position (same pattern used by `Resizable`).
pub struct DragResizeElement {
child: Box<dyn Element>,
handle: DragResizeHandle,
on_resize_update: ResizeUpdateFn,
on_resize_end: Option<ResizeEndFn>,
origin: Option<Point>,
child_max_z_index: Option<ZIndex>,
}
impl DragResizeElement {
pub fn new(
handle: DragResizeHandle,
child: Box<dyn Element>,
on_resize_update: impl Fn(f32, &mut EventContext, &AppContext) + 'static,
on_resize_end: Option<ResizeEndFn>,
) -> Self {
Self {
child,
handle,
on_resize_update: Box::new(on_resize_update),
on_resize_end,
origin: None,
child_max_z_index: None,
}
}
fn state(&self) -> std::sync::MutexGuard<'_, DragResizeState> {
// This is the same (slightly scary) pattern as `Resizable::state()`.
// Poisoning should only occur after a prior panic (already in a bad state).
self.handle.lock().expect("DragResizeState lock poisoned")
}
}
impl Element for DragResizeElement {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.child.after_layout(ctx, app);
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
self.child.paint(origin, ctx, app);
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
// Always let the child see the event first.
let child_handled = self.child.dispatch_event(event, ctx, app);
// Use raw_event() for drag/up so they are position-independent.
match event.raw_event() {
Event::LeftMouseDown { position, .. } => {
if child_handled {
return true;
}
// Check if the click is within our bounds.
if let (Some(origin), Some(size)) = (self.origin, self.size()) {
if let Some(rect) = ctx.visible_rect(origin, size) {
if rect.contains_point(*position) {
self.state().begin(position.y());
return true;
}
}
}
false
}
Event::LeftMouseDragged { position, .. } => {
if self.state().is_dragging() {
if let Some(z_index) = self.child_max_z_index {
ctx.set_cursor(Cursor::ResizeUpDown, z_index);
}
let delta = self.state().consume_delta(position.y());
(self.on_resize_update)(delta, ctx, app);
return true;
}
child_handled
}
Event::LeftMouseUp { .. } => {
if self.state().is_dragging() {
self.state().end();
if let Some(on_end) = &self.on_resize_end {
(on_end)(ctx, app);
}
ctx.reset_cursor();
return true;
}
child_handled
}
Event::MouseMoved { .. } => {
if self.state().is_dragging() {
if let Some(z_index) = self.child_max_z_index {
ctx.set_cursor(Cursor::ResizeUpDown, z_index);
}
return true;
}
child_handled
}
_ => child_handled,
}
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn origin(&self) -> Option<Point> {
self.child.origin()
}
}
@@ -0,0 +1,75 @@
use crate::event::DispatchedEvent;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use pathfinder_geometry::vector::Vector2F;
#[derive(Default)]
pub struct Empty {
size: Option<Vector2F>,
origin: Option<Point>,
}
impl Empty {
pub fn new() -> Self {
Self {
size: None,
origin: None,
}
}
}
impl Element for Empty {
fn layout(
&mut self,
constraint: SizeConstraint,
_: &mut LayoutContext,
_: &AppContext,
) -> Vector2F {
// Set the size of the element to be the max constraint. If the max constraint is unbounded
// use the min constraint to avoid rendering an unbounded-sized element.
let max_constraint = constraint.max;
let x = if max_constraint.x().is_infinite() {
constraint.min.x()
} else {
max_constraint.x()
};
let y = if max_constraint.y().is_infinite() {
constraint.min.y()
} else {
max_constraint.y()
};
let size = Vector2F::new(x, y);
self.size = Some(size);
size
}
fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
}
fn dispatch_event(
&mut self,
_: &DispatchedEvent,
_: &mut EventContext,
_: &AppContext,
) -> bool {
false
}
fn size(&self) -> Option<Vector2F> {
self.size
}
fn origin(&self) -> Option<Point> {
self.origin
}
}
@@ -0,0 +1,372 @@
use crate::{
event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState},
keymap::Keystroke,
platform::keyboard::KeyCode,
};
use super::{
AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext,
LayoutContext, PaintContext, Point, SizeConstraint, ZIndex,
};
use pathfinder_geometry::vector::Vector2F;
use std::cell::RefCell;
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult>;
type KeyHandler = Box<dyn FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult>;
type ScrollHandler = Box<
dyn FnMut(&mut EventContext, &AppContext, &Vector2F, &ModifiersState) -> DispatchEventResult,
>;
type ModifierStateChangedHandler =
Box<dyn FnMut(&mut EventContext, &AppContext, &KeyCode, &KeyState) -> DispatchEventResult>;
#[derive(Debug, Clone, Copy)]
pub struct MouseInBehavior {
/// Whether to fire the `mouse_in` event on synthetic events, which are events the UI
/// framework generates so in order to trigger hover effects when the underlying view has
/// changed even though the mouse hasn't actually moved. Typically elements should handle
/// sythetic hovers, but there are some cases where it's the incorrect behavior.
pub fire_on_synthetic_events: bool,
/// Whether to fire the `mouse_in` event when the element is covered by another element.
/// This is true by default, but some elements may want to configure this behavior.
pub fire_when_covered: bool,
}
impl Default for MouseInBehavior {
fn default() -> Self {
Self {
fire_on_synthetic_events: true,
fire_when_covered: true,
}
}
}
pub struct EventHandler {
child: Box<dyn Element>,
/// Allow this element to handle events even if a descendent already handled it.
always_handle: bool,
left_mouse_down: Option<RefCell<Handler>>,
left_mouse_up: Option<RefCell<Handler>>,
middle_mouse_down: Option<RefCell<Handler>>,
right_mouse_down: Option<RefCell<Handler>>,
forward_mouse_down: Option<RefCell<Handler>>,
back_mouse_down: Option<RefCell<Handler>>,
mouse_in: Option<RefCell<Handler>>,
mouse_in_behavior: MouseInBehavior,
mouse_out: Option<RefCell<Handler>>,
mouse_dragged: Option<RefCell<Handler>>,
scroll_wheel: Option<RefCell<ScrollHandler>>,
keydown: Option<RefCell<KeyHandler>>,
modifier_state_changed: Option<RefCell<ModifierStateChangedHandler>>,
origin: Option<Point>,
// This is a short-term solution for properly handling events on stacks. A stack will always
// put its children on higher z-indexes than its origin, so a hit test using the standard
// `z_index` method would always result in the event being covered (by the children of the
// stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element.
// Then we use that upper bound to do the hit testing, which means a parent will always get
// events from its children, regardless of whether they are stacks or not.
child_max_z_index: Option<ZIndex>,
}
impl EventHandler {
pub fn new(child: Box<dyn Element>) -> Self {
Self {
child,
always_handle: false,
left_mouse_down: None,
left_mouse_up: None,
middle_mouse_down: None,
right_mouse_down: None,
forward_mouse_down: None,
back_mouse_down: None,
mouse_in: None,
mouse_out: None,
mouse_dragged: None,
scroll_wheel: None,
keydown: None,
modifier_state_changed: None,
origin: None,
child_max_z_index: None,
mouse_in_behavior: Default::default(),
}
}
pub fn with_always_handle(mut self) -> Self {
self.always_handle = true;
self
}
pub fn on_keydown<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult,
{
self.keydown = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_modifier_state_changed<F>(mut self, callback: F) -> Self
where
F: 'static
+ FnMut(&mut EventContext, &AppContext, &KeyCode, &KeyState) -> DispatchEventResult,
{
self.modifier_state_changed = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_left_mouse_down<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.left_mouse_down = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_left_mouse_up<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.left_mouse_up = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_right_mouse_down<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.right_mouse_down = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_middle_mouse_down<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.middle_mouse_down = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_forward_mouse_down<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.forward_mouse_down = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_back_mouse_down<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.back_mouse_down = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_mouse_in<F>(mut self, callback: F, mouse_in_behavior: Option<MouseInBehavior>) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.mouse_in = Some(RefCell::new(Box::new(callback)));
self.mouse_in_behavior = mouse_in_behavior.unwrap_or_default();
self
}
pub fn on_mouse_out<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.mouse_out = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_mouse_dragged<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult,
{
self.mouse_dragged = Some(RefCell::new(Box::new(callback)));
self
}
pub fn on_scroll_wheel<F>(mut self, callback: F) -> Self
where
F: 'static
+ FnMut(&mut EventContext, &AppContext, &Vector2F, &ModifiersState) -> DispatchEventResult,
{
self.scroll_wheel = Some(RefCell::new(Box::new(callback)));
self
}
fn dispatch_callback(
&self,
callback: Option<&RefCell<Handler>>,
ctx: &mut EventContext,
position: Vector2F,
app: &AppContext,
) -> bool {
if let Some(callback) = callback.as_ref() {
if let Some(rect) = ctx.visible_rect(self.origin.unwrap(), self.size().unwrap()) {
if rect.contains_point(position) {
return match callback.borrow_mut()(ctx, app, position) {
DispatchEventResult::PropagateToParent => false,
DispatchEventResult::StopPropagation => true,
};
}
}
}
false
}
}
impl Element for EventHandler {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.child.after_layout(ctx, app);
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
self.child.paint(origin, ctx, app);
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
let handled = self.child.dispatch_event(event, ctx, app);
if handled && !self.always_handle {
return true;
}
let Some(z_index) = self.child_max_z_index else {
log::error!(
"Dispatching event {:?} on EventHandler element which was never painted.",
EventDiscriminants::from(event.raw_event())
);
return false;
};
match event.at_z_index(z_index, ctx) {
Some(Event::MouseMoved {
position,
is_synthetic,
..
}) => {
let MouseInBehavior {
fire_on_synthetic_events,
fire_when_covered,
} = self.mouse_in_behavior;
let is_covered = ctx.is_covered(Point::from_vec2f(
*position,
self.child_max_z_index.expect("child max z index not set"),
));
let should_fire = (!is_synthetic || fire_on_synthetic_events)
&& (fire_when_covered || !is_covered);
if should_fire
&& self.dispatch_callback(self.mouse_in.as_ref(), ctx, *position, app)
{
return true;
}
if self.dispatch_callback(self.mouse_out.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::LeftMouseDragged { position, .. }) => {
if self.dispatch_callback(self.mouse_dragged.as_ref(), ctx, *position, app) {
return true;
}
if self.dispatch_callback(self.mouse_in.as_ref(), ctx, *position, app) {
return true;
}
if self.dispatch_callback(self.mouse_out.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::LeftMouseDown { position, .. }) => {
if self.dispatch_callback(self.left_mouse_down.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::LeftMouseUp { position, .. }) => {
if self.dispatch_callback(self.left_mouse_up.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::MiddleMouseDown { position, .. }) => {
if self.dispatch_callback(self.middle_mouse_down.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::RightMouseDown { position, .. }) => {
if self.dispatch_callback(self.right_mouse_down.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::BackMouseDown { position, .. }) => {
if self.dispatch_callback(self.back_mouse_down.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::ForwardMouseDown { position, .. }) => {
if self.dispatch_callback(self.forward_mouse_down.as_ref(), ctx, *position, app) {
return true;
}
}
Some(Event::KeyDown { keystroke, .. }) => {
if let Some(callback) = self.keydown.as_ref() {
return match callback.borrow_mut()(ctx, app, keystroke) {
DispatchEventResult::PropagateToParent => false,
DispatchEventResult::StopPropagation => true,
};
}
}
Some(Event::ModifierKeyChanged { key_code, state }) => {
if let Some(callback) = self.modifier_state_changed.as_ref() {
return match callback.borrow_mut()(ctx, app, key_code, state) {
DispatchEventResult::PropagateToParent => false,
DispatchEventResult::StopPropagation => true,
};
}
}
Some(Event::ScrollWheel {
position,
delta,
precise: _,
modifiers,
}) => {
if let Some(callback) = self.scroll_wheel.as_ref() {
if let Some(rect) = ctx.visible_rect(self.origin.unwrap(), self.size().unwrap())
{
if rect.contains_point(*position) {
return match callback.borrow_mut()(ctx, app, delta, modifiers) {
DispatchEventResult::PropagateToParent => false,
DispatchEventResult::StopPropagation => true,
};
}
}
}
}
_ => {}
}
handled
}
fn origin(&self) -> Option<Point> {
self.child.origin()
}
}
#[cfg(test)]
#[path = "event_handler_test.rs"]
mod tests;
@@ -0,0 +1,621 @@
use super::*;
use crate::{
elements::{
ChildAnchor, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Rect, Stack,
},
platform::WindowStyle,
App, AppContext, Entity, EntityId, Presenter, TypedActionView, ViewContext, WindowInvalidation,
};
use pathfinder_geometry::vector::vec2f;
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
enum ElementIdentifier {
Base,
Inset,
Overlay,
}
#[derive(Default)]
struct View {
// Maps identifier to number of mouse down events
mouse_downs: HashMap<ElementIdentifier, usize>,
mouse_ins: HashMap<ElementIdentifier, usize>,
mouse_in_behavior: MouseInBehavior,
}
pub fn init(app: &mut AppContext) {
app.add_action("event_handler_test:mouse_down", View::mouse_down);
app.add_action("event_handler_test:mouse_in", View::mouse_in);
}
impl View {
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
*entry += 1;
true
}
fn mouse_in(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
let entry = self.mouse_ins.entry(*identifier).or_insert(0);
*entry += 1;
true
}
}
impl Entity for View {
type Event = ();
}
impl View {
fn new(mouse_in_behavior: MouseInBehavior) -> Self {
Self {
mouse_in_behavior,
..Default::default()
}
}
}
impl crate::core::View for View {
fn ui_name() -> &'static str {
"event_handler_test_view"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
let mut inner_stack = Stack::new();
inner_stack.add_child(
ConstrainedBox::new(Rect::new().finish())
.with_height(100.)
.with_width(100.)
.finish(),
);
inner_stack.add_positioned_child(
EventHandler::new(
ConstrainedBox::new(Rect::new().finish())
.with_height(25.)
.with_width(25.)
.finish(),
)
.on_left_mouse_down(|evt, _, _| {
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Inset);
DispatchEventResult::StopPropagation
})
.on_mouse_in(
|evt, _, _| {
evt.dispatch_action("event_handler_test:mouse_in", ElementIdentifier::Inset);
DispatchEventResult::StopPropagation
},
Some(self.mouse_in_behavior),
)
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 75.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
let mut stack = Stack::new();
stack.add_child(
EventHandler::new(inner_stack.finish())
.on_left_mouse_down(|evt, _, _| {
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Base);
DispatchEventResult::StopPropagation
})
.on_mouse_in(
|evt, _, _| {
evt.dispatch_action("event_handler_test:mouse_in", ElementIdentifier::Base);
DispatchEventResult::StopPropagation
},
Some(self.mouse_in_behavior),
)
.finish(),
);
stack.add_positioned_child(
EventHandler::new(
ConstrainedBox::new(Rect::new().finish())
.with_height(25.)
.with_width(25.)
.finish(),
)
.on_left_mouse_down(|evt, _, _| {
evt.dispatch_action("event_handler_test:mouse_down", ElementIdentifier::Overlay);
DispatchEventResult::StopPropagation
})
.on_mouse_in(
|evt, _, _| {
evt.dispatch_action("event_handler_test:mouse_in", ElementIdentifier::Overlay);
DispatchEventResult::StopPropagation
},
Some(self.mouse_in_behavior),
)
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(75., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
stack.finish()
}
}
impl TypedActionView for View {
type Action = ();
}
#[test]
fn test_layered_click_handling() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
assert_eq!(scene.z_index(), ZIndex::new(0));
assert_eq!(scene.layer_count(), 5);
let presenter = Rc::new(RefCell::new(presenter));
// Click on the overlay
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(90., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
// Click on the inset
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(10., 90.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
// Click on the top-left area of the base
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(10., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
// Click on the bottom-right area of the base
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(90., 90.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter,
);
});
view.read(app, |view, _| {
assert_eq!(
1,
*view.mouse_downs.get(&ElementIdentifier::Overlay).unwrap()
);
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
assert_eq!(2, *view.mouse_downs.get(&ElementIdentifier::Base).unwrap());
});
});
}
#[test]
fn test_default_mouse_in_behavior() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
assert_eq!(scene.z_index(), ZIndex::new(0));
assert_eq!(scene.layer_count(), 5);
let presenter = Rc::new(RefCell::new(presenter));
// Non-synthetic move over the overlay
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(90., 10.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
// Non-synthetic move over the inset
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(10., 90.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
// Non-synthetic move over top left the base
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(10., 10.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
// Non-synthetic move over the bottom right of base
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(90., 90.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
});
view.read(app, |view, _| {
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Overlay).unwrap());
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Inset).unwrap());
// Only 2 events should be fired because 1) the inset is a child of the base
// and doesn't propagate events to its parent 2) the overlay event is not propagated
// to the base.
assert_eq!(2, *view.mouse_ins.get(&ElementIdentifier::Base).unwrap());
});
});
}
#[test]
fn test_mouse_in_behavior_dont_fire_on_synthetic_events() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::new(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
})
});
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
assert_eq!(scene.z_index(), ZIndex::new(0));
assert_eq!(scene.layer_count(), 5);
let presenter = Rc::new(RefCell::new(presenter));
// Non-synthetic move over the overlay
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(90., 10.),
cmd: false,
shift: false,
is_synthetic: true,
},
window_id,
presenter.clone(),
);
});
view.read(app, |view, _| {
assert_eq!(
0,
*view
.mouse_ins
.get(&ElementIdentifier::Overlay)
.unwrap_or(&0)
);
assert_eq!(
0,
*view.mouse_ins.get(&ElementIdentifier::Inset).unwrap_or(&0)
);
assert_eq!(
0,
*view.mouse_ins.get(&ElementIdentifier::Base).unwrap_or(&0)
);
});
});
}
#[test]
fn test_mouse_in_behavior_dont_fire_when_covered() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::new(MouseInBehavior {
fire_on_synthetic_events: true,
fire_when_covered: false,
})
});
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
assert_eq!(scene.z_index(), ZIndex::new(0));
assert_eq!(scene.layer_count(), 5);
let presenter = Rc::new(RefCell::new(presenter));
// Non-synthetic move over the overlay
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(90., 10.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
// Non-synthetic move over the inset
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(10., 90.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
// Non-synthetic move over top left the base
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(10., 10.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
// Non-synthetic move over the bottom right of base
ctx.simulate_window_event(
Event::MouseMoved {
position: vec2f(90., 90.),
cmd: false,
shift: false,
is_synthetic: false,
},
window_id,
presenter.clone(),
);
});
view.read(app, |view, _| {
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Overlay).unwrap());
assert_eq!(1, *view.mouse_ins.get(&ElementIdentifier::Inset).unwrap());
assert_eq!(2, *view.mouse_ins.get(&ElementIdentifier::Base).unwrap());
});
});
}
/// For testing event propagation
#[derive(Debug)]
enum PropagationViewAction {
MouseDown(ElementIdentifier),
}
#[derive(Default)]
struct PropagationView {
// Maps identifier to number of mouse down events
mouse_downs: HashMap<ElementIdentifier, usize>,
allow_propagation: bool,
}
impl PropagationView {
fn mouse_down(&mut self, identifier: &ElementIdentifier) -> bool {
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
*entry += 1;
true
}
fn set_propagation(&mut self, allow_propagation: bool, ctx: &mut ViewContext<Self>) {
self.allow_propagation = allow_propagation;
ctx.notify();
}
}
impl Entity for PropagationView {
type Event = ();
}
impl crate::core::View for PropagationView {
fn ui_name() -> &'static str {
"event_handler_test_propagation_view"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
let allow_propagation = self.allow_propagation;
let handler = EventHandler::new(
ConstrainedBox::new(Rect::new().finish())
.with_height(100.)
.with_width(100.)
.finish(),
)
.on_left_mouse_down(move |evt, _, _| {
evt.dispatch_typed_action(PropagationViewAction::MouseDown(ElementIdentifier::Inset));
if allow_propagation {
DispatchEventResult::PropagateToParent
} else {
DispatchEventResult::StopPropagation
}
})
.finish();
EventHandler::new(handler)
.on_left_mouse_down(|evt, _, _| {
evt.dispatch_typed_action(PropagationViewAction::MouseDown(
ElementIdentifier::Base,
));
DispatchEventResult::StopPropagation
})
.finish()
}
}
impl TypedActionView for PropagationView {
type Action = PropagationViewAction;
fn handle_action(&mut self, action: &Self::Action, _: &mut ViewContext<Self>) {
match action {
PropagationViewAction::MouseDown(identifier) => {
self.mouse_down(identifier);
}
}
}
}
fn invalidate_and_rebuild_scene(
presenter: &Rc<RefCell<Presenter>>,
root_view_id: EntityId,
ctx: &mut AppContext,
) {
let mut updated = HashSet::new();
updated.insert(root_view_id);
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(100., 100.), 1., None, ctx);
}
#[test]
fn test_event_propagation() {
App::test((), |mut app| async move {
let (window_id, view) =
app.add_window(WindowStyle::NotStealFocus, |_| PropagationView::default());
let root_view_id = view.id();
app.update(move |ctx| {
invalidate_and_rebuild_scene(
&ctx.presenter(window_id).expect("Window should exist"),
root_view_id,
ctx,
);
// Click on the inset with propagation disabled
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(90., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
ctx.presenter(window_id)
.expect("window should exist")
.clone(),
);
});
view.read(&app, |view, _| {
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
assert_eq!(view.mouse_downs.get(&ElementIdentifier::Base), None);
});
// Allow propagation
view.update(&mut app, |view, ctx| {
view.set_propagation(true, ctx);
});
app.update(move |ctx| {
// Click on the inset with propagation enabled
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(90., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
ctx.presenter(window_id)
.expect("window should exist")
.clone(),
);
});
// Both the inset and the base should have received the even
view.read(&app, |view, _| {
assert_eq!(2, *view.mouse_downs.get(&ElementIdentifier::Inset).unwrap());
assert_eq!(1, *view.mouse_downs.get(&ElementIdentifier::Base).unwrap());
});
})
}
@@ -0,0 +1,75 @@
# Flex Element Debugging Guide
This guide helps diagnose and fix common Flex layout panics in WarpUI.
## Quick Reference: Error Messages → Fixes
### Error: `flex contains flexible children but has an infinite constraint along the flex axis`
**Cause**: A `Flex` with `MainAxisSize::Min` (the default) contains an `Expanded` or `Shrinkable` child but has no max constraint along the main axis.
**Fixes** (in order of preference):
1. Remove `Expanded`/`Shrinkable` from the child if growing isn't necessary
2. Add a max constraint to the `Flex` or an ancestor using `ConstrainedBox`:
- For `Flex::row()`: add `max_width`
- For `Flex::column()`: add `max_height`
3. If the `Flex` is inside another `Flex`, ensure the parent passes down bounded constraints
### Error: `A flex that should expand to a max space can't be rendered in an infinite max constraint`
**Cause**: A `Flex` has `MainAxisSize::Max` but no ancestor provides a maximum size constraint.
**Fixes**:
1. Remove `.with_main_axis_size(MainAxisSize::Max)` if the Flex doesn't need to fill its parent (use default `MainAxisSize::Min`)
2. Add a `ConstrainedBox` with a max constraint to the `Flex` or an ancestor
## Key Concepts
### Two Types of Children
- **Flexible children** (`Expanded`, `Shrinkable`): Size is calculated by dividing remaining space by flex ratio
- **Non-flexible children**: Laid out using their intrinsic size but still respect the max constraints from their parent `Flex`
### Important Behaviors
1. **`Expanded` only works as direct child of `Flex`** - wrapping in `Container`/`ConstrainedBox` breaks it
2. **`Expanded` doesn't force growth** - unlike CSS `flex-grow`, it only grants the *ability* to grow. Elements like `Text` don't expand by default; wrap in `Align` if needed
3. **`MainAxisSize::Min` + `Expanded` = effectively `MainAxisSize::Max`** - the `Expanded` child will grow to fill available space anyway
4. **Nested `Flex` with `MainAxisSize::Max`** - putting a `Flex` with `MainAxisSize::Max` inside another `Flex` with `MainAxisSize::Max` will cause a layout panic when neither receives a max constraint from an ancestor
## Common Patterns
### Centering horizontally (requires bounded parent)
```rust
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_child(element)
.finish()
```
### Centering vertically (requires bounded parent)
```rust
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(element)
.finish()
```
### Spacing groups apart (e.g., left/right aligned items)
```rust
Flex::row()
.with_child(left_element)
.with_child(Expanded::new(1.0, Empty::new())) // spacer
.with_child(right_element)
.finish()
```
## Debugging Tips
1. Run with `RUST_BACKTRACE=full` to identify which element(s) cause the panic
2. Check the element hierarchy for unbounded `Flex` containers
3. Trace constraints from root to the failing element - find where max constraint is lost
4. Avoid unnecessary `MainAxisSize::Max` - only use when the `Flex` *must* fill its parent
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,669 @@
use std::collections::HashSet;
use super::*;
use crate::elements::{Align, SavePosition, Stack};
use crate::geometry::rect::RectF;
use crate::platform::WindowStyle;
use crate::{
elements::{ConstrainedBox, ParentElement, Rect},
App, Entity, Presenter, TypedActionView, WindowId, WindowInvalidation,
};
type RenderFn = dyn Fn(&AppContext) -> Box<dyn Element> + 'static;
struct TestDynamicView {
render: Box<RenderFn>,
}
impl TestDynamicView {
fn new(render: impl Fn(&AppContext) -> Box<dyn Element> + 'static) -> Self {
Self {
render: Box::new(render),
}
}
}
impl Entity for TestDynamicView {
type Event = ();
}
impl crate::core::View for TestDynamicView {
fn ui_name() -> &'static str {
"Flex::tests::TestDynamicView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
(self.render)(app)
}
}
impl TypedActionView for TestDynamicView {
type Action = ();
}
/// Asserts that the bounds of all the painted rects match that of `rects`.
fn assert_bounds_of_rects(
app: &mut App,
window_id: WindowId,
rects: impl IntoIterator<Item = RectF>,
) {
let presenter_ref = app
.presenter(window_id)
.expect("Test window should have a presenter since first frame is rendered.");
let presenter = presenter_ref.borrow();
let scene = presenter
.scene()
.expect("Presenter should have rendered a scene after the test_view was updated.");
let actual_rects = scene
.layers()
.next()
.into_iter()
.flat_map(|layer| layer.rects.iter())
.map(|rect| rect.bounds);
itertools::assert_equal(actual_rects, rects);
}
struct View {
flex_main_axis_size: MainAxisSize,
flex_main_axis_alignment: MainAxisAlignment,
flex_cross_axis_alignment: CrossAxisAlignment,
}
impl View {
fn new(
axis_size: MainAxisSize,
main_axis_alignment: MainAxisAlignment,
cross_axis_alignment: CrossAxisAlignment,
) -> Self {
Self {
flex_main_axis_size: axis_size,
flex_main_axis_alignment: main_axis_alignment,
flex_cross_axis_alignment: cross_axis_alignment,
}
}
}
impl Entity for View {
type Event = String;
}
impl crate::core::View for View {
fn render<'a>(&self, _: &AppContext) -> Box<dyn Element> {
let flex = Flex::row()
.with_children([
SavePosition::new(
ConstrainedBox::new(Rect::new().finish())
.with_height(20.)
.with_width(50.)
.finish(),
"view_1",
)
.finish(),
SavePosition::new(
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(50.)
.finish(),
"view_2",
)
.finish(),
SavePosition::new(
Flex::row()
.with_child(
ConstrainedBox::new(Rect::new().finish())
.with_height(50.)
.with_width(50.)
.finish(),
)
.finish(),
"view_3",
)
.finish(),
])
.with_cross_axis_alignment(self.flex_cross_axis_alignment)
.with_main_axis_alignment(self.flex_main_axis_alignment)
.with_main_axis_size(self.flex_main_axis_size);
Stack::new()
.with_child(
Align::new(SavePosition::new(flex.finish(), "flex").finish())
.top_left()
.finish(),
)
.finish()
}
fn ui_name() -> &'static str {
"View"
}
}
impl TypedActionView for View {
type Action = ();
}
#[test]
fn test_flex_main_axis_alignment() {
App::test((), |mut app| async move {
let app = &mut app;
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::new(
MainAxisSize::Min,
MainAxisAlignment::Start,
CrossAxisAlignment::Start,
)
});
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).expect("root view should exist"));
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation.clone(), ctx);
let window_size = RectF::new(Vector2F::zero(), vec2f(300., 300.));
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
let view_1_size = vec2f(50., 20.);
let view_2_size = vec2f(50., 30.);
let view_3_size = vec2f(50., 50.);
// The view has a min axis size, so each element should be rendered right next to
// each other and and the flex should take up the total size of the elements.
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_main_axis_alignment = MainAxisAlignment::Start;
view.flex_main_axis_size = MainAxisSize::Max;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
// The view has a flex axis alignment of start, so ensure that each child element
// is rendered next to each other, but that the flex expands out to the max size of
// the window.
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_main_axis_alignment = MainAxisAlignment::SpaceBetween;
view.flex_main_axis_size = MainAxisSize::Max;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
// Ensure that the the elements are evenly spaced (with no extra space at the
// beginning or end) and that the flex expands out to the max size of the window.
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(125., 0.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(250., 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_main_axis_alignment = MainAxisAlignment::SpaceEvenly;
view.flex_main_axis_size = MainAxisSize::Max;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
// Ensure that the the elements are evenly spaced, including space before and after
// the child elements, and that the flex expands out to the max size of the window.
assert_eq!(view_1, RectF::new(vec2f(37.5, 0.), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(125., 0.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(212.5, 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_main_axis_alignment = MainAxisAlignment::End;
view.flex_main_axis_size = MainAxisSize::Max;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
// The view has a flex axis alignment of end, so ensure that each child element
// is rendered next to each other, but that the flex expands out to the max size of
// the window.
assert_eq!(view_3, RectF::new(vec2f(250., 0.), view_3_size));
assert_eq!(view_2, RectF::new(vec2f(200., 0.), view_2_size));
assert_eq!(view_1, RectF::new(vec2f(150., 0.), view_1_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(300., 50.)));
});
})
}
#[test]
fn test_flex_row_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test basic row spacing
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
Flex::row()
.with_spacing(10.)
.with_children([
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(50.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(60.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(40.)
.finish(),
])
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Children should have 10px spacing between them
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), vec2f(50., 30.)),
RectF::new(vec2f(60., 0.), vec2f(60., 30.)), // 50 + 10
RectF::new(vec2f(130., 0.), vec2f(40., 30.)), // 50 + 10 + 60 + 10
],
);
})
}
#[test]
fn test_flex_column_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test column spacing
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
Flex::column()
.with_spacing(15.)
.with_children([
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(60.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_height(40.)
.with_width(80.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_height(25.)
.with_width(70.)
.finish(),
])
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Children should have 15px vertical spacing between them
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), vec2f(60., 30.)),
RectF::new(vec2f(0., 45.), vec2f(80., 40.)), // 30 + 15
RectF::new(vec2f(0., 100.), vec2f(70., 25.)), // 30 + 15 + 40 + 15
],
);
})
}
#[test]
fn test_flex_spacing_with_center_alignment() {
App::test((), |mut app| async move {
let app = &mut app;
// Test spacing with center alignment
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
ConstrainedBox::new(
Flex::row()
.with_spacing(20.)
.with_children([
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(40.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(40.)
.finish(),
])
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.finish(),
)
.with_width(300.)
.with_height(100.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Total content width: 40 + 20 + 40 = 100
// Remaining space: 300 - 100 = 200
// Leading space: 200 / 2 = 100
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(100., 0.), vec2f(40., 30.)),
RectF::new(vec2f(160., 0.), vec2f(40., 30.)), // 100 + 40 + 20
],
);
})
}
#[test]
fn test_flex_spacing_empty() {
App::test((), |mut app| async move {
let app = &mut app;
// Test empty flex with spacing
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| Flex::row().with_spacing(15.).finish())
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Empty flex should render no children
assert_bounds_of_rects(app, window_id, []);
})
}
#[test]
fn test_flex_spacing_single_child() {
App::test((), |mut app| async move {
let app = &mut app;
// Test single child with spacing (should have no effect)
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
Flex::row()
.with_spacing(20.)
.with_child(
ConstrainedBox::new(Rect::new().finish())
.with_height(30.)
.with_width(50.)
.finish(),
)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Single child should be positioned at origin regardless of spacing
assert_bounds_of_rects(app, window_id, [RectF::new(vec2f(0., 0.), vec2f(50., 30.))]);
})
}
#[test]
fn test_flex_cross_axis_alignment() {
App::test((), |mut app| async move {
let app = &mut app;
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::new(
MainAxisSize::Min,
MainAxisAlignment::Start,
CrossAxisAlignment::Start,
)
});
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).expect("root view should exist"));
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation.clone(), ctx);
let window_size = RectF::new(Vector2F::zero(), vec2f(300., 300.));
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1_size = vec2f(50., 20.);
let view_2_size = vec2f(50., 30.);
let view_3_size = vec2f(50., 50.);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
assert_eq!(view_1, RectF::new(Vector2F::zero(), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_cross_axis_alignment = CrossAxisAlignment::Center;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
assert_eq!(view_1, RectF::new(vec2f(0., 15.), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(50., 10.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_cross_axis_alignment = CrossAxisAlignment::End;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
assert_eq!(view_1, RectF::new(vec2f(0., 30.), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(50., 20.), view_2_size));
assert_eq!(view_3, RectF::new(vec2f(100., 0.), view_3_size));
assert_eq!(flex, RectF::new(Vector2F::zero(), vec2f(150., 50.)));
view.update(ctx, |view, _ctx| {
view.flex_cross_axis_alignment = CrossAxisAlignment::Stretch;
});
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(window_size.size(), 1., None, ctx);
let view_1 = presenter
.position_cache()
.get_position("view_1")
.expect("position should exist");
let view_2 = presenter
.position_cache()
.get_position("view_2")
.expect("position should exist");
let view_3 = presenter
.position_cache()
.get_position("view_3")
.expect("position should exist");
let flex = presenter
.position_cache()
.get_position("flex")
.expect("position should exist");
assert_eq!(view_1, RectF::new(vec2f(0., 0.), view_1_size));
assert_eq!(view_2, RectF::new(vec2f(50., 0.), view_2_size));
// view 3 is a Flex::row(), so applying cross-axis stretch to its
// parent should cause the child flex height to fill the parent's
// maximum height (which, in this case, is the height of the window).
assert_eq!(
view_3,
RectF::new(
vec2f(100., 0.),
vec2f(view_3_size.x(), window_size.height())
)
);
assert_eq!(
flex,
RectF::new(Vector2F::zero(), vec2f(150., window_size.height()))
);
});
})
}
@@ -0,0 +1,618 @@
use crate::elements::AxisOrientation;
use crate::event::DispatchedEvent;
use crate::ClipBounds;
use super::cross_axis_size;
use super::AppContext;
use super::Axis;
use super::CrossAxisAlignment;
use super::Element;
use super::EventContext;
use super::LayoutContext;
use super::MainAxisSize;
use super::PaintContext;
use super::Point;
use super::SizeConstraint;
use super::Vector2FExt;
use crate::elements::flex::{main_axis_size, size_along_axis, LayoutState};
use crate::elements::MainAxisAlignment;
use ordered_float::OrderedFloat;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
/// An element that positions its children in horizontal or vertical runs, leaving space in between
/// each run.
///
/// This element can be thought of as a bare-bones version of a flex element with the `flex-wrap`
/// property set in CSS. Children are laid out greedily until they can no longer fit on the current
/// run, in which case a new run is created with the child as the first element. If a child exceeds
/// the incoming size constraints it is clamped to the constraint max and clipped during painting.
/// Children that can't fit in any run along the cross axis are not laid out or painted.
pub struct Wrap {
axis: Axis,
orientation: AxisOrientation,
children: Vec<WrapChild>,
size: Option<Vector2F>,
origin: Option<Point>,
spacing: f32,
runs: Vec<Run>,
run_spacing: f32,
main_axis_alignment: MainAxisAlignment,
main_axis_size: MainAxisSize,
cross_axis_alignment: CrossAxisAlignment,
}
impl Wrap {
pub fn new(axis: Axis) -> Self {
Self {
axis,
orientation: AxisOrientation::Normal,
children: vec![],
size: None,
origin: None,
spacing: 0.,
runs: vec![],
run_spacing: 0.,
main_axis_alignment: MainAxisAlignment::Start,
main_axis_size: MainAxisSize::Max,
cross_axis_alignment: CrossAxisAlignment::Start,
}
}
pub fn row() -> Self {
Self::new(Axis::Horizontal)
}
pub fn column() -> Self {
Self::new(Axis::Vertical)
}
pub fn with_reverse_orientation(mut self) -> Self {
self.orientation = AxisOrientation::Reverse;
self
}
pub fn with_spacing(mut self, spacing: f32) -> Self {
self.spacing = spacing;
self
}
/// Use the specified amount of `spacing` between each run when positioning children.
pub fn with_run_spacing(mut self, spacing: f32) -> Self {
self.run_spacing = spacing;
self
}
fn size_along_cross_axis(runs: &[Run], run_spacing: f32) -> f32 {
let run_height: f32 = runs.iter().map(|run| run.size_along_cross_axis).sum();
run_height + run_spacing * (runs.len().saturating_sub(1)) as f32
}
/// Specifies the strategy to render children in each run when there is remaining space.
pub fn with_main_axis_alignment(mut self, alignment: MainAxisAlignment) -> Self {
self.main_axis_alignment = alignment;
self
}
/// Specifies the strategy to size the overall element when there is remaining space after
/// runs.
pub fn with_main_axis_size(mut self, size: MainAxisSize) -> Self {
self.main_axis_size = size;
self
}
pub fn with_cross_axis_alignment(mut self, alignment: CrossAxisAlignment) -> Self {
self.cross_axis_alignment = alignment;
self
}
}
impl Extend<Box<dyn Element>> for Wrap {
fn extend<T: IntoIterator<Item = Box<dyn Element>>>(&mut self, iter: T) {
self.children.extend(iter.into_iter().map(WrapChild::new));
}
}
impl Element for Wrap {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.children.iter_mut().for_each(WrapChild::reset);
self.runs.clear();
let max_constraint_along_cross_axis = constraint.max_along(self.axis.invert());
let max_constraint_along_main_axis = constraint.max_along(self.axis);
let mut current_run = RunBuilder::default();
for child in &mut self.children {
let child_constraint = match child.data() {
Some(child_data) if child_data.fill_run() => {
// If the child expands/shrinks based on the remaining space, then lay it out
// with _that_ as the max constraint along its main axis, rather than an
// infinite max constraint.
let mut remaining_space_along_main_axis =
max_constraint_along_main_axis - current_run.size_along_main_axis;
let should_create_new_run = match child_data {
WrapParentData::FillRemainingSpaceInRun { min_space, .. } => {
// If there's insufficient space along the main axis, start a new run rather
// than trying to lay out the child with the remaining space. This prevents
// calling child.layout() with a maximum constraint that's less than whatever
// minimum it might've set.
remaining_space_along_main_axis < min_space
}
WrapParentData::FillEntireRun => true,
};
if should_create_new_run {
let mut new_run = RunBuilder::default();
std::mem::swap(&mut new_run, &mut current_run);
self.runs.push(new_run.build(
self.spacing,
max_constraint_along_main_axis,
self.main_axis_alignment,
self.axis,
));
remaining_space_along_main_axis = max_constraint_along_main_axis;
}
// Let the child expand along the cross axis as well.
let remaining_space_along_cross_axis = max_constraint_along_cross_axis
- Self::size_along_cross_axis(self.runs.as_slice(), self.run_spacing);
match self.axis {
Axis::Horizontal => SizeConstraint::new(
vec2f(0., constraint.min.y()),
vec2f(
remaining_space_along_main_axis,
remaining_space_along_cross_axis,
),
),
Axis::Vertical => SizeConstraint::new(
vec2f(constraint.min.x(), 0.),
vec2f(
remaining_space_along_cross_axis,
remaining_space_along_main_axis,
),
),
}
}
// Lay out the child so that it has an infinite max constraint along its main axis. The
// incoming max size constraint is respected along the cross axis.
_ => SizeConstraint::child_constraint_along_axis(self.axis, constraint),
};
let size = child.layout(child_constraint, ctx, app);
// If the child individually exceeds the incoming size constraints, clamp it
// to the constraint max so it doesn't overflow the container. We continue
// laying out subsequent children rather than stopping entirely.
let size = vec2f(
size.x().min(constraint.max.x()),
size.y().min(constraint.max.y()),
);
let child_size_along_main_axis = size.along(self.axis);
let child_size_along_cross_axis = size.along(self.axis.invert());
// The child doesn't fit in the current run--create a new run.
if child_size_along_main_axis + current_run.size_along_main_axis
> max_constraint_along_main_axis
{
let mut new_run = RunBuilder::default();
std::mem::swap(&mut new_run, &mut current_run);
self.runs.push(new_run.build(
self.spacing,
max_constraint_along_main_axis,
self.main_axis_alignment,
self.axis,
));
}
if child_size_along_cross_axis > current_run.size_along_cross_axis {
// If the new size would cause the element to exceed the max size along the
// cross axis--don't add the item to the run and immediately break.
let total_run_size_on_cross_axis = child_size_along_cross_axis
+ Self::size_along_cross_axis(self.runs.as_slice(), self.run_spacing);
if total_run_size_on_cross_axis > max_constraint_along_cross_axis {
break;
}
current_run.size_along_cross_axis = child_size_along_cross_axis;
}
current_run.num_children += 1;
current_run.size_along_main_axis += child_size_along_main_axis;
// Add the spacing between the child and the next child (were we to add one).
current_run.size_along_main_axis += self.spacing;
}
if current_run.num_children > 0 {
self.runs.push(current_run.build(
self.spacing,
max_constraint_along_main_axis,
self.main_axis_alignment,
self.axis,
))
}
let size_along_cross_axis = Self::size_along_cross_axis(&self.runs, self.run_spacing);
let size_along_main_axis = match self.main_axis_size {
MainAxisSize::Min => {
// Use the largest run along the main axis as the overall element width.
self.runs
.iter()
.map(|run| OrderedFloat(run.size_along_main_axis))
.max()
.unwrap_or_default()
.0
}
MainAxisSize::Max => constraint.max_along(self.axis),
};
let size = match self.axis {
Axis::Horizontal => vec2f(size_along_main_axis, size_along_cross_axis),
Axis::Vertical => vec2f(size_along_cross_axis, size_along_main_axis),
};
self.size = Some(size);
size
}
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &crate::AppContext) {
for child in &mut self.children {
child.after_layout(ctx, app)
}
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
let mut num_children_painted = 0;
let original_origin = origin;
let wrap_size = self.size.expect("size should exist at paint time");
let clip_bounds = RectF::new(origin, wrap_size);
// Clip children to the wrap's own bounds so oversized items don't overflow.
ctx.scene
.start_layer(ClipBounds::BoundedByActiveLayerAnd(clip_bounds));
let mut origin = origin;
// If the axis is reversed, offset the origin position by the length of the flex along its main axis,
if let AxisOrientation::Reverse = self.orientation {
let size_shift = size_along_axis(main_axis_size(wrap_size, self.axis), self.axis);
origin += size_shift;
};
for run in &self.runs {
let mut run_origin = match self.orientation {
AxisOrientation::Normal => origin + run.layout_state.leading_space,
AxisOrientation::Reverse => origin - run.layout_state.leading_space,
};
for child in self
.children
.iter_mut()
.skip(num_children_painted)
.take(run.num_children)
{
let child_size = child.size().expect("child size should exist at paint time");
let child_cross_size = cross_axis_size(child_size, self.axis);
let child_cross_shift = match self.cross_axis_alignment {
CrossAxisAlignment::Center => {
run.size_along_cross_axis / 2. - child_cross_size / 2.
}
CrossAxisAlignment::Start => 0.,
CrossAxisAlignment::End => run.size_along_cross_axis - child_cross_size,
CrossAxisAlignment::Stretch => 0.,
};
// Paint the child and offset the origin by the size of the child along the main
// axis.
match self.orientation {
AxisOrientation::Normal => {
child.paint(
run_origin + size_along_axis(child_cross_shift, self.axis.invert()),
ctx,
app,
);
if let Some(child_size) = child.size() {
run_origin +=
size_along_axis(main_axis_size(child_size, self.axis), self.axis);
}
run_origin += run.layout_state.between_space;
}
AxisOrientation::Reverse => {
if let Some(child_size) = child.size() {
run_origin -=
size_along_axis(main_axis_size(child_size, self.axis), self.axis);
}
child.paint(run_origin, ctx, app);
run_origin -= run.layout_state.between_space;
}
};
}
num_children_painted += run.num_children;
// We're finished painting the run. Update the origin to be at the start of the new run.
origin += size_along_axis(
run.size_along_cross_axis + self.run_spacing,
self.axis.invert(),
);
}
ctx.scene.stop_layer();
self.origin = Some(Point::from_vec2f(original_origin, ctx.scene.z_index()));
}
fn size(&self) -> Option<Vector2F> {
self.size
}
fn origin(&self) -> Option<Point> {
self.origin
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
let mut handled = false;
for child in &mut self.children {
let child_dispatch = child.dispatch_event(event, ctx, app);
handled |= child_dispatch;
}
handled
}
}
#[derive(Clone, Copy)]
enum WrapParentData {
FillRemainingSpaceInRun {
/// If `true`, the child element will be laid out with the run's remaining space, rather than
/// infinite width. This allows children to expand to fill runs.
fill_run: bool,
/// The minimum space along the main axis that this child needs. Generally, child elements
/// should reserve required space in their [`Element::layout`] implementations instead.
/// However, for flexible children, we sometimes need a minimum here.
min_space: f32,
},
FillEntireRun,
}
/// Convenience wrapper for a [`Wrap`] child that must consume the entire run.
///
/// When a child is wrapped in `WrapFillEntireRun`, the `Wrap` layout will place that child alone
/// on its own run and treat it as occupying all remaining main-axis space for that run. This is
/// useful for elements like wide cards or chips that should expand to the full width of the
/// current row instead of sharing the row with other wrapped children.
pub struct WrapFillEntireRun(WrapFill);
impl WrapFillEntireRun {
pub fn new(child: Box<dyn Element>) -> Self {
Self(WrapFill {
parent_data: WrapParentData::FillEntireRun,
child,
})
}
pub fn finish(self) -> Box<dyn Element> {
self.0.finish()
}
}
/// Marker for children of a [`Wrap`] element that preferentially expand to fill the current
/// row/column before starting a new row/column.
pub struct WrapFill {
parent_data: WrapParentData,
child: Box<dyn Element>,
}
impl WrapFill {
pub fn new(min_space: f32, child: Box<dyn Element>) -> Self {
Self {
parent_data: WrapParentData::FillRemainingSpaceInRun {
fill_run: true,
min_space,
},
child,
}
}
}
impl WrapParentData {
fn fill_run(&self) -> bool {
match self {
WrapParentData::FillRemainingSpaceInRun { fill_run, .. } => *fill_run,
WrapParentData::FillEntireRun => true,
}
}
}
impl Element for WrapFill {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &AppContext) {
self.child.after_layout(ctx, app);
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.child.paint(origin, ctx, app);
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn origin(&self) -> Option<Point> {
self.child.origin()
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
self.child.dispatch_event(event, ctx, app)
}
fn parent_data(&self) -> Option<&dyn std::any::Any> {
Some(&self.parent_data)
}
}
/// Helper struct to encapsulate a child of a `Wrap` element that may not be painted or laid out
/// depending on the number of elements that fit into the `Wrap` given incoming size constraints.
struct WrapChild {
element: Box<dyn Element>,
is_laid_out: bool,
is_painted: bool,
}
impl WrapChild {
fn new(element: Box<dyn Element>) -> Self {
Self {
element,
is_laid_out: false,
is_painted: false,
}
}
fn data(&self) -> Option<WrapParentData> {
self.element
.parent_data()
.and_then(|data| data.downcast_ref())
.copied()
}
fn reset(&mut self) {
self.is_laid_out = false;
self.is_painted = false;
}
}
impl Element for WrapChild {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
self.is_laid_out = true;
self.element.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &crate::AppContext) {
if self.is_laid_out {
self.element.after_layout(ctx, app);
}
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
if self.is_laid_out {
self.element.paint(origin, ctx, app);
self.is_painted = true;
}
}
fn size(&self) -> Option<Vector2F> {
self.element.size()
}
fn origin(&self) -> Option<Point> {
self.element.origin()
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
if self.is_painted {
self.element.dispatch_event(event, ctx, app)
} else {
false
}
}
}
/// A given run of a `Wrap` element.
#[derive(Debug)]
struct Run {
/// The size along the cross axis of the run. This is functionally the max size on the cross
/// axis of the elements within the run.
size_along_cross_axis: f32,
/// The size along the main axis of the run. This is the sum of each element within the run's
/// size, plus the leading space and space between each child.
size_along_main_axis: f32,
/// The number of children of the parent `Wrap` element that are rendered within this run.
num_children: usize,
/// Metadata used to layout the run. This is used to properly respect `MainAxisAlignment` within
/// each run.
layout_state: LayoutState,
}
/// Builder type to construct a `Run`.
#[derive(Debug, Default)]
struct RunBuilder {
/// The size along the cross axis of the run. This is functionally the max size on the cross
/// axis of the elements within the run.
size_along_cross_axis: f32,
/// The main axis size along the run.
size_along_main_axis: f32,
/// The number of children of the parent `Wrap` element that are rendered within this run.
num_children: usize,
}
impl RunBuilder {
fn build(
self,
spacing: f32,
max_constraint_along_main_axis: f32,
main_axis_alignment: MainAxisAlignment,
axis: Axis,
) -> Run {
// We added spacing after every child, but we only want spacing _between_ children,
// so subtract the (extra) spacing after the last child.
let size_along_main_axis = self.size_along_main_axis - spacing;
let layout_state = LayoutState::compute(
self.num_children,
spacing,
max_constraint_along_main_axis - size_along_main_axis,
main_axis_alignment,
axis,
);
let size_along_main_axis = size_along_main_axis
+ layout_state.leading_space.along(axis)
+ layout_state.between_space.along(axis) * (self.num_children as f32 - 1.);
Run {
size_along_cross_axis: self.size_along_cross_axis,
size_along_main_axis,
num_children: self.num_children,
layout_state,
}
}
}
#[cfg(test)]
#[path = "wrap_test.rs"]
mod tests;
@@ -0,0 +1,811 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f;
use crate::elements::ConstrainedBox;
use crate::elements::Container;
use crate::elements::Empty;
use crate::elements::ParentElement;
use crate::elements::Rect;
use crate::platform::WindowStyle;
use crate::Entity;
use crate::View;
use crate::WindowId;
use crate::{App, TypedActionView};
use super::*;
struct TestRootView {
parent_size: Vector2F,
children_sizes: Vec<Vector2F>,
axis: Axis,
run_spacing: f32,
}
impl TestRootView {
pub fn new(
parent_size: Vector2F,
children_sizes: Vec<Vector2F>,
axis: Axis,
run_spacing: f32,
) -> Self {
Self {
parent_size,
children_sizes,
axis,
run_spacing,
}
}
}
impl Entity for TestRootView {
type Event = ();
}
impl View for TestRootView {
fn ui_name() -> &'static str {
"Wrap::tests::TestRootView"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
let mut wrap = Wrap::new(self.axis).with_run_spacing(self.run_spacing);
wrap.extend(self.children_sizes.iter().map(|size| {
ConstrainedBox::new(Rect::new().finish())
.with_height(size.y())
.with_width(size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(self.parent_size.x())
.with_height(self.parent_size.y())
.finish()
}
}
impl TypedActionView for TestRootView {
type Action = ();
}
type RenderFn = dyn Fn(&AppContext) -> Box<dyn Element> + 'static;
struct TestDynamicView {
render: Box<RenderFn>,
}
impl TestDynamicView {
fn new(render: impl Fn(&AppContext) -> Box<dyn Element> + 'static) -> Self {
Self {
render: Box::new(render),
}
}
}
impl Entity for TestDynamicView {
type Event = ();
}
impl View for TestDynamicView {
fn ui_name() -> &'static str {
"Wrap::tests::TestDynamicView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
(self.render)(app)
}
}
impl TypedActionView for TestDynamicView {
type Action = ();
}
/// Asserts that the bounds of all the painted rects match that of `rects`.
fn assert_bounds_of_rects(
app: &mut App,
window_id: WindowId,
rects: impl IntoIterator<Item = RectF>,
) {
let presenter_ref = app
.presenter(window_id)
.expect("Test window should have a presenter since first frame is rendered.");
let presenter = presenter_ref.borrow();
let scene = presenter
.scene()
.expect("Presenter should have rendered a scene after the test_view was updated.");
let actual_rects = scene
.layers()
.flat_map(|layer| layer.rects.iter())
.map(|rect| rect.bounds);
itertools::assert_equal(actual_rects, rects);
}
#[test]
fn test_row_wraps_across_runs() {
App::test((), |mut app| async move {
let child_size = vec2f(100., 100.);
let app = &mut app;
// Attempt to render 4 100x100 rects into a 250x250 box. This should result in the first
// two rects rendered in a row, followed by a 10px horizontal spacing, followed by the
// next two rects rendered in a row.
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestRootView::new(
vec2f(250., 250.),
vec![child_size; 4],
Axis::Horizontal,
10.,
)
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(100., 0.), child_size),
RectF::new(vec2f(0., 110.), child_size),
RectF::new(vec2f(100., 110.), child_size),
],
);
})
}
#[test]
fn test_column_wraps_across_runs() {
App::test((), |mut app| async move {
let child_size = vec2f(100., 100.);
let app = &mut app;
// Attempt to render 4 100x100 rects into a 250x250 box. This should result in the first
// two rects rendered in a column, followed by a 10px vertical spacing, followed by the
// next two rects rendered in a column.
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestRootView::new(vec2f(250., 250.), vec![child_size; 4], Axis::Vertical, 10.)
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(0., 100.), child_size),
RectF::new(vec2f(110., 0.), child_size),
RectF::new(vec2f(110., 100.), child_size),
],
);
})
}
/// Tests that elements within a `Wrap` are not rendered if they can't be fit within the max
/// size constraint along the cross axis.
#[test]
fn test_wrap_with_too_many_elements() {
App::test((), |mut app| async move {
let child_size = vec2f(100., 100.);
let app = &mut app;
// Attempt to render 10 100x100 rects into a 250x250 box. This should result in the
// first two rects rendered in a row, followed by a 10px horizontal spacing, followed by
// the next two rects rendered in a row. Only four of the ten rects can fit in the
// parent box.
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestRootView::new(vec2f(250., 250.), vec![child_size; 10], Axis::Vertical, 10.)
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Only 4 elements are painted--even though there were 10 initial elements passed to the
// wrap.
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(0., 100.), child_size),
RectF::new(vec2f(110., 0.), child_size),
RectF::new(vec2f(110., 100.), child_size),
],
);
})
}
/// Tests that when the first item exceeds the max size constraint, it is clamped for layout
/// purposes and clipped during paint. Subsequent items that fit are still laid out.
#[test]
fn test_wrap_first_element_exceeds_size_constraint() {
App::test((), |mut app| async move {
let child_size = vec2f(100., 100.);
let mut children = vec![vec2f(500., 500.)];
children.extend(vec![child_size; 2]);
let app = &mut app;
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestRootView::new(vec2f(250., 250.), children, Axis::Vertical, 10.)
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// The first oversized element is clamped to 250x250 for layout, filling the
// entire space. Subsequent items can't fit on the cross axis.
// The child is laid out at 250x500 (cross axis is clamped by ConstrainedBox
// to 250, main axis stays 500), then clamped to 250x250 for run placement.
assert_bounds_of_rects(
app,
window_id,
[RectF::new(vec2f(0., 0.), vec2f(250., 500.))],
);
})
}
/// Tests that when the second element exceeds the size constraint, it is clamped and
/// subsequent items continue to be laid out if they fit.
#[test]
fn test_second_element_exceeds_size_constraint() {
App::test((), |mut app| async move {
let child_size = vec2f(100., 100.);
let children = vec![child_size, vec2f(300., 300.), child_size, child_size];
let app = &mut app;
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestRootView::new(vec2f(250., 250.), children, Axis::Vertical, 10.)
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// The first element (100x100) is in column 1. The oversized second element
// (250x300 after ConstrainedBox clamp, then 250x250 for layout) doesn't fit
// in the current run and creates a new one. Its clamped cross-axis size (250)
// plus existing runs (100 + 10) exceeds 250, so only the first child fits.
assert_bounds_of_rects(app, window_id, [RectF::new(vec2f(0., 0.), child_size)]);
})
}
#[test]
fn test_min_size_along_row() {
App::test((), |mut app| async move {
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
// Use a Container so there's a rect for the Wrap element's bounds.
ConstrainedBox::new(
Container::new(
Wrap::row()
.with_main_axis_size(MainAxisSize::Min)
.with_children([
ConstrainedBox::new(Empty::new().finish())
.with_width(100.)
.with_height(100.)
.finish(),
ConstrainedBox::new(Empty::new().finish())
.with_width(100.)
.with_height(100.)
.finish(),
])
.finish(),
)
.finish(),
)
// Ensure the Wrap has _more_ than enough space for the two children.
.with_max_width(250.)
.with_max_height(250.)
.finish()
})
});
test_view.update(&mut app, |_, ctx| ctx.notify());
// The Wrap element is painted using the minimum space needed for the two children, even
// though it could expand further.
assert_bounds_of_rects(
&mut app,
window_id,
[RectF::new(vec2f(0., 0.), vec2f(200., 100.))],
);
});
}
#[test]
fn test_fill_element_within_size_constraint() {
App::test((), |mut app| async move {
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
ConstrainedBox::new(
Wrap::row()
.with_children([
ConstrainedBox::new(Rect::new().finish())
.with_width(100.)
.with_height(100.)
.finish(),
WrapFill::new(200., Rect::new().finish()).finish(),
])
.finish(),
)
.with_width(400.)
.with_height(100.)
.finish()
})
});
test_view.update(&mut app, |_, ctx| ctx.notify());
// Both children are painted, and the second expands to fill available space.
assert_bounds_of_rects(
&mut app,
window_id,
[
RectF::new(vec2f(0., 0.), vec2f(100., 100.)),
RectF::new(vec2f(100., 0.), vec2f(300., 100.)),
],
);
});
}
#[test]
fn test_wrap_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test with spacing between children in the same run
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(100., 50.);
// Create a Wrap that can fit 3 children horizontally with spacing
let mut wrap = Wrap::row().with_spacing(10.);
wrap.extend((0..3).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(350.) // 100 + 10 + 100 + 10 + 100 = 320, so they fit in one run
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(100., 50.);
// All 3 children should be in the same run with 10px spacing between them
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(110., 0.), child_size), // 100 + 10
RectF::new(vec2f(220., 0.), child_size), // 100 + 10 + 100 + 10
],
);
})
}
#[test]
fn test_wrap_spacing_with_wrapping() {
App::test((), |mut app| async move {
let app = &mut app;
// Test with spacing that forces wrapping
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(100., 50.);
let mut wrap = Wrap::row().with_spacing(20.);
wrap.extend((0..4).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(250.) // Can only fit 2 children per run: 100 + 20 + 100 = 220
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(100., 50.);
// First 2 children in first run, next 2 in second run
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(120., 0.), child_size), // 100 + 20
RectF::new(vec2f(0., 50.), child_size), // New run
RectF::new(vec2f(120., 50.), child_size), // 100 + 20 in second run
],
);
})
}
#[test]
fn test_wrap_run_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test run_spacing (vertical spacing between runs)
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(80., 40.);
let mut wrap = Wrap::row().with_run_spacing(30.);
wrap.extend((0..4).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(170.) // Can fit 2 children per run: 80 + 80 = 160
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(80., 40.);
// Two runs with 30px spacing between them
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(80., 0.), child_size),
RectF::new(vec2f(0., 70.), child_size), // 40 + 30
RectF::new(vec2f(80., 70.), child_size), // 40 + 30
],
);
})
}
#[test]
fn test_wrap_both_spacings() {
App::test((), |mut app| async move {
let app = &mut app;
// Test both spacing and run_spacing together
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(60., 30.);
let mut wrap = Wrap::row()
.with_spacing(15.) // 15px between children in same run
.with_run_spacing(25.); // 25px between runs
wrap.extend((0..6).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(200.) // Can fit 2 children per run: 60 + 15 + 60 = 135
.with_height(300.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(60., 30.);
// 3 runs with 2 children each
assert_bounds_of_rects(
app,
window_id,
[
// First run
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(75., 0.), child_size), // 60 + 15
// Second run (30 + 25 = 55)
RectF::new(vec2f(0., 55.), child_size),
RectF::new(vec2f(75., 55.), child_size),
// Third run (30 + 25 + 30 + 25 = 110)
RectF::new(vec2f(0., 110.), child_size),
RectF::new(vec2f(75., 110.), child_size),
],
);
})
}
#[test]
fn test_wrap_column_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test spacing in column wrap
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(50., 80.);
let mut wrap = Wrap::column().with_spacing(12.);
wrap.extend((0..4).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(200.)
.with_height(185.) // Can fit 2 children per column: 80 + 12 + 80 = 172
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(50., 80.);
// Two columns with 2 children each
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(0., 92.), child_size), // 80 + 12
RectF::new(vec2f(50., 0.), child_size), // New column
RectF::new(vec2f(50., 92.), child_size), // 80 + 12 in second column
],
);
})
}
#[test]
fn test_wrap_column_run_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test run_spacing in column wrap (horizontal spacing between columns)
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(40., 70.);
let mut wrap = Wrap::column().with_run_spacing(20.);
wrap.extend((0..4).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(200.)
.with_height(145.) // Can fit 2 children per column: 70 + 70 = 140
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(40., 70.);
// Two columns with 20px horizontal spacing between them
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(0., 70.), child_size),
RectF::new(vec2f(60., 0.), child_size), // 40 + 20
RectF::new(vec2f(60., 70.), child_size), // 40 + 20
],
);
})
}
#[test]
fn test_wrap_spacing_edge_cases() {
App::test((), |mut app| async move {
let app = &mut app;
// Test single child with spacing (should have no effect)
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(80., 40.);
let mut wrap = Wrap::row().with_spacing(20.);
wrap.extend((0..1).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(300.)
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(80., 40.);
// Single child should be positioned at origin regardless of spacing
assert_bounds_of_rects(app, window_id, [RectF::new(vec2f(0., 0.), child_size)]);
})
}
#[test]
fn test_wrap_zero_spacing() {
App::test((), |mut app| async move {
let app = &mut app;
// Test zero spacing and zero run_spacing
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let child_size = vec2f(60., 30.);
let mut wrap = Wrap::row()
.with_spacing(0.) // No spacing between children
.with_run_spacing(0.); // No spacing between runs
wrap.extend((0..4).map(|_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(child_size.y())
.with_width(child_size.x())
.finish()
}));
ConstrainedBox::new(wrap.finish())
.with_width(130.) // Can fit 2 children per run: 60 + 60 = 120
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
let child_size = vec2f(60., 30.);
// Children should be tightly packed with no gaps
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), child_size),
RectF::new(vec2f(60., 0.), child_size), // No spacing
RectF::new(vec2f(0., 30.), child_size), // New run, no run_spacing
RectF::new(vec2f(60., 30.), child_size), // No spacing in second run
],
);
})
}
#[test]
fn test_wrap_empty() {
App::test((), |mut app| async move {
let app = &mut app;
// Test empty wrap with spacing settings
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let wrap = Wrap::row().with_spacing(15.).with_run_spacing(25.);
// No children added
ConstrainedBox::new(wrap.finish())
.with_width(300.)
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| {
ctx.notify();
});
// Empty wrap should render no children
assert_bounds_of_rects(app, window_id, []);
})
}
/// Tests the core fix: an oversized item in a row wrap is clamped so that
/// subsequent items continue to be laid out on later runs.
#[test]
fn test_oversized_item_does_not_block_subsequent_items() {
App::test((), |mut app| async move {
let app = &mut app;
// Container is 300x200 (row wrap). Children:
// 1) 100x50 (fits)
// 2) 500x50 (exceeds 300 on main axis, will be clamped to 300x50)
// 3) 100x50 (fits on a new run after the oversized item)
// 4) 100x50 (fits on the same run as child 3)
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
let mut wrap = Wrap::row();
wrap.extend([
ConstrainedBox::new(Rect::new().finish())
.with_width(100.)
.with_height(50.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_width(500.)
.with_height(50.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_width(100.)
.with_height(50.)
.finish(),
ConstrainedBox::new(Rect::new().finish())
.with_width(100.)
.with_height(50.)
.finish(),
]);
ConstrainedBox::new(wrap.finish())
.with_width(300.)
.with_height(200.)
.finish()
})
});
test_view.update(app, |_, ctx| ctx.notify());
// Row 1: child 1 (100x50)
// Row 2: child 2, laid out at 300x50 (ConstrainedBox cross-clamp) then clamped
// to 300x50 for layout — it takes the full main axis.
// Row 3: child 3 + child 4 side by side.
// Previously, the break at line 190 would have stopped all layout after child 2.
assert_bounds_of_rects(
app,
window_id,
[
RectF::new(vec2f(0., 0.), vec2f(100., 50.)),
// The oversized child paints at its full 500px width; the clip
// layer on the Wrap visually clips it to 300px.
RectF::new(vec2f(0., 50.), vec2f(500., 50.)),
RectF::new(vec2f(0., 100.), vec2f(100., 50.)),
RectF::new(vec2f(100., 100.), vec2f(100., 50.)),
],
);
})
}
#[test]
fn test_fill_element_exceeds_size_constraint() {
App::test((), |mut app| async move {
let (window_id, test_view) = app.add_window(WindowStyle::NotStealFocus, |_| {
TestDynamicView::new(|_| {
ConstrainedBox::new(
Wrap::row()
.with_children([
ConstrainedBox::new(Rect::new().finish())
.with_width(100.)
.with_height(100.)
.finish(),
WrapFill::new(200., Rect::new().finish()).finish(),
])
.finish(),
)
.with_width(250.)
.with_height(250.)
.finish()
})
});
test_view.update(&mut app, |_, ctx| ctx.notify());
// Both children are painted, and the second wraps to a new row while filling the remaining
// height.
assert_bounds_of_rects(
&mut app,
window_id,
[
RectF::new(vec2f(0., 0.), vec2f(100., 100.)),
RectF::new(vec2f(0., 100.), vec2f(250., 150.)),
],
);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,222 @@
use super::{
FormattedTextElement, FrameMouseHandlers, HeadingFontSizeMultipliers, HighlightedHyperlink,
HyperlinkSupport, LaidOutTextFrame, SecretRange,
};
use crate::text::BlockHeaderSize;
use crate::{
elements::{Point, SelectableElement, ZIndex},
fonts::FamilyId,
text_layout::TextFrame,
};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::{rect::RectF, vector::vec2f};
use std::borrow::Cow;
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use string_offset::ByteOffset;
use super::apply_secret_replacements;
#[test]
fn test_default_heading_font_size_multipliers() {
let multipliers = HeadingFontSizeMultipliers::default();
// Test that default values match the BlockHeaderSize ratios
assert_eq!(
multipliers.h1,
BlockHeaderSize::Header1.font_size_multiplication_ratio()
);
assert_eq!(
multipliers.h2,
BlockHeaderSize::Header2.font_size_multiplication_ratio()
);
assert_eq!(
multipliers.h3,
BlockHeaderSize::Header3.font_size_multiplication_ratio()
);
assert_eq!(
multipliers.h4,
BlockHeaderSize::Header4.font_size_multiplication_ratio()
);
assert_eq!(
multipliers.h5,
BlockHeaderSize::Header5.font_size_multiplication_ratio()
);
assert_eq!(
multipliers.h6,
BlockHeaderSize::Header6.font_size_multiplication_ratio()
);
}
#[test]
fn test_get_multiplier_method() {
let multipliers = HeadingFontSizeMultipliers::default();
// Test valid heading levels
assert_eq!(multipliers.get_multiplier(1), multipliers.h1);
assert_eq!(multipliers.get_multiplier(2), multipliers.h2);
assert_eq!(multipliers.get_multiplier(3), multipliers.h3);
assert_eq!(multipliers.get_multiplier(4), multipliers.h4);
assert_eq!(multipliers.get_multiplier(5), multipliers.h5);
assert_eq!(multipliers.get_multiplier(6), multipliers.h6);
// Test invalid heading levels return 1.0
assert_eq!(multipliers.get_multiplier(0), 1.0);
assert_eq!(multipliers.get_multiplier(7), 1.0);
assert_eq!(multipliers.get_multiplier(999), 1.0);
}
#[test]
fn test_custom_heading_font_size_multipliers() {
let custom_multipliers = HeadingFontSizeMultipliers {
h1: 2.0,
h2: 1.8,
h3: 1.5,
..Default::default()
};
// Test custom values
assert_eq!(custom_multipliers.h1, 2.0);
assert_eq!(custom_multipliers.h2, 1.8);
assert_eq!(custom_multipliers.h3, 1.5);
// Test that other values still use defaults
assert_eq!(
custom_multipliers.h4,
BlockHeaderSize::Header4.font_size_multiplication_ratio()
);
assert_eq!(
custom_multipliers.h5,
BlockHeaderSize::Header5.font_size_multiplication_ratio()
);
assert_eq!(
custom_multipliers.h6,
BlockHeaderSize::Header6.font_size_multiplication_ratio()
);
}
fn sr(char_start: usize, char_end: usize, byte_start: usize, byte_end: usize) -> SecretRange {
SecretRange {
char_range: char_start..char_end,
byte_range: byte_start..byte_end,
}
}
#[test]
fn applies_replacements_with_multibyte_and_prefix() {
let original = "令狐冲abcXYZ"; // Multibyte + ASCII
let mut text = format!("{}{}", "• ", original);
let glyph_offset = 3; // prefix length in chars
// Secret over chars [2..5): "冲ab"
let start_byte = original
.chars()
.take(2)
.map(|c| c.len_utf8())
.sum::<usize>();
let secret_bytes_len = original
.chars()
.skip(2)
.take(3)
.map(|c| c.len_utf8())
.sum::<usize>();
let secret = sr(2, 5, start_byte, start_byte + secret_bytes_len);
let replacements = vec![(secret, Cow::Owned("***".to_string()))];
apply_secret_replacements(&mut text, glyph_offset, &replacements);
assert_eq!(text, format!("{}{}", "• ", "令狐***cXYZ"));
}
#[test]
fn applies_multiple_replacements_in_descending_order() {
let original = "abcdefg";
let mut text = format!("{}{}", "• ", original);
let glyph_offset = 3;
// [1..3) => "bc", [4..6) => "ef"
let s1 = sr(1, 3, 1, 3);
let s2 = sr(4, 6, 4, 6);
let replacements = vec![(s1, Cow::Borrowed("**")), (s2, Cow::Borrowed("##"))];
apply_secret_replacements(&mut text, glyph_offset, &replacements);
assert_eq!(text, format!("{}{}", "• ", "a**d##g"));
}
#[test]
fn order_matters_when_replacement_changes_length() {
// This test demonstrates why we apply replacements in descending order.
// Here, the first replacement shortens the text; if applied before the second,
// the second range would be misaligned relative to the original char indices.
let original = "abcdefghi";
let mut text = original.to_string();
let glyph_offset = 0;
// Two secrets in original char coordinates: [1..5) => "bcde" then [5..8) => "fgh"
// Replace first with a shorter string, second with equal length.
let s1 = sr(1, 5, 1, 5);
let s2 = sr(5, 8, 5, 8);
let replacements = vec![
(s1, Cow::Borrowed("*")), // length 1 instead of 4
(s2, Cow::Borrowed("###")), // same length as original 3
];
apply_secret_replacements(&mut text, glyph_offset, &replacements);
// With descending-order application, expected result is:
// apply s2 first: abcde###i
// then s1: a*###i
assert_eq!(text, "a*###i");
}
fn select_first_character(text: &str, _click_offset: ByteOffset) -> Option<Range<ByteOffset>> {
(!text.is_empty()).then_some(ByteOffset::zero()..ByteOffset::from(1))
}
fn test_formatted_text_element(text: &str, origin_x: f32, origin_y: f32) -> FormattedTextElement {
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(text),
])]);
let text_frame = Arc::new(TextFrame::mock(text));
let frame_bounds = RectF::new(
vec2f(origin_x, origin_y),
vec2f(text_frame.max_width(), text_frame.height()),
);
let mouse_handlers = Rc::new(RefCell::new(FrameMouseHandlers::default()));
let mut element = FormattedTextElement::new(
formatted_text,
13.0,
FamilyId(0),
FamilyId(0),
ColorU::black(),
HighlightedHyperlink::default(),
);
element.origin = Some(Point::new(origin_x, origin_y, ZIndex::new(0)));
element.size = Some(frame_bounds.size());
element.laid_out_text = vec![LaidOutTextFrame::Text {
text_frame,
frame_bounds,
bottom_padding: 0.0,
raw_text: text.to_string(),
mouse_handlers: mouse_handlers.clone(),
}];
element.text_frame_mouse_handlers = vec![mouse_handlers];
element.hyperlink_support = HyperlinkSupport {
highlighted_hyperlink: Arc::new(Mutex::new(None)),
hyperlink_font_color: ColorU::black(),
};
element
}
#[test]
fn smart_select_returns_none_when_point_is_outside_horizontal_bounds() {
let element = test_formatted_text_element("hello", 10.0, 20.0);
let point = vec2f(10.0 + 100.0, 25.0);
assert!(element
.smart_select(point, select_first_character)
.is_none());
}
@@ -0,0 +1,771 @@
use super::{Point, SelectableElement, Selection, SelectionFragment, ZIndex};
use crate::platform::Cursor;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
use crate::TaskId;
use crate::{
event::DispatchedEvent, AfterLayoutContext, AppContext, Element, Event, EventContext,
PaintContext,
};
use instant::Instant;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use std::mem;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
/// First arg is is_hovered. True when hovering in, false when hovering out.
type HoverHandler = Box<dyn FnMut(bool, &mut EventContext, &AppContext, Vector2F)>;
type ClickHandler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F)>;
pub struct Hoverable {
child: Box<dyn Element>,
state: MouseStateHandle,
origin: Option<Point>,
hover_handler: Option<HoverHandler>,
// A click is comprised of a mouse down and a mouse up,
// both within the hoverable.
click_handler: Option<ClickHandler>,
mouse_down_handler: Option<ClickHandler>,
double_click_handler: Option<ClickHandler>,
middle_click_handler: Option<ClickHandler>,
right_click_handler: Option<ClickHandler>,
forward_click_handler: Option<ClickHandler>,
back_click_handler: Option<ClickHandler>,
disabled: bool,
hover_in_delay: Option<Duration>,
hover_out_delay: Option<Duration>,
skip_synthetic_hover_out: bool,
hover_cursor: Option<Cursor>,
reset_cursor_after_click: bool,
// This is a short-term solution for properly handling events on stacks. A stack will always
// put its children on higher z-indexes than its origin, so a hit test using the standard
// `z_index` method would always result in the event being covered (by the children of the
// stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element.
// Then we use that upper bound to do the hit testing, which means a parent will always get
// events from its children, regardless of whether they are stacks or not.
child_max_z_index: Option<ZIndex>,
//
suppress_drag: bool,
defer_events_to_children: bool,
}
#[derive(Clone, Debug, Default)]
pub struct MouseState {
click_count: Option<u32>,
/// Whether the element should be considered hovered.
///
/// When there are hover delays, this does not necessarily
/// mean that the mouse is actively over the element;
/// see [`Self::is_mouse_over_element`] and [`Self::hovered`] for more details.
pub(crate) is_hovered: bool,
/// Whether the mouse is currently over the element.
///
/// This property is _not_ delayed by hover delays.
is_mouse_over_element: bool,
/// Keep track of whether the last event changing the hover
/// state is a synthetic mouse move. If there are two consecutive
/// events that both want to alter the hover state, we stop the
/// invocation to prevent the potential infinite loop. Note that
/// any non-synthetic event should reset this state to false.
last_event_is_synthetic_hover: bool,
/// A timer that starts when the mouse begins hovering the element.
///
/// Only [`Some`] if [`Hoverable::hover_in_delay`] is set.
hover_in_timer: Option<HoverTimer>,
/// A timer that starts when the mouse is no longer hovering the element.
///
/// Only [`Some`] if [`Hoverable::hover_out_delay`] is set.
hover_out_timer: Option<HoverTimer>,
}
impl MouseState {
/// True iff the element is actively being clicked.
pub fn is_clicked(&self) -> bool {
self.click_count.is_some()
}
/// [`Some`] iff the element is actively being clicked.
/// The number represents how many clicks were registered
/// in the corresponding mouse down event.
pub fn click_count(&self) -> Option<u32> {
self.click_count
}
/// True iff the element is considered hovered.
///
/// This does not necessarily imply that the mouse
/// is actively hovering the element because this
/// takes into account any delays. For example,
/// if there is a hover-in delay, this will be
/// true _after_ the delay (if the mouse is still covering the element).
/// See [`Self::is_mouse_over_element`] for that.
pub fn is_hovered(&self) -> bool {
self.is_hovered
}
/// True iff the mouse is currently over the element.
/// This is not affected by any hover delays.
pub fn is_mouse_over_element(&self) -> bool {
self.is_mouse_over_element
}
pub fn reset_hover_state(&mut self) {
self.is_hovered = false;
}
/// Fully clear interaction state. Useful when a click triggers navigation or focus changes,
/// and the original element will no longer receive follow-up mouse events (e.g. mouseup).
/// This prevents immediate re-hover from synthetic mouse events during layout.
pub fn reset_interaction_state(&mut self) {
// Clear pressed state so clicked styles don't persist
self.click_count = None;
// Clear hover states so hover styles/tooltips don't persist
self.is_hovered = false;
self.is_mouse_over_element = false;
// Treat the next synthetic hover as a no-op (avoids instant re-hover during layout)
self.last_event_is_synthetic_hover = true;
// Cancel any pending hover timers
self.hover_in_timer = None;
self.hover_out_timer = None;
}
fn set_hover_timer(&mut self, timer_type: HoverTimerType, hover_timer: HoverTimer) {
match timer_type {
HoverTimerType::HoverIn => self.hover_in_timer = Some(hover_timer),
HoverTimerType::HoverOut => self.hover_out_timer = Some(hover_timer),
}
}
fn hover_timer(&self, timer_type: HoverTimerType) -> Option<&HoverTimer> {
match timer_type {
HoverTimerType::HoverIn => self.hover_in_timer.as_ref(),
HoverTimerType::HoverOut => self.hover_out_timer.as_ref(),
}
}
fn take_hover_timer(&mut self, timer_type: HoverTimerType) -> Option<HoverTimer> {
match timer_type {
HoverTimerType::HoverIn => self.hover_in_timer.take(),
HoverTimerType::HoverOut => self.hover_out_timer.take(),
}
}
}
pub type MouseStateHandle = Arc<Mutex<MouseState>>;
#[derive(Clone, Debug)]
struct HoverTimer {
hover_at: Instant,
timer_id: TaskId,
}
#[derive(Clone, Copy, Debug)]
enum HoverTimerType {
HoverIn,
HoverOut,
}
impl HoverTimerType {
fn opposite(&self) -> HoverTimerType {
match self {
Self::HoverIn => Self::HoverOut,
Self::HoverOut => Self::HoverIn,
}
}
}
impl Hoverable {
pub fn new<F>(state: MouseStateHandle, build_child: F) -> Self
where
F: FnOnce(&MouseState) -> Box<dyn Element>,
{
let child = build_child(&state.lock().unwrap());
Self {
child,
state,
origin: None,
hover_handler: None,
click_handler: None,
mouse_down_handler: None,
double_click_handler: None,
middle_click_handler: None,
right_click_handler: None,
forward_click_handler: None,
back_click_handler: None,
hover_in_delay: None,
hover_out_delay: None,
skip_synthetic_hover_out: false,
hover_cursor: None,
reset_cursor_after_click: false,
disabled: false,
child_max_z_index: None,
suppress_drag: true,
defer_events_to_children: false,
}
}
/// Adds additional behavior on hover to any existing hover handler, instead
/// of replacing the existing handler.
pub fn additional_on_hover<F>(mut self, mut callback: F) -> Self
where
F: 'static + FnMut(bool, &mut EventContext, &AppContext, Vector2F),
{
let Some(mut hover_handler) = self.hover_handler else {
return self.on_hover(callback);
};
hover_handler = Box::new(move |is_hovered, ctx, app, pos| {
hover_handler(is_hovered, ctx, app, pos);
callback(is_hovered, ctx, app, pos);
});
self.hover_handler = Some(hover_handler);
self
}
/// Fires whenever [`MouseState::hovered`] changes.
pub fn on_hover<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(bool, &mut EventContext, &AppContext, Vector2F),
{
self.hover_handler = Some(Box::new(callback));
self
}
/// Fires when the mouse is released within the hoverable after it was pressed within the hoverable.
pub fn on_click<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.click_handler = Some(Box::new(callback));
self
}
/// Fires on `LeftMouseDown` (instead of on mouse up).
/// Useful when an action should happen immediately on press (e.g. tab activation).
pub fn on_mouse_down<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.mouse_down_handler = Some(Box::new(callback));
self
}
pub fn on_double_click<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.double_click_handler = Some(Box::new(callback));
self
}
pub fn on_middle_click<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.middle_click_handler = Some(Box::new(callback));
self
}
pub fn on_right_click<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.right_click_handler = Some(Box::new(callback));
self
}
pub fn on_back_click<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.back_click_handler = Some(Box::new(callback));
self
}
pub fn on_forward_click<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
{
self.forward_click_handler = Some(Box::new(callback));
self
}
/// Sets a delay between the time that the mouse hovers
/// over the element and the time that the mouse state is
/// considered hovered via [`MouseState::hovered`], including
/// when the [`Hoverable::on_hover`] fires.
pub fn with_hover_in_delay(mut self, delay: Duration) -> Self {
self.hover_in_delay = Some(delay);
self
}
/// Sets a delay between the time that the mouse stops hovering
/// over the element and the time that the mouse state is
/// considered unhovered via [`MouseState::hovered`], including
/// when the [`Hoverable::on_hover`] fires.
pub fn with_hover_out_delay(mut self, delay: Duration) -> Self {
self.hover_out_delay = Some(delay);
self
}
/// Skip firing [`Hoverable::on_hover`] when an item is hovered on synthetic mouse events.
/// Synthetic events are generated by the UI framework when layout changes,
/// even though the mouse hasn't actually moved.
pub fn with_skip_synthetic_hover_out(mut self) -> Self {
self.skip_synthetic_hover_out = true;
self
}
/// Change the mouse cursor when hovered
pub fn with_cursor(mut self, cursor: Cursor) -> Self {
self.hover_cursor = Some(cursor);
self
}
pub fn with_reset_cursor_after_click(mut self) -> Self {
self.reset_cursor_after_click = true;
self
}
pub fn with_propagate_drag(mut self) -> Self {
self.suppress_drag = false;
self
}
/// When enabled, skips this Hoverable's click handler if a child element
/// already handled the click event.
pub fn with_defer_events_to_children(mut self) -> Self {
self.defer_events_to_children = true;
self
}
pub fn disable(mut self) -> Self {
self.disabled = true;
self
}
fn state(&mut self) -> MutexGuard<'_, MouseState> {
self.state.lock().unwrap()
}
/// Determine if the mouse is currently over the element.
///
/// If there is another element above this one at the cursor position, then we treat that as
/// outside the element for purposes of [`MouseState`].
fn is_mouse_over_element(&self, ctx: &EventContext, position: Vector2F) -> bool {
let Some(origin) = self.origin else {
log::warn!("self.origin was None in `Hoverable::is_mouse_over_element`");
return false;
};
let Some(size) = self.size() else {
log::warn!("self.size() was None in `Hoverable::is_mouse_over_element`");
return false;
};
let Some(z_index) = self.child_max_z_index else {
log::warn!("self.child_max_z_index was None in `Hoverable::is_mouse_over_element`");
return false;
};
let is_hovering = ctx
.visible_rect(origin, size)
.is_some_and(|bound| bound.contains_point(position));
let point = Point::from_vec2f(position, z_index);
let is_covered = ctx.is_covered(point);
is_hovering && !is_covered
}
fn set_cursor(&mut self, ctx: &mut EventContext) {
if let Some((z_index, cursor)) = self.z_index().zip(self.hover_cursor) {
ctx.set_cursor(cursor, z_index);
}
}
fn reset_cursor(&mut self, ctx: &mut EventContext) {
if self.hover_cursor.is_some() {
ctx.reset_cursor();
}
}
fn hover_delay(&self, is_hovered: bool) -> Option<Duration> {
if is_hovered {
self.hover_in_delay
} else {
self.hover_out_delay
}
}
/// The main handler for [`Event::MouseMoved`] events.
fn handle_mouse_moved(
&mut self,
position: Vector2F,
is_synthetic: bool,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
let was_mouse_over_element = self.state().is_mouse_over_element;
let is_hovered = self.is_mouse_over_element(ctx, position);
self.state().is_mouse_over_element = is_hovered;
// The type of timer that we might need to set, if there's a corresponding delay.
let hover_timer_type = if is_hovered {
HoverTimerType::HoverIn
} else {
HoverTimerType::HoverOut
};
// If there's a pending hover task for the opposite delay,
// cancel it because we're now handling a new hover action.
if let Some(timer) = self.state().take_hover_timer(hover_timer_type.opposite()) {
ctx.clear_notify_timer(timer.timer_id);
}
// We set / reset cursors immediately (not taking into account
// delays) because we want to reflect the correct cursor as
// the user is moving their mouse.
if was_mouse_over_element != is_hovered {
if is_hovered {
self.set_cursor(ctx);
} else {
self.reset_cursor(ctx);
}
ctx.notify();
}
// If there aren't any delays, then we can just handle
// the mouse movement immediately.
let Some(hover_delay) = self.hover_delay(is_hovered) else {
return self.handle_mouse_moved_without_delay(
is_hovered,
position,
is_synthetic,
ctx,
app,
);
};
// If a timer has already been started, then only handle
// the event if the timer is expired. Otherwise, we'll wait
// until the timer expires.
let timer = self.state().hover_timer(hover_timer_type).cloned();
if let Some(timer) = timer {
if Instant::now() >= timer.hover_at {
return self.handle_mouse_moved_without_delay(
is_hovered,
position,
is_synthetic,
ctx,
app,
);
}
} else {
// If a timer has not been started, start it now.
let (timer_id, hover_at) = ctx.notify_after(hover_delay);
self.state()
.set_hover_timer(hover_timer_type, HoverTimer { hover_at, timer_id });
}
false
}
/// Handles [`Event::MouseMoved`] events when the
/// element is going transitioning between hovered <-> unhovered
/// states (identified by `is_hovered`).
///
/// This does _not_ take into account any delays; the handler
/// immediately sets the hovered state and fires any related
/// callbacks.
fn handle_mouse_moved_without_delay(
&mut self,
is_hovered: bool,
position: Vector2F,
is_synthetic: bool,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
// If there's no change in hover-state, then there's
// no work to do.
//
// Note: we intentionally compare the `hovered` property
// and not the `is_mouse_over_element` property.
let was_hovered = self.state().is_hovered;
if was_hovered == is_hovered {
return false;
}
self.state().is_hovered = is_hovered;
// We should only handle this event if not both the previous and current instance of the state change
// is triggered by a synthetic mouse event. This is to prevent infinite loops when a child element
// conditional on the state of the hoverable might in return trigger the state change of the hoverable.
//
// TODO: we should re-consider this approach. It can lead to missed `on_hover` dispatches.
let was_synthetic = mem::replace(
&mut self.state().last_event_is_synthetic_hover,
is_synthetic,
);
if was_synthetic && is_synthetic {
log::warn!(
"Not handling MouseMoved event in Hoverable due to back-to-back synthetic events."
);
return false;
}
// Skip synthetic hover-out events if configured to do so.
if !is_hovered && is_synthetic && self.skip_synthetic_hover_out {
return false;
}
// If there's a [`Hoverable::on_hover`] callback registered, call it.
if let Some(handler) = self.hover_handler.as_mut() {
handler(is_hovered, ctx, app, position);
};
ctx.notify();
true
}
}
impl Element for Hoverable {
fn layout(
&mut self,
constraint: crate::SizeConstraint,
ctx: &mut crate::LayoutContext,
app: &AppContext,
) -> Vector2F {
self.child.layout(constraint, ctx, app)
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.child.after_layout(ctx, app)
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
self.child.paint(origin, ctx, app);
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
}
fn size(&self) -> Option<Vector2F> {
self.child.size()
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
let handled = self.child.dispatch_event(event, ctx, app);
if self.disabled {
return handled;
}
if self.defer_events_to_children && handled {
return true;
}
if self.bounds().is_none() {
return handled;
}
if !matches!(event.raw_event(), Event::MouseMoved { .. }) {
self.state().last_event_is_synthetic_hover = false;
}
// If there's a mouse-down event outside of the element,
// there's nothing to do except reset the hover state
// (because there might have been a hover delay in-progress).
if let Some(position) = event.raw_event().mouse_down_position() {
if !self.is_mouse_over_element(ctx, position) {
self.state().is_hovered = false;
self.state().is_mouse_over_element = false;
return handled;
}
}
match event.raw_event() {
Event::MiddleMouseDown { position, .. } => {
if let Some(handler) = self.middle_click_handler.as_mut() {
handler(ctx, app, *position);
ctx.notify();
return true;
}
}
Event::BackMouseDown { position, .. } => {
if let Some(handler) = self.back_click_handler.as_mut() {
handler(ctx, app, *position);
ctx.notify();
return true;
}
}
Event::ForwardMouseDown { position, .. } => {
if let Some(handler) = self.forward_click_handler.as_mut() {
handler(ctx, app, *position);
ctx.notify();
return true;
}
}
Event::RightMouseDown { position, .. } => {
if let Some(handler) = self.right_click_handler.as_mut() {
handler(ctx, app, *position);
ctx.notify();
return true;
}
}
Event::LeftMouseDown {
click_count,
position,
..
} => {
// Mouse-down sets the mouse state handle accordingly.
self.state().click_count = Some(*click_count);
// Fire the mouse-down handler immediately if one is set.
if let Some(handler) = self.mouse_down_handler.as_mut() {
handler(ctx, app, *position);
ctx.notify();
return true;
}
// We mark this as handled if we have a handler waiting to take action on the mouse-up event.
if self.click_handler.is_some()
|| (*click_count == 2 && self.double_click_handler.is_some())
{
ctx.notify();
return true;
}
}
Event::LeftMouseUp { position, .. } => {
// Mouse-up should always reset clicked and double-clicked to false.
let click_count = self.state().click_count.take();
// If the event occurs outside the element, don't handle it.
if !self.is_mouse_over_element(ctx, *position) {
return handled;
}
if self.reset_cursor_after_click {
ctx.reset_cursor();
}
// The double-clicked handler takes precendence. However, we should still fall back to the single-click handler
// on a double-click if there's no double-click handler set.
if matches!(click_count, Some(2)) && self.double_click_handler.is_some() {
let handler = self
.double_click_handler
.as_mut()
.expect("handler should exist");
handler(ctx, app, *position);
ctx.notify();
return true;
} else if click_count.is_some() && self.click_handler.is_some() {
let handler = self.click_handler.as_mut().expect("handler should exist");
handler(ctx, app, *position);
ctx.notify();
return true;
}
}
Event::MouseMoved {
position,
is_synthetic,
..
} => {
if self.handle_mouse_moved(*position, *is_synthetic, ctx, app) {
return true;
}
}
Event::LeftMouseDragged { .. } => {
if self.suppress_drag && self.state().is_clicked() {
return true;
}
}
_ => {}
}
handled
}
fn origin(&self) -> Option<Point> {
self.origin
}
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
Some(self as &dyn SelectableElement)
}
#[cfg(any(test, feature = "test-util"))]
fn debug_text_content(&self) -> Option<String> {
self.child.debug_text_content()
}
}
impl SelectableElement for Hoverable {
fn get_selection(
&self,
selection_start: Vector2F,
selection_end: Vector2F,
is_rect: IsRect,
) -> Option<Vec<SelectionFragment>> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.get_selection(selection_start, selection_end, is_rect)
})
}
fn expand_selection(
&self,
point: Vector2F,
direction: SelectionDirection,
unit: SelectionType,
word_boundaries_policy: &WordBoundariesPolicy,
) -> Option<Vector2F> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
})
}
fn is_point_semantically_before(
&self,
absolute_point: Vector2F,
absolute_point_other: Vector2F,
) -> Option<bool> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
})
}
fn smart_select(
&self,
absolute_point: Vector2F,
smart_select_fn: crate::elements::SmartSelectFn,
) -> Option<(Vector2F, Vector2F)> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.smart_select(absolute_point, smart_select_fn)
})
}
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
self.child
.as_selectable_element()
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
.unwrap_or_default()
}
}
#[cfg(test)]
#[path = "hoverable_test.rs"]
mod tests;
@@ -0,0 +1,712 @@
use super::*;
use crate::elements::DispatchEventResult;
use crate::r#async::Timer;
use crate::{
elements::{
ChildAnchor, ConstrainedBox, EventHandler, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Rect, Stack, Text,
},
fonts::FamilyId,
platform::WindowStyle,
App, AppContext, Entity, Event, Presenter, TypedActionView, ViewContext, WindowInvalidation,
};
use pathfinder_geometry::vector::vec2f;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
rc::Rc,
};
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
enum ElementIdentifier {
BottomStack,
HoverableElementBottomLeft,
HoverableElementTopRight,
}
fn mouse_moved_event(position: Vector2F) -> Event {
Event::MouseMoved {
position,
cmd: false,
shift: false,
is_synthetic: false,
}
}
#[derive(Default)]
struct View {
// Maps identifier to number of mouse down events
mouse_downs: HashMap<ElementIdentifier, usize>,
mouse_ups: HashMap<ElementIdentifier, usize>,
hover_ins: HashMap<ElementIdentifier, usize>,
hover_outs: HashMap<ElementIdentifier, usize>,
bottom_mouse_state: MouseStateHandle,
top_mouse_state: MouseStateHandle,
/// If [`Some`], the top-right hoverable will
/// have a hover-in delay with this duration.
hover_in_delay: Option<Duration>,
/// If [`Some`], the top-right hoverable will
/// have a hover-out delay with this duration.
hover_out_delay: Option<Duration>,
}
pub fn init(app: &mut AppContext) {
app.add_action("hoverable_test:mouse_down", View::mouse_down);
app.add_action("hoverable_test:mouse_up", View::mouse_up);
app.add_action("hoverable_test:hover_in", View::hover_in);
app.add_action("hoverable_test:hover_out", View::hover_out);
}
impl View {
fn with_hover_in_delay(mut self, delay: Duration) -> Self {
self.hover_in_delay = Some(delay);
self
}
fn with_hover_out_delay(mut self, delay: Duration) -> Self {
self.hover_out_delay = Some(delay);
self
}
fn mouse_down(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
log::info!("Recording mouse_down on element {identifier:?}");
let entry = self.mouse_downs.entry(*identifier).or_insert(0);
*entry += 1;
true
}
fn mouse_up(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
log::info!("Recording mouse_up on element {identifier:?}");
let entry = self.mouse_ups.entry(*identifier).or_insert(0);
*entry += 1;
true
}
fn hover_in(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
log::info!("Recording hover in on element {identifier:?}");
let entry = self.hover_ins.entry(*identifier).or_insert(0);
*entry += 1;
true
}
fn hover_out(&mut self, identifier: &ElementIdentifier, _: &mut ViewContext<Self>) -> bool {
log::info!("Recording hover out on element {identifier:?}");
let entry = self.hover_outs.entry(*identifier).or_insert(0);
*entry += 1;
true
}
fn num_hover_in_events(&self, identifier: &ElementIdentifier) -> usize {
*self.hover_ins.get(identifier).unwrap_or(&0)
}
fn num_hover_out_events(&self, identifier: &ElementIdentifier) -> usize {
*self.hover_outs.get(identifier).unwrap_or(&0)
}
}
impl Entity for View {
type Event = ();
}
impl crate::core::View for View {
fn ui_name() -> &'static str {
"hoverable_test_view"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
let mut stack = Stack::new();
stack.add_child(
ConstrainedBox::new(Rect::new().finish())
.with_height(100.)
.with_width(100.)
.finish(),
);
stack.add_positioned_child(
Hoverable::new(self.bottom_mouse_state.clone(), |_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(25.)
.with_width(25.)
.finish()
})
.on_click(|evt, _, _| {
evt.dispatch_action(
"hoverable_test:mouse_down",
ElementIdentifier::HoverableElementBottomLeft,
);
})
.on_hover(|hovered, evt, _, _| {
let action_name = if hovered {
"hoverable_test:hover_in"
} else {
"hoverable_test:hover_out"
};
evt.dispatch_action(action_name, ElementIdentifier::HoverableElementBottomLeft);
})
.with_cursor(Cursor::Crosshair)
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 75.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
let mut hoverable = Hoverable::new(self.top_mouse_state.clone(), |_| {
ConstrainedBox::new(Rect::new().finish())
.with_height(25.)
.with_width(25.)
.finish()
})
.on_hover(|hovered, evt, _, _| {
let action_name = if hovered {
"hoverable_test:hover_in"
} else {
"hoverable_test:hover_out"
};
evt.dispatch_action(action_name, ElementIdentifier::HoverableElementTopRight);
})
.with_cursor(Cursor::PointingHand);
if let Some(delay) = self.hover_in_delay {
hoverable = hoverable.with_hover_in_delay(delay);
}
if let Some(delay) = self.hover_out_delay {
hoverable = hoverable.with_hover_out_delay(delay);
}
stack.add_positioned_child(
hoverable.finish(),
OffsetPositioning::offset_from_parent(
vec2f(75., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
stack.add_positioned_child(
ConstrainedBox::new(Rect::new().finish())
.with_height(70.)
.with_width(70.)
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(15., 15.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
let mut scene = Stack::new();
scene.add_child(
EventHandler::new(stack.finish())
.on_left_mouse_down(|evt, _, _| {
evt.dispatch_action(
"hoverable_test:mouse_down",
ElementIdentifier::BottomStack,
);
DispatchEventResult::StopPropagation
})
.on_left_mouse_up(|evt, _, _| {
evt.dispatch_action("hoverable_test:mouse_up", ElementIdentifier::BottomStack);
DispatchEventResult::StopPropagation
})
.finish(),
);
scene.finish()
}
}
impl TypedActionView for View {
type Action = ();
}
#[test]
fn test_hoverable_element_click_handling() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
let presenter = Rc::new(RefCell::new(presenter));
// Click on the hoverable element on the bottom left corner.
// This event should be handled by the Hoverable as it has a
// click_handler.
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(10., 90.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
// Mouse up on the hoverable element on the bottom left corner.
// This should trigger the click_handler and increment the mouse_down
// count on HoverableElement by 1.
ctx.simulate_window_event(
Event::LeftMouseUp {
position: vec2f(10., 90.),
modifiers: Default::default(),
},
window_id,
presenter.clone(),
);
// Click on the hoverable element on the upper right corner.
// This event should be handled by the BottomStack instead of Hoverable
// as the element does not have a click_handler.
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(90., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
// Mouse up on the hoverable element on the top right corner.
// Since the element does not have a click_handler, this should
// be captured by the base stack.
ctx.simulate_window_event(
Event::LeftMouseUp {
position: vec2f(90., 10.),
modifiers: Default::default(),
},
window_id,
presenter.clone(),
);
// Click on the base stack.
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(10., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter,
);
});
view.read(app, |view, _| {
assert_eq!(
2,
*view
.mouse_downs
.get(&ElementIdentifier::BottomStack)
.unwrap()
);
assert_eq!(
1,
*view
.mouse_downs
.get(&ElementIdentifier::HoverableElementBottomLeft)
.unwrap()
);
assert_eq!(
1,
*view.mouse_ups.get(&ElementIdentifier::BottomStack).unwrap()
);
});
});
}
#[test]
fn test_hoverable_element_hover_handling_no_delay() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
// Make sure there are no hover events to start.
view.read(app, |view, _| {
assert_eq!(
0,
view.num_hover_in_events(&ElementIdentifier::HoverableElementBottomLeft)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementBottomLeft)
);
});
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
let presenter = Rc::new(RefCell::new(presenter));
// Move the mouse over the hoverable element in the bottom left corner.
// This event should be handled immediately by the hover handler
// without delay.
let event = mouse_moved_event(vec2f(10., 90.));
// Before the event, the cursor should have it's default shape.
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
// After the event, the cursor should have the set cursor shape.
assert_eq!(ctx.get_cursor_shape(), Cursor::Crosshair);
// Move the mouse to over the covering element. Still over the bottom-left
// hoverable, but since it's covered, it should be treated as not hovering
let event = mouse_moved_event(vec2f(20., 80.));
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
// Move the mouse back to the bottom-left hoverable (not over the covering element)
// This should trigger another hover event
let event = mouse_moved_event(vec2f(10., 90.));
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
assert_eq!(ctx.get_cursor_shape(), Cursor::Crosshair);
});
view.read(app, |view, _| {
assert_eq!(
2,
view.num_hover_in_events(&ElementIdentifier::HoverableElementBottomLeft)
);
assert_eq!(
1,
view.num_hover_out_events(&ElementIdentifier::HoverableElementBottomLeft)
);
});
});
}
#[test]
fn test_hoverable_element_hover_handling_with_hover_in_delay() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::default().with_hover_in_delay(Duration::from_millis(500))
});
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
let presenter_clone = presenter.clone();
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(100., 100.), 1., None, ctx);
// Move the mouse over the hoverable in the top-left corner.
// This should not immmediately trigger hover events because this hoverable
// has a 0.5s hover-in delay.
let event = mouse_moved_event(vec2f(90., 10.));
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
ctx.simulate_window_event(event.clone(), window_id, presenter);
ctx.set_last_mouse_move_event(window_id, event);
// The cursor, however, should be updated immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
});
view.read(app, |view, _| {
assert_eq!(
0,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
// Wait 1s for the delay to complete, then verify that we got a hover event from the
// top-right Hoverable
Timer::after(Duration::from_secs(1)).await;
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
app.update(move |ctx| {
// Move the mouse away from the hoverable.
// There's no hover-out delay so there shouldn't
// be any delay in registering the hover-out event.
let event = mouse_moved_event(vec2f(100., 100.));
ctx.simulate_window_event(event.clone(), window_id, presenter_clone);
ctx.set_last_mouse_move_event(window_id, event);
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
});
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
1,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
});
}
#[test]
fn test_hoverable_element_hover_handling_with_hover_out_delay() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::default().with_hover_out_delay(Duration::from_millis(500))
});
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
let presenter_clone = presenter.clone();
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(100., 100.), 1., None, ctx);
// Move the mouse over the hoverable in the top-left corner.
// This should immmediately trigger a hover event because there is no
// hover-in delay.
let event = mouse_moved_event(vec2f(90., 10.));
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
ctx.simulate_window_event(event.clone(), window_id, presenter);
ctx.set_last_mouse_move_event(window_id, event);
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
});
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
app.update(move |ctx| {
// Move the mouse away from the hoverable.
// This should not immmediately trigger hover events because
// this hoverable has a 0.5s hover-out delay.
let event = mouse_moved_event(vec2f(100., 100.));
ctx.simulate_window_event(event.clone(), window_id, presenter_clone);
ctx.set_last_mouse_move_event(window_id, event);
// The cursor, however, should be updated immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
});
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
Timer::after(Duration::from_millis(1000)).await;
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
1,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
});
}
#[test]
fn test_hoverable_element_hover_handling_with_hover_in_out_delay() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
View::default()
.with_hover_out_delay(Duration::from_millis(500))
.with_hover_in_delay(Duration::from_millis(500))
});
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
let mut updated = HashSet::new();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(100., 100.), 1., None, ctx);
// Move the mouse over the hoverable in the top-left corner.
// This should not immmediately trigger hover events because
// this hoverable has a 0.5s hover-out delay.
let event = mouse_moved_event(vec2f(90., 10.));
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
// The cursor should still update immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
// Move the mouse away from the hoverable in the top-left corner.
// Again, there's a hover-out delay so no hover events should be
// fired still.
let event = mouse_moved_event(vec2f(100., 100.));
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
// The cursor should still update immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
// Move it back over the hoverable and wait.
let event = mouse_moved_event(vec2f(90., 10.));
ctx.simulate_window_event(event.clone(), window_id, presenter);
ctx.set_last_mouse_move_event(window_id, event);
// The cursor should still update immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
});
view.read(app, |view, _| {
assert_eq!(
0,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
// After waiting, there should ultimately be just one hover-in event
// for the final mouse movement.
//
// The other hover-in and hover-out events should have been dropped
// due to the mouse moving in and out of the hoverable during the
// delay period.
Timer::after(Duration::from_millis(1000)).await;
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
});
}
// Why would Elements that haven't been painted need to receive any mouse events?
// Shouldn't paint happen BEFORE any user interaction? Yes, but remember that Elements are
// disposable, and may get discarded and created anew between invalidations. Most of the time,
// Elements do get painted again immediately after creation. However, sometimes Elements can
// get scrolled out of view, e.g. outside a ClippedScrollable, and may not get painted. If the
// element, like an Editor, is still focused while out of view, it needs to respond to events
// without panicking. This test just never paints the Hoverable, and dispatches events on it.
#[test]
fn test_unpainted_hoverable_receives_click_events_without_panic() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let mut presenter = Presenter::new(window_id);
let hover_state = MouseStateHandle::default();
let mut hoverable = Hoverable::new(hover_state, |_state| {
Text::new_inline("foobar", FamilyId(0), 10.0).finish()
});
app.update(move |ctx| {
// We can't use ctx.simulate_window_event for click events here because that would
// require us to paint the scene by calling presenter.build_scene. That's because
// that code path uses the sizes and origins of the elements to determine which
// elements to dispatch the click event on. Instead, we need to call the dispatch_event
// method on the Element directly. That requires us to mock the EventContext, which we
// can pluck from the Presenter like so:
let mut event_ctx = presenter.mock_event_context(ctx.font_cache());
let mouse_down = Event::LeftMouseDown {
position: vec2f(10., 90.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
};
hoverable.dispatch_event(&DispatchedEvent::from(mouse_down), &mut event_ctx, ctx);
let mouse_up = Event::LeftMouseUp {
position: vec2f(10., 90.),
modifiers: Default::default(),
};
hoverable.dispatch_event(&DispatchedEvent::from(mouse_up), &mut event_ctx, ctx);
});
});
}
+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");
}
@@ -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 galaxyui_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]);
}

Some files were not shown because too many files have changed in this diff Show More