first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+14 -2
View File
@@ -8,8 +8,12 @@ license = "MIT"
[features]
traces = []
tui = ["dep:ratatui"]
# Feature enabled only when app is compiled for integration tests.
integration_tests = ["dep:minimp4", "dep:openh264"]
# When enabled, skip eager scene building from the window-invalidation callback
# and rely on the normal platform redraw path instead; see core/app.rs.
defer_scene_build = []
# 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 = []
@@ -25,7 +29,7 @@ async-channel.workspace = true
async-executor = "1.4.1"
async-task.workspace = true
async-fs.workspace = true
bounded-vec-deque = "0.1"
bounded-vec-deque.workspace = true
bytes.workspace = true
cfg-if.workspace = true
chrono = "0.4"
@@ -58,6 +62,9 @@ pathfinder_color.workspace = true
pathfinder_geometry.workspace = true
rand.workspace = true
rangemap.workspace = true
ratatui = { version = "0.30", optional = true, features = [
"unstable-rendered-line-info",
] }
raw-window-handle = "0.6.2"
resvg.workspace = true
rstar = "0.12.2"
@@ -74,6 +81,7 @@ tempfile.workspace = true
thiserror.workspace = true
titlecase = "1.0"
tokio.workspace = true
tracing.workspace = true
trait-set = "0.3.0"
vec1.workspace = true
galaxy_util.workspace = true
@@ -94,7 +102,7 @@ ctrlc.workspace = true
font-kit.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies]
arboard = { workspace = true, features = ["image-data"] }
[target.'cfg(target_family = "wasm")'.dependencies]
@@ -104,3 +112,7 @@ woothee = "0.13.0"
[build-dependencies]
cfg_aliases.workspace = true
# The TUI integration test references tui-only API, so it must be skipped
# entirely when the feature is off; `required-features` needs an explicit
# target section.
+2 -2
View File
@@ -41,9 +41,9 @@ Entities are of course entitled to own any state they like directly as well. Han
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.
To describe the view's appearance, 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 precisely, 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.
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, handling 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
+2 -1
View File
@@ -45,10 +45,11 @@
//! 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};
use crate::Action;
#[derive(Debug, Clone)]
/// Main structure describing the content VoiceOver (or other screen reading software) will receive.
pub struct AccessibilityContent {
@@ -1,7 +1,8 @@
use crate::time::get_current_time;
use chrono::{DateTime, Duration, Utc};
use serde_json::json;
use crate::time::get_current_time;
// `DailyAppFocusDuration` records the cumulative duration for focus events that end on each day.
struct DailyAppFocusDuration {
duration: Duration,
@@ -83,5 +84,5 @@ impl AppFocusInfo {
}
#[cfg(test)]
#[path = "app_focus_telemetry_test.rs"]
#[path = "app_focus_telemetry_tests.rs"]
mod tests;
@@ -1,6 +1,7 @@
use chrono::Duration;
use crate::app_focus_telemetry::AppFocusInfo;
use crate::time::test_offset_time;
use chrono::Duration;
#[test]
fn test_daily_app_focus_duration_increase() {
@@ -0,0 +1,41 @@
use chrono::Duration;
use crate::app_focus_telemetry::AppFocusInfo;
use crate::time::test_offset_time;
#[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));
}
+151 -12
View File
@@ -1,18 +1,23 @@
use anyhow::anyhow;
use anyhow::{Error, Result};
use std::any::{Any, TypeId};
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::Hash;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::time::SystemTime;
use anyhow::{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 futures::future::BoxFuture;
use futures::{Future, FutureExt as _};
use super::AssetProvider;
use crate::image_cache::ImageCache;
use crate::r#async::executor;
use crate::{Entity, ModelContext, SingletonEntity};
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 {}
@@ -60,6 +65,41 @@ impl std::fmt::Debug for AsyncAssetId {
}
}
/// A content fingerprint for a local file on disk.
///
/// Used as part of an [`AssetSource::LocalFile`] cache key so that a file whose
/// contents change on disk is treated as a distinct asset and re-read, rather
/// than served from a now-stale cache entry.
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
pub struct LocalFileContentVersion {
modified: Option<SystemTime>,
file_size: u64,
}
impl LocalFileContentVersion {
/// Builds a content version by reading filesystem metadata for `path`.
///
/// Performs blocking filesystem I/O, so this must only be called off the
/// render hot path (for example, once when a view resolves its image
/// sources), never on every frame. Returns `None` when metadata cannot be
/// read.
#[cfg(not(target_arch = "wasm32"))]
pub fn for_path(path: impl AsRef<std::path::Path>) -> Option<Self> {
let metadata = std::fs::metadata(path).ok()?;
Some(Self {
modified: metadata.modified().ok(),
file_size: metadata.len(),
})
}
/// Filesystem metadata is unavailable on WASM, so a local-file content
/// version is never computed there.
#[cfg(target_arch = "wasm32")]
pub fn for_path(_path: impl AsRef<std::path::Path>) -> Option<Self> {
None
}
}
/// A "URI" for some data file. In other words, the location of an asset.
#[derive(Derivative)]
#[derivative(Clone, Hash, PartialEq, Eq, Debug)]
@@ -81,11 +121,39 @@ pub enum AssetSource {
path: &'static str,
},
/// Accessible in the user's local filesystem at the provided path.
LocalFile { path: String },
LocalFile {
path: String,
/// Optional content fingerprint. When present, it makes the cache key
/// sensitive to on-disk changes so an edited file is re-read instead of
/// served stale. `None` preserves path-only caching for callers that do
/// not need invalidation.
content_version: Option<LocalFileContentVersion>,
},
/// Image loaded directly with bytes
Raw { id: String },
}
impl AssetSource {
/// Returns this source with a freshly-read local-file content version
/// attached when it is an [`AssetSource::LocalFile`]; all other variants are
/// returned unchanged.
///
/// Reads filesystem metadata, so call this off the render hot path (for
/// example, once when a view resolves its image sources), never per frame.
pub fn with_local_file_content_version(self) -> Self {
match self {
AssetSource::LocalFile { path, .. } => {
let content_version = LocalFileContentVersion::for_path(&path);
AssetSource::LocalFile {
path,
content_version,
}
}
other => other,
}
}
}
/// The public representation of an asset's current state (i.e., in-memory availability).
pub enum AssetState<T> {
Loading { handle: AssetHandle },
@@ -163,6 +231,7 @@ pub struct AssetCache {
inner: Rc<RefCell<HashMap<AssetHandle, AssetStateInternal>>>,
bundled_asset_provider: Box<dyn AssetProvider>,
image_cache: ImageCache,
foreground_executor: Rc<executor::Foreground>,
background_executor: Arc<executor::Background>,
}
@@ -192,15 +261,18 @@ impl Asset for String {
impl AssetCache {
const MAX_RAW_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
const MAX_VERSIONED_LOCAL_FILE_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
pub fn new(
bundled_asset_provider: Box<dyn AssetProvider>,
image_cache: ImageCache,
foreground_executor: Rc<executor::Foreground>,
background_executor: Arc<executor::Background>,
) -> Self {
Self {
inner: Rc::new(RefCell::new(HashMap::new())),
bundled_asset_provider,
image_cache,
foreground_executor,
background_executor,
}
@@ -275,6 +347,59 @@ impl AssetCache {
evicted_image_ids
}
fn evict_versioned_local_file_assets(
assets: &mut HashMap<AssetHandle, AssetStateInternal>,
max_total_size: usize,
) -> Vec<AssetSource> {
let mut versioned_local_file_assets: Vec<_> = assets
.iter()
.filter_map(|(handle, state)| {
if matches!(
handle.source,
AssetSource::LocalFile {
content_version: Some(_),
..
}
) {
if let AssetStateInternal::Loaded {
timestamp,
size_in_bytes,
..
} = state
{
return Some((handle.clone(), *timestamp, *size_in_bytes));
}
}
None
})
.collect();
let mut total_size = versioned_local_file_assets
.iter()
.map(|(_, _, size_in_bytes)| size_in_bytes)
.sum::<usize>();
if total_size <= max_total_size {
return vec![];
}
versioned_local_file_assets.sort_by_key(|(_, timestamp, _)| *timestamp);
let mut evicted_sources = vec![];
for (handle, _, size_in_bytes) in versioned_local_file_assets {
if total_size <= max_total_size {
break;
}
if assets.remove(&handle).is_some() {
total_size -= size_in_bytes;
evicted_sources.push(handle.source.clone());
assets.insert(handle, AssetStateInternal::Evicted);
}
}
evicted_sources
}
/// 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.
@@ -317,7 +442,7 @@ impl AssetCache {
};
assets.insert(key.clone(), asset_state);
}
AssetSource::LocalFile { path } => {
AssetSource::LocalFile { path, .. } => {
assets.insert(key.clone(), AssetStateInternal::loading());
self.load_asynchronously::<T>(
source.clone(),
@@ -434,6 +559,7 @@ impl AssetCache {
// Spawn a receiver on the foreground executor.
let assets = Rc::downgrade(&self.inner);
let image_cache = self.image_cache.clone();
self.foreground_executor
.spawn_boxed(Box::pin(async move {
let result = match rx.await {
@@ -483,6 +609,15 @@ impl AssetCache {
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
}
}
let evicted_sources = Self::evict_versioned_local_file_assets(
&mut assets,
Self::MAX_VERSIONED_LOCAL_FILE_ASSET_SIZE,
);
drop(assets);
for source in evicted_sources {
image_cache.evict_image(&source);
}
}))
.detach();
}
@@ -498,3 +633,7 @@ impl Entity for AssetCache {
}
impl SingletonEntity for AssetCache {}
#[cfg(test)]
#[path = "asset_cache_tests.rs"]
mod tests;
@@ -0,0 +1,167 @@
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::rc::Rc;
use std::time::{Duration, UNIX_EPOCH};
use super::{AssetCache, AssetHandle, AssetSource, AssetStateInternal, LocalFileContentVersion};
#[cfg(not(target_arch = "wasm32"))]
fn unique_temp_path(name: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"warp_asset_cache_test_{}_{name}",
std::process::id()
));
path
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn local_file_content_version_changes_when_file_contents_change() {
let path = unique_temp_path("contents_change.png");
std::fs::write(&path, b"aaaa").expect("write temp file");
let path_string = path.to_string_lossy().to_string();
let source = AssetSource::LocalFile {
path: path_string.clone(),
content_version: None,
}
.with_local_file_content_version();
match &source {
AssetSource::LocalFile {
path: resolved_path,
content_version,
} => {
assert_eq!(resolved_path, &path_string);
let content_version = content_version
.as_ref()
.expect("expected a content version for an existing file");
assert_eq!(
content_version.modified,
std::fs::metadata(&path)
.expect("read temp file metadata")
.modified()
.ok()
);
assert_eq!(content_version.file_size, 4);
}
other => panic!("expected a local file source, got {other:?}"),
}
let unchanged = AssetSource::LocalFile {
path: path_string.clone(),
content_version: None,
}
.with_local_file_content_version();
assert_eq!(
source, unchanged,
"an unmodified file should produce the same cache key"
);
std::fs::write(&path, b"bbbbbbbb").expect("rewrite temp file");
let after_change = AssetSource::LocalFile {
path: path_string,
content_version: None,
}
.with_local_file_content_version();
assert_ne!(
source, after_change,
"changing file contents should produce a different cache key"
);
let _ = std::fs::remove_file(&path);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn local_file_content_version_is_none_for_missing_file() {
let path = unique_temp_path("definitely_missing.png");
let _ = std::fs::remove_file(&path);
assert!(
LocalFileContentVersion::for_path(&path).is_none(),
"a missing file should not produce a content version"
);
}
#[test]
fn with_local_file_content_version_leaves_non_local_sources_unchanged() {
let bundled = AssetSource::Bundled { path: "icon.svg" };
assert_eq!(bundled.clone().with_local_file_content_version(), bundled);
}
#[test]
fn evicts_oldest_versioned_local_file_assets_over_limit() {
let oldest_source = AssetSource::LocalFile {
path: "oldest.png".to_string(),
content_version: Some(LocalFileContentVersion {
modified: Some(UNIX_EPOCH + Duration::from_secs(1)),
file_size: 4,
}),
};
let newest_source = AssetSource::LocalFile {
path: "newest.png".to_string(),
content_version: Some(LocalFileContentVersion {
modified: Some(UNIX_EPOCH + Duration::from_secs(2)),
file_size: 4,
}),
};
let unversioned_source = AssetSource::LocalFile {
path: "unversioned.png".to_string(),
content_version: None,
};
let oldest_handle = AssetHandle {
source: oldest_source.clone(),
asset_type: TypeId::of::<String>(),
};
let newest_handle = AssetHandle {
source: newest_source.clone(),
asset_type: TypeId::of::<String>(),
};
let unversioned_handle = AssetHandle {
source: unversioned_source,
asset_type: TypeId::of::<String>(),
};
let mut assets = HashMap::from([
(
oldest_handle.clone(),
AssetStateInternal::Loaded {
data: Rc::new(String::new()) as Rc<dyn Any>,
timestamp: 1,
size_in_bytes: 4,
},
),
(
newest_handle.clone(),
AssetStateInternal::Loaded {
data: Rc::new(String::new()) as Rc<dyn Any>,
timestamp: 2,
size_in_bytes: 4,
},
),
(
unversioned_handle.clone(),
AssetStateInternal::Loaded {
data: Rc::new(String::new()) as Rc<dyn Any>,
timestamp: 0,
size_in_bytes: 10,
},
),
]);
let evicted_sources = AssetCache::evict_versioned_local_file_assets(&mut assets, 4);
assert_eq!(evicted_sources, vec![oldest_source]);
assert!(matches!(
assets.get(&oldest_handle),
Some(AssetStateInternal::Evicted)
));
assert!(matches!(
assets.get(&newest_handle),
Some(AssetStateInternal::Loaded { .. })
));
assert!(matches!(
assets.get(&unversioned_handle),
Some(AssetStateInternal::Loaded { .. })
));
}
+2 -1
View File
@@ -1,5 +1,6 @@
use anyhow::{anyhow, Result};
use std::borrow::Cow;
use anyhow::{anyhow, Result};
pub mod asset_cache;
impl AssetProvider for () {
+7 -8
View File
@@ -1,8 +1,6 @@
use std::{
future::Future,
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
use std::future::Future;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use futures::{pin_mut, FutureExt as _};
use futures_util::stream::AbortHandle;
@@ -17,10 +15,10 @@ cfg_if::cfg_if! {
}
}
pub use futures_util::future::LocalBoxFuture;
// 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;
use thiserror::Error;
pub mod executor {
#[derive(thiserror::Error, Debug)]
@@ -109,7 +107,8 @@ impl futures_util::task::Spawn for executor::Background {
}
}
#[derive(Debug)]
#[derive(Debug, Error)]
#[error("Timed out waiting for future")]
pub struct TimeoutError;
pub trait FutureExt: Future {
@@ -1,22 +1,18 @@
use std::{
marker::PhantomData,
pin::Pin,
rc::Rc,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
task::{Context, Poll},
};
use std::marker::PhantomData;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use async_executor::LocalExecutor;
use futures::{
future::{BoxFuture, LocalBoxFuture},
Future, FutureExt,
};
use futures::future::{BoxFuture, LocalBoxFuture};
use futures::{Future, FutureExt};
use futures_util::future::{AbortHandle, Abortable};
use tracing::Instrument as _;
use crate::{platform, r#async::executor::Error};
use crate::platform;
use crate::r#async::executor::Error;
pub type ForegroundTask = async_task::Task<()>;
@@ -90,6 +86,7 @@ impl Foreground {
/// less code than a generic implementation, with no noticeable performance
/// impact.
pub fn spawn_boxed(&self, future: LocalBoxFuture<'static, ()>) -> ForegroundTask {
let future = future.instrument(tracing::Span::current());
match self {
Foreground::Platform {
not_send_or_sync: _,
@@ -196,6 +193,7 @@ impl Background {
/// less code than a generic implementation, with no noticeable performance
/// impact.
pub fn spawn_boxed(&self, future: BoxFuture<'static, ()>) -> BackgroundTask {
let future = future.instrument(tracing::Span::current());
let inner = match &self.runtime {
Some(runtime) => Some(runtime.spawn(future)),
None => {
@@ -2,7 +2,6 @@ pub mod executor;
pub use async_io::{block_on, Timer};
use futures::Future;
pub use futures_util::future::BoxFuture;
trait_set::trait_set! {
@@ -1,10 +1,13 @@
use std::sync::Arc;
use futures::{future::LocalBoxFuture, Future, FutureExt};
use futures::future::LocalBoxFuture;
use futures::{Future, FutureExt};
use futures_util::future::{AbortHandle, Abortable};
use tracing::Instrument as _;
use wasm_bindgen_futures::spawn_local;
use crate::{platform, r#async::executor::Error};
use crate::platform;
use crate::r#async::executor::Error;
/// A handle to a task that will run on the main thread.
pub struct ForegroundTask;
@@ -51,7 +54,7 @@ impl Foreground {
/// less code than a generic implementation, with no noticeable performance
/// impact.
pub fn spawn_boxed(&self, future: LocalBoxFuture<'static, ()>) -> ForegroundTask {
spawn_local(future);
spawn_local(future.instrument(tracing::Span::current()));
ForegroundTask
}
@@ -104,7 +107,7 @@ impl Background {
/// less code than a generic implementation, with no noticeable performance
/// impact.
pub fn spawn_boxed(&self, future: LocalBoxFuture<'static, ()>) -> BackgroundTask {
spawn_local(future);
spawn_local(future.instrument(tracing::Span::current()));
BackgroundTask
}
+4 -7
View File
@@ -1,18 +1,15 @@
pub mod executor;
use std::{
pin::Pin,
task::{Context, Poll},
};
use std::pin::Pin;
use std::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;
use gloo::timers::future::TimeoutFuture;
use instant::Instant;
// Define a trait that is implemented by all types to allow us to not
// place any restrictions on SpawnableOutput.
+9 -10
View File
@@ -1,10 +1,9 @@
#[allow(unused_imports)]
use crate::clipboard::{Clipboard, ClipboardContent};
#[cfg(any(target_os = "linux", target_os = "windows"))]
use itertools::Itertools;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
use {arboard, image::ImageEncoder};
use itertools::Itertools;
#[allow(unused_imports)]
use crate::clipboard::{Clipboard, ClipboardContent};
/// Supported image file extensions for clipboard operations.
pub const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp"];
@@ -19,7 +18,7 @@ pub const CLIPBOARD_IMAGE_MIME_TYPES: &[&str] = &[
];
/// Minimum bytes needed for image format detection.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
const MIN_IMAGE_HEADER_SIZE: usize = 8;
/// Check if a string has an image file extension.
@@ -270,7 +269,7 @@ pub fn strip_html_to_plain_text(html: &str) -> String {
}
/// Process clipboard image data, preserving original format or converting to PNG.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
pub fn process_clipboard_image(
arboard_image: &arboard::ImageData,
filename: Option<String>,
@@ -295,7 +294,7 @@ pub fn process_clipboard_image(
}
/// Read image data from clipboard, checking for images before expensive filename extraction.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
pub fn read_images_from_clipboard(
clipboard: &mut arboard::Clipboard,
html_content: &Option<String>,
@@ -326,7 +325,7 @@ pub fn read_images_from_clipboard(
}
/// Try to preserve original image format using infer crate for detection.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
pub fn try_preserve_original_format(
bytes: &[u8],
filename: Option<String>,
@@ -353,7 +352,7 @@ pub fn try_preserve_original_format(
}
/// Converts RGBA bitmap data to PNG format, returns None on invalid dimensions/encoding.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
pub fn convert_raw_bitmap_to_png(
width: usize,
height: usize,
@@ -5,7 +5,7 @@ use crate::clipboard::{ClipboardContent, ImageData};
// HELPER FUNCTIONS (shared across tests)
// ============================================================================
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
fn create_rgba_data(w: usize, h: usize) -> Vec<u8> {
// Simple test pattern: red gradient
(0..h)
@@ -15,19 +15,19 @@ fn create_rgba_data(w: usize, h: usize) -> Vec<u8> {
.collect()
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", 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"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", 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"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
fn create_simple_gif() -> Vec<u8> {
// GIF header
let mut data = Vec::new();
@@ -36,7 +36,7 @@ fn create_simple_gif() -> Vec<u8> {
data
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
fn create_simple_webp() -> Vec<u8> {
// WebP header
let mut data = Vec::new();
@@ -47,7 +47,7 @@ fn create_simple_webp() -> Vec<u8> {
data
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", 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");
@@ -187,9 +187,8 @@ fn test_extract_filename_from_clipboard_content() {
// IMAGE PROCESSING TESTS (Linux/Windows platforms only)
// ============================================================================
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
mod image_processing_tests {
use super::*;
#[test]
fn test_rgba_bitmap_processing() {
+2 -4
View File
@@ -1,7 +1,5 @@
use std::{
any::{Any, TypeId},
fmt::Debug,
};
use std::any::{Any, TypeId};
use std::fmt::Debug;
/// Trait representing a Typed action.
///
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
//! The TUI-backend half of the `AppContext` API: TUI view/window creation and
//! TUI view rendering, available when compiled with `tui` feature.
use anyhow::{anyhow, Result};
use super::{
autotracking, AddWindowOptions, AppContext, StoredView, TypedActionView, ViewContext,
ViewHandle, Window,
};
use crate::{EntityId, WindowId};
impl AppContext {
/// Adds a TUI view to the given window.
pub fn add_tui_view<T, F>(&mut self, window_id: WindowId, build_view: F) -> ViewHandle<T>
where
T: crate::TuiView,
F: FnOnce(&mut ViewContext<T>) -> T,
{
let view_id = EntityId::new();
self.pending_flushes += 1;
let mut ctx = ViewContext::new(self, window_id, view_id);
let view = build_view(&mut ctx);
let window = self
.windows
.get_mut(&window_id)
.expect("Window does not exist");
window
.views
.insert(view_id, StoredView::Tui(Box::new(view)));
self.view_to_window.insert(view_id, window_id);
self.window_invalidations
.entry(window_id)
.or_default()
.updated
.insert(view_id);
let handle = ViewHandle::new(window_id, view_id, &self.ref_counts);
self.flush_effects();
handle
}
/// Adds a TUI view that handles typed actions.
pub fn add_typed_action_tui_view<V, F>(
&mut self,
window_id: WindowId,
build_view: F,
) -> ViewHandle<V>
where
V: TypedActionView + crate::TuiView,
F: FnOnce(&mut ViewContext<V>) -> V,
{
self.add_typed_action_tui_view_internal(window_id, build_view, None)
}
/// [`Self::add_typed_action_tui_view`] with creation-time structural
/// parentage, mirroring [`Self::add_typed_action_view_with_parent`].
pub(crate) fn add_typed_action_tui_view_with_parent<V, F>(
&mut self,
window_id: WindowId,
build_view: F,
parent_view_id: EntityId,
) -> ViewHandle<V>
where
V: TypedActionView + crate::TuiView,
F: FnOnce(&mut ViewContext<V>) -> V,
{
self.add_typed_action_tui_view_internal(window_id, build_view, Some(parent_view_id))
}
fn add_typed_action_tui_view_internal<V, F>(
&mut self,
window_id: WindowId,
build_view: F,
parent_view_id: Option<EntityId>,
) -> ViewHandle<V>
where
V: TypedActionView + crate::TuiView,
F: FnOnce(&mut ViewContext<V>) -> V,
{
self.pending_flushes += 1;
let view_id = EntityId::new();
let mut ctx = ViewContext::new(self, window_id, view_id);
let view = build_view(&mut ctx);
let window = self
.windows
.get_mut(&window_id)
.expect("Window does not exist");
window
.views
.insert(view_id, StoredView::Tui(Box::new(view)));
self.register_typed_action_view_internal::<V>(window_id, view_id, parent_view_id)
}
/// Creates a new TUI window with the view returned by `build_root_view` as
/// its root view. The TUI counterpart of [`Self::add_window`], reduced to
/// the backend-neutral subset: window-id + bounds bookkeeping, root-view
/// construction, and focus. No presenter, platform window, or
/// scene/event callbacks — the [`runtime::TuiRuntime`](crate::runtime::TuiRuntime)
/// owns the draw + input loop for the window.
pub fn add_tui_window<T, F>(
&mut self,
options: AddWindowOptions,
build_root_view: F,
) -> (WindowId, ViewHandle<T>)
where
T: crate::TuiView + TypedActionView,
F: FnOnce(&mut ViewContext<T>) -> T,
{
let AddWindowOptions {
window_bounds,
anchor_new_windows_from_closed_position,
..
} = options;
let window_id = WindowId::new();
// Store the window bounds before creating the root view, in case it
// uses this value.
self.window_bounds.insert(window_id, window_bounds.bounds());
self.next_window_bounds_map
.insert(window_id, anchor_new_windows_from_closed_position);
// Clear the next window bounds if they were set - we don't want to
// start from the last closed position after a new window has been
// created.
self.next_window_bounds = None;
self.windows.insert(window_id, Window::default());
let root_handle = self.add_typed_action_tui_view(window_id, build_root_view);
let root_view_id = root_handle.id();
self.windows
.get_mut(&window_id)
.expect("this window was just inserted and should still exist")
.root_view = Some((&root_handle).into());
self.focus(window_id, root_view_id);
(window_id, root_handle)
}
/// Renders the given TUI view to a [`TuiElement`](crate::elements::tui::TuiElement),
/// tracking any `Tracked` reads as rendering dependencies.
pub fn render_tui_view(
&self,
window_id: WindowId,
view_id: EntityId,
) -> Result<Box<dyn crate::elements::tui::TuiElement>> {
let window = self
.windows
.get(&window_id)
.ok_or_else(|| anyhow!("window not found"))?;
match window.views.get(&view_id) {
Some(StoredView::Tui(view)) => {
Ok(autotracking::render_view(window_id, view_id, || {
view.render(self)
}))
}
Some(StoredView::Gui(_)) => Err(anyhow!("view is not a TUI view")),
None => Err(anyhow!("view not found")),
}
}
}
@@ -1,12 +1,10 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use super::*;
use crate::{
elements::Empty, platform::WindowStyle, App, AppContext, Element, Entity, ModelHandle,
TypedActionView, View,
};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use crate::elements::Empty;
use crate::platform::WindowStyle;
use crate::{App, AppContext, Element, Entity, ModelHandle, TypedActionView, View};
#[derive(Default)]
struct Model {
@@ -0,0 +1,291 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use super::*;
use crate::elements::Empty;
use crate::platform::WindowStyle;
use crate::{App, AppContext, Element, Entity, ModelHandle, TypedActionView, View};
#[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);
});
}
@@ -100,18 +100,20 @@
mod tracked;
#[cfg(test)]
#[path = "autotracking_test.rs"]
#[path = "autotracking_tests.rs"]
mod tests;
use std::cell::UnsafeCell;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::mem;
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;
use super::{EntityId, EntityIdSet, WindowId};
/// Internal state cache used for autotracking changes
///
/// Rendering dependencies are stored in two maps, one from `TrackedId` -> Set of `View`s that
@@ -206,7 +208,7 @@ pub(super) fn windows_with_invalidations() -> Vec<WindowId> {
/// system.
///
/// Note: This will clear the cache of invalidations for this window.
pub(super) fn take_invalidations_for_window(window_id: WindowId) -> HashSet<EntityId> {
pub(super) fn take_invalidations_for_window(window_id: WindowId) -> EntityIdSet {
with_cache(|cache| {
let (matching, remainder) = mem::take(&mut cache.invalidations)
.into_iter()
@@ -1,8 +1,9 @@
use super::{track_read, track_update};
use std::marker::PhantomData;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{track_read, track_update};
/// An autotracked value type
///
/// This implements `Deref` and `DerefMut` for the underlying type `T`, so in most cases can be
+7
View File
@@ -1,6 +1,7 @@
use core::fmt;
use std::sync::atomic::{AtomicUsize, Ordering};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use crate::ModelHandle;
@@ -12,6 +13,12 @@ use crate::ModelHandle;
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct EntityId(usize);
/// A hash map keyed by [`EntityId`].
pub type EntityIdMap<V> = FxHashMap<EntityId, V>;
/// A hash set of [`EntityId`]s.
pub type EntityIdSet = FxHashSet<EntityId>;
impl EntityId {
/// Constructs a new globally-unique entity ID.
#[allow(clippy::new_without_default)]
+44 -30
View File
@@ -6,34 +6,32 @@ mod model;
mod view;
mod window;
use std::any::{Any, TypeId};
use std::collections::HashSet;
use std::fmt::{self, Debug};
use std::hash::Hash;
use std::mem;
use std::path::Path;
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
pub use action::*;
use anyhow::Error;
pub use app::*;
pub use autotracking::Tracked;
use derivative::Derivative;
pub use entity::*;
use futures_util::future::BoxFuture;
pub use model::*;
use pathfinder_geometry::rect::RectF;
use serde::{Deserialize, Serialize};
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},
};
use crate::{keymap, Element};
/// A unique identifier for a display.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -96,7 +94,7 @@ impl fmt::Display for DisplayIdx {
}
/// Information to display the IME editor near the active cursor.
#[derive(Debug)]
#[derive(Debug, PartialEq)]
pub struct CursorInfo {
/// Position of the active cursor.
pub position: RectF,
@@ -134,10 +132,9 @@ impl fmt::Display for TaskId {
pub type OptionalPlatformWindow = Option<Rc<dyn platform::Window>>;
type ActionCallback =
dyn FnMut(&mut dyn AnyView, &dyn Any, &mut AppContext, WindowId, EntityId) -> bool;
dyn FnMut(&mut dyn Any, &dyn Any, &mut AppContext, WindowId, EntityId) -> bool;
type TypedActionCallback =
dyn FnMut(&mut dyn AnyView, &dyn Any, &mut AppContext, WindowId, EntityId);
type TypedActionCallback = dyn FnMut(&mut dyn Any, &dyn Any, &mut AppContext, WindowId, EntityId);
type GlobalActionCallback =
dyn FnMut(&dyn Any, &'static std::panic::Location<'static>, &mut AppContext);
@@ -195,8 +192,8 @@ pub(crate) type SpawnedFuture = BoxFuture<'static, ()>;
#[derive(Debug, Default, Clone)]
pub struct WindowInvalidation {
pub updated: HashSet<EntityId>,
pub removed: HashSet<EntityId>,
pub updated: EntityIdSet,
pub removed: EntityIdSet,
/// 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
@@ -266,6 +263,7 @@ pub trait AnyView {
app: &mut AppContext,
view_id: EntityId,
);
fn child_view_ids(&self, app: &AppContext) -> Vec<EntityId>;
fn self_or_child_interacted_with(
&self,
app: &mut AppContext,
@@ -356,6 +354,10 @@ where
View::on_window_transferred(self, source_window_id, target_window_id, &mut ctx);
}
fn child_view_ids(&self, app: &AppContext) -> Vec<EntityId> {
View::child_view_ids(self, app)
}
fn keymap_context(&self, app: &AppContext) -> keymap::Context {
View::keymap_context(self, app)
}
@@ -394,13 +396,13 @@ pub enum EntityLocation {
#[derive(Default)]
struct RefCounts {
entity_counts: HashMap<EntityId, usize>,
entity_counts: EntityIdMap<usize>,
dropped: DroppedItems,
}
#[derive(Default)]
struct DroppedItems {
models: HashSet<EntityId>,
models: EntityIdSet,
views: HashSet<(WindowId, EntityId)>,
}
@@ -434,6 +436,14 @@ impl RefCounts {
}
}
fn is_model_dropped(&self, model_id: EntityId) -> bool {
self.dropped.models.contains(&model_id)
}
fn is_view_dropped(&self, view_id: EntityId) -> bool {
self.dropped.views.iter().any(|(_, id)| *id == view_id)
}
fn take_dropped(&mut self) -> DroppedItems {
mem::take(&mut self.dropped)
}
@@ -533,12 +543,12 @@ type ModelFromStreamItemCallback = dyn FnMut(&mut dyn Any, Box<dyn Any>, &mut Ap
type ModelFromStreamDoneCallback = dyn FnOnce(&mut dyn Any, &mut AppContext, EntityId);
type ViewFromFutureCallback =
dyn FnOnce(&mut dyn AnyView, Box<dyn Any>, &mut AppContext, WindowId, EntityId);
dyn FnOnce(&mut dyn Any, Box<dyn Any>, &mut AppContext, WindowId, EntityId);
type ViewFromStreamItemCallback =
dyn FnMut(&mut dyn AnyView, Box<dyn Any>, &mut AppContext, WindowId, EntityId);
dyn FnMut(&mut dyn Any, Box<dyn Any>, &mut AppContext, WindowId, EntityId);
type ViewFromStreamDoneCallback = dyn FnOnce(&mut dyn AnyView, &mut AppContext, WindowId, EntityId);
type ViewFromStreamDoneCallback = dyn FnOnce(&mut dyn Any, &mut AppContext, WindowId, EntityId);
enum TaskCallback {
ModelFromFuture {
@@ -662,5 +672,9 @@ impl<T> RequestState<T> {
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
#[cfg(all(test, feature = "tui"))]
#[path = "tui_view_tests.rs"]
mod tui_view_tests;
+24 -25
View File
@@ -1,26 +1,26 @@
use std::{
cell::RefCell,
pin::Pin,
rc::Rc,
sync::atomic::AtomicBool,
sync::Arc,
task::{Context, Poll},
};
use std::cell::RefCell;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::task::{Context, Poll};
use crate::r#async::Timer;
use anyhow::Result;
use futures_util::{stream, Stream};
use parking_lot::Mutex;
use super::*;
use crate::{
elements::*,
keymap::{macros::*, Keystroke},
};
use crate::elements::*;
use crate::keymap::macros::*;
use crate::keymap::Keystroke;
use crate::r#async::Timer;
#[path = "transfer_view_tests.rs"]
mod transfer_view_tests;
#[path = "ref_count_tests.rs"]
mod ref_count_tests;
#[test]
fn test_subscribe_and_emit_from_model() {
#[derive(Default)]
@@ -40,10 +40,10 @@ fn test_subscribe_and_emit_from_model() {
let handle_2b = handle_2.clone();
handle_1.update(app, |_, c| {
c.subscribe_to_model(&handle_2, move |model: &mut Model, event, c| {
c.subscribe_to_model(&handle_2, move |model: &mut Model, _, event, c| {
model.events.push(*event);
c.subscribe_to_model(&handle_2b, |model, event, _| {
c.subscribe_to_model(&handle_2b, |model, _, event, _| {
model.events.push(*event * 2);
});
});
@@ -255,7 +255,7 @@ fn test_subscribe_to_view_from_model() {
let model_handle = app.add_model(|ctx| {
let model = Model::default();
ctx.subscribe_to_view(&view_handle, |model: &mut Model, event, _ctx| {
ctx.subscribe_to_view(&view_handle, |model: &mut Model, _, event, _ctx| {
model.val = *event;
});
model
@@ -750,7 +750,7 @@ fn test_dropping_subscribers() {
ctx.subscribe_to_model(&observed_model, |_, _, _, _| {});
});
observing_model.update(app, |_, ctx| {
ctx.subscribe_to_model(&observed_model, |_, _, _| {});
ctx.subscribe_to_model(&observed_model, |_, _, _, _| {});
});
app.update(|_| {
@@ -2266,7 +2266,6 @@ fn test_editable_binding_getters() {
struct Action(#[allow(dead_code)] String);
App::test((), |mut app| async move {
use crate::keymap::macros::*;
app.update(|ctx| {
ctx.register_editable_bindings([
EditableBinding::new("a", "Action a", Action("a".into()))
@@ -2352,7 +2351,7 @@ fn test_unsubscribe_from_model_inside_callback() {
// Subscribe, and unsubscribe from inside the callback.
subscriber.update(&mut app, |_, ctx| {
let emitter_for_unsubscribe = emitter_clone.clone();
ctx.subscribe_to_model(&emitter_clone, move |model, event, ctx| {
ctx.subscribe_to_model(&emitter_clone, move |model, _, event, ctx| {
model.events.push(*event);
// Unsubscribe from inside the callback.
ctx.unsubscribe_from_model(&emitter_for_unsubscribe);
@@ -2410,13 +2409,13 @@ fn test_unsubscribe_from_model_inside_callback_with_multiple_subscriptions() {
subscriber.update(&mut app, |_, ctx| {
// First subscription: will call unsubscribe.
let emitter_for_unsubscribe = emitter_clone1.clone();
ctx.subscribe_to_model(&emitter_clone1, move |model, _, ctx| {
ctx.subscribe_to_model(&emitter_clone1, move |model, _, _, ctx| {
model.events.push("first");
ctx.unsubscribe_from_model(&emitter_for_unsubscribe);
});
// Second subscription: should still be called for this event.
ctx.subscribe_to_model(&emitter_clone2, move |model, _, _| {
ctx.subscribe_to_model(&emitter_clone2, move |model, _, _, _| {
model.events.push("second");
});
});
@@ -2470,12 +2469,12 @@ fn test_unsubscribe_then_resubscribe_from_model_inside_callback_keeps_new_subscr
let emitter_for_unsubscribe = emitter_clone.clone();
let emitter_for_resubscribe = emitter_clone.clone();
ctx.subscribe_to_model(&emitter_clone, move |model, _, ctx| {
ctx.subscribe_to_model(&emitter_clone, move |model, _, _, ctx| {
model.events.push("old");
ctx.unsubscribe_from_model(&emitter_for_unsubscribe);
ctx.subscribe_to_model(&emitter_for_resubscribe, |model, _, _| {
ctx.subscribe_to_model(&emitter_for_resubscribe, |model, _, _, _| {
model.events.push("new");
});
});
@@ -2528,10 +2527,10 @@ fn test_subscribe_then_unsubscribe_from_model_inside_callback_drops_new_subscrip
let emitter_for_subscribe = emitter_clone.clone();
let emitter_for_unsubscribe = emitter_clone.clone();
ctx.subscribe_to_model(&emitter_clone, move |model, _, ctx| {
ctx.subscribe_to_model(&emitter_clone, move |model, _, _, ctx| {
model.events.push("old");
ctx.subscribe_to_model(&emitter_for_subscribe, |model, _, _| {
ctx.subscribe_to_model(&emitter_for_subscribe, |model, _, _, _| {
model.events.push("new");
});
File diff suppressed because it is too large Load Diff
+44 -30
View File
@@ -1,23 +1,21 @@
use std::{any::Any, future::Future, marker::PhantomData, sync::Arc};
use std::any::Any;
use std::future::Future;
use std::marker::PhantomData;
use std::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 futures::stream::{AbortHandle, Abortable};
use futures::FutureExt;
use thiserror::Error;
use crate::accessibility::AccessibilityContent;
use crate::core::{Observation, Subscription, SubscriptionKey, TaskCallback};
use crate::r#async::{executor, SpawnableOutput, SpawnedFutureHandle, SpawnedLocalStream, Timer};
use crate::windowing::WindowManager;
use crate::{
accessibility::AccessibilityContent,
core::{Observation, Subscription, SubscriptionKey, TaskCallback},
r#async::{executor, SpawnedFutureHandle, SpawnedLocalStream},
AppContext, Effect, Entity, EntityId, GetSingletonModelHandle, ModelAsRef, ModelHandle,
RequestState, RetryOption, UpdateModel,
ReadModel, ReadView, RequestState, RetryOption, UpdateModel, UpdateView, View, ViewAsRef,
ViewContext, ViewHandle, WeakModelHandle,
};
/// Error returned when a model has been dropped, and so references to it are invalid.
@@ -69,8 +67,17 @@ impl<'a, T: Entity> ModelContext<'a, T> {
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>),
F: 'static + FnMut(&mut T, ModelHandle<S>, &S::Event, &mut ModelContext<T>),
{
// Self-subscriptions are disallowed: `emit_event` temporarily removes the subscriber model
// from `app.models` before invoking callbacks, so `emitter_handle.upgrade` would return
// `None` and silently drop the callback.
debug_assert_ne!(
handle.id(),
self.model_id,
"a model must not subscribe to its own events"
);
let emitter_handle = handle.downgrade();
self.app
.subscriptions
.entry(handle.id())
@@ -78,11 +85,13 @@ impl<'a, T: Entity> ModelContext<'a, T> {
.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);
if let Some(emitter_handle) = emitter_handle.upgrade(app) {
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, emitter_handle, payload, &mut ctx);
}
}),
});
}
@@ -132,8 +141,9 @@ impl<'a, T: Entity> ModelContext<'a, T> {
where
V: View,
V::Event: 'static,
F: 'static + FnMut(&mut T, &V::Event, &mut ModelContext<T>),
F: 'static + FnMut(&mut T, ViewHandle<V>, &V::Event, &mut ModelContext<T>),
{
let emitter_handle = handle.downgrade();
self.app
.subscriptions
.entry(handle.id())
@@ -141,10 +151,12 @@ impl<'a, T: Entity> ModelContext<'a, T> {
.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);
if let Some(emitter_handle) = emitter_handle.upgrade(app) {
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, emitter_handle, payload, &mut ctx);
}
}),
});
}
@@ -552,11 +564,11 @@ impl<T> std::ops::DerefMut for ModelContext<'_, T> {
}
impl<M> ViewAsRef for ModelContext<'_, M> {
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T {
fn view<T: 'static>(&self, handle: &ViewHandle<T>) -> &T {
self.app.view(handle)
}
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T> {
fn try_view<T: 'static>(&self, handle: &ViewHandle<T>) -> Option<&T> {
self.app.try_view(handle)
}
}
@@ -564,7 +576,7 @@ impl<M> ViewAsRef for ModelContext<'_, M> {
impl<M> ReadView for ModelContext<'_, M> {
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
where
T: View,
T: 'static,
F: FnOnce(&T, &AppContext) -> S,
{
self.app.read_view(handle, read)
@@ -574,7 +586,7 @@ impl<M> ReadView for ModelContext<'_, M> {
impl<M> UpdateView for ModelContext<'_, M> {
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
where
T: View,
T: Entity,
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
{
self.app.update_view(handle, update)
@@ -636,9 +648,11 @@ impl<M> ModelSpawner<M> {
work: impl FnOnce(&mut M, &mut ModelContext<M>) -> R + Send + 'static,
) -> Result<R, ModelDropped> {
let (tx, rx) = futures::channel::oneshot::channel();
let span = tracing::Span::current();
self.task_sender
.send(Box::new(move |me, ctx| {
let _guard = span.enter();
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.
@@ -652,5 +666,5 @@ impl<M> ModelSpawner<M> {
}
#[cfg(test)]
#[path = "context_test.rs"]
#[path = "context_tests.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));
})
}
+10 -9
View File
@@ -1,14 +1,13 @@
use std::{
any::{type_name, TypeId},
fmt::{self, Debug},
hash::{Hash, Hasher},
marker::PhantomData,
sync::{Arc, Weak},
};
use std::any::{type_name, TypeId};
use std::fmt::{self, Debug};
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
use std::sync::{Arc, Weak};
use parking_lot::Mutex;
use crate::{core::RefCounts, AppContext, Entity, EntityId, EntityLocation, Handle, ModelContext};
use crate::core::RefCounts;
use crate::{AppContext, Entity, EntityId, EntityLocation, Handle, ModelContext};
/// A strong reference to a particular [`Entity`] instance within the application.
///
@@ -219,7 +218,9 @@ impl<T: Entity> WeakModelHandle<T> {
}
pub fn upgrade(&self, app: &AppContext) -> Option<ModelHandle<T>> {
if app.models.contains_key(&self.model_id) {
if app.models.contains_key(&self.model_id)
&& !app.ref_counts.lock().is_model_dropped(self.model_id)
{
Some(ModelHandle::new(self.model_id, &app.ref_counts))
} else {
None
+2 -2
View File
@@ -3,10 +3,10 @@ pub mod handle;
use std::any::Any;
pub use self::context::*;
pub use self::handle::*;
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;
@@ -0,0 +1,178 @@
use super::*;
/// Test that a weak handle correctly fails to upgrade after the last strong
/// handle is dropped during event processing.
///
/// When the sole `ModelHandle` to a model is dropped inside an event callback,
/// the model should be cleaned up by `remove_dropped_items` and any
/// `WeakModelHandle::upgrade` attempted afterward must return `None`.
#[test]
fn test_weak_handle_fails_after_last_strong_handle_dropped_in_event_callback() {
/// The model whose events trigger the callback.
struct Trigger;
impl Entity for Trigger {
type Event = ();
}
/// The model that is being dropped and weak-upgraded.
struct Target;
impl Entity for Target {
type Event = ();
}
struct Subscriber {
target: Option<ModelHandle<Target>>,
target_weak: Option<WeakModelHandle<Target>>,
}
impl Entity for Subscriber {
type Event = ();
}
App::test((), |mut app| async move {
let trigger = app.add_model(|_| Trigger);
let subscriber = app.add_model(|_| Subscriber {
target: None,
target_weak: None,
});
// Create the target in a block so the test-scope handle is dropped,
// leaving the subscriber as the sole strong-handle holder.
{
let target = app.add_model(|_| Target);
let target_weak = target.downgrade();
subscriber.update(&mut app, |sub, ctx| {
sub.target = Some(target);
sub.target_weak = Some(target_weak);
// When the trigger fires, drop the only remaining strong
// handle to `target` and immediately try to upgrade the
// weak handle.
ctx.subscribe_to_model(&trigger, |sub: &mut Subscriber, _, _event, ctx| {
sub.target.take();
let upgrade_result = sub.target_weak.as_ref().unwrap().upgrade(ctx);
assert!(
upgrade_result.is_none(),
"weak upgrade should return None immediately after \
the last strong handle is dropped"
);
});
});
}
// Fire the trigger.
trigger.update(&mut app, |_, ctx| ctx.emit(()));
// After effect processing, the target should have been removed from
// the store. The weak handle must fail to upgrade.
subscriber.read(&app, |sub, ctx| {
let upgrade_result = sub.target_weak.as_ref().unwrap().upgrade(ctx);
assert!(
upgrade_result.is_none(),
"weak upgrade should return None after last strong handle was dropped"
);
});
});
}
/// Same as the model test above, but for views: a weak view handle must fail
/// to upgrade after the last strong `ViewHandle` is dropped during event
/// processing.
#[test]
fn test_weak_view_handle_fails_after_last_strong_handle_dropped_in_event_callback() {
use crate::elements::Empty;
use crate::platform::WindowStyle;
struct TargetView;
impl Entity for TargetView {
type Event = ();
}
impl super::super::View for TargetView {
fn render(&self, _: &AppContext) -> Box<dyn crate::Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"TargetView"
}
}
impl super::super::TypedActionView for TargetView {
type Action = ();
}
/// A model that triggers the drop-then-upgrade sequence for a view handle.
struct Orchestrator {
target_view: Option<ViewHandle<TargetView>>,
target_weak: Option<WeakViewHandle<TargetView>>,
}
impl Entity for Orchestrator {
type Event = ();
}
/// A separate model whose event triggers the orchestrator's callback.
struct Trigger;
impl Entity for Trigger {
type Event = ();
}
App::test((), |mut app| async move {
let orchestrator = app.add_model(|_| Orchestrator {
target_view: None,
target_weak: None,
});
let trigger = app.add_model(|_| Trigger);
// Create a window, then add the target view to it. Using add_view
// (not add_window) so the window doesn't hold a root-view handle —
// the orchestrator will be the sole strong-handle holder.
let (window_id, _root) = app.add_window(WindowStyle::NotStealFocus, |_| TargetView);
{
let target = app.add_view(window_id, |_| TargetView);
let target_weak = target.downgrade();
let trigger_clone = trigger.clone();
orchestrator.update(&mut app, |orch, ctx| {
orch.target_view = Some(target.clone());
orch.target_weak = Some(target_weak);
// When the trigger fires, drop the last strong ViewHandle
// and immediately try to upgrade the weak handle.
ctx.subscribe_to_model(
&trigger_clone,
|orch: &mut Orchestrator, _, _event, ctx| {
orch.target_view.take();
let upgrade_result = orch.target_weak.as_ref().unwrap().upgrade(ctx);
assert!(
upgrade_result.is_none(),
"weak view upgrade should return None immediately \
after the last strong handle is dropped"
);
},
);
});
}
// Fire the trigger.
trigger.update(&mut app, |_, ctx| ctx.emit(()));
// Post-processing: the weak handle should still fail.
orchestrator.read(&app, |orch, ctx| {
let upgrade_result = orch.target_weak.as_ref().unwrap().upgrade(ctx);
assert!(
upgrade_result.is_none(),
"weak view upgrade should return None after effect processing"
);
});
});
}
@@ -1,4 +1,5 @@
use std::{cell::RefCell, rc::Rc};
use std::cell::RefCell;
use std::rc::Rc;
use super::*;
@@ -835,3 +836,165 @@ fn test_transfer_structural_children_does_not_move_unrelated_views() {
});
});
}
#[test]
fn test_transfer_view_tree_moves_child_view_ids_owned_views() {
// A view can own child views that are never rendered (e.g. held through a
// model, like a pane's navigation stack) and are not registered as
// structural children. Such views are invisible to both the render-time
// parent graph and the structural graph, so `transfer_view_tree_to_window`
// must consult `View::child_view_ids` to avoid orphaning them in the
// source window. Regression test for the "circular view reference" panic
// that occurred during cross-window tab drags when a `TerminalView` (owned
// via `PaneStack`) was left behind.
struct OwnerView {
owned: Vec<EntityId>,
}
impl Entity for OwnerView {
type Event = ();
}
impl View for OwnerView {
fn render(&self, _: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
fn ui_name() -> &'static str {
"OwnerView"
}
fn child_view_ids(&self, _app: &AppContext) -> Vec<EntityId> {
self.owned.clone()
}
}
impl TypedActionView for OwnerView {
type Action = ();
}
App::test((), |mut app| async move {
let (window_1_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| OwnerView {
owned: Vec::new(),
});
let (window_2_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| OwnerView {
owned: Vec::new(),
});
// Child created without a structural parent and never rendered, so it
// is reachable only through the parent's `child_view_ids`.
let owned_child = app.add_view(window_1_id, |_| OwnerView { owned: Vec::new() });
let child_id = owned_child.id();
let parent = app.add_view(window_1_id, |_| OwnerView {
owned: vec![child_id],
});
let parent_id = parent.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 owned only via child_view_ids should be transferred"
);
app.read(|ctx| {
assert!(
ctx.windows[&window_2_id].views.contains_key(&child_id),
"owned child should now live in window 2"
);
assert!(
!ctx.windows[&window_1_id].views.contains_key(&child_id),
"owned child should no longer be in window 1"
);
assert_eq!(
owned_child.window_id(ctx),
window_2_id,
"view_to_window should point the owned child at window 2"
);
});
});
}
#[test]
fn test_transfer_view_tree_reconciles_views_known_only_to_target_presenter() {
// Reproduces the cross-window tab "put-back" panic. The forward walk seeds
// its subtree from the *source* window's presenter, so a render-time
// descendant that was never laid out in the source (e.g. when the source
// is a freshly created preview window) is missing from the source parent
// map and would be left behind. When the subtree is moved back to a window
// it previously lived in, that window's presenter still references the
// child, so `transfer_view_tree_to_window` must re-walk from the target
// and reconcile the straggler instead of stranding it (which previously
// desynced `view_to_window` and triggered "circular view reference").
#[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 root = app.add_view(window_1_id, |_| TestView);
let root_id = root.id();
// A child that lives in window 1 but is reachable only through window
// 2's presenter parent map: not a structural child, not exposed via
// `child_view_ids`, and absent from window 1's presenter. This models
// a view that was laid out while the subtree previously lived in
// window 2.
let stranded = app.add_view(window_1_id, |_| TestView);
let stranded_id = stranded.id();
app.update(|ctx| {
ctx.record_view_parent(window_2_id, stranded_id, root_id);
});
let transferred =
app.update(|ctx| ctx.transfer_view_tree_to_window(root_id, window_1_id, window_2_id));
assert!(transferred.contains(&root_id), "root should be transferred");
assert!(
transferred.contains(&stranded_id),
"child reachable only via the target presenter should be reconciled"
);
app.read(|ctx| {
assert!(
ctx.windows[&window_2_id].views.contains_key(&stranded_id),
"stranded child should now live in window 2"
);
assert!(
!ctx.windows[&window_1_id].views.contains_key(&stranded_id),
"stranded child should no longer be in window 1"
);
assert_eq!(
stranded.window_id(ctx),
window_2_id,
"view_to_window should point the stranded child at window 2"
);
});
});
}
@@ -0,0 +1,283 @@
//! Tests proving that a [`TuiView`] reuses the shared application core exactly
//! as a GUI `View` does: registration + focus/blur hook dispatch, handle
//! update/read, typed-action dispatch through the shared responder chain,
//! model subscription, drop/ref-count cleanup, and TUI rendering — all running
//! additively alongside the full GUI test suite under `--features tui`.
use super::*;
use crate::elements::tui::{
TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiRect, TuiSize,
};
use crate::platform::WindowStyle;
/// A GUI root view hosting TUI views: under the additive design, GUI and TUI
/// views coexist in the same window registry.
#[derive(Default)]
struct RootView {
pings: usize,
}
impl Entity for RootView {
type Event = ();
}
impl View for RootView {
fn ui_name() -> &'static str {
"RootView"
}
fn render(&self, _: &AppContext) -> Box<dyn crate::elements::Element> {
crate::elements::Empty::new().finish()
}
}
#[derive(Debug)]
struct RootPing;
impl TypedActionView for RootView {
type Action = RootPing;
fn handle_action(&mut self, _action: &RootPing, _ctx: &mut ViewContext<Self>) {
self.pings += 1;
}
}
/// A minimal `TuiView` carrying a counter and focus/blur hook recorders.
#[derive(Default)]
struct CounterView {
count: usize,
focus_events: usize,
blur_events: usize,
}
impl Entity for CounterView {
type Event = ();
}
impl TuiView for CounterView {
fn ui_name() -> &'static str {
"CounterView"
}
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
Box::new(TuiEmpty)
}
fn on_focus(&mut self, focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.focus_events += 1;
}
}
fn on_blur(&mut self, blur_ctx: &BlurContext, _ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
self.blur_events += 1;
}
}
}
#[test]
fn test_add_focus_and_hook_dispatch() {
App::test((), |mut app| async move {
let (window_id, root) = app.add_window(WindowStyle::NotStealFocus, |_| RootView::default());
let tui = app.update(|ctx| ctx.add_tui_view(window_id, |_| CounterView::default()));
let name = app.read(|ctx| ctx.view_name(window_id, tui.id()).map(str::to_owned));
assert_eq!(name.as_deref(), Some("CounterView"));
// Focus the TUI view: the shared focus effect must dispatch its
// on_focus hook through the unified ViewContext.
tui.update(&mut app, |_, ctx| ctx.focus_self());
assert_eq!(app.focused_view_id(window_id), Some(tui.id()));
assert!(app.read(|ctx| tui.is_focused(ctx)));
assert_eq!(tui.read(&app, |view, _| view.focus_events), 1);
assert_eq!(tui.read(&app, |view, _| view.blur_events), 0);
// Refocus the GUI root: the TUI view's on_blur hook fires.
root.update(&mut app, |_, ctx| ctx.focus_self());
assert_eq!(app.focused_view_id(window_id), Some(root.id()));
assert_eq!(tui.read(&app, |view, _| view.blur_events), 1);
});
}
#[test]
fn test_update_and_read_via_handle() {
App::test((), |mut app| async move {
let (window_id, _root) =
app.add_window(WindowStyle::NotStealFocus, |_| RootView::default());
let tui = app.update(|ctx| ctx.add_tui_view(window_id, |_| CounterView::default()));
tui.update(&mut app, |view, _| view.count = 41);
tui.update(&mut app, |view, _| view.count += 1);
assert_eq!(tui.read(&app, |view, _| view.count), 42);
});
}
#[test]
fn test_render_tui_view() {
App::test((), |mut app| async move {
let (window_id, root) = app.add_window(WindowStyle::NotStealFocus, |_| RootView::default());
let tui = app.update(|ctx| ctx.add_tui_view(window_id, |_| CounterView::default()));
// The TUI view renders through the TUI path and is rejected by the GUI path.
assert!(app.read(|ctx| ctx.render_tui_view(window_id, tui.id()).is_ok()));
assert!(app.read(|ctx| ctx.render_view(window_id, tui.id()).is_err()));
// And vice versa for the GUI root.
assert!(app.read(|ctx| ctx.render_view(window_id, root.id()).is_ok()));
assert!(app.read(|ctx| ctx.render_tui_view(window_id, root.id()).is_err()));
});
}
#[derive(Debug)]
struct Increment(usize);
#[derive(Default)]
struct ActionView {
total: usize,
}
impl Entity for ActionView {
type Event = ();
}
/// An empty element, useful as a placeholder render output.
#[derive(Default)]
pub struct TuiEmpty;
impl TuiElement for TuiEmpty {
fn layout(
&mut self,
_constraint: TuiConstraint,
_ctx: &mut TuiLayoutContext,
_app: &AppContext,
) -> TuiSize {
TuiSize::ZERO
}
fn render(&self, _area: TuiRect, _buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {}
}
impl TuiView for ActionView {
fn ui_name() -> &'static str {
"ActionView"
}
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
Box::new(TuiEmpty)
}
}
impl TypedActionView for ActionView {
type Action = Increment;
fn handle_action(&mut self, action: &Increment, _ctx: &mut ViewContext<Self>) {
self.total += action.0;
}
}
#[test]
fn test_typed_action_dispatch_through_shared_responder_chain() {
App::test((), |mut app| async move {
let (window_id, root) = app.add_window(WindowStyle::NotStealFocus, |_| RootView::default());
// Create the TUI view as a structural child of the GUI root, joining
// the shared view_parents hierarchy.
let tui = root.update(&mut app, |_, ctx| {
ctx.add_typed_action_tui_view(|_| ActionView::default())
});
// Dispatch typed actions from the TUI leaf: the responder chain is
// derived from the shared view hierarchy and the handler registered in
// the shared typed_actions registry runs on the TUI view.
app.update(|ctx| ctx.dispatch_typed_action_for_view(window_id, tui.id(), &Increment(5)));
app.update(|ctx| ctx.dispatch_typed_action_for_view(window_id, tui.id(), &Increment(3)));
assert_eq!(tui.read(&app, |view, _| view.total), 8);
// An action only the GUI root handles, dispatched from the TUI leaf,
// traverses the chain through the TUI view up to the GUI parent.
app.update(|ctx| ctx.dispatch_typed_action_for_view(window_id, tui.id(), &RootPing));
assert_eq!(root.read(&app, |view, _| view.pings), 1);
});
}
struct CounterModel {
value: usize,
}
impl Entity for CounterModel {
type Event = usize;
}
#[derive(Default)]
struct SubscriberView {
last_seen: usize,
model: Option<ModelHandle<CounterModel>>,
}
impl Entity for SubscriberView {
type Event = ();
}
impl TuiView for SubscriberView {
fn ui_name() -> &'static str {
"SubscriberView"
}
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
Box::new(TuiEmpty)
}
}
#[test]
fn test_model_subscription_from_tui_view() {
App::test((), |mut app| async move {
let (window_id, _root) =
app.add_window(WindowStyle::NotStealFocus, |_| RootView::default());
let tui = app.update(|ctx| {
ctx.add_tui_view(window_id, |vctx| {
let model = vctx.add_model(|_| CounterModel { value: 0 });
vctx.subscribe_to_model(&model, |view: &mut SubscriberView, _handle, event, _| {
view.last_seen = *event;
});
SubscriberView {
last_seen: 0,
model: Some(model),
}
})
});
let model = tui.read(&app, |view, _| view.model.clone().unwrap());
app.update(|ctx| {
ctx.update_model(&model, |model, mctx| {
model.value = 99;
mctx.emit(model.value);
});
});
assert_eq!(tui.read(&app, |view, _| view.last_seen), 99);
});
}
#[test]
fn test_drop_removes_tui_view() {
App::test((), |mut app| async move {
let (window_id, _root) =
app.add_window(WindowStyle::NotStealFocus, |_| RootView::default());
let tui = app.update(|ctx| ctx.add_tui_view(window_id, |_| CounterView::default()));
let view_id = tui.id();
assert!(app.read(|ctx| ctx.view_name(window_id, view_id).is_some()));
// Dropping the last strong handle removes the TUI view through the
// shared ref-count/remove_dropped_items path.
drop(tui);
app.update(|_| {});
assert!(app.read(|ctx| ctx.view_name(window_id, view_id).is_none()));
});
}
+55 -56
View File
@@ -1,35 +1,49 @@
use std::{any::Any, marker::PhantomData, rc::Rc, sync::Arc};
#[cfg(feature = "tui")]
mod tui;
use std::any::Any;
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
use futures::future::{AbortHandle, Abortable};
use futures::{Future, FutureExt};
use pathfinder_geometry::rect::RectF;
use thiserror::Error;
use super::handle::{AnyViewHandle, ReadView, UpdateView, ViewAsRef, ViewHandle, WeakViewHandle};
use super::{TypedActionView, View};
use crate::accessibility::AccessibilityContent;
use crate::core::{Observation, Subscription, SubscriptionKey, TaskCallback};
use crate::fonts::Cache as FontCache;
use crate::modals::{AlertDialogWithCallbacks, ModalButton, ViewModalCallback};
use crate::platform::{
file_picker::{FilePickerConfiguration, FilePickerError},
Cursor, SaveFilePickerConfiguration, TerminationMode,
};
use crate::r#async::SpawnableOutput;
use crate::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification};
use crate::platform::file_picker::{FilePickerConfiguration, FilePickerError};
use crate::platform::{Cursor, SaveFilePickerConfiguration, TerminationMode};
use crate::r#async::executor::{Background, Foreground};
use crate::r#async::{SpawnableOutput, SpawnedFutureHandle, SpawnedLocalStream};
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,
Action, AppContext, Effect, Entity, EntityId, GetSingletonModelHandle, ModelAsRef,
ModelContext, ModelHandle, ReadModel, UpdateModel, WindowId,
};
use crate::{GetSingletonModelHandle, ReadModel};
use super::{
handle::{AnyViewHandle, ReadView, UpdateView, ViewAsRef, ViewHandle, WeakViewHandle},
TypedActionView, View,
};
impl<'a, T: View> ViewContext<'a, T> {
/// The layout-position cache is only populated by the GUI presenter; in
/// TUI mode this method returns `None` because there is no presenter.
pub fn element_position_by_id<S>(&self, id: S) -> Option<pathfinder_geometry::rect::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
}
}
}
/// Structure that combines view identifiers and a handle to the application
/// context/application state.
@@ -40,7 +54,7 @@ pub struct ViewContext<'a, T: ?Sized> {
view_type: PhantomData<T>,
}
impl<'a, T: View> ViewContext<'a, T> {
impl<'a, T: Entity> ViewContext<'a, T> {
pub(in crate::core) fn new(
app: &'a mut AppContext,
window_id: WindowId,
@@ -102,21 +116,7 @@ impl<'a, T: View> ViewContext<'a, T> {
.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>) {
pub fn focus<S: Entity>(&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),
@@ -192,7 +192,7 @@ impl<'a, T: View> ViewContext<'a, T> {
pub fn subscribe_to_view<V, F>(&mut self, handle: &ViewHandle<V>, mut callback: F)
where
V: View,
V: Entity,
V::Event: 'static,
F: 'static + FnMut(&mut T, ViewHandle<V>, &V::Event, &mut ViewContext<T>),
{
@@ -518,7 +518,7 @@ impl<'a, T: View> ViewContext<'a, T> {
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 view = view.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));
@@ -663,13 +663,13 @@ impl<'a, T: View> ViewContext<'a, T> {
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 view = view.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 view = view.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);
}),
@@ -783,10 +783,7 @@ impl<'a, T: View> ViewContext<'a, T> {
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 view = view.downcast_mut().expect("unexpected view type");
let task: ViewTask<T> = *task
.downcast()
.expect("task from spawner should be ViewTask<T>");
@@ -832,9 +829,11 @@ impl<V> ViewSpawner<V> {
work: impl FnOnce(&mut V, &mut ViewContext<V>) -> R + Send + 'static,
) -> Result<R, ViewDropped> {
let (tx, rx) = futures::channel::oneshot::channel();
let span = tracing::Span::current();
self.task_sender
.send(Box::new(move |me, ctx| {
let _guard = span.enter();
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.
@@ -868,7 +867,7 @@ impl<V> ReadModel for ViewContext<'_, V> {
}
}
impl<V: View> UpdateModel for ViewContext<'_, V> {
impl<V: Entity> UpdateModel for ViewContext<'_, V> {
fn update_model<T, F, S>(&mut self, handle: &ModelHandle<T>, update: F) -> S
where
T: Entity,
@@ -878,42 +877,42 @@ impl<V: View> UpdateModel for ViewContext<'_, V> {
}
}
impl<V: View> ViewAsRef for ViewContext<'_, V> {
fn view<T: View>(&self, handle: &ViewHandle<T>) -> &T {
impl<V: Entity> ViewAsRef for ViewContext<'_, V> {
fn view<T: 'static>(&self, handle: &ViewHandle<T>) -> &T {
self.app.view(handle)
}
fn try_view<T: View>(&self, handle: &ViewHandle<T>) -> Option<&T> {
fn try_view<T: 'static>(&self, handle: &ViewHandle<T>) -> Option<&T> {
self.app.try_view(handle)
}
}
impl<V: View> UpdateView for ViewContext<'_, V> {
impl<V: Entity> UpdateView for ViewContext<'_, V> {
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
where
T: View,
T: Entity,
F: FnOnce(&mut T, &mut ViewContext<T>) -> S,
{
self.app.update_view(handle, update)
}
}
impl<V: View> ReadView for ViewContext<'_, V> {
impl<V: Entity> ReadView for ViewContext<'_, V> {
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
where
T: View,
T: 'static,
F: FnOnce(&T, &AppContext) -> S,
{
self.app.read_view(handle, read)
}
}
impl<V: View> GetSingletonModelHandle for ViewContext<'_, V> {
impl<V: Entity> 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"]
#[path = "context_tests.rs"]
mod tests;
@@ -0,0 +1,27 @@
//! TUI-backend extensions to [`ViewContext`].
use super::{TypedActionView, ViewContext, ViewHandle};
use crate::Entity;
impl<'a, T: Entity> ViewContext<'a, T> {
/// The TUI counterpart of [`Self::add_view`].
pub fn add_tui_view<S, F>(&mut self, build_view: F) -> ViewHandle<S>
where
S: crate::TuiView,
F: FnOnce(&mut ViewContext<S>) -> S,
{
self.app.add_tui_view(self.window_id, build_view)
}
/// The TUI counterpart of [`Self::add_typed_action_view`]: the new view is
/// recorded as a structural child of this context's view, so it joins the
/// shared responder chain.
pub fn add_typed_action_tui_view<V, F>(&mut self, build_view: F) -> ViewHandle<V>
where
V: TypedActionView + crate::TuiView,
F: FnOnce(&mut ViewContext<V>) -> V,
{
self.app
.add_typed_action_tui_view_with_parent(self.window_id, build_view, self.view_id)
}
}
@@ -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);
});
});
}
+46 -34
View File
@@ -1,15 +1,13 @@
use std::{
any::TypeId,
fmt::{self, Debug},
marker::PhantomData,
sync::{Arc, Weak},
};
use std::any::TypeId;
use std::fmt::{self, Debug};
use std::marker::PhantomData;
use std::sync::{Arc, Weak};
use parking_lot::Mutex;
use crate::{core::RefCounts, AppContext, EntityId, WindowId};
use super::{context::ViewContext, View};
use super::context::ViewContext;
use crate::core::RefCounts;
use crate::{AppContext, Entity, EntityId, WindowId};
/// A strong reference to a particular [`View`] instance within the application.
///
@@ -25,7 +23,7 @@ pub struct ViewHandle<T> {
ref_counts: Weak<Mutex<RefCounts>>,
}
impl<T: View> ViewHandle<T> {
impl<T: Entity> ViewHandle<T> {
pub(in crate::core) fn new(
window_id: WindowId,
view_id: EntityId,
@@ -44,22 +42,6 @@ impl<T: View> ViewHandle<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)
@@ -104,6 +86,27 @@ impl<T: View> ViewHandle<T> {
}
}
// These accessors don't depend on `T`, so they live in an unbounded impl block:
// this keeps them callable from the read path, where handles are only known to
// be `T: 'static` rather than `T: Entity`.
impl<T> ViewHandle<T> {
/// 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
}
}
impl<T> Clone for ViewHandle<T> {
fn clone(&self) -> Self {
if let Some(ref_counts) = self.ref_counts.upgrade() {
@@ -178,7 +181,7 @@ impl AnyViewHandle {
TypeId::of::<T>() == self.view_type
}
pub fn downcast<T: View>(self) -> Option<ViewHandle<T>> {
pub fn downcast<T: Entity>(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));
@@ -203,7 +206,7 @@ impl Clone for AnyViewHandle {
}
}
impl<T: View> From<&ViewHandle<T>> for AnyViewHandle {
impl<T: Entity> 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);
@@ -217,7 +220,7 @@ impl<T: View> From<&ViewHandle<T>> for AnyViewHandle {
}
}
impl<T: View> From<ViewHandle<T>> for AnyViewHandle {
impl<T: Entity> From<ViewHandle<T>> for AnyViewHandle {
fn from(handle: ViewHandle<T>) -> Self {
(&handle).into()
}
@@ -241,7 +244,7 @@ pub struct WeakViewHandle<T> {
view_type: PhantomData<T>,
}
impl<T: View> WeakViewHandle<T> {
impl<T: Entity> WeakViewHandle<T> {
pub(super) fn new(view_id: EntityId) -> Self {
Self {
view_id,
@@ -258,6 +261,7 @@ impl<T: View> WeakViewHandle<T> {
.get(&window_id)
.and_then(|w| w.views.get(&self.view_id))
.is_some()
&& !app.ref_counts.lock().is_view_dropped(self.view_id)
{
Some(ViewHandle::new(window_id, self.view_id, &app.ref_counts))
} else {
@@ -296,23 +300,31 @@ 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;
// Reading a view only needs `T: 'static` to downcast and hand out `&T`; the
// view-ness is already carried by `ViewHandle<T>`, which is only mintable for
// view types, so we don't need the stronger `Entity` bound here.
fn view<T: 'static>(&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>;
fn try_view<T: 'static>(&self, handle: &ViewHandle<T>) -> Option<&T>;
}
pub trait ReadView: ViewAsRef {
// As with `ViewAsRef`, a read closure only ever sees `&T` and `&AppContext`,
// so `T: 'static` is all that is required.
fn read_view<T, F, S>(&self, handle: &ViewHandle<T>, read: F) -> S
where
T: View,
T: 'static,
F: FnOnce(&T, &AppContext) -> S;
}
pub trait UpdateView: ReadView {
// `update_view` keeps the `Entity` bound because it hands the closure a
// `&mut ViewContext<T>`, which exposes `emit(T::Event)` and therefore needs
// `T: Entity`.
fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
where
T: View,
T: Entity,
F: FnOnce(&mut T, &mut ViewContext<T>) -> S;
}
+30 -8
View File
@@ -1,14 +1,15 @@
mod context;
mod handle;
#[cfg(feature = "tui")]
mod tui;
use crate::{
accessibility::{AccessibilityContent, ActionAccessibilityContent},
keymap, Action, AppContext, CursorInfo, Element, Entity,
};
pub use self::{context::*, handle::*};
pub use self::context::*;
pub use self::handle::*;
#[cfg(feature = "tui")]
pub use self::tui::*;
use super::EntityId;
use crate::accessibility::{AccessibilityContent, ActionAccessibilityContent};
use crate::{keymap, Action, AppContext, CursorInfo, Element, Entity};
pub enum FocusContext {
SelfFocused,
@@ -107,6 +108,27 @@ pub trait View: Entity {
) {
}
/// Returns the ids of child views this view directly owns via
/// [`ViewHandle`]s (including views held indirectly through models the
/// view owns), regardless of whether they are currently being rendered.
///
/// This is the authoritative ownership graph used when transferring a
/// view subtree between windows (see
/// [`AppContext::transfer_view_tree_to_window`]). The default render-time
/// parent/child graph only captures views that were laid out in the most
/// recent frame and explicit typed-action children; a view that owns
/// children through a model (e.g. a navigation stack) or that holds child
/// handles that are not always rendered must override this so those
/// children are not orphaned when the owning view moves to another
/// window.
///
/// Returned ids only need to be the view's *direct* children — the
/// transfer walk recurses, so each child's own `child_view_ids` (and its
/// structural/rendered descendants) are collected automatically.
fn child_view_ids(&self, _app: &AppContext) -> Vec<EntityId> {
Vec::new()
}
/// 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 {
@@ -154,7 +176,7 @@ pub trait TypedActionView {
///
/// ### 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()`).
/// If your Action enum has tons of actions, and only some of them are meaningful, you can use helper implementations from the `galaxyui_core::accessibility` (like `ActionAccessibilityContent::Default()` or `ActionAccessibility_content::from_debug()`).
fn action_accessibility_contents(
&mut self,
_action: &Self::Action,
+137
View File
@@ -0,0 +1,137 @@
//! The TUI view layer, additive behind the `tui` feature.
//!
//! [`TuiView`] is the TUI sibling of [`View`](super::View): it shares all of
//! the neutral entity machinery (entity IDs, ref counts, handles,
//! subscriptions/observations, focus, the responder chain, typed actions, and
//! the unified [`ViewContext`]) and differs only in its render output
//! ([`TuiElement`] instead of `Element`).
use std::any::Any;
use super::{BlurContext, FocusContext, ViewContext};
use crate::elements::tui::TuiElement;
use crate::{keymap, AppContext, Entity, EntityId, WindowId};
/// An interactive, renderable TUI component. The TUI counterpart of
/// [`View`](crate::View); registered with
/// [`AppContext::add_tui_view`](crate::AppContext::add_tui_view) or
/// [`AppContext::add_typed_action_tui_view`](crate::AppContext::add_typed_action_tui_view).
pub trait TuiView: Entity {
/// Returns a unique name for this implementation of TuiView.
fn ui_name() -> &'static str;
/// Produces the [`TuiElement`] representation of this view.
///
/// Terminal resizes are handled through the layout pass, not a dedicated
/// hook: the presenter lays out against the current terminal size every
/// frame, and each [`TuiElement::layout`] receives the [`AppContext`], so
/// width-dependent state (e.g. a char-cell editor's terminal width) is
/// refreshed there.
fn render(&self, app: &AppContext) -> Box<dyn TuiElement>;
/// Handles the view or its descendent receiving focus.
fn on_focus(&mut self, _focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) {}
/// Handles the view or its descendent losing focus.
fn on_blur(&mut self, _blur_ctx: &BlurContext, _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
}
/// Returns the ids of child views this view directly owns via
/// [`ViewHandle`]s that are not registered in the structural parent/child
/// graph, regardless of whether they are currently being rendered.
///
/// See [`View::child_view_ids`](crate::View::child_view_ids) for the full
/// contract. The semantics are identical for TUI views.
fn child_view_ids(&self, _app: &AppContext) -> Vec<EntityId> {
Vec::new()
}
}
/// The object-safe, type-erased TUI view object stored per window: the TUI
/// counterpart of [`AnyView`](crate::AnyView), with hook signatures that match
/// it so the shared dispatch paths treat both uniformly.
pub trait AnyTuiView {
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 TuiElement>;
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 child_view_ids(&self, app: &AppContext) -> Vec<EntityId>;
}
impl<T> AnyTuiView for T
where
T: TuiView,
{
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(&self, app: &AppContext) -> Box<dyn TuiElement> {
TuiView::render(self, app)
}
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);
TuiView::on_focus(self, focus_ctx, &mut ctx);
}
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);
TuiView::on_blur(self, blur_ctx, &mut ctx);
}
fn keymap_context(&self, app: &AppContext) -> keymap::Context {
TuiView::keymap_context(self, app)
}
fn child_view_ids(&self, app: &AppContext) -> Vec<EntityId> {
TuiView::child_view_ids(self, app)
}
}
+161 -6
View File
@@ -1,12 +1,14 @@
use core::fmt;
use std::{
collections::HashMap,
sync::atomic::{AtomicUsize, Ordering},
};
use std::any::Any;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::{Deserialize, Serialize};
use crate::{core::view::AnyViewHandle, AnyView, EntityId};
#[cfg(feature = "tui")]
use crate::core::view::AnyTuiView;
use crate::core::view::AnyViewHandle;
use crate::core::{AnyView, BlurContext, FocusContext};
use crate::{keymap, AccessibilityData, AppContext, CursorInfo, EntityId, EntityIdMap};
/// A unique identifier for a window.
///
@@ -40,7 +42,7 @@ impl fmt::Display for WindowId {
#[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>>,
pub views: EntityIdMap<StoredView>,
/// A handle to the window's root view (top of the view hierarchy), if any.
pub root_view: Option<AnyViewHandle>,
@@ -48,3 +50,156 @@ pub(super) struct Window {
/// The ID of the currently focused view, if any.
pub focused_view: Option<EntityId>,
}
/// A type-erased view stored in a window's view registry.
///
/// GUI views ([`AnyView`]) and TUI views ([`AnyTuiView`], additive behind the
/// `tui` feature) share the same registry, keyed by the same [`EntityId`]s, so
/// all of the neutral machinery (handles + ref counts, focus, the responder
/// chain, typed actions, subscriptions/observations, drop/transfer) flows
/// through the same paths for both. The inherent methods below delegate the
/// neutral hook subset; render and the GUI-only hooks are matched exhaustively
/// at their call sites.
pub(crate) enum StoredView {
Gui(Box<dyn AnyView>),
#[cfg(feature = "tui")]
Tui(Box<dyn AnyTuiView>),
}
impl StoredView {
pub fn as_any(&self) -> &dyn Any {
match self {
StoredView::Gui(view) => view.as_any(),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.as_any(),
}
}
pub fn as_any_mut(&mut self) -> &mut dyn Any {
match self {
StoredView::Gui(view) => view.as_any_mut(),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.as_any_mut(),
}
}
pub fn ui_name(&self) -> &'static str {
match self {
StoredView::Gui(view) => view.ui_name(),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.ui_name(),
}
}
pub fn on_focus(
&mut self,
focus_ctx: &FocusContext,
app: &mut AppContext,
window_id: WindowId,
view_id: EntityId,
) {
match self {
StoredView::Gui(view) => view.on_focus(focus_ctx, app, window_id, view_id),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.on_focus(focus_ctx, app, window_id, view_id),
}
}
pub fn on_blur(
&mut self,
blur_ctx: &BlurContext,
app: &mut AppContext,
window_id: WindowId,
view_id: EntityId,
) {
match self {
StoredView::Gui(view) => view.on_blur(blur_ctx, app, window_id, view_id),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.on_blur(blur_ctx, app, window_id, view_id),
}
}
pub fn keymap_context(&self, app: &AppContext) -> keymap::Context {
match self {
StoredView::Gui(view) => view.keymap_context(app),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.keymap_context(app),
}
}
pub fn active_cursor_position(
&self,
app: &mut AppContext,
window_id: WindowId,
view_id: EntityId,
) -> Option<CursorInfo> {
match self {
StoredView::Gui(view) => view.active_cursor_position(app, window_id, view_id),
#[cfg(feature = "tui")]
StoredView::Tui(_) => None,
}
}
pub fn on_window_closed(
&mut self,
app: &mut AppContext,
window_id: WindowId,
view_id: EntityId,
) {
match self {
StoredView::Gui(view) => view.on_window_closed(app, window_id, view_id),
#[cfg(feature = "tui")]
StoredView::Tui(_) => {}
}
}
pub fn on_window_transferred(
&mut self,
source_window_id: WindowId,
target_window_id: WindowId,
app: &mut AppContext,
view_id: EntityId,
) {
match self {
StoredView::Gui(view) => {
view.on_window_transferred(source_window_id, target_window_id, app, view_id)
}
#[cfg(feature = "tui")]
StoredView::Tui(_) => {}
}
}
pub fn self_or_child_interacted_with(
&self,
app: &mut AppContext,
window_id: WindowId,
view_id: EntityId,
) {
match self {
StoredView::Gui(view) => view.self_or_child_interacted_with(app, window_id, view_id),
#[cfg(feature = "tui")]
StoredView::Tui(_) => {}
}
}
pub fn accessibility_data(
&self,
app: &mut AppContext,
window_id: WindowId,
view_id: EntityId,
) -> Option<AccessibilityData> {
match self {
StoredView::Gui(view) => view.accessibility_data(app, window_id, view_id),
#[cfg(feature = "tui")]
StoredView::Tui(_) => None,
}
}
pub fn child_view_ids(&self, app: &AppContext) -> Vec<EntityId> {
match self {
StoredView::Gui(view) => view.child_view_ids(app),
#[cfg(feature = "tui")]
StoredView::Tui(view) => view.child_view_ids(app),
}
}
}
+4 -6
View File
@@ -1,12 +1,10 @@
use std::collections::HashMap;
use super::view_tree_debug_view::ViewTreeDebugView;
use crate::elements::ChildView;
use crate::{
elements::ChildView, AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext,
AppContext, Element, Entity, EntityId, EntityIdMap, 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>,
@@ -19,7 +17,7 @@ impl TypedActionView for DebugRootView {
impl DebugRootView {
pub fn new(
target_window_id: WindowId,
view_parent_map: HashMap<EntityId, EntityId>,
view_parent_map: EntityIdMap<EntityId>,
root_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) -> Self {
@@ -1,21 +1,19 @@
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,
};
use crate::{
elements::{
Align, Container, Fill, Hoverable, MouseStateHandle, ScrollStateHandle, Scrollable,
ScrollableElement, ScrollbarWidth, Text, UniformList, UniformListState,
},
AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext, WeakViewHandle,
WindowId,
AppContext, Element, Entity, EntityId, EntityIdMap, 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>>,
view_children_map: &EntityIdMap<Vec<EntityId>>,
current_view_id: EntityId,
depth: usize,
view_list: &mut Vec<(EntityId, usize)>,
@@ -96,11 +94,11 @@ pub(super) struct ViewTreeDebugView {
impl ViewTreeDebugView {
pub fn new(
target_window_id: WindowId,
view_parent_map: HashMap<EntityId, EntityId>,
view_parent_map: EntityIdMap<EntityId>,
root_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) -> Self {
let mut view_children_map: HashMap<EntityId, Vec<EntityId>> = Default::default();
let mut view_children_map: EntityIdMap<Vec<EntityId>> = Default::default();
for (child, parent) in view_parent_map.into_iter() {
view_children_map.entry(parent).or_default().push(child);
}
+4 -5
View File
@@ -1,14 +1,13 @@
use crate::{
event::DispatchedEvent,
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SelectableElement, Selection, SelectionFragment, SizeConstraint,
};
use pathfinder_geometry::vector::{vec2f, Vector2F};
use crate::event::DispatchedEvent;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
pub struct Align {
child: Box<dyn Element>,
+6 -4
View File
@@ -1,11 +1,13 @@
use std::any::Any;
use pathfinder_geometry::vector::Vector2F;
use super::Point;
use crate::event::DispatchedEvent;
use crate::{
event::DispatchedEvent, AfterLayoutContext, AppContext, ClipBounds, Element, EventContext,
LayoutContext, PaintContext, SizeConstraint,
AfterLayoutContext, AppContext, ClipBounds, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
use std::any::Any;
/// Element that clips a child to its bounds
pub struct Clipped {
@@ -95,5 +97,5 @@ impl Element for Clipped {
}
#[cfg(test)]
#[path = "clipped_test.rs"]
#[path = "clipped_tests.rs"]
mod tests;
@@ -1,18 +1,20 @@
use parking_lot::Mutex;
use std::sync::Arc;
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use parking_lot::Mutex;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::new_scrollable::util::scroll_delta_for_axis;
use super::{
Axis, F32Ext, Fill, Point, ScrollData, ScrollStateHandle, Scrollable, ScrollableElement,
ScrollbarWidth, Selection, Vector2FExt,
};
use crate::event::DispatchedEvent;
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,
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -465,5 +467,5 @@ impl ScrollableElement for ClippedScrollable {
}
#[cfg(test)]
#[path = "clipped_scrollable_test.rs"]
#[path = "clipped_scrollable_tests.rs"]
mod tests;
@@ -1,15 +1,10 @@
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};
use crate::elements::{Axis, ConstrainedBox, Empty, Flex, ParentElement, SavePosition, Stack};
use crate::platform::WindowStyle;
use crate::units::IntoPixels;
use crate::{App, Element, Entity, EntityIdSet, Presenter, TypedActionView, WindowInvalidation};
macro_rules! assert_float_eq {
($lhs:expr, $rhs:expr) => {{
@@ -80,7 +75,7 @@ fn test_scroll_to_position() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
@@ -1,18 +1,18 @@
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 std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
rc::Rc,
use super::*;
use crate::elements::{
ChildAnchor, ConstrainedBox, DispatchEventResult, EventHandler, Hoverable, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Rect, Stack, ZIndex,
};
use crate::platform::WindowStyle;
use crate::{
App, AppContext, Entity, EntityIdSet, Event, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
@@ -202,7 +202,7 @@ fn test_clipped_element_click_handling() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
@@ -1,14 +1,13 @@
use crate::{
event::DispatchedEvent,
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SelectableElement, Selection, SelectionFragment, SizeConstraint,
};
use pathfinder_geometry::vector::Vector2F;
use crate::event::DispatchedEvent;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
pub struct ConstrainedBox {
child: Box<dyn Element>,
+35 -11
View File
@@ -1,19 +1,17 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::{
AfterLayoutContext, AppContext, DropShadow, Element, EventContext, Fill, LayoutContext, Margin,
Overdraw, Padding, PaintContext, Point, SelectableElement, Selection, SelectionFragment,
SizeConstraint,
};
use crate::event::DispatchedEvent;
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},
};
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
use crate::{ClipBounds, Gradient};
pub struct Container {
margin: Margin,
@@ -24,6 +22,7 @@ pub struct Container {
corner_radius: CornerRadius,
drop_shadow: Option<DropShadow>,
foreground_overlay: Option<Fill>,
foreground_border: Option<Border>,
child: Box<dyn Element>,
size: Option<Vector2F>,
origin: Option<Point>,
@@ -43,6 +42,7 @@ impl Container {
border: Border::default(),
corner_radius: CornerRadius::default(),
foreground_overlay: None,
foreground_border: None,
drop_shadow: None,
child,
size: None,
@@ -65,6 +65,14 @@ impl Container {
self
}
/// Draws a border on top of the container without reserving layout space
/// (unlike [`Self::with_border`], which insets the child by the border
/// width).
pub fn with_foreground_border(mut self, border: impl Into<Border>) -> Self {
self.foreground_border = Some(border.into());
self
}
pub fn with_margin_top(mut self, margin: f32) -> Self {
self.margin.top = margin;
self
@@ -331,6 +339,22 @@ impl Element for Container {
.with_corner_radius(self.corner_radius);
ctx.scene.stop_layer();
}
// Draw a foreground border on top of the container.
if let Some(foreground_border) = self.foreground_border {
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_border(foreground_border)
.with_corner_radius(self.corner_radius);
ctx.scene.stop_layer();
}
}
fn dispatch_event(
@@ -421,5 +445,5 @@ impl SelectableElement for Container {
}
#[cfg(test)]
#[path = "container_test.rs"]
#[path = "container_tests.rs"]
mod tests;
@@ -1,14 +1,15 @@
use super::*;
use crate::{
elements::{ConstrainedBox, DispatchEventResult, EventHandler, Rect, ZIndex},
platform::WindowStyle,
App, AppContext, Entity, Event, Presenter, TypedActionView, ViewContext, WindowInvalidation,
};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
rc::Rc,
use super::*;
use crate::elements::{ConstrainedBox, DispatchEventResult, EventHandler, Rect, ZIndex};
use crate::platform::WindowStyle;
use crate::{
App, AppContext, Entity, EntityIdSet, Event, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
@@ -79,7 +80,7 @@ fn test_container_element_overlay_click_handling() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
+4 -2
View File
@@ -1,13 +1,15 @@
//! Module containing the definition of [`DebugElement`].
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
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 {
+26 -7
View File
@@ -1,9 +1,11 @@
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::Point;
use crate::event::DispatchedEvent;
use crate::{
event::DispatchedEvent, AfterLayoutContext, AppContext, ClipBounds, Element, Event,
EventContext, LayoutContext, PaintContext, SizeConstraint,
AfterLayoutContext, AppContext, ClipBounds, Element, Event, EventContext, LayoutContext,
PaintContext, SizeConstraint,
};
type DismissCallback = Box<dyn FnMut(&mut EventContext, &AppContext)>;
@@ -91,21 +93,38 @@ impl Element for Dismiss {
let z_index = self.z_index().unwrap();
match (
self.dismiss_handler.as_mut(),
event.at_z_index(z_index, ctx),
) {
let event_at_z_index = event.at_z_index(z_index, ctx);
match (self.dismiss_handler.as_mut(), event_at_z_index) {
// 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);
return self.prevent_interaction_with_other_elements;
}
(None, Some(Event::LeftMouseDown { .. })) => {
log::warn!("Dismiss underlay was clicked but no handler was set!");
return self.prevent_interaction_with_other_elements;
}
_ => {}
};
if self.prevent_interaction_with_other_elements
&& event_at_z_index.is_some()
&& matches!(
event.raw_event(),
Event::ScrollWheel { .. }
| Event::LeftMouseUp { .. }
| Event::LeftMouseDragged { .. }
| Event::MiddleMouseDown { .. }
| Event::RightMouseDown { .. }
| Event::BackMouseDown { .. }
| Event::ForwardMouseDown { .. }
| Event::MouseMoved { .. }
)
{
return true;
}
false
}
@@ -2,23 +2,21 @@ 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};
use crate::elements::{DropTargetData, Point};
use crate::event::{DispatchedEvent, Event};
use crate::platform::Cursor;
use crate::presenter::PositionCache;
use crate::scene::{ClipBounds, ZIndex};
use crate::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
/// The default drag threshold used when no value is explicitly set by the creator
const DEFAULT_DRAG_THRESHOLD: f32 = 5.;
@@ -65,6 +63,14 @@ impl DraggableState {
}
}
/// Returns the actual mouse position while dragging an element.
pub fn dragging_mouse_position(&self) -> Option<Vector2F> {
match self.read() {
DragState::Dragging { mouse_position, .. } => Some(mouse_position),
DragState::None | DragState::WaitingToDrag { .. } => None,
}
}
pub fn adjust_mouse_position(&self, delta: Vector2F) {
let mut guard = self.inner.lock();
if let DragState::Dragging { mouse_position, .. } = &mut *guard {
@@ -253,6 +259,8 @@ pub struct Draggable {
bounds_cache: Option<RectF>,
/// If true, keeps the original element visible in its original position during drag.
keep_original_visible: bool,
/// Defers to a nested `Draggable` that already claimed the mouse-down.
defer_to_handled_child_mouse_down: bool,
start_handler: Option<Handler>,
drag_handler: Option<DragDropHandler>,
@@ -275,6 +283,7 @@ impl Draggable {
drag_bounds: DragBounds::None,
bounds_cache: None,
keep_original_visible: false,
defer_to_handled_child_mouse_down: false,
start_handler: None,
drag_handler: None,
is_accepted_by_drop_target_handler: None,
@@ -317,6 +326,12 @@ impl Draggable {
self
}
/// Skips initiating this drag when a nested `Draggable` already claimed the mouse-down.
pub fn with_defer_to_handled_child_mouse_down(mut self) -> Self {
self.defer_to_handled_child_mouse_down = true;
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;
@@ -593,6 +608,9 @@ impl Element for Draggable {
match event.raw_event() {
Event::LeftMouseDown { position, .. } => {
if self.defer_to_handled_child_mouse_down && ctx.descendant_draggable_initiated() {
return true;
}
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");
@@ -607,6 +625,7 @@ impl Element for Draggable {
mouse_down_position: *position,
mouse_down_offset,
});
ctx.mark_descendant_draggable_initiated();
ctx.set_cursor(Cursor::PointingHand, max_z_index);
return true;
@@ -1,13 +1,14 @@
use crate::elements::Point;
use crate::event::DispatchedEvent;
use crate::{AppContext, Element, EventContext, LayoutContext, PaintContext, SizeConstraint};
use std::any::Any;
use std::fmt::Debug;
use std::sync::Arc;
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;
use crate::elements::Point;
use crate::event::DispatchedEvent;
use crate::{AppContext, Element, EventContext, LayoutContext, PaintContext, SizeConstraint};
/// Trait to identify data that is passed to a [`crate::elements::Draggable`] when dropped on
/// a [`DropTarget`].
@@ -1,11 +1,13 @@
use super::Point;
use super::ZIndex;
use pathfinder_geometry::vector::Vector2F;
use std::sync::{Arc, Mutex};
use pathfinder_geometry::vector::Vector2F;
use super::{Point, ZIndex};
use crate::event::DispatchedEvent;
use crate::platform::Cursor;
use crate::{
event::DispatchedEvent, platform::Cursor, AfterLayoutContext, AppContext, Element, Event,
EventContext, LayoutContext, PaintContext, SizeConstraint,
AfterLayoutContext, AppContext, Element, Event, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
/// Shared handle for drag-to-resize state, following the same `Arc<Mutex<_>>`
+2 -2
View File
@@ -1,10 +1,10 @@
use crate::event::DispatchedEvent;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use pathfinder_geometry::vector::Vector2F;
use crate::event::DispatchedEvent;
#[derive(Default)]
pub struct Empty {
@@ -1,15 +1,14 @@
use crate::{
event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState},
keymap::Keystroke,
platform::keyboard::KeyCode,
};
use std::cell::RefCell;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext,
LayoutContext, PaintContext, Point, SizeConstraint, ZIndex,
};
use pathfinder_geometry::vector::Vector2F;
use std::cell::RefCell;
use crate::event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState};
use crate::keymap::Keystroke;
use crate::platform::keyboard::KeyCode;
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult>;
type KeyHandler = Box<dyn FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult>;
@@ -368,5 +367,5 @@ impl Element for EventHandler {
}
#[cfg(test)]
#[path = "event_handler_test.rs"]
#[path = "event_handler_tests.rs"]
mod tests;
@@ -1,16 +1,17 @@
use super::*;
use crate::{
elements::{
ChildAnchor, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Rect, Stack,
},
platform::WindowStyle,
App, AppContext, Entity, EntityId, Presenter, TypedActionView, ViewContext, WindowInvalidation,
};
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use std::{
collections::{HashMap, HashSet},
rc::Rc,
use super::*;
use crate::elements::{
ChildAnchor, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Rect, Stack,
};
use crate::platform::WindowStyle;
use crate::{
App, AppContext, Entity, EntityId, EntityIdSet, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
@@ -160,7 +161,7 @@ fn test_layered_click_handling() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
@@ -243,7 +244,7 @@ fn test_default_mouse_in_behavior() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
@@ -331,7 +332,7 @@ fn test_mouse_in_behavior_dont_fire_on_synthetic_events() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
@@ -392,7 +393,7 @@ fn test_mouse_in_behavior_dont_fire_when_covered() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
@@ -545,7 +546,7 @@ fn invalidate_and_rebuild_scene(
root_view_id: EntityId,
ctx: &mut AppContext,
) {
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(root_view_id);
let invalidation = WindowInvalidation {
updated,
@@ -1,20 +1,19 @@
mod wrap;
pub use wrap::*;
use std::any::Any;
use crate::{
event::DispatchedEvent,
text::{word_boundaries::WordBoundariesPolicy, IsRect, SelectionDirection, SelectionType},
};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
pub use wrap::*;
use super::{
AfterLayoutContext, AppContext, Axis, AxisOrientation, Element, EventContext, LayoutContext,
PaintContext, Point, SelectableElement, Selection, SelectionFragment, SizeConstraint,
Vector2FExt,
};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use std::any::Any;
use crate::event::DispatchedEvent;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
pub struct Flex {
axis: Axis,
@@ -997,5 +996,5 @@ impl SelectableElement for Expanded {
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
@@ -1,14 +1,8 @@
use std::collections::HashSet;
use super::*;
use crate::elements::{Align, SavePosition, Stack};
use crate::elements::{Align, ConstrainedBox, ParentElement, Rect, SavePosition, Stack};
use crate::geometry::rect::RectF;
use crate::platform::WindowStyle;
use crate::{
elements::{ConstrainedBox, ParentElement, Rect},
App, Entity, Presenter, TypedActionView, WindowId, WindowInvalidation,
};
use crate::{App, Entity, EntityIdSet, Presenter, TypedActionView, WindowId, WindowInvalidation};
type RenderFn = dyn Fn(&AppContext) -> Box<dyn Element> + 'static;
@@ -159,7 +153,7 @@ fn test_flex_main_axis_alignment() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).expect("root view should exist"));
let invalidation = WindowInvalidation {
updated,
@@ -193,7 +187,7 @@ fn test_flex_main_axis_alignment() {
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.
// each other 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));
@@ -257,7 +251,7 @@ fn test_flex_main_axis_alignment() {
.get_position("flex")
.expect("position should exist");
// Ensure that the the elements are evenly spaced (with no extra space at the
// Ensure that 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));
@@ -289,7 +283,7 @@ fn test_flex_main_axis_alignment() {
.get_position("flex")
.expect("position should exist");
// Ensure that the the elements are evenly spaced, including space before and after
// Ensure that 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));
@@ -528,7 +522,7 @@ fn test_flex_cross_axis_alignment() {
let mut presenter = Presenter::new(window_id);
let mut updated = HashSet::new();
let mut updated = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).expect("root view should exist"));
let invalidation = WindowInvalidation {
updated,
+10 -19
View File
@@ -1,25 +1,16 @@
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};
use super::{
cross_axis_size, main_axis_size, size_along_axis, AppContext, Axis, CrossAxisAlignment,
Element, EventContext, LayoutContext, LayoutState, MainAxisSize, PaintContext, Point,
SizeConstraint, Vector2FExt,
};
use crate::elements::{AxisOrientation, MainAxisAlignment};
use crate::event::DispatchedEvent;
use crate::ClipBounds;
/// An element that positions its children in horizontal or vertical runs, leaving space in between
/// each run.
///
@@ -614,5 +605,5 @@ impl RunBuilder {
}
#[cfg(test)]
#[path = "wrap_test.rs"]
#[path = "wrap_tests.rs"]
mod tests;
@@ -1,18 +1,10 @@
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::*;
use crate::elements::{ConstrainedBox, Container, Empty, ParentElement, Rect};
use crate::platform::WindowStyle;
use crate::{App, Entity, TypedActionView, View, WindowId};
struct TestRootView {
parent_size: Vector2F,
@@ -1,44 +1,40 @@
use super::{Highlight, ListNumbering, Selection};
use crate::elements::{
ClickableCharRange, CornerRadius, Fill, HighlightedRange, HoverableCharRange, MouseStateHandle,
PartialClickableElement, Radius, SecretRange, SelectableElement, SelectionFragment,
SmartSelectFn, ZIndex, SELECTED_HIGHLIGHT_COLOR,
};
use crate::event::ModifiersState;
use crate::fonts::Weight;
use crate::geometry::rect::RectF;
use crate::platform::Cursor;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{
char_slice, count_chars_up_to_byte, BlockHeaderSize, IsRect, SelectionDirection, SelectionType,
TextBuffer,
};
use crate::text_layout::{ClipConfig, TextAlignment, DEFAULT_TOP_BOTTOM_RATIO};
use crate::Event;
use crate::{
elements::{Axis, Point},
event::DispatchedEvent,
fonts::{FamilyId, Properties, Style},
platform::LineStyle,
text_layout::{StyleAndFont, TextFrame, TextStyle},
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
use itertools::Itertools;
use markdown_parser::{Action, FormattedText, FormattedTextFragment, FormattedTextLine, Hyperlink};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use std::borrow::Cow;
use std::cell::RefCell;
use std::cmp::Reverse;
use std::default::Default;
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::Once;
use std::sync::{Arc, Mutex, Once};
use itertools::Itertools;
use markdown_parser::{Action, FormattedText, FormattedTextFragment, FormattedTextLine, Hyperlink};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use string_offset::{ByteOffset, CharOffset};
use vec1::vec1;
use super::{Highlight, ListNumbering, Selection};
use crate::elements::{
Axis, ClickableCharRange, CornerRadius, Fill, HighlightedRange, HoverableCharRange,
MouseStateHandle, PartialClickableElement, Point, Radius, SecretRange, SelectableElement,
SelectionFragment, SmartSelectFn, ZIndex, SELECTED_HIGHLIGHT_COLOR,
};
use crate::event::{DispatchedEvent, ModifiersState};
use crate::fonts::{FamilyId, Properties, Style, Weight};
use crate::geometry::rect::RectF;
use crate::platform::{Cursor, LineStyle};
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{
char_slice, count_chars_up_to_byte, BlockHeaderSize, IsRect, SelectionDirection, SelectionType,
TextBuffer,
};
use crate::text_layout::{
ClipConfig, StyleAndFont, TextAlignment, TextFrame, TextStyle, DEFAULT_TOP_BOTTOM_RATIO,
};
use crate::{
AfterLayoutContext, AppContext, Element, Event, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
#[derive(Debug, Clone, PartialEq)]
pub struct HeadingFontSizeMultipliers {
pub h1: f32,
@@ -1034,6 +1030,53 @@ impl FormattedTextElement {
handled
}
/// Returns `true` if `position` lands on a registered clickable range (e.g. a hyperlink),
/// *without* invoking the click handler. Used to report a single click as handled across the
/// whole down/up lifecycle: `handle_mouse_down` fires the click on press, and this lets the
/// matching release also be reported as handled so an enclosing `SelectableArea` does not run
/// its mouse-up selection handler (which dispatches a selection action that dismisses the link
/// tooltip the press just opened).
fn point_is_on_clickable_range(
&self,
position: Vector2F,
z_index: ZIndex,
ctx: &EventContext,
) -> bool {
if self.is_mouse_interaction_disabled
|| ctx.is_covered(Point::from_vec2f(position, z_index))
{
return false;
}
if !self
.bounds()
.is_some_and(|bounds| bounds.contains_point(position))
{
return false;
}
let Some(click_pos) =
self.position_for_point(position, SnappingPolicy::precise_char_range())
else {
return false;
};
let handlers = match self.laid_out_text.get(click_pos.frame_index) {
Some(
LaidOutTextFrame::Text { mouse_handlers, .. }
| LaidOutTextFrame::Indented { mouse_handlers, .. },
) => mouse_handlers,
_ => return false,
};
let handlers = handlers.borrow();
let glyph_offset = handlers.glyph_offset;
handlers.click_handlers.iter().any(|clickable_range| {
let handler_char_range = (clickable_range.char_range.start + glyph_offset.as_usize())
..(clickable_range.char_range.end + glyph_offset.as_usize());
handler_char_range.contains(&click_pos.glyph_index)
})
}
fn frame_index_to_line_index(&self, frame_index: usize) -> usize {
// Render text line-by-line.
let mut lines = self.formatted_text.lines.iter().enumerate().peekable();
@@ -2226,15 +2269,33 @@ impl Element for FormattedTextElement {
Some(Event::LeftMouseDown {
position,
modifiers,
click_count,
..
}) => {
self.handle_mouse_down(*position, z_index, modifiers, ctx, app);
false
// Only a single click can land on a clickable range (e.g. a hyperlink). When it
// does, report the event as handled so an enclosing `SelectableArea` does not also
// treat the press as the start of a text selection. Otherwise the selection path
// dispatches its selection handler on the same press, which clears the link
// tooltip/click that `handle_mouse_down` just triggered — making clicks on
// markdown links appear to do nothing. Multi-clicks fall through so word/line
// selection still works when it begins on top of a link.
if *click_count == 1 {
self.handle_mouse_down(*position, z_index, modifiers, ctx, app)
} else {
false
}
}
Some(Event::LeftMouseUp { position, .. }) => {
// Report the release of a click that lands on a link as handled too. The press is
// handled above; if the matching release is left unhandled, an enclosing
// `SelectableArea` runs its mouse-up selection handler on the same click, which
// dispatches a selection action that dismisses the link tooltip the press opened.
// A genuine drag selection bypasses the child on mouse-up, so this only affects
// plain clicks on a link.
self.point_is_on_clickable_range(*position, z_index, ctx)
}
_ => false,
}
// false
}
fn origin(&self) -> Option<Point> {
@@ -2360,18 +2421,13 @@ impl SelectableElement for FormattedTextElement {
..
} => {
let frame_bounds = frame.get_frame_bounds();
let inner_point = if matches!(direction, SelectionDirection::Backward) {
raw_text.word_starts_backward_from_offset_exclusive(CharOffset::from(
text_selection_bound.glyph_index,
))
} else {
raw_text.word_ends_from_offset_exclusive(CharOffset::from(
text_selection_bound.glyph_index,
))
}
.ok()?
.with_policy(word_boundaries_policy)
.next()?;
let inner_point = raw_text
.semantic_expansion_target(
CharOffset::from(text_selection_bound.glyph_index),
direction,
word_boundaries_policy,
)
.ok()?;
let offset = raw_text.to_offset(inner_point).ok()?.as_usize();
@@ -0,0 +1,192 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SelectableElement, Selection, SelectionFragment, SizeConstraint,
};
use crate::event::DispatchedEvent;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
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,101 @@
use std::any::Any;
use pathfinder_geometry::vector::Vector2F;
use super::Point;
use crate::event::DispatchedEvent;
use crate::{
AfterLayoutContext, AppContext, ClipBounds, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
/// 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_tests.rs"]
mod tests;
@@ -0,0 +1,471 @@
use std::sync::Arc;
use parking_lot::Mutex;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::new_scrollable::util::scroll_delta_for_axis;
use super::{
Axis, F32Ext, Fill, Point, ScrollData, ScrollStateHandle, Scrollable, ScrollableElement,
ScrollbarWidth, Selection, Vector2FExt,
};
use crate::event::DispatchedEvent;
use crate::scene::ClipBounds;
use crate::units::{IntoPixels, Pixels};
use crate::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
#[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_tests.rs"]
mod tests;
@@ -0,0 +1,158 @@
use pathfinder_geometry::vector::vec2f;
use super::{ClippedScrollStateHandle, ClippedScrollable, ScrollTarget, ScrollToPositionMode};
use crate::elements::{Axis, ConstrainedBox, Empty, Flex, ParentElement, SavePosition, Stack};
use crate::platform::WindowStyle;
use crate::units::IntoPixels;
use crate::{App, Element, Entity, EntityIdSet, Presenter, TypedActionView, WindowInvalidation};
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 = EntityIdSet::default();
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 std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use super::*;
use crate::elements::{
ChildAnchor, ConstrainedBox, DispatchEventResult, EventHandler, Hoverable, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Rect, Stack, ZIndex,
};
use crate::platform::WindowStyle;
use crate::{
App, AppContext, Entity, EntityIdSet, Event, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[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 = EntityIdSet::default();
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,168 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SelectableElement, Selection, SelectionFragment, SizeConstraint,
};
use crate::event::DispatchedEvent;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
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,449 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::{
AfterLayoutContext, AppContext, DropShadow, Element, EventContext, Fill, LayoutContext, Margin,
Overdraw, Padding, PaintContext, Point, SelectableElement, Selection, SelectionFragment,
SizeConstraint,
};
use crate::event::DispatchedEvent;
pub use crate::scene::{Border, CornerRadius, Radius};
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
use crate::{ClipBounds, Gradient};
pub struct Container {
margin: Margin,
padding: Padding,
overdraw: Overdraw,
background: Fill,
border: Border,
corner_radius: CornerRadius,
drop_shadow: Option<DropShadow>,
foreground_overlay: Option<Fill>,
foreground_border: Option<Border>,
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,
foreground_border: 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
}
/// Draws a border on top of the container without reserving layout space
/// (unlike [`Self::with_border`], which insets the child by the border
/// width).
pub fn with_foreground_border(mut self, border: impl Into<Border>) -> Self {
self.foreground_border = Some(border.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();
}
// Draw a foreground border on top of the container.
if let Some(foreground_border) = self.foreground_border {
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_border(foreground_border)
.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_tests.rs"]
mod tests;
@@ -0,0 +1,119 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use super::*;
use crate::elements::{ConstrainedBox, DispatchEventResult, EventHandler, Rect, ZIndex};
use crate::platform::WindowStyle;
use crate::{
App, AppContext, Entity, EntityIdSet, Event, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[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 = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.invalidate(invalidation, ctx);
let scene = presenter.build_scene(vec2f(100., 100.), 1., None, ctx);
assert_eq!(scene.z_index(), ZIndex::new(0));
assert_eq!(scene.layer_count(), 2);
let presenter = Rc::new(RefCell::new(presenter));
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(50., 50.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter,
);
});
view.read(app, |view, _| {
assert_eq!(
1,
*view
.mouse_downs
.get(&ElementIdentifier::BottomContainer)
.unwrap()
);
});
});
}
@@ -0,0 +1,124 @@
//! Module containing the definition of [`DebugElement`].
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use crate::event::DispatchedEvent;
use crate::scene::{Border, Dash};
/// 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,143 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::Point;
use crate::event::DispatchedEvent;
use crate::{
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();
let event_at_z_index = event.at_z_index(z_index, ctx);
match (self.dismiss_handler.as_mut(), event_at_z_index) {
// 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);
return self.prevent_interaction_with_other_elements;
}
(None, Some(Event::LeftMouseDown { .. })) => {
log::warn!("Dismiss underlay was clicked but no handler was set!");
return self.prevent_interaction_with_other_elements;
}
_ => {}
};
if self.prevent_interaction_with_other_elements
&& event_at_z_index.is_some()
&& matches!(
event.raw_event(),
Event::ScrollWheel { .. }
| Event::LeftMouseUp { .. }
| Event::LeftMouseDragged { .. }
| Event::MiddleMouseDown { .. }
| Event::RightMouseDown { .. }
| Event::BackMouseDown { .. }
| Event::ForwardMouseDown { .. }
| Event::MouseMoved { .. }
)
{
return true;
}
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,800 @@
use std::cmp::Ordering;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::Arc;
use itertools::Itertools;
use parking_lot::Mutex;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use crate::elements::{DropTargetData, Point};
use crate::event::{DispatchedEvent, Event};
use crate::platform::Cursor;
use crate::presenter::PositionCache;
use crate::scene::{ClipBounds, ZIndex};
use crate::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
/// 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),
}
}
/// Returns the actual mouse position while dragging an element.
pub fn dragging_mouse_position(&self) -> Option<Vector2F> {
match self.read() {
DragState::Dragging { mouse_position, .. } => Some(mouse_position),
DragState::None | DragState::WaitingToDrag { .. } => None,
}
}
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,
/// Defers to a nested `Draggable` that already claimed the mouse-down.
defer_to_handled_child_mouse_down: 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,
defer_to_handled_child_mouse_down: 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
}
/// Skips initiating this drag when a nested `Draggable` already claimed the mouse-down.
pub fn with_defer_to_handled_child_mouse_down(mut self) -> Self {
self.defer_to_handled_child_mouse_down = true;
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, .. } => {
if self.defer_to_handled_child_mouse_down && ctx.descendant_draggable_initiated() {
return true;
}
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.mark_descendant_draggable_initiated();
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,104 @@
use std::any::Any;
use std::fmt::Debug;
use std::sync::Arc;
use ordered_float::OrderedFloat;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use crate::elements::Point;
use crate::event::DispatchedEvent;
use crate::{AppContext, Element, EventContext, LayoutContext, PaintContext, SizeConstraint};
/// 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,184 @@
use std::sync::{Arc, Mutex};
use pathfinder_geometry::vector::Vector2F;
use super::{Point, ZIndex};
use crate::event::DispatchedEvent;
use crate::platform::Cursor;
use crate::{
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 pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use crate::event::DispatchedEvent;
#[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,371 @@
use std::cell::RefCell;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext,
LayoutContext, PaintContext, Point, SizeConstraint, ZIndex,
};
use crate::event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState};
use crate::keymap::Keystroke;
use crate::platform::keyboard::KeyCode;
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_tests.rs"]
mod tests;
@@ -0,0 +1,622 @@
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use super::*;
use crate::elements::{
ChildAnchor, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Rect, Stack,
};
use crate::platform::WindowStyle;
use crate::{
App, AppContext, Entity, EntityId, EntityIdSet, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[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 = EntityIdSet::default();
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 = EntityIdSet::default();
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 = EntityIdSet::default();
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 = EntityIdSet::default();
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 = EntityIdSet::default();
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,663 @@
use super::*;
use crate::elements::{Align, ConstrainedBox, ParentElement, Rect, SavePosition, Stack};
use crate::geometry::rect::RectF;
use crate::platform::WindowStyle;
use crate::{App, Entity, EntityIdSet, 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 = EntityIdSet::default();
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 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 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 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 = EntityIdSet::default();
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,609 @@
use ordered_float::OrderedFloat;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::{
cross_axis_size, main_axis_size, size_along_axis, AppContext, Axis, CrossAxisAlignment,
Element, EventContext, LayoutContext, LayoutState, MainAxisSize, PaintContext, Point,
SizeConstraint, Vector2FExt,
};
use crate::elements::{AxisOrientation, MainAxisAlignment};
use crate::event::DispatchedEvent;
use crate::ClipBounds;
/// 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_tests.rs"]
mod tests;
@@ -0,0 +1,803 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f;
use super::*;
use crate::elements::{ConstrainedBox, Container, Empty, ParentElement, Rect};
use crate::platform::WindowStyle;
use crate::{App, Entity, TypedActionView, View, WindowId};
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,498 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f;
use string_offset::ByteOffset;
use super::{
apply_secret_replacements, FormattedTextElement, FrameMouseHandlers,
HeadingFontSizeMultipliers, HighlightedHyperlink, HyperlinkSupport, LaidOutTextFrame,
SecretRange,
};
use crate::elements::{Element, PartialClickableElement, Point, SelectableElement, ZIndex};
use crate::event::DispatchedEvent;
use crate::fonts::FamilyId;
use crate::platform::WindowStyle;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{BlockHeaderSize, SelectionDirection, SelectionType};
use crate::text_layout::TextFrame;
use crate::{App, AppContext, Entity, Event, Presenter, TypedActionView};
#[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());
}
const TEST_GLYPH_ADVANCE: f32 = 10.0;
/// Builds a single-line, left-aligned [`FormattedTextElement`] at origin (0, 0) whose glyphs each
/// advance by [`TEST_GLYPH_ADVANCE`], so `expand_selection` results can be checked by x position.
fn positioned_text_element(text: &str) -> FormattedTextElement {
let text_frame = Arc::new(TextFrame::mock_with_positions(text, TEST_GLYPH_ADVANCE));
let frame_bounds = RectF::new(
vec2f(0.0, 0.0),
vec2f(text_frame.max_width(), text_frame.height()),
);
let mouse_handlers = Rc::new(RefCell::new(FrameMouseHandlers::default()));
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(text),
])]);
let mut element = FormattedTextElement::new(
formatted_text,
13.0,
FamilyId(0),
FamilyId(0),
ColorU::black(),
HighlightedHyperlink::default(),
);
element.origin = Some(Point::new(0.0, 0.0, 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
}
/// Drives `expand_selection` for a semantic selection whose tail is over the glyph at
/// `glyph_index`, returning the resulting x position. The element is laid out so x == glyph index
/// times [`TEST_GLYPH_ADVANCE`], so the caller can assert which character boundary it snapped to.
fn semantic_target_x(
element: &FormattedTextElement,
glyph_index: usize,
direction: SelectionDirection,
) -> f32 {
// Aim near the middle of the target glyph so the point snaps to it.
let point = vec2f(glyph_index as f32 * TEST_GLYPH_ADVANCE + 2.0, 5.0);
element
.expand_selection(
point,
direction,
SelectionType::Semantic,
&WordBoundariesPolicy::Default,
)
.expect("expand_selection should return a point")
.x()
}
#[test]
fn expand_selection_excludes_trailing_whitespace_after_punctuation() {
// "alpha, bravo": a0 l1 p2 h3 a4 ,5 <space>6 b7 r8 a9 v10 o11
let element = positioned_text_element("alpha, bravo");
// Forward drag with the tail on the comma ends just after the comma (x == 6 * advance),
// NOT including the following space (which would be x == 7 * advance). This is the
// reporter's case and matches the terminal block list.
assert_eq!(
semantic_target_x(&element, 5, SelectionDirection::Forward),
6.0 * TEST_GLYPH_ADVANCE,
);
// Forward drag with the tail on a word char selects the whole word "alpha" (end x == 5 * advance).
assert_eq!(
semantic_target_x(&element, 1, SelectionDirection::Forward),
5.0 * TEST_GLYPH_ADVANCE,
);
// Backward drag with the tail on a word char selects from the start of "bravo" (x == 7 * advance).
assert_eq!(
semantic_target_x(&element, 8, SelectionDirection::Backward),
7.0 * TEST_GLYPH_ADVANCE,
);
}
#[test]
fn expand_selection_stops_at_end_of_punctuation_run() {
// "foo... bar": f0 o1 o2 .3 .4 .5 <space>6 b7 a8 r9
let element = positioned_text_element("foo... bar");
// Tail on the last dot ends at x == 6 * advance ("foo..."), excluding the trailing space
// (which would be x == 7 * advance) and never reaching "bar".
assert_eq!(
semantic_target_x(&element, 5, SelectionDirection::Forward),
6.0 * TEST_GLYPH_ADVANCE,
);
}
#[derive(Default)]
struct LinkClickTestView;
impl Entity for LinkClickTestView {
type Event = ();
}
impl crate::core::View for LinkClickTestView {
fn ui_name() -> &'static str {
"formatted_text_link_click_test_view"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
FormattedTextElement::from_str("", FamilyId(0), 13.0).finish()
}
}
impl TypedActionView for LinkClickTestView {
type Action = ();
}
/// Builds a positioned single-line element whose chars `0..link_len` are registered as one
/// clickable range (mimicking a detected hyperlink). Each click that lands on that range
/// increments the returned counter.
fn element_with_clickable_link(
text: &str,
link_len: usize,
) -> (FormattedTextElement, Rc<RefCell<usize>>) {
let clicks = Rc::new(RefCell::new(0usize));
let clicks_for_handler = clicks.clone();
let handlers = FrameMouseHandlers::default().with_clickable_char_range(
0..link_len,
move |_modifiers, _ctx, _app| {
*clicks_for_handler.borrow_mut() += 1;
},
);
let text_frame = Arc::new(TextFrame::mock_with_positions(text, TEST_GLYPH_ADVANCE));
let frame_bounds = RectF::new(
vec2f(0.0, 0.0),
vec2f(text_frame.max_width(), text_frame.height()),
);
let mouse_handlers = Rc::new(RefCell::new(handlers));
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(text),
])]);
let mut element = FormattedTextElement::new(
formatted_text,
13.0,
FamilyId(0),
FamilyId(0),
ColorU::black(),
HighlightedHyperlink::default(),
);
element.origin = Some(Point::new(0.0, 0.0, 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, clicks)
}
fn single_left_click_at(x: f32) -> Event {
Event::LeftMouseDown {
position: vec2f(x, 5.0),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
}
}
fn left_mouse_up_at(x: f32) -> Event {
Event::LeftMouseUp {
position: vec2f(x, 5.0),
modifiers: Default::default(),
}
}
/// A single click on a hyperlink must be reported as handled by `dispatch_event` so an enclosing
/// `SelectableArea` does not also treat the press as the start of a text selection. When it
/// didn't (the regression), the selection path fired its selection handler on the same press and
/// cleared the link tooltip/click that `handle_mouse_down` had just triggered, making clicks on
/// markdown links in AI output appear to do nothing. A click that misses every clickable range
/// must still report unhandled so normal text selection keeps working.
#[test]
fn single_click_on_link_is_handled_so_selection_is_not_started() {
App::test((), |mut app| async move {
let app = &mut app;
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| LinkClickTestView);
let mut presenter = Presenter::new(window_id);
app.update(move |ctx| {
// "linktext": chars 0..4 ("link") are clickable; the rest are not.
let (mut element, clicks) = element_with_clickable_link("linktext", 4);
let mut event_ctx = presenter.mock_event_context(ctx.font_cache());
// Click squarely on the first glyph of the link.
let handled_on_link = element.dispatch_event(
&DispatchedEvent::from(single_left_click_at(TEST_GLYPH_ADVANCE / 2.0)),
&mut event_ctx,
ctx,
);
assert!(
handled_on_link,
"a single click on a link should be reported as handled"
);
assert_eq!(
*clicks.borrow(),
1,
"the link click handler should fire once"
);
// Click on a non-link glyph (index 6) within the same element.
let handled_off_link = element.dispatch_event(
&DispatchedEvent::from(single_left_click_at(
6.0 * TEST_GLYPH_ADVANCE + TEST_GLYPH_ADVANCE / 2.0,
)),
&mut event_ctx,
ctx,
);
assert!(
!handled_off_link,
"a click that misses every clickable range should not be reported as handled"
);
assert_eq!(
*clicks.borrow(),
1,
"clicking off the link should not fire the link handler"
);
// The matching mouse-up over the link must also be reported as handled so the
// enclosing SelectableArea does not run its mouse-up selection handler (which would
// dismiss the link tooltip the press opened). The release must not re-fire the click.
let up_on_link = element.dispatch_event(
&DispatchedEvent::from(left_mouse_up_at(TEST_GLYPH_ADVANCE / 2.0)),
&mut event_ctx,
ctx,
);
assert!(
up_on_link,
"a mouse-up over a link should be reported as handled"
);
assert_eq!(
*clicks.borrow(),
1,
"the mouse-up must not re-fire the link click handler"
);
// A mouse-up that misses every clickable range stays unhandled so selection finalizes.
let up_off_link = element.dispatch_event(
&DispatchedEvent::from(left_mouse_up_at(
6.0 * TEST_GLYPH_ADVANCE + TEST_GLYPH_ADVANCE / 2.0,
)),
&mut event_ctx,
ctx,
);
assert!(
!up_off_link,
"a mouse-up off any link should not be reported as handled"
);
});
});
}
@@ -0,0 +1,832 @@
use std::mem;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use instant::Instant;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{Point, SelectableElement, Selection, SelectionFragment, ZIndex};
use crate::event::{DispatchedEvent, ModifiersState};
use crate::platform::Cursor;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
use crate::{AfterLayoutContext, AppContext, Element, Event, EventContext, PaintContext, TaskId};
/// 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)>;
/// Click handler that additionally receives the keyboard modifiers held at
/// click time, captured from the `LeftMouseUp` event. Used by callers that
/// need to distinguish plain clicks from shift/cmd/ctrl/alt-clicks.
type ClickWithModifiersHandler =
Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F, ModifiersState)>;
/// Mouse-down handler that additionally receives the keyboard modifiers held
/// at press time, captured from the `LeftMouseDown` event.
type MouseDownWithModifiersHandler =
Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F, ModifiersState)>;
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>,
// Fires on the same mouse-down-then-mouse-up sequence as `click_handler`,
// but the callback also receives the keyboard modifiers held at click time.
click_with_modifiers_handler: Option<ClickWithModifiersHandler>,
mouse_down_handler: Option<ClickHandler>,
// Fires on `LeftMouseDown` and additionally receives the keyboard modifiers
// held at press time.
mouse_down_with_modifiers_handler: Option<MouseDownWithModifiersHandler>,
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,
click_with_modifiers_handler: None,
mouse_down_handler: None,
mouse_down_with_modifiers_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 when the mouse is released within the hoverable after it was pressed
/// within the hoverable. The callback also receives the keyboard modifiers
/// (e.g. `shift`, `cmd`) held at click time, so callers can branch on
/// modifier-clicks.
pub fn on_click_with_modifiers<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F, ModifiersState),
{
self.click_with_modifiers_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
}
/// Fires on `LeftMouseDown`, with the keyboard modifiers held at press time.
/// Use this when you need both press-based timing (snappy activation,
/// drag-implies-activate) and modifier-aware branching (e.g. shift/cmd-click
/// extending a selection).
pub fn on_mouse_down_with_modifiers<F>(mut self, callback: F) -> Self
where
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F, ModifiersState),
{
self.mouse_down_with_modifiers_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,
modifiers,
..
} => {
// Mouse-down sets the mouse state handle accordingly.
self.state().click_count = Some(*click_count);
// Fire the mouse-down-with-modifiers handler immediately if set.
if let Some(handler) = self.mouse_down_with_modifiers_handler.as_mut() {
handler(ctx, app, *position, *modifiers);
ctx.notify();
return true;
}
// 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()
|| self.click_with_modifiers_handler.is_some()
|| (*click_count == 2 && self.double_click_handler.is_some())
{
ctx.notify();
return true;
}
}
Event::LeftMouseUp {
position,
modifiers,
} => {
// 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;
} else if click_count.is_some() && self.click_with_modifiers_handler.is_some() {
let handler = self
.click_with_modifiers_handler
.as_mut()
.expect("handler should exist");
handler(ctx, app, *position, *modifiers);
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_tests.rs"]
mod tests;
@@ -0,0 +1,712 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use pathfinder_geometry::vector::vec2f;
use super::*;
use crate::elements::{
ChildAnchor, ConstrainedBox, DispatchEventResult, EventHandler, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Rect, Stack, Text,
};
use crate::fonts::FamilyId;
use crate::platform::WindowStyle;
use crate::r#async::Timer;
use crate::{
App, AppContext, Entity, EntityIdSet, Event, Presenter, TypedActionView, ViewContext,
WindowInvalidation,
};
#[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 = EntityIdSet::default();
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 = EntityIdSet::default();
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 = EntityIdSet::default();
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 = EntityIdSet::default();
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 = EntityIdSet::default();
updated.insert(app.root_view_id(window_id).unwrap());
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
app.update(move |ctx| {
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(100., 100.), 1., None, ctx);
// Move the mouse over the hoverable in the top-left corner.
// This should not immmediately trigger hover events because
// this hoverable has a 0.5s hover-out delay.
let event = mouse_moved_event(vec2f(90., 10.));
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
// The cursor should still update immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
// Move the mouse away from the hoverable in the top-left corner.
// Again, there's a hover-out delay so no hover events should be
// fired still.
let event = mouse_moved_event(vec2f(100., 100.));
ctx.simulate_window_event(event.clone(), window_id, presenter.clone());
ctx.set_last_mouse_move_event(window_id, event);
// The cursor should still update immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::Arrow);
// Move it back over the hoverable and wait.
let event = mouse_moved_event(vec2f(90., 10.));
ctx.simulate_window_event(event.clone(), window_id, presenter);
ctx.set_last_mouse_move_event(window_id, event);
// The cursor should still update immediately.
assert_eq!(ctx.get_cursor_shape(), Cursor::PointingHand);
});
view.read(app, |view, _| {
assert_eq!(
0,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
// After waiting, there should ultimately be just one hover-in event
// for the final mouse movement.
//
// The other hover-in and hover-out events should have been dropped
// due to the mouse moving in and out of the hoverable during the
// delay period.
Timer::after(Duration::from_millis(1000)).await;
view.read(app, |view, _| {
assert_eq!(
1,
view.num_hover_in_events(&ElementIdentifier::HoverableElementTopRight)
);
assert_eq!(
0,
view.num_hover_out_events(&ElementIdentifier::HoverableElementTopRight)
);
});
});
}
// Why would Elements that haven't been painted need to receive any mouse events?
// Shouldn't paint happen BEFORE any user interaction? Yes, but remember that Elements are
// disposable, and may get discarded and created anew between invalidations. Most of the time,
// Elements do get painted again immediately after creation. However, sometimes Elements can
// get scrolled out of view, e.g. outside a ClippedScrollable, and may not get painted. If the
// element, like an Editor, is still focused while out of view, it needs to respond to events
// without panicking. This test just never paints the Hoverable, and dispatches events on it.
#[test]
fn test_unpainted_hoverable_receives_click_events_without_panic() {
App::test((), |mut app| async move {
let app = &mut app;
app.update(init);
let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| View::default());
let mut presenter = Presenter::new(window_id);
let hover_state = MouseStateHandle::default();
let mut hoverable = Hoverable::new(hover_state, |_state| {
Text::new_inline("foobar", FamilyId(0), 10.0).finish()
});
app.update(move |ctx| {
// We can't use ctx.simulate_window_event for click events here because that would
// require us to paint the scene by calling presenter.build_scene. That's because
// that code path uses the sizes and origins of the elements to determine which
// elements to dispatch the click event on. Instead, we need to call the dispatch_event
// method on the Element directly. That requires us to mock the EventContext, which we
// can pluck from the Presenter like so:
let mut event_ctx = presenter.mock_event_context(ctx.font_cache());
let mouse_down = Event::LeftMouseDown {
position: vec2f(10., 90.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
};
hoverable.dispatch_event(&DispatchedEvent::from(mouse_down), &mut event_ctx, ctx);
let mouse_up = Event::LeftMouseUp {
position: vec2f(10., 90.),
modifiers: Default::default(),
};
hoverable.dispatch_event(&DispatchedEvent::from(mouse_up), &mut event_ctx, ctx);
});
});
}
@@ -0,0 +1,135 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{Element, Point};
use crate::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use crate::event::DispatchedEvent;
use crate::image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache};
use crate::{
AfterLayoutContext, AppContext, EventContext, LayoutContext, PaintContext, SingletonEntity,
SizeConstraint,
};
/// An element that renders a monochrome icon. This differs from `Svg` in that it sets the color dynamically
/// instead of statically from the SVG itself.
#[derive(Clone, Copy)]
pub struct Icon {
path: &'static str,
opacity: f32,
size: Option<Vector2F>,
origin: Option<Point>,
color: ColorU,
#[cfg(debug_assertions)]
/// Captures the location of the constructor call site. This is used for debugging purposes.
constructor_location: Option<&'static std::panic::Location<'static>>,
}
impl Icon {
#[cfg_attr(debug_assertions, track_caller)]
pub fn new(path: &'static str, color: impl Into<ColorU>) -> Self {
Self {
path,
opacity: 1.,
size: None,
color: color.into(),
origin: None,
#[cfg(debug_assertions)]
constructor_location: Some(std::panic::Location::caller()),
}
}
pub fn with_opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity;
self
}
pub fn with_color(mut self, color: impl Into<ColorU>) -> Self {
self.color = color.into();
self
}
}
impl Element for Icon {
fn layout(
&mut self,
constraint: SizeConstraint,
_: &mut LayoutContext,
_: &AppContext,
) -> Vector2F {
let size = constraint.max;
self.size = Some(size);
size
}
fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
let bounds = (self.size.unwrap() * ctx.scene.scale_factor()).to_i32();
// If the x or y bounds are less than or equal to 0, don't attempt to paint the icon.
if bounds.x() <= 0 || bounds.y() <= 0 {
return;
}
let asset_cache = AssetCache::as_ref(app);
match ImageCache::as_ref(app).image(
// Right now, the location of SVG files is hard-coded to be the app bundle. In the future,
// to make icons a fetch-able asset, we should modify the API of Icon to accept an AssetSource,
// exactly how Image does.
AssetSource::Bundled { path: self.path },
bounds,
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
CacheOption::BySize,
ctx.max_texture_dimension_2d,
asset_cache,
) {
AssetState::Loaded { data } => match data.as_ref() {
Image::Static(image) => {
let logical_image_size = image.size().to_f32() / ctx.scene.scale_factor();
let origin = origin + ((self.size().unwrap() - logical_image_size) / 2.0);
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
#[cfg(debug_assertions)]
ctx.scene
.set_location_for_panic_logging(self.constructor_location);
ctx.scene.draw_icon(
RectF::new(origin, logical_image_size),
image.clone(),
self.opacity,
self.color,
);
}
Image::Animated(_image) => {
log::info!("Animated icons are currently not supported");
}
},
AssetState::Loading { handle } => {
ctx.repaint_after_load(handle);
}
AssetState::Evicted => {
log::warn!("Unable to render svg because it was evicted");
}
AssetState::FailedToLoad(err) => {
log::warn!("Unable to render svg: {err:#}");
}
}
}
fn size(&self) -> Option<Vector2F> {
self.size
}
fn dispatch_event(
&mut self,
_: &DispatchedEvent,
_: &mut EventContext,
_: &AppContext,
) -> bool {
false
}
fn origin(&self) -> Option<Point> {
self.origin
}
}
@@ -0,0 +1,549 @@
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use std::time::Duration;
use instant::Instant;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F, Vector2I};
use super::{CornerRadius, Element, Point};
use crate::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use crate::event::DispatchedEvent;
pub use crate::image_cache::CacheOption;
use crate::image_cache::{AnimatedImage, AnimatedImageBehavior, FitType, ImageCache, StaticImage};
use crate::{
AfterLayoutContext, AppContext, EventContext, LayoutContext, PaintContext, SingletonEntity,
SizeConstraint,
};
lazy_static! {
static ref IMAGE_LOAD_TIMEOUT_STARTED_AT: Mutex<HashMap<u64, Instant>> =
Mutex::new(HashMap::new());
}
struct LoadTimeout {
timeout: Duration,
element: Box<dyn Element>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BackupElementKind {
BeforeLoad,
FailedToLoad,
LoadTimeout,
}
pub struct Image {
source: AssetSource,
opacity: f32,
size: Option<Vector2F>,
origin: Option<Point>,
fit_type: FitType,
/// If true, layout will produce a size based on the bounds at which the image will
/// be painted instead of simply using the incoming max constraint.
layout_using_paint_bounds: bool,
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>>,
failed_to_load_element: Option<Box<dyn Element>>,
load_timeout: Option<LoadTimeout>,
/// 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,
failed_to_load_element: None,
load_timeout: None,
requested_repaint_after_load: false,
layout_using_paint_bounds: 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
}
/// Uses the paint boundary of the image as the laid-out size.
///
/// By default, the [`Image`] element bounds will be the incoming max constraint.
/// For some cases, we'd prefer for the element's laid-out size to match the painted
/// image size.
pub fn layout_using_paint_bounds(mut self) -> Self {
self.layout_using_paint_bounds = 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
}
pub fn on_load_failure(mut self, element: Box<dyn Element>) -> Self {
self.failed_to_load_element = Some(element);
self
}
pub fn on_load_timeout(mut self, timeout: Duration, element: Box<dyn Element>) -> Self {
self.load_timeout = Some(LoadTimeout { timeout, element });
self
}
fn load_timeout_key(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.source.hash(&mut hasher);
hasher.finish()
}
fn load_started_at(&self, now: Instant) -> Instant {
*IMAGE_LOAD_TIMEOUT_STARTED_AT
.lock()
.entry(self.load_timeout_key())
.or_insert(now)
}
fn clear_load_timeout_started_at(&self) {
IMAGE_LOAD_TIMEOUT_STARTED_AT
.lock()
.remove(&self.load_timeout_key());
}
fn loading_backup_element_kind(
&mut self,
now: Instant,
) -> (Option<BackupElementKind>, Option<Duration>) {
let Some(load_timeout) = self.load_timeout.as_ref() else {
return (
self.before_load_element
.as_ref()
.map(|_| BackupElementKind::BeforeLoad),
None,
);
};
let started_at = self.load_started_at(now);
let elapsed = now.duration_since(started_at);
if elapsed >= load_timeout.timeout {
return (Some(BackupElementKind::LoadTimeout), None);
}
(
self.before_load_element
.as_ref()
.map(|_| BackupElementKind::BeforeLoad),
Some(load_timeout.timeout - elapsed),
)
}
fn failed_to_load_backup_element_kind(&self) -> Option<BackupElementKind> {
if self.failed_to_load_element.is_some() {
Some(BackupElementKind::FailedToLoad)
} else if self.before_load_element.is_some() {
Some(BackupElementKind::BeforeLoad)
} else {
None
}
}
fn paint_backup_element(
&mut self,
kind: BackupElementKind,
origin: Vector2F,
ctx: &mut PaintContext,
app: &AppContext,
) {
match kind {
BackupElementKind::BeforeLoad => {
if let Some(before_load_element) = self.before_load_element.as_mut() {
before_load_element.paint(origin, ctx, app);
}
}
BackupElementKind::FailedToLoad => {
if let Some(failed_to_load_element) = self.failed_to_load_element.as_mut() {
failed_to_load_element.paint(origin, ctx, app);
}
}
BackupElementKind::LoadTimeout => {
if let Some(load_timeout) = self.load_timeout.as_mut() {
load_timeout.element.paint(origin, ctx, app);
}
}
}
}
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 mut size = constraint.max;
if self.layout_using_paint_bounds {
let asset_cache = AssetCache::as_ref(app);
let image_size = ImageCache::as_ref(app).image_size(self.source.clone(), asset_cache);
if let Some(image_size) = image_size {
size = dimensions(image_size.to_f32(), size, self.fit_type);
}
}
self.size = Some(size);
if let Some(before_load_element) = self.before_load_element.as_mut() {
before_load_element.layout(constraint, ctx, app);
}
if let Some(failed_to_load_element) = self.failed_to_load_element.as_mut() {
failed_to_load_element.layout(constraint, ctx, app);
}
if let Some(load_timeout) = self.load_timeout.as_mut() {
load_timeout.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);
}
if let Some(failed_to_load_element) = self.failed_to_load_element.as_mut() {
failed_to_load_element.after_layout(ctx, app);
}
if let Some(load_timeout) = self.load_timeout.as_mut() {
load_timeout.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;
}
let (backup_element_kind, repaint_after) =
self.loading_backup_element_kind(Instant::now());
if let Some(repaint_after) = repaint_after {
ctx.repaint_after(repaint_after);
}
if let Some(kind) = backup_element_kind {
self.paint_backup_element(kind, origin, ctx, app);
}
}
AssetState::Evicted => {
self.clear_load_timeout_started_at();
if let Some(before_load_element) = self.before_load_element.as_mut() {
before_load_element.paint(origin, ctx, app);
}
}
AssetState::FailedToLoad(_) => {
self.clear_load_timeout_started_at();
if let Some(kind) = self.failed_to_load_backup_element_kind() {
self.paint_backup_element(kind, 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.clear_load_timeout_started_at();
self.before_load_element = None;
self.failed_to_load_element = None;
self.load_timeout = 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,120 @@
use super::*;
struct TestElement;
impl Element for TestElement {
fn layout(
&mut self,
constraint: SizeConstraint,
_: &mut LayoutContext,
_: &AppContext,
) -> Vector2F {
constraint.max
}
fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
fn paint(&mut self, _: Vector2F, _: &mut PaintContext, _: &AppContext) {}
fn size(&self) -> Option<Vector2F> {
None
}
fn origin(&self) -> Option<Point> {
None
}
fn dispatch_event(
&mut self,
_: &DispatchedEvent,
_: &mut EventContext,
_: &AppContext,
) -> bool {
false
}
}
fn test_element() -> Box<dyn Element> {
Box::new(TestElement)
}
fn test_image() -> Image {
Image::new(
AssetSource::Raw {
id: "test".to_string(),
},
CacheOption::BySize,
)
}
#[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());
}
#[test]
fn failed_to_load_prefers_failure_element_when_provided() {
let image = test_image()
.before_load(test_element())
.on_load_failure(test_element());
assert_eq!(
image.failed_to_load_backup_element_kind(),
Some(BackupElementKind::FailedToLoad)
);
}
#[test]
fn failed_to_load_falls_back_to_before_load_element() {
let image = test_image().before_load(test_element());
assert_eq!(
image.failed_to_load_backup_element_kind(),
Some(BackupElementKind::BeforeLoad)
);
}
#[test]
fn loading_image_switches_to_timeout_element_after_timeout() {
let mut image = test_image()
.before_load(test_element())
.on_load_timeout(Duration::from_secs(10), test_element());
image.clear_load_timeout_started_at();
let now = Instant::now();
let (initial_kind, initial_repaint_after) = image.loading_backup_element_kind(now);
assert_eq!(initial_kind, Some(BackupElementKind::BeforeLoad));
assert_eq!(initial_repaint_after, Some(Duration::from_secs(10)));
let (timed_out_kind, timed_out_repaint_after) =
image.loading_backup_element_kind(now + Duration::from_secs(11));
assert_eq!(timed_out_kind, Some(BackupElementKind::LoadTimeout));
assert_eq!(timed_out_repaint_after, None);
}
#[test]
fn loading_timeout_survives_image_rebuild_for_same_source() {
let mut image = test_image()
.before_load(test_element())
.on_load_timeout(Duration::from_secs(10), test_element());
image.clear_load_timeout_started_at();
let now = Instant::now();
let (initial_kind, _initial_repaint_after) = image.loading_backup_element_kind(now);
assert_eq!(initial_kind, Some(BackupElementKind::BeforeLoad));
let mut rebuilt_image = test_image()
.before_load(test_element())
.on_load_timeout(Duration::from_secs(10), test_element());
let (timed_out_kind, timed_out_repaint_after) =
rebuilt_image.loading_backup_element_kind(now + Duration::from_secs(11));
assert_eq!(timed_out_kind, Some(BackupElementKind::LoadTimeout));
assert_eq!(timed_out_repaint_after, None);
}
@@ -0,0 +1,191 @@
//! Utilities for editing lists.
use std::fmt;
use enum_iterator::Sequence;
#[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,82 @@
use std::time::Duration;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use crate::event::DispatchedEvent;
/// A wrapper element that triggers periodic repaints at a fixed interval.
///
/// Wrap any child element in `LiveElement` to ensure the view repaints on a
/// timer, which is useful for content that changes over time (e.g. an elapsed
/// duration counter). The repaint cycle is self-sustaining: each `paint` call
/// schedules the next repaint.
pub struct LiveElement {
child: Box<dyn Element>,
repaint_interval: Duration,
size: Option<Vector2F>,
origin: Option<Point>,
}
impl LiveElement {
pub fn new(child: Box<dyn Element>, repaint_interval: Duration) -> Self {
Self {
child,
repaint_interval,
size: None,
origin: None,
}
}
}
impl Element for LiveElement {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
let size = self.child.layout(constraint, ctx, app);
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);
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
ctx.repaint_after(self.repaint_interval);
}
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 as_selectable_element(&self) -> Option<&dyn super::SelectableElement> {
self.child.as_selectable_element()
}
#[cfg(any(test, feature = "test-util"))]
fn debug_text_content(&self) -> Option<String> {
self.child.debug_text_content()
}
}
@@ -0,0 +1,136 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SelectableElement, Selection, SelectionFragment, SizeConstraint,
};
use crate::event::DispatchedEvent;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
/// `MinSize` ensures that it takes up *at least* the minimum size constraint specified by its
/// parent. It's similar to [`super::Align`] but will not grow to fill the maximum space available.
pub struct MinSize {
child: Box<dyn Element>,
size: Option<Vector2F>,
}
impl MinSize {
pub fn new(child: Box<dyn Element>) -> Self {
Self { child, size: None }
}
}
impl Element for MinSize {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
let child_constraint = SizeConstraint::new(Vector2F::zero(), constraint.max);
let mut size = self.child.layout(child_constraint, ctx, app);
size.set_x(size.x().max(constraint.min.x()));
size.set_y(size.y().max(constraint.min.y()));
self.size = Some(size);
size
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.child.after_layout(ctx, app);
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
let self_center = self.size.expect("Size must be set during paint") / 2.0;
let child_center = self
.child
.size()
.expect("Child size must be set during paint")
/ 2.0;
let child_origin = origin - (child_center - self_center);
self.child.paint(child_origin, ctx, app);
}
fn dispatch_event(
&mut self,
event: &DispatchedEvent,
ctx: &mut EventContext,
app: &AppContext,
) -> bool {
self.child.dispatch_event(event, ctx, app)
}
fn size(&self) -> Option<Vector2F> {
self.size
}
fn origin(&self) -> Option<Point> {
self.child.origin()
}
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
Some(self as &dyn SelectableElement)
}
}
impl SelectableElement for MinSize {
fn get_selection(
&self,
selection_start: Vector2F,
selection_end: Vector2F,
is_rect: IsRect,
) -> Option<Vec<SelectionFragment>> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.get_selection(selection_start, selection_end, is_rect)
})
}
fn expand_selection(
&self,
point: Vector2F,
direction: SelectionDirection,
unit: SelectionType,
word_boundaries_policy: &WordBoundariesPolicy,
) -> Option<Vector2F> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
})
}
fn is_point_semantically_before(
&self,
absolute_point: Vector2F,
absolute_point_other: Vector2F,
) -> Option<bool> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
})
}
fn smart_select(
&self,
absolute_point: Vector2F,
smart_select_fn: crate::elements::SmartSelectFn,
) -> Option<(Vector2F, Vector2F)> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.smart_select(absolute_point, smart_select_fn)
})
}
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
self.child
.as_selectable_element()
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
.unwrap_or_default()
}
}
@@ -0,0 +1,799 @@
mod align;
mod child_view;
mod clipped;
mod clipped_scrollable;
mod constrained_box;
mod container;
#[cfg(debug_assertions)]
mod debug;
mod dismiss;
mod drag;
pub mod drag_resize;
mod empty;
mod event_handler;
mod flex;
mod formatted_text_element;
mod hoverable;
mod icon;
mod image;
mod list;
mod live;
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;
use core::fmt;
use std::any::Any;
use std::borrow::Cow;
use std::ops::Range;
use std::sync::MutexGuard;
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 live::*;
pub use min_size::*;
pub use new_scrollable::NewScrollable;
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
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::{DispatchedEvent, ModifiersState};
use crate::platform::Cursor;
pub use crate::scene::{Dash, ZIndex};
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
use crate::Gradient;
pub use crate::{
AfterLayoutContext, AppContext, Event, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
/// 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,909 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::util::{scroll_clipped_scrollable_handle_with_delta, scroll_delta_for_axis};
use super::{NewScrollableElement, SingleAxisConfig};
use crate::elements::new_scrollable::util::child_constraint_for_axis;
use crate::elements::new_scrollable::ScrollableAxis;
use crate::elements::{
Axis, ClippedScrollStateHandle, ScrollData, ScrollStateHandle, ScrollTarget, SelectableElement,
Vector2FExt,
};
use crate::event::DispatchedEvent;
use crate::units::{IntoPixels, Pixels};
use crate::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
/// 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);
}

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