Merge pull request #2698 from iced-rs/feature/test-crate

Headless Mode Testing
This commit is contained in:
Héctor 2024-12-17 17:28:46 +01:00 committed by GitHub
commit f2c9b6b2ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 8395 additions and 196 deletions

View file

@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
rust: [stable, beta, "1.80"]
rust: [stable, beta, "1.81"]
steps:
- uses: hecrj/setup-rust-action@v2
with:

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
target/
pkg/
**/*.rs.bk
Cargo.lock
dist/
traces/

7150
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@ all-features = true
maintenance = { status = "actively-developed" }
[features]
default = ["wgpu", "tiny-skia", "fira-sans", "auto-detect-theme"]
default = ["wgpu", "tiny-skia", "auto-detect-theme"]
# Enables the `wgpu` GPU-accelerated renderer backend
wgpu = ["iced_renderer/wgpu", "iced_widget/wgpu"]
# Enables the `tiny-skia` software renderer backend
@ -53,13 +53,13 @@ smol = ["iced_futures/smol"]
system = ["iced_winit/system"]
# Enables broken "sRGB linear" blending to reproduce color management of the Web
web-colors = ["iced_renderer/web-colors"]
# Enables the WebGL backend, replacing WebGPU
# Enables the WebGL backend
webgl = ["iced_renderer/webgl"]
# Enables syntax highligthing
highlighter = ["iced_highlighter", "iced_widget/highlighter"]
# Enables the advanced module
advanced = ["iced_core/advanced", "iced_widget/advanced"]
# Embeds Fira Sans as the default font on Wasm builds
# Embeds Fira Sans into the final application; useful for testing and Wasm builds
fira-sans = ["iced_renderer/fira-sans"]
# Auto-detects light/dark mode for the built-in theme
auto-detect-theme = ["iced_core/auto-detect-theme"]
@ -111,6 +111,7 @@ members = [
"highlighter",
"renderer",
"runtime",
"test",
"tiny_skia",
"wgpu",
"widget",
@ -127,7 +128,7 @@ repository = "https://github.com/iced-rs/iced"
homepage = "https://iced.rs"
categories = ["gui"]
keywords = ["gui", "ui", "graphics", "interface", "widgets"]
rust-version = "1.80"
rust-version = "1.81"
[workspace.dependencies]
iced = { version = "0.14.0-dev", path = "." }
@ -137,6 +138,7 @@ iced_graphics = { version = "0.14.0-dev", path = "graphics" }
iced_highlighter = { version = "0.14.0-dev", path = "highlighter" }
iced_renderer = { version = "0.14.0-dev", path = "renderer" }
iced_runtime = { version = "0.14.0-dev", path = "runtime" }
iced_test = { version = "0.14.0-dev", path = "test" }
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" }
@ -163,11 +165,13 @@ num-traits = "0.2"
once_cell = "1.0"
ouroboros = "0.18"
palette = "0.7"
png = "0.17"
pulldown-cmark = "0.11"
qrcode = { version = "0.13", default-features = false }
raw-window-handle = "0.6"
resvg = "0.42"
rustc-hash = "2.0"
sha2 = "0.10"
smol = "1.0"
smol_str = "0.2"
softbuffer = "0.4"

View file

@ -32,6 +32,12 @@ impl Key {
}
}
impl From<Named> for Key {
fn from(named: Named) -> Self {
Self::Named(named)
}
}
/// A named key.
///
/// This is mostly the `NamedKey` type found in [`winit`].

View file

@ -40,6 +40,7 @@ mod pixels;
mod point;
mod rectangle;
mod rotation;
mod settings;
mod shadow;
mod shell;
mod size;
@ -67,6 +68,7 @@ pub use point::Point;
pub use rectangle::Rectangle;
pub use renderer::Renderer;
pub use rotation::Rotation;
pub use settings::Settings;
pub use shadow::Shadow;
pub use shell::Shell;
pub use size::Size;

View file

@ -3,7 +3,8 @@
mod null;
use crate::{
Background, Border, Color, Rectangle, Shadow, Size, Transformation, Vector,
Background, Border, Color, Font, Pixels, Rectangle, Shadow, Size,
Transformation, Vector,
};
/// A component that can be used by widgets to draw themselves on a screen.
@ -100,3 +101,19 @@ impl Default for Style {
}
}
}
/// A headless renderer is a renderer that can render offscreen without
/// a window nor a compositor.
pub trait Headless {
/// Creates a new [`Headless`] renderer;
fn new(default_font: Font, default_text_size: Pixels) -> Self;
/// Draws offscreen into a screenshot, returning a collection of
/// bytes representing the rendered pixels in RGBA order.
fn screenshot(
&mut self,
size: Size<u32>,
scale_factor: f32,
background_color: Color,
) -> Vec<u8>;
}

View file

@ -29,11 +29,9 @@ pub struct Settings {
/// primitives.
///
/// Enabling it can produce a smoother result in some widgets, like the
/// [`Canvas`], at a performance cost.
/// `canvas` widget, at a performance cost.
///
/// By default, it is disabled.
///
/// [`Canvas`]: crate::widget::Canvas
pub antialiasing: bool,
}
@ -48,12 +46,3 @@ impl Default for Settings {
}
}
}
impl From<Settings> for iced_winit::Settings {
fn from(settings: Settings) -> iced_winit::Settings {
iced_winit::Settings {
id: settings.id,
fonts: settings.fonts,
}
}
}

View file

@ -3,6 +3,8 @@ pub mod palette;
pub use palette::Palette;
use crate::Color;
use std::fmt;
use std::sync::Arc;
@ -246,3 +248,35 @@ impl fmt::Display for Custom {
write!(f, "{}", self.name)
}
}
/// The base style of a [`Theme`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Style {
/// The background [`Color`] of the application.
pub background_color: Color,
/// The default text [`Color`] of the application.
pub text_color: Color,
}
/// The default blank style of a [`Theme`].
pub trait Base {
/// Returns the default base [`Style`] of a [`Theme`].
fn base(&self) -> Style;
}
impl Base for Theme {
fn base(&self) -> Style {
default(self)
}
}
/// The default [`Style`] of a built-in [`Theme`].
pub fn default(theme: &Theme) -> Style {
let palette = theme.extended_palette();
Style {
background_color: palette.background.base.color,
text_color: palette.background.base.text,
}
}

View file

@ -30,24 +30,45 @@ pub trait Operation<T = ()>: Send {
);
/// Operates on a widget that can be focused.
fn focusable(&mut self, _state: &mut dyn Focusable, _id: Option<&Id>) {}
fn focusable(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
_state: &mut dyn Focusable,
) {
}
/// Operates on a widget that can be scrolled.
fn scrollable(
&mut self,
_state: &mut dyn Scrollable,
_id: Option<&Id>,
_bounds: Rectangle,
_content_bounds: Rectangle,
_translation: Vector,
_state: &mut dyn Scrollable,
) {
}
/// Operates on a widget that has text input.
fn text_input(&mut self, _state: &mut dyn TextInput, _id: Option<&Id>) {}
fn text_input(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
_state: &mut dyn TextInput,
) {
}
/// Operates on a widget that contains some text.
fn text(&mut self, _id: Option<&Id>, _bounds: Rectangle, _text: &str) {}
/// Operates on a custom widget with some state.
fn custom(&mut self, _state: &mut dyn Any, _id: Option<&Id>) {}
fn custom(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
_state: &mut dyn Any,
) {
}
/// Finishes the [`Operation`] and returns its [`Outcome`].
fn finish(&self) -> Outcome<T> {
@ -68,33 +89,52 @@ where
self.as_mut().container(id, bounds, operate_on_children);
}
fn focusable(&mut self, state: &mut dyn Focusable, id: Option<&Id>) {
self.as_mut().focusable(state, id);
fn focusable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Focusable,
) {
self.as_mut().focusable(id, bounds, state);
}
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: Vector,
state: &mut dyn Scrollable,
) {
self.as_mut().scrollable(
state,
id,
bounds,
content_bounds,
translation,
state,
);
}
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
self.as_mut().text_input(state, id);
fn text_input(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn TextInput,
) {
self.as_mut().text_input(id, bounds, state);
}
fn custom(&mut self, state: &mut dyn Any, id: Option<&Id>) {
self.as_mut().custom(state, id);
fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) {
self.as_mut().text(id, bounds, text);
}
fn custom(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Any,
) {
self.as_mut().custom(id, bounds, state);
}
fn finish(&self) -> Outcome<O> {
@ -150,33 +190,52 @@ where
});
}
fn focusable(&mut self, state: &mut dyn Focusable, id: Option<&Id>) {
self.operation.focusable(state, id);
fn focusable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Focusable,
) {
self.operation.focusable(id, bounds, state);
}
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: Vector,
state: &mut dyn Scrollable,
) {
self.operation.scrollable(
state,
id,
bounds,
content_bounds,
translation,
state,
);
}
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
self.operation.text_input(state, id);
fn text_input(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn TextInput,
) {
self.operation.text_input(id, bounds, state);
}
fn custom(&mut self, state: &mut dyn Any, id: Option<&Id>) {
self.operation.custom(state, id);
fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) {
self.operation.text(id, bounds, text);
}
fn custom(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Any,
) {
self.operation.custom(id, bounds, state);
}
fn finish(&self) -> Outcome<O> {
@ -234,39 +293,55 @@ where
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: Vector,
state: &mut dyn Scrollable,
) {
self.operation.scrollable(
state,
id,
bounds,
content_bounds,
translation,
state,
);
}
fn focusable(
&mut self,
state: &mut dyn Focusable,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Focusable,
) {
self.operation.focusable(state, id);
self.operation.focusable(id, bounds, state);
}
fn text_input(
&mut self,
state: &mut dyn TextInput,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn TextInput,
) {
self.operation.text_input(state, id);
self.operation.text_input(id, bounds, state);
}
fn custom(&mut self, state: &mut dyn Any, id: Option<&Id>) {
self.operation.custom(state, id);
fn text(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
text: &str,
) {
self.operation.text(id, bounds, text);
}
fn custom(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Any,
) {
self.operation.custom(id, bounds, state);
}
}
@ -275,33 +350,52 @@ where
MapRef { operation }.container(id, bounds, operate_on_children);
}
fn focusable(&mut self, state: &mut dyn Focusable, id: Option<&Id>) {
self.operation.focusable(state, id);
fn focusable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Focusable,
) {
self.operation.focusable(id, bounds, state);
}
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: Vector,
state: &mut dyn Scrollable,
) {
self.operation.scrollable(
state,
id,
bounds,
content_bounds,
translation,
state,
);
}
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
self.operation.text_input(state, id);
fn text_input(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn TextInput,
) {
self.operation.text_input(id, bounds, state);
}
fn custom(&mut self, state: &mut dyn Any, id: Option<&Id>) {
self.operation.custom(state, id);
fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) {
self.operation.text(id, bounds, text);
}
fn custom(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Any,
) {
self.operation.custom(id, bounds, state);
}
fn finish(&self) -> Outcome<B> {
@ -361,33 +455,52 @@ where
});
}
fn focusable(&mut self, state: &mut dyn Focusable, id: Option<&Id>) {
self.operation.focusable(state, id);
fn focusable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Focusable,
) {
self.operation.focusable(id, bounds, state);
}
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: crate::Vector,
state: &mut dyn Scrollable,
) {
self.operation.scrollable(
state,
id,
bounds,
content_bounds,
translation,
state,
);
}
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
self.operation.text_input(state, id);
fn text_input(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn TextInput,
) {
self.operation.text_input(id, bounds, state);
}
fn custom(&mut self, state: &mut dyn std::any::Any, id: Option<&Id>) {
self.operation.custom(state, id);
fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) {
self.operation.text(id, bounds, text);
}
fn custom(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
state: &mut dyn Any,
) {
self.operation.custom(id, bounds, state);
}
fn finish(&self) -> Outcome<B> {

View file

@ -32,7 +32,12 @@ pub fn focus<T>(target: Id) -> impl Operation<T> {
}
impl<T> Operation<T> for Focus {
fn focusable(&mut self, state: &mut dyn Focusable, id: Option<&Id>) {
fn focusable(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn Focusable,
) {
match id {
Some(id) if id == &self.target => {
state.focus();
@ -64,7 +69,12 @@ pub fn count() -> impl Operation<Count> {
}
impl Operation<Count> for CountFocusable {
fn focusable(&mut self, state: &mut dyn Focusable, _id: Option<&Id>) {
fn focusable(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn Focusable,
) {
if state.is_focused() {
self.count.focused = Some(self.count.total);
}
@ -104,7 +114,12 @@ where
}
impl<T> Operation<T> for FocusPrevious {
fn focusable(&mut self, state: &mut dyn Focusable, _id: Option<&Id>) {
fn focusable(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn Focusable,
) {
if self.count.total == 0 {
return;
}
@ -147,7 +162,12 @@ where
}
impl<T> Operation<T> for FocusNext {
fn focusable(&mut self, state: &mut dyn Focusable, _id: Option<&Id>) {
fn focusable(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn Focusable,
) {
match self.count.focused {
None if self.current == 0 => state.focus(),
Some(focused) if focused == self.current => state.unfocus(),
@ -179,7 +199,12 @@ pub fn find_focused() -> impl Operation<Id> {
}
impl Operation<Id> for FindFocused {
fn focusable(&mut self, state: &mut dyn Focusable, id: Option<&Id>) {
fn focusable(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn Focusable,
) {
if state.is_focused() && id.is_some() {
self.focused = id.cloned();
}

View file

@ -39,11 +39,11 @@ pub fn snap_to<T>(target: Id, offset: RelativeOffset) -> impl Operation<T> {
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
_bounds: Rectangle,
_content_bounds: Rectangle,
_translation: Vector,
state: &mut dyn Scrollable,
) {
if Some(&self.target) == id {
state.snap_to(self.offset);
@ -74,11 +74,11 @@ pub fn scroll_to<T>(target: Id, offset: AbsoluteOffset) -> impl Operation<T> {
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
_bounds: Rectangle,
_content_bounds: Rectangle,
_translation: Vector,
state: &mut dyn Scrollable,
) {
if Some(&self.target) == id {
state.scroll_to(self.offset);
@ -109,11 +109,11 @@ pub fn scroll_by<T>(target: Id, offset: AbsoluteOffset) -> impl Operation<T> {
fn scrollable(
&mut self,
state: &mut dyn Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
_translation: Vector,
state: &mut dyn Scrollable,
) {
if Some(&self.target) == id {
state.scroll_by(self.offset, bounds, content_bounds);

View file

@ -23,7 +23,12 @@ pub fn move_cursor_to_front<T>(target: Id) -> impl Operation<T> {
}
impl<T> Operation<T> for MoveCursor {
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
fn text_input(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn TextInput,
) {
match id {
Some(id) if id == &self.target => {
state.move_cursor_to_front();
@ -53,7 +58,12 @@ pub fn move_cursor_to_end<T>(target: Id) -> impl Operation<T> {
}
impl<T> Operation<T> for MoveCursor {
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
fn text_input(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn TextInput,
) {
match id {
Some(id) if id == &self.target => {
state.move_cursor_to_end();
@ -84,7 +94,12 @@ pub fn move_cursor_to<T>(target: Id, position: usize) -> impl Operation<T> {
}
impl<T> Operation<T> for MoveCursor {
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
fn text_input(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn TextInput,
) {
match id {
Some(id) if id == &self.target => {
state.move_cursor_to(self.position);
@ -113,7 +128,12 @@ pub fn select_all<T>(target: Id) -> impl Operation<T> {
}
impl<T> Operation<T> for MoveCursor {
fn text_input(&mut self, state: &mut dyn TextInput, id: Option<&Id>) {
fn text_input(
&mut self,
id: Option<&Id>,
_bounds: Rectangle,
state: &mut dyn TextInput,
) {
match id {
Some(id) if id == &self.target => {
state.select_all();

View file

@ -267,6 +267,16 @@ where
draw(renderer, defaults, layout, state.0.raw(), style, viewport);
}
fn operate(
&self,
_state: &mut Tree,
layout: Layout<'_>,
_renderer: &Renderer,
operation: &mut dyn super::Operation,
) {
operation.text(None, layout.bounds(), &self.fragment);
}
}
/// Produces the [`layout::Node`] of a [`Text`] widget.

View file

@ -1,5 +1,6 @@
//! Build window-based GUI applications.
pub mod icon;
pub mod screenshot;
pub mod settings;
mod event;
@ -17,5 +18,6 @@ pub use level::Level;
pub use mode::Mode;
pub use position::Position;
pub use redraw_request::RedrawRequest;
pub use screenshot::Screenshot;
pub use settings::Settings;
pub use user_attention::UserAttention;

View file

@ -1,5 +1,5 @@
//! Take screenshots of a window.
use crate::core::{Rectangle, Size};
use crate::{Rectangle, Size};
use bytes::Bytes;
use std::fmt::{Debug, Formatter};

View file

@ -28,6 +28,7 @@ use crate::window::{Icon, Level, Position};
use crate::Size;
pub use platform::PlatformSpecific;
/// The window settings of an application.
#[derive(Debug, Clone)]
pub struct Settings {

View file

@ -10,4 +10,7 @@ iced.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
iced.workspace = true
iced.features = ["webgl"]
iced.features = ["webgl", "fira-sans"]
[dev-dependencies]
iced_test.workspace = true

View file

@ -38,3 +38,31 @@ impl Counter {
.align_x(Center)
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced_test::selector::text;
use iced_test::{simulator, Error};
#[test]
fn it_counts() -> Result<(), Error> {
let mut counter = Counter { value: 0 };
let mut ui = simulator(counter.view());
let _ = ui.click(text("Increment"))?;
let _ = ui.click(text("Increment"))?;
let _ = ui.click(text("Decrement"))?;
for message in ui.into_messages() {
counter.update(message);
}
assert_eq!(counter.value, 1);
let mut ui = simulator(counter.view());
assert!(ui.find(text("1")).is_ok(), "Counter should display 1!");
Ok(())
}
}

View file

@ -1,5 +1,5 @@
use iced::application;
use iced::gradient;
use iced::theme;
use iced::widget::{
checkbox, column, container, horizontal_space, row, slider, text,
};
@ -95,16 +95,14 @@ impl Gradient {
.into()
}
fn style(&self, theme: &Theme) -> application::Appearance {
use application::DefaultStyle;
fn style(&self, theme: &Theme) -> theme::Style {
if self.transparent {
application::Appearance {
theme::Style {
background_color: Color::TRANSPARENT,
text_color: theme.palette().text,
}
} else {
Theme::default_style(theme)
theme::default(theme)
}
}
}

View file

@ -20,12 +20,15 @@ tracing-subscriber = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
iced.workspace = true
iced.features = ["debug", "webgl"]
iced.features = ["debug", "webgl", "fira-sans"]
uuid = { version = "1.0", features = ["js"] }
web-sys = { workspace = true, features = ["Window", "Storage"] }
wasm-timer.workspace = true
[dev-dependencies]
iced_test.workspace = true
[package.metadata.deb]
assets = [
["target/release-opt/todos", "usr/bin/iced-todos", "755"],

View file

@ -0,0 +1 @@
3160686067cb7c738802009cdf2f3c5f5a5bd8c89ada70517388b7adbe64c313

View file

@ -15,7 +15,7 @@ pub fn main() -> iced::Result {
iced::application(Todos::title, Todos::update, Todos::view)
.subscription(Todos::subscription)
.font(include_bytes!("../fonts/icons.ttf").as_slice())
.font(Todos::ICON_FONT)
.window_size((500.0, 800.0))
.run_with(Todos::new)
}
@ -48,6 +48,8 @@ enum Message {
}
impl Todos {
const ICON_FONT: &'static [u8] = include_bytes!("../fonts/icons.ttf");
fn new() -> (Self, Command<Message>) {
(
Self::Loading,
@ -449,11 +451,10 @@ fn empty_message(message: &str) -> Element<'_, Message> {
}
// Fonts
const ICONS: Font = Font::with_name("Iced-Todos-Icons");
fn icon(unicode: char) -> Text<'static> {
text(unicode.to_string())
.font(ICONS)
.font(Font::with_name("Iced-Todos-Icons"))
.width(20)
.align_x(Center)
}
@ -584,3 +585,49 @@ impl SavedState {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced::{Settings, Theme};
use iced_test::selector::text;
use iced_test::{Error, Simulator};
fn simulator(todos: &Todos) -> Simulator<Message> {
Simulator::with_settings(
Settings {
fonts: vec![Todos::ICON_FONT.into()],
..Settings::default()
},
todos.view(),
)
}
#[test]
fn it_creates_a_new_task() -> Result<(), Error> {
let (mut todos, _command) = Todos::new();
let _command = todos.update(Message::Loaded(Err(LoadError::File)));
let mut ui = simulator(&todos);
let _input = ui.click("new-task")?;
let _ = ui.typewrite("Create the universe");
let _ = ui.tap_key(keyboard::key::Named::Enter);
for message in ui.into_messages() {
let _command = todos.update(message);
}
let mut ui = simulator(&todos);
let _ = ui.find(text("Create the universe"))?;
let snapshot = ui.snapshot(&Theme::Dark)?;
assert!(
snapshot.matches_hash("snapshots/creates_a_new_task")?,
"snapshots should match!"
);
Ok(())
}
}

View file

@ -14,7 +14,7 @@ tracing-subscriber = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
iced.workspace = true
iced.features = ["image", "debug", "webgl"]
iced.features = ["image", "debug", "webgl", "fira-sans"]
console_error_panic_hook = "0.1"
console_log = "1.0"

View file

@ -16,6 +16,7 @@ use crate::core::{Color, Pixels, Point, Rectangle, Size, Transformation};
use once_cell::sync::OnceCell;
use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::{Arc, RwLock, Weak};
/// A text primitive.
@ -146,11 +147,12 @@ impl Text {
/// The regular variant of the [Fira Sans] font.
///
/// It is loaded as part of the default fonts in Wasm builds.
/// It is loaded as part of the default fonts when the `fira-sans`
/// feature is enabled.
///
/// [Fira Sans]: https://mozilla.github.io/Fira/
#[cfg(all(target_arch = "wasm32", feature = "fira-sans"))]
pub const FIRA_SANS_REGULAR: &'static [u8] =
#[cfg(feature = "fira-sans")]
pub const FIRA_SANS_REGULAR: &[u8] =
include_bytes!("../fonts/FiraSans-Regular.ttf").as_slice();
/// Returns the global [`FontSystem`].
@ -163,11 +165,12 @@ pub fn font_system() -> &'static RwLock<FontSystem> {
cosmic_text::fontdb::Source::Binary(Arc::new(
include_bytes!("../fonts/Iced-Icons.ttf").as_slice(),
)),
#[cfg(all(target_arch = "wasm32", feature = "fira-sans"))]
#[cfg(feature = "fira-sans")]
cosmic_text::fontdb::Source::Binary(Arc::new(
include_bytes!("../fonts/FiraSans-Regular.ttf").as_slice(),
)),
]),
loaded_fonts: HashSet::new(),
version: Version::default(),
})
})
@ -177,6 +180,7 @@ pub fn font_system() -> &'static RwLock<FontSystem> {
#[allow(missing_debug_implementations)]
pub struct FontSystem {
raw: cosmic_text::FontSystem,
loaded_fonts: HashSet<usize>,
version: Version,
}
@ -188,6 +192,14 @@ impl FontSystem {
/// Loads a font from its bytes.
pub fn load_font(&mut self, bytes: Cow<'static, [u8]>) {
if let Cow::Borrowed(bytes) = bytes {
let address = bytes.as_ptr() as usize;
if !self.loaded_fonts.insert(address) {
return;
}
}
let _ = self.raw.db_mut().load_font_source(
cosmic_text::fontdb::Source::Binary(Arc::new(bytes.into_owned())),
);

View file

@ -23,6 +23,9 @@ pub type Compositor = renderer::Compositor;
#[cfg(all(feature = "wgpu", feature = "tiny-skia"))]
mod renderer {
use crate::core::renderer;
use crate::core::{Color, Font, Pixels, Size};
pub type Renderer = crate::fallback::Renderer<
iced_wgpu::Renderer,
iced_tiny_skia::Renderer,
@ -32,6 +35,31 @@ mod renderer {
iced_wgpu::window::Compositor,
iced_tiny_skia::window::Compositor,
>;
impl renderer::Headless for Renderer {
fn new(default_font: Font, default_text_size: Pixels) -> Self {
Self::Secondary(iced_tiny_skia::Renderer::new(
default_font,
default_text_size,
))
}
fn screenshot(
&mut self,
size: Size<u32>,
scale_factor: f32,
background_color: Color,
) -> Vec<u8> {
match self {
crate::fallback::Renderer::Primary(_) => unreachable!(
"iced_wgpu does not support headless mode yet!"
),
crate::fallback::Renderer::Secondary(renderer) => {
renderer.screenshot(size, scale_factor, background_color)
}
}
}
}
}
#[cfg(all(feature = "wgpu", not(feature = "tiny-skia")))]

View file

@ -1,11 +1,7 @@
//! Build window-based GUI applications.
pub mod screenshot;
pub use screenshot::Screenshot;
use crate::core::time::Instant;
use crate::core::window::{
Event, Icon, Id, Level, Mode, Settings, UserAttention,
Event, Icon, Id, Level, Mode, Screenshot, Settings, UserAttention,
};
use crate::core::{Point, Size};
use crate::futures::event;

View file

@ -31,6 +31,7 @@
//! }
//! ```
use crate::program::{self, Program};
use crate::theme;
use crate::window;
use crate::{
Element, Executor, Font, Result, Settings, Size, Subscription, Task,
@ -38,8 +39,6 @@ use crate::{
use std::borrow::Cow;
pub use crate::shell::program::{Appearance, DefaultStyle};
/// Creates an iced [`Application`] given its title, update, and view logic.
///
/// # Example
@ -76,7 +75,7 @@ pub fn application<State, Message, Theme, Renderer>(
where
State: 'static,
Message: Send + std::fmt::Debug + 'static,
Theme: Default + DefaultStyle,
Theme: Default + theme::Base,
Renderer: program::Renderer,
{
use std::marker::PhantomData;
@ -94,7 +93,7 @@ where
for Instance<State, Message, Theme, Renderer, Update, View>
where
Message: Send + std::fmt::Debug + 'static,
Theme: Default + DefaultStyle,
Theme: Default + theme::Base,
Renderer: program::Renderer,
Update: self::Update<State, Message>,
View: for<'a> self::View<'a, State, Message, Theme, Renderer>,
@ -352,7 +351,7 @@ impl<P: Program> Application<P> {
/// Sets the style logic of the [`Application`].
pub fn style(
self,
f: impl Fn(&P::State, &P::Theme) -> Appearance,
f: impl Fn(&P::State, &P::Theme) -> theme::Style,
) -> Application<
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {

View file

@ -1,13 +1,12 @@
//! Create and run daemons that run in the background.
use crate::application;
use crate::program::{self, Program};
use crate::theme;
use crate::window;
use crate::{Element, Executor, Font, Result, Settings, Subscription, Task};
use std::borrow::Cow;
pub use crate::shell::program::{Appearance, DefaultStyle};
/// Creates an iced [`Daemon`] given its title, update, and view logic.
///
/// A [`Daemon`] will not open a window by default, but will run silently
@ -26,7 +25,7 @@ pub fn daemon<State, Message, Theme, Renderer>(
where
State: 'static,
Message: Send + std::fmt::Debug + 'static,
Theme: Default + DefaultStyle,
Theme: Default + theme::Base,
Renderer: program::Renderer,
{
use std::marker::PhantomData;
@ -44,7 +43,7 @@ where
for Instance<State, Message, Theme, Renderer, Update, View>
where
Message: Send + std::fmt::Debug + 'static,
Theme: Default + DefaultStyle,
Theme: Default + theme::Base,
Renderer: program::Renderer,
Update: application::Update<State, Message>,
View: for<'a> self::View<'a, State, Message, Theme, Renderer>,
@ -201,7 +200,7 @@ impl<P: Program> Daemon<P> {
/// Sets the style logic of the [`Daemon`].
pub fn style(
self,
f: impl Fn(&P::State, &P::Theme) -> Appearance,
f: impl Fn(&P::State, &P::Theme) -> theme::Style,
) -> Daemon<
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {

View file

@ -491,7 +491,6 @@ mod program;
pub mod application;
pub mod daemon;
pub mod settings;
pub mod time;
pub mod window;
@ -506,8 +505,8 @@ pub use crate::core::padding;
pub use crate::core::theme;
pub use crate::core::{
Alignment, Background, Border, Color, ContentFit, Degrees, Gradient,
Length, Padding, Pixels, Point, Radians, Rectangle, Rotation, Shadow, Size,
Theme, Transformation, Vector,
Length, Padding, Pixels, Point, Radians, Rectangle, Rotation, Settings,
Shadow, Size, Theme, Transformation, Vector,
};
pub use crate::runtime::exit;
pub use iced_futures::Subscription;
@ -624,8 +623,8 @@ pub use error::Error;
pub use event::Event;
pub use executor::Executor;
pub use font::Font;
pub use program::Program;
pub use renderer::Renderer;
pub use settings::Settings;
pub use task::Task;
#[doc(inline)]
@ -686,7 +685,7 @@ pub fn run<State, Message, Theme, Renderer>(
where
State: Default + 'static,
Message: std::fmt::Debug + Send + 'static,
Theme: Default + program::DefaultStyle + 'static,
Theme: Default + theme::Base + 'static,
Renderer: program::Renderer + 'static,
{
application(title, update, view).run()

View file

@ -1,11 +1,10 @@
use crate::core::text;
use crate::graphics::compositor;
use crate::shell;
use crate::theme;
use crate::window;
use crate::{Element, Executor, Result, Settings, Subscription, Task};
pub use crate::shell::program::{Appearance, DefaultStyle};
/// The internal definition of a [`Program`].
///
/// You should not need to implement this trait directly. Instead, use the
@ -19,7 +18,7 @@ pub trait Program: Sized {
type Message: Send + std::fmt::Debug + 'static;
/// The theme of the program.
type Theme: Default + DefaultStyle;
type Theme: Default + theme::Base;
/// The renderer of the program.
type Renderer: Renderer;
@ -51,11 +50,11 @@ pub trait Program: Sized {
}
fn theme(&self, _state: &Self::State, _window: window::Id) -> Self::Theme {
Self::Theme::default()
<Self::Theme as Default>::default()
}
fn style(&self, _state: &Self::State, theme: &Self::Theme) -> Appearance {
DefaultStyle::default_style(theme)
fn style(&self, _state: &Self::State, theme: &Self::Theme) -> theme::Style {
theme::Base::base(theme)
}
fn scale_factor(&self, _state: &Self::State, _window: window::Id) -> f64 {
@ -153,7 +152,7 @@ pub trait Program: Sized {
self.program.theme(&self.state, window)
}
fn style(&self, theme: &Self::Theme) -> Appearance {
fn style(&self, theme: &Self::Theme) -> theme::Style {
self.program.style(&self.state, theme)
}
@ -252,7 +251,7 @@ pub fn with_title<P: Program>(
&self,
state: &Self::State,
theme: &Self::Theme,
) -> Appearance {
) -> theme::Style {
self.program.style(state, theme)
}
@ -322,7 +321,7 @@ pub fn with_subscription<P: Program>(
&self,
state: &Self::State,
theme: &Self::Theme,
) -> Appearance {
) -> theme::Style {
self.program.style(state, theme)
}
@ -395,7 +394,7 @@ pub fn with_theme<P: Program>(
&self,
state: &Self::State,
theme: &Self::Theme,
) -> Appearance {
) -> theme::Style {
self.program.style(state, theme)
}
@ -409,7 +408,7 @@ pub fn with_theme<P: Program>(
pub fn with_style<P: Program>(
program: P,
f: impl Fn(&P::State, &P::Theme) -> Appearance,
f: impl Fn(&P::State, &P::Theme) -> theme::Style,
) -> impl Program<State = P::State, Message = P::Message, Theme = P::Theme> {
struct WithStyle<P, F> {
program: P,
@ -418,7 +417,7 @@ pub fn with_style<P: Program>(
impl<P: Program, F> Program for WithStyle<P, F>
where
F: Fn(&P::State, &P::Theme) -> Appearance,
F: Fn(&P::State, &P::Theme) -> theme::Style,
{
type State = P::State;
type Message = P::Message;
@ -430,7 +429,7 @@ pub fn with_style<P: Program>(
&self,
state: &Self::State,
theme: &Self::Theme,
) -> Appearance {
) -> theme::Style {
(self.style)(state, theme)
}
@ -535,7 +534,7 @@ pub fn with_scale_factor<P: Program>(
&self,
state: &Self::State,
theme: &Self::Theme,
) -> Appearance {
) -> theme::Style {
self.program.style(state, theme)
}
@ -609,7 +608,7 @@ pub fn with_executor<P: Program, E: Executor>(
&self,
state: &Self::State,
theme: &Self::Theme,
) -> Appearance {
) -> theme::Style {
self.program.style(state, theme)
}

24
test/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "iced_test"
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
[lints]
workspace = true
[dependencies]
iced_runtime.workspace = true
iced_renderer.workspace = true
iced_renderer.features = ["fira-sans"]
png.workspace = true
sha2.workspace = true
thiserror.workspace = true

637
test/src/lib.rs Normal file
View file

@ -0,0 +1,637 @@
//! Test your `iced` applications in headless mode.
//!
//! # Basic Usage
//! Let's assume we want to test [the classical counter interface].
//!
//! First, we will want to create a [`Simulator`] of our interface:
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # impl Counter {
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! use iced_test::simulator;
//!
//! let mut counter = Counter { value: 0 };
//! let mut ui = simulator(counter.view());
//! ```
//!
//! Now we can simulate a user interacting with our interface. Let's use [`Simulator::click`] to click
//! the counter buttons:
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # 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("-"));
//! ```
//!
//! [`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.
//!
//! We can now process any messages produced by these interactions and then assert that the final value of our counter is
//! indeed `1`!
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # impl Counter {
//! # 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("-"));
//! #
//! for message in ui.into_messages() {
//! counter.update(message);
//! }
//!
//! assert_eq!(counter.value, 1);
//! ```
//!
//! We can even rebuild the interface to make sure the counter _displays_ the proper value with [`Simulator::find`]:
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # 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!");
//! ```
//!
//! And that's it! That's the gist of testing `iced` applications!
//!
//! [`Simulator`] contains additional operations you can use to simulate more interactions—like [`tap_key`](Simulator::tap_key) or
//! [`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 selector::Selector;
use iced_renderer as renderer;
use iced_runtime as runtime;
use iced_runtime::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, Rectangle, Settings, Size, SmolStr,
};
use crate::runtime::user_interface;
use crate::runtime::UserInterface;
use std::borrow::Cow;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// 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)
}
/// 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>,
}
/// 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,
}
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 =
Renderer::new(default_font, settings.default_text_size);
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(
&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))
}
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))
}
}
}
/// 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 [`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,
);
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,
);
let _ = 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,
f64::from(scale_factor),
),
})
}
/// Turns the [`Simulator`] into the sequence of messages produced by any interactions.
pub fn into_messages(self) -> impl Iterator<Item = Message> {
self.messages.into_iter()
}
}
/// A frame of a user interface rendered by a [`Simulator`].
#[derive(Debug, Clone)]
pub struct Snapshot {
screenshot: window::Screenshot,
}
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 = snapshot_path(path, "png");
if path.exists() {
let file = fs::File::open(&path)?;
let decoder = png::Decoder::new(file);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size()];
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 = snapshot_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)
}
}
}
/// 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(())
}
fn snapshot_path(path: impl AsRef<Path>, extension: &str) -> PathBuf {
path.as_ref().with_extension(extension)
}

29
test/src/selector.rs Normal file
View file

@ -0,0 +1,29 @@
//! 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(id: &'static str) -> Self {
Self::Id(widget::Id::new(id))
}
}
/// Creates [`Selector`] that finds the widget containing the given text fragment.
pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector {
Selector::Text(fragment.into_fragment())
}

View file

@ -29,7 +29,7 @@ pub use geometry::Geometry;
use crate::core::renderer;
use crate::core::{
Background, Color, Font, Pixels, Point, Rectangle, Transformation,
Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation,
};
use crate::engine::Engine;
use crate::graphics::compositor;
@ -405,3 +405,26 @@ impl core::svg::Renderer for Renderer {
impl compositor::Default for Renderer {
type Compositor = window::Compositor;
}
impl renderer::Headless for Renderer {
fn new(default_font: Font, default_text_size: Pixels) -> Self {
Self::new(default_font, default_text_size)
}
fn screenshot(
&mut self,
size: Size<u32>,
scale_factor: f32,
background_color: Color,
) -> Vec<u8> {
let viewport =
Viewport::with_physical_size(size, f64::from(scale_factor));
window::compositor::screenshot::<&str>(
self,
&viewport,
background_color,
&[],
)
}
}

View file

@ -445,6 +445,16 @@ where
);
}
}
fn operate(
&self,
_state: &mut Tree,
layout: Layout<'_>,
_renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
operation.text(None, layout.bounds(), &self.label);
}
}
impl<'a, Message, Theme, Renderer> From<Checkbox<'a, Message, Theme, Renderer>>

View file

@ -493,11 +493,11 @@ pub fn visible_bounds(id: Id) -> Task<Option<Rectangle>> {
impl Operation<Option<Rectangle>> for VisibleBounds {
fn scrollable(
&mut self,
_state: &mut dyn widget::operation::Scrollable,
_id: Option<&widget::Id>,
bounds: Rectangle,
_content_bounds: Rectangle,
translation: Vector,
_state: &mut dyn widget::operation::Scrollable,
) {
match self.scrollables.last() {
Some((last_translation, last_viewport, _depth)) => {

View file

@ -487,11 +487,11 @@ where
state.translation(self.direction, bounds, content_bounds);
operation.scrollable(
state,
self.id.as_ref().map(|id| &id.0),
bounds,
content_bounds,
translation,
state,
);
operation.container(

View file

@ -971,13 +971,13 @@ where
fn operate(
&self,
tree: &mut widget::Tree,
_layout: Layout<'_>,
layout: Layout<'_>,
_renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
let state = tree.state.downcast_mut::<State<Highlighter>>();
operation.focusable(state, None);
operation.focusable(None, layout.bounds(), state);
}
}

View file

@ -617,14 +617,23 @@ where
fn operate(
&self,
tree: &mut Tree,
_layout: Layout<'_>,
layout: Layout<'_>,
_renderer: &Renderer,
operation: &mut dyn Operation,
) {
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
operation.focusable(state, self.id.as_ref().map(|id| &id.0));
operation.text_input(state, self.id.as_ref().map(|id| &id.0));
operation.focusable(
self.id.as_ref().map(|id| &id.0),
layout.bounds(),
state,
);
operation.text_input(
self.id.as_ref().map(|id| &id.0),
layout.bounds(),
state,
);
}
fn update(

View file

@ -8,10 +8,11 @@ use crate::conversion;
use crate::core;
use crate::core::mouse;
use crate::core::renderer;
use crate::core::theme;
use crate::core::time::Instant;
use crate::core::widget::operation;
use crate::core::window;
use crate::core::{Color, Element, Point, Size, Theme};
use crate::core::{Element, Point, Size};
use crate::futures::futures::channel::mpsc;
use crate::futures::futures::channel::oneshot;
use crate::futures::futures::task;
@ -46,7 +47,7 @@ use std::sync::Arc;
pub trait Program
where
Self: Sized,
Self::Theme: DefaultStyle,
Self::Theme: theme::Base,
{
/// The type of __messages__ your [`Program`] will produce.
type Message: std::fmt::Debug + Send;
@ -106,8 +107,8 @@ where
fn theme(&self, window: window::Id) -> Self::Theme;
/// Returns the `Style` variation of the `Theme`.
fn style(&self, theme: &Self::Theme) -> Appearance {
theme.default_style()
fn style(&self, theme: &Self::Theme) -> theme::Style {
theme::Base::base(theme)
}
/// Returns the event `Subscription` for the current state of the
@ -138,37 +139,6 @@ where
}
}
/// The appearance of a program.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Appearance {
/// The background [`Color`] of the application.
pub background_color: Color,
/// The default text [`Color`] of the application.
pub text_color: Color,
}
/// The default style of a [`Program`].
pub trait DefaultStyle {
/// Returns the default style of a [`Program`].
fn default_style(&self) -> Appearance;
}
impl DefaultStyle for Theme {
fn default_style(&self) -> Appearance {
default(self)
}
}
/// The default [`Appearance`] of a [`Program`] with the built-in [`Theme`].
pub fn default(theme: &Theme) -> Appearance {
let palette = theme.extended_palette();
Appearance {
background_color: palette.background.base.color,
text_color: palette.background.base.text,
}
}
/// Runs a [`Program`] with an executor, compositor, and the provided
/// settings.
pub fn run<P, C>(
@ -180,7 +150,7 @@ pub fn run<P, C>(
where
P: Program + 'static,
C: Compositor<Renderer = P::Renderer> + 'static,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
use winit::event_loop::EventLoop;
@ -674,7 +644,7 @@ async fn run_instance<P, C>(
) where
P: Program + 'static,
C: Compositor<Renderer = P::Renderer> + 'static,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
use winit::event;
use winit::event_loop::ControlFlow;
@ -1170,7 +1140,7 @@ fn build_user_interface<'a, P: Program>(
id: window::Id,
) -> UserInterface<'a, P::Message, P::Theme, P::Renderer>
where
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
debug.view_started();
let view = program.view(id);
@ -1189,7 +1159,7 @@ fn update<P: Program, E: Executor>(
debug: &mut Debug,
messages: &mut Vec<P::Message>,
) where
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
for message in messages.drain(..) {
debug.log_message(&message);
@ -1226,7 +1196,7 @@ fn run_action<P, C>(
) where
P: Program,
C: Compositor<Renderer = P::Renderer> + 'static,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
use crate::runtime::clipboard;
use crate::runtime::system;
@ -1461,7 +1431,7 @@ fn run_action<P, C>(
&debug.overlay(),
);
let _ = channel.send(window::Screenshot::new(
let _ = channel.send(core::window::Screenshot::new(
bytes,
window.state.physical_size(),
window.state.viewport().scale_factor(),
@ -1536,7 +1506,7 @@ pub fn build_user_interfaces<'a, P: Program, C>(
) -> FxHashMap<window::Id, UserInterface<'a, P::Message, P::Theme, P::Renderer>>
where
C: Compositor<Renderer = P::Renderer>,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
cached_user_interfaces
.drain()

View file

@ -1,17 +1,18 @@
use crate::conversion;
use crate::core::{mouse, window};
use crate::core::{mouse, theme, window};
use crate::core::{Color, Size};
use crate::graphics::Viewport;
use crate::program::{self, Program};
use std::fmt::{Debug, Formatter};
use crate::program::Program;
use winit::event::{Touch, WindowEvent};
use winit::window::Window;
use std::fmt::{Debug, Formatter};
/// The state of a multi-windowed [`Program`].
pub struct State<P: Program>
where
P::Theme: program::DefaultStyle,
P::Theme: theme::Base,
{
title: String,
scale_factor: f64,
@ -20,12 +21,12 @@ where
cursor_position: Option<winit::dpi::PhysicalPosition<f64>>,
modifiers: winit::keyboard::ModifiersState,
theme: P::Theme,
appearance: program::Appearance,
style: theme::Style,
}
impl<P: Program> Debug for State<P>
where
P::Theme: program::DefaultStyle,
P::Theme: theme::Base,
{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("multi_window::State")
@ -34,14 +35,14 @@ where
.field("viewport", &self.viewport)
.field("viewport_version", &self.viewport_version)
.field("cursor_position", &self.cursor_position)
.field("appearance", &self.appearance)
.field("style", &self.style)
.finish()
}
}
impl<P: Program> State<P>
where
P::Theme: program::DefaultStyle,
P::Theme: theme::Base,
{
/// Creates a new [`State`] for the provided [`Program`]'s `window`.
pub fn new(
@ -52,7 +53,7 @@ where
let title = application.title(window_id);
let scale_factor = application.scale_factor(window_id);
let theme = application.theme(window_id);
let appearance = application.style(&theme);
let style = application.style(&theme);
let viewport = {
let physical_size = window.inner_size();
@ -71,7 +72,7 @@ where
cursor_position: None,
modifiers: winit::keyboard::ModifiersState::default(),
theme,
appearance,
style,
}
}
@ -127,12 +128,12 @@ where
/// Returns the current background [`Color`] of the [`State`].
pub fn background_color(&self) -> Color {
self.appearance.background_color
self.style.background_color
}
/// Returns the current text [`Color`] of the [`State`].
pub fn text_color(&self) -> Color {
self.appearance.text_color
self.style.text_color
}
/// Processes the provided window event and updates the [`State`] accordingly.
@ -237,6 +238,6 @@ where
// Update theme and appearance
self.theme = application.theme(window_id);
self.appearance = application.style(&self.theme);
self.style = application.style(&self.theme);
}
}

View file

@ -1,9 +1,10 @@
use crate::core::mouse;
use crate::core::theme;
use crate::core::time::Instant;
use crate::core::window::Id;
use crate::core::{Point, Size};
use crate::graphics::Compositor;
use crate::program::{DefaultStyle, Program, State};
use crate::program::{Program, State};
use std::collections::BTreeMap;
use std::sync::Arc;
@ -14,7 +15,7 @@ pub struct WindowManager<P, C>
where
P: Program,
C: Compositor<Renderer = P::Renderer>,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
aliases: BTreeMap<winit::window::WindowId, Id>,
entries: BTreeMap<Id, Window<P, C>>,
@ -24,7 +25,7 @@ impl<P, C> WindowManager<P, C>
where
P: Program,
C: Compositor<Renderer = P::Renderer>,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
pub fn new() -> Self {
Self {
@ -132,7 +133,7 @@ impl<P, C> Default for WindowManager<P, C>
where
P: Program,
C: Compositor<Renderer = P::Renderer>,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
fn default() -> Self {
Self::new()
@ -144,7 +145,7 @@ pub struct Window<P, C>
where
P: Program,
C: Compositor<Renderer = P::Renderer>,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
pub raw: Arc<winit::window::Window>,
pub state: State<P>,
@ -160,7 +161,7 @@ impl<P, C> Window<P, C>
where
P: Program,
C: Compositor<Renderer = P::Renderer>,
P::Theme: DefaultStyle,
P::Theme: theme::Base,
{
pub fn position(&self) -> Option<Point> {
self.raw

View file

@ -1,4 +1,6 @@
//! Configure your application.
use crate::core;
use std::borrow::Cow;
/// The settings of an application.
@ -13,3 +15,12 @@ pub struct Settings {
/// The fonts to load on boot.
pub fonts: Vec<Cow<'static, [u8]>>,
}
impl From<core::Settings> for Settings {
fn from(settings: core::Settings) -> Self {
Self {
id: settings.id,
fonts: settings.fonts,
}
}
}