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:
commit
0a34496c79
148 changed files with 6025 additions and 2039 deletions
491
Cargo.lock
generated
491
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
Cargo.toml
19
Cargo.toml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
))
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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");
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ab8f9190260837ba9b0cf30f072116e86be1c90197a56ad00da6de60a618a3b8
|
||||
896072b46221f83e1edaa37574436af6474969625f5c1a41cc5ddc2e20823cee
|
||||
|
|
@ -1 +1 @@
|
|||
ddee619e66418803c64ed5677fd375ad596e234ab9541ab197f17c81e2100279
|
||||
2010df2e80bfc72e7e9274de07b77dc4843485f6be38266fdfb7a4f129d75da1
|
||||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
examples/todos/tests/carl_sagan.ice
Normal file
14
examples/todos/tests/carl_sagan.ice
Normal 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"
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ rust-version.workspace = true
|
|||
workspace = true
|
||||
|
||||
[features]
|
||||
debug = []
|
||||
time-travel = []
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -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
33
program/src/message.rs
Normal 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
42
program/src/preset.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
//! Overlays for user interfaces.
|
||||
mod nested;
|
||||
|
||||
pub use nested::Nested;
|
||||
|
|
@ -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()),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
5
runtime/src/widget.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Operate on widgets and query them at runtime.
|
||||
pub mod operation;
|
||||
|
||||
#[cfg(feature = "selector")]
|
||||
pub mod selector;
|
||||
88
runtime/src/widget/operation.rs
Normal file
88
runtime/src/widget/operation.rs
Normal 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())))
|
||||
}
|
||||
28
runtime/src/widget/selector.rs
Normal file
28
runtime/src/widget/selector.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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
17
selector/Cargo.toml
Normal 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
276
selector/src/find.rs
Normal 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
148
selector/src/lib.rs
Normal 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
291
selector/src/target.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
134
src/daemon.rs
134
src/daemon.rs
|
|
@ -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`].
|
||||
|
|
|
|||
12
src/lib.rs
12
src/lib.rs
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
485
test/src/emulator.rs
Normal 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
76
test/src/error.rs
Normal 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
237
test/src/ice.rs
Normal 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
729
test/src/instruction.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
666
test/src/lib.rs
666
test/src/lib.rs
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
427
test/src/simulator.rs
Normal 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
20
tester/Cargo.toml
Normal 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
|
||||
15
tester/fonts/iced_tester-icons.toml
Normal file
15
tester/fonts/iced_tester-icons.toml
Normal 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"
|
||||
BIN
tester/fonts/iced_tester-icons.ttf
Normal file
BIN
tester/fonts/iced_tester-icons.ttf
Normal file
Binary file not shown.
118
tester/src/icon.rs
Normal file
118
tester/src/icon.rs
Normal 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
945
tester/src/lib.rs
Normal 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(¤t)).size(14)
|
||||
]
|
||||
.padding([0, 10])
|
||||
.height(Fill)
|
||||
.align_y(Center),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
498
tester/src/recorder.rs
Normal file
498
tester/src/recorder.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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}';
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}';
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,7 +269,6 @@ impl Viewport {
|
|||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub struct Pipeline {
|
||||
format: wgpu::TextureFormat,
|
||||
cache: cryoglyph::Cache,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ crisp = []
|
|||
|
||||
[dependencies]
|
||||
iced_renderer.workspace = true
|
||||
iced_runtime.workspace = true
|
||||
|
||||
num-traits.workspace = true
|
||||
log.workspace = true
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue