Merge pull request #3059 from iced-rs/feature/test-recorder

First-Class End-to-End Testing — Test Recorder, Runtime Emulator, Program Presets, Ice Test Syntax, and Selector API
This commit is contained in:
Héctor 2025-09-23 02:41:49 +02:00 committed by GitHub
commit 0a34496c79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 6025 additions and 2039 deletions

491
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -42,11 +42,13 @@ markdown = ["iced_widget/markdown"]
# Enables lazy widgets
lazy = ["iced_widget/lazy"]
# Enables debug metrics in native platforms (press F12)
debug = ["iced_winit/debug", "iced_devtools"]
debug = ["iced_winit/debug", "dep:iced_devtools"]
# Enables time-travel debugging (very experimental!)
time-travel = ["debug", "iced_devtools/time-travel"]
# Enables hot reloading (very experimental!)
hot = ["debug", "iced_debug/hot"]
# Enables the tester developer tool for recording and playing tests (press F12)
tester = ["dep:iced_tester"]
# Enables the `thread-pool` futures executor as the `executor::Default` on native platforms
thread-pool = ["iced_futures/thread-pool"]
# Enables `tokio` as the `executor::Default` on native platforms
@ -63,6 +65,8 @@ crisp = ["iced_core/crisp", "iced_widget/crisp"]
webgl = ["iced_renderer/webgl"]
# Enables syntax highlighting
highlighter = ["iced_highlighter", "iced_widget/highlighter"]
# Enables the `widget::selector` module
selector = ["iced_runtime/selector"]
# Enables the advanced module
advanced = ["iced_core/advanced", "iced_widget/advanced"]
# Embeds Fira Sans into the final application; useful for testing and Wasm builds
@ -87,12 +91,14 @@ iced_futures.workspace = true
iced_renderer.workspace = true
iced_runtime.workspace = true
iced_widget.workspace = true
iced_winit.features = ["program"]
iced_winit.workspace = true
iced_devtools.workspace = true
iced_devtools.optional = true
iced_tester.workspace = true
iced_tester.optional = true
iced_highlighter.workspace = true
iced_highlighter.optional = true
@ -132,7 +138,9 @@ members = [
"program",
"renderer",
"runtime",
"selector",
"test",
"tester",
"tiny_skia",
"wgpu",
"widget",
@ -163,7 +171,9 @@ iced_highlighter = { version = "0.14.0-dev", path = "highlighter" }
iced_program = { version = "0.14.0-dev", path = "program" }
iced_renderer = { version = "0.14.0-dev", path = "renderer" }
iced_runtime = { version = "0.14.0-dev", path = "runtime" }
iced_selector = { version = "0.14.0-dev", path = "selector" }
iced_test = { version = "0.14.0-dev", path = "test" }
iced_tester = { version = "0.14.0-dev", path = "tester" }
iced_tiny_skia = { version = "0.14.0-dev", path = "tiny_skia" }
iced_wgpu = { version = "0.14.0-dev", path = "wgpu" }
iced_widget = { version = "0.14.0-dev", path = "widget" }
@ -188,6 +198,7 @@ log = "0.4"
lyon = "1.0"
lyon_path = "1.0"
mundy = { version = "0.2", default-features = false }
nom = "8"
num-traits = "0.2"
ouroboros = "0.18"
png = "0.18"
@ -195,6 +206,7 @@ pulldown-cmark = "0.12"
qrcode = { version = "0.13", default-features = false }
raw-window-handle = "0.6"
resvg = "0.42"
rfd = "0.15"
rustc-hash = "2.0"
semver = "1.0"
serde = "1.0"
@ -205,7 +217,7 @@ smol_str = "0.2"
softbuffer = "0.4"
syntect = "5.1"
sysinfo = "0.33"
thiserror = "1.0"
thiserror = "2"
tiny-skia = "0.11"
tokio = "1.0"
tracing = "0.1"
@ -221,7 +233,6 @@ winit = { git = "https://github.com/iced-rs/winit.git", rev = "05b8ff17a06562f0a
[workspace.lints.rust]
rust_2018_idioms = { level = "deny", priority = -1 }
missing_debug_implementations = "deny"
missing_docs = "deny"
unsafe_code = "deny"
unused_results = "deny"

View file

@ -20,7 +20,6 @@ use std::borrow::Borrow;
/// to turn it into an [`Element`].
///
/// [built-in widget]: crate::widget
#[allow(missing_debug_implementations)]
pub struct Element<'a, Message, Theme, Renderer> {
widget: Box<dyn Widget<Message, Theme, Renderer> + 'a>,
}

View file

@ -1,14 +0,0 @@
/// The hasher used to compare layouts.
#[allow(missing_debug_implementations)] // Doesn't really make sense to have debug on the hasher state anyways.
#[derive(Default)]
pub struct Hasher(rustc_hash::FxHasher);
impl core::hash::Hasher for Hasher {
fn write(&mut self, bytes: &[u8]) {
self.0.write(bytes);
}
fn finish(&self) -> u64 {
self.0.finish()
}
}

View file

@ -1,9 +1,11 @@
//! Display interactive elements on top of other widgets.
mod element;
mod group;
mod nested;
pub use element::Element;
pub use group::Group;
pub use nested::Nested;
use crate::layout;
use crate::mouse;

View file

@ -7,7 +7,6 @@ use crate::widget;
use crate::{Clipboard, Event, Layout, Shell, Size};
/// A generic [`Overlay`].
#[allow(missing_debug_implementations)]
pub struct Element<'a, Message, Theme, Renderer> {
overlay: Box<dyn Overlay<Message, Theme, Renderer> + 'a>,
}

View file

@ -7,7 +7,6 @@ use crate::{Clipboard, Event, Layout, Overlay, Shell, Size};
/// An [`Overlay`] container that displays multiple overlay [`overlay::Element`]
/// children.
#[allow(missing_debug_implementations)]
pub struct Group<'a, Message, Theme, Renderer> {
children: Vec<overlay::Element<'a, Message, Theme, Renderer>>,
}
@ -127,7 +126,7 @@ where
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
operation.container(None, layout.bounds(), &mut |operation| {
operation.traverse(&mut |operation| {
self.children.iter_mut().zip(layout.children()).for_each(
|(child, layout)| {
child.as_overlay_mut().operate(layout, renderer, operation);

View file

@ -1,13 +1,12 @@
use crate::core::event;
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget;
use crate::core::{Clipboard, Event, Layout, Shell, Size};
use crate::event;
use crate::layout;
use crate::mouse;
use crate::overlay;
use crate::renderer;
use crate::widget;
use crate::{Clipboard, Event, Layout, Shell, Size};
/// An overlay container that displays nested overlays
#[allow(missing_debug_implementations)]
pub struct Nested<'a, Message, Theme, Renderer> {
overlay: overlay::Element<'a, Message, Theme, Renderer>,
}

View file

@ -31,7 +31,6 @@ impl text::Renderer for () {
type Paragraph = ();
type Editor = ();
const MONOSPACE_FONT: Font = Font::MONOSPACE;
const ICON_FONT: Font = Font::DEFAULT;
const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0';

View file

@ -299,11 +299,6 @@ pub trait Renderer: crate::Renderer {
/// The [`Editor`] of this [`Renderer`].
type Editor: Editor<Font = Self::Font> + 'static;
/// A monospace font.
///
/// It may be used by devtools.
const MONOSPACE_FONT: Self::Font;
/// The icon font of the backend.
const ICON_FONT: Self::Font;

View file

@ -8,9 +8,9 @@ static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
pub struct Id(Internal);
impl Id {
/// Creates a custom [`Id`].
pub fn new(id: impl Into<borrow::Cow<'static, str>>) -> Self {
Self(Internal::Custom(id.into()))
/// Creates a new [`Id`] from a static `str`.
pub const fn new(id: &'static str) -> Self {
Self(Internal::Custom(borrow::Cow::Borrowed(id)))
}
/// Creates a unique [`Id`].
@ -29,6 +29,12 @@ impl From<&'static str> for Id {
}
}
impl From<String> for Id {
fn from(value: String) -> Self {
Self(Internal::Custom(borrow::Cow::Owned(value)))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Internal {
Unique(usize),

View file

@ -18,25 +18,16 @@ use std::sync::Arc;
/// A piece of logic that can traverse the widget tree of an application in
/// order to query or update some widget state.
pub trait Operation<T = ()>: Send {
/// Operates on a widget that contains other widgets.
/// Requests further traversal of the widget tree to keep operating.
///
/// The `operate_on_children` function can be called to return control to
/// the widget tree and keep traversing it.
fn container(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
);
/// The provided `operate` closure may be called by an [`Operation`]
/// to return control to the widget tree and keep traversing it. If
/// the closure is not called, the children of the widget asking for
/// traversal will be skipped.
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>));
/// Operates on a widget that can be focused.
fn focusable(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
_state: &mut dyn Focusable,
) {
}
/// Operates on a widget that contains other widgets.
fn container(&mut self, _id: Option<&Id>, _bounds: Rectangle) {}
/// Operates on a widget that can be scrolled.
fn scrollable(
@ -49,6 +40,15 @@ pub trait Operation<T = ()>: Send {
) {
}
/// Operates on a widget that can be focused.
fn focusable(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
_state: &mut dyn Focusable,
) {
}
/// Operates on a widget that has text input.
fn text_input(
&mut self,
@ -80,13 +80,12 @@ impl<T, O> Operation<O> for Box<T>
where
T: Operation<O> + ?Sized,
{
fn container(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<O>),
) {
self.as_mut().container(id, bounds, operate_on_children);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<O>)) {
self.as_mut().traverse(operate);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
self.as_mut().container(id, bounds);
}
fn focusable(
@ -179,17 +178,19 @@ where
}
impl<T, O> Operation<O> for BlackBox<'_, T> {
fn container(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<O>),
) {
self.operation.container(id, bounds, &mut |operation| {
operate_on_children(&mut BlackBox { operation });
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<O>))
where
Self: Sized,
{
self.operation.traverse(&mut |operation| {
operate(&mut BlackBox { operation });
});
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
self.operation.container(id, bounds);
}
fn focusable(
&mut self,
id: Option<&Id>,
@ -255,7 +256,6 @@ where
A: 'static,
B: 'static,
{
#[allow(missing_debug_implementations)]
struct Map<O, A, B> {
operation: O,
f: Arc<dyn Fn(A) -> B + Send + Sync>,
@ -267,28 +267,25 @@ where
A: 'static,
B: 'static,
{
fn container(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<B>),
) {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<B>)) {
struct MapRef<'a, A> {
operation: &'a mut dyn Operation<A>,
}
impl<A, B> Operation<B> for MapRef<'_, A> {
fn container(
fn traverse(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<B>),
operate: &mut dyn FnMut(&mut dyn Operation<B>),
) {
self.operation.traverse(&mut |operation| {
operate(&mut MapRef { operation });
});
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
let Self { operation, .. } = self;
operation.container(id, bounds, &mut |operation| {
operate_on_children(&mut MapRef { operation });
});
operation.container(id, bounds);
}
fn scrollable(
@ -345,9 +342,13 @@ where
}
}
let Self { operation, .. } = self;
self.operation.traverse(&mut |operation| {
operate(&mut MapRef { operation });
});
}
MapRef { operation }.container(id, bounds, operate_on_children);
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
self.operation.container(id, bounds);
}
fn focusable(
@ -444,17 +445,16 @@ where
A: 'static,
B: Send + 'static,
{
fn container(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<B>),
) {
self.operation.container(id, bounds, &mut |operation| {
operate_on_children(&mut black_box(operation));
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<B>)) {
self.operation.traverse(&mut |operation| {
operate(&mut black_box(operation));
});
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
self.operation.container(id, bounds);
}
fn focusable(
&mut self,
id: Option<&Id>,
@ -531,21 +531,26 @@ pub fn scope<T: 'static>(
) -> impl Operation<T> {
struct ScopedOperation<Message> {
target: Id,
current: Option<Id>,
operation: Box<dyn Operation<Message>>,
}
impl<Message: 'static> Operation<Message> for ScopedOperation<Message> {
fn container(
fn traverse(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<Message>),
operate: &mut dyn FnMut(&mut dyn Operation<Message>),
) {
if id == Some(&self.target) {
operate_on_children(self.operation.as_mut());
if self.current.as_ref() == Some(&self.target) {
self.operation.as_mut().traverse(operate);
} else {
operate_on_children(self);
operate(self);
}
self.current = None;
}
fn container(&mut self, id: Option<&Id>, _bounds: Rectangle) {
self.current = id.cloned();
}
fn finish(&self) -> Outcome<Message> {
@ -553,6 +558,7 @@ pub fn scope<T: 'static>(
Outcome::Chain(next) => {
Outcome::Chain(Box::new(ScopedOperation {
target: self.target.clone(),
current: None,
operation: next,
}))
}
@ -563,6 +569,7 @@ pub fn scope<T: 'static>(
ScopedOperation {
target,
current: None,
operation: Box::new(operation),
}
}

View file

@ -48,13 +48,8 @@ pub fn focus<T>(target: Id) -> impl Operation<T> {
}
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -75,13 +70,8 @@ pub fn unfocus<T>() -> impl Operation<T> {
state.unfocus();
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -109,13 +99,11 @@ pub fn count() -> impl Operation<Count> {
self.count.total += 1;
}
fn container(
fn traverse(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<Count>),
operate: &mut dyn FnMut(&mut dyn Operation<Count>),
) {
operate_on_children(self);
operate(self);
}
fn finish(&self) -> Outcome<Count> {
@ -163,13 +151,8 @@ where
self.current += 1;
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -205,13 +188,8 @@ where
self.current += 1;
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -237,13 +215,11 @@ pub fn find_focused() -> impl Operation<Id> {
}
}
fn container(
fn traverse(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<Id>),
operate: &mut dyn FnMut(&mut dyn Operation<Id>),
) {
operate_on_children(self);
operate(self);
}
fn finish(&self) -> Outcome<Id> {
@ -279,17 +255,15 @@ pub fn is_focused(target: Id) -> impl Operation<bool> {
}
}
fn container(
fn traverse(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<bool>),
operate: &mut dyn FnMut(&mut dyn Operation<bool>),
) {
if self.is_focused.is_some() {
return;
}
operate_on_children(self);
operate(self);
}
fn finish(&self) -> Outcome<bool> {

View file

@ -28,13 +28,8 @@ pub fn snap_to<T>(target: Id, offset: RelativeOffset) -> impl Operation<T> {
}
impl<T> Operation<T> for SnapTo {
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
fn scrollable(
@ -63,13 +58,8 @@ pub fn scroll_to<T>(target: Id, offset: AbsoluteOffset) -> impl Operation<T> {
}
impl<T> Operation<T> for ScrollTo {
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
fn scrollable(
@ -98,13 +88,8 @@ pub fn scroll_by<T>(target: Id, offset: AbsoluteOffset) -> impl Operation<T> {
}
impl<T> Operation<T> for ScrollBy {
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
fn scrollable(

View file

@ -5,12 +5,20 @@ use crate::widget::operation::Operation;
/// The internal state of a widget that has text input.
pub trait TextInput {
/// Returns the current _visible_ text of the text input
///
/// Normally, this is either its value or its placeholder.
fn text(&self) -> &str;
/// Moves the cursor of the text input to the front of the input text.
fn move_cursor_to_front(&mut self);
/// Moves the cursor of the text input to the end of the input text.
fn move_cursor_to_end(&mut self);
/// Moves the cursor of the text input to an arbitrary location.
fn move_cursor_to(&mut self, position: usize);
/// Selects all the content of the text input.
fn select_all(&mut self);
}
@ -37,13 +45,8 @@ pub fn move_cursor_to_front<T>(target: Id) -> impl Operation<T> {
}
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -72,13 +75,8 @@ pub fn move_cursor_to_end<T>(target: Id) -> impl Operation<T> {
}
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -108,13 +106,8 @@ pub fn move_cursor_to<T>(target: Id, position: usize) -> impl Operation<T> {
}
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}
@ -142,13 +135,8 @@ pub fn select_all<T>(target: Id) -> impl Operation<T> {
}
}
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
}

View file

@ -55,7 +55,6 @@ pub use text::{Alignment, LineHeight, Shaping, Wrapping};
/// .into()
/// }
/// ```
#[allow(missing_debug_implementations)]
pub struct Text<'a, Theme, Renderer>
where
Theme: Catalog,

View file

@ -413,7 +413,7 @@ mod internal {
}
}
#[cfg(feature = "hot")]
#[cfg(all(feature = "hot", not(target_arch = "wasm32")))]
mod hot {
use std::collections::BTreeSet;
use std::sync::atomic::{self, AtomicBool};
@ -478,7 +478,7 @@ mod hot {
}
}
#[cfg(not(feature = "hot"))]
#[cfg(any(not(feature = "hot"), target_arch = "wasm32"))]
mod hot {
pub fn init() {}
@ -486,7 +486,7 @@ mod hot {
f()
}
pub fn on_hotpatch(_f: impl Fn() + Send + Sync + 'static) {}
pub fn on_hotpatch(_f: impl Fn()) {}
pub fn is_stale() -> bool {
false

View file

@ -18,7 +18,8 @@ time-travel = ["iced_program/time-travel"]
[dependencies]
iced_debug.workspace = true
iced_program.workspace = true
iced_widget.workspace = true
log.workspace = true
iced_program.workspace = true
iced_program.features = ["debug"]

View file

@ -1,5 +1,4 @@
use crate::executor;
use crate::runtime::Task;
use crate::runtime::task::{self, Task};
use std::process;
@ -7,7 +6,7 @@ pub const COMPATIBLE_REVISION: &str =
"20f9c9a897fecac5dce0977bbb5639fdce1f54b9";
pub fn launch() -> Task<launch::Result> {
executor::try_spawn_blocking(|mut sender| {
task::try_blocking(|mut sender| {
let cargo_install = process::Command::new("cargo")
.args(["install", "--list"])
.output()?;
@ -48,7 +47,7 @@ pub fn launch() -> Task<launch::Result> {
}
pub fn install() -> Task<install::Result> {
executor::try_spawn_blocking(|mut sender| {
task::try_blocking(|mut sender| {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};

View file

@ -1,43 +0,0 @@
use crate::futures::futures::channel::mpsc;
use crate::futures::futures::channel::oneshot;
use crate::futures::futures::stream::{self, StreamExt};
use crate::runtime::Task;
use std::thread;
pub fn spawn_blocking<T>(
f: impl FnOnce(mpsc::Sender<T>) + Send + 'static,
) -> Task<T>
where
T: Send + 'static,
{
let (sender, receiver) = mpsc::channel(1);
let _ = thread::spawn(move || {
f(sender);
});
Task::stream(receiver)
}
pub fn try_spawn_blocking<T, E>(
f: impl FnOnce(mpsc::Sender<T>) -> Result<(), E> + Send + 'static,
) -> Task<Result<T, E>>
where
T: Send + 'static,
E: Send + 'static,
{
let (sender, receiver) = mpsc::channel(1);
let (error_sender, error_receiver) = oneshot::channel();
let _ = thread::spawn(move || {
if let Err(error) = f(sender) {
let _ = error_sender.send(Err(error));
}
});
Task::stream(stream::select(
receiver.map(Ok),
stream::once(error_receiver).filter_map(async |result| result.ok()),
))
}

View file

@ -1,13 +1,12 @@
#![allow(missing_docs)]
use iced_debug as debug;
use iced_program as program;
use iced_program::runtime;
use iced_program::runtime::futures;
use iced_widget as widget;
use iced_widget::core;
use iced_widget::runtime;
use iced_widget::runtime::futures;
mod comet;
mod executor;
mod time_machine;
use crate::core::border;
@ -15,14 +14,17 @@ use crate::core::keyboard;
use crate::core::theme::{self, Theme};
use crate::core::time::seconds;
use crate::core::window;
use crate::core::{Alignment::Center, Color, Element, Length::Fill};
use crate::core::{
Alignment::Center, Color, Element, Font, Length::Fill, Settings,
};
use crate::futures::Subscription;
use crate::program::Program;
use crate::runtime::Task;
use crate::program::message;
use crate::runtime::task::{self, Task};
use crate::time_machine::TimeMachine;
use crate::widget::{
bottom_right, button, center, column, container, horizontal_space, opaque,
row, scrollable, stack, text, themer,
bottom_right, button, center, column, container, opaque, row, scrollable,
space, stack, text, themer,
};
use std::fmt;
@ -42,6 +44,7 @@ pub struct Attach<P> {
impl<P> Program for Attach<P>
where
P: Program + 'static,
P::Message: std::fmt::Debug + message::MaybeClone,
{
type State = DevTools<P>;
type Message = Event<P>;
@ -53,6 +56,14 @@ where
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
let (state, boot) = self.program.boot();
let (devtools, task) = DevTools::new(state);
@ -83,10 +94,7 @@ where
state.title(&self.program, window)
}
fn subscription(
&self,
state: &Self::State,
) -> runtime::futures::Subscription<Self::Message> {
fn subscription(&self, state: &Self::State) -> Subscription<Self::Message> {
state.subscription(&self.program)
}
@ -108,15 +116,14 @@ where
}
/// The state of the devtools.
#[allow(missing_debug_implementations)]
pub struct DevTools<P>
where
P: Program,
{
state: P::State,
mode: Mode,
show_notification: bool,
time_machine: TimeMachine<P>,
mode: Mode,
}
#[derive(Debug, Clone)]
@ -130,7 +137,7 @@ pub enum Message {
}
enum Mode {
None,
Hidden,
Setup(Setup),
}
@ -147,28 +154,29 @@ enum Goal {
impl<P> DevTools<P>
where
P: Program + 'static,
P::Message: std::fmt::Debug + message::MaybeClone,
{
fn new(state: P::State) -> (Self, Task<Message>) {
pub fn new(state: P::State) -> (Self, Task<Message>) {
(
Self {
state,
mode: Mode::None,
mode: Mode::Hidden,
show_notification: true,
time_machine: TimeMachine::new(),
},
executor::spawn_blocking(|mut sender| {
Task::batch([task::blocking(|mut sender| {
thread::sleep(seconds(2));
let _ = sender.try_send(());
})
.map(|_| Message::HideNotification),
.map(|_| Message::HideNotification)]),
)
}
fn title(&self, program: &P, window: window::Id) -> String {
pub fn title(&self, program: &P, window: window::Id) -> String {
program.title(&self.state, window)
}
fn update(&mut self, program: &P, event: Event<P>) -> Task<Event<P>> {
pub fn update(&mut self, program: &P, event: Event<P>) -> Task<Event<P>> {
match event {
Event::Message(message) => match message {
Message::HideNotification => {
@ -179,7 +187,7 @@ where
Message::ToggleComet => {
if let Mode::Setup(setup) = &self.mode {
if matches!(setup, Setup::Idle { .. }) {
self.mode = Mode::None;
self.mode = Mode::Hidden;
}
Task::none()
@ -219,7 +227,6 @@ where
.map(Message::Installing)
.map(Event::Message)
}
Message::Installing(Ok(installation)) => {
let Mode::Setup(Setup::Running { logs }) = &mut self.mode
else {
@ -232,7 +239,7 @@ where
Task::none()
}
comet::install::Event::Finished => {
self.mode = Mode::None;
self.mode = Mode::Hidden;
comet::launch().discard()
}
}
@ -255,7 +262,7 @@ where
Task::none()
}
Message::CancelSetup => {
self.mode = Mode::None;
self.mode = Mode::Hidden;
Task::none()
}
@ -294,7 +301,7 @@ where
}
}
fn view(
pub fn view(
&self,
program: &P,
window: window::Id,
@ -311,39 +318,36 @@ where
}
};
fn derive_theme<T: theme::Base>(theme: &T) -> Theme {
theme
.palette()
let theme = || {
program
.theme(state, window)
.as_ref()
.and_then(theme::Base::palette)
.map(|palette| Theme::custom("iced devtools", palette))
.unwrap_or(Theme::Dark)
}
};
let mode = match &self.mode {
Mode::None => None,
Mode::Setup(setup) => {
let stage: Element<'_, _, Theme, P::Renderer> = match setup {
Setup::Idle { goal } => self::setup(goal),
Setup::Running { logs } => installation(logs),
};
let setup = if let Mode::Setup(setup) = &self.mode {
let stage: Element<'_, _, Theme, P::Renderer> = match setup {
Setup::Idle { goal } => self::setup(goal),
Setup::Running { logs } => installation(logs),
};
let setup = center(
container(stage)
.padding(20)
.max_width(500)
.style(container::bordered_box),
)
.padding(10)
.style(|_theme| {
container::Style::default()
.background(Color::BLACK.scale_alpha(0.8))
});
let setup = center(
container(stage)
.padding(20)
.max_width(500)
.style(container::bordered_box),
)
.padding(10)
.style(|_theme| {
container::Style::default()
.background(Color::BLACK.scale_alpha(0.8))
});
Some(setup)
}
}
.map(|mode| {
themer(derive_theme, Element::from(mode).map(Event::Message))
});
Some(themer(theme(), Element::from(setup).map(Event::Message)))
} else {
None
};
let notification = self
.show_notification
@ -354,25 +358,25 @@ where
"Types have changed. Restart to re-enable hotpatching.",
)
})
});
stack![view]
.height(Fill)
.push_maybe(mode.map(opaque))
.push_maybe(notification.map(|notification| {
})
.map(|notification| {
themer(
derive_theme,
theme(),
bottom_right(opaque(
container(notification)
.padding(10)
.style(container::dark),
)),
)
}))
});
stack![view, setup, notification]
.width(Fill)
.height(Fill)
.into()
}
fn subscription(&self, program: &P) -> Subscription<Event<P>> {
pub fn subscription(&self, program: &P) -> Subscription<Event<P>> {
let subscription =
program.subscription(&self.state).map(Event::Program);
debug::subscriptions_tracked(subscription.units());
@ -391,19 +395,19 @@ where
Subscription::batch([subscription, hotkeys, commands])
}
fn theme(&self, program: &P, window: window::Id) -> Option<P::Theme> {
pub fn theme(&self, program: &P, window: window::Id) -> Option<P::Theme> {
program.theme(self.state(), window)
}
fn style(&self, program: &P, theme: &P::Theme) -> theme::Style {
pub fn style(&self, program: &P, theme: &P::Theme) -> theme::Style {
program.style(self.state(), theme)
}
fn scale_factor(&self, program: &P, window: window::Id) -> f32 {
pub fn scale_factor(&self, program: &P, window: window::Id) -> f32 {
program.scale_factor(self.state(), window)
}
fn state(&self) -> &P::State {
pub fn state(&self) -> &P::State {
self.time_machine.state().unwrap_or(&self.state)
}
}
@ -421,6 +425,7 @@ where
impl<P> fmt::Debug for Event<P>
where
P: Program,
P::Message: std::fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@ -432,31 +437,16 @@ where
}
}
#[cfg(feature = "time-travel")]
impl<P> Clone for Event<P>
where
P: Program,
{
fn clone(&self) -> Self {
match self {
Self::Message(message) => Self::Message(message.clone()),
Self::Program(message) => Self::Program(message.clone()),
Self::Command(command) => Self::Command(*command),
Self::Discard => Self::Discard,
}
}
}
fn setup<Renderer>(goal: &Goal) -> Element<'_, Message, Theme, Renderer>
where
Renderer: core::text::Renderer + 'static,
Renderer: program::Renderer + 'static,
{
let controls = row![
button(text("Cancel").center().width(Fill))
.width(100)
.on_press(Message::CancelSetup)
.style(button::danger),
horizontal_space(),
space::horizontal(),
button(
text(match goal {
Goal::Installation => "Install",
@ -478,7 +468,7 @@ where
comet::COMPATIBLE_REVISION
)
.size(14)
.font(Renderer::MONOSPACE_FONT),
.font(Font::MONOSPACE),
)
.width(Fill)
.padding(5)
@ -495,7 +485,7 @@ where
your iced applications.",
column![
"Do you wish to install it with the \
following command?",
following command?",
command
]
.spacing(10),
@ -506,13 +496,13 @@ where
let comparison = column![
row![
"Installed revision:",
horizontal_space(),
space::horizontal(),
inline_code(revision.as_deref().unwrap_or("Unknown"))
]
.align_y(Center),
row![
"Compatible revision:",
horizontal_space(),
space::horizontal(),
inline_code(comet::COMPATIBLE_REVISION),
]
.align_y(Center)
@ -539,15 +529,15 @@ fn installation<'a, Renderer>(
logs: &'a [String],
) -> Element<'a, Message, Theme, Renderer>
where
Renderer: core::text::Renderer + 'a,
Renderer: program::Renderer + 'a,
{
column![
text("Installing comet...").size(20),
container(
scrollable(
column(logs.iter().map(|log| {
text(log).size(12).font(Renderer::MONOSPACE_FONT).into()
}),)
text(log).size(12).font(Font::MONOSPACE).into()
}))
.spacing(3),
)
.spacing(10)
@ -566,9 +556,9 @@ fn inline_code<'a, Renderer>(
code: impl text::IntoFragment<'a>,
) -> Element<'a, Message, Theme, Renderer>
where
Renderer: core::text::Renderer + 'a,
Renderer: program::Renderer + 'a,
{
container(text(code).font(Renderer::MONOSPACE_FONT).size(12))
container(text(code).size(12).font(Font::MONOSPACE))
.style(|_theme| {
container::Style::default()
.background(Color::BLACK)

View file

@ -13,6 +13,7 @@ where
impl<P> TimeMachine<P>
where
P: Program,
P::Message: Clone,
{
pub fn new() -> Self {
Self {
@ -30,6 +31,7 @@ where
}
pub fn rewind(&mut self, program: &P, message: usize) {
crate::debug::disable();
let (mut state, _) = program.boot();
if message < self.messages.len() {
@ -40,7 +42,6 @@ where
}
self.state = Some(state);
crate::debug::disable();
}
pub fn go_to_present(&mut self) {

View file

@ -1,5 +1,5 @@
//! This example showcases an interactive `Canvas` for drawing Bézier curves.
use iced::widget::{button, container, horizontal_space, hover, right};
use iced::widget::{button, container, hover, right, space};
use iced::{Element, Theme};
pub fn main() -> iced::Result {
@ -38,7 +38,7 @@ impl Example {
container(hover(
self.bezier.view(&self.curves).map(Message::AddCurve),
if self.curves.is_empty() {
container(horizontal_space())
container(space::horizontal())
} else {
right(
button("Clear")

View file

@ -1,6 +1,4 @@
use iced::widget::{
center, column, combo_box, scrollable, text, vertical_space,
};
use iced::widget::{center, column, combo_box, scrollable, space, text};
use iced::{Center, Element, Fill};
pub fn main() -> iced::Result {
@ -62,7 +60,7 @@ impl Example {
text(&self.text),
"What is your language?",
combo_box,
vertical_space().height(150),
space().height(150),
]
.width(Fill)
.align_x(Center)

View file

@ -1,5 +1,5 @@
[package]
name = "visible_bounds"
name = "delineate"
version = "0.1.0"
authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"]
edition = "2024"
@ -7,4 +7,4 @@ publish = false
[dependencies]
iced.workspace = true
iced.features = ["debug"]
iced.features = ["debug", "selector"]

View file

@ -1,7 +1,7 @@
use iced::event::{self, Event};
use iced::mouse;
use iced::widget::{
column, container, horizontal_space, row, scrollable, text, vertical_space,
self, column, container, row, scrollable, selector, space, text,
};
use iced::window;
use iced::{
@ -23,13 +23,13 @@ struct Example {
inner_bounds: Option<Rectangle>,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
enum Message {
MouseMoved(Point),
WindowResized,
Scrolled,
OuterBoundsFetched(Option<Rectangle>),
InnerBoundsFetched(Option<Rectangle>),
OuterFound(Option<Rectangle>),
InnerFound(Option<Rectangle>),
}
impl Example {
@ -41,18 +41,16 @@ impl Example {
Task::none()
}
Message::Scrolled | Message::WindowResized => Task::batch(vec![
container::visible_bounds(OUTER_CONTAINER.clone())
.map(Message::OuterBoundsFetched),
container::visible_bounds(INNER_CONTAINER.clone())
.map(Message::InnerBoundsFetched),
selector::delineate(OUTER_CONTAINER).map(Message::OuterFound),
selector::delineate(INNER_CONTAINER).map(Message::InnerFound),
]),
Message::OuterBoundsFetched(outer_bounds) => {
self.outer_bounds = outer_bounds;
Message::OuterFound(outer) => {
self.outer_bounds = outer;
Task::none()
}
Message::InnerBoundsFetched(inner_bounds) => {
self.inner_bounds = inner_bounds;
Message::InnerFound(inner) => {
self.inner_bounds = inner;
Task::none()
}
@ -63,7 +61,7 @@ impl Example {
let data_row = |label, value, color| {
row![
text(label),
horizontal_space(),
space::horizontal(),
text(value)
.font(Font::MONOSPACE)
.size(14)
@ -111,21 +109,21 @@ impl Example {
scrollable(
column![
text("Scroll me!"),
vertical_space().height(400),
space().height(400),
container(text("I am the outer container!"))
.id(OUTER_CONTAINER.clone())
.id(OUTER_CONTAINER)
.padding(40)
.style(container::rounded_box),
vertical_space().height(400),
space().height(400),
scrollable(
column![
text("Scroll me!"),
vertical_space().height(400),
space().height(400),
container(text("I am the inner container!"))
.id(INNER_CONTAINER.clone())
.id(INNER_CONTAINER)
.padding(40)
.style(container::rounded_box),
vertical_space().height(400),
space().height(400),
]
.padding(20)
)
@ -157,9 +155,5 @@ impl Example {
}
}
use std::sync::LazyLock;
static OUTER_CONTAINER: LazyLock<container::Id> =
LazyLock::new(|| container::Id::new("outer"));
static INNER_CONTAINER: LazyLock<container::Id> =
LazyLock::new(|| container::Id::new("inner"));
const OUTER_CONTAINER: widget::Id = widget::Id::new("outer");
const INNER_CONTAINER: widget::Id = widget::Id::new("inner");

View file

@ -1,8 +1,8 @@
use iced::highlighter;
use iced::keyboard;
use iced::widget::{
self, button, center_x, column, container, horizontal_space, pick_list,
row, text, text_editor, toggler, tooltip,
button, center_x, column, container, operation, pick_list, row, space,
text, text_editor, toggler, tooltip,
};
use iced::{Center, Element, Fill, Font, Task, Theme};
@ -59,7 +59,7 @@ impl Editor {
)),
Message::FileOpened,
),
widget::focus_next(),
operation::focus_next(),
]),
)
}
@ -157,7 +157,7 @@ impl Editor {
"Save file",
self.is_dirty.then_some(Message::SaveFile)
),
horizontal_space(),
space::horizontal(),
toggler(self.word_wrap)
.label("Word Wrap")
.on_toggle(Message::WordWrapToggled),
@ -184,7 +184,7 @@ impl Editor {
} else {
String::from("New file")
}),
horizontal_space(),
space::horizontal(),
text({
let (line, column) = self.content.cursor_position();

View file

@ -15,7 +15,7 @@ pub struct Image {
}
impl Image {
pub const LIMIT: usize = 99;
pub const LIMIT: usize = 96;
pub async fn list() -> Result<Vec<Self>, Error> {
let client = reqwest::Client::new();

View file

@ -9,8 +9,8 @@ use crate::civitai::{Error, Id, Image, Rgba, Size};
use iced::animation;
use iced::time::{Instant, milliseconds};
use iced::widget::{
button, container, float, grid, horizontal_space, image, mouse_area,
opaque, scrollable, sensor, stack,
button, container, float, grid, image, mouse_area, opaque, scrollable,
sensor, space, stack,
};
use iced::window;
use iced::{
@ -227,7 +227,7 @@ fn card<'a>(
})
.into()
} else {
horizontal_space().into()
space::horizontal().into()
};
if let Some(blurhash) = preview.blurhash(now) {
@ -241,7 +241,7 @@ fn card<'a>(
thumbnail
}
} else {
horizontal_space().into()
space::horizontal().into()
};
let card = mouse_area(container(image).style(container::dark))
@ -264,7 +264,7 @@ fn card<'a>(
}
fn placeholder<'a>() -> Element<'a, Message> {
container(horizontal_space()).style(container::dark).into()
container(space()).style(container::dark).into()
}
enum Preview {
@ -415,35 +415,32 @@ impl Viewer {
|| self.image_fade_in.is_animating(now)
}
fn view(&self, now: Instant) -> Element<'_, Message> {
fn view(&self, now: Instant) -> Option<Element<'_, Message>> {
let opacity = self.background_fade_in.interpolate(0.0, 0.8, now);
let image: Element<'_, _> = if let Some(handle) = &self.image {
if opacity <= 0.0 {
return None;
}
let image = self.image.as_ref().map(|handle| {
image(handle)
.width(Fill)
.height(Fill)
.opacity(self.image_fade_in.interpolate(0.0, 1.0, now))
.scale(self.image_fade_in.interpolate(1.5, 1.0, now))
.into()
} else {
horizontal_space().into()
};
});
if opacity > 0.0 {
opaque(
mouse_area(
container(image)
.center(Fill)
.style(move |_theme| {
container::Style::default()
.background(color!(0x000000, opacity))
})
.padding(20),
)
.on_press(Message::Close),
Some(opaque(
mouse_area(
container(image)
.center(Fill)
.style(move |_theme| {
container::Style::default()
.background(color!(0x000000, opacity))
})
.padding(20),
)
} else {
horizontal_space().into()
}
.on_press(Message::Close),
))
}
}

View file

@ -1,8 +1,6 @@
use iced::gradient;
use iced::theme;
use iced::widget::{
checkbox, column, container, horizontal_space, row, slider, text,
};
use iced::widget::{checkbox, column, container, row, slider, space, text};
use iced::{Center, Color, Element, Fill, Radians, Theme, color};
pub fn main() -> iced::Result {
@ -59,7 +57,7 @@ impl Gradient {
transparent,
} = *self;
let gradient_box = container(horizontal_space())
let gradient_box = container(space())
.style(move |_theme| {
let gradient = gradient::Linear::new(angle)
.add_stop(0.0, start)

View file

@ -2,9 +2,8 @@ use iced::border;
use iced::keyboard;
use iced::mouse;
use iced::widget::{
button, canvas, center, center_y, checkbox, column, container,
horizontal_rule, horizontal_space, pick_list, pin, row, scrollable, stack,
text, vertical_rule,
button, canvas, center, center_y, checkbox, column, container, pick_list,
pin, row, rule, scrollable, space, stack, text,
};
use iced::{
Center, Element, Fill, Font, Length, Point, Rectangle, Renderer, Shrink,
@ -71,7 +70,7 @@ impl Layout {
fn view(&self) -> Element<'_, Message> {
let header = row![
text(self.example.title).size(20).font(Font::MONOSPACE),
horizontal_space(),
space::horizontal(),
checkbox("Explain", self.explain)
.on_toggle(Message::ExplainToggled),
pick_list(Theme::ALL, self.theme.as_ref(), Message::ThemeSelected)
@ -93,23 +92,19 @@ impl Layout {
})
.padding(4);
let controls = row([
let controls = row![
(!self.example.is_first()).then_some(
button(text("← Previous"))
.padding([5, 10])
.on_press(Message::Previous)
.into(),
),
Some(horizontal_space().into()),
space::horizontal(),
(!self.example.is_last()).then_some(
button(text("Next →"))
.padding([5, 10])
.on_press(Message::Next)
.into(),
),
]
.into_iter()
.flatten());
];
column![header, example, controls]
.spacing(10)
@ -144,7 +139,7 @@ impl Example {
},
Self {
title: "Space",
view: space,
view: space_,
},
Self {
title: "Application",
@ -238,17 +233,17 @@ fn row_<'a>() -> Element<'a, Message> {
.into()
}
fn space<'a>() -> Element<'a, Message> {
row!["Left!", horizontal_space(), "Right!"].into()
fn space_<'a>() -> Element<'a, Message> {
row!["Left!", space::horizontal(), "Right!"].into()
}
fn application<'a>() -> Element<'a, Message> {
let header = container(
row![
square(40),
horizontal_space(),
space::horizontal(),
"Header!",
horizontal_space(),
space::horizontal(),
square(40),
]
.padding(10)
@ -295,7 +290,7 @@ fn quotes<'a>() -> Element<'a, Message> {
fn quote<'a>(
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
row![vertical_rule(1), content.into()]
row![rule::vertical(1), content.into()]
.spacing(10)
.height(Shrink)
.into()
@ -313,7 +308,7 @@ fn quotes<'a>() -> Element<'a, Message> {
reply("This is the original message", "This is a reply"),
"This is another reply",
),
horizontal_rule(1),
rule::horizontal(1),
text("A separator ↑"),
]
.width(Shrink)

View file

@ -1,6 +1,5 @@
use iced::widget::{
button, column, horizontal_space, lazy, pick_list, row, scrollable, text,
text_input,
button, column, lazy, pick_list, row, scrollable, space, text, text_input,
};
use iced::{Element, Fill};
@ -174,7 +173,7 @@ impl App {
row![
text(item.name.clone()).color(item.color),
horizontal_space(),
space::horizontal(),
pick_list(Color::ALL, Some(item.color), move |color| {
Message::ItemColorChanged(item.clone(), color)
}),

View file

@ -21,7 +21,6 @@ const MIN_ANGLE: Radians = Radians(PI / 8.0);
const WRAP_ANGLE: Radians = Radians(2.0 * PI - PI / 4.0);
const BASE_ROTATION_SPEED: u32 = u32::MAX / 80;
#[allow(missing_debug_implementations)]
pub struct Circular<'a, Theme>
where
Theme: StyleSheet,

View file

@ -12,7 +12,6 @@ use super::easing::{self, Easing};
use std::time::Duration;
#[allow(missing_debug_implementations)]
pub struct Linear<'a, Theme>
where
Theme: StyleSheet,

View file

@ -5,8 +5,8 @@ use iced::clipboard;
use iced::highlighter;
use iced::time::{self, Instant, milliseconds};
use iced::widget::{
self, button, center_x, container, horizontal_space, hover, image,
markdown, right, row, scrollable, sensor, text_editor, toggler,
button, center_x, container, hover, image, markdown, operation, right, row,
scrollable, sensor, space, text_editor, toggler,
};
use iced::window;
use iced::{
@ -78,7 +78,7 @@ impl Markdown {
theme: Theme::TokyoNight,
now: Instant::now(),
},
widget::focus_next(),
operation::focus_next(),
)
}
@ -140,10 +140,7 @@ impl Markdown {
pending: self.raw.text(),
};
scrollable::snap_to(
"preview",
scrollable::RelativeOffset::END,
)
operation::snap_to_end("preview")
} else {
self.mode = Mode::Preview;
@ -267,7 +264,7 @@ impl<'a> markdown::Viewer<'a, Message> for CustomViewer<'a> {
)
.into()
} else {
sensor(horizontal_space())
sensor(space())
.key_ref(url.as_str())
.delay(milliseconds(500))
.on_show(|_size| Message::ImageShown(url.clone()))

View file

@ -2,8 +2,8 @@ use iced::event::{self, Event};
use iced::keyboard;
use iced::keyboard::key;
use iced::widget::{
self, button, center, column, container, horizontal_space, mouse_area,
opaque, pick_list, row, stack, text, text_input,
button, center, column, container, mouse_area, opaque, operation,
pick_list, row, space, stack, text, text_input,
};
use iced::{Bottom, Color, Element, Fill, Subscription, Task};
@ -43,7 +43,7 @@ impl App {
match message {
Message::ShowModal => {
self.show_modal = true;
widget::focus_next()
operation::focus_next()
}
Message::HideModal => {
self.hide_modal();
@ -75,9 +75,9 @@ impl App {
..
}) => {
if modifiers.shift() {
widget::focus_previous()
operation::focus_previous()
} else {
widget::focus_next()
operation::focus_next()
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
@ -95,12 +95,12 @@ impl App {
fn view(&self) -> Element<'_, Message> {
let content = container(
column![
row![text("Top Left"), horizontal_space(), text("Top Right")]
row![text("Top Left"), space::horizontal(), text("Top Right")]
.height(Fill),
center(button(text("Show Modal")).on_press(Message::ShowModal)),
row![
text("Bottom Left"),
horizontal_space(),
space::horizontal(),
text("Bottom Right")
]
.align_y(Bottom)

View file

@ -1,5 +1,5 @@
use iced::widget::{
button, center, center_x, column, container, horizontal_space, scrollable,
button, center, center_x, column, container, operation, scrollable, space,
text, text_input,
};
use iced::window;
@ -42,7 +42,7 @@ enum Message {
impl Example {
fn new() -> (Self, Task<Message>) {
let (_id, open) = window::open(window::Settings::default());
let (_, open) = window::open(window::Settings::default());
(
Self {
@ -77,7 +77,7 @@ impl Example {
},
);
let (_id, open) = window::open(window::Settings {
let (_, open) = window::open(window::Settings {
position,
..window::Settings::default()
});
@ -88,7 +88,7 @@ impl Example {
}
Message::WindowOpened(id) => {
let window = Window::new(self.windows.len() + 1);
let focus_input = text_input::focus(format!("input-{id}"));
let focus_input = operation::focus(format!("input-{id}"));
self.windows.insert(id, window);
@ -134,7 +134,7 @@ impl Example {
if let Some(window) = self.windows.get(&window_id) {
center(window.view(window_id)).into()
} else {
horizontal_space().into()
space().into()
}
}

View file

@ -1,4 +1,4 @@
use iced::widget::{column, pick_list, scrollable, vertical_space};
use iced::widget::{column, pick_list, scrollable, space};
use iced::{Center, Element, Fill};
pub fn main() -> iced::Result {
@ -33,10 +33,10 @@ impl Example {
.placeholder("Choose a language...");
let content = column![
vertical_space().height(600),
space().height(600),
"Which is your favorite language?",
pick_list,
vertical_space().height(600),
space().height(600),
]
.width(Fill)
.align_x(Center)

View file

@ -1,14 +1,9 @@
use iced::widget::{
button, column, container, horizontal_space, progress_bar, radio, row,
scrollable, slider, text, vertical_space,
button, column, container, operation, progress_bar, radio, row, scrollable,
slider, space, text,
};
use iced::{Border, Center, Color, Element, Fill, Task, Theme};
use std::sync::LazyLock;
static SCROLLABLE_ID: LazyLock<scrollable::Id> =
LazyLock::new(scrollable::Id::unique);
pub fn main() -> iced::Result {
iced::application(
ScrollableDemo::default,
@ -65,19 +60,13 @@ impl ScrollableDemo {
self.current_scroll_offset = scrollable::RelativeOffset::START;
self.scrollable_direction = direction;
scrollable::snap_to(
SCROLLABLE_ID.clone(),
self.current_scroll_offset,
)
operation::snap_to(SCROLLABLE, self.current_scroll_offset)
}
Message::AlignmentChanged(alignment) => {
self.current_scroll_offset = scrollable::RelativeOffset::START;
self.anchor = alignment;
scrollable::snap_to(
SCROLLABLE_ID.clone(),
self.current_scroll_offset,
)
operation::snap_to(SCROLLABLE, self.current_scroll_offset)
}
Message::ScrollbarWidthChanged(width) => {
self.scrollbar_width = width;
@ -97,18 +86,12 @@ impl ScrollableDemo {
Message::ScrollToBeginning => {
self.current_scroll_offset = scrollable::RelativeOffset::START;
scrollable::snap_to(
SCROLLABLE_ID.clone(),
self.current_scroll_offset,
)
operation::snap_to(SCROLLABLE, self.current_scroll_offset)
}
Message::ScrollToEnd => {
self.current_scroll_offset = scrollable::RelativeOffset::END;
scrollable::snap_to(
SCROLLABLE_ID.clone(),
self.current_scroll_offset,
)
operation::snap_to(SCROLLABLE, self.current_scroll_offset)
}
Message::Scrolled(viewport) => {
self.current_scroll_offset = viewport.relative_offset();
@ -207,9 +190,9 @@ impl ScrollableDemo {
column![
scroll_to_end_button(),
text("Beginning!"),
vertical_space().height(1200),
space().height(1200),
text("Middle!"),
vertical_space().height(1200),
space().height(1200),
text("End!"),
scroll_to_beginning_button(),
]
@ -226,15 +209,15 @@ impl ScrollableDemo {
))
.width(Fill)
.height(Fill)
.id(SCROLLABLE_ID.clone())
.id(SCROLLABLE)
.on_scroll(Message::Scrolled),
Direction::Horizontal => scrollable(
row![
scroll_to_end_button(),
text("Beginning!"),
horizontal_space().width(1200),
space().width(1200),
text("Middle!"),
horizontal_space().width(1200),
space().width(1200),
text("End!"),
scroll_to_beginning_button(),
]
@ -252,32 +235,32 @@ impl ScrollableDemo {
))
.width(Fill)
.height(Fill)
.id(SCROLLABLE_ID.clone())
.id(SCROLLABLE)
.on_scroll(Message::Scrolled),
Direction::Multi => scrollable(
//horizontal content
row![
column![
text("Let's do some scrolling!"),
vertical_space().height(2400)
space().height(2400)
],
scroll_to_end_button(),
text("Horizontal - Beginning!"),
horizontal_space().width(1200),
space().width(1200),
//vertical content
column![
text("Horizontal - Middle!"),
scroll_to_end_button(),
text("Vertical - Beginning!"),
vertical_space().height(1200),
space().height(1200),
text("Vertical - Middle!"),
vertical_space().height(1200),
space().height(1200),
text("Vertical - End!"),
scroll_to_beginning_button(),
vertical_space().height(40),
space().height(40),
]
.spacing(40),
horizontal_space().width(1200),
space().width(1200),
text("Horizontal - End!"),
scroll_to_beginning_button(),
]
@ -299,7 +282,7 @@ impl ScrollableDemo {
})
.width(Fill)
.height(Fill)
.id(SCROLLABLE_ID.clone())
.id(SCROLLABLE)
.on_scroll(Message::Scrolled),
});
@ -348,3 +331,5 @@ fn progress_bar_custom_style(theme: &Theme) -> progress_bar::Style {
border: Border::default(),
}
}
const SCROLLABLE: &str = "scrollable";

View file

@ -1 +1 @@
ab8f9190260837ba9b0cf30f072116e86be1c90197a56ad00da6de60a618a3b8
896072b46221f83e1edaa37574436af6474969625f5c1a41cc5ddc2e20823cee

View file

@ -1 +1 @@
ddee619e66418803c64ed5677fd375ad596e234ab9541ab197f17c81e2100279
2010df2e80bfc72e7e9274de07b77dc4843485f6be38266fdfb7a4f129d75da1

View file

@ -1,8 +1,8 @@
use iced::keyboard;
use iced::widget::{
button, center_x, center_y, checkbox, column, container, horizontal_rule,
pick_list, progress_bar, row, scrollable, slider, text, text_input,
toggler, vertical_rule, vertical_space,
button, center_x, center_y, checkbox, column, container, pick_list,
progress_bar, row, rule, scrollable, slider, space, text, text_input,
toggler,
};
use iced::{Center, Element, Fill, Shrink, Subscription, Theme};
@ -127,7 +127,7 @@ impl Styling {
let scroll_me = scrollable(column![
"Scroll me!",
vertical_space().height(800),
space().height(800),
"You did it!"
])
.width(Fill)
@ -162,14 +162,14 @@ impl Styling {
let content = column![
choose_theme,
horizontal_rule(1),
rule::horizontal(1),
text_input,
buttons,
slider(),
progress_bar(),
row![
scroll_me,
vertical_rule(1),
rule::vertical(1),
column![check, check_disabled, toggle, disabled_toggle]
.spacing(10)
]

View file

@ -2,7 +2,7 @@ use iced::event::{self, Event};
use iced::keyboard;
use iced::keyboard::key;
use iced::widget::{
self, button, center, column, pick_list, row, slider, text, text_input,
button, center, column, operation, pick_list, row, slider, text, text_input,
};
use iced::{Center, Element, Fill, Subscription, Task};
@ -83,11 +83,11 @@ impl App {
key: keyboard::Key::Named(key::Named::Tab),
modifiers,
..
})) if modifiers.shift() => widget::focus_previous(),
})) if modifiers.shift() => operation::focus_previous(),
Message::Event(Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key::Named::Tab),
..
})) => widget::focus_next(),
})) => operation::focus_next(),
Message::Event(_) => Task::none(),
}
}
@ -171,9 +171,7 @@ mod toast {
use iced::mouse;
use iced::theme;
use iced::time::{self, Duration, Instant};
use iced::widget::{
button, column, container, horizontal_rule, horizontal_space, row, text,
};
use iced::widget::{button, column, container, row, rule, space, text};
use iced::window;
use iced::{
Alignment, Center, Element, Event, Fill, Length, Point, Rectangle,
@ -239,7 +237,7 @@ mod toast {
container(
row![
text(toast.title.as_str()),
horizontal_space(),
space::horizontal(),
button("X")
.on_press((on_close)(index))
.padding(3),
@ -254,7 +252,7 @@ mod toast {
Status::Success => success,
Status::Danger => danger,
}),
horizontal_rule(1),
rule::horizontal(1),
container(text(toast.body.as_str()))
.width(Fill)
.padding(5)
@ -349,7 +347,8 @@ mod toast {
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(None, layout.bounds(), &mut |operation| {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut state.children[0],
layout,
@ -580,7 +579,8 @@ mod toast {
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
operation.container(None, layout.bounds(), &mut |operation| {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.toasts
.iter_mut()
.zip(self.state.iter_mut())

View file

@ -5,6 +5,9 @@ authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"]
edition = "2024"
publish = false
[features]
tester = ["iced/tester"]
[dependencies]
iced.workspace = true
iced.features = ["tokio", "debug", "time-travel"]

View file

@ -1,11 +1,13 @@
use iced::keyboard;
use iced::time::milliseconds;
use iced::widget::{
self, Text, button, center, center_x, checkbox, column, keyed_column, row,
scrollable, text, text_input,
self, Text, button, center, center_x, checkbox, column, keyed_column,
operation, row, scrollable, text, text_input,
};
use iced::window;
use iced::{
Center, Element, Fill, Font, Function, Subscription, Task as Command, Theme,
Application, Center, Element, Fill, Font, Function, Preset, Program,
Subscription, Task as Command, Theme,
};
use serde::{Deserialize, Serialize};
@ -15,12 +17,16 @@ pub fn main() -> iced::Result {
#[cfg(not(target_arch = "wasm32"))]
tracing_subscriber::fmt::init();
application().run()
}
fn application() -> Application<impl Program<Message = Message>> {
iced::application(Todos::new, Todos::update, Todos::view)
.subscription(Todos::subscription)
.title(Todos::title)
.font(Todos::ICON_FONT)
.window_size((500.0, 800.0))
.run()
.presets(presets())
}
#[derive(Debug)]
@ -87,7 +93,7 @@ impl Todos {
_ => {}
}
text_input::focus("new-task")
operation::focus("new-task")
}
Todos::Loaded(state) => {
let mut saved = false;
@ -128,8 +134,8 @@ impl Todos {
if should_focus {
let id = Task::text_input_id(i);
Command::batch(vec![
text_input::focus(id.clone()),
text_input::select_all(id),
operation::focus(id.clone()),
operation::select_all(id),
])
} else {
Command::none()
@ -146,9 +152,9 @@ impl Todos {
}
Message::TabPressed { shift } => {
if shift {
widget::focus_previous()
operation::focus_previous()
} else {
widget::focus_next()
operation::focus_next()
}
}
Message::ToggleFullscreen(mode) => window::latest()
@ -301,8 +307,8 @@ pub enum TaskMessage {
}
impl Task {
fn text_input_id(i: usize) -> text_input::Id {
text_input::Id::new(format!("task-{i}"))
fn text_input_id(i: usize) -> widget::Id {
widget::Id::from(format!("task-{i}"))
}
fn new(description: String) -> Self {
@ -539,8 +545,8 @@ impl SavedState {
.map_err(|_| SaveError::Write)?;
}
// This is a simple way to save at most once every couple seconds
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// This is a simple way to save at most twice every second
tokio::time::sleep(milliseconds(500)).await;
Ok(())
}
@ -582,6 +588,32 @@ impl SavedState {
}
}
fn presets() -> impl IntoIterator<Item = Preset<Todos, Message>> {
[
Preset::new("Empty", || {
(Todos::Loaded(State::default()), Command::none())
}),
Preset::new("Carl Sagan", || {
(
Todos::Loaded(State {
input_value: "Make an apple pie".to_owned(),
filter: Filter::All,
tasks: vec![Task {
id: Uuid::new_v4(),
description: "Create the universe".to_owned(),
completed: false,
state: TaskState::Idle,
}],
dirty: false,
saving: false,
}),
Command::none(),
)
}),
]
.into_iter()
}
#[cfg(test)]
mod tests {
use super::*;
@ -627,4 +659,13 @@ mod tests {
Ok(())
}
#[test]
#[ignore]
fn it_passes_the_ice_tests() -> Result<(), Error> {
iced_test::run(
application(),
format!("{}/tests", env!("CARGO_MANIFEST_DIR")),
)
}
}

View file

@ -0,0 +1,14 @@
viewport: 500x800
mode: Immediate
preset: Empty
-----
click "What needs to be done?"
type "Create the universe"
type enter
type "Make an apple pie"
type enter
expect "2 tasks left"
click "Create the universe"
expect "1 task left"
click "Make an apple pie"
expect "0 tasks left"

View file

@ -1,8 +1,7 @@
use iced::widget::{Button, Column, Container, Slider};
use iced::widget::{
button, center_x, center_y, checkbox, column, horizontal_space, image,
radio, rich_text, row, scrollable, slider, span, text, text_input, toggler,
vertical_space,
button, center_x, center_y, checkbox, column, image, radio, rich_text, row,
scrollable, slider, space, span, text, text_input, toggler,
};
use iced::{Center, Color, Element, Fill, Font, Pixels, color};
@ -147,7 +146,7 @@ impl Tour {
.on_press(Message::BackPressed)
.style(button::secondary)
}),
horizontal_space(),
space::horizontal(),
self.can_continue().then(|| {
padded_button("Next").on_press(Message::NextPressed)
})
@ -406,14 +405,14 @@ impl Tour {
text("Tip: You can use the scrollbar to scroll down faster!")
.size(16),
)
.push(vertical_space().height(4096))
.push(space().height(4096))
.push(
text("You are halfway there!")
.width(Fill)
.size(30)
.align_x(Center),
)
.push(vertical_space().height(4096))
.push(space().height(4096))
.push(ferris(300, image::FilterMethod::Linear))
.push(text("You made it!").width(Fill).size(50).align_x(Center))
}

View file

@ -1,8 +1,6 @@
use iced::alignment;
use iced::mouse;
use iced::widget::{
canvas, checkbox, column, horizontal_space, row, slider, text,
};
use iced::widget::{canvas, checkbox, column, row, slider, space, text};
use iced::{Center, Element, Fill, Point, Rectangle, Renderer, Theme, Vector};
pub fn main() -> iced::Result {
@ -51,7 +49,7 @@ impl VectorialText {
fn view(&self) -> Element<'_, Message> {
let slider_with_label = |label, range, value, message: fn(f32) -> _| {
column![
row![text(label), horizontal_space(), text!("{:.2}", value)],
row![text(label), space::horizontal(), text!("{:.2}", value)],
slider(range, value, message).step(0.01)
]
.spacing(2)

View file

@ -30,7 +30,6 @@ pub fn connect() -> impl Sipper<Never, Event> {
tokio::time::sleep(tokio::time::Duration::from_secs(1))
.await;
output.send(Event::Disconnected).await;
continue;
}
};

View file

@ -1,10 +1,9 @@
mod echo;
use iced::widget::{
self, button, center, column, row, scrollable, text, text_input,
button, center, column, operation, row, scrollable, text, text_input,
};
use iced::{Center, Element, Fill, Subscription, Task, color};
use std::sync::LazyLock;
pub fn main() -> iced::Result {
iced::application(WebSocket::new, WebSocket::update, WebSocket::view)
@ -23,7 +22,6 @@ enum Message {
NewMessageChanged(String),
Send(echo::Message),
Echo(echo::Event),
Server,
}
impl WebSocket {
@ -35,8 +33,8 @@ impl WebSocket {
state: State::Disconnected,
},
Task::batch([
Task::perform(echo::server::run(), |_| Message::Server),
widget::focus_next(),
Task::future(echo::server::run()).discard(),
operation::focus_next(),
]),
)
}
@ -76,13 +74,9 @@ impl WebSocket {
echo::Event::MessageReceived(message) => {
self.messages.push(message);
scrollable::snap_to(
MESSAGE_LOG.clone(),
scrollable::RelativeOffset::END,
)
operation::snap_to_end(MESSAGE_LOG)
}
},
Message::Server => Task::none(),
}
}
@ -102,7 +96,7 @@ impl WebSocket {
column(self.messages.iter().map(text).map(Element::from))
.spacing(10),
)
.id(MESSAGE_LOG.clone())
.id(MESSAGE_LOG)
.height(Fill)
.spacing(10)
.into()
@ -139,5 +133,4 @@ enum State {
Connected(echo::Connection),
}
static MESSAGE_LOG: LazyLock<scrollable::Id> =
LazyLock::new(scrollable::Id::unique);
const MESSAGE_LOG: &str = "message_log";

View file

@ -2,7 +2,7 @@
use crate::subscription;
use crate::{BoxStream, Executor, MaybeSend};
use futures::{Sink, channel::mpsc};
use futures::{Sink, SinkExt, channel::mpsc};
use std::marker::PhantomData;
/// A batteries-included runtime of commands and subscriptions.
@ -79,6 +79,15 @@ where
self.executor.spawn(future);
}
/// Sends a message concurrently through the [`Runtime`].
pub fn send(&mut self, message: Message) {
let mut sender = self.sender.clone();
self.executor.spawn(async move {
let _ = sender.send(message).await;
});
}
/// Tracks a [`Subscription`] in the [`Runtime`].
///
/// It will spawn new streams or close old ones as necessary! See

View file

@ -3,7 +3,6 @@ use crate::core::{Point, Radians, Rectangle, Size, Vector};
use crate::geometry::{self, Fill, Image, Path, Stroke, Svg, Text};
/// The region of a surface that can be used to draw geometry.
#[allow(missing_debug_implementations)]
pub struct Frame<Renderer>
where
Renderer: geometry::Renderer,

View file

@ -10,7 +10,6 @@ use lyon_path::math;
/// A [`Path`] builder.
///
/// Once a [`Path`] is built, it can no longer be mutated.
#[allow(missing_debug_implementations)]
pub struct Builder {
raw: builder::WithSvg<lyon_path::path::BuilderImpl>,
}

View file

@ -134,7 +134,6 @@ pub fn font_system() -> &'static RwLock<FontSystem> {
}
/// A set of system fonts.
#[allow(missing_debug_implementations)]
pub struct FontSystem {
raw: cosmic_text::FontSystem,
loaded_fonts: HashSet<usize>,

View file

@ -14,6 +14,7 @@ rust-version.workspace = true
workspace = true
[features]
debug = []
time-travel = []
[dependencies]

View file

@ -4,10 +4,17 @@ pub use iced_runtime as runtime;
pub use iced_runtime::core;
pub use iced_runtime::futures;
use crate::core::Element;
pub mod message;
mod preset;
pub use preset::Preset;
use crate::core::renderer;
use crate::core::text;
use crate::core::theme;
use crate::core::window;
use crate::core::{Element, Font, Settings};
use crate::futures::{Executor, Subscription};
use crate::graphics::compositor;
use crate::runtime::Task;
@ -22,7 +29,7 @@ pub trait Program: Sized {
type State;
/// The message of the program.
type Message: Message + 'static;
type Message: Send + 'static;
/// The theme of the program.
type Theme: theme::Base;
@ -36,6 +43,10 @@ pub trait Program: Sized {
/// Returns the unique name of the [`Program`].
fn name() -> &'static str;
fn settings(&self) -> Settings;
fn window(&self) -> Option<window::Settings>;
fn boot(&self) -> (Self::State, Task<Self::Message>);
fn update(
@ -101,6 +112,10 @@ pub trait Program: Sized {
fn scale_factor(&self, _state: &Self::State, _window: window::Id) -> f32 {
1.0
}
fn presets(&self) -> &[Preset<Self::State, Self::Message>] {
&[]
}
}
/// Decorates a [`Program`] with the given title function.
@ -132,6 +147,14 @@ pub fn with_title<P: Program>(
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.program.boot()
}
@ -214,6 +237,14 @@ pub fn with_subscription<P: Program>(
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.program.boot()
}
@ -297,6 +328,14 @@ pub fn with_theme<P: Program>(
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.program.boot()
}
@ -376,6 +415,14 @@ pub fn with_style<P: Program>(
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.program.boot()
}
@ -451,6 +498,14 @@ pub fn with_scale_factor<P: Program>(
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.program.boot()
}
@ -534,6 +589,14 @@ pub fn with_executor<P: Program, E: Executor>(
P::name()
}
fn settings(&self) -> Settings {
self.program.settings()
}
fn window(&self) -> Option<window::Settings> {
self.program.window()
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.program.boot()
}
@ -589,12 +652,17 @@ pub fn with_executor<P: Program, E: Executor>(
}
/// The renderer of some [`Program`].
pub trait Renderer: text::Renderer + compositor::Default {}
pub trait Renderer:
text::Renderer<Font = Font> + compositor::Default + renderer::Headless
{
}
impl<T> Renderer for T where T: text::Renderer + compositor::Default {}
impl<T> Renderer for T where
T: text::Renderer<Font = Font> + compositor::Default + renderer::Headless
{
}
/// A particular instance of a running [`Program`].
#[allow(missing_debug_implementations)]
pub struct Instance<P: Program> {
program: P,
state: P::State,
@ -646,17 +714,3 @@ impl<P: Program> Instance<P> {
self.program.scale_factor(&self.state, window)
}
}
/// A trait alias for the [`Message`](Program::Message) of a [`Program`].
#[cfg(feature = "time-travel")]
pub trait Message: Send + std::fmt::Debug + Clone {}
#[cfg(feature = "time-travel")]
impl<T: Send + std::fmt::Debug + Clone> Message for T {}
/// A trait alias for the [`Message`](Program::Message) of a [`Program`].
#[cfg(not(feature = "time-travel"))]
pub trait Message: Send + std::fmt::Debug {}
#[cfg(not(feature = "time-travel"))]
impl<T: Send + std::fmt::Debug> Message for T {}

33
program/src/message.rs Normal file
View file

@ -0,0 +1,33 @@
//! Traits for the message type of a [`Program`](crate::Program).
/// A trait alias for [`Clone`], but only when the `time-travel`
/// feature is enabled.
#[cfg(feature = "time-travel")]
pub trait MaybeClone: Clone {}
#[cfg(feature = "time-travel")]
impl<T> MaybeClone for T where T: Clone {}
/// A trait alias for [`Clone`], but only when the `time-travel`
/// feature is enabled.
#[cfg(not(feature = "time-travel"))]
pub trait MaybeClone {}
#[cfg(not(feature = "time-travel"))]
impl<T> MaybeClone for T {}
/// A trait alias for [`Debug`](std::fmt::Debug), but only when the
/// `debug` feature is enabled.
#[cfg(feature = "debug")]
pub trait MaybeDebug: std::fmt::Debug {}
#[cfg(feature = "debug")]
impl<T> MaybeDebug for T where T: std::fmt::Debug {}
/// A trait alias for [`Debug`](std::fmt::Debug), but only when the
/// `debug` feature is enabled.
#[cfg(not(feature = "debug"))]
pub trait MaybeDebug {}
#[cfg(not(feature = "debug"))]
impl<T> MaybeDebug for T {}

42
program/src/preset.rs Normal file
View file

@ -0,0 +1,42 @@
use crate::runtime::Task;
use std::borrow::Cow;
use std::fmt;
/// A specific boot strategy for a [`Program`](crate::Program).
pub struct Preset<State, Message> {
name: Cow<'static, str>,
boot: Box<dyn Fn() -> (State, Task<Message>)>,
}
impl<State, Message> Preset<State, Message> {
/// Creates a new [`Preset`] with the given name and boot strategy.
pub fn new(
name: impl Into<Cow<'static, str>>,
boot: impl Fn() -> (State, Task<Message>) + 'static,
) -> Self {
Self {
name: name.into(),
boot: Box::new(boot),
}
}
/// Returns the name of the [`Preset`].
pub fn name(&self) -> &str {
&self.name
}
/// Boots the [`Preset`], returning the initial [`Program`](crate::Program) state and
/// a [`Task`] for concurrent booting.
pub fn boot(&self) -> (State, Task<Message>) {
(self.boot)()
}
}
impl<State, Message> fmt::Debug for Preset<State, Message> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Preset")
.field("name", &self.name)
.finish_non_exhaustive()
}
}

View file

@ -84,7 +84,6 @@ where
type Paragraph = A::Paragraph;
type Editor = A::Editor;
const MONOSPACE_FONT: Self::Font = A::MONOSPACE_FONT;
const ICON_FONT: Self::Font = A::ICON_FONT;
const CHECKMARK_ICON: char = A::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = A::ARROW_DOWN_ICON;

View file

@ -10,6 +10,9 @@ homepage.workspace = true
categories.workspace = true
keywords.workspace = true
[features]
selector = ["dep:iced_selector"]
[lints]
workspace = true
@ -17,7 +20,6 @@ workspace = true
bytes.workspace = true
iced_core.workspace = true
iced_debug.workspace = true
iced_futures.workspace = true
raw-window-handle.workspace = true
@ -25,3 +27,6 @@ thiserror.workspace = true
sipper.workspace = true
sipper.optional = true
iced_selector.workspace = true
iced_selector.optional = true

View file

@ -12,10 +12,10 @@
pub mod clipboard;
pub mod font;
pub mod keyboard;
pub mod overlay;
pub mod system;
pub mod task;
pub mod user_interface;
pub mod widget;
pub mod window;
pub use iced_core as core;
@ -25,7 +25,6 @@ pub use iced_futures as futures;
pub use task::Task;
pub use user_interface::UserInterface;
use crate::core::widget;
use crate::futures::futures::channel::oneshot;
use std::borrow::Cow;
@ -45,7 +44,7 @@ pub enum Action<T> {
},
/// Run a widget operation.
Widget(Box<dyn widget::Operation>),
Widget(Box<dyn core::widget::Operation>),
/// Run a clipboard action.
Clipboard(clipboard::Action),
@ -67,8 +66,8 @@ pub enum Action<T> {
}
impl<T> Action<T> {
/// Creates a new [`Action::Widget`] with the given [`widget::Operation`].
pub fn widget(operation: impl widget::Operation + 'static) -> Self {
/// Creates a new [`Action::Widget`] with the given [`widget::Operation`](core::widget::Operation).
pub fn widget(operation: impl core::widget::Operation + 'static) -> Self {
Self::Widget(Box::new(operation))
}

View file

@ -1,4 +0,0 @@
//! Overlays for user interfaces.
mod nested;
pub use nested::Nested;

View file

@ -9,6 +9,7 @@ use crate::futures::{BoxStream, MaybeSend, boxed_stream};
use std::convert::Infallible;
use std::sync::Arc;
use std::thread;
#[cfg(feature = "sipper")]
#[doc(no_inline)]
@ -466,3 +467,47 @@ pub fn effect<T>(action: impl Into<Action<Infallible>>) -> Task<T> {
pub fn into_stream<T>(task: Task<T>) -> Option<BoxStream<Action<T>>> {
task.stream
}
/// Creates a new [`Task`] that will run the given closure in a new thread.
///
/// Any data sent by the closure through the [`mpsc::Sender`] will be produced
/// by the [`Task`].
pub fn blocking<T>(f: impl FnOnce(mpsc::Sender<T>) + Send + 'static) -> Task<T>
where
T: Send + 'static,
{
let (sender, receiver) = mpsc::channel(1);
let _ = thread::spawn(move || {
f(sender);
});
Task::stream(receiver)
}
/// Creates a new [`Task`] that will run the given closure that can fail in a new
/// thread.
///
/// Any data sent by the closure through the [`mpsc::Sender`] will be produced
/// by the [`Task`].
pub fn try_blocking<T, E>(
f: impl FnOnce(mpsc::Sender<T>) -> Result<(), E> + Send + 'static,
) -> Task<Result<T, E>>
where
T: Send + 'static,
E: Send + 'static,
{
let (sender, receiver) = mpsc::channel(1);
let (error_sender, error_receiver) = oneshot::channel();
let _ = thread::spawn(move || {
if let Err(error) = f(sender) {
let _ = error_sender.send(Err(error));
}
});
Task::stream(stream::select(
receiver.map(Ok),
stream::once(error_receiver).filter_map(async |result| result.ok()),
))
}

View file

@ -2,13 +2,13 @@
use crate::core::event::{self, Event};
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget;
use crate::core::window;
use crate::core::{
Clipboard, Element, InputMethod, Layout, Rectangle, Shell, Size, Vector,
};
use crate::overlay;
/// A set of interactive graphical elements with a specific [`Layout`].
///
@ -22,7 +22,6 @@ use crate::overlay;
/// existing graphical application.
///
/// [`integration`]: https://github.com/iced-rs/iced/tree/0.13/examples/integration
#[allow(missing_debug_implementations)]
pub struct UserInterface<'a, Message, Theme, Renderer> {
root: Element<'a, Message, Theme, Renderer>,
base: layout::Node,

5
runtime/src/widget.rs Normal file
View file

@ -0,0 +1,5 @@
//! Operate on widgets and query them at runtime.
pub mod operation;
#[cfg(feature = "selector")]
pub mod selector;

View file

@ -0,0 +1,88 @@
//! Change internal widget state.
use crate::core::widget::Id;
use crate::core::widget::operation;
use crate::task;
use crate::{Action, Task};
pub use crate::core::widget::operation::scrollable::{
AbsoluteOffset, RelativeOffset,
};
/// Snaps the scrollable with the given [`Id`] to the provided [`RelativeOffset`].
pub fn snap_to<T>(id: impl Into<Id>, offset: RelativeOffset) -> Task<T> {
task::effect(Action::widget(operation::scrollable::snap_to(
id.into(),
offset,
)))
}
/// Snaps the scrollable with the given [`Id`] to the [`RelativeOffset::END`].
pub fn snap_to_end<T>(id: impl Into<Id>) -> Task<T> {
task::effect(Action::widget(operation::scrollable::snap_to(
id.into(),
RelativeOffset::END,
)))
}
/// Scrolls the scrollable with the given [`Id`] to the provided [`AbsoluteOffset`].
pub fn scroll_to<T>(id: impl Into<Id>, offset: AbsoluteOffset) -> Task<T> {
task::effect(Action::widget(operation::scrollable::scroll_to(
id.into(),
offset,
)))
}
/// Scrolls the scrollable with the given [`Id`] by the provided [`AbsoluteOffset`].
pub fn scroll_by<T>(id: impl Into<Id>, offset: AbsoluteOffset) -> Task<T> {
task::effect(Action::widget(operation::scrollable::scroll_by(
id.into(),
offset,
)))
}
/// Focuses the previous focusable widget.
pub fn focus_previous<T>() -> Task<T> {
task::effect(Action::widget(operation::focusable::focus_previous()))
}
/// Focuses the next focusable widget.
pub fn focus_next<T>() -> Task<T> {
task::effect(Action::widget(operation::focusable::focus_next()))
}
/// Returns whether the widget with the given [`Id`] is focused or not.
pub fn is_focused(id: impl Into<Id>) -> Task<bool> {
task::widget(operation::focusable::is_focused(id.into()))
}
/// Focuses the widget with the given [`Id`].
pub fn focus<T>(id: impl Into<Id>) -> Task<T> {
task::effect(Action::widget(operation::focusable::focus(id.into())))
}
/// Moves the cursor of the widget with the given [`Id`] to the end.
pub fn move_cursor_to_end<T>(id: impl Into<Id>) -> Task<T> {
task::effect(Action::widget(operation::text_input::move_cursor_to_end(
id.into(),
)))
}
/// Moves the cursor of the widget with the given [`Id`] to the front.
pub fn move_cursor_to_front<T>(id: impl Into<Id>) -> Task<T> {
task::effect(Action::widget(operation::text_input::move_cursor_to_front(
id.into(),
)))
}
/// Moves the cursor of the widget with the given [`Id`] to the provided position.
pub fn move_cursor_to<T>(id: impl Into<Id>, position: usize) -> Task<T> {
task::effect(Action::widget(operation::text_input::move_cursor_to(
id.into(),
position,
)))
}
/// Selects all the content of the widget with the given [`Id`].
pub fn select_all<T>(id: impl Into<Id>) -> Task<T> {
task::effect(Action::widget(operation::text_input::select_all(id.into())))
}

View file

@ -0,0 +1,28 @@
//! Find and query widgets in your applications.
pub use iced_selector::{Bounded, Candidate, Selector, Target, Text};
use crate::core::Rectangle;
use crate::Task;
use crate::core::widget;
use crate::task;
/// Finds a widget by the given [`widget::Id`].
pub fn find_by_id(id: impl Into<widget::Id>) -> Task<Option<Target>> {
task::widget(id.into().find())
}
/// Finds a widget that contains the given text.
pub fn find_by_text(text: impl Into<String>) -> Task<Option<Text>> {
task::widget(Selector::find(text.into()))
}
/// Finds the visible bounds of the first [`Selector`] target.
pub fn delineate<S>(selector: S) -> Task<Option<Rectangle>>
where
S: Selector + Send + 'static,
S::Output: Bounded + Clone + Send + 'static,
{
task::widget(selector.find())
.map(|target| target.as_ref().and_then(Bounded::visible_bounds))
}

View file

@ -15,7 +15,6 @@ pub use raw_window_handle;
use raw_window_handle::WindowHandle;
/// An operation to be performed on some window.
#[allow(missing_debug_implementations)]
pub enum Action {
/// Opens a new window with some [`Settings`].
Open(Id, Settings, oneshot::Sender<Id>),

17
selector/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "iced_selector"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
categories.workspace = true
keywords.workspace = true
rust-version.workspace = true
[dependencies]
iced_core.workspace = true
[lints]
workspace = true

276
selector/src/find.rs Normal file
View file

@ -0,0 +1,276 @@
use crate::Selector;
use crate::core::widget::operation::{
Focusable, Outcome, Scrollable, TextInput,
};
use crate::core::widget::{Id, Operation};
use crate::core::{Rectangle, Vector};
use crate::target::Candidate;
use std::any::Any;
/// An [`Operation`] that runs the [`Selector`] and stops after
/// the first [`Output`](Selector::Output) is produced.
pub type Find<S> = Finder<One<S>>;
/// An [`Operation`] that runs the [`Selector`] for the entire
/// widget tree and aggregates all of its [`Output`](Selector::Output).
pub type FindAll<S> = Finder<All<S>>;
#[derive(Debug)]
pub struct One<S>
where
S: Selector,
{
selector: S,
output: Option<S::Output>,
}
impl<S> One<S>
where
S: Selector,
{
pub fn new(selector: S) -> Self {
Self {
selector,
output: None,
}
}
}
impl<S> Strategy for One<S>
where
S: Selector,
S::Output: Clone,
{
type Output = Option<S::Output>;
fn feed(&mut self, target: Candidate<'_>) {
if let Some(output) = self.selector.select(target) {
self.output = Some(output);
}
}
fn is_done(&self) -> bool {
self.output.is_some()
}
fn finish(&self) -> Self::Output {
self.output.clone()
}
}
#[derive(Debug)]
pub struct All<S>
where
S: Selector,
{
selector: S,
outputs: Vec<S::Output>,
}
impl<S> All<S>
where
S: Selector,
{
pub fn new(selector: S) -> Self {
Self {
selector,
outputs: Vec::new(),
}
}
}
impl<S> Strategy for All<S>
where
S: Selector,
S::Output: Clone,
{
type Output = Vec<S::Output>;
fn feed(&mut self, target: Candidate<'_>) {
if let Some(output) = self.selector.select(target) {
self.outputs.push(output);
}
}
fn is_done(&self) -> bool {
false
}
fn finish(&self) -> Self::Output {
self.outputs.clone()
}
}
pub trait Strategy {
type Output;
fn feed(&mut self, target: Candidate<'_>);
fn is_done(&self) -> bool;
fn finish(&self) -> Self::Output;
}
#[derive(Debug)]
pub struct Finder<S> {
strategy: S,
stack: Vec<(Rectangle, Vector)>,
viewport: Rectangle,
translation: Vector,
}
impl<S> Finder<S> {
pub fn new(strategy: S) -> Self {
Self {
strategy,
stack: vec![(Rectangle::INFINITE, Vector::ZERO)],
viewport: Rectangle::INFINITE,
translation: Vector::ZERO,
}
}
}
impl<S> Operation<S::Output> for Finder<S>
where
S: Strategy + Send,
S::Output: Send,
{
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn Operation<S::Output>),
) {
if self.strategy.is_done() {
return;
}
self.stack.push((self.viewport, self.translation));
operate(self);
let _ = self.stack.pop();
let (viewport, translation) = self.stack.last().unwrap();
self.viewport = *viewport;
self.translation = *translation;
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if self.strategy.is_done() {
return;
}
self.strategy.feed(Candidate::Container {
id,
bounds,
visible_bounds: self
.viewport
.intersection(&(bounds + self.translation)),
});
}
fn focusable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Focusable,
) {
if self.strategy.is_done() {
return;
}
self.strategy.feed(Candidate::Focusable {
id,
bounds,
visible_bounds: self
.viewport
.intersection(&(bounds + self.translation)),
state,
});
}
fn scrollable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: Vector,
state: &mut dyn Scrollable,
) {
if self.strategy.is_done() {
return;
}
let visible_bounds =
self.viewport.intersection(&(bounds + self.translation));
self.strategy.feed(Candidate::Scrollable {
id,
bounds,
visible_bounds,
content_bounds,
translation,
state,
});
self.translation = self.translation - translation;
self.viewport = visible_bounds.unwrap_or_default();
}
fn text_input(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn TextInput,
) {
if self.strategy.is_done() {
return;
}
self.strategy.feed(Candidate::TextInput {
id,
bounds,
visible_bounds: self
.viewport
.intersection(&(bounds + self.translation)),
state,
});
}
fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) {
if self.strategy.is_done() {
return;
}
self.strategy.feed(Candidate::Text {
id,
bounds,
visible_bounds: self
.viewport
.intersection(&(bounds + self.translation)),
content: text,
});
}
fn custom(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Any,
) {
if self.strategy.is_done() {
return;
}
self.strategy.feed(Candidate::Custom {
id,
bounds,
visible_bounds: self
.viewport
.intersection(&(bounds + self.translation)),
state,
});
}
fn finish(&self) -> Outcome<S::Output> {
Outcome::Some(self.strategy.finish())
}
}

148
selector/src/lib.rs Normal file
View file

@ -0,0 +1,148 @@
//! Select data from the widget tree.
use iced_core as core;
mod find;
mod target;
pub use find::{Find, FindAll};
pub use target::{Bounded, Candidate, Target, Text};
use crate::core::Point;
use crate::core::widget;
/// A type that traverses the widget tree to "select" data and produce some output.
pub trait Selector {
/// The output type of the [`Selector`].
///
/// For most selectors, this will normally be a [`Target`]. However, some
/// selectors may want to return a more limited type to encode the selection
/// guarantees in the type system.
///
/// For instance, the implementations of [`String`] and [`str`] of [`Selector`]
/// return a [`target::Text`] instead of a generic [`Target`], since they are
/// guaranteed to only select text.
type Output;
/// Performs a selection of the given [`Candidate`], if applicable.
///
/// This method traverses the widget tree in depth-first order.
fn select(&mut self, candidate: Candidate<'_>) -> Option<Self::Output>;
/// Returns a short description of the [`Selector`] for debugging purposes.
fn description(&self) -> String;
/// Returns a [`widget::Operation`] that runs the [`Selector`] and stops after
/// the first [`Output`](Self::Output) is produced.
fn find(self) -> Find<Self>
where
Self: Sized,
{
Find::new(find::One::new(self))
}
/// Returns a [`widget::Operation`] that runs the [`Selector`] for the entire
/// widget tree and aggregates all of its [`Output`](Self::Output).
fn find_all(self) -> FindAll<Self>
where
Self: Sized,
{
FindAll::new(find::All::new(self))
}
}
impl Selector for &str {
type Output = target::Text;
fn select(&mut self, candidate: Candidate<'_>) -> Option<Self::Output> {
match candidate {
Candidate::TextInput {
id,
bounds,
visible_bounds,
state,
} if state.text() == *self => Some(target::Text::Input {
id: id.cloned(),
bounds,
visible_bounds,
}),
Candidate::Text {
id,
bounds,
visible_bounds,
content,
} if content == *self => Some(target::Text::Raw {
id: id.cloned(),
bounds,
visible_bounds,
}),
_ => None,
}
}
fn description(&self) -> String {
format!("text == {self:?}")
}
}
impl Selector for String {
type Output = target::Text;
fn select(&mut self, candidate: Candidate<'_>) -> Option<Self::Output> {
self.as_str().select(candidate)
}
fn description(&self) -> String {
self.as_str().description()
}
}
impl Selector for widget::Id {
type Output = Target;
fn select(&mut self, candidate: Candidate<'_>) -> Option<Self::Output> {
if candidate.id() != Some(self) {
return None;
}
Some(Target::from(candidate))
}
fn description(&self) -> String {
format!("id == {self:?}")
}
}
impl Selector for Point {
type Output = Target;
fn select(&mut self, candidate: Candidate<'_>) -> Option<Self::Output> {
candidate
.visible_bounds()
.is_some_and(|visible_bounds| visible_bounds.contains(*self))
.then(|| Target::from(candidate))
}
fn description(&self) -> String {
format!("bounds contains {self:?}")
}
}
impl<F, T> Selector for F
where
F: FnMut(Candidate<'_>) -> Option<T>,
{
type Output = T;
fn select(&mut self, candidate: Candidate<'_>) -> Option<Self::Output> {
(self)(candidate)
}
fn description(&self) -> String {
format!("custom selector: {}", std::any::type_name_of_val(self))
}
}
/// Creates a new [`Selector`] that matches widgets with the given [`widget::Id`].
pub fn id(id: impl Into<widget::Id>) -> impl Selector<Output = Target> {
id.into()
}

291
selector/src/target.rs Normal file
View file

@ -0,0 +1,291 @@
use crate::core::widget::Id;
use crate::core::widget::operation::{Focusable, Scrollable, TextInput};
use crate::core::{Rectangle, Vector};
use std::any::Any;
/// A generic widget match produced during selection.
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq)]
pub enum Target {
Container {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
},
Focusable {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
},
Scrollable {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
content_bounds: Rectangle,
translation: Vector,
},
TextInput {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
content: String,
},
Text {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
content: String,
},
Custom {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
},
}
impl Target {
/// Returns the layout bounds of the [`Target`].
pub fn bounds(&self) -> Rectangle {
match self {
Target::Container { bounds, .. }
| Target::Focusable { bounds, .. }
| Target::Scrollable { bounds, .. }
| Target::TextInput { bounds, .. }
| Target::Text { bounds, .. }
| Target::Custom { bounds, .. } => *bounds,
}
}
/// Returns the visible bounds of the [`Target`], in screen coordinates.
pub fn visible_bounds(&self) -> Option<Rectangle> {
match self {
Target::Container { visible_bounds, .. }
| Target::Focusable { visible_bounds, .. }
| Target::Scrollable { visible_bounds, .. }
| Target::TextInput { visible_bounds, .. }
| Target::Text { visible_bounds, .. }
| Target::Custom { visible_bounds, .. } => *visible_bounds,
}
}
}
impl From<Candidate<'_>> for Target {
fn from(candidate: Candidate<'_>) -> Self {
match candidate {
Candidate::Container {
id,
bounds,
visible_bounds,
} => Self::Container {
id: id.cloned(),
bounds,
visible_bounds,
},
Candidate::Focusable {
id,
bounds,
visible_bounds,
..
} => Self::Focusable {
id: id.cloned(),
bounds,
visible_bounds,
},
Candidate::Scrollable {
id,
bounds,
visible_bounds,
content_bounds,
translation,
..
} => Self::Scrollable {
id: id.cloned(),
bounds,
visible_bounds,
content_bounds,
translation,
},
Candidate::TextInput {
id,
bounds,
visible_bounds,
state,
} => Self::TextInput {
id: id.cloned(),
bounds,
visible_bounds,
content: state.text().to_owned(),
},
Candidate::Text {
id,
bounds,
visible_bounds,
content,
} => Self::Text {
id: id.cloned(),
bounds,
visible_bounds,
content: content.to_owned(),
},
Candidate::Custom {
id,
bounds,
visible_bounds,
..
} => Self::Custom {
id: id.cloned(),
bounds,
visible_bounds,
},
}
}
}
impl Bounded for Target {
fn bounds(&self) -> Rectangle {
self.bounds()
}
fn visible_bounds(&self) -> Option<Rectangle> {
self.visible_bounds()
}
}
/// A selection candidate.
///
/// This is provided to [`Selector::select`](crate::Selector::select).
#[allow(missing_docs)]
#[derive(Clone)]
pub enum Candidate<'a> {
Container {
id: Option<&'a Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
},
Focusable {
id: Option<&'a Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
state: &'a dyn Focusable,
},
Scrollable {
id: Option<&'a Id>,
bounds: Rectangle,
content_bounds: Rectangle,
visible_bounds: Option<Rectangle>,
translation: Vector,
state: &'a dyn Scrollable,
},
TextInput {
id: Option<&'a Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
state: &'a dyn TextInput,
},
Text {
id: Option<&'a Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
content: &'a str,
},
Custom {
id: Option<&'a Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
state: &'a dyn Any,
},
}
impl<'a> Candidate<'a> {
/// Returns the widget [`Id`] of the [`Candidate`].
pub fn id(&self) -> Option<&'a Id> {
match self {
Candidate::Container { id, .. }
| Candidate::Focusable { id, .. }
| Candidate::Scrollable { id, .. }
| Candidate::TextInput { id, .. }
| Candidate::Text { id, .. }
| Candidate::Custom { id, .. } => *id,
}
}
/// Returns the layout bounds of the [`Candidate`].
pub fn bounds(&self) -> Rectangle {
match self {
Candidate::Container { bounds, .. }
| Candidate::Focusable { bounds, .. }
| Candidate::Scrollable { bounds, .. }
| Candidate::TextInput { bounds, .. }
| Candidate::Text { bounds, .. }
| Candidate::Custom { bounds, .. } => *bounds,
}
}
/// Returns the visible bounds of the [`Candidate`], in screen coordinates.
pub fn visible_bounds(&self) -> Option<Rectangle> {
match self {
Candidate::Container { visible_bounds, .. }
| Candidate::Focusable { visible_bounds, .. }
| Candidate::Scrollable { visible_bounds, .. }
| Candidate::TextInput { visible_bounds, .. }
| Candidate::Text { visible_bounds, .. }
| Candidate::Custom { visible_bounds, .. } => *visible_bounds,
}
}
}
/// A bounded type has both layout bounds and visible bounds.
///
/// This trait lets us write generic code over the [`Output`](crate::Selector::Output)
/// of a [`Selector`](crate::Selector).
pub trait Bounded: std::fmt::Debug {
/// Returns the layout bounds.
fn bounds(&self) -> Rectangle;
/// Returns the visible bounds, in screen coordinates.
fn visible_bounds(&self) -> Option<Rectangle>;
}
/// A text match.
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq)]
pub enum Text {
Raw {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
},
Input {
id: Option<Id>,
bounds: Rectangle,
visible_bounds: Option<Rectangle>,
},
}
impl Text {
/// Returns the layout bounds of the [`Text`].
pub fn bounds(&self) -> Rectangle {
match self {
Text::Raw { bounds, .. } | Text::Input { bounds, .. } => *bounds,
}
}
/// Returns the visible bounds of the [`Text`], in screen coordinates.
pub fn visible_bounds(&self) -> Option<Rectangle> {
match self {
Text::Raw { visible_bounds, .. }
| Text::Input { visible_bounds, .. } => *visible_bounds,
}
}
}
impl Bounded for Text {
fn bounds(&self) -> Rectangle {
self.bounds()
}
fn visible_bounds(&self) -> Option<Rectangle> {
self.visible_bounds()
}
}

View file

@ -30,12 +30,14 @@
//! ]
//! }
//! ```
use crate::message;
use crate::program::{self, Program};
use crate::shell;
use crate::theme;
use crate::window;
use crate::{
Element, Executor, Font, Result, Settings, Size, Subscription, Task, Theme,
Element, Executor, Font, Preset, Result, Settings, Size, Subscription,
Task, Theme,
};
use iced_debug as debug;
@ -81,7 +83,7 @@ pub fn application<State, Message, Theme, Renderer>(
) -> Application<impl Program<State = State, Message = Message, Theme = Theme>>
where
State: 'static,
Message: program::Message + 'static,
Message: Send + 'static,
Theme: theme::Base,
Renderer: program::Renderer,
{
@ -100,7 +102,7 @@ where
impl<State, Message, Theme, Renderer, Boot, Update, View> Program
for Instance<State, Message, Theme, Renderer, Boot, Update, View>
where
Message: program::Message + 'static,
Message: Send + 'static,
Theme: theme::Base,
Renderer: program::Renderer,
Boot: self::BootFn<State, Message>,
@ -128,7 +130,7 @@ where
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
debug::hot(|| self.update.update(state, message))
self.update.update(state, message)
}
fn view<'a>(
@ -136,7 +138,15 @@ where
state: &'a Self::State,
_window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
debug::hot(|| self.view.view(state))
self.view.view(state)
}
fn settings(&self) -> Settings {
Settings::default()
}
fn window(&self) -> Option<iced_core::window::Settings> {
Some(window::Settings::default())
}
}
@ -152,6 +162,7 @@ where
},
settings: Settings::default(),
window: window::Settings::default(),
presets: Vec::new(),
}
}
@ -167,6 +178,7 @@ pub struct Application<P: Program> {
raw: P,
settings: Settings,
window: window::Settings,
presets: Vec<Preset<P::State, P::Message>>,
}
impl<P: Program> Application<P> {
@ -174,22 +186,25 @@ impl<P: Program> Application<P> {
pub fn run(self) -> Result
where
Self: 'static,
P::Message: message::MaybeDebug + message::MaybeClone,
{
#[cfg(all(feature = "debug", not(target_arch = "wasm32")))]
let program = {
iced_debug::init(iced_debug::Metadata {
name: P::name(),
theme: None,
can_time_travel: cfg!(feature = "time-travel"),
});
#[cfg(feature = "debug")]
iced_debug::init(iced_debug::Metadata {
name: P::name(),
theme: None,
can_time_travel: cfg!(feature = "time-travel"),
});
iced_devtools::attach(self.raw)
};
#[cfg(feature = "tester")]
let program = iced_tester::attach(self);
#[cfg(any(not(feature = "debug"), target_arch = "wasm32"))]
let program = self.raw;
#[cfg(all(feature = "debug", not(feature = "tester")))]
let program = iced_devtools::attach(self);
Ok(shell::run(program, self.settings, Some(self.window))?)
#[cfg(not(any(feature = "tester", feature = "debug")))]
let program = self;
Ok(shell::run(program)?)
}
/// Sets the [`Settings`] that will be used to run the [`Application`].
@ -329,10 +344,11 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_title(self.raw, move |state, _window| {
debug::hot(|| title.title(state))
title.title(state)
}),
settings: self.settings,
window: self.window,
presets: self.presets,
}
}
@ -344,11 +360,10 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_subscription(self.raw, move |state| {
debug::hot(|| f(state))
}),
raw: program::with_subscription(self.raw, f),
settings: self.settings,
window: self.window,
presets: self.presets,
}
}
@ -361,10 +376,11 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_theme(self.raw, move |state, _window| {
debug::hot(|| f.theme(state))
f.theme(state)
}),
settings: self.settings,
window: self.window,
presets: self.presets,
}
}
@ -376,11 +392,10 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_style(self.raw, move |state, theme| {
debug::hot(|| f(state, theme))
}),
raw: program::with_style(self.raw, f),
settings: self.settings,
window: self.window,
presets: self.presets,
}
}
@ -393,10 +408,11 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_scale_factor(self.raw, move |state, _window| {
debug::hot(|| f(state))
f(state)
}),
settings: self.settings,
window: self.window,
presets: self.presets,
}
}
@ -413,8 +429,92 @@ impl<P: Program> Application<P> {
raw: program::with_executor::<P, E>(self.raw),
settings: self.settings,
window: self.window,
presets: self.presets,
}
}
/// Sets the boot presets of the [`Application`].
///
/// Presets can be used to override the default booting strategy
/// of your application during testing to create reproducible
/// environments.
pub fn presets(
self,
presets: impl IntoIterator<Item = Preset<P::State, P::Message>>,
) -> Self {
Self {
presets: presets.into_iter().collect(),
..self
}
}
}
impl<P: Program> Program for Application<P> {
type State = P::State;
type Message = P::Message;
type Theme = P::Theme;
type Renderer = P::Renderer;
type Executor = P::Executor;
fn name() -> &'static str {
P::name()
}
fn settings(&self) -> Settings {
self.settings.clone()
}
fn window(&self) -> Option<window::Settings> {
Some(self.window.clone())
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.raw.boot()
}
fn update(
&self,
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
debug::hot(|| self.raw.update(state, message))
}
fn view<'a>(
&self,
state: &'a Self::State,
window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
debug::hot(|| self.raw.view(state, window))
}
fn title(&self, state: &Self::State, window: window::Id) -> String {
debug::hot(|| self.raw.title(state, window))
}
fn subscription(&self, state: &Self::State) -> Subscription<Self::Message> {
debug::hot(|| self.raw.subscription(state))
}
fn theme(
&self,
state: &Self::State,
window: iced_core::window::Id,
) -> Option<Self::Theme> {
debug::hot(|| self.raw.theme(state, window))
}
fn style(&self, state: &Self::State, theme: &Self::Theme) -> theme::Style {
debug::hot(|| self.raw.style(state, theme))
}
fn scale_factor(&self, state: &Self::State, window: window::Id) -> f32 {
debug::hot(|| self.raw.scale_factor(state, window))
}
fn presets(&self) -> &[Preset<Self::State, Self::Message>] {
&self.presets
}
}
/// The logic to initialize the `State` of some [`Application`].

View file

@ -29,7 +29,7 @@ pub fn timed<State, Message, Theme, Renderer>(
>
where
State: 'static,
Message: program::Message + 'static,
Message: Send + 'static,
Theme: theme::Base + 'static,
Renderer: program::Renderer + 'static,
{
@ -68,7 +68,7 @@ where
View,
>
where
Message: program::Message + 'static,
Message: Send + 'static,
Theme: theme::Base + 'static,
Renderer: program::Renderer + 'static,
Boot: self::BootFn<State, Message>,
@ -88,6 +88,14 @@ where
name.split("::").next().unwrap_or("a_cool_application")
}
fn settings(&self) -> Settings {
Settings::default()
}
fn window(&self) -> Option<iced_core::window::Settings> {
Some(window::Settings::default())
}
fn boot(&self) -> (State, Task<Self::Message>) {
let (state, task) = self.boot.boot();
@ -143,6 +151,7 @@ where
},
settings: Settings::default(),
window: window::Settings::default(),
presets: Vec::new(),
}
}

View file

@ -1,11 +1,13 @@
//! Create and run daemons that run in the background.
use crate::application;
use crate::message;
use crate::program::{self, Program};
use crate::shell;
use crate::theme;
use crate::window;
use crate::{
Element, Executor, Font, Result, Settings, Subscription, Task, Theme,
Element, Executor, Font, Preset, Result, Settings, Subscription, Task,
Theme,
};
use iced_debug as debug;
@ -29,7 +31,7 @@ pub fn daemon<State, Message, Theme, Renderer>(
) -> Daemon<impl Program<State = State, Message = Message, Theme = Theme>>
where
State: 'static,
Message: program::Message + 'static,
Message: Send + 'static,
Theme: theme::Base,
Renderer: program::Renderer,
{
@ -48,7 +50,7 @@ where
impl<State, Message, Theme, Renderer, Boot, Update, View> Program
for Instance<State, Message, Theme, Renderer, Boot, Update, View>
where
Message: program::Message + 'static,
Message: Send + 'static,
Theme: theme::Base,
Renderer: program::Renderer,
Boot: application::BootFn<State, Message>,
@ -67,6 +69,14 @@ where
name.split("::").next().unwrap_or("a_cool_daemon")
}
fn settings(&self) -> Settings {
Settings::default()
}
fn window(&self) -> Option<iced_core::window::Settings> {
None
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.boot.boot()
}
@ -76,7 +86,7 @@ where
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
debug::hot(|| self.update.update(state, message))
self.update.update(state, message)
}
fn view<'a>(
@ -84,7 +94,7 @@ where
state: &'a Self::State,
window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
debug::hot(|| self.view.view(state, window))
self.view.view(state, window)
}
}
@ -99,6 +109,7 @@ where
_renderer: PhantomData,
},
settings: Settings::default(),
presets: Vec::new(),
}
}
@ -113,6 +124,7 @@ where
pub struct Daemon<P: Program> {
raw: P,
settings: Settings,
presets: Vec<Preset<P::State, P::Message>>,
}
impl<P: Program> Daemon<P> {
@ -120,6 +132,7 @@ impl<P: Program> Daemon<P> {
pub fn run(self) -> Result
where
Self: 'static,
P::Message: message::MaybeDebug + message::MaybeClone,
{
#[cfg(all(feature = "debug", not(target_arch = "wasm32")))]
let program = {
@ -129,13 +142,13 @@ impl<P: Program> Daemon<P> {
can_time_travel: cfg!(feature = "time-travel"),
});
iced_devtools::attach(self.raw)
iced_devtools::attach(self)
};
#[cfg(any(not(feature = "debug"), target_arch = "wasm32"))]
let program = self.raw;
let program = self;
Ok(shell::run(program, self.settings, None)?)
Ok(shell::run(program)?)
}
/// Sets the [`Settings`] that will be used to run the [`Daemon`].
@ -180,9 +193,10 @@ impl<P: Program> Daemon<P> {
> {
Daemon {
raw: program::with_title(self.raw, move |state, window| {
debug::hot(|| title.title(state, window))
title.title(state, window)
}),
settings: self.settings,
presets: self.presets,
}
}
@ -194,10 +208,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_subscription(self.raw, move |state| {
debug::hot(|| f(state))
}),
raw: program::with_subscription(self.raw, f),
settings: self.settings,
presets: self.presets,
}
}
@ -210,9 +223,10 @@ impl<P: Program> Daemon<P> {
> {
Daemon {
raw: program::with_theme(self.raw, move |state, window| {
debug::hot(|| f.theme(state, window))
f.theme(state, window)
}),
settings: self.settings,
presets: self.presets,
}
}
@ -224,10 +238,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_style(self.raw, move |state, theme| {
debug::hot(|| f(state, theme))
}),
raw: program::with_style(self.raw, f),
settings: self.settings,
presets: self.presets,
}
}
@ -239,10 +252,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_scale_factor(self.raw, move |state, window| {
debug::hot(|| f(state, window))
}),
raw: program::with_scale_factor(self.raw, f),
settings: self.settings,
presets: self.presets,
}
}
@ -258,8 +270,92 @@ impl<P: Program> Daemon<P> {
Daemon {
raw: program::with_executor::<P, E>(self.raw),
settings: self.settings,
presets: self.presets,
}
}
/// Sets the boot presets of the [`Daemon`].
///
/// Presets can be used to override the default booting strategy
/// of your application during testing to create reproducible
/// environments.
pub fn presets(
self,
presets: impl IntoIterator<Item = Preset<P::State, P::Message>>,
) -> Self {
Self {
presets: presets.into_iter().collect(),
..self
}
}
}
impl<P: Program> Program for Daemon<P> {
type State = P::State;
type Message = P::Message;
type Theme = P::Theme;
type Renderer = P::Renderer;
type Executor = P::Executor;
fn name() -> &'static str {
P::name()
}
fn settings(&self) -> Settings {
self.settings.clone()
}
fn window(&self) -> Option<window::Settings> {
None
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
self.raw.boot()
}
fn update(
&self,
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
debug::hot(|| self.raw.update(state, message))
}
fn view<'a>(
&self,
state: &'a Self::State,
window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
debug::hot(|| self.raw.view(state, window))
}
fn title(&self, state: &Self::State, window: window::Id) -> String {
debug::hot(|| self.raw.title(state, window))
}
fn subscription(&self, state: &Self::State) -> Subscription<Self::Message> {
debug::hot(|| self.raw.subscription(state))
}
fn theme(
&self,
state: &Self::State,
window: iced_core::window::Id,
) -> Option<Self::Theme> {
debug::hot(|| self.raw.theme(state, window))
}
fn style(&self, state: &Self::State, theme: &Self::Theme) -> theme::Style {
debug::hot(|| self.raw.style(state, theme))
}
fn scale_factor(&self, state: &Self::State, window: window::Id) -> f32 {
debug::hot(|| self.raw.scale_factor(state, window))
}
fn presets(&self) -> &[Preset<Self::State, Self::Message>] {
&self.presets
}
}
/// The title logic of some [`Daemon`].

View file

@ -326,8 +326,8 @@
//!
//! Tasks can also be used to interact with the iced runtime. Some modules
//! expose functions that create tasks for different purposes—like [changing
//! window settings](window#functions), [focusing a widget](widget::focus_next), or
//! [querying its visible bounds](widget::container::visible_bounds).
//! window settings](window#functions), [focusing a widget](widget::operation::focus_next), or
//! [querying its visible bounds](widget::selector::find_by_id).
//!
//! Like futures and streams, tasks expose [a monadic interface](Task::then)—but they can also be
//! [mapped](Task::map), [chained](Task::chain), [batched](Task::batch), [canceled](Task::abortable),
@ -525,6 +525,8 @@ pub use crate::core::{
Function, Gradient, Length, Padding, Pixels, Point, Radians, Rectangle,
Rotation, Settings, Shadow, Size, Theme, Transformation, Vector, never,
};
pub use crate::program::Preset;
pub use crate::program::message;
pub use crate::runtime::exit;
pub use iced_futures::Subscription;
@ -621,15 +623,13 @@ pub mod touch {
#[allow(hidden_glob_reexports)]
pub mod widget {
//! Use the built-in widgets or create your own.
pub use iced_runtime::widget::*;
pub use iced_widget::*;
// We hide the re-exported modules by `iced_widget`
mod core {}
mod graphics {}
mod native {}
mod renderer {}
mod style {}
mod runtime {}
}
pub use application::Application;
@ -698,7 +698,7 @@ pub fn run<State, Message, Theme, Renderer>(
) -> Result
where
State: Default + 'static,
Message: program::Message + 'static,
Message: Send + message::MaybeDebug + message::MaybeClone + 'static,
Theme: theme::Base + 'static,
Renderer: program::Renderer + 'static,
{

View file

@ -15,10 +15,13 @@ workspace = true
[dependencies]
iced_runtime.workspace = true
iced_program.workspace = true
iced_selector.workspace = true
iced_renderer.workspace = true
iced_renderer.features = ["fira-sans"]
nom.workspace = true
png.workspace = true
sha2.workspace = true
thiserror.workspace = true

485
test/src/emulator.rs Normal file
View file

@ -0,0 +1,485 @@
//! Run your application in a headless runtime.
use crate::core;
use crate::core::mouse;
use crate::core::renderer;
use crate::core::widget;
use crate::core::{Element, Point, Size};
use crate::instruction;
use crate::program;
use crate::program::Program;
use crate::runtime;
use crate::runtime::futures::futures::StreamExt;
use crate::runtime::futures::futures::channel::mpsc;
use crate::runtime::futures::futures::stream;
use crate::runtime::futures::subscription;
use crate::runtime::futures::{Executor, Runtime};
use crate::runtime::task;
use crate::runtime::user_interface;
use crate::runtime::window;
use crate::runtime::{Task, UserInterface};
use crate::{Instruction, Selector};
use std::fmt;
/// A headless runtime that can run iced applications and execute
/// [instructions](crate::Instruction).
///
/// An [`Emulator`] runs its program as faithfully as possible to the real thing.
/// It will run subscriptions and tasks with the [`Executor`](Program::Executor) of
/// the [`Program`].
///
/// If you want to run a simulation without side effects, use a [`Simulator`](crate::Simulator)
/// instead.
pub struct Emulator<P: Program> {
state: P::State,
runtime: Runtime<P::Executor, mpsc::Sender<Event<P>>, Event<P>>,
renderer: P::Renderer,
mode: Mode,
size: Size,
window: core::window::Id,
cursor: mouse::Cursor,
clipboard: Clipboard,
cache: Option<user_interface::Cache>,
pending_tasks: usize,
}
/// An emulation event.
pub enum Event<P: Program> {
/// An action that must be [performed](Emulator::perform) by the [`Emulator`].
Action(Action<P>),
/// An [`Instruction`] failed to be executed.
Failed(Instruction),
/// The [`Emulator`] is ready.
Ready,
}
/// An action that must be [performed](Emulator::perform) by the [`Emulator`].
pub struct Action<P: Program>(Action_<P>);
enum Action_<P: Program> {
Runtime(runtime::Action<P::Message>),
CountDown,
}
impl<P: Program + 'static> Emulator<P> {
/// Creates a new [`Emulator`] of the [`Program`] with the given [`Mode`] and [`Size`].
///
/// The [`Emulator`] will send [`Event`] notifications through the provided [`mpsc::Sender`].
///
/// When the [`Emulator`] has finished booting, an [`Event::Ready`] will be produced.
pub fn new(
sender: mpsc::Sender<Event<P>>,
program: &P,
mode: Mode,
size: Size,
) -> Emulator<P> {
Self::with_preset(sender, program, mode, size, None)
}
/// Creates a new [`Emulator`] analogously to [`new`](Self::new), but it also takes a
/// [`program::Preset`] that will be used as the initial state.
///
/// When the [`Emulator`] has finished booting, an [`Event::Ready`] will be produced.
pub fn with_preset(
sender: mpsc::Sender<Event<P>>,
program: &P,
mode: Mode,
size: Size,
preset: Option<&program::Preset<P::State, P::Message>>,
) -> Emulator<P> {
use renderer::Headless;
let settings = program.settings();
// TODO: Error handling
let executor = P::Executor::new().expect("Create emulator executor");
let renderer = executor
.block_on(P::Renderer::new(
settings.default_font,
settings.default_text_size,
None,
))
.expect("Create emulator renderer");
let runtime = Runtime::new(executor, sender);
let (state, task) = runtime.enter(|| {
if let Some(preset) = preset {
preset.boot()
} else {
program.boot()
}
});
let mut emulator = Self {
state,
runtime,
renderer,
mode,
size,
clipboard: Clipboard { content: None },
cursor: mouse::Cursor::Unavailable,
window: core::window::Id::unique(),
cache: Some(user_interface::Cache::default()),
pending_tasks: 0,
};
emulator.resubscribe(program);
emulator.wait_for(task);
emulator
}
/// Updates the state of the [`Emulator`] program.
///
/// This is equivalent to calling the [`Program::update`] function,
/// resubscribing to any subscriptions, and running the resulting tasks
/// concurrently.
pub fn update(&mut self, program: &P, message: P::Message) {
let task = self
.runtime
.enter(|| program.update(&mut self.state, message));
self.resubscribe(program);
match self.mode {
Mode::Zen if self.pending_tasks > 0 => self.wait_for(task),
_ => {
if let Some(stream) = task::into_stream(task) {
self.runtime.run(
stream
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.boxed(),
);
}
}
}
}
/// Performs an [`Action`].
///
/// Whenever an [`Emulator`] sends an [`Event::Action`], this
/// method must be called to proceed with the execution.
pub fn perform(&mut self, program: &P, action: Action<P>) {
match action.0 {
Action_::CountDown => {
if self.pending_tasks > 0 {
self.pending_tasks -= 1;
if self.pending_tasks == 0 {
self.runtime.send(Event::Ready);
}
}
}
Action_::Runtime(action) => match action {
runtime::Action::Output(message) => {
self.update(program, message);
}
runtime::Action::LoadFont { .. } => {
// TODO
}
runtime::Action::Widget(operation) => {
let mut user_interface = UserInterface::build(
program.view(&self.state, self.window),
self.size,
self.cache.take().unwrap(),
&mut self.renderer,
);
let mut operation = Some(operation);
while let Some(mut current) = operation.take() {
user_interface.operate(&self.renderer, &mut current);
match current.finish() {
widget::operation::Outcome::None => {}
widget::operation::Outcome::Some(()) => {}
widget::operation::Outcome::Chain(next) => {
operation = Some(next);
}
}
}
self.cache = Some(user_interface.into_cache());
}
runtime::Action::Clipboard(action) => {
// TODO
dbg!(action);
}
runtime::Action::Window(action) => match action {
window::Action::Open(id, _settings, sender) => {
self.window = id;
let _ = sender.send(self.window);
}
window::Action::GetOldest(sender)
| window::Action::GetLatest(sender) => {
let _ = sender.send(Some(self.window));
}
window::Action::GetSize(id, sender) => {
if id == self.window {
let _ = sender.send(self.size);
}
}
window::Action::GetMaximized(id, sender) => {
if id == self.window {
let _ = sender.send(false);
}
}
window::Action::GetMinimized(id, sender) => {
if id == self.window {
let _ = sender.send(None);
}
}
window::Action::GetPosition(id, sender) => {
if id == self.window {
let _ = sender.send(Some(Point::ORIGIN));
}
}
window::Action::GetScaleFactor(id, sender) => {
if id == self.window {
let _ = sender.send(1.0);
}
}
window::Action::GetMode(id, sender) => {
if id == self.window {
let _ = sender.send(core::window::Mode::Windowed);
}
}
_ => {
// Ignored
}
},
runtime::Action::System(action) => {
// TODO
dbg!(action);
}
runtime::Action::Exit => {
// TODO
}
runtime::Action::Reload => {
// TODO
}
},
}
}
/// Runs an [`Instruction`].
///
/// If the [`Instruction`] executes successfully, an [`Event::Ready`] will be
/// produced by the [`Emulator`].
///
/// Otherwise, an [`Event::Failed`] will be triggered.
pub fn run(&mut self, program: &P, instruction: Instruction) {
let mut user_interface = UserInterface::build(
program.view(&self.state, self.window),
self.size,
self.cache.take().unwrap(),
&mut self.renderer,
);
let mut messages = Vec::new();
match &instruction {
Instruction::Interact(interaction) => {
let Some(events) = interaction.events(|target| match target {
instruction::Target::Point(position) => Some(*position),
instruction::Target::Text(text) => {
use widget::Operation;
let mut operation = Selector::find(text.as_str());
user_interface.operate(
&self.renderer,
&mut widget::operation::black_box(&mut operation),
);
match operation.finish() {
widget::operation::Outcome::Some(text) => {
Some(text?.visible_bounds()?.center())
}
_ => None,
}
}
}) else {
self.runtime.send(Event::Failed(instruction));
self.cache = Some(user_interface.into_cache());
return;
};
for event in &events {
if let core::Event::Mouse(mouse::Event::CursorMoved {
position,
}) = event
{
self.cursor = mouse::Cursor::Available(*position);
}
}
let (_state, _status) = user_interface.update(
&events,
self.cursor,
&mut self.renderer,
&mut self.clipboard,
&mut messages,
);
self.cache = Some(user_interface.into_cache());
let task = self.runtime.enter(|| {
Task::batch(messages.into_iter().map(|message| {
program.update(&mut self.state, message)
}))
});
self.resubscribe(program);
self.wait_for(task);
}
Instruction::Expect(expectation) => match expectation {
instruction::Expectation::Text(text) => {
use widget::Operation;
let mut operation = Selector::find(text.as_str());
user_interface.operate(
&self.renderer,
&mut widget::operation::black_box(&mut operation),
);
match operation.finish() {
widget::operation::Outcome::Some(Some(_text)) => {
self.runtime.send(Event::Ready);
}
_ => {
self.runtime.send(Event::Failed(instruction));
}
}
self.cache = Some(user_interface.into_cache());
}
},
}
}
fn wait_for(&mut self, task: Task<P::Message>) {
if let Some(stream) = task::into_stream(task) {
match self.mode {
Mode::Zen => {
self.pending_tasks += 1;
self.runtime.run(
stream
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.chain(stream::once(async {
Event::Action(Action(Action_::CountDown))
}))
.boxed(),
);
}
Mode::Patient => {
self.runtime.run(
stream
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.chain(stream::once(async { Event::Ready }))
.boxed(),
);
}
Mode::Immediate => {
self.runtime.run(
stream
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.boxed(),
);
self.runtime.send(Event::Ready);
}
}
} else if self.pending_tasks == 0 {
self.runtime.send(Event::Ready);
}
}
fn resubscribe(&mut self, program: &P) {
self.runtime
.track(subscription::into_recipes(self.runtime.enter(|| {
program.subscription(&self.state).map(|message| {
Event::Action(Action(Action_::Runtime(
runtime::Action::Output(message),
)))
})
})));
}
/// Returns the current view of the [`Emulator`].
pub fn view(
&self,
program: &P,
) -> Element<'_, P::Message, P::Theme, P::Renderer> {
program.view(&self.state, self.window)
}
/// Returns the current theme of the [`Emulator`].
pub fn theme(&self, program: &P) -> Option<P::Theme> {
program.theme(&self.state, self.window)
}
/// Turns the [`Emulator`] into its internal state.
pub fn into_state(self) -> (P::State, core::window::Id) {
(self.state, self.window)
}
}
/// The strategy used by an [`Emulator`] when waiting for tasks to finish.
///
/// A [`Mode`] can be used to make an [`Emulator`] wait for side effects to finish before
/// continuing execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
/// Waits for all tasks spawned by an [`Instruction`], as well as all tasks indirectly
/// spawned by the the results of those tasks.
///
/// This is the default.
#[default]
Zen,
/// Waits only for the tasks directly spawned by an [`Instruction`].
Patient,
/// Never waits for any tasks to finish.
Immediate,
}
impl Mode {
/// A list of all the available modes.
pub const ALL: &[Self] = &[Self::Zen, Self::Patient, Self::Immediate];
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Zen => "Zen",
Self::Patient => "Patient",
Self::Immediate => "Immediate",
})
}
}
struct Clipboard {
content: Option<String>,
}
impl core::Clipboard for Clipboard {
fn read(&self, _kind: core::clipboard::Kind) -> Option<String> {
self.content.clone()
}
fn write(&mut self, _kind: core::clipboard::Kind, contents: String) {
self.content = Some(contents);
}
}

76
test/src/error.rs Normal file
View file

@ -0,0 +1,76 @@
use crate::Instruction;
use crate::ice;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
/// A test error.
#[derive(Debug, Clone, thiserror::Error)]
pub enum Error {
/// No matching widget was found for the [`Selector`](crate::Selector).
#[error("no matching widget was found for the selector: {selector}")]
SelectorNotFound {
/// A description of the selector.
selector: String,
},
/// A target matched, but is not visible.
#[error("the matching target is not visible: {target:?}")]
TargetNotVisible {
/// The target
target: Arc<dyn std::fmt::Debug + Send + Sync>,
},
/// An IO operation failed.
#[error("an IO operation failed: {0}")]
IOFailed(Arc<io::Error>),
/// The decoding of some PNG image failed.
#[error("the decoding of some PNG image failed: {0}")]
PngDecodingFailed(Arc<png::DecodingError>),
/// The encoding of some PNG image failed.
#[error("the encoding of some PNG image failed: {0}")]
PngEncodingFailed(Arc<png::EncodingError>),
/// The parsing of an [`Ice`](crate::Ice) test failed.
#[error("the ice test ({file}) is invalid: {error}")]
IceParsingFailed {
/// The path of the test.
file: PathBuf,
/// The parse error.
error: ice::ParseError,
},
/// The execution of an [`Ice`](crate::Ice) test failed.
#[error("the ice test ({file}) failed")]
IceTestingFailed {
/// The path of the test.
file: PathBuf,
/// The [`Instruction`] that failed.
instruction: Instruction,
},
/// The [`Preset`](crate::program::Preset) of a program could not be found.
#[error(
"the preset \"{name}\" does not exist (available presets: {available:?})"
)]
PresetNotFound {
/// The name of the [`Preset`](crate::program::Preset).
name: String,
/// The available set of presets.
available: Vec<String>,
},
}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Self::IOFailed(Arc::new(error))
}
}
impl From<png::DecodingError> for Error {
fn from(error: png::DecodingError) -> Self {
Self::PngDecodingFailed(Arc::new(error))
}
}
impl From<png::EncodingError> for Error {
fn from(error: png::EncodingError) -> Self {
Self::PngEncodingFailed(Arc::new(error))
}
}

237
test/src/ice.rs Normal file
View file

@ -0,0 +1,237 @@
//! A shareable, simple format of end-to-end tests.
use crate::Instruction;
use crate::core::Size;
use crate::emulator;
use crate::instruction;
/// An end-to-end test for iced applications.
///
/// Ice tests encode a certain configuration together with a sequence of instructions.
/// An ice test passes if all the instructions can be executed successfully.
///
/// Normally, ice tests are run by an [`Emulator`](crate::Emulator) in continuous
/// integration pipelines.
///
/// Ice tests can be easily run by saving them as `.ice` files in a folder and simply
/// calling [`run`](crate::run). These test files can be recorded by enabling the `tester`
/// feature flag in the root crate.
#[derive(Debug, Clone, PartialEq)]
pub struct Ice {
/// The viewport [`Size`] that must be used for the test.
pub viewport: Size,
/// The [`emulator::Mode`] that must be used for the test.
pub mode: emulator::Mode,
/// The name of the [`Preset`](crate::program::Preset) that must be used for the test.
pub preset: Option<String>,
/// The sequence of instructions of the test.
pub instructions: Vec<Instruction>,
}
impl Ice {
/// Parses an [`Ice`] test from its textual representation.
///
/// Here is an example of the [`Ice`] test syntax:
///
/// ```text
/// viewport: 500x800
/// mode: Immediate
/// preset: Empty
/// -----
/// click "What needs to be done?"
/// type "Create the universe"
/// type enter
/// type "Make an apple pie"
/// type enter
/// expect "2 tasks left"
/// click "Create the universe"
/// expect "1 task left"
/// click "Make an apple pie"
/// expect "0 tasks left"
/// ```
///
/// This syntax is _very_ experimental and extremely likely to change often.
/// For this reason, it is reserved for advanced users that want to early test it.
///
/// Currently, in order to use it, you will need to earn the right and prove you understand
/// its experimental nature by reading the code!
pub fn parse(content: &str) -> Result<Self, ParseError> {
let Some((metadata, rest)) = content.split_once("-") else {
return Err(ParseError::NoMetadata);
};
let mut viewport = None;
let mut mode = None;
let mut preset = None;
for (i, line) in metadata.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let Some((field, value)) = line.split_once(':') else {
return Err(ParseError::InvalidMetadata {
line: i,
content: line.to_owned(),
});
};
match field.trim() {
"viewport" => {
viewport = Some(
if let Some((width, height)) =
value.trim().split_once('x')
&& let Ok(width) = width.parse()
&& let Ok(height) = height.parse()
{
Size::new(width, height)
} else {
return Err(ParseError::InvalidViewport {
line: i,
value: value.to_owned(),
});
},
);
}
"mode" => {
mode = Some(match value.trim().to_lowercase().as_str() {
"zen" => emulator::Mode::Zen,
"patient" => emulator::Mode::Patient,
"immediate" => emulator::Mode::Immediate,
_ => {
return Err(ParseError::InvalidMode {
line: i,
value: value.to_owned(),
});
}
});
}
"preset" => {
preset = Some(value.trim().to_owned());
}
field => {
return Err(ParseError::UnknownField {
line: i,
field: field.to_owned(),
});
}
}
}
let Some(viewport) = viewport else {
return Err(ParseError::MissingViewport);
};
let Some(mode) = mode else {
return Err(ParseError::MissingMode);
};
let instructions = rest
.lines()
.skip(1)
.enumerate()
.map(|(i, line)| {
Instruction::parse(line).map_err(|error| {
ParseError::InvalidInstruction {
line: metadata.lines().count() + 1 + i,
error,
}
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
viewport,
mode,
preset,
instructions,
})
}
}
impl std::fmt::Display for Ice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
"viewport: {width}x{height}",
width = self.viewport.width as u32, // TODO
height = self.viewport.height as u32, // TODO
)?;
writeln!(f, "mode: {}", self.mode)?;
if let Some(preset) = &self.preset {
writeln!(f, "preset: {preset}")?;
}
f.write_str("-----\n")?;
for instruction in &self.instructions {
instruction.fmt(f)?;
f.write_str("\n")?;
}
Ok(())
}
}
/// An error produced during [`Ice::parse`].
#[derive(Debug, Clone, thiserror::Error)]
pub enum ParseError {
/// No metadata is present.
#[error("the ice test has no metadata")]
NoMetadata,
/// The metadata is invalid.
#[error("invalid metadata in line {line}: \"{content}\"")]
InvalidMetadata {
/// The number of the invalid line.
line: usize,
/// The content of the invalid line.
content: String,
},
/// The viewport is invalid.
#[error("invalid viewport in line {line}: \"{value}\"")]
InvalidViewport {
/// The number of the invalid line.
line: usize,
/// The invalid value.
value: String,
},
/// The [`emulator::Mode`] is invalid.
#[error("invalid mode in line {line}: \"{value}\"")]
InvalidMode {
/// The number of the invalid line.
line: usize,
/// The invalid value.
value: String,
},
/// A metadata field is unknown.
#[error("unknown metadata field in line {line}: \"{field}\"")]
UnknownField {
/// The number of the invalid line.
line: usize,
/// The name of the unknown field.
field: String,
},
/// Viewport metadata is missing.
#[error("metadata is missing the viewport field")]
MissingViewport,
/// [`emulator::Mode`] metadata is missing.
#[error("metadata is missing the mode field")]
MissingMode,
/// An [`Instruction`] failed to parse.
#[error("invalid instruction in line {line}: {error}")]
InvalidInstruction {
/// The number of the invalid line.
line: usize,
/// The parse error.
error: instruction::ParseError,
},
}

729
test/src/instruction.rs Normal file
View file

@ -0,0 +1,729 @@
//! A step in an end-to-end test.
use crate::core::keyboard;
use crate::core::mouse;
use crate::core::{Event, Point};
use crate::simulator;
use std::fmt;
/// A step in an end-to-end test.
///
/// An [`Instruction`] can be run by an [`Emulator`](crate::Emulator).
#[derive(Debug, Clone, PartialEq)]
pub enum Instruction {
/// A user [`Interaction`].
Interact(Interaction),
/// A testing [`Expectation`].
Expect(Expectation),
}
impl Instruction {
/// Parses an [`Instruction`] from its textual representation.
pub fn parse(line: &str) -> Result<Self, ParseError> {
parser::run(line)
}
}
impl fmt::Display for Instruction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Instruction::Interact(interaction) => interaction.fmt(f),
Instruction::Expect(expectation) => expectation.fmt(f),
}
}
}
/// A user interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Interaction {
/// A mouse interaction.
Mouse(Mouse),
/// A keyboard interaction.
Keyboard(Keyboard),
}
impl Interaction {
/// Creates an [`Interaction`] from a runtime [`Event`].
///
/// This can be useful for recording tests during real usage.
pub fn from_event(event: &Event) -> Option<Self> {
Some(match event {
Event::Mouse(mouse) => Self::Mouse(match mouse {
mouse::Event::CursorMoved { position } => {
Mouse::Move(Target::Point(*position))
}
mouse::Event::ButtonPressed(button) => Mouse::Press {
button: *button,
target: None,
},
mouse::Event::ButtonReleased(button) => Mouse::Release {
button: *button,
target: None,
},
_ => None?,
}),
Event::Keyboard(keyboard) => Self::Keyboard(match keyboard {
keyboard::Event::KeyPressed { key, text, .. } => match key {
keyboard::Key::Named(keyboard::key::Named::Enter) => {
Keyboard::Press(Key::Enter)
}
keyboard::Key::Named(keyboard::key::Named::Escape) => {
Keyboard::Press(Key::Escape)
}
keyboard::Key::Named(keyboard::key::Named::Tab) => {
Keyboard::Press(Key::Tab)
}
keyboard::Key::Named(keyboard::key::Named::Backspace) => {
Keyboard::Press(Key::Backspace)
}
_ => Keyboard::Typewrite(text.as_ref()?.to_string()),
},
keyboard::Event::KeyReleased { key, .. } => match key {
keyboard::Key::Named(keyboard::key::Named::Enter) => {
Keyboard::Release(Key::Enter)
}
keyboard::Key::Named(keyboard::key::Named::Escape) => {
Keyboard::Release(Key::Escape)
}
keyboard::Key::Named(keyboard::key::Named::Tab) => {
Keyboard::Release(Key::Tab)
}
keyboard::Key::Named(keyboard::key::Named::Backspace) => {
Keyboard::Release(Key::Backspace)
}
_ => None?,
},
keyboard::Event::ModifiersChanged(_) => None?,
}),
_ => None?,
})
}
/// Merges two interactions together, if possible.
///
/// This method can turn certain sequences of interactions into a single one.
/// For instance, a mouse movement, left button press, and left button release
/// can all be merged into a single click interaction.
///
/// Merging is lossy and, therefore, it is not always desirable if you are recording
/// a test and want full reproducibility.
///
/// If the interactions cannot be merged, the `next` interaction will be
/// returned as the second element of the tuple.
pub fn merge(self, next: Self) -> (Self, Option<Self>) {
match (self, next) {
(Self::Mouse(current), Self::Mouse(next)) => {
match (current, next) {
(Mouse::Move(_), Mouse::Move(to)) => {
(Self::Mouse(Mouse::Move(to)), None)
}
(
Mouse::Move(to),
Mouse::Press {
button,
target: None,
},
) => (
Self::Mouse(Mouse::Press {
button,
target: Some(to),
}),
None,
),
(
Mouse::Move(to),
Mouse::Release {
button,
target: None,
},
) => (
Self::Mouse(Mouse::Release {
button,
target: Some(to),
}),
None,
),
(
Mouse::Press {
button: press,
target: press_at,
},
Mouse::Release {
button: release,
target: release_at,
},
) if press == release
&& release_at.as_ref().is_none_or(|release_at| {
Some(release_at) == press_at.as_ref()
}) =>
{
(
Self::Mouse(Mouse::Click {
button: press,
target: press_at,
}),
None,
)
}
(
Mouse::Press {
button,
target: Some(press_at),
},
Mouse::Move(move_at),
) if press_at == move_at => (
Self::Mouse(Mouse::Press {
button,
target: Some(press_at),
}),
None,
),
(
Mouse::Click {
button,
target: Some(click_at),
},
Mouse::Move(move_at),
) if click_at == move_at => (
Self::Mouse(Mouse::Click {
button,
target: Some(click_at),
}),
None,
),
(current, next) => {
(Self::Mouse(current), Some(Self::Mouse(next)))
}
}
}
(Self::Keyboard(current), Self::Keyboard(next)) => {
match (current, next) {
(
Keyboard::Typewrite(current),
Keyboard::Typewrite(next),
) => (
Self::Keyboard(Keyboard::Typewrite(format!(
"{current}{next}"
))),
None,
),
(Keyboard::Press(current), Keyboard::Release(next))
if current == next =>
{
(Self::Keyboard(Keyboard::Type(current)), None)
}
(current, next) => {
(Self::Keyboard(current), Some(Self::Keyboard(next)))
}
}
}
(current, next) => (current, Some(next)),
}
}
/// Returns a list of runtime events representing the [`Interaction`].
///
/// The `find_target` closure must convert a [`Target`] into its screen
/// coordinates.
pub fn events(
&self,
find_target: impl FnOnce(&Target) -> Option<Point>,
) -> Option<Vec<Event>> {
let mouse_move_ =
|to| Event::Mouse(mouse::Event::CursorMoved { position: to });
let mouse_press =
|button| Event::Mouse(mouse::Event::ButtonPressed(button));
let mouse_release =
|button| Event::Mouse(mouse::Event::ButtonReleased(button));
let key_press = |key| simulator::press_key(key, None);
let key_release = |key| simulator::release_key(key);
Some(match self {
Interaction::Mouse(mouse) => match mouse {
Mouse::Move(to) => vec![mouse_move_(find_target(to)?)],
Mouse::Press {
button,
target: Some(at),
} => vec![mouse_move_(find_target(at)?), mouse_press(*button)],
Mouse::Press {
button,
target: None,
} => {
vec![mouse_press(*button)]
}
Mouse::Release {
button,
target: Some(at),
} => {
vec![mouse_move_(find_target(at)?), mouse_release(*button)]
}
Mouse::Release {
button,
target: None,
} => {
vec![mouse_release(*button)]
}
Mouse::Click {
button,
target: Some(at),
} => {
vec![
mouse_move_(find_target(at)?),
mouse_press(*button),
mouse_release(*button),
]
}
Mouse::Click {
button,
target: None,
} => {
vec![mouse_press(*button), mouse_release(*button)]
}
},
Interaction::Keyboard(keyboard) => match keyboard {
Keyboard::Press(key) => vec![key_press(*key)],
Keyboard::Release(key) => vec![key_release(*key)],
Keyboard::Type(key) => vec![key_press(*key), key_release(*key)],
Keyboard::Typewrite(text) => {
simulator::typewrite(text).collect()
}
},
})
}
}
impl fmt::Display for Interaction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Interaction::Mouse(mouse) => mouse.fmt(f),
Interaction::Keyboard(keyboard) => keyboard.fmt(f),
}
}
}
/// A mouse interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Mouse {
/// The mouse was moved.
Move(Target),
/// A button was pressed.
Press {
/// The button.
button: mouse::Button,
/// The location of the press.
target: Option<Target>,
},
/// A button was released.
Release {
/// The button.
button: mouse::Button,
/// The location of the release.
target: Option<Target>,
},
/// A button was clicked.
Click {
/// The button.
button: mouse::Button,
/// The location of the click.
target: Option<Target>,
},
}
impl fmt::Display for Mouse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Mouse::Move(target) => {
write!(f, "move cursor to {}", target)
}
Mouse::Press { button, target } => {
write!(
f,
"press {}",
format::button_at(*button, target.as_ref())
)
}
Mouse::Release { button, target } => {
write!(
f,
"release {}",
format::button_at(*button, target.as_ref())
)
}
Mouse::Click { button, target } => {
write!(
f,
"click {}",
format::button_at(*button, target.as_ref())
)
}
}
}
}
/// The target of an interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Target {
/// A specific point of the viewport.
Point(Point),
/// A UI element containing the given text.
Text(String),
}
impl fmt::Display for Target {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Point(point) => f.write_str(&format::point(*point)),
Self::Text(text) => f.write_str(&format::string(text)),
}
}
}
/// A keyboard interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Keyboard {
/// A key was pressed.
Press(Key),
/// A key was released.
Release(Key),
/// A key was "typed" (press and released).
Type(Key),
/// A bunch of text was typed.
Typewrite(String),
}
impl fmt::Display for Keyboard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Keyboard::Press(key) => {
write!(f, "press {}", format::key(*key))
}
Keyboard::Release(key) => {
write!(f, "release {}", format::key(*key))
}
Keyboard::Type(key) => {
write!(f, "type {}", format::key(*key))
}
Keyboard::Typewrite(text) => {
write!(f, "type \"{text}\"")
}
}
}
}
/// A keyboard key.
///
/// Only a small subset of keys is supported currently!
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(missing_docs)]
pub enum Key {
Enter,
Escape,
Tab,
Backspace,
}
impl From<Key> for keyboard::Key {
fn from(key: Key) -> Self {
match key {
Key::Enter => Self::Named(keyboard::key::Named::Enter),
Key::Escape => Self::Named(keyboard::key::Named::Escape),
Key::Tab => Self::Named(keyboard::key::Named::Tab),
Key::Backspace => Self::Named(keyboard::key::Named::Backspace),
}
}
}
mod format {
use super::*;
pub fn button_at(button: mouse::Button, at: Option<&Target>) -> String {
let button = self::button(button);
if let Some(at) = at {
if button.is_empty() {
at.to_string()
} else {
format!("{} {}", button, at)
}
} else {
button.to_owned()
}
}
pub fn button(button: mouse::Button) -> &'static str {
match button {
mouse::Button::Left => "",
mouse::Button::Right => "right",
mouse::Button::Middle => "middle",
mouse::Button::Back => "back",
mouse::Button::Forward => "forward",
mouse::Button::Other(_) => "other",
}
}
pub fn point(point: Point) -> String {
format!("({:.2}, {:.2})", point.x, point.y)
}
pub fn key(key: Key) -> &'static str {
match key {
Key::Enter => "enter",
Key::Escape => "escape",
Key::Tab => "tab",
Key::Backspace => "backspace",
}
}
pub fn string(text: &str) -> String {
format!("\"{}\"", text.escape_default())
}
}
/// A testing assertion.
///
/// Expectations are instructions that verify the current state of
/// the user interface of an application.
#[derive(Debug, Clone, PartialEq)]
pub enum Expectation {
/// Expect some element to contain some text.
Text(String),
}
impl fmt::Display for Expectation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expectation::Text(text) => {
write!(f, "expect {}", format::string(text))
}
}
}
}
pub use parser::Error as ParseError;
mod parser {
use super::*;
use nom::branch::alt;
use nom::bytes::complete::tag;
use nom::bytes::{is_not, take_while_m_n};
use nom::character::complete::{char, multispace0, multispace1};
use nom::combinator::{map, map_opt, map_res, opt, success, value, verify};
use nom::error::ParseError;
use nom::multi::fold;
use nom::number::float;
use nom::sequence::{delimited, preceded, separated_pair};
use nom::{Finish, IResult, Parser};
/// A parsing error.
#[derive(Debug, Clone, thiserror::Error)]
#[error("parse error: {0}")]
pub struct Error(nom::error::Error<String>);
pub fn run(input: &str) -> Result<Instruction, Error> {
match instruction.parse_complete(input).finish() {
Ok((_rest, instruction)) => Ok(instruction),
Err(error) => Err(Error(error.cloned())),
}
}
fn instruction(input: &str) -> IResult<&str, Instruction> {
alt((
map(interaction, Instruction::Interact),
map(expectation, Instruction::Expect),
))
.parse(input)
}
fn interaction(input: &str) -> IResult<&str, Interaction> {
alt((
map(mouse, Interaction::Mouse),
map(keyboard, Interaction::Keyboard),
))
.parse(input)
}
fn mouse(input: &str) -> IResult<&str, Mouse> {
let mouse_move =
preceded(tag("move cursor to "), target).map(Mouse::Move);
alt((mouse_move, mouse_click)).parse(input)
}
fn mouse_click(input: &str) -> IResult<&str, Mouse> {
let (input, _) = tag("click ")(input)?;
let (input, (button, target)) = mouse_button_at(input)?;
Ok((input, Mouse::Click { button, target }))
}
fn mouse_button_at(
input: &str,
) -> IResult<&str, (mouse::Button, Option<Target>)> {
let (input, button) = mouse_button(input)?;
let (input, at) = opt(target).parse(input)?;
Ok((input, (button, at)))
}
fn target(input: &str) -> IResult<&str, Target> {
alt((string.map(Target::Text), point.map(Target::Point))).parse(input)
}
fn mouse_button(input: &str) -> IResult<&str, mouse::Button> {
alt((
tag("right").map(|_| mouse::Button::Right),
success(mouse::Button::Left),
))
.parse(input)
}
fn keyboard(input: &str) -> IResult<&str, Keyboard> {
alt((
map(preceded(tag("type "), string), Keyboard::Typewrite),
map(preceded(tag("type "), key), Keyboard::Type),
))
.parse(input)
}
fn expectation(input: &str) -> IResult<&str, Expectation> {
map(preceded(tag("expect "), string), |text| {
Expectation::Text(text)
})
.parse(input)
}
fn key(input: &str) -> IResult<&str, Key> {
alt((
map(tag("enter"), |_| Key::Enter),
map(tag("escape"), |_| Key::Escape),
map(tag("tab"), |_| Key::Tab),
map(tag("backspace"), |_| Key::Backspace),
))
.parse(input)
}
fn point(input: &str) -> IResult<&str, Point> {
let comma = whitespace(char(','));
map(
delimited(
char('('),
separated_pair(float(), comma, float()),
char(')'),
),
|(x, y)| Point { x, y },
)
.parse(input)
}
pub fn whitespace<'a, O, E: ParseError<&'a str>, F>(
inner: F,
) -> impl Parser<&'a str, Output = O, Error = E>
where
F: Parser<&'a str, Output = O, Error = E>,
{
delimited(multispace0, inner, multispace0)
}
// Taken from https://github.com/rust-bakery/nom/blob/51c3c4e44fa78a8a09b413419372b97b2cc2a787/examples/string.rs
//
// Copyright (c) 2014-2019 Geoffroy Couprie
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
fn string(input: &str) -> IResult<&str, String> {
#[derive(Debug, Clone, Copy)]
enum Fragment<'a> {
Literal(&'a str),
EscapedChar(char),
EscapedWS,
}
fn fragment(input: &str) -> IResult<&str, Fragment<'_>> {
alt((
map(string_literal, Fragment::Literal),
map(escaped_char, Fragment::EscapedChar),
value(Fragment::EscapedWS, escaped_whitespace),
))
.parse(input)
}
fn string_literal<'a, E: ParseError<&'a str>>(
input: &'a str,
) -> IResult<&'a str, &'a str, E> {
let not_quote_slash = is_not("\"\\");
verify(not_quote_slash, |s: &str| !s.is_empty()).parse(input)
}
fn unicode(input: &str) -> IResult<&str, char> {
let parse_hex =
take_while_m_n(1, 6, |c: char| c.is_ascii_hexdigit());
let parse_delimited_hex =
preceded(char('u'), delimited(char('{'), parse_hex, char('}')));
let parse_u32 = map_res(parse_delimited_hex, move |hex| {
u32::from_str_radix(hex, 16)
});
map_opt(parse_u32, std::char::from_u32).parse(input)
}
fn escaped_char(input: &str) -> IResult<&str, char> {
preceded(
char('\\'),
alt((
unicode,
value('\n', char('n')),
value('\r', char('r')),
value('\t', char('t')),
value('\u{08}', char('b')),
value('\u{0C}', char('f')),
value('\\', char('\\')),
value('/', char('/')),
value('"', char('"')),
)),
)
.parse(input)
}
fn escaped_whitespace(input: &str) -> IResult<&str, &str> {
preceded(char('\\'), multispace1).parse(input)
}
let build_string =
fold(0.., fragment, String::new, |mut string, fragment| {
match fragment {
Fragment::Literal(s) => string.push_str(s),
Fragment::EscapedChar(c) => string.push(c),
Fragment::EscapedWS => {}
}
string
});
delimited(char('"'), build_string, char('"')).parse(input)
}
}

View file

@ -24,19 +24,18 @@
//! # impl Counter {
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! use iced_test::selector::text;
//! # use iced_test::simulator;
//! #
//! # let mut counter = Counter { value: 0 };
//! # let mut ui = simulator(counter.view());
//!
//! let _ = ui.click(text("+"));
//! let _ = ui.click(text("+"));
//! let _ = ui.click(text("-"));
//! #
//! let _ = ui.click("+");
//! let _ = ui.click("+");
//! let _ = ui.click("-");
//! ```
//!
//! [`Simulator::click`] takes a [`Selector`]. A [`Selector`] describes a way to query the widgets of an interface. In this case,
//! [`selector::text`] lets us select a widget by the text it contains.
//! [`Simulator::click`] takes a type implementing the [`Selector`] trait. A [`Selector`] describes a way to query the widgets of an interface.
//! In this case, we leverage the [`Selector`] implementation of `&str`, which selects a widget by the text it contains.
//!
//! We can now process any messages produced by these interactions and then assert that the final value of our counter is
//! indeed `1`!
@ -47,15 +46,14 @@
//! # pub fn update(&mut self, message: ()) {}
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! # use iced_test::selector::text;
//! # use iced_test::simulator;
//! #
//! # let mut counter = Counter { value: 0 };
//! # let mut ui = simulator(counter.view());
//! #
//! # let _ = ui.click(text("+"));
//! # let _ = ui.click(text("+"));
//! # let _ = ui.click(text("-"));
//! # let _ = ui.click("+");
//! # let _ = ui.click("+");
//! # let _ = ui.click("-");
//! #
//! for message in ui.into_messages() {
//! counter.update(message);
@ -71,13 +69,12 @@
//! # impl Counter {
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! # use iced_test::selector::text;
//! # use iced_test::simulator;
//! #
//! # let mut counter = Counter { value: 0 };
//! let mut ui = simulator(counter.view());
//!
//! assert!(ui.find(text("1")).is_ok(), "Counter should display 1!");
//! assert!(ui.find("1").is_ok(), "Counter should display 1!");
//! ```
//!
//! And that's it! That's the gist of testing `iced` applications!
@ -86,578 +83,133 @@
//! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)!
//!
//! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface
pub mod selector;
pub use iced_program as program;
pub use iced_renderer as renderer;
pub use iced_runtime as runtime;
pub use iced_runtime::core;
pub use iced_selector as selector;
pub mod emulator;
pub mod ice;
pub mod instruction;
pub mod simulator;
mod error;
pub use emulator::Emulator;
pub use error::Error;
pub use ice::Ice;
pub use instruction::Instruction;
pub use selector::Selector;
pub use simulator::{Simulator, simulator};
use iced_renderer as renderer;
use iced_runtime as runtime;
use iced_runtime::core;
use std::path::Path;
use crate::core::clipboard;
use crate::core::event;
use crate::core::keyboard;
use crate::core::mouse;
use crate::core::theme;
use crate::core::time;
use crate::core::widget;
use crate::core::window;
use crate::core::{
Element, Event, Font, Point, Rectangle, Settings, Size, SmolStr,
};
use crate::runtime::UserInterface;
use crate::runtime::user_interface;
use std::borrow::Cow;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Creates a new [`Simulator`].
/// Runs an [`Ice`] test suite for the given [`Program`](program::Program).
///
/// This is just a function version of [`Simulator::new`].
pub fn simulator<'a, Message, Theme, Renderer>(
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Simulator<'a, Message, Theme, Renderer>
where
Theme: theme::Base,
Renderer: core::Renderer + core::renderer::Headless,
{
Simulator::new(element)
}
/// Any `.ice` tests will be parsed from the given directory and executed in
/// an [`Emulator`] of the given [`Program`](program::Program).
///
/// Remember that an [`Emulator`] executes the real thing! Side effects _will_
/// take place. It is up to you to ensure your tests have reproducible environments
/// by leveraging [`Preset`][program::Preset].
pub fn run(
program: impl program::Program + 'static,
tests_dir: impl AsRef<Path>,
) -> Result<(), Error> {
use crate::runtime::futures::futures::StreamExt;
use crate::runtime::futures::futures::channel::mpsc;
use crate::runtime::futures::futures::executor;
/// A user interface that can be interacted with and inspected programmatically.
#[allow(missing_debug_implementations)]
pub struct Simulator<
'a,
Message,
Theme = core::Theme,
Renderer = renderer::Renderer,
> {
raw: UserInterface<'a, Message, Theme, Renderer>,
renderer: Renderer,
size: Size,
cursor: mouse::Cursor,
messages: Vec<Message>,
}
use std::ffi::OsStr;
use std::fs;
/// A specific area of a [`Simulator`], normally containing a widget.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Target {
/// The bounds of the area.
pub bounds: Rectangle,
}
let files = fs::read_dir(tests_dir)?;
let mut tests = Vec::new();
impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer>
where
Theme: theme::Base,
Renderer: core::Renderer + core::renderer::Headless,
{
/// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768).
pub fn new(
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self::with_settings(Settings::default(), element)
}
for file in files {
let file = file?;
/// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768).
pub fn with_settings(
settings: Settings,
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self::with_size(settings, window::Settings::default().size, element)
}
/// Creates a new [`Simulator`] with the given [`Settings`] and size.
pub fn with_size(
settings: Settings,
size: impl Into<Size>,
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let size = size.into();
let default_font = match settings.default_font {
Font::DEFAULT => Font::with_name("Fira Sans"),
_ => settings.default_font,
};
for font in settings.fonts {
load_font(font).expect("Font must be valid");
if file.path().extension().and_then(OsStr::to_str) != Some("ice") {
continue;
}
let mut renderer = {
let backend = env::var("ICED_TEST_BACKEND").ok();
let content = fs::read_to_string(file.path())?;
iced_runtime::futures::futures::executor::block_on(Renderer::new(
default_font,
settings.default_text_size,
backend.as_deref(),
))
.expect("Create new headless renderer")
};
match Ice::parse(&content) {
Ok(ice) => {
let preset = if let Some(preset) = &ice.preset {
let Some(preset) = program
.presets()
.iter()
.find(|candidate| candidate.name() == preset)
else {
return Err(Error::PresetNotFound {
name: preset.to_owned(),
available: program
.presets()
.iter()
.map(program::Preset::name)
.map(str::to_owned)
.collect(),
});
};
let raw = UserInterface::build(
element,
size,
user_interface::Cache::default(),
&mut renderer,
);
Some(preset)
} else {
None
};
Simulator {
raw,
renderer,
size,
cursor: mouse::Cursor::Unavailable,
messages: Vec::new(),
}
}
/// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`].
pub fn find(
&mut self,
selector: impl Into<Selector>,
) -> Result<Target, Error> {
let selector = selector.into();
match &selector {
Selector::Id(id) => {
struct FindById<'a> {
id: &'a widget::Id,
target: Option<Target>,
}
impl widget::Operation for FindById<'_> {
fn container(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(
&mut dyn widget::Operation<()>,
),
) {
if self.target.is_some() {
return;
}
if Some(self.id) == id {
self.target = Some(Target { bounds });
return;
}
operate_on_children(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_content_bounds: Rectangle,
_translation: core::Vector,
_state: &mut dyn widget::operation::Scrollable,
) {
if self.target.is_some() {
return;
}
if Some(self.id) == id {
self.target = Some(Target { bounds });
}
}
fn text_input(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_state: &mut dyn widget::operation::TextInput,
) {
if self.target.is_some() {
return;
}
if Some(self.id) == id {
self.target = Some(Target { bounds });
}
}
fn text(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_text: &str,
) {
if self.target.is_some() {
return;
}
if Some(self.id) == id {
self.target = Some(Target { bounds });
}
}
fn custom(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_state: &mut dyn std::any::Any,
) {
if self.target.is_some() {
return;
}
if Some(self.id) == id {
self.target = Some(Target { bounds });
}
}
}
let mut find = FindById { id, target: None };
self.raw.operate(&self.renderer, &mut find);
find.target.ok_or(Error::NotFound(selector))
tests.push((file, ice, preset));
}
Selector::Text(text) => {
struct FindByText<'a> {
text: &'a str,
target: Option<Target>,
}
impl widget::Operation for FindByText<'_> {
fn container(
&mut self,
_id: Option<&widget::Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(
&mut dyn widget::Operation<()>,
),
) {
if self.target.is_some() {
return;
}
operate_on_children(self);
}
fn text(
&mut self,
_id: Option<&widget::Id>,
bounds: Rectangle,
text: &str,
) {
if self.target.is_some() {
return;
}
if self.text == text {
self.target = Some(Target { bounds });
}
}
}
let mut find = FindByText { text, target: None };
self.raw.operate(&self.renderer, &mut find);
find.target.ok_or(Error::NotFound(selector))
Err(error) => {
return Err(Error::IceParsingFailed {
file: file.path().to_path_buf(),
error,
});
}
}
}
/// Points the mouse cursor at the given position in the [`Simulator`].
///
/// This does _not_ produce mouse movement events!
pub fn point_at(&mut self, position: impl Into<Point>) {
self.cursor = mouse::Cursor::Available(position.into());
}
// TODO: Concurrent runtimes
for (file, ice, preset) in tests {
let (sender, mut receiver) = mpsc::channel(1);
/// Clicks the [`Target`] found by the given [`Selector`], if any.
///
/// This consists in:
/// - Pointing the mouse cursor at the center of the [`Target`].
/// - Simulating a [`click`].
pub fn click(
&mut self,
selector: impl Into<Selector>,
) -> Result<Target, Error> {
let target = self.find(selector)?;
self.point_at(target.bounds.center());
let _ = self.simulate(click());
Ok(target)
}
/// Simulates a key press, followed by a release, in the [`Simulator`].
pub fn tap_key(&mut self, key: impl Into<keyboard::Key>) -> event::Status {
self.simulate(tap_key(key, None))
.first()
.copied()
.unwrap_or(event::Status::Ignored)
}
/// Simulates a user typing in the keyboard the given text in the [`Simulator`].
pub fn typewrite(&mut self, text: &str) -> event::Status {
let statuses = self.simulate(typewrite(text));
statuses
.into_iter()
.fold(event::Status::Ignored, event::Status::merge)
}
/// Simulates the given raw sequence of events in the [`Simulator`].
pub fn simulate(
&mut self,
events: impl IntoIterator<Item = Event>,
) -> Vec<event::Status> {
let events: Vec<Event> = events.into_iter().collect();
let (_state, statuses) = self.raw.update(
&events,
self.cursor,
&mut self.renderer,
&mut clipboard::Null,
&mut self.messages,
let mut emulator = Emulator::with_preset(
sender,
&program,
ice.mode,
ice.viewport,
preset,
);
statuses
}
let mut instructions = ice.instructions.into_iter();
/// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`].
pub fn snapshot(&mut self, theme: &Theme) -> Result<Snapshot, Error> {
let base = theme.base();
loop {
let event = executor::block_on(receiver.next())
.expect("emulator runtime should never stop on its own");
let _ = self.raw.update(
&[Event::Window(window::Event::RedrawRequested(
time::Instant::now(),
))],
self.cursor,
&mut self.renderer,
&mut clipboard::Null,
&mut self.messages,
);
match event {
emulator::Event::Action(action) => {
emulator.perform(&program, action);
}
emulator::Event::Failed(instruction) => {
return Err(Error::IceTestingFailed {
file: file.path().to_path_buf(),
instruction,
});
}
emulator::Event::Ready => {
let Some(instruction) = instructions.next() else {
break;
};
self.raw.draw(
&mut self.renderer,
theme,
&core::renderer::Style {
text_color: base.text_color,
},
self.cursor,
);
let scale_factor = 2.0;
let physical_size = Size::new(
(self.size.width * scale_factor).round() as u32,
(self.size.height * scale_factor).round() as u32,
);
let rgba = self.renderer.screenshot(
physical_size,
scale_factor,
base.background_color,
);
Ok(Snapshot {
screenshot: window::Screenshot::new(
rgba,
physical_size,
scale_factor,
),
renderer: self.renderer.name(),
})
}
/// Turns the [`Simulator`] into the sequence of messages produced by any interactions.
pub fn into_messages(
self,
) -> impl Iterator<Item = Message> + use<Message, Theme, Renderer> {
self.messages.into_iter()
}
}
/// A frame of a user interface rendered by a [`Simulator`].
#[derive(Debug, Clone)]
pub struct Snapshot {
screenshot: window::Screenshot,
renderer: String,
}
impl Snapshot {
/// Compares the [`Snapshot`] with the PNG image found in the given path, returning
/// `true` if they are identical.
///
/// If the PNG image does not exist, it will be created by the [`Snapshot`] for future
/// testing and `true` will be returned.
pub fn matches_image(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
let path = self.path(path, "png");
if path.exists() {
let file = fs::File::open(&path)?;
let decoder = png::Decoder::new(io::BufReader::new(file));
let mut reader = decoder.read_info()?;
let n = reader
.output_buffer_size()
.expect("snapshot should fit in memory");
let mut bytes = vec![0; n];
let info = reader.next_frame(&mut bytes)?;
Ok(self.screenshot.bytes == bytes[..info.buffer_size()])
} else {
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
emulator.run(&program, instruction);
}
}
let file = fs::File::create(path)?;
let mut encoder = png::Encoder::new(
file,
self.screenshot.size.width,
self.screenshot.size.height,
);
encoder.set_color(png::ColorType::Rgba);
let mut writer = encoder.write_header()?;
writer.write_image_data(&self.screenshot.bytes)?;
writer.finish()?;
Ok(true)
}
}
/// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning
/// `true` if they are identical.
///
/// If the hash file does not exist, it will be created by the [`Snapshot`] for future
/// testing and `true` will be returned.
pub fn matches_hash(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
use sha2::{Digest, Sha256};
let path = self.path(path, "sha256");
let hash = {
let mut hasher = Sha256::new();
hasher.update(&self.screenshot.bytes);
format!("{:x}", hasher.finalize())
};
if path.exists() {
let saved_hash = fs::read_to_string(&path)?;
Ok(hash == saved_hash)
} else {
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
}
fs::write(path, hash)?;
Ok(true)
}
}
fn path(&self, path: impl AsRef<Path>, extension: &str) -> PathBuf {
let path = path.as_ref();
path.with_file_name(format!(
"{name}-{renderer}",
name = path
.file_stem()
.map(std::ffi::OsStr::to_string_lossy)
.unwrap_or_default(),
renderer = self.renderer
))
.with_extension(extension)
}
}
/// Returns the sequence of events of a click.
pub fn click() -> impl Iterator<Item = Event> {
[
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
]
.into_iter()
}
/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key).
pub fn tap_key(
key: impl Into<keyboard::Key>,
text: Option<SmolStr>,
) -> impl Iterator<Item = Event> {
let key = key.into();
[
Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key.clone(),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::default(),
text,
}),
Event::Keyboard(keyboard::Event::KeyReleased {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::default(),
}),
]
.into_iter()
}
/// Returns the sequence of events of typewriting the given text in a keyboard.
pub fn typewrite(text: &str) -> impl Iterator<Item = Event> + '_ {
text.chars()
.map(|c| SmolStr::new_inline(&c.to_string()))
.flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c)))
}
/// A test error.
#[derive(Debug, Clone, thiserror::Error)]
pub enum Error {
/// No matching widget was found for the [`Selector`].
#[error("no matching widget was found for the selector: {0:?}")]
NotFound(Selector),
/// An IO operation failed.
#[error("an IO operation failed: {0}")]
IOFailed(Arc<io::Error>),
/// The decoding of some PNG image failed.
#[error("the decoding of some PNG image failed: {0}")]
PngDecodingFailed(Arc<png::DecodingError>),
/// The encoding of some PNG image failed.
#[error("the encoding of some PNG image failed: {0}")]
PngEncodingFailed(Arc<png::EncodingError>),
}
impl From<io::Error> for Error {
fn from(error: io::Error) -> Self {
Self::IOFailed(Arc::new(error))
}
}
impl From<png::DecodingError> for Error {
fn from(error: png::DecodingError) -> Self {
Self::PngDecodingFailed(Arc::new(error))
}
}
impl From<png::EncodingError> for Error {
fn from(error: png::EncodingError) -> Self {
Self::PngEncodingFailed(Arc::new(error))
}
}
fn load_font(font: impl Into<Cow<'static, [u8]>>) -> Result<(), Error> {
renderer::graphics::text::font_system()
.write()
.expect("Write to font system")
.load_font(font.into());
Ok(())
}

View file

@ -1,34 +0,0 @@
//! Select widgets of a user interface.
use crate::core::text;
use crate::core::widget;
/// A selector describes a strategy to find a certain widget in a user interface.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Selector {
/// Find the widget with the given [`widget::Id`].
Id(widget::Id),
/// Find the widget containing the given [`text::Fragment`].
Text(text::Fragment<'static>),
}
impl From<widget::Id> for Selector {
fn from(id: widget::Id) -> Self {
Self::Id(id)
}
}
impl From<&'static str> for Selector {
fn from(text: &'static str) -> Self {
Self::Text(text.into())
}
}
/// Creates a [`Selector`] that finds the widget with the given [`widget::Id`].
pub fn id(id: impl Into<widget::Id>) -> Selector {
Selector::Id(id.into())
}
/// Creates a [`Selector`] that finds the widget containing the given text fragment.
pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector {
Selector::Text(fragment.into_fragment())
}

427
test/src/simulator.rs Normal file
View file

@ -0,0 +1,427 @@
//! Run a simulation of your application without side effects.
use crate::core;
use crate::core::clipboard;
use crate::core::event;
use crate::core::keyboard;
use crate::core::mouse;
use crate::core::theme;
use crate::core::time;
use crate::core::widget;
use crate::core::window;
use crate::core::{Element, Event, Font, Point, Settings, Size, SmolStr};
use crate::renderer;
use crate::runtime::UserInterface;
use crate::runtime::user_interface;
use crate::selector::Bounded;
use crate::{Error, Selector};
use std::borrow::Cow;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// A user interface that can be interacted with and inspected programmatically.
pub struct Simulator<
'a,
Message,
Theme = core::Theme,
Renderer = renderer::Renderer,
> {
raw: UserInterface<'a, Message, Theme, Renderer>,
renderer: Renderer,
size: Size,
cursor: mouse::Cursor,
messages: Vec<Message>,
}
impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer>
where
Theme: theme::Base,
Renderer: core::Renderer + core::renderer::Headless,
{
/// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768).
pub fn new(
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self::with_settings(Settings::default(), element)
}
/// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768).
pub fn with_settings(
settings: Settings,
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self::with_size(settings, window::Settings::default().size, element)
}
/// Creates a new [`Simulator`] with the given [`Settings`] and size.
pub fn with_size(
settings: Settings,
size: impl Into<Size>,
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
let size = size.into();
let default_font = match settings.default_font {
Font::DEFAULT => Font::with_name("Fira Sans"),
_ => settings.default_font,
};
for font in settings.fonts {
load_font(font).expect("Font must be valid");
}
let mut renderer = {
let backend = env::var("ICED_TEST_BACKEND").ok();
iced_runtime::futures::futures::executor::block_on(Renderer::new(
default_font,
settings.default_text_size,
backend.as_deref(),
))
.expect("Create new headless renderer")
};
let raw = UserInterface::build(
element,
size,
user_interface::Cache::default(),
&mut renderer,
);
Simulator {
raw,
renderer,
size,
cursor: mouse::Cursor::Unavailable,
messages: Vec::new(),
}
}
/// Finds the target of the given widget [`Selector`] in the [`Simulator`].
pub fn find<S>(&mut self, selector: S) -> Result<S::Output, Error>
where
S: Selector + Send,
S::Output: Clone + Send,
{
use widget::Operation;
let description = selector.description();
let mut operation = selector.find();
self.raw.operate(
&self.renderer,
&mut widget::operation::black_box(&mut operation),
);
match operation.finish() {
widget::operation::Outcome::Some(output) => {
output.ok_or(Error::SelectorNotFound {
selector: description,
})
}
_ => Err(Error::SelectorNotFound {
selector: description,
}),
}
}
/// Points the mouse cursor at the given position in the [`Simulator`].
///
/// This does _not_ produce mouse movement events!
pub fn point_at(&mut self, position: impl Into<Point>) {
self.cursor = mouse::Cursor::Available(position.into());
}
/// Clicks the [`Bounded`] target found by the given [`Selector`], if any.
///
/// This consists in:
/// - Pointing the mouse cursor at the center of the [`Bounded`] target.
/// - Simulating a [`click`].
pub fn click<S>(&mut self, selector: S) -> Result<S::Output, Error>
where
S: Selector + Send,
S::Output: Bounded + Clone + Send + Sync + 'static,
{
let target = self.find(selector)?;
let Some(visible_bounds) = target.visible_bounds() else {
return Err(Error::TargetNotVisible {
target: Arc::new(target),
});
};
self.point_at(visible_bounds.center());
let _ = self.simulate(click());
Ok(target)
}
/// Simulates a key press, followed by a release, in the [`Simulator`].
pub fn tap_key(&mut self, key: impl Into<keyboard::Key>) -> event::Status {
self.simulate(tap_key(key, None))
.first()
.copied()
.unwrap_or(event::Status::Ignored)
}
/// Simulates a user typing in the keyboard the given text in the [`Simulator`].
pub fn typewrite(&mut self, text: &str) -> event::Status {
let statuses = self.simulate(typewrite(text));
statuses
.into_iter()
.fold(event::Status::Ignored, event::Status::merge)
}
/// Simulates the given raw sequence of events in the [`Simulator`].
pub fn simulate(
&mut self,
events: impl IntoIterator<Item = Event>,
) -> Vec<event::Status> {
let events: Vec<Event> = events.into_iter().collect();
let (_state, statuses) = self.raw.update(
&events,
self.cursor,
&mut self.renderer,
&mut clipboard::Null,
&mut self.messages,
);
statuses
}
/// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`].
pub fn snapshot(&mut self, theme: &Theme) -> Result<Snapshot, Error> {
let base = theme.base();
let _ = self.raw.update(
&[Event::Window(window::Event::RedrawRequested(
time::Instant::now(),
))],
self.cursor,
&mut self.renderer,
&mut clipboard::Null,
&mut self.messages,
);
self.raw.draw(
&mut self.renderer,
theme,
&core::renderer::Style {
text_color: base.text_color,
},
self.cursor,
);
let scale_factor = 2.0;
let physical_size = Size::new(
(self.size.width * scale_factor).round() as u32,
(self.size.height * scale_factor).round() as u32,
);
let rgba = self.renderer.screenshot(
physical_size,
scale_factor,
base.background_color,
);
Ok(Snapshot {
screenshot: window::Screenshot::new(
rgba,
physical_size,
scale_factor,
),
renderer: self.renderer.name(),
})
}
/// Turns the [`Simulator`] into the sequence of messages produced by any interactions.
pub fn into_messages(
self,
) -> impl Iterator<Item = Message> + use<Message, Theme, Renderer> {
self.messages.into_iter()
}
}
/// A frame of a user interface rendered by a [`Simulator`].
#[derive(Debug, Clone)]
pub struct Snapshot {
screenshot: window::Screenshot,
renderer: String,
}
impl Snapshot {
/// Compares the [`Snapshot`] with the PNG image found in the given path, returning
/// `true` if they are identical.
///
/// If the PNG image does not exist, it will be created by the [`Snapshot`] for future
/// testing and `true` will be returned.
pub fn matches_image(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
let path = self.path(path, "png");
if path.exists() {
let file = fs::File::open(&path)?;
let decoder = png::Decoder::new(io::BufReader::new(file));
let mut reader = decoder.read_info()?;
let n = reader
.output_buffer_size()
.expect("snapshot should fit in memory");
let mut bytes = vec![0; n];
let info = reader.next_frame(&mut bytes)?;
Ok(self.screenshot.bytes == bytes[..info.buffer_size()])
} else {
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
}
let file = fs::File::create(path)?;
let mut encoder = png::Encoder::new(
file,
self.screenshot.size.width,
self.screenshot.size.height,
);
encoder.set_color(png::ColorType::Rgba);
let mut writer = encoder.write_header()?;
writer.write_image_data(&self.screenshot.bytes)?;
writer.finish()?;
Ok(true)
}
}
/// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning
/// `true` if they are identical.
///
/// If the hash file does not exist, it will be created by the [`Snapshot`] for future
/// testing and `true` will be returned.
pub fn matches_hash(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
use sha2::{Digest, Sha256};
let path = self.path(path, "sha256");
let hash = {
let mut hasher = Sha256::new();
hasher.update(&self.screenshot.bytes);
format!("{:x}", hasher.finalize())
};
if path.exists() {
let saved_hash = fs::read_to_string(&path)?;
Ok(hash == saved_hash)
} else {
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
}
fs::write(path, hash)?;
Ok(true)
}
}
fn path(&self, path: impl AsRef<Path>, extension: &str) -> PathBuf {
let path = path.as_ref();
path.with_file_name(format!(
"{name}-{renderer}",
name = path
.file_stem()
.map(std::ffi::OsStr::to_string_lossy)
.unwrap_or_default(),
renderer = self.renderer
))
.with_extension(extension)
}
}
/// Creates a new [`Simulator`].
///
/// This is just a function version of [`Simulator::new`].
pub fn simulator<'a, Message, Theme, Renderer>(
element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Simulator<'a, Message, Theme, Renderer>
where
Theme: theme::Base,
Renderer: core::Renderer + core::renderer::Headless,
{
Simulator::new(element)
}
/// Returns the sequence of events of a click.
pub fn click() -> impl Iterator<Item = Event> {
[
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
]
.into_iter()
}
/// Returns the sequence of events of a key press.
pub fn press_key(
key: impl Into<keyboard::Key>,
text: Option<SmolStr>,
) -> Event {
let key = key.into();
Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::default(),
text,
})
}
/// Returns the sequence of events of a key release.
pub fn release_key(key: impl Into<keyboard::Key>) -> Event {
let key = key.into();
Event::Keyboard(keyboard::Event::KeyReleased {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::default(),
})
}
/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key).
pub fn tap_key(
key: impl Into<keyboard::Key>,
text: Option<SmolStr>,
) -> impl Iterator<Item = Event> {
let key = key.into();
[press_key(key.clone(), text), release_key(key)].into_iter()
}
/// Returns the sequence of events of typewriting the given text in a keyboard.
pub fn typewrite(text: &str) -> impl Iterator<Item = Event> + '_ {
text.chars()
.map(|c| SmolStr::new_inline(&c.to_string()))
.flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c)))
}
fn load_font(font: impl Into<Cow<'static, [u8]>>) -> Result<(), Error> {
renderer::graphics::text::font_system()
.write()
.expect("Write to font system")
.load_font(font.into());
Ok(())
}

20
tester/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "iced_tester"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
categories.workspace = true
keywords.workspace = true
rust-version.workspace = true
[dependencies]
iced_test.workspace = true
iced_widget.workspace = true
log.workspace = true
rfd.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,15 @@
module = "icon"
[glyphs]
play = "entypo-play"
stop = "entypo-stop"
pause = "entypo-pause"
record = "entypo-record"
lightbulb = "fontawesome-lightbulb"
check = "entypo-check"
cancel = "entypo-cancel"
folder = "entypo-folder"
floppy = "entypo-floppy"
pencil = "entypo-pencil"
mouse_pointer = "fontawesome-mouse-pointer"
keyboard = "entypo-keyboard"

Binary file not shown.

118
tester/src/icon.rs Normal file
View file

@ -0,0 +1,118 @@
#![allow(unused)]
use crate::core::Font;
use crate::program;
use crate::widget::{Text, text};
pub const FONT: &[u8] = include_bytes!("../fonts/iced_tester-icons.ttf");
pub fn cancel<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2715}")
}
pub fn check<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2713}")
}
pub fn floppy<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{1F4BE}")
}
pub fn folder<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{1F4C1}")
}
pub fn keyboard<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2328}")
}
pub fn lightbulb<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{F0EB}")
}
pub fn mouse_pointer<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{F245}")
}
pub fn pause<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2389}")
}
pub fn pencil<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{270E}")
}
pub fn play<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{25B6}")
}
pub fn record<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{26AB}")
}
pub fn stop<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{25A0}")
}
pub fn tape<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
icon("\u{2707}")
}
fn icon<'a, Theme, Renderer>(codepoint: &'a str) -> Text<'a, Theme, Renderer>
where
Theme: text::Catalog + 'a,
Renderer: program::Renderer,
{
text(codepoint).font(Font::with_name("iced_devtools-icons"))
}

945
tester/src/lib.rs Normal file
View file

@ -0,0 +1,945 @@
//! Record, edit, and run end-to-end tests for your iced applications.
pub use iced_test as test;
pub use iced_test::core;
pub use iced_test::program;
pub use iced_test::runtime;
pub use iced_test::runtime::futures;
pub use iced_widget as widget;
mod icon;
mod recorder;
use recorder::recorder;
use crate::core::Alignment::Center;
use crate::core::Length::Fill;
use crate::core::alignment::Horizontal::Right;
use crate::core::border;
use crate::core::mouse;
use crate::core::window;
use crate::core::{Color, Element, Font, Settings, Size, Theme};
use crate::futures::futures::channel::mpsc;
use crate::program::Program;
use crate::runtime::task::{self, Task};
use crate::test::emulator;
use crate::test::ice;
use crate::test::instruction;
use crate::test::{Emulator, Ice, Instruction};
use crate::widget::{
button, center, column, combo_box, container, pick_list, row, rule,
scrollable, slider, space, stack, text, text_editor, themer,
};
use std::ops::RangeInclusive;
/// Attaches a [`Tester`] to the given [`Program`].
pub fn attach<P: Program + 'static>(program: P) -> Attach<P> {
Attach { program }
}
/// A [`Program`] with a [`Tester`] attached to it.
#[derive(Debug)]
pub struct Attach<P> {
/// The original [`Program`] attached to the [`Tester`].
pub program: P,
}
impl<P> Program for Attach<P>
where
P: Program + 'static,
{
type State = Tester<P>;
type Message = Message<P>;
type Theme = Theme;
type Renderer = P::Renderer;
type Executor = P::Executor;
fn name() -> &'static str {
P::name()
}
fn settings(&self) -> Settings {
let mut settings = self.program.settings();
settings.fonts.push(icon::FONT.into());
settings
}
fn window(&self) -> Option<window::Settings> {
self.program.window().map(|window| window::Settings {
size: window.size + Size::new(300.0, 80.0),
..window
})
}
fn boot(&self) -> (Self::State, Task<Self::Message>) {
(Tester::new(&self.program), Task::none())
}
fn update(
&self,
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
state.tick(&self.program, message.0).map(Message)
}
fn view<'a>(
&self,
state: &'a Self::State,
window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
state.view(&self.program, window).map(Message)
}
}
/// A tester decorates a [`Program`] definition and attaches a test recorder on top.
///
/// It can be used to both record and play [`Ice`] tests.
pub struct Tester<P: Program> {
viewport: Size,
mode: emulator::Mode,
presets: combo_box::State<String>,
preset: Option<String>,
instructions: Vec<Instruction>,
state: State<P>,
edit: Option<text_editor::Content<P::Renderer>>,
}
enum State<P: Program> {
Empty,
Idle {
state: P::State,
},
Recording {
emulator: Emulator<P>,
},
Asserting {
state: P::State,
window: window::Id,
last_interaction: Option<instruction::Interaction>,
},
Playing {
emulator: Emulator<P>,
current: usize,
outcome: Outcome,
},
}
enum Outcome {
Running,
Failed,
Success,
}
/// The message of a [`Tester`].
pub struct Message<P: Program>(Tick<P>);
#[derive(Debug, Clone)]
enum Event {
ViewportChanged(Size),
ModeSelected(emulator::Mode),
PresetSelected(String),
Record,
Stop,
Play,
Import,
Export,
Imported(Result<Ice, ice::ParseError>),
Edit,
Edited(text_editor::Action),
Confirm,
}
enum Tick<P: Program> {
Tester(Event),
Program(P::Message),
Emulator(emulator::Event<P>),
Record(instruction::Interaction),
Assert(instruction::Interaction),
}
impl<P: Program + 'static> Tester<P> {
fn new(program: &P) -> Self {
let (state, _) = program.boot();
let window = program.window().unwrap_or_default();
Self {
mode: emulator::Mode::default(),
viewport: window.size,
presets: combo_box::State::new(
program
.presets()
.iter()
.map(program::Preset::name)
.map(str::to_owned)
.collect(),
),
preset: None,
instructions: Vec::new(),
state: State::Idle { state },
edit: None,
}
}
fn is_busy(&self) -> bool {
matches!(
self.state,
State::Recording { .. }
| State::Playing {
outcome: Outcome::Running,
..
}
)
}
fn update(&mut self, program: &P, event: Event) -> Task<Tick<P>> {
match event {
Event::ViewportChanged(viewport) => {
self.viewport = viewport;
Task::none()
}
Event::ModeSelected(mode) => {
self.mode = mode;
Task::none()
}
Event::PresetSelected(preset) => {
self.preset = Some(preset);
let (state, _) = self
.preset(program)
.map(program::Preset::boot)
.unwrap_or_else(|| program.boot());
self.state = State::Idle { state };
Task::none()
}
Event::Record => {
self.edit = None;
self.instructions.clear();
let (sender, receiver) = mpsc::channel(1);
let emulator = Emulator::with_preset(
sender,
program,
self.mode,
self.viewport,
self.preset(program),
);
self.state = State::Recording { emulator };
Task::run(receiver, Tick::Emulator)
}
Event::Stop => {
let State::Recording { emulator } =
std::mem::replace(&mut self.state, State::Empty)
else {
return Task::none();
};
while let Some(Instruction::Interact(
instruction::Interaction::Mouse(instruction::Mouse::Move(
_,
)),
)) = self.instructions.last()
{
let _ = self.instructions.pop();
}
let (state, window) = emulator.into_state();
self.state = State::Asserting {
state,
window,
last_interaction: None,
};
Task::none()
}
Event::Play => {
self.confirm();
let (sender, receiver) = mpsc::channel(1);
let emulator = Emulator::with_preset(
sender,
program,
self.mode,
self.viewport,
self.preset(program),
);
self.state = State::Playing {
emulator,
current: 0,
outcome: Outcome::Running,
};
Task::run(receiver, Tick::Emulator)
}
Event::Import => {
use std::fs;
let import = rfd::AsyncFileDialog::new()
.add_filter("ice", &["ice"])
.pick_file();
Task::future(import)
.and_then(|file| {
task::blocking(move |mut sender| {
let _ = sender.try_send(Ice::parse(
&fs::read_to_string(file.path())
.unwrap_or_default(),
));
})
})
.map(Event::Imported)
.map(Tick::Tester)
}
Event::Export => {
use std::fs;
use std::thread;
self.confirm();
let ice = Ice {
viewport: self.viewport,
mode: self.mode,
preset: self.preset.clone(),
instructions: self.instructions.clone(),
};
let export = rfd::AsyncFileDialog::new()
.add_filter("ice", &["ice"])
.save_file();
Task::future(async move {
let Some(file) = export.await else {
return;
};
let _ = thread::spawn(move || {
fs::write(file.path(), ice.to_string())
});
})
.discard()
}
Event::Imported(Ok(ice)) => {
self.viewport = ice.viewport;
self.mode = ice.mode;
self.preset = ice.preset;
self.instructions = ice.instructions;
self.edit = None;
let (state, _) = self
.preset(program)
.map(program::Preset::boot)
.unwrap_or_else(|| program.boot());
self.state = State::Idle { state };
Task::none()
}
Event::Edit => {
if self.is_busy() {
return Task::none();
}
self.edit = Some(text_editor::Content::with_text(
&self
.instructions
.iter()
.map(Instruction::to_string)
.collect::<Vec<_>>()
.join("\n"),
));
Task::none()
}
Event::Edited(action) => {
if let Some(edit) = &mut self.edit {
edit.perform(action);
}
Task::none()
}
Event::Confirm => {
self.confirm();
Task::none()
}
Event::Imported(Err(error)) => {
log::error!("{error}");
Task::none()
}
}
}
fn confirm(&mut self) {
let Some(edit) = &mut self.edit else {
return;
};
self.instructions = edit
.lines()
.filter(|line| !line.text.trim().is_empty())
.filter_map(|line| Instruction::parse(&line.text).ok())
.collect();
self.edit = None;
}
fn preset<'a>(
&self,
program: &'a P,
) -> Option<&'a program::Preset<P::State, P::Message>> {
self.preset.as_ref().and_then(|preset| {
program
.presets()
.iter()
.find(|candidate| candidate.name() == preset)
})
}
fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
match tick {
Tick::Tester(message) => self.update(program, message),
Tick::Program(message) => {
let State::Recording { emulator } = &mut self.state else {
return Task::none();
};
emulator.update(program, message);
Task::none()
}
Tick::Emulator(event) => {
match &mut self.state {
State::Recording { emulator } => {
if let emulator::Event::Action(action) = event {
emulator.perform(program, action);
}
}
State::Playing {
emulator,
current,
outcome,
} => match event {
emulator::Event::Action(action) => {
emulator.perform(program, action);
}
emulator::Event::Failed(_instruction) => {
*outcome = Outcome::Failed;
}
emulator::Event::Ready => {
*current += 1;
if let Some(instruction) =
self.instructions.get(*current - 1).cloned()
{
emulator.run(program, instruction);
}
if *current >= self.instructions.len() {
*outcome = Outcome::Success;
}
}
},
State::Empty
| State::Idle { .. }
| State::Asserting { .. } => {}
}
Task::none()
}
Tick::Record(interaction) => {
let mut interaction = Some(interaction);
while let Some(new_interaction) = interaction.take() {
if let Some(Instruction::Interact(last_interaction)) =
self.instructions.pop()
{
let (merged_interaction, new_interaction) =
last_interaction.merge(new_interaction);
if let Some(new_interaction) = new_interaction {
self.instructions.push(Instruction::Interact(
merged_interaction,
));
self.instructions
.push(Instruction::Interact(new_interaction));
} else {
interaction = Some(merged_interaction);
}
} else {
self.instructions
.push(Instruction::Interact(new_interaction));
}
}
Task::none()
}
Tick::Assert(interaction) => {
let State::Asserting {
last_interaction, ..
} = &mut self.state
else {
return Task::none();
};
*last_interaction =
if let Some(last_interaction) = last_interaction.take() {
let (merged, new) = last_interaction.merge(interaction);
Some(new.unwrap_or(merged))
} else {
Some(interaction)
};
let Some(interaction) = last_interaction.take() else {
return Task::none();
};
let instruction::Interaction::Mouse(
instruction::Mouse::Click {
button: mouse::Button::Left,
target: Some(instruction::Target::Text(text)),
},
) = interaction
else {
*last_interaction = Some(interaction);
return Task::none();
};
self.instructions.push(Instruction::Expect(
instruction::Expectation::Text(text),
));
Task::none()
}
}
}
fn view<'a>(
&'a self,
program: &P,
window: window::Id,
) -> Element<'a, Tick<P>, Theme, P::Renderer> {
let status = {
let (icon, label) = match &self.state {
State::Empty | State::Idle { .. } => (text(""), "Idle"),
State::Recording { .. } => (icon::record(), "Recording"),
State::Asserting { .. } => (icon::lightbulb(), "Asserting"),
State::Playing { outcome, .. } => match outcome {
Outcome::Running => (icon::play(), "Playing"),
Outcome::Failed => (icon::cancel(), "Failed"),
Outcome::Success => (icon::check(), "Success"),
},
};
container(row![icon.size(14), label].align_y(Center).spacing(8))
.style(|theme: &Theme| {
let palette = theme.extended_palette();
container::Style {
text_color: Some(match &self.state {
State::Empty | State::Idle { .. } => {
palette.background.strongest.color
}
State::Recording { .. } => {
palette.danger.base.color
}
State::Asserting { .. } => {
palette.warning.base.color
}
State::Playing { outcome, .. } => match outcome {
Outcome::Running => theme.palette().primary,
Outcome::Failed => theme.palette().danger,
Outcome::Success => {
theme
.extended_palette()
.success
.strong
.color
}
},
}),
..container::Style::default()
}
})
};
let viewport = container(
scrollable(
container(match &self.state {
State::Empty => Element::from(space()),
State::Idle { state } => {
let theme = program.theme(state, window);
themer(
theme,
program.view(state, window).map(Tick::Program),
)
.into()
}
State::Recording { emulator } => {
let theme = emulator.theme(program);
let view = emulator.view(program).map(Tick::Program);
recorder(themer(theme, view))
.on_record(Tick::Record)
.into()
}
State::Asserting { state, window, .. } => {
let theme = program.theme(state, *window);
let view =
program.view(state, *window).map(Tick::Program);
recorder(themer(theme, view))
.on_record(Tick::Assert)
.into()
}
State::Playing { emulator, .. } => {
let theme = emulator.theme(program);
let view = emulator.view(program).map(Tick::Program);
themer(theme, view).into()
}
})
.width(self.viewport.width)
.height(self.viewport.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
}),
)
.style(|theme: &Theme| {
let palette = theme.extended_palette();
container::Style {
border: border::width(2.0).color(match &self.state {
State::Empty | State::Idle { .. } => {
palette.background.strongest.color
}
State::Recording { .. } => palette.danger.base.color,
State::Asserting { .. } => palette.warning.weak.color,
State::Playing { outcome, .. } => match outcome {
Outcome::Running => palette.primary.base.color,
Outcome::Failed => palette.danger.strong.color,
Outcome::Success => palette.success.strong.color,
},
}),
..container::Style::default()
}
})
.padding(10);
row![
center(column![status, viewport].spacing(10).align_x(Right))
.padding(10),
rule::vertical(1).style(rule::weak),
container(self.controls().map(Tick::Tester))
.width(250)
.padding(10)
.style(|theme| container::Style::default().background(
theme.extended_palette().background.weakest.color
)),
]
.into()
}
fn controls(&self) -> Element<'_, Event, Theme, P::Renderer> {
let viewport = column![
labeled_slider(
"Width",
100.0..=2000.0,
self.viewport.width,
|width| Event::ViewportChanged(Size {
width,
..self.viewport
}),
|width| format!("{width:.0}"),
),
labeled_slider(
"Height",
100.0..=2000.0,
self.viewport.height,
|height| Event::ViewportChanged(Size {
height,
..self.viewport
}),
|height| format!("{height:.0}"),
),
]
.spacing(10);
let preset = combo_box(
&self.presets,
"Default",
self.preset.as_ref(),
Event::PresetSelected,
)
.size(14)
.width(Fill);
let mode = pick_list(
emulator::Mode::ALL,
Some(self.mode),
Event::ModeSelected,
)
.text_size(14)
.width(Fill);
let player = {
let instructions = if let Some(edit) = &self.edit {
text_editor(edit)
.size(12)
.height(Fill)
.font(Font::MONOSPACE)
.on_action(Event::Edited)
.into()
} else if self.instructions.is_empty() {
Element::from(center(
text("No instructions recorded yet!")
.size(14)
.font(Font::MONOSPACE)
.width(Fill)
.center(),
))
} else {
scrollable(
column(self.instructions.iter().enumerate().map(
|(i, instruction)| {
text(instruction.to_string())
.wrapping(text::Wrapping::None) // TODO: Ellipsize?
.size(10)
.font(Font::MONOSPACE)
.style(move |theme: &Theme| text::Style {
color: match &self.state {
State::Playing {
current,
outcome,
..
} => {
if *current == i + 1 {
Some(match outcome {
Outcome::Running => {
theme.palette().primary
}
Outcome::Failed => {
theme
.extended_palette()
.danger
.strong
.color
}
Outcome::Success => {
theme
.extended_palette()
.success
.strong
.color
}
})
} else if *current > i + 1 {
Some(
theme
.extended_palette()
.success
.strong
.color,
)
} else {
None
}
}
_ => None,
},
})
.into()
},
))
.spacing(5),
)
.width(Fill)
.height(Fill)
.spacing(5)
.into()
};
let control = |icon: text::Text<'static, _, _>| {
button(icon.size(14).width(Fill).height(Fill).center())
};
let play = control(icon::play()).on_press_maybe(
(!matches!(self.state, State::Recording { .. })
&& !self.instructions.is_empty())
.then_some(Event::Play),
);
let record = if let State::Recording { .. } = &self.state {
control(icon::stop())
.on_press(Event::Stop)
.style(button::success)
} else {
control(icon::record())
.on_press_maybe((!self.is_busy()).then_some(Event::Record))
.style(button::danger)
};
let import = control(icon::folder())
.on_press_maybe((!self.is_busy()).then_some(Event::Import))
.style(button::secondary);
let export = control(icon::floppy())
.on_press_maybe(
(!matches!(self.state, State::Recording { .. })
&& !self.instructions.is_empty())
.then_some(Event::Export),
)
.style(button::success);
let controls =
row![import, export, play, record].height(30).spacing(10);
column![instructions, controls].spacing(10).align_x(Center)
};
let edit = if self.is_busy() {
Element::from(space::horizontal())
} else if self.edit.is_none() {
button(icon::pencil().size(14))
.padding(0)
.on_press(Event::Edit)
.style(button::text)
.into()
} else {
button(icon::check().size(14))
.padding(0)
.on_press(Event::Confirm)
.style(button::text)
.into()
};
column![
labeled("Viewport", viewport),
labeled("Mode", mode),
labeled("Preset", preset),
labeled_with("Instructions", edit, player)
]
.spacing(10)
.into()
}
}
fn labeled<'a, Message, Renderer>(
fragment: impl text::IntoFragment<'a>,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: program::Renderer + 'a,
{
column![
text(fragment).size(14).font(Font::MONOSPACE),
content.into()
]
.spacing(5)
.into()
}
fn labeled_with<'a, Message, Renderer>(
fragment: impl text::IntoFragment<'a>,
control: impl Into<Element<'a, Message, Theme, Renderer>>,
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: program::Renderer + 'a,
{
column![
row![
text(fragment).size(14).font(Font::MONOSPACE),
space::horizontal(),
control.into()
]
.spacing(5)
.align_y(Center),
content.into()
]
.spacing(5)
.into()
}
fn labeled_slider<'a, Message, Renderer>(
label: impl text::IntoFragment<'a>,
range: RangeInclusive<f32>,
current: f32,
on_change: impl Fn(f32) -> Message + 'a,
to_string: impl Fn(&f32) -> String,
) -> Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Renderer: core::text::Renderer + 'a,
{
stack![
container(
slider(range, current, on_change)
.step(10.0)
.width(Fill)
.height(24)
.style(|theme: &core::Theme, status| {
let palette = theme.extended_palette();
slider::Style {
rail: slider::Rail {
backgrounds: (
match status {
slider::Status::Active
| slider::Status::Dragged => {
palette.background.strongest.color
}
slider::Status::Hovered => {
palette.background.stronger.color
}
}
.into(),
Color::TRANSPARENT.into(),
),
width: 24.0,
border: border::rounded(2),
},
handle: slider::Handle {
shape: slider::HandleShape::Circle { radius: 0.0 },
background: Color::TRANSPARENT.into(),
border_width: 0.0,
border_color: Color::TRANSPARENT,
},
}
})
)
.style(|theme| container::Style::default()
.background(theme.extended_palette().background.weak.color)
.border(border::rounded(2))),
row![
text(label).size(14).style(|theme: &core::Theme| {
text::Style {
color: Some(theme.extended_palette().background.weak.text),
}
}),
space::horizontal(),
text(to_string(&current)).size(14)
]
.padding([0, 10])
.height(Fill)
.align_y(Center),
]
.into()
}

498
tester/src/recorder.rs Normal file
View file

@ -0,0 +1,498 @@
use crate::core::layout;
use crate::core::mouse;
use crate::core::overlay;
use crate::core::renderer;
use crate::core::widget;
use crate::core::widget::operation;
use crate::core::widget::tree;
use crate::core::{
self, Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell,
Size, Theme, Vector, Widget,
};
use crate::test::Selector;
use crate::test::instruction::{Interaction, Mouse, Target};
use crate::test::selector;
pub fn recorder<'a, Message, Renderer>(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Recorder<'a, Message, Renderer> {
Recorder::new(content)
}
pub struct Recorder<'a, Message, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
on_record: Option<Box<dyn Fn(Interaction) -> Message + 'a>>,
has_overlay: bool,
}
impl<'a, Message, Renderer> Recorder<'a, Message, Renderer> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self {
Self {
content: content.into(),
on_record: None,
has_overlay: false,
}
}
pub fn on_record(
mut self,
on_record: impl Fn(Interaction) -> Message + 'a,
) -> Self {
self.on_record = Some(Box::new(on_record));
self
}
}
struct State {
last_hovered: Option<Rectangle>,
last_hovered_overlay: Option<Rectangle>,
}
impl<Message, Renderer> Widget<Message, Theme, Renderer>
for Recorder<'_, Message, Renderer>
where
Renderer: core::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State {
last_hovered: None,
last_hovered_overlay: None,
})
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&self, tree: &mut tree::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
if !self.has_overlay
&& let Some(on_record) = &self.on_record
{
let state = tree.state.downcast_mut::<State>();
record(
event,
cursor,
shell,
layout.bounds(),
&mut state.last_hovered,
on_record,
|operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
},
);
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(
&mut tree.children[0],
renderer,
limits,
)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
let state = tree.state.downcast_ref::<State>();
let Some(last_hovered) = &state.last_hovered else {
return;
};
let palette = theme.palette();
renderer.with_layer(*viewport, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds: *last_hovered,
..renderer::Quad::default()
},
palette.primary.scale_alpha(0.7),
);
});
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
_viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
self.has_overlay = false;
self.content
.as_widget_mut()
.overlay(
&mut tree.children[0],
layout,
renderer,
&layout.bounds(),
translation,
)
.map(|raw| {
self.has_overlay = true;
let state = tree.state.downcast_mut::<State>();
overlay::Element::new(Box::new(Overlay {
raw,
bounds: layout.bounds(),
last_hovered: &mut state.last_hovered_overlay,
on_record: self.on_record.as_deref(),
}))
})
}
}
impl<'a, Message, Renderer> From<Recorder<'a, Message, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: core::Renderer + 'a,
{
fn from(recorder: Recorder<'a, Message, Renderer>) -> Self {
Element::new(recorder)
}
}
struct Overlay<'a, Message, Renderer> {
raw: overlay::Element<'a, Message, Theme, Renderer>,
bounds: Rectangle,
last_hovered: &'a mut Option<Rectangle>,
on_record: Option<&'a dyn Fn(Interaction) -> Message>,
}
impl<'a, Message, Renderer> core::Overlay<Message, Theme, Renderer>
for Overlay<'a, Message, Renderer>
where
Renderer: core::Renderer + 'a,
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.raw.as_overlay_mut().layout(renderer, bounds)
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
self.raw
.as_overlay()
.draw(renderer, theme, style, layout, cursor);
let Some(last_hovered) = &self.last_hovered else {
return;
};
let palette = theme.palette();
renderer.with_layer(self.bounds, |renderer| {
renderer.fill_quad(
renderer::Quad {
bounds: *last_hovered,
..renderer::Quad::default()
},
palette.primary.scale_alpha(0.7),
);
});
}
fn operate(
&mut self,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.raw
.as_overlay_mut()
.operate(layout, renderer, operation);
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
if shell.is_event_captured() {
return;
}
if let Some(on_event) = &self.on_record {
record(
event,
cursor,
shell,
self.bounds,
self.last_hovered,
on_event,
|operation| {
self.raw
.as_overlay_mut()
.operate(layout, renderer, operation);
},
);
}
self.raw
.as_overlay_mut()
.update(event, layout, cursor, renderer, clipboard, shell);
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
self.raw
.as_overlay()
.mouse_interaction(layout, cursor, renderer)
}
fn overlay<'b>(
&'b mut self,
layout: Layout<'b>,
renderer: &Renderer,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.raw
.as_overlay_mut()
.overlay(layout, renderer)
.map(|raw| {
overlay::Element::new(Box::new(Overlay {
raw,
bounds: self.bounds,
last_hovered: self.last_hovered,
on_record: self.on_record,
}))
})
}
fn index(&self) -> f32 {
self.raw.as_overlay().index()
}
}
fn record<Message>(
event: &Event,
cursor: mouse::Cursor,
shell: &mut Shell<'_, Message>,
bounds: Rectangle,
last_hovered: &mut Option<Rectangle>,
on_record: impl Fn(Interaction) -> Message,
operate: impl FnMut(&mut dyn widget::Operation),
) {
if let Event::Mouse(_) = event
&& !cursor.is_over(bounds)
{
return;
}
let interaction =
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
Interaction::from_event(&Event::Mouse(mouse::Event::CursorMoved {
position: *position - (bounds.position() - Point::ORIGIN),
}))
} else {
Interaction::from_event(event)
};
let Some(mut interaction) = interaction else {
return;
};
let Interaction::Mouse(
Mouse::Move(target)
| Mouse::Press {
target: Some(target),
..
}
| Mouse::Release {
target: Some(target),
..
}
| Mouse::Click {
target: Some(target),
..
},
) = &mut interaction
else {
shell.publish(on_record(interaction));
return;
};
let Target::Point(position) = *target else {
shell.publish(on_record(interaction));
return;
};
if let Some((content, visible_bounds)) =
find_text(position + (bounds.position() - Point::ORIGIN), operate)
{
*target = Target::Text(content);
*last_hovered = visible_bounds;
} else {
*last_hovered = None;
}
shell.publish(on_record(interaction));
}
fn find_text(
position: Point,
mut operate: impl FnMut(&mut dyn widget::Operation),
) -> Option<(String, Option<Rectangle>)> {
use widget::Operation;
let mut by_position = position.find_all();
operate(&mut operation::black_box(&mut by_position));
let operation::Outcome::Some(targets) = by_position.finish() else {
return None;
};
let (content, visible_bounds) =
targets.into_iter().rev().find_map(|target| {
if let selector::Target::Text {
content,
visible_bounds,
..
}
| selector::Target::TextInput {
content,
visible_bounds,
..
} = target
{
Some((content, visible_bounds))
} else {
None
}
})?;
let mut by_text = content.clone().find_all();
operate(&mut operation::black_box(&mut by_text));
let operation::Outcome::Some(texts) = by_text.finish() else {
return None;
};
if texts.len() > 1 {
return None;
}
Some((content, visible_bounds))
}

View file

@ -235,7 +235,6 @@ impl core::text::Renderer for Renderer {
type Paragraph = Paragraph;
type Editor = Editor;
const MONOSPACE_FONT: Font = Font::MONOSPACE;
const ICON_FONT: Font = Font::with_name("Iced-Icons");
const CHECKMARK_ICON: char = '\u{f00c}';
const ARROW_DOWN_ICON: char = '\u{e800}';

View file

@ -8,13 +8,11 @@ use crate::{Layer, Renderer, Settings};
use std::collections::VecDeque;
use std::num::NonZeroU32;
#[allow(missing_debug_implementations)]
pub struct Compositor {
context: softbuffer::Context<Box<dyn compositor::Window>>,
settings: Settings,
}
#[allow(missing_debug_implementations)]
pub struct Surface {
window: softbuffer::Surface<
Box<dyn compositor::Window>,

View file

@ -7,7 +7,6 @@ use crate::triangle;
use std::sync::{Arc, RwLock};
#[derive(Clone)]
#[allow(missing_debug_implementations)]
pub struct Engine {
pub(crate) device: wgpu::Device,
pub(crate) queue: wgpu::Queue,

View file

@ -92,7 +92,6 @@ impl Cached for Geometry {
}
/// A frame for drawing some geometry.
#[allow(missing_debug_implementations)]
pub struct Frame {
clip_bounds: Rectangle,
buffers: BufferStack,

View file

@ -72,7 +72,6 @@ use crate::graphics::text::{Editor, Paragraph};
///
/// [`wgpu`]: https://github.com/gfx-rs/wgpu-rs
/// [`iced`]: https://github.com/iced-rs/iced
#[allow(missing_debug_implementations)]
pub struct Renderer {
engine: Engine,
@ -709,7 +708,6 @@ impl core::text::Renderer for Renderer {
type Paragraph = Paragraph;
type Editor = Editor;
const MONOSPACE_FONT: Font = Font::MONOSPACE;
const ICON_FONT: Font = Font::with_name("Iced-Icons");
const CHECKMARK_ICON: char = '\u{f00c}';
const ARROW_DOWN_ICON: char = '\u{e800}';

View file

@ -197,7 +197,6 @@ pub trait Renderer: core::Renderer {
/// Stores custom, user-provided types.
#[derive(Default)]
#[allow(missing_debug_implementations)]
pub struct Storage {
pipelines: FxHashMap<TypeId, Box<dyn AnyConcurrent>>,
}

View file

@ -269,7 +269,6 @@ impl Viewport {
}
#[derive(Clone)]
#[allow(missing_debug_implementations)]
pub struct Pipeline {
format: wgpu::TextureFormat,
cache: cryoglyph::Cache,

View file

@ -8,7 +8,6 @@ use crate::settings::{self, Settings};
use crate::{Engine, Renderer};
/// A window graphics backend for iced powered by `wgpu`.
#[allow(missing_debug_implementations)]
pub struct Compositor {
instance: wgpu::Instance,
adapter: wgpu::Adapter,

View file

@ -31,7 +31,6 @@ crisp = []
[dependencies]
iced_renderer.workspace = true
iced_runtime.workspace = true
num-traits.workspace = true
log.workspace = true

View file

@ -68,7 +68,6 @@ use crate::core::{
/// button("I am disabled!").into()
/// }
/// ```
#[allow(missing_debug_implementations)]
pub struct Button<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
where
Renderer: crate::core::Renderer,
@ -262,7 +261,8 @@ where
renderer: &Renderer,
operation: &mut dyn Operation,
) {
operation.container(None, layout.bounds(), &mut |operation| {
operation.container(None, layout.bounds());
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout.children().next().unwrap(),

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